import type { Express } from "express"; import * as db from "./db"; import { getSalleWorkflowData, updateSalleWorkflowQuoteStatus, } from "./salleReservationWorkflow"; /** * Register Express routes for email-based actions (validate/refuse requests) * These routes handle one-time token links sent in notification emails */ export function registerEmailActionRoutes(app: Express) { // Handle email action token - renders a confirmation page app.get("/api/email-action/:token", async (req, res) => { try { const { token } = req.params; const actionToken = await db.getEmailActionToken(token); if (!actionToken) { return res.status(404).send(renderPage("Lien invalide", "Ce lien d'action n'existe pas ou a été supprimé.", "error")); } if (actionToken.used) { return res.status(410).send(renderPage("Action déjà effectuée", "Ce lien a déjà été utilisé. La demande a été traitée.", "warning")); } if (new Date() > actionToken.expiresAt) { return res.status(410).send(renderPage("Lien expiré", "Ce lien d'action a expiré. Veuillez traiter la demande depuis l'interface d'administration.", "warning")); } // Get request details const request = await db.getRequestById(actionToken.requestId); if (!request) { return res.status(404).send(renderPage("Demande introuvable", "La demande associée à ce lien n'existe plus.", "error")); } const isQuoteAction = actionToken.action === "acceptation_devis_salle" || actionToken.action === "refus_devis_salle"; if (isQuoteAction) { const workflow = getSalleWorkflowData(request.formData); if (workflow.quoteStatus !== "en_attente_association") { return res.status(410).send( renderPage( "Réponse déjà enregistrée", "L'association a déjà répondu à ce devis. Retournez au portail pour consulter l'état actuel du dossier.", "warning" ) ); } } // Check if request is still in a processable state if (request.status === 'validee' || request.status === 'refusee') { return res.status(410).send(renderPage("Demande déjà traitée", `Cette demande a déjà été ${request.status === 'validee' ? 'validée' : 'refusée'}.`, "warning")); } const actionLabel = actionToken.action === 'validee' ? 'Valider' : actionToken.action === 'acceptation_devis_salle' ? "Accepter le devis" : 'Refuser'; const actionColor = actionToken.action === 'validee' || actionToken.action === "acceptation_devis_salle" ? '#18753C' : '#CE0500'; const actionEmoji = actionToken.action === 'validee' || actionToken.action === "acceptation_devis_salle" ? '✅' : '❌'; // Render confirmation page return res.send(renderConfirmationPage(request, actionToken, actionLabel, actionColor, actionEmoji, isQuoteAction)); } catch (error) { console.error("[EmailAction] Error:", error); return res.status(500).send(renderPage("Erreur serveur", "Une erreur inattendue s'est produite. Veuillez réessayer.", "error")); } }); // Handle confirmation POST app.post("/api/email-action/:token/confirm", async (req, res) => { try { const { token } = req.params; const actionToken = await db.getEmailActionToken(token); if (!actionToken) { return res.status(404).send(renderPage("Lien invalide", "Ce lien d'action n'existe pas.", "error")); } if (actionToken.used) { return res.status(410).send(renderPage("Action déjà effectuée", "Ce lien a déjà été utilisé.", "warning")); } if (new Date() > actionToken.expiresAt) { return res.status(410).send(renderPage("Lien expiré", "Ce lien a expiré.", "warning")); } const request = await db.getRequestById(actionToken.requestId); if (!request) { return res.status(404).send(renderPage("Demande introuvable", "La demande n'existe plus.", "error")); } if (actionToken.action === "acceptation_devis_salle" || actionToken.action === "refus_devis_salle") { const workflow = getSalleWorkflowData(request.formData); if (workflow.quoteStatus !== "en_attente_association") { return res.status(410).send( renderPage( "Réponse déjà enregistrée", "L'association a déjà répondu à ce devis. Aucun nouvel enregistrement n'est nécessaire.", "warning" ) ); } } if (request.status === 'validee' || request.status === 'refusee') { return res.status(410).send(renderPage("Demande déjà traitée", `Cette demande a déjà été ${request.status === 'validee' ? 'validée' : 'refusée'}.`, "warning")); } let successTitle = ""; let successMessage = ""; if (actionToken.action === "acceptation_devis_salle" || actionToken.action === "refus_devis_salle") { const nextWorkflowFormData = updateSalleWorkflowQuoteStatus( request.formData, actionToken.action === "acceptation_devis_salle" ? "accepte" : "refuse" ); const workflow = getSalleWorkflowData(nextWorkflowFormData); const isAccepted = actionToken.action === "acceptation_devis_salle"; const nextStatus = isAccepted ? request.status : "refusee"; await db.updateRequest(actionToken.requestId, { formData: nextWorkflowFormData, status: nextStatus, dateTraitement: isAccepted ? request.dateTraitement : new Date(), commentaireAdmin: isAccepted ? request.commentaireAdmin : ((request.commentaireAdmin ? `${request.commentaireAdmin}\n\n` : "") + "Devis refusé par l'association via email"), }); await db.createRequestHistory({ requestId: actionToken.requestId, action: isAccepted ? "modification" : "refus", ancienStatut: request.status, nouveauStatut: nextStatus, userId: 0, commentaire: isAccepted ? "Devis accepté par l'association via email" : "Devis refusé par l'association via email", }); await db.createAdminNotification({ type: "systeme", titre: isAccepted ? `Devis accepté : ${request.titre}` : `Devis refusé : ${request.titre}`, message: isAccepted ? `L'association a accepté le devis pour la demande "${request.titre}".` : `L'association a refusé le devis pour la demande "${request.titre}".`, lien: `/dashboard/requests/${request.id}`, }); successTitle = isAccepted ? "✅ Devis accepté" : "❌ Devis refusé"; successMessage = isAccepted ? `Votre accord a bien été enregistré pour "${request.titre}". Le dossier repart vers l'hôtesse pour transmission à la Directrice.` : `Votre refus a bien été enregistré pour "${request.titre}".`; } else { // Process the admin validation/refusal action const newStatus = actionToken.action; const commentaire = req.body?.commentaire || (newStatus === 'validee' ? 'Demande validée via email' : 'Demande refusée via email'); await db.updateRequest(actionToken.requestId, { status: newStatus, commentaireAdmin: commentaire, dateTraitement: new Date(), }); await db.createRequestHistory({ requestId: actionToken.requestId, action: newStatus === 'validee' ? 'validation' : 'refus', ancienStatut: request.status, nouveauStatut: newStatus, userId: actionToken.usedBy || 0, commentaire: commentaire, }); successTitle = newStatus === 'validee' ? '✅ Demande validée' : '❌ Demande refusée'; successMessage = newStatus === 'validee' ? `La demande "${request.titre}" a été validée avec succès.` : `La demande "${request.titre}" a été refusée.`; } // Mark token as used await db.markTokenAsUsed(token, 0); return res.send(renderPage(successTitle, successMessage, "success")); } catch (error) { console.error("[EmailAction] Confirm error:", error); return res.status(500).send(renderPage("Erreur serveur", "Une erreur s'est produite lors du traitement.", "error")); } }); } function renderConfirmationPage( request: any, actionToken: any, actionLabel: string, actionColor: string, actionEmoji: string, isQuoteAction: boolean ): string { const statusLabels: Record = { brouillon: 'Brouillon', soumise: 'Soumise', en_cours_traitement: 'En cours', information_complementaire: 'Info. complémentaire', validee: 'Validée', refusee: 'Refusée', }; const typeLabels: Record = { subvention_fonctionnement: 'Subvention de fonctionnement', subvention_projet: 'Subvention de projet', agrement_jeunesse_education: 'Agrément Jeunesse et Éducation', agrement_sport: 'Agrément Sport', autorisation_occupation: 'Autorisation d\'occupation', demande_salle: 'Demande de salle', demande_materiel_evenementiel: 'Demande du matériel événementiel', autre: 'Autre', }; const montant = request.montantDemande ? `${(request.montantDemande / 100).toLocaleString('fr-FR')} €` : 'Non spécifié'; return ` ${actionEmoji} ${actionLabel} la demande - Portail Associations

Portail Associations

Traitement de demande par email

${actionEmoji} ${actionLabel}
Demande ${escapeHtml(request.titre)}
Type ${typeLabels[request.type] || request.type}
Statut actuel ${statusLabels[request.status] || request.status}
Montant demandé ${montant}
${request.description ? `
Description ${escapeHtml(request.description).substring(0, 200)}${request.description.length > 200 ? '...' : ''}
` : ''}

Attention : Vous êtes sur le point de ${actionLabel.toLowerCase()} ${isQuoteAction ? 'ce devis / cette mise à disposition' : 'cette demande'}. Cette action est irréversible depuis cet email.

Annuler
`; } function renderPage(title: string, message: string, type: "success" | "error" | "warning"): string { const colors = { success: { bg: '#B8FEC9', border: '#18753C', icon: '✅' }, error: { bg: '#FFE9E6', border: '#CE0500', icon: '❌' }, warning: { bg: '#FEF7DA', border: '#D64D00', icon: '⚠️' }, }; const c = colors[type]; return ` ${title} - Portail Associations

Portail Associations

${c.icon}

${escapeHtml(title)}

${escapeHtml(message)}

Retour au portail
`; } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }