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>; 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) { 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) { 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>>; request: NonNullable; association: Awaited>; 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; 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>) { 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; } })() ), }; }