369 lines
17 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|