portail-associations/server/materialReturnWorkflow.ts

436 lines
15 KiB
TypeScript

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;
}
})()
),
};
}