Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
436
server/materialReturnWorkflow.ts
Normal file
436
server/materialReturnWorkflow.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
import * as db from "./db";
|
||||
import { sendOperationalEmail } from "./mailer";
|
||||
import { generateMaterialReturnReminderEmail } from "./materialReturnEmail";
|
||||
import { generateMaterialReturnStatementPdf } from "./materialReturnPdf";
|
||||
import { getLogisticsSettings } from "./logisticsSettings";
|
||||
import { sanitizeMaterialEventQuantityMap } from "@shared/materialEvent";
|
||||
|
||||
type RequestLike = Awaited<ReturnType<typeof db.getRequestById>>;
|
||||
|
||||
export const materialReturnStatusLabels = {
|
||||
planifie: "Planifié",
|
||||
en_attente: "En attente",
|
||||
en_cours: "En cours",
|
||||
cloture: "Clôturé / Récupéré",
|
||||
} as const;
|
||||
|
||||
export const materialReturnStatusColors = {
|
||||
planifie: "bg-slate-100 text-slate-700",
|
||||
en_attente: "bg-red-100 text-red-700",
|
||||
en_cours: "bg-amber-100 text-amber-700",
|
||||
cloture: "bg-green-100 text-green-700",
|
||||
} as const;
|
||||
|
||||
export const materialReturnBoardStateLabels = {
|
||||
a_attribuer: "À attribuer",
|
||||
planifie: "Planifié",
|
||||
terrain: "Sur le terrain",
|
||||
retard: "Alerte / retard",
|
||||
conforme: "Retour conforme",
|
||||
litige: "Litige / dégradation",
|
||||
} as const;
|
||||
|
||||
export const materialReturnBoardStateColors = {
|
||||
a_attribuer: "bg-slate-100 text-slate-700 border-slate-200",
|
||||
planifie: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
terrain: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
retard: "bg-red-100 text-red-700 border-red-200",
|
||||
conforme: "bg-green-100 text-green-700 border-green-200",
|
||||
litige: "bg-orange-100 text-orange-800 border-orange-200",
|
||||
} as const;
|
||||
|
||||
export const materialReturnDiscrepancyOptions = [
|
||||
{ value: "materiel_manquant", label: "Matériel manquant" },
|
||||
{ value: "accessoires_manquants", label: "Visserie / accessoires manquants" },
|
||||
{ value: "structure_deformee", label: "Structure déformée / tordue" },
|
||||
{ value: "toile_dechiree", label: "Toile / bâche déchirée" },
|
||||
{ value: "choc_important", label: "Choc important" },
|
||||
{ value: "materiel_sale", label: "Matériel restitué sale" },
|
||||
{ value: "materiel_humide", label: "Matériel humide (risque moisissure)" },
|
||||
] as const;
|
||||
|
||||
export const agentRoleOptions = [
|
||||
"Agent technique",
|
||||
"Responsable logistique",
|
||||
"Agent MJS",
|
||||
"Agent DSU",
|
||||
] as const;
|
||||
|
||||
export const borrowerRoleOptions = [
|
||||
"Président d'association",
|
||||
"Trésorier",
|
||||
"Régisseur commune",
|
||||
"Bénévole mandaté",
|
||||
] as const;
|
||||
|
||||
export function parseRecipientEmails(value: string | null | undefined) {
|
||||
if (!value) return [] as string[];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map((entry) => String(entry || "").trim()).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMaterialReturnDate(date: Date | string | null | undefined) {
|
||||
if (!date) return "";
|
||||
return new Date(date).toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
export function buildMaterialReturnUploadLink(baseUrl: string, token: string) {
|
||||
return `${baseUrl.replace(/\/$/, "")}/materiel/restitution/${token}`;
|
||||
}
|
||||
|
||||
export function getMaterialReturnUploadLinkState(input: {
|
||||
status?: string | null;
|
||||
uploadTokenExpiresAt?: Date | string | null;
|
||||
now?: Date;
|
||||
}) {
|
||||
if (input.status === "cloture") {
|
||||
return "closed" as const;
|
||||
}
|
||||
if (!input.uploadTokenExpiresAt) {
|
||||
return "expired" as const;
|
||||
}
|
||||
const now = input.now || new Date();
|
||||
return now <= new Date(input.uploadTokenExpiresAt) ? "active" as const : "expired" as const;
|
||||
}
|
||||
|
||||
function getRestitutionDateFromRequest(request: NonNullable<RequestLike>) {
|
||||
try {
|
||||
const formData = request.formData ? JSON.parse(request.formData) : {};
|
||||
const rawDate = formData?.dateRestitution;
|
||||
if (!rawDate) return null;
|
||||
const date = new Date(rawDate);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getPlannedSendAt(restitutionDate: Date, leadDays: number) {
|
||||
const planned = new Date(restitutionDate);
|
||||
planned.setDate(planned.getDate() - leadDays);
|
||||
planned.setHours(8, 0, 0, 0);
|
||||
return planned;
|
||||
}
|
||||
|
||||
function getUploadTokenExpiry(restitutionDate: Date, graceDays: number) {
|
||||
const expiry = new Date(restitutionDate);
|
||||
expiry.setDate(expiry.getDate() + graceDays);
|
||||
expiry.setHours(23, 59, 59, 999);
|
||||
return expiry;
|
||||
}
|
||||
|
||||
function getReactivatedUploadTokenExpiry(now: Date, graceDays: number) {
|
||||
const expiry = new Date(now);
|
||||
expiry.setDate(expiry.getDate() + Math.max(0, graceDays));
|
||||
expiry.setHours(23, 59, 59, 999);
|
||||
return expiry;
|
||||
}
|
||||
|
||||
function getDelayAlertDate(restitutionDate: Date | string, graceDays: number) {
|
||||
const alertDate = new Date(restitutionDate);
|
||||
alertDate.setDate(alertDate.getDate() + graceDays);
|
||||
alertDate.setHours(0, 0, 0, 0);
|
||||
return alertDate;
|
||||
}
|
||||
|
||||
function hasOperationalAssignment(followup: {
|
||||
serviceLabel?: string | null;
|
||||
recipientEmails?: string | null;
|
||||
} | null | undefined) {
|
||||
if (!followup) return false;
|
||||
return Boolean(followup.serviceLabel?.trim()) || parseRecipientEmails(followup.recipientEmails).length > 0;
|
||||
}
|
||||
|
||||
function mergeRecipientLists(...recipientSets: Array<string[] | undefined>) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
recipientSets
|
||||
.flatMap((recipientSet) => recipientSet || [])
|
||||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getNotificationRecipients(followup: {
|
||||
recipientEmails?: string | null;
|
||||
supervisionRecipientEmails?: string | null;
|
||||
}, tone: "assignment" | "scheduled" | "overdue" | "reactivation") {
|
||||
const terrainRecipients = parseRecipientEmails(followup.recipientEmails);
|
||||
const supervisionRecipients = parseRecipientEmails(followup.supervisionRecipientEmails);
|
||||
if (tone === "overdue") {
|
||||
return mergeRecipientLists(supervisionRecipients, terrainRecipients);
|
||||
}
|
||||
return terrainRecipients;
|
||||
}
|
||||
|
||||
export function computeMaterialReturnBoardState(input: {
|
||||
requestStatus?: string | null;
|
||||
restitutionDate?: Date | string | null;
|
||||
followup?: {
|
||||
status?: string | null;
|
||||
issueFlag?: boolean | null;
|
||||
compliance?: string | null;
|
||||
litigationStatus?: string | null;
|
||||
serviceLabel?: string | null;
|
||||
recipientEmails?: string | null;
|
||||
} | null;
|
||||
now?: Date;
|
||||
graceDays?: number;
|
||||
}) {
|
||||
const now = input.now || new Date();
|
||||
const followup = input.followup;
|
||||
const graceDays = Number.isFinite(input.graceDays) ? Math.max(0, input.graceDays || 0) : 3;
|
||||
|
||||
if (followup?.litigationStatus === "pending") {
|
||||
return "litige" as const;
|
||||
}
|
||||
if (followup?.status === "cloture") {
|
||||
return "conforme" as const;
|
||||
}
|
||||
|
||||
const restitutionDate = input.restitutionDate ? new Date(input.restitutionDate) : null;
|
||||
const assigned = hasOperationalAssignment(followup);
|
||||
if (!assigned) {
|
||||
return "a_attribuer" as const;
|
||||
}
|
||||
|
||||
if (restitutionDate) {
|
||||
const delayAlertDate = getDelayAlertDate(restitutionDate, graceDays);
|
||||
if (now >= delayAlertDate) {
|
||||
return "retard" as const;
|
||||
}
|
||||
|
||||
const restitutionDay = new Date(restitutionDate);
|
||||
restitutionDay.setHours(0, 0, 0, 0);
|
||||
const currentDay = new Date(now);
|
||||
currentDay.setHours(0, 0, 0, 0);
|
||||
if (currentDay >= restitutionDay) {
|
||||
return "terrain" as const;
|
||||
}
|
||||
}
|
||||
|
||||
return "planifie" as const;
|
||||
}
|
||||
|
||||
export async function sendMaterialReturnFollowupEmail(input: {
|
||||
followup: NonNullable<Awaited<ReturnType<typeof db.getMaterialReturnFollowupByRequestId>>>;
|
||||
request: NonNullable<RequestLike>;
|
||||
association: Awaited<ReturnType<typeof db.getAssociationById>>;
|
||||
baseUrl: string;
|
||||
tone?: "assignment" | "scheduled" | "overdue" | "reactivation";
|
||||
}) {
|
||||
const tone = input.tone || "scheduled";
|
||||
const recipients = getNotificationRecipients(input.followup, tone);
|
||||
if (recipients.length === 0) {
|
||||
return { sent: false as const, reason: "no_recipients" as const };
|
||||
}
|
||||
|
||||
const uploadLink = buildMaterialReturnUploadLink(input.baseUrl, input.followup.uploadToken);
|
||||
const pdf = await generateMaterialReturnStatementPdf({
|
||||
request: input.request,
|
||||
association: input.association,
|
||||
});
|
||||
const email = generateMaterialReturnReminderEmail({
|
||||
associationName: input.association?.nomAssociation || "Association",
|
||||
serviceLabel: input.followup.serviceLabel,
|
||||
restitutionDate: input.followup.restitutionDate,
|
||||
uploadLink,
|
||||
requestTitle: input.request.titre,
|
||||
tone,
|
||||
});
|
||||
|
||||
return sendOperationalEmail({
|
||||
to: recipients,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
attachments: [
|
||||
{
|
||||
filename: pdf.fileName,
|
||||
content: pdf.buffer,
|
||||
contentType: "application/pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function scheduleMaterialReturnFollowup(input: {
|
||||
request: NonNullable<RequestLike>;
|
||||
serviceLabel?: string;
|
||||
recipientEmails: string[];
|
||||
supervisionServiceLabel?: string;
|
||||
supervisionRecipientEmails?: string[];
|
||||
}, options?: {
|
||||
forceNewToken?: boolean;
|
||||
forceImmediateSendWindow?: boolean;
|
||||
reactivatedByUserId?: number | null;
|
||||
reactivatedAt?: Date | null;
|
||||
reactivationReason?: string | null;
|
||||
}) {
|
||||
const logisticsSettings = await getLogisticsSettings();
|
||||
const restitutionDate = getRestitutionDateFromRequest(input.request);
|
||||
if (!restitutionDate) return null;
|
||||
|
||||
const existing = await db.getMaterialReturnFollowupByRequestId(input.request.id);
|
||||
const uploadToken = options?.forceNewToken || !existing?.uploadToken
|
||||
? randomBytes(32).toString("hex")
|
||||
: existing.uploadToken;
|
||||
const now = new Date();
|
||||
const plannedSendAt = options?.forceImmediateSendWindow ? now : getPlannedSendAt(restitutionDate, logisticsSettings.materialReturnLeadDays);
|
||||
const defaultUploadTokenExpiry = getUploadTokenExpiry(restitutionDate, logisticsSettings.materialReturnGraceDays);
|
||||
const shouldReactivateWindow = Boolean(options?.forceNewToken || options?.forceImmediateSendWindow);
|
||||
const uploadTokenExpiresAt = shouldReactivateWindow
|
||||
? getReactivatedUploadTokenExpiry(options?.reactivatedAt || now, logisticsSettings.materialReturnGraceDays)
|
||||
: existing
|
||||
&& existing.status !== "cloture"
|
||||
&& existing.uploadTokenExpiresAt
|
||||
&& new Date(existing.uploadTokenExpiresAt) < now
|
||||
? getReactivatedUploadTokenExpiry(now, logisticsSettings.materialReturnGraceDays)
|
||||
: defaultUploadTokenExpiry;
|
||||
|
||||
await db.upsertMaterialReturnFollowup(input.request.id, {
|
||||
associationId: input.request.associationId,
|
||||
serviceLabel: input.serviceLabel || null,
|
||||
recipientEmails: JSON.stringify(input.recipientEmails),
|
||||
supervisionServiceLabel: input.supervisionServiceLabel || null,
|
||||
supervisionRecipientEmails: JSON.stringify(input.supervisionRecipientEmails || []),
|
||||
restitutionDate,
|
||||
plannedSendAt,
|
||||
status: existing?.status === "cloture" ? "cloture" : "planifie",
|
||||
uploadToken,
|
||||
uploadTokenExpiresAt,
|
||||
signedFileKey: existing?.signedFileKey || null,
|
||||
signedFileUrl: existing?.signedFileUrl || null,
|
||||
signedFileName: existing?.signedFileName || null,
|
||||
signedMimeType: existing?.signedMimeType || null,
|
||||
uploadedByName: existing?.uploadedByName || null,
|
||||
uploadedByEmail: existing?.uploadedByEmail || null,
|
||||
uploadedAt: existing?.uploadedAt || null,
|
||||
closedAt: existing?.closedAt || null,
|
||||
lastReminderSentAt: existing?.lastReminderSentAt || null,
|
||||
reactivatedByUserId: options?.reactivatedByUserId ?? existing?.reactivatedByUserId ?? null,
|
||||
reactivatedAt: options?.reactivatedAt ?? existing?.reactivatedAt ?? null,
|
||||
reactivationReason: options?.reactivationReason ?? existing?.reactivationReason ?? null,
|
||||
sentAt: existing?.sentAt || null,
|
||||
});
|
||||
|
||||
return db.getMaterialReturnFollowupByRequestId(input.request.id);
|
||||
}
|
||||
|
||||
export async function runMaterialReturnScheduler(baseUrl: string) {
|
||||
const now = new Date();
|
||||
const logisticsSettings = await getLogisticsSettings();
|
||||
|
||||
const toEscalate = await db.listMaterialReturnFollowupsToEscalate();
|
||||
for (const followup of toEscalate) {
|
||||
const alertDate = getDelayAlertDate(followup.restitutionDate, logisticsSettings.materialReturnGraceDays);
|
||||
if (now >= alertDate) {
|
||||
const shouldSendOverdueAlert = !followup.lastReminderSentAt || new Date(followup.lastReminderSentAt) < alertDate;
|
||||
if (shouldSendOverdueAlert) {
|
||||
const request = await db.getRequestById(followup.requestId);
|
||||
if (request?.status === "validee" && request.type === "demande_materiel_evenementiel") {
|
||||
const association = await db.getAssociationById(request.associationId);
|
||||
try {
|
||||
const result = await sendMaterialReturnFollowupEmail({
|
||||
followup,
|
||||
request,
|
||||
association,
|
||||
baseUrl,
|
||||
tone: "overdue",
|
||||
});
|
||||
if (result.sent) {
|
||||
await db.updateMaterialReturnFollowup(followup.id, {
|
||||
lastReminderSentAt: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[MaterialReturnScheduler] Overdue reminder failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.updateMaterialReturnFollowup(followup.id, {
|
||||
status: "en_cours",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dueFollowups = await db.listPendingMaterialReturnFollowups(now);
|
||||
for (const followup of dueFollowups) {
|
||||
const request = await db.getRequestById(followup.requestId);
|
||||
if (!request || request.status !== "validee" || request.type !== "demande_materiel_evenementiel") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const association = await db.getAssociationById(request.associationId);
|
||||
|
||||
try {
|
||||
const result = await sendMaterialReturnFollowupEmail({
|
||||
followup,
|
||||
request,
|
||||
association,
|
||||
baseUrl,
|
||||
tone: "scheduled",
|
||||
});
|
||||
|
||||
if (result.sent) {
|
||||
await db.updateMaterialReturnFollowup(followup.id, {
|
||||
status: "en_attente",
|
||||
sentAt: new Date(),
|
||||
lastReminderSentAt: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[MaterialReturnScheduler] Email send failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeMaterialReturnFollowup(followup: Awaited<ReturnType<typeof db.getMaterialReturnFollowupByRequestId>>) {
|
||||
if (!followup) return null;
|
||||
let discrepancyCategories: string[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(followup.discrepancyCategories || "[]");
|
||||
if (Array.isArray(parsed)) {
|
||||
discrepancyCategories = parsed.map((entry) => String(entry || "")).filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
discrepancyCategories = [];
|
||||
}
|
||||
|
||||
return {
|
||||
...followup,
|
||||
recipientEmails: parseRecipientEmails(followup.recipientEmails),
|
||||
supervisionRecipientEmails: parseRecipientEmails(followup.supervisionRecipientEmails),
|
||||
serviceLabel: typeof followup.serviceLabel === "string" ? followup.serviceLabel : "",
|
||||
supervisionServiceLabel: typeof followup.supervisionServiceLabel === "string" ? followup.supervisionServiceLabel : "",
|
||||
status: typeof followup.status === "string" ? followup.status : "planifie",
|
||||
uploadLinkState: getMaterialReturnUploadLinkState({
|
||||
status: followup.status,
|
||||
uploadTokenExpiresAt: followup.uploadTokenExpiresAt,
|
||||
}),
|
||||
compliance: followup.compliance === "conforme" || followup.compliance === "non_conforme"
|
||||
? followup.compliance
|
||||
: null,
|
||||
issueFlag: Boolean(followup.issueFlag),
|
||||
litigationStatus: followup.litigationStatus === "pending" ? "pending" : "none",
|
||||
geoFailureReason: typeof followup.geoFailureReason === "string" ? followup.geoFailureReason : null,
|
||||
discrepancyCategories,
|
||||
blockedItems: sanitizeMaterialEventQuantityMap(
|
||||
(() => {
|
||||
try {
|
||||
return followup.blockedItems ? JSON.parse(followup.blockedItems) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue