Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
369
server/emailActions.ts
Normal file
369
server/emailActions.ts
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
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, ''');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue