portail-associations/server/emailActions.ts

369 lines
17 KiB
TypeScript

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<string, string> = {
brouillon: 'Brouillon',
soumise: 'Soumise',
en_cours_traitement: 'En cours',
information_complementaire: 'Info. complémentaire',
validee: 'Validée',
refusee: 'Refusée',
};
const typeLabels: Record<string, string> = {
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 `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${actionEmoji} ${actionLabel} la demande - Portail Associations</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Marianne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f6f6f6; color: #161616; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
.container { max-width: 600px; width: 100%; background: white; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden; }
.header { background: #000091; color: white; padding: 24px 32px; }
.header h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.header p { font-size: 14px; opacity: 0.8; }
.content { padding: 32px; }
.badge { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-bottom: 16px; }
.badge-action { background: ${actionColor}15; color: ${actionColor}; border: 1px solid ${actionColor}30; }
.info-grid { display: grid; gap: 12px; margin-bottom: 24px; }
.info-row { display: flex; border-bottom: 1px solid #eee; padding-bottom: 8px; }
.info-label { font-weight: 600; color: #666; min-width: 140px; font-size: 14px; }
.info-value { font-size: 14px; color: #161616; }
.warning-box { background: #FEF7DA; border-left: 4px solid #D64D00; padding: 16px; border-radius: 0 4px 4px 0; margin-bottom: 24px; }
.warning-box p { font-size: 14px; color: #3A3A3A; }
.comment-box { margin-bottom: 24px; }
.comment-box label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 8px; }
.comment-box textarea { width: 100%; min-height: 100px; padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-family: inherit; font-size: 14px; resize: vertical; }
.comment-box textarea:focus { outline: none; border-color: #000091; box-shadow: 0 0 0 2px #000091 20; }
.actions { display: flex; gap: 12px; justify-content: flex-end; }
.btn { padding: 12px 24px; border-radius: 4px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; transition: opacity 0.2s; }
.btn:hover { opacity: 0.85; }
.btn-primary { background: ${actionColor}; color: white; }
.btn-secondary { background: white; color: #161616; border: 1px solid #ddd; }
.footer { padding: 16px 32px; background: #f6f6f6; border-top: 1px solid #eee; text-align: center; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Portail Associations</h1>
<p>Traitement de demande par email</p>
</div>
<div class="content">
<span class="badge badge-action">${actionEmoji} ${actionLabel}</span>
<div class="info-grid">
<div class="info-row">
<span class="info-label">Demande</span>
<span class="info-value">${escapeHtml(request.titre)}</span>
</div>
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">${typeLabels[request.type] || request.type}</span>
</div>
<div class="info-row">
<span class="info-label">Statut actuel</span>
<span class="info-value">${statusLabels[request.status] || request.status}</span>
</div>
<div class="info-row">
<span class="info-label">Montant demandé</span>
<span class="info-value">${montant}</span>
</div>
${request.description ? `<div class="info-row">
<span class="info-label">Description</span>
<span class="info-value">${escapeHtml(request.description).substring(0, 200)}${request.description.length > 200 ? '...' : ''}</span>
</div>` : ''}
</div>
<div class="warning-box">
<p><strong>Attention :</strong> Vous êtes sur le point de <strong>${actionLabel.toLowerCase()}</strong> ${isQuoteAction ? 'ce devis / cette mise à disposition' : 'cette demande'}. Cette action est irréversible depuis cet email.</p>
</div>
<form method="POST" action="/api/email-action/${actionToken.token}/confirm">
<div class="comment-box">
<label for="commentaire">Commentaire (optionnel) :</label>
<textarea id="commentaire" name="commentaire" placeholder="${isQuoteAction ? "Ajoutez un commentaire à destination de l'administration..." : "Ajoutez un commentaire pour l'association..."}"></textarea>
</div>
<div class="actions">
<a href="javascript:window.close()" class="btn btn-secondary">Annuler</a>
<button type="submit" class="btn btn-primary">${actionEmoji} Confirmer : ${actionLabel}</button>
</div>
</form>
</div>
<div class="footer">
<p>Ce lien expire le ${actionToken.expiresAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })} — Portail Associations</p>
</div>
</div>
</body>
</html>`;
}
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 `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} - Portail Associations</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Marianne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f6f6f6; color: #161616; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
.container { max-width: 500px; width: 100%; background: white; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden; }
.header { background: #000091; color: white; padding: 24px 32px; }
.header h1 { font-size: 20px; font-weight: 700; }
.content { padding: 32px; text-align: center; }
.icon { font-size: 48px; margin-bottom: 16px; }
.alert { background: ${c.bg}; border-left: 4px solid ${c.border}; padding: 16px; border-radius: 0 4px 4px 0; margin-bottom: 24px; text-align: left; }
.alert h2 { font-size: 16px; margin-bottom: 8px; color: ${c.border}; }
.alert p { font-size: 14px; color: #3A3A3A; }
.btn { display: inline-block; padding: 12px 24px; background: #000091; color: white; border-radius: 4px; text-decoration: none; font-weight: 600; font-size: 14px; margin-top: 16px; }
.btn:hover { opacity: 0.85; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Portail Associations</h1>
</div>
<div class="content">
<div class="icon">${c.icon}</div>
<div class="alert">
<h2>${escapeHtml(title)}</h2>
<p>${escapeHtml(message)}</p>
</div>
<a href="/" class="btn">Retour au portail</a>
</div>
</div>
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}