commit acd9e14ba50f1d33316801c820198464810bb013 Author: Selecta Keke Date: Wed Jun 24 00:02:55 2026 -0300 Initial local backup snapshot diff --git a/.codex-admin-directory-desktop.png b/.codex-admin-directory-desktop.png new file mode 100644 index 0000000..ca77414 Binary files /dev/null and b/.codex-admin-directory-desktop.png differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2168f06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +.git +.DS_Store +.env +.env.* +npm-debug.log* +pnpm-debug.log* +coverage +uploads diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6decbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,114 @@ +# Dependencies +**/node_modules +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock +*.bak + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Gatsby files +.cache/ + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Local services +local-services/forgejo/data/ +local-services/forgejo/db/ + +# Local tools +local-services/forgejo/.env.local diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.manus/db/db-query-1769818203014.json b/.manus/db/db-query-1769818203014.json new file mode 100644 index 0000000..b717437 --- /dev/null +++ b/.manus/db/db-query-1769818203014.json @@ -0,0 +1,28 @@ +{ + "query": "SELECT id, name, email, role, isActive, createdAt, lastSignedIn FROM users ORDER BY createdAt;", + "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway03.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2fRWrTVEDLYRs9G.c8049d3e27ba --database EAGCgSPXcL4ryFq9XLBKvW --execute SELECT id, name, email, role, isActive, createdAt, lastSignedIn FROM users ORDER BY createdAt;", + "rows": [ + { + "id": "1", + "name": "Selecta Keke", + "email": "selectakeke973@gmail.com", + "role": "admin", + "isActive": "1", + "createdAt": "2026-01-30 21:56:11", + "lastSignedIn": "2026-01-31 00:03:40" + }, + { + "id": "90004", + "name": "William", + "email": "william.meri@gmail.com", + "role": "user", + "isActive": "1", + "createdAt": "2026-01-30 23:26:14", + "lastSignedIn": "2026-01-31 00:08:06" + } + ], + "messages": [], + "stdout": "id\tname\temail\trole\tisActive\tcreatedAt\tlastSignedIn\n1\tSelecta Keke\tselectakeke973@gmail.com\tadmin\t1\t2026-01-30 21:56:11\t2026-01-31 00:03:40\n90004\tWilliam\twilliam.meri@gmail.com\tuser\t1\t2026-01-30 23:26:14\t2026-01-31 00:08:06\n", + "stderr": "", + "execution_time_ms": 214 +} \ No newline at end of file diff --git a/.manus/db/db-query-1769818206932.json b/.manus/db/db-query-1769818206932.json new file mode 100644 index 0000000..80dd0bc --- /dev/null +++ b/.manus/db/db-query-1769818206932.json @@ -0,0 +1,34 @@ +{ + "query": "SELECT * FROM users;", + "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway03.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2fRWrTVEDLYRs9G.c8049d3e27ba --database EAGCgSPXcL4ryFq9XLBKvW --execute SELECT * FROM users;", + "rows": [ + { + "id": "1", + "openId": "Pzgrt3DbY8fVWA3UaMwk39", + "name": "Selecta Keke", + "email": "selectakeke973@gmail.com", + "loginMethod": "google", + "role": "admin", + "createdAt": "2026-01-30 21:56:11", + "updatedAt": "2026-01-31 00:03:40", + "lastSignedIn": "2026-01-31 00:03:40", + "isActive": "1" + }, + { + "id": "90004", + "openId": "ijeVp4mua5wFXDoHd43yfm", + "name": "William", + "email": "william.meri@gmail.com", + "loginMethod": "google", + "role": "user", + "createdAt": "2026-01-30 23:26:14", + "updatedAt": "2026-01-31 00:08:05", + "lastSignedIn": "2026-01-31 00:08:06", + "isActive": "1" + } + ], + "messages": [], + "stdout": "id\topenId\tname\temail\tloginMethod\trole\tcreatedAt\tupdatedAt\tlastSignedIn\tisActive\n1\tPzgrt3DbY8fVWA3UaMwk39\tSelecta Keke\tselectakeke973@gmail.com\tgoogle\tadmin\t2026-01-30 21:56:11\t2026-01-31 00:03:40\t2026-01-31 00:03:40\t1\n90004\tijeVp4mua5wFXDoHd43yfm\tWilliam\twilliam.meri@gmail.com\tgoogle\tuser\t2026-01-30 23:26:14\t2026-01-31 00:08:05\t2026-01-31 00:08:06\t1\n", + "stderr": "", + "execution_time_ms": 62 +} \ No newline at end of file diff --git a/.manus/db/db-query-1769818255994.json b/.manus/db/db-query-1769818255994.json new file mode 100644 index 0000000..f6f1131 --- /dev/null +++ b/.manus/db/db-query-1769818255994.json @@ -0,0 +1,9 @@ +{ + "query": "UPDATE users SET role = 'super_admin' WHERE id = 1; UPDATE users SET role = 'admin' WHERE id = 90004;", + "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway03.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2fRWrTVEDLYRs9G.c8049d3e27ba --database EAGCgSPXcL4ryFq9XLBKvW --execute UPDATE users SET role = 'super_admin' WHERE id = 1; UPDATE users SET role = 'admin' WHERE id = 90004;", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 65 +} \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7284259 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Generated files +*.tsbuildinfo +coverage/ + +# Package files +package-lock.json +pnpm-lock.yaml + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# Environment files +.env* + +# IDE files +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..67c0bc8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "proseWrap": "preserve" +} diff --git a/BDD ACTEURS SAVANES 2024 1.xlsx b/BDD ACTEURS SAVANES 2024 1.xlsx new file mode 100644 index 0000000..b395c9c Binary files /dev/null and b/BDD ACTEURS SAVANES 2024 1.xlsx differ diff --git a/CCDS_Organigramme_2022.png b/CCDS_Organigramme_2022.png new file mode 100644 index 0000000..546a26d Binary files /dev/null and b/CCDS_Organigramme_2022.png differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ccc0369 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-alpine AS base + +WORKDIR /app + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" + +RUN corepack enable && corepack prepare pnpm@10.4.1 --activate + +COPY package.json pnpm-lock.yaml ./ +COPY patches ./patches + +RUN pnpm install --frozen-lockfile + +COPY . . + +RUN pnpm build + +EXPOSE 3000 + +CMD ["pnpm", "start"] diff --git a/MAIL_PROVIDERS.md b/MAIL_PROVIDERS.md new file mode 100644 index 0000000..3d99867 --- /dev/null +++ b/MAIL_PROVIDERS.md @@ -0,0 +1,70 @@ +# Configuration mail + +Le portail supporte maintenant un preset simple par fournisseur avec `SMTP_PROVIDER`. + +## 1. OVH + +```env +SMTP_PROVIDER=ovh +SMTP_FROM=notifications@votre-domaine.fr +SMTP_USER=notifications@votre-domaine.fr +SMTP_PASS=votre-mot-de-passe-ou-mot-de-passe-app +``` + +Valeurs automatiques appliquées : + +- `SMTP_HOST=smtp.mail.ovh.net` +- `SMTP_PORT=465` +- `SMTP_SECURE=true` + +## 2. Gmail + +```env +SMTP_PROVIDER=gmail +SMTP_FROM=votre-adresse@gmail.com +SMTP_USER=votre-adresse@gmail.com +SMTP_PASS=votre-mot-de-passe-app +``` + +Valeurs automatiques appliquées : + +- `SMTP_HOST=smtp.gmail.com` +- `SMTP_PORT=465` +- `SMTP_SECURE=true` + +Important : pour Gmail, utilise un **mot de passe d'application**. + +## 3. Outlook / Microsoft 365 + +```env +SMTP_PROVIDER=outlook +SMTP_FROM=notifications@votre-domaine.fr +SMTP_USER=notifications@votre-domaine.fr +SMTP_PASS=votre-mot-de-passe-ou-mot-de-passe-app +``` + +Valeurs automatiques appliquées : + +- `SMTP_HOST=smtp.office365.com` +- `SMTP_PORT=587` +- `SMTP_REQUIRE_TLS=true` + +## 4. SMTP personnalisé + +```env +SMTP_PROVIDER=custom +SMTP_HOST=mail.votre-serveur.fr +SMTP_PORT=587 +SMTP_FROM=notifications@votre-domaine.fr +SMTP_USER=notifications@votre-domaine.fr +SMTP_PASS=votre-mot-de-passe +SMTP_SECURE=false +SMTP_REQUIRE_TLS=true +``` + +## Notes utiles + +- Un seul fournisseur est actif par environnement à la fois. +- `SMTP_FROM` reste obligatoire. +- Si `SMTP_USER` est renseigné, `SMTP_PASS` doit l'être aussi. +- Le portail n'enverra pas d'email tant que la configuration restera incomplète. diff --git a/cahier-des-charges-techniques-ccds.md b/cahier-des-charges-techniques-ccds.md new file mode 100644 index 0000000..f14a3cb --- /dev/null +++ b/cahier-des-charges-techniques-ccds.md @@ -0,0 +1,359 @@ +# Cahier des Charges Techniques & Specifications Fonctionnelles + +## Projet + +**Systeme d'Information et de Logistique Evenementielle Interconnecte** +**Client :** Communaute de Communes Des Savanes (CCDS) + +## 1. Architecture technique & configuration globale + +### 1.1 Exigences environnementales & compatibilite + +Le systeme doit fonctionner comme une plateforme web complete, sans imposer l'installation d'une application native externe aux agents techniques ou aux usagers. + +Exigences : + +- aucune application PWA, APK ou iOS dediee ne doit etre obligatoire ; +- toutes les interactions doivent etre possibles depuis un navigateur web standard ; +- l'interface d'administration doit etre optimisee pour bureau et tablette ; +- l'interface terrain / restitution doit etre concue en approche mobile-first pour des ecrans de 4 a 7 pouces ; +- les emails emis par la plateforme doivent etre compatibles avec Outlook, Gmail, Apple Mail, Yahoo et les principaux webmails. + +### 1.2 Gestion de la securite, roles et permissions (RBAC) + +La base de donnees et la logique applicative doivent implementer strictement trois niveaux d'acces. + +#### Super-administrateur + +- acces complet en lecture / ecriture sur l'ensemble du systeme ; +- seul role autorise a creer, suspendre ou supprimer des comptes ; +- seul role autorise a configurer l'inventaire materiel global ; +- seul role autorise a modifier les variables de pilotage du systeme ; +- seul role autorise a definir les stocks plafonds, les valeurs de remplacement et les regles sensibles. + +#### Administrateur + +- instruction complete des demandes associations ; +- validation / refus des dossiers ; +- affectation des services et agents techniques ; +- pilotage du calendrier, de la logistique et des retours ; +- traitement des dossiers de cautionnement et d'arbitrage en cas de litige ; +- impossibilite de modifier la structure de base de l'inventaire ou les comptes utilisateurs. + +#### Utilisateur simple / agent de terrain + +- acces limite a ses missions affectees ; +- interface mobile epuree ; +- consultation et cloture de ses seules missions logistiques ; +- absence d'acces aux modules globaux d'administration, stock, comptes, statistiques et reglage. + +### 1.3 Journal d'audit inviolable + +Le systeme doit integrer un journal d'audit en mode **append-only** : + +- aucune modification d'une ligne d'audit ne doit etre possible ; +- aucune suppression d'une ligne d'audit ne doit etre possible, y compris pour le super-administrateur ; +- les ecritures doivent etre ajoutees chronologiquement uniquement. + +Chaque enregistrement doit contenir au minimum : + +- `timestamp` : date et heure issues exclusivement de l'horloge serveur ; +- `user_id` et `user_role` ; +- `action_type` ; +- `payload` lisible en langage clair ; +- `ip_address`. + +Exemple attendu : + +`Le 18/05 à 14h22, l'Admin X a accepté la demande #412` + +## 2. Specifications des modules back-office + +### 2.1 Tableau de bord operationnel + +Le tableau de bord est l'ecran d'accueil par defaut apres authentification administrative. + +Il doit fonctionner comme un centre de decision et se mettre a jour de facon dynamique sans rechargement complet de page. + +Composants attendus : + +#### Bandeau d'alertes logistiques + +- **Alerte rouge** : nombre de restitutions en retard + Condition : `date actuelle > date restitution + 3 jours` +- **Alerte orange** : nombre de litiges / degradations non arbitres +- **Badge bleu** : nouvelles demandes en attente d'instruction + +Chaque indicateur doit etre cliquable et appliquer un filtre direct sur les dossiers concernes. + +#### Flux operationnel journalier + +Deux colonnes : + +- **Departs / matin** : mises a disposition ou livraisons du jour +- **Retours / apres-midi** : restitutions attendues au jour J + +Chaque ligne doit faire apparaitre : + +- l'association ; +- le type de mission ; +- le statut d'assignation ; +- un acces rapide au dossier. + +#### Composant de visualisation des stocks + +- graphique donut ou barres ; +- quantites disponibles / engagees / bloquees ; +- lecture simple par categorie de materiel. + +#### Fil d'activite + +- affichage des 5 derniers evenements du journal d'audit ; +- lecture chronologique claire ; +- acces au journal complet. + +### 2.2 Module unifie "Dossier & assignation logistique" + +Le systeme doit supprimer les blocages de navigation entre consultation et action. + +#### Depuis la fiche dossier + +La fiche complete d'une demande acceptee doit integrer un bloc : + +`Assigner un service recuperateur` + +Ce bloc doit permettre : + +- de choisir un service ou agent via un menu relie a l'annuaire interne ; +- de valider l'assignation sans quitter la fiche ; +- de declencher le workflow de terrain. + +#### Depuis l'ecran d'assignation + +Un bouton secondaire : + +`Voir la demande` + +doit ouvrir un panneau lateral ou une drawer affichant : + +- details de l'association ; +- liste complete du materiel ; +- dates ; +- lieu / manifestation ; +- informations utiles a la decision. + +### 2.3 Calendrier logistique predictif + +Le calendrier doit etre relie a l'inventaire reel. + +#### Regle d'indexation stock + +Lorsqu'une demande est validee sur une periode donnee : + +- les quantites reservees doivent etre deduites du stock global ; +- cette deduction ne s'applique que sur la plage temporelle concernee. + +#### Affichage visuel attendu + +Chaque dossier doit produire trois phases visibles : + +- **Sortie / livraison** : bleu +- **Manifestation / indisponibilite stock** : phase d'utilisation +- **Alerte restitution** : jaune + +#### Interactions attendues + +Au clic sur un evenement du calendrier : + +- consulter l'agent ou service affecte ; +- reassigner la mission ; +- telecharger le PDF finalise si disponible. + +### 2.4 Cartographie interactive & implantation territoriale + +La cartographie doit etre connectee a la base de donnees et servir a la fois le grand public et l'administration. + +Technologies recommandees : + +- Leaflet.js +- ou Mapbox GL JS + +Traitements attendus : + +- geocodage automatique des adresses via `api-adresse.data.gouv.fr` ou `Nominatim` +- stockage des coordonnees geographiques +- clustering obligatoire pour eviter la surcharge visuelle + +Filtres attendus : + +- annuaire public par thematique ; +- vue logistique admin ; +- heatmap / densite ; +- filtres territoriaux sur Iracoubo, Kourou, Saint-Elie, Sinnamary. + +## 3. Specifications du workflow mobile terrain + +### 3.1 Declenchement & routage + +Un traitement planifie quotidien doit verifier les dossiers. + +Pour tout dossier dont la restitution theorique est a **J-4** : + +- generer un jeton d'acces securise temporaire ; +- construire une URL unique ; +- injecter cette URL dans l'email du service ou agent affecte. + +Exemple de forme attendue : + +`https://ccds.fr/restitution/v1?token=sha256_unique_hash` + +### 3.2 Interface mobile & gestion non bloquante + +Lors de l'ouverture sur smartphone : + +- les donnees de mise a disposition sont pre-remplies et figees ; +- l'agent ne modifie pas les donnees source de la demande ; +- le constat terrain se fait sur l'etat reel de restitution. + +#### Bloc "Restitution de materiel" + +Boutons radio exclusifs : + +- `Conforme` +- `Non conforme / Partiel` + +Comportement attendu : + +- `Conforme` valide globalement le retour ; +- `Non conforme / Partiel` active obligatoirement le bloc observations. + +#### Bloc "Observations contradictoires" + +Cases a cocher : + +- equipement manquant +- accessoires / visserie manquants +- armature deformee +- toile / bache dechiree +- impact / choc majeur +- restitution sale +- materiel humide + +Des qu'une case est cochee : + +- le champ texte libre devient obligatoire. + +### 3.3 Double signature, horodatage et resilience GPS + +#### Horodatage certifie + +Le backend doit ecraser toute date / heure issue de l'appareil. + +La date officielle est generee exclusivement par le serveur au moment de la soumission. + +#### Gestion robuste de la geolocalisation + +Le script `navigator.geolocation` doit : + +- fonctionner en asynchrone ; +- comporter un timeout strict de `5000 ms` ; +- ne jamais bloquer la validation finale. + +Scenarios : + +- **Succes** : les coordonnees sont jointes a la soumission +- **Echec** : la valeur `Non disponible` est enregistree, mais la validation continue normalement + +#### Signature tactile + +Le systeme doit integrer : + +- une zone de signature pour l'agent ; +- une zone de signature pour l'emprunteur ; +- les qualites associees ; +- un rendu suffisamment fluide pour un usage doigt / stylet. + +### 3.4 Generation dynamique du PDF & archivage + +Apres validation finale : + +- le serveur compile le document officiel ; +- fusionne les donnees, signatures et metadonnees ; +- applique le logo CCDS ; +- produit un PDF A4 sur une page si le contenu le permet. + +Contraintes de sortie : + +- CSS imprimee calibree ; +- signatures visibles ; +- mise en page stable ; +- envoi automatique en piece jointe : + - a l'association ; + - a la DSU ; +- mise a jour du statut en base. + +## 4. Protocole expeditif automatise : gestion des litiges + +Si la restitution est saisie comme `Non conforme / Partiel` ou `Mauvais etat` : + +### 4.1 Rupture de flux & gel logistique + +Le dossier passe en : + +`LITIGE / ATTENTE ARBITRAGE` + +Le materiel concerne doit etre marque indisponible pour : + +- bloquer son retour dans le calendrier ; +- empecher sa relocation par erreur. + +### 4.2 Interface d'arbitrage financier + +La fiche admin du dossier doit permettre : + +- de saisir le cout d'un devis ; +- de saisir un cout interne ; +- ou d'appliquer la valeur de remplacement a neuf issue de l'inventaire. + +### 4.3 Notification & cloture + +Le systeme doit proposer : + +- un bouton `Generer courrier de litige` +- un document pre-rempli pour notifier l'association + +Le dossier ne peut etre cloture definitivement qu'apres arbitrage explicite parmi : + +- retenue partielle sur caution +- encaissement total +- classement sans suite + +La decision doit : + +- mettre a jour le stock ; +- liberer ou non le materiel ; +- etre ecrite dans le journal d'audit. + +## 5. Vision d'ensemble + +La plateforme doit etre comprise comme un **ecosysteme logistique connecte et predictif**. + +Elle doit permettre : + +- de centraliser la gestion associative ; +- de piloter le materiel dans le temps ; +- de fluidifier les workflows terrain ; +- de proteger juridiquement et financierement la collectivite ; +- d'offrir a la direction un vrai centre de controle. + +## 6. Resultat attendu + +Le systeme final ne doit pas etre un simple outil de gestion administrative. + +Il doit devenir : + +- une plateforme de service public numerique ; +- un centre de pilotage logistique ; +- un outil de tracabilite ; +- un support de decision ; +- et un cadre fiable pour l'arbitrage, le stock et les operations terrain. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..b4f5eb1 --- /dev/null +++ b/client/index.html @@ -0,0 +1,23 @@ + + + + + + + + Portail Associations + + + + +
+ + + + diff --git a/client/public/.gitkeep b/client/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/client/public/__manus__/debug-collector.js b/client/public/__manus__/debug-collector.js new file mode 100644 index 0000000..0504555 --- /dev/null +++ b/client/public/__manus__/debug-collector.js @@ -0,0 +1,821 @@ +/** + * Manus Debug Collector (agent-friendly) + * + * Captures: + * 1) Console logs + * 2) Network requests (fetch + XHR) + * 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.) + * + * Data is periodically sent to /__manus__/logs + * Note: uiEvents are mirrored to sessionEvents for sessionReplay.log + */ +(function () { + "use strict"; + + // Prevent double initialization + if (window.__MANUS_DEBUG_COLLECTOR__) return; + + // ========================================================================== + // Configuration + // ========================================================================== + const CONFIG = { + reportEndpoint: "/__manus__/logs", + bufferSize: { + console: 500, + network: 200, + // semantic, agent-friendly UI events + ui: 500, + }, + reportInterval: 2000, + sensitiveFields: [ + "password", + "token", + "secret", + "key", + "authorization", + "cookie", + "session", + ], + maxBodyLength: 10240, + // UI event logging privacy policy: + // - inputs matching sensitiveFields or type=password are masked by default + // - non-sensitive inputs log up to 200 chars + uiInputMaxLen: 200, + uiTextMaxLen: 80, + // Scroll throttling: minimum ms between scroll events + scrollThrottleMs: 500, + }; + + // ========================================================================== + // Storage + // ========================================================================== + const store = { + consoleLogs: [], + networkRequests: [], + uiEvents: [], + lastReportTime: Date.now(), + lastScrollTime: 0, + }; + + // ========================================================================== + // Utility Functions + // ========================================================================== + + function sanitizeValue(value, depth) { + if (depth === void 0) depth = 0; + if (depth > 5) return "[Max Depth]"; + if (value === null) return null; + if (value === undefined) return undefined; + + if (typeof value === "string") { + return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value; + } + + if (typeof value !== "object") return value; + + if (Array.isArray(value)) { + return value.slice(0, 100).map(function (v) { + return sanitizeValue(v, depth + 1); + }); + } + + var sanitized = {}; + for (var k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + var isSensitive = CONFIG.sensitiveFields.some(function (f) { + return k.toLowerCase().indexOf(f) !== -1; + }); + if (isSensitive) { + sanitized[k] = "[REDACTED]"; + } else { + sanitized[k] = sanitizeValue(value[k], depth + 1); + } + } + } + return sanitized; + } + + function formatArg(arg) { + try { + if (arg instanceof Error) { + return { type: "Error", message: arg.message, stack: arg.stack }; + } + if (typeof arg === "object") return sanitizeValue(arg); + return String(arg); + } catch (e) { + return "[Unserializable]"; + } + } + + function formatArgs(args) { + var result = []; + for (var i = 0; i < args.length; i++) result.push(formatArg(args[i])); + return result; + } + + function pruneBuffer(buffer, maxSize) { + if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize); + } + + function tryParseJson(str) { + if (typeof str !== "string") return str; + try { + return JSON.parse(str); + } catch (e) { + return str; + } + } + + // ========================================================================== + // Semantic UI Event Logging (agent-friendly) + // ========================================================================== + + function shouldIgnoreTarget(target) { + try { + if (!target || !(target instanceof Element)) return false; + return !!target.closest(".manus-no-record"); + } catch (e) { + return false; + } + } + + function compactText(s, maxLen) { + try { + var t = (s || "").trim().replace(/\s+/g, " "); + if (!t) return ""; + return t.length > maxLen ? t.slice(0, maxLen) + "…" : t; + } catch (e) { + return ""; + } + } + + function elText(el) { + try { + var t = el.innerText || el.textContent || ""; + return compactText(t, CONFIG.uiTextMaxLen); + } catch (e) { + return ""; + } + } + + function describeElement(el) { + if (!el || !(el instanceof Element)) return null; + + var getAttr = function (name) { + return el.getAttribute(name); + }; + + var tag = el.tagName ? el.tagName.toLowerCase() : null; + var id = el.id || null; + var name = getAttr("name") || null; + var role = getAttr("role") || null; + var ariaLabel = getAttr("aria-label") || null; + + var dataLoc = getAttr("data-loc") || null; + var testId = + getAttr("data-testid") || + getAttr("data-test-id") || + getAttr("data-test") || + null; + + var type = tag === "input" ? (getAttr("type") || "text") : null; + var href = tag === "a" ? getAttr("href") || null : null; + + // a small, stable hint for agents (avoid building full CSS paths) + var selectorHint = null; + if (testId) selectorHint = '[data-testid="' + testId + '"]'; + else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]'; + else if (id) selectorHint = "#" + id; + else selectorHint = tag || "unknown"; + + return { + tag: tag, + id: id, + name: name, + type: type, + role: role, + ariaLabel: ariaLabel, + testId: testId, + dataLoc: dataLoc, + href: href, + text: elText(el), + selectorHint: selectorHint, + }; + } + + function isSensitiveField(el) { + if (!el || !(el instanceof Element)) return false; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea") return false; + + var type = (el.getAttribute("type") || "").toLowerCase(); + if (type === "password") return true; + + var name = (el.getAttribute("name") || "").toLowerCase(); + var id = (el.id || "").toLowerCase(); + + return CONFIG.sensitiveFields.some(function (f) { + return name.indexOf(f) !== -1 || id.indexOf(f) !== -1; + }); + } + + function getInputValueSafe(el) { + if (!el || !(el instanceof Element)) return null; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea" && tag !== "select") return null; + + var v = ""; + try { + v = el.value != null ? String(el.value) : ""; + } catch (e) { + v = ""; + } + + if (isSensitiveField(el)) return { masked: true, length: v.length }; + + if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…"; + return v; + } + + function logUiEvent(kind, payload) { + var entry = { + timestamp: Date.now(), + kind: kind, + url: location.href, + viewport: { width: window.innerWidth, height: window.innerHeight }, + payload: sanitizeValue(payload), + }; + store.uiEvents.push(entry); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + } + + function installUiEventListeners() { + // Clicks + document.addEventListener( + "click", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("click", { + target: describeElement(t), + x: e.clientX, + y: e.clientY, + }); + }, + true + ); + + // Typing "commit" events + document.addEventListener( + "change", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("change", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + document.addEventListener( + "focusin", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusin", { target: describeElement(t) }); + }, + true + ); + + document.addEventListener( + "focusout", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusout", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + // Enter/Escape are useful for form flows & modals + document.addEventListener( + "keydown", + function (e) { + if (e.key !== "Enter" && e.key !== "Escape") return; + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("keydown", { key: e.key, target: describeElement(t) }); + }, + true + ); + + // Form submissions + document.addEventListener( + "submit", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("submit", { target: describeElement(t) }); + }, + true + ); + + // Throttled scroll events + window.addEventListener( + "scroll", + function () { + var now = Date.now(); + if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return; + store.lastScrollTime = now; + + logUiEvent("scroll", { + scrollX: window.scrollX, + scrollY: window.scrollY, + documentHeight: document.documentElement.scrollHeight, + viewportHeight: window.innerHeight, + }); + }, + { passive: true } + ); + + // Navigation tracking for SPAs + function nav(reason) { + logUiEvent("navigate", { reason: reason }); + } + + var origPush = history.pushState; + history.pushState = function () { + origPush.apply(this, arguments); + nav("pushState"); + }; + + var origReplace = history.replaceState; + history.replaceState = function () { + origReplace.apply(this, arguments); + nav("replaceState"); + }; + + window.addEventListener("popstate", function () { + nav("popstate"); + }); + window.addEventListener("hashchange", function () { + nav("hashchange"); + }); + } + + // ========================================================================== + // Console Interception + // ========================================================================== + + var originalConsole = { + log: console.log.bind(console), + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + ["log", "debug", "info", "warn", "error"].forEach(function (method) { + console[method] = function () { + var args = Array.prototype.slice.call(arguments); + + var entry = { + timestamp: Date.now(), + level: method.toUpperCase(), + args: formatArgs(args), + stack: method === "error" ? new Error().stack : null, + }; + + store.consoleLogs.push(entry); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + originalConsole[method].apply(console, args); + }; + }); + + window.addEventListener("error", function (event) { + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UncaughtError", + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + }, + ], + stack: event.error ? event.error.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + // Mark an error moment in UI event stream for agents + logUiEvent("error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + }); + + window.addEventListener("unhandledrejection", function (event) { + var reason = event.reason; + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UnhandledRejection", + reason: reason && reason.message ? reason.message : String(reason), + stack: reason && reason.stack ? reason.stack : null, + }, + ], + stack: reason && reason.stack ? reason.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + logUiEvent("unhandledrejection", { + reason: reason && reason.message ? reason.message : String(reason), + }); + }); + + // ========================================================================== + // Fetch Interception + // ========================================================================== + + var originalFetch = window.fetch.bind(window); + + window.fetch = function (input, init) { + init = init || {}; + var startTime = Date.now(); + // Handle string, Request object, or URL object + var url = typeof input === "string" + ? input + : (input && (input.url || input.href || String(input))) || ""; + var method = init.method || (input && input.method) || "GET"; + + // Don't intercept internal requests + if (url.indexOf("/__manus__/") === 0) { + return originalFetch(input, init); + } + + // Safely parse headers (avoid breaking if headers format is invalid) + var requestHeaders = {}; + try { + if (init.headers) { + requestHeaders = Object.fromEntries(new Headers(init.headers).entries()); + } + } catch (e) { + requestHeaders = { _parseError: true }; + } + + var entry = { + timestamp: startTime, + type: "fetch", + method: method.toUpperCase(), + url: url, + request: { + headers: requestHeaders, + body: init.body ? sanitizeValue(tryParseJson(init.body)) : null, + }, + response: null, + duration: null, + error: null, + }; + + return originalFetch(input, init) + .then(function (response) { + entry.duration = Date.now() - startTime; + + var contentType = (response.headers.get("content-type") || "").toLowerCase(); + var contentLength = response.headers.get("content-length"); + + entry.response = { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: null, + }; + + // Semantic network hint for agents on failures (sync, no need to wait for body) + if (response.status >= 400) { + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + status: response.status, + statusText: response.statusText, + }); + } + + // Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + if (isStreaming) { + entry.response.body = "[Streaming response - not captured]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for large responses to avoid memory issues + if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) { + entry.response.body = "[Response too large: " + contentLength + " bytes]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + if (isBinary) { + entry.response.body = "[Binary content: " + contentType + "]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // For text responses, clone and read body in background + var clonedResponse = response.clone(); + + // Async: read body in background, don't block the response + clonedResponse + .text() + .then(function (text) { + if (text.length <= CONFIG.maxBodyLength) { + entry.response.body = sanitizeValue(tryParseJson(text)); + } else { + entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } + }) + .catch(function () { + entry.response.body = "[Unable to read body]"; + }) + .finally(function () { + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + }); + + // Return response immediately, don't wait for body reading + return response; + }) + .catch(function (error) { + entry.duration = Date.now() - startTime; + entry.error = { message: error.message, stack: error.stack }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + message: error.message, + }); + + throw error; + }); + }; + + // ========================================================================== + // XHR Interception + // ========================================================================== + + var originalXHROpen = XMLHttpRequest.prototype.open; + var originalXHRSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this._manusData = { + method: (method || "GET").toUpperCase(), + url: url, + startTime: null, + }; + return originalXHROpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + var xhr = this; + + if ( + xhr._manusData && + xhr._manusData.url && + xhr._manusData.url.indexOf("/__manus__/") !== 0 + ) { + xhr._manusData.startTime = Date.now(); + xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null; + + xhr.addEventListener("load", function () { + var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase(); + var responseBody = null; + + // Skip body capture for streaming responses + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + + if (isStreaming) { + responseBody = "[Streaming response - not captured]"; + } else if (isBinary) { + responseBody = "[Binary content: " + contentType + "]"; + } else { + // Safe to read responseText for text responses + try { + var text = xhr.responseText || ""; + if (text.length > CONFIG.maxBodyLength) { + responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } else { + responseBody = sanitizeValue(tryParseJson(text)); + } + } catch (e) { + // responseText may throw for non-text responses + responseBody = "[Unable to read response: " + e.message + "]"; + } + } + + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: { + status: xhr.status, + statusText: xhr.statusText, + body: responseBody, + }, + duration: Date.now() - xhr._manusData.startTime, + error: null, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + if (entry.response && entry.response.status >= 400) { + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + status: entry.response.status, + statusText: entry.response.statusText, + }); + } + }); + + xhr.addEventListener("error", function () { + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: null, + duration: Date.now() - xhr._manusData.startTime, + error: { message: "Network error" }, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + message: "Network error", + }); + }); + } + + return originalXHRSend.apply(this, arguments); + }; + + // ========================================================================== + // Data Reporting + // ========================================================================== + + function reportLogs() { + var consoleLogs = store.consoleLogs.splice(0); + var networkRequests = store.networkRequests.splice(0); + var uiEvents = store.uiEvents.splice(0); + + // Skip if no new data + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return Promise.resolve(); + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + // agent-friendly semantic events + uiEvents: uiEvents, + }; + + return originalFetch(CONFIG.reportEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(function () { + // Put data back on failure (but respect limits) + store.consoleLogs = consoleLogs.concat(store.consoleLogs); + store.networkRequests = networkRequests.concat(store.networkRequests); + store.uiEvents = uiEvents.concat(store.uiEvents); + + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + }); + } + + // Periodic reporting + setInterval(reportLogs, CONFIG.reportInterval); + + // Report on page unload + window.addEventListener("beforeunload", function () { + var consoleLogs = store.consoleLogs; + var networkRequests = store.networkRequests; + var uiEvents = store.uiEvents; + + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return; + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + uiEvents: uiEvents, + }; + + if (navigator.sendBeacon) { + var payloadStr = JSON.stringify(payload); + // sendBeacon has ~64KB limit, truncate if too large + var MAX_BEACON_SIZE = 60000; // Leave some margin + if (payloadStr.length > MAX_BEACON_SIZE) { + // Prioritize: keep recent events, drop older logs + var truncatedPayload = { + timestamp: Date.now(), + consoleLogs: consoleLogs.slice(-50), + networkRequests: networkRequests.slice(-20), + sessionEvents: uiEvents.slice(-100), + uiEvents: uiEvents.slice(-100), + _truncated: true, + }; + payloadStr = JSON.stringify(truncatedPayload); + } + navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr); + } + }); + + // ========================================================================== + // Initialization + // ========================================================================== + + // Install semantic UI listeners ASAP + try { + installUiEventListeners(); + } catch (e) { + console.warn("[Manus] Failed to install UI listeners:", e); + } + + // Mark as initialized + window.__MANUS_DEBUG_COLLECTOR__ = { + version: "2.0-no-rrweb", + store: store, + forceReport: reportLogs, + }; + + console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)"); +})(); diff --git a/client/public/__manus__/version.json b/client/public/__manus__/version.json new file mode 100644 index 0000000..2c84e70 --- /dev/null +++ b/client/public/__manus__/version.json @@ -0,0 +1,4 @@ +{ + "version": "9ae06d72", + "timestamp": 1775163491691 +} \ No newline at end of file diff --git a/client/public/map-styles/openmaptiles-positron-admin/style.json b/client/public/map-styles/openmaptiles-positron-admin/style.json new file mode 100644 index 0000000..604ae4f --- /dev/null +++ b/client/public/map-styles/openmaptiles-positron-admin/style.json @@ -0,0 +1,2375 @@ +{ + "version": 8, + "name": "OSM Bright CCDS Admin Dense", + "metadata": { + "mapbox:autocomposite": false, + "mapbox:groups": { + "101da9f13b64a08fa4b6ac1168e89e5f": { + "collapsed": false, + "name": "Places" + }, + "a14c9607bc7954ba1df7205bf660433f": { + "name": "Boundaries" + }, + "b6371a3f2f5a9932464fa3867530a2e5": { + "collapsed": false, + "name": "Transportation" + } + }, + "mapbox:type": "template", + "openmaptiles:mapbox:owner": "openmaptiles", + "openmaptiles:mapbox:source:url": "mapbox://openmaptiles.4qljc88t", + "openmaptiles:version": "3.x" + }, + "sources": { + "openmaptiles": { + "type": "vector", + "url": "https://www.portail-association973.com/data/openmaptiles.json" + } + }, + "sprite": "https://www.portail-association973.com/map-styles/openmaptiles-positron/sprite", + "glyphs": "https://www.portail-association973.com/fonts/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#f6f4ef" + } + }, + { + "id": "park", + "type": "fill", + "source": "openmaptiles", + "source-layer": "park", + "filter": [ + "==", + "$type", + "Polygon" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#e4ebdf" + } + }, + { + "id": "water", + "type": "fill", + "source": "openmaptiles", + "source-layer": "water", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "#c7dde7" + } + }, + { + "id": "landcover_ice_shelf", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landcover", + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "subclass", + "ice_shelf" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(0, 0%, 98%)", + "fill-opacity": 0.7 + } + }, + { + "id": "landcover_glacier", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landcover", + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "subclass", + "glacier" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(0, 0%, 98%)", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 0, + 1 + ], + [ + 8, + 0.5 + ] + ] + } + } + }, + { + "id": "landuse_residential", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landuse", + "maxzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "residential" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#ebe7de", + "fill-opacity": { + "base": 0.6, + "stops": [ + [ + 8, + 0.8 + ], + [ + 9, + 0.6 + ] + ] + } + } + }, + { + "id": "landcover_wood", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landcover", + "minzoom": 10, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "wood" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#dbe6d3", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 8, + 0 + ], + [ + 12, + 1 + ] + ] + } + } + }, + { + "id": "waterway", + "type": "line", + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "==", + "$type", + "LineString" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "#8fb4c7" + } + }, + { + "id": "water_name", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": [ + "==", + "$type", + "LineString" + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 520, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-rotation-alignment": "map", + "text-size": 11.5 + }, + "paint": { + "text-color": "#6a8aa0", + "text-halo-blur": 1, + "text-halo-color": "#f6f4ef", + "text-halo-width": 1.3 + }, + "minzoom": 8 + }, + { + "id": "building", + "type": "fill", + "source": "openmaptiles", + "source-layer": "building", + "minzoom": 12, + "paint": { + "fill-antialias": true, + "fill-color": "#e7e1d7", + "fill-outline-color": "#d7cec2" + } + }, + { + "id": "tunnel_motorway_casing", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "rgb(213, 213, 213)", + "line-opacity": 1, + "line-width": { + "base": 1.4, + "stops": [ + [ + 5.8, + 0 + ], + [ + 6, + 3 + ], + [ + 20, + 40 + ] + ] + } + } + }, + { + "id": "tunnel_motorway_inner", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgb(234,234,234)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 2 + ], + [ + 6, + 1.3 + ], + [ + 20, + 30 + ] + ] + } + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 12, + "filter": [ + "all", + [ + "in", + "class", + "taxiway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 88%)", + "line-opacity": 1, + "line-width": { + "base": 1.55, + "stops": [ + [ + 13, + 1.8 + ], + [ + 20, + 20 + ] + ] + } + } + }, + { + "id": "aeroway-runway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 11, + "filter": [ + "all", + [ + "in", + "class", + "runway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 88%)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 6 + ], + [ + 17, + 55 + ] + ] + } + } + }, + { + "id": "aeroway-area", + "type": "fill", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "in", + "class", + "runway", + "taxiway" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(255, 255, 255, 1)", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 11, + "filter": [ + "all", + [ + "in", + "class", + "runway" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 4 + ], + [ + 17, + 50 + ] + ] + } + } + }, + { + "id": "road_area_pier", + "type": "fill", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "pier" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "#f3f1ea" + } + }, + { + "id": "road_pier", + "type": "line", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "pier" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#f3f1ea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 17, + 4 + ] + ] + } + } + }, + { + "id": "highway_path", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "path" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#ddd8cd", + "line-opacity": 0.9, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1 + ], + [ + 20, + 10 + ] + ] + } + } + }, + { + "id": "highway_minor", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#ebe4d8", + "line-opacity": 0.9, + "line-width": { + "base": 1.55, + "stops": [ + [ + 13, + 1.8 + ], + [ + 20, + 20 + ] + ] + } + } + }, + { + "id": "highway_major_casing", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "primary", + "secondary", + "tertiary", + "trunk" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "#d3c7b5", + "line-dasharray": [ + 12, + 0 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 10, + 3 + ], + [ + 20, + 23 + ] + ] + } + } + }, + { + "id": "highway_major_inner", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "primary", + "secondary", + "tertiary", + "trunk" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fffdf8", + "line-width": { + "base": 1.3, + "stops": [ + [ + 10, + 2 + ], + [ + 20, + 20 + ] + ] + } + } + }, + { + "id": "highway_major_subtle", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "maxzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "primary", + "secondary", + "tertiary", + "trunk" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(201, 188, 168, 0.45)", + "line-width": 2 + } + }, + { + "id": "highway_motorway_casing", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "#c4a27d", + "line-dasharray": [ + 2, + 0 + ], + "line-opacity": 1, + "line-width": { + "base": 1.4, + "stops": [ + [ + 5.8, + 0 + ], + [ + 6, + 3 + ], + [ + 20, + 40 + ] + ] + } + } + }, + { + "id": "highway_motorway_inner", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 5.8, + "rgba(196, 162, 125, 0.34)" + ], + [ + 6, + "#f8f0e3" + ] + ] + }, + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 2 + ], + [ + 6, + 1.3 + ], + [ + 20, + 30 + ] + ] + } + } + }, + { + "id": "highway_motorway_subtle", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "maxzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "motorway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(184, 149, 110, 0.35)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 2 + ], + [ + 6, + 1.3 + ] + ] + } + } + }, + { + "id": "railway_transit", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "transit" + ], + [ + "!in", + "brunnel", + "tunnel" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#cdc8c1", + "line-width": 3 + } + }, + { + "id": "railway_transit_dashline", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "transit" + ], + [ + "!in", + "brunnel", + "tunnel" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fafafa", + "line-dasharray": [ + 3, + 3 + ], + "line-width": 2 + } + }, + { + "id": "railway_service", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "has", + "service" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#d7d2ca", + "line-width": 3 + } + }, + { + "id": "railway_service_dashline", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "rail" + ], + [ + "has", + "service" + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fafafa", + "line-dasharray": [ + 3, + 3 + ], + "line-width": 2 + } + }, + { + "id": "railway", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!has", + "service" + ], + [ + "==", + "class", + "rail" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#cac6bf", + "line-width": { + "base": 1.3, + "stops": [ + [ + 16, + 3 + ], + [ + 20, + 7 + ] + ] + } + } + }, + { + "id": "railway_dashline", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!has", + "service" + ], + [ + "==", + "class", + "rail" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fafafa", + "line-dasharray": [ + 3, + 3 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 16, + 2 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "highway_motorway_bridge_casing", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "#b8956e", + "line-dasharray": [ + 2, + 0 + ], + "line-opacity": 1, + "line-width": { + "base": 1.4, + "stops": [ + [ + 5.8, + 0 + ], + [ + 6, + 5 + ], + [ + 20, + 45 + ] + ] + } + } + }, + { + "id": "highway_motorway_bridge_inner", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 5.8, + "rgba(184, 149, 110, 0.45)" + ], + [ + 6, + "#f6ead6" + ] + ] + }, + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 2 + ], + [ + 6, + 1.3 + ], + [ + 20, + 30 + ] + ] + } + } + }, + { + "id": "highway_name_other", + "type": "symbol", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation_name", + "filter": [ + "all", + [ + "!=", + "class", + "motorway" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-angle": 30, + "text-pitch-alignment": "viewport", + "text-rotation-alignment": "map", + "text-size": 9.4, + "visibility": "visible", + "text-letter-spacing": 0.04 + }, + "paint": { + "text-color": "#8a8176", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.3, + "text-translate": [ + 0, + 0 + ] + }, + "minzoom": 10 + }, + { + "id": "highway_name_motorway", + "type": "symbol", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation_name", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "motorway" + ] + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 420, + "text-field": "{ref}", + "text-font": [ + "Noto Sans Regular" + ], + "text-pitch-alignment": "viewport", + "text-rotation-alignment": "viewport", + "text-size": 9.5, + "visibility": "visible" + }, + "paint": { + "text-color": "#7d6f61", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.3, + "text-translate": [ + 0, + 2 + ] + }, + "minzoom": 9.5 + }, + { + "id": "boundary_state", + "type": "line", + "metadata": { + "mapbox:group": "a14c9607bc7954ba1df7205bf660433f" + }, + "source": "openmaptiles", + "source-layer": "boundary", + "filter": [ + "==", + "admin_level", + 4 + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-blur": 0.4, + "line-color": "#d1c6c8", + "line-dasharray": [ + 2, + 2 + ], + "line-opacity": 1, + "line-width": { + "base": 1.3, + "stops": [ + [ + 3, + 1 + ], + [ + 22, + 15 + ] + ] + } + } + }, + { + "id": "boundary_country_z0-4", + "type": "line", + "metadata": { + "mapbox:group": "a14c9607bc7954ba1df7205bf660433f" + }, + "source": "openmaptiles", + "source-layer": "boundary", + "maxzoom": 5, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "!has", + "claimed_by" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-blur": { + "base": 1, + "stops": [ + [ + 0, + 0.4 + ], + [ + 22, + 4 + ] + ] + }, + "line-color": "#d1c6c8", + "line-opacity": 1, + "line-width": { + "base": 1.1, + "stops": [ + [ + 3, + 1 + ], + [ + 22, + 20 + ] + ] + } + } + }, + { + "id": "boundary_country_z5-", + "type": "line", + "metadata": { + "mapbox:group": "a14c9607bc7954ba1df7205bf660433f" + }, + "source": "openmaptiles", + "source-layer": "boundary", + "minzoom": 5, + "filter": [ + "==", + "admin_level", + 2 + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-blur": { + "base": 1, + "stops": [ + [ + 0, + 0.4 + ], + [ + 22, + 4 + ] + ] + }, + "line-color": "#d1c6c8", + "line-opacity": 1, + "line-width": { + "base": 1.1, + "stops": [ + [ + 3, + 1 + ], + [ + 22, + 20 + ] + ] + } + } + }, + { + "id": "place_other", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 14, + "filter": [ + "all", + [ + "in", + "class", + "continent", + "hamlet", + "neighbourhood", + "isolated_dwelling" + ], + [ + "==", + "$type", + "Point" + ] + ], + "layout": { + "text-anchor": "center", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "center", + "text-offset": [ + 0.5, + 0 + ], + "text-size": 9.5, + "text-transform": "uppercase", + "visibility": "visible", + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": "#667483", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.3 + } + }, + { + "id": "place_suburb", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "suburb" + ] + ], + "layout": { + "text-anchor": "center", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "center", + "text-offset": [ + 0.5, + 0 + ], + "text-size": 9.5, + "text-transform": "uppercase", + "visibility": "visible", + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": "#667483", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.3 + } + }, + { + "id": "place_village", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "village" + ] + ], + "layout": { + "icon-size": 0.4, + "text-anchor": "left", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 9.5, + "visibility": "visible", + "text-letter-spacing": 0.04 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#556371", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.3 + }, + "minzoom": 7.5 + }, + { + "id": "place_town", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "town" + ] + ], + "layout": { + "icon-image": { + "base": 1, + "stops": [ + [ + 0, + "circle-11" + ], + [ + 8, + "" + ] + ] + }, + "icon-size": 0.4, + "text-anchor": { + "base": 1, + "stops": [ + [ + 0, + "left" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 10, + "visibility": "visible", + "text-letter-spacing": 0.03 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#556371", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.3 + }, + "minzoom": 6.5 + }, + { + "id": "place_city", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "all", + [ + "!=", + "capital", + 2 + ], + [ + "==", + "class", + "city" + ], + [ + ">", + "rank", + 3 + ] + ] + ], + "layout": { + "icon-image": { + "base": 1, + "stops": [ + [ + 0, + "circle-11" + ], + [ + 8, + "" + ] + ] + }, + "icon-size": 0.4, + "text-anchor": { + "base": 1, + "stops": [ + [ + 0, + "left" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 11, + "visibility": "visible", + "text-letter-spacing": 0.015 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#344556", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.4 + }, + "minzoom": 5.8 + }, + { + "id": "place_capital", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "all", + [ + "==", + "capital", + 2 + ], + [ + "==", + "class", + "city" + ] + ] + ], + "layout": { + "icon-image": { + "base": 1, + "stops": [ + [ + 0, + "star-11" + ], + [ + 8, + "" + ] + ] + }, + "icon-size": 1, + "text-anchor": { + "base": 1, + "stops": [ + [ + 0, + "left" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 13.5, + "visibility": "visible", + "text-letter-spacing": 0.015 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#2a3b4a", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.4 + }, + "minzoom": 4 + }, + { + "id": "place_city_large", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "all", + [ + "!=", + "capital", + 2 + ], + [ + "<=", + "rank", + 3 + ], + [ + "==", + "class", + "city" + ] + ] + ], + "layout": { + "icon-image": { + "base": 1, + "stops": [ + [ + 0, + "circle-11" + ], + [ + 8, + "" + ] + ] + }, + "icon-size": 0.4, + "text-anchor": { + "base": 1, + "stops": [ + [ + 0, + "left" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 12.5, + "visibility": "visible", + "text-letter-spacing": 0.015 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#2f4051", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.4 + }, + "minzoom": 4.8 + }, + { + "id": "place_state", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "state" + ] + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-size": 10, + "text-transform": "uppercase", + "visibility": "visible", + "text-letter-spacing": 0.04 + }, + "paint": { + "text-color": "#627181", + "text-halo-blur": 1, + "text-halo-color": "#fcfbf8", + "text-halo-width": 1.4 + }, + "minzoom": 3.8 + }, + { + "id": "place_country_other", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "country" + ], + [ + "!has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 0, + 9 + ], + [ + 6, + 11 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": { + "base": 1, + "stops": [ + [ + 3, + "#8ea0af" + ], + [ + 4, + "#7a8b9a" + ] + ] + }, + "text-halo-color": "rgba(248,246,239,0.85)", + "text-halo-width": 1.4 + } + }, + { + "id": "place_country_minor", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "country" + ], + [ + ">=", + "rank", + 2 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 0, + 10 + ], + [ + 6, + 12 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": { + "base": 1, + "stops": [ + [ + 3, + "#8799aa" + ], + [ + 4, + "#718495" + ] + ] + }, + "text-halo-color": "rgba(248,246,239,0.85)", + "text-halo-width": 1.4 + } + }, + { + "id": "place_country_major", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "<=", + "rank", + 1 + ], + [ + "==", + "class", + "country" + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-anchor": "center", + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-size": { + "base": 1.4, + "stops": [ + [ + 0, + 10 + ], + [ + 3, + 12 + ], + [ + 4, + 14 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": { + "base": 1, + "stops": [ + [ + 3, + "#708497" + ], + [ + 4, + "#5d7184" + ] + ] + }, + "text-halo-color": "rgba(248,246,239,0.9)", + "text-halo-width": 1.4 + } + } + ], + "id": "positron" +} diff --git a/client/public/map-styles/openmaptiles-positron/sprite.json b/client/public/map-styles/openmaptiles-positron/sprite.json new file mode 100644 index 0000000..2d20beb --- /dev/null +++ b/client/public/map-styles/openmaptiles-positron/sprite.json @@ -0,0 +1,16 @@ +{ + "circle-11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 0, + "y": 0 + }, + "star-11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 17, + "y": 0 + } +} \ No newline at end of file diff --git a/client/public/map-styles/openmaptiles-positron/sprite.png b/client/public/map-styles/openmaptiles-positron/sprite.png new file mode 100644 index 0000000..3539834 Binary files /dev/null and b/client/public/map-styles/openmaptiles-positron/sprite.png differ diff --git a/client/public/map-styles/openmaptiles-positron/sprite@2x.json b/client/public/map-styles/openmaptiles-positron/sprite@2x.json new file mode 100644 index 0000000..498b044 --- /dev/null +++ b/client/public/map-styles/openmaptiles-positron/sprite@2x.json @@ -0,0 +1,16 @@ +{ + "circle-11": { + "height": 34, + "pixelRatio": 2, + "width": 34, + "x": 0, + "y": 0 + }, + "star-11": { + "height": 34, + "pixelRatio": 2, + "width": 34, + "x": 34, + "y": 0 + } +} \ No newline at end of file diff --git a/client/public/map-styles/openmaptiles-positron/sprite@2x.png b/client/public/map-styles/openmaptiles-positron/sprite@2x.png new file mode 100644 index 0000000..1939a3b Binary files /dev/null and b/client/public/map-styles/openmaptiles-positron/sprite@2x.png differ diff --git a/client/public/map-styles/openmaptiles-positron/style.json b/client/public/map-styles/openmaptiles-positron/style.json new file mode 100644 index 0000000..c354f48 --- /dev/null +++ b/client/public/map-styles/openmaptiles-positron/style.json @@ -0,0 +1,2375 @@ +{ + "version": 8, + "name": "OSM Bright CCDS Premium", + "metadata": { + "mapbox:autocomposite": false, + "mapbox:groups": { + "101da9f13b64a08fa4b6ac1168e89e5f": { + "collapsed": false, + "name": "Places" + }, + "a14c9607bc7954ba1df7205bf660433f": { + "name": "Boundaries" + }, + "b6371a3f2f5a9932464fa3867530a2e5": { + "collapsed": false, + "name": "Transportation" + } + }, + "mapbox:type": "template", + "openmaptiles:mapbox:owner": "openmaptiles", + "openmaptiles:mapbox:source:url": "mapbox://openmaptiles.4qljc88t", + "openmaptiles:version": "3.x" + }, + "sources": { + "openmaptiles": { + "type": "vector", + "url": "https://www.portail-association973.com/data/openmaptiles.json" + } + }, + "sprite": "https://www.portail-association973.com/map-styles/openmaptiles-positron/sprite", + "glyphs": "https://www.portail-association973.com/fonts/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#f3f1ea" + } + }, + { + "id": "park", + "type": "fill", + "source": "openmaptiles", + "source-layer": "park", + "filter": [ + "==", + "$type", + "Polygon" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#dde9d9" + } + }, + { + "id": "water", + "type": "fill", + "source": "openmaptiles", + "source-layer": "water", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "#bfd9e6" + } + }, + { + "id": "landcover_ice_shelf", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landcover", + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "subclass", + "ice_shelf" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(0, 0%, 98%)", + "fill-opacity": 0.7 + } + }, + { + "id": "landcover_glacier", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landcover", + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "subclass", + "glacier" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(0, 0%, 98%)", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 0, + 1 + ], + [ + 8, + 0.5 + ] + ] + } + } + }, + { + "id": "landuse_residential", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landuse", + "maxzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "residential" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#ebe7de", + "fill-opacity": { + "base": 0.6, + "stops": [ + [ + 8, + 0.8 + ], + [ + 9, + 0.6 + ] + ] + } + } + }, + { + "id": "landcover_wood", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landcover", + "minzoom": 10, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "wood" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#d4e2cf", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 8, + 0 + ], + [ + 12, + 1 + ] + ] + } + } + }, + { + "id": "waterway", + "type": "line", + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "==", + "$type", + "LineString" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "#86b7cd" + } + }, + { + "id": "water_name", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": [ + "==", + "$type", + "LineString" + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 470, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-rotation-alignment": "map", + "text-size": 12.5 + }, + "paint": { + "text-color": "#557f98", + "text-halo-blur": 1, + "text-halo-color": "#f3f1ea", + "text-halo-width": 1.5 + }, + "minzoom": 7 + }, + { + "id": "building", + "type": "fill", + "source": "openmaptiles", + "source-layer": "building", + "minzoom": 12, + "paint": { + "fill-antialias": true, + "fill-color": "#e7e1d7", + "fill-outline-color": "#d7cec2" + } + }, + { + "id": "tunnel_motorway_casing", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "rgb(213, 213, 213)", + "line-opacity": 1, + "line-width": { + "base": 1.4, + "stops": [ + [ + 5.8, + 0 + ], + [ + 6, + 3 + ], + [ + 20, + 40 + ] + ] + } + } + }, + { + "id": "tunnel_motorway_inner", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgb(234,234,234)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 2 + ], + [ + 6, + 1.3 + ], + [ + 20, + 30 + ] + ] + } + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 12, + "filter": [ + "all", + [ + "in", + "class", + "taxiway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 88%)", + "line-opacity": 1, + "line-width": { + "base": 1.55, + "stops": [ + [ + 13, + 1.8 + ], + [ + 20, + 20 + ] + ] + } + } + }, + { + "id": "aeroway-runway-casing", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 11, + "filter": [ + "all", + [ + "in", + "class", + "runway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 88%)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 6 + ], + [ + 17, + 55 + ] + ] + } + } + }, + { + "id": "aeroway-area", + "type": "fill", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "in", + "class", + "runway", + "taxiway" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(255, 255, 255, 1)", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "metadata": { + "mapbox:group": "1444849345966.4436" + }, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 11, + "filter": [ + "all", + [ + "in", + "class", + "runway" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 4 + ], + [ + 17, + 50 + ] + ] + } + } + }, + { + "id": "road_area_pier", + "type": "fill", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "pier" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "#f3f1ea" + } + }, + { + "id": "road_pier", + "type": "line", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "pier" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#f3f1ea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 17, + 4 + ] + ] + } + } + }, + { + "id": "highway_path", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "path" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#ddd8cd", + "line-opacity": 0.9, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1 + ], + [ + 20, + 10 + ] + ] + } + } + }, + { + "id": "highway_minor", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#ebe4d8", + "line-opacity": 0.9, + "line-width": { + "base": 1.55, + "stops": [ + [ + 13, + 1.8 + ], + [ + 20, + 20 + ] + ] + } + } + }, + { + "id": "highway_major_casing", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "primary", + "secondary", + "tertiary", + "trunk" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "#cdbfaa", + "line-dasharray": [ + 12, + 0 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 10, + 3 + ], + [ + 20, + 23 + ] + ] + } + } + }, + { + "id": "highway_major_inner", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "primary", + "secondary", + "tertiary", + "trunk" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fff9f0", + "line-width": { + "base": 1.3, + "stops": [ + [ + 10, + 2 + ], + [ + 20, + 20 + ] + ] + } + } + }, + { + "id": "highway_major_subtle", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "maxzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "primary", + "secondary", + "tertiary", + "trunk" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(201, 188, 168, 0.45)", + "line-width": 2 + } + }, + { + "id": "highway_motorway_casing", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "#b8956e", + "line-dasharray": [ + 2, + 0 + ], + "line-opacity": 1, + "line-width": { + "base": 1.4, + "stops": [ + [ + 5.8, + 0 + ], + [ + 6, + 3 + ], + [ + 20, + 40 + ] + ] + } + } + }, + { + "id": "highway_motorway_inner", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "brunnel", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 5.8, + "rgba(184, 149, 110, 0.45)" + ], + [ + 6, + "#f6ead6" + ] + ] + }, + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 2 + ], + [ + 6, + 1.3 + ], + [ + 20, + 30 + ] + ] + } + } + }, + { + "id": "highway_motorway_subtle", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "maxzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "motorway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(184, 149, 110, 0.35)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 2 + ], + [ + 6, + 1.3 + ] + ] + } + } + }, + { + "id": "railway_transit", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "transit" + ], + [ + "!in", + "brunnel", + "tunnel" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#cdc8c1", + "line-width": 3 + } + }, + { + "id": "railway_transit_dashline", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "transit" + ], + [ + "!in", + "brunnel", + "tunnel" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fafafa", + "line-dasharray": [ + 3, + 3 + ], + "line-width": 2 + } + }, + { + "id": "railway_service", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "has", + "service" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#d7d2ca", + "line-width": 3 + } + }, + { + "id": "railway_service_dashline", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "rail" + ], + [ + "has", + "service" + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fafafa", + "line-dasharray": [ + 3, + 3 + ], + "line-width": 2 + } + }, + { + "id": "railway", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!has", + "service" + ], + [ + "==", + "class", + "rail" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#cac6bf", + "line-width": { + "base": 1.3, + "stops": [ + [ + 16, + 3 + ], + [ + 20, + 7 + ] + ] + } + } + }, + { + "id": "railway_dashline", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!has", + "service" + ], + [ + "==", + "class", + "rail" + ] + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fafafa", + "line-dasharray": [ + 3, + 3 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 16, + 2 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "highway_motorway_bridge_casing", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "#b8956e", + "line-dasharray": [ + 2, + 0 + ], + "line-opacity": 1, + "line-width": { + "base": 1.4, + "stops": [ + [ + 5.8, + 0 + ], + [ + 6, + 5 + ], + [ + 20, + 45 + ] + ] + } + } + }, + { + "id": "highway_motorway_bridge_inner", + "type": "line", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "brunnel", + "bridge" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 5.8, + "rgba(184, 149, 110, 0.45)" + ], + [ + 6, + "#f6ead6" + ] + ] + }, + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 2 + ], + [ + 6, + 1.3 + ], + [ + 20, + 30 + ] + ] + } + } + }, + { + "id": "highway_name_other", + "type": "symbol", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation_name", + "filter": [ + "all", + [ + "!=", + "class", + "motorway" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 300, + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-max-angle": 30, + "text-pitch-alignment": "viewport", + "text-rotation-alignment": "map", + "text-size": 10.7, + "visibility": "visible", + "text-letter-spacing": 0.03 + }, + "paint": { + "text-color": "#756858", + "text-halo-blur": 1, + "text-halo-color": "#fbfaf6", + "text-halo-width": 1.5, + "text-translate": [ + 0, + 0 + ] + }, + "minzoom": 9 + }, + { + "id": "highway_name_motorway", + "type": "symbol", + "metadata": { + "mapbox:group": "b6371a3f2f5a9932464fa3867530a2e5" + }, + "source": "openmaptiles", + "source-layer": "transportation_name", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "motorway" + ] + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 380, + "text-field": "{ref}", + "text-font": [ + "Noto Sans Regular" + ], + "text-pitch-alignment": "viewport", + "text-rotation-alignment": "viewport", + "text-size": 10.5, + "visibility": "visible" + }, + "paint": { + "text-color": "#6d5a49", + "text-halo-blur": 1, + "text-halo-color": "#fbfaf6", + "text-halo-width": 1.4, + "text-translate": [ + 0, + 2 + ] + }, + "minzoom": 8.5 + }, + { + "id": "boundary_state", + "type": "line", + "metadata": { + "mapbox:group": "a14c9607bc7954ba1df7205bf660433f" + }, + "source": "openmaptiles", + "source-layer": "boundary", + "filter": [ + "==", + "admin_level", + 4 + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-blur": 0.4, + "line-color": "#c7b8bb", + "line-dasharray": [ + 2, + 2 + ], + "line-opacity": 1, + "line-width": { + "base": 1.3, + "stops": [ + [ + 3, + 1 + ], + [ + 22, + 15 + ] + ] + } + } + }, + { + "id": "boundary_country_z0-4", + "type": "line", + "metadata": { + "mapbox:group": "a14c9607bc7954ba1df7205bf660433f" + }, + "source": "openmaptiles", + "source-layer": "boundary", + "maxzoom": 5, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "!has", + "claimed_by" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-blur": { + "base": 1, + "stops": [ + [ + 0, + 0.4 + ], + [ + 22, + 4 + ] + ] + }, + "line-color": "#c7b8bb", + "line-opacity": 1, + "line-width": { + "base": 1.1, + "stops": [ + [ + 3, + 1 + ], + [ + 22, + 20 + ] + ] + } + } + }, + { + "id": "boundary_country_z5-", + "type": "line", + "metadata": { + "mapbox:group": "a14c9607bc7954ba1df7205bf660433f" + }, + "source": "openmaptiles", + "source-layer": "boundary", + "minzoom": 5, + "filter": [ + "==", + "admin_level", + 2 + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-blur": { + "base": 1, + "stops": [ + [ + 0, + 0.4 + ], + [ + 22, + 4 + ] + ] + }, + "line-color": "#c7b8bb", + "line-opacity": 1, + "line-width": { + "base": 1.1, + "stops": [ + [ + 3, + 1 + ], + [ + 22, + 20 + ] + ] + } + } + }, + { + "id": "place_other", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 14, + "filter": [ + "all", + [ + "in", + "class", + "continent", + "hamlet", + "neighbourhood", + "isolated_dwelling" + ], + [ + "==", + "$type", + "Point" + ] + ], + "layout": { + "text-anchor": "center", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "center", + "text-offset": [ + 0.5, + 0 + ], + "text-size": 10.5, + "text-transform": "uppercase", + "visibility": "visible", + "text-letter-spacing": 0.08 + }, + "paint": { + "text-color": "#435160", + "text-halo-blur": 1, + "text-halo-color": "#f8f6ef", + "text-halo-width": 1.6 + } + }, + { + "id": "place_suburb", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "suburb" + ] + ], + "layout": { + "text-anchor": "center", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "center", + "text-offset": [ + 0.5, + 0 + ], + "text-size": 10.5, + "text-transform": "uppercase", + "visibility": "visible", + "text-letter-spacing": 0.08 + }, + "paint": { + "text-color": "#435160", + "text-halo-blur": 1, + "text-halo-color": "#f8f6ef", + "text-halo-width": 1.6 + } + }, + { + "id": "place_village", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "village" + ] + ], + "layout": { + "icon-size": 0.4, + "text-anchor": "left", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 10.5, + "visibility": "visible", + "text-letter-spacing": 0.02 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#435160", + "text-halo-blur": 1, + "text-halo-color": "#f8f6ef", + "text-halo-width": 1.5 + }, + "minzoom": 6.8 + }, + { + "id": "place_town", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "town" + ] + ], + "layout": { + "icon-image": { + "base": 1, + "stops": [ + [ + 0, + "circle-11" + ], + [ + 8, + "" + ] + ] + }, + "icon-size": 0.4, + "text-anchor": { + "base": 1, + "stops": [ + [ + 0, + "left" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 11, + "visibility": "visible", + "text-letter-spacing": 0.015 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#435160", + "text-halo-blur": 1, + "text-halo-color": "#f8f6ef", + "text-halo-width": 1.5 + }, + "minzoom": 5.8 + }, + { + "id": "place_city", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "all", + [ + "!=", + "capital", + 2 + ], + [ + "==", + "class", + "city" + ], + [ + ">", + "rank", + 3 + ] + ] + ], + "layout": { + "icon-image": { + "base": 1, + "stops": [ + [ + 0, + "circle-11" + ], + [ + 8, + "" + ] + ] + }, + "icon-size": 0.4, + "text-anchor": { + "base": 1, + "stops": [ + [ + 0, + "left" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 12.5, + "visibility": "visible", + "text-letter-spacing": 0.01 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#334454", + "text-halo-blur": 1, + "text-halo-color": "#f8f6ef", + "text-halo-width": 1.5 + }, + "minzoom": 5.2 + }, + { + "id": "place_capital", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "all", + [ + "==", + "capital", + 2 + ], + [ + "==", + "class", + "city" + ] + ] + ], + "layout": { + "icon-image": { + "base": 1, + "stops": [ + [ + 0, + "star-11" + ], + [ + 8, + "" + ] + ] + }, + "icon-size": 1, + "text-anchor": { + "base": 1, + "stops": [ + [ + 0, + "left" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 15.5, + "visibility": "visible", + "text-letter-spacing": 0.01 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#273a4a", + "text-halo-blur": 1, + "text-halo-color": "#f8f6ef", + "text-halo-width": 1.5 + }, + "minzoom": 3.5 + }, + { + "id": "place_city_large", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "all", + [ + "!=", + "capital", + 2 + ], + [ + "<=", + "rank", + 3 + ], + [ + "==", + "class", + "city" + ] + ] + ], + "layout": { + "icon-image": { + "base": 1, + "stops": [ + [ + 0, + "circle-11" + ], + [ + 8, + "" + ] + ] + }, + "icon-size": 0.4, + "text-anchor": { + "base": 1, + "stops": [ + [ + 0, + "left" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-justify": "left", + "text-offset": [ + 0.5, + 0.2 + ], + "text-size": 14.5, + "visibility": "visible", + "text-letter-spacing": 0.01 + }, + "paint": { + "icon-opacity": 0.7, + "text-color": "#2e4051", + "text-halo-blur": 1, + "text-halo-color": "#f8f6ef", + "text-halo-width": 1.5 + }, + "minzoom": 4.2 + }, + { + "id": "place_state", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "state" + ] + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-size": 10.8, + "text-transform": "uppercase", + "visibility": "visible", + "text-letter-spacing": 0.03 + }, + "paint": { + "text-color": "#526273", + "text-halo-blur": 1, + "text-halo-color": "#f8f6ef", + "text-halo-width": 1.5 + }, + "minzoom": 3.2 + }, + { + "id": "place_country_other", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "country" + ], + [ + "!has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Italic" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 0, + 9 + ], + [ + 6, + 11 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": { + "base": 1, + "stops": [ + [ + 3, + "#8092a1" + ], + [ + 4, + "#6d7d8d" + ] + ] + }, + "text-halo-color": "rgba(248,246,239,0.85)", + "text-halo-width": 1.4 + } + }, + { + "id": "place_country_minor", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "country" + ], + [ + ">=", + "rank", + 2 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 0, + 10 + ], + [ + 6, + 12 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": { + "base": 1, + "stops": [ + [ + 3, + "#7b8d9d" + ], + [ + 4, + "#67798a" + ] + ] + }, + "text-halo-color": "rgba(248,246,239,0.85)", + "text-halo-width": 1.4 + } + }, + { + "id": "place_country_major", + "type": "symbol", + "metadata": { + "mapbox:group": "101da9f13b64a08fa4b6ac1168e89e5f" + }, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 6, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "<=", + "rank", + 1 + ], + [ + "==", + "class", + "country" + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-anchor": "center", + "text-field": "{name:latin}", + "text-font": [ + "Noto Sans Regular" + ], + "text-size": { + "base": 1.4, + "stops": [ + [ + 0, + 10 + ], + [ + 3, + 12 + ], + [ + 4, + 14 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": { + "base": 1, + "stops": [ + [ + 3, + "#667a8f" + ], + [ + 4, + "#52667c" + ] + ] + }, + "text-halo-color": "rgba(248,246,239,0.9)", + "text-halo-width": 1.4 + } + } + ], + "id": "positron" +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..484c3c0 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,266 @@ +import { Suspense, lazy, type ComponentType, useEffect } from "react"; +import { Route, Switch, useLocation } from "wouter"; + +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import ErrorBoundary from "./components/ErrorBoundary"; +import DashboardLayout from "./components/DashboardLayout"; +import { ThemeProvider, useTheme } from "./contexts/ThemeContext"; +import { + getBackgroundScopeAliases, + getBackgroundScopeForPath, + isDecorationScopeActive, + resolveBackgroundBlurForScope, + resolveBackgroundImageForScope, + resolveBackgroundOverlayOpacityForScope, + resolveBackgroundPositionForScope, + resolveBackgroundPresentationModeForScope, +} from "./lib/appearanceScopes"; + +const UserOperatingTour = lazy(() => import("@/components/UserOperatingTour")); +const NotFound = lazy(() => import("@/pages/NotFound")); +const Home = lazy(() => import("@/pages/Home")); +const Dashboard = lazy(() => import("@/pages/Dashboard")); +const Profile = lazy(() => import("@/pages/Profile")); +const Documents = lazy(() => import("@/pages/Documents")); +const NewRequest = lazy(() => import("@/pages/NewRequest")); +const Requests = lazy(() => import("@/pages/Requests")); +const RequestDetail = lazy(() => import("@/pages/RequestDetail")); +const EditRequest = lazy(() => import("@/pages/EditRequest")); +const Admin = lazy(() => import("@/pages/Admin")); +const FAQ = lazy(() => import("@/pages/FAQ")); +const ReservationSalle = lazy(() => import("@/pages/ReservationSalle")); +const Login = lazy(() => import("@/pages/Login")); +const DemandeMaterielEvenementiel = lazy(() => import("@/pages/DemandeMaterielEvenementiel")); +const AssociationDirectoryPortal = lazy(() => import("@/pages/AssociationDirectoryPortal")); +const AssociationInvitation = lazy(() => import("@/pages/AssociationInvitation")); +const AssociationMapPublic = lazy(() => import("@/pages/AssociationMapPublic")); +const AssociationPublicProfile = lazy(() => import("@/pages/AssociationPublicProfile")); +const MaterialReturnUpload = lazy(() => import("@/pages/MaterialReturnUpload")); +const MaterialReturnFinalPdfView = lazy(() => import("@/pages/MaterialReturnFinalPdfView")); +const MaterialConventionDownload = lazy(() => import("@/pages/MaterialConventionDownload")); +const SalleWorkflowDocumentDownload = lazy(() => import("@/pages/SalleWorkflowDocumentDownload")); +const StatsReportArchiveView = lazy(() => import("@/pages/StatsReportArchiveView")); +const TerrainMissions = lazy(() => import("@/pages/TerrainMissions")); +const PrivacyPolicy = lazy(() => import("@/pages/PrivacyPolicy")); +const LegalNotice = lazy(() => import("@/pages/LegalNotice")); +const CookiesPolicy = lazy(() => import("@/pages/CookiesPolicy")); +const TermsOfService = lazy(() => import("@/pages/TermsOfService")); + +function PageLoader({ compact = false }: { compact?: boolean }) { + return ( +
+
+
+

Chargement de l’interface...

+
+
+ ); +} + +function withRouteSuspense(Component: ComponentType) { + return function SuspendedRoute(props: T) { + return ( + }> + + + ); + }; +} + +function withDashboardLayout(Component: ComponentType) { + return function DashboardRoute(props: T) { + return ( + + }> + + + + ); + }; +} + +const HomeRoute = withRouteSuspense(Home); +const LoginRoute = withRouteSuspense(Login); +const InvitationRoute = withRouteSuspense(AssociationInvitation); +const MaterialReturnFinalPdfRoute = withRouteSuspense(MaterialReturnFinalPdfView); +const MaterialReturnUploadRoute = withRouteSuspense(MaterialReturnUpload); +const TerrainMissionsRoute = withRouteSuspense(TerrainMissions); +const AssociationDirectoryRoute = withRouteSuspense(AssociationDirectoryPortal); +const AssociationMapRoute = withRouteSuspense(AssociationMapPublic); +const AssociationPublicProfileRoute = withRouteSuspense(AssociationPublicProfile); +const DashboardHomeRoute = withRouteSuspense(Dashboard); +const DashboardProfileRoute = withDashboardLayout(Profile); +const DashboardDocumentsRoute = withDashboardLayout(Documents); +const DashboardNewRequestRoute = withDashboardLayout(NewRequest); +const DashboardReservationSalleRoute = withDashboardLayout(ReservationSalle); +const DashboardMaterialRoute = withDashboardLayout(DemandeMaterielEvenementiel); +const DashboardDirectoryRoute = withDashboardLayout(AssociationDirectoryPortal); +const DashboardRequestsRoute = withDashboardLayout(Requests); +const DashboardRequestDetailRoute = withDashboardLayout(RequestDetail); +const DashboardEditRequestRoute = withDashboardLayout(EditRequest); +const MaterialConventionRoute = withRouteSuspense(MaterialConventionDownload); +const SalleWorkflowDocumentRoute = withRouteSuspense(SalleWorkflowDocumentDownload); +const StatsReportArchiveRoute = withRouteSuspense(StatsReportArchiveView); +const AdminRoute = withDashboardLayout(Admin); +const FAQRoute = withRouteSuspense(FAQ); +const PrivacyPolicyRoute = withRouteSuspense(PrivacyPolicy); +const LegalNoticeRoute = withRouteSuspense(LegalNotice); +const CookiesPolicyRoute = withRouteSuspense(CookiesPolicy); +const TermsOfServiceRoute = withRouteSuspense(TermsOfService); +const NotFoundRoute = withRouteSuspense(NotFound); + +function AppearanceDecorationLayer() { + const [location] = useLocation(); + const { appearance } = useTheme(); + const scope = getBackgroundScopeForPath(location); + const decorativeElements = (appearance.decorativeElements || []).filter( + (entry) => entry.imageUrl && entry.layer === "background" && isDecorationScopeActive(entry.scope, scope), + ); + + if (!appearance.backgroundEnabled || decorativeElements.length === 0) { + return null; + } + + return ( + + ); +} + +function AppearanceScopeBridge() { + const [location] = useLocation(); + const { appearance, resolvedTheme } = useTheme(); + + useEffect(() => { + const root = document.documentElement; + const scope = getBackgroundScopeForPath(location); + const resolvedBackgroundImage = resolveBackgroundImageForScope(scope, appearance); + const resolvedBackgroundOverlayOpacity = resolveBackgroundOverlayOpacityForScope(scope, appearance); + const resolvedBackgroundPosition = resolveBackgroundPositionForScope(scope, appearance); + const resolvedBackgroundBlur = resolveBackgroundBlurForScope(scope, appearance); + const resolvedBackgroundPresentationMode = resolveBackgroundPresentationModeForScope(scope, appearance); + const applies = + appearance.backgroundEnabled && + getBackgroundScopeAliases(scope).some((candidate) => + appearance.backgroundTargets.includes(candidate as typeof appearance.backgroundTargets[number]) + ); + root.style.setProperty( + "--portal-background-image", + applies && resolvedBackgroundImage ? `url("${resolvedBackgroundImage}")` : "none" + ); + root.style.setProperty( + "--portal-background-overlay", + resolvedTheme === "dark" + ? `rgba(6, 12, 18, ${resolvedBackgroundOverlayOpacity / 100})` + : `rgba(247, 250, 252, ${resolvedBackgroundOverlayOpacity / 100})` + ); + root.style.setProperty("--portal-background-position", resolvedBackgroundPosition); + root.style.setProperty("--portal-background-blur", `${resolvedBackgroundBlur}px`); + root.style.setProperty("--portal-background-presentation-mode", resolvedBackgroundPresentationMode); + root.style.setProperty( + "--portal-background-center-overlay", + resolvedBackgroundPresentationMode === "edge_frame" + ? (resolvedTheme === "dark" ? "rgba(6, 12, 18, 0.84)" : "rgba(255, 255, 255, 0.88)") + : (resolvedTheme === "dark" ? "rgba(6, 12, 18, 0.16)" : "rgba(255, 255, 255, 0.12)") + ); + root.style.setProperty( + "--portal-background-edge-strength", + resolvedBackgroundPresentationMode === "edge_frame" + ? (resolvedTheme === "dark" ? "0.98" : "0.94") + : (resolvedTheme === "dark" ? "0.28" : "0.22") + ); + root.dataset.portalBackgroundScope = scope; + root.dataset.portalBackgroundActive = applies ? "true" : "false"; + root.dataset.portalBackgroundPresentationMode = resolvedBackgroundPresentationMode; + }, [ + appearance.backgroundEnabled, + appearance.backgroundImageUrl, + appearance.backgroundMode, + appearance.backgroundOverlayOpacity, + appearance.backgroundBlurRadius, + appearance.backgroundPresentationMode, + appearance.backgroundTargetImageUrls, + appearance.backgroundTargetBlurRadii, + appearance.backgroundTargetOverlayOpacities, + appearance.backgroundTargetPositions, + appearance.backgroundTargetPresentationModes, + appearance.backgroundTargets, + location, + resolvedTheme, + ]); + + return null; +} + +function Router() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function App() { + return ( + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/client/src/_core/hooks/useAuth.ts b/client/src/_core/hooks/useAuth.ts new file mode 100644 index 0000000..806b9d1 --- /dev/null +++ b/client/src/_core/hooks/useAuth.ts @@ -0,0 +1,111 @@ +import { getLoginUrl } from "@/const"; +import { trpc } from "@/lib/trpc"; +import { TRPCClientError } from "@trpc/client"; +import { useCallback, useEffect, useMemo } from "react"; + +type UseAuthOptions = { + redirectOnUnauthenticated?: boolean; + redirectPath?: string; +}; + +export function useAuth(options?: UseAuthOptions) { + const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } = + options ?? {}; + const utils = trpc.useUtils(); + + const meQuery = trpc.auth.me.useQuery(undefined, { + retry: false, + refetchOnWindowFocus: false, + }); + const providersQuery = trpc.auth.providers.useQuery(undefined, { + retry: false, + refetchOnWindowFocus: false, + }); + + const logoutMutation = trpc.auth.logout.useMutation({ + onSuccess: () => { + utils.auth.me.setData(undefined, null); + }, + }); + + const loginMutation = trpc.auth.login.useMutation({ + onSuccess: result => { + if (!result.mfaRequired) { + utils.auth.me.setData(undefined, result.user); + } + }, + }); + + const registerMutation = trpc.auth.register.useMutation({ + onSuccess: user => { + utils.auth.me.setData(undefined, user); + }, + }); + + const logout = useCallback(async () => { + try { + await logoutMutation.mutateAsync(); + } catch (error: unknown) { + if ( + error instanceof TRPCClientError && + error.data?.code === "UNAUTHORIZED" + ) { + return; + } + throw error; + } finally { + utils.auth.me.setData(undefined, null); + await utils.auth.me.invalidate(); + } + }, [logoutMutation, utils]); + + const state = useMemo(() => { + localStorage.setItem( + "local-auth-user-info", + JSON.stringify(meQuery.data) + ); + return { + user: meQuery.data ?? null, + loading: meQuery.isLoading || logoutMutation.isPending || loginMutation.isPending || registerMutation.isPending, + error: meQuery.error ?? logoutMutation.error ?? loginMutation.error ?? registerMutation.error ?? null, + isAuthenticated: Boolean(meQuery.data), + providers: providersQuery.data ?? { google: false, facebook: false }, + }; + }, [ + meQuery.data, + meQuery.error, + meQuery.isLoading, + providersQuery.data, + logoutMutation.error, + logoutMutation.isPending, + loginMutation.error, + loginMutation.isPending, + registerMutation.error, + registerMutation.isPending, + ]); + + useEffect(() => { + if (!redirectOnUnauthenticated) return; + if (meQuery.isLoading || logoutMutation.isPending) return; + if (state.user) return; + if (typeof window === "undefined") return; + if (window.location.pathname === redirectPath) return; + + window.location.href = redirectPath + }, [ + redirectOnUnauthenticated, + redirectPath, + logoutMutation.isPending, + meQuery.isLoading, + state.user, + ]); + + return { + ...state, + refresh: () => meQuery.refetch(), + login: loginMutation.mutateAsync, + register: registerMutation.mutateAsync, + providersLoading: providersQuery.isLoading, + logout, + }; +} diff --git a/client/src/assets/ccds.png b/client/src/assets/ccds.png new file mode 100644 index 0000000..8187d4d Binary files /dev/null and b/client/src/assets/ccds.png differ diff --git a/client/src/assets/hero-ccds-illustration.svg b/client/src/assets/hero-ccds-illustration.svg new file mode 100644 index 0000000..bd51d8e --- /dev/null +++ b/client/src/assets/hero-ccds-illustration.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/components/AIChatBox.tsx b/client/src/components/AIChatBox.tsx new file mode 100644 index 0000000..1c00871 --- /dev/null +++ b/client/src/components/AIChatBox.tsx @@ -0,0 +1,335 @@ +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { Loader2, Send, User, Sparkles } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { Streamdown } from "streamdown"; + +/** + * Message type matching server-side LLM Message interface + */ +export type Message = { + role: "system" | "user" | "assistant"; + content: string; +}; + +export type AIChatBoxProps = { + /** + * Messages array to display in the chat. + * Should match the format used by invokeLLM on the server. + */ + messages: Message[]; + + /** + * Callback when user sends a message. + * Typically you'll call a tRPC mutation here to invoke the LLM. + */ + onSendMessage: (content: string) => void; + + /** + * Whether the AI is currently generating a response + */ + isLoading?: boolean; + + /** + * Placeholder text for the input field + */ + placeholder?: string; + + /** + * Custom className for the container + */ + className?: string; + + /** + * Height of the chat box (default: 600px) + */ + height?: string | number; + + /** + * Empty state message to display when no messages + */ + emptyStateMessage?: string; + + /** + * Suggested prompts to display in empty state + * Click to send directly + */ + suggestedPrompts?: string[]; +}; + +/** + * A ready-to-use AI chat box component that integrates with the LLM system. + * + * Features: + * - Matches server-side Message interface for seamless integration + * - Markdown rendering with Streamdown + * - Auto-scrolls to latest message + * - Loading states + * - Uses global theme colors from index.css + * + * @example + * ```tsx + * const ChatPage = () => { + * const [messages, setMessages] = useState([ + * { role: "system", content: "You are a helpful assistant." } + * ]); + * + * const chatMutation = trpc.ai.chat.useMutation({ + * onSuccess: (response) => { + * // Assuming your tRPC endpoint returns the AI response as a string + * setMessages(prev => [...prev, { + * role: "assistant", + * content: response + * }]); + * }, + * onError: (error) => { + * console.error("Chat error:", error); + * // Optionally show error message to user + * } + * }); + * + * const handleSend = (content: string) => { + * const newMessages = [...messages, { role: "user", content }]; + * setMessages(newMessages); + * chatMutation.mutate({ messages: newMessages }); + * }; + * + * return ( + * + * ); + * }; + * ``` + */ +export function AIChatBox({ + messages, + onSendMessage, + isLoading = false, + placeholder = "Type your message...", + className, + height = "600px", + emptyStateMessage = "Start a conversation with AI", + suggestedPrompts, +}: AIChatBoxProps) { + const [input, setInput] = useState(""); + const scrollAreaRef = useRef(null); + const containerRef = useRef(null); + const inputAreaRef = useRef(null); + const textareaRef = useRef(null); + + // Filter out system messages + const displayMessages = messages.filter((msg) => msg.role !== "system"); + + // Calculate min-height for last assistant message to push user message to top + const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0); + + useEffect(() => { + if (containerRef.current && inputAreaRef.current) { + const containerHeight = containerRef.current.offsetHeight; + const inputHeight = inputAreaRef.current.offsetHeight; + const scrollAreaHeight = containerHeight - inputHeight; + + // Reserve space for: + // - padding (p-4 = 32px top+bottom) + // - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px + // Note: margin-bottom is not counted because it naturally pushes the assistant message down + const userMessageReservedHeight = 56; + const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight; + + setMinHeightForLastMessage(Math.max(0, calculatedHeight)); + } + }, []); + + // Scroll to bottom helper function with smooth animation + const scrollToBottom = () => { + const viewport = scrollAreaRef.current?.querySelector( + '[data-radix-scroll-area-viewport]' + ) as HTMLDivElement; + + if (viewport) { + requestAnimationFrame(() => { + viewport.scrollTo({ + top: viewport.scrollHeight, + behavior: 'smooth' + }); + }); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedInput = input.trim(); + if (!trimmedInput || isLoading) return; + + onSendMessage(trimmedInput); + setInput(""); + + // Scroll immediately after sending + scrollToBottom(); + + // Keep focus on input + textareaRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + return ( +
+ {/* Messages Area */} +
+ {displayMessages.length === 0 ? ( +
+
+
+ +

{emptyStateMessage}

+
+ + {suggestedPrompts && suggestedPrompts.length > 0 && ( +
+ {suggestedPrompts.map((prompt, index) => ( + + ))} +
+ )} +
+
+ ) : ( + +
+ {displayMessages.map((message, index) => { + // Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it) + const isLastMessage = index === displayMessages.length - 1; + const shouldApplyMinHeight = + isLastMessage && !isLoading && minHeightForLastMessage > 0; + + return ( +
+ {message.role === "assistant" && ( +
+ +
+ )} + +
+ {message.role === "assistant" ? ( +
+ {message.content} +
+ ) : ( +

+ {message.content} +

+ )} +
+ + {message.role === "user" && ( +
+ +
+ )} +
+ ); + })} + + {isLoading && ( +
0 + ? { minHeight: `${minHeightForLastMessage}px` } + : undefined + } + > +
+ +
+
+ +
+
+ )} +
+
+ )} +
+ + {/* Input Area */} +
+ +
+ +
+ 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, '''); +} diff --git a/server/emailRecap.ts b/server/emailRecap.ts new file mode 100644 index 0000000..532d025 --- /dev/null +++ b/server/emailRecap.ts @@ -0,0 +1,343 @@ +/** + * Génère un récapitulatif complet d'une demande traitée pour diffusion interne. + */ + +const requestTypeLabels: Record = { + subvention_fonctionnement: "Subvention de fonctionnement", + subvention_projet: "Subvention de projet", + agrement_jeunesse_education: "Agrément Jeunesse et Éducation Populaire", + agrement_sport: "Agrément Sport", + autorisation_occupation: "Autorisation d'occupation", + demande_salle: "Demande de salle / local", + demande_materiel_evenementiel: "Demande de matériel événementiel", + autre: "Autre demande", +}; + +const statusLabels: Record = { + validee: "Validée", + refusee: "Refusée", + en_cours_traitement: "En cours de traitement", + information_complementaire: "Information complémentaire demandée", +}; + +const statusBadgeStyles: Record = { + validee: { label: "VALIDÉE", bg: "#ecfdf3", border: "#a7f3d0", text: "#166534" }, + refusee: { label: "REFUSÉE", bg: "#fef2f2", border: "#fecaca", text: "#b91c1c" }, + en_cours_traitement: { label: "EN COURS", bg: "#eff6ff", border: "#bfdbfe", text: "#1d4ed8" }, + information_complementaire: { label: "INFO REQUISE", bg: "#fff7ed", border: "#fed7aa", text: "#c2410c" }, +}; + +interface RecapData { + request: { + id: number; + titre: string; + type: string; + status: string; + description: string | null; + montantDemande: number | null; + montantAccorde: number | null; + commentaireAdmin: string | null; + formData: string | null; + dateSubmission: Date | null; + dateTraitement: Date | null; + createdAt: Date | null; + }; + association: { + nomAssociation: string; + siret: string | null; + adresse: string | null; + codePostal: string | null; + ville: string | null; + telephone: string | null; + emailContact: string | null; + nomRepresentant: string | null; + } | null; + traitePar: string; + documents?: { name: string; type: string }[]; + serviceLabel?: string | null; +} + +function formatDateTimeFr(date: Date | string | null | undefined) { + if (!date) return "Non renseignée"; + return new Date(date).toLocaleString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatDateFr(date: string | null | undefined) { + if (!date) return "Non renseignée"; + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +function formatAmount(amount: number | null | undefined) { + if (amount == null) return "Non renseigné"; + return `${(amount / 100).toLocaleString("fr-FR")} €`; +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function parseRequestHighlights(requestType: string, formData: any): Array<{ label: string; value: string }> { + if (!formData || typeof formData !== "object") return []; + + if (requestType === "demande_salle") { + const salles = Array.isArray(formData.sallesNoms) + ? formData.sallesNoms.join(", ") + : Array.isArray(formData.sallesSelectionnees) + ? formData.sallesSelectionnees.join(", ") + : ""; + + const highlights = [ + salles ? { label: "Salle(s)", value: salles } : null, + formData.dateReservation ? { label: "Date de début", value: formatDateFr(formData.dateReservation) } : null, + formData.dateFinReservation ? { label: "Date de fin", value: formatDateFr(formData.dateFinReservation) } : null, + formData.heureDebut || formData.heureFin + ? { label: "Créneau", value: `${formData.heureDebut || "?"} - ${formData.heureFin || "?"}` } + : null, + formData.nombreParticipants ? { label: "Participants", value: String(formData.nombreParticipants) } : null, + formData.motifReservation ? { label: "Motif", value: String(formData.motifReservation) } : null, + ]; + return highlights.filter(Boolean) as Array<{ label: string; value: string }>; + } + + if (requestType === "demande_materiel_evenementiel") { + const highlights = [ + formData.commune ? { label: "Commune", value: String(formData.commune) } : null, + formData.dateDebutManifestation || formData.dateManifestation + ? { label: "Début manifestation", value: formatDateFr(formData.dateDebutManifestation || formData.dateManifestation) } + : null, + formData.dateFinManifestation + ? { label: "Fin manifestation", value: formatDateFr(formData.dateFinManifestation) } + : null, + formData.dateRestitution ? { label: "Restitution", value: formatDateFr(formData.dateRestitution) } : null, + formData.motifDemande ? { label: "Motif", value: String(formData.motifDemande) } : null, + ]; + return highlights.filter(Boolean) as Array<{ label: string; value: string }>; + } + + return [ + formData.dateDebut ? { label: "Date de début", value: formatDateFr(formData.dateDebut) } : null, + formData.dateFin ? { label: "Date de fin", value: formatDateFr(formData.dateFin) } : null, + formData.objetProjet ? { label: "Objet", value: String(formData.objetProjet) } : null, + ].filter(Boolean) as Array<{ label: string; value: string }>; +} + +export function generateRequestRecapEmail(data: RecapData): { title: string; content: string; html: string } { + const { request, association, traitePar, documents, serviceLabel } = data; + const typeLabel = requestTypeLabels[request.type] || request.type; + const statusLabel = statusLabels[request.status] || request.status; + const badge = statusBadgeStyles[request.status] || { + label: statusLabel.toUpperCase(), + bg: "#f3f4f6", + border: "#d1d5db", + text: "#374151", + }; + + let formData: any = {}; + try { + formData = request.formData ? JSON.parse(request.formData) : {}; + } catch { + formData = {}; + } + + const highlights = parseRequestHighlights(request.type, formData); + const subject = `Demande traitée #${request.id} - ${statusLabel} - ${request.titre}`; + + const textSections: string[] = [ + "Récapitulatif de demande traitée", + "", + `Demande n°${request.id} - ${statusLabel}`, + `Type : ${typeLabel}`, + `Association : ${association?.nomAssociation || "Non renseignée"}`, + `Traitée par : ${traitePar}`, + `Date de traitement : ${formatDateTimeFr(request.dateTraitement)}`, + serviceLabel ? `Service concerné : ${serviceLabel}` : "", + request.commentaireAdmin ? `Commentaire : ${request.commentaireAdmin}` : "", + request.montantAccorde != null ? `Montant accordé : ${formatAmount(request.montantAccorde)}` : "", + "", + "Informations de l'association", + `- Nom : ${association?.nomAssociation || "Non renseigné"}`, + association?.siret ? `- SIRET : ${association.siret}` : "", + association?.adresse ? `- Adresse : ${association.adresse}${association.codePostal ? `, ${association.codePostal}` : ""}${association.ville ? ` ${association.ville}` : ""}` : "", + association?.nomRepresentant ? `- Représentant : ${association.nomRepresentant}` : "", + association?.telephone ? `- Téléphone : ${association.telephone}` : "", + association?.emailContact ? `- Email : ${association.emailContact}` : "", + "", + "Points clés du dossier", + ...highlights.map((item) => `- ${item.label} : ${item.value}`), + request.description ? `- Description : ${request.description}` : "", + request.montantDemande != null ? `- Montant demandé : ${formatAmount(request.montantDemande)}` : "", + ]; + + if (documents?.length) { + textSections.push("", "Documents joints au dossier"); + textSections.push(...documents.map((doc, index) => `${index + 1}. ${doc.name} (${doc.type})`)); + } + + textSections.push( + "", + "Le PDF récapitulatif est joint à cet email lorsqu'il est disponible pour ce type de demande.", + "", + "Ce message a été généré automatiquement par le Portail Associations." + ); + + const htmlHighlights = highlights + .map( + (item) => ` + + ${escapeHtml(item.label)} + ${escapeHtml(item.value)} + ` + ) + .join(""); + + const htmlDocuments = documents?.length + ? ` +
+

Documents du dossier

+
    + ${documents.map((doc) => `
  • ${escapeHtml(doc.name)} (${escapeHtml(doc.type)})
  • `).join("")} +
+
` + : ""; + + const html = ` +
+
+
+
Portail Associations
+

Transmission d'une demande traitée

+

+ Ce message reprend les informations essentielles du dossier et le PDF récapitulatif est joint lorsqu'il est disponible. +

+
+ +
+
+ ${escapeHtml(badge.label)} +
+ +

${escapeHtml(request.titre)}

+

+ Demande #${request.id} • ${escapeHtml(typeLabel)} +

+ +
+

Décision et transmission

+ + + + + + + + + + ${serviceLabel ? ` + + + + ` : ""} + ${request.montantAccorde != null ? ` + + + + ` : ""} +
Traitée par${escapeHtml(traitePar)}
Date de traitement${escapeHtml(formatDateTimeFr(request.dateTraitement))}
Service concerné${escapeHtml(serviceLabel)}
Montant accordé${escapeHtml(formatAmount(request.montantAccorde))}
+ ${request.commentaireAdmin ? ` +
+
Commentaire administratif
+
${escapeHtml(request.commentaireAdmin).replace(/\n/g, "
")}
+
` : ""} +
+ +
+

Association

+ + + + + + ${association?.siret ? ` + + + + ` : ""} + ${(association?.adresse || association?.ville || association?.codePostal) ? ` + + + + ` : ""} + ${association?.nomRepresentant ? ` + + + + ` : ""} + ${association?.telephone ? ` + + + + ` : ""} + ${association?.emailContact ? ` + + + + ` : ""} +
Nom${escapeHtml(association?.nomAssociation || "Non renseignée")}
SIRET${escapeHtml(association.siret)}
Adresse${escapeHtml( + [association?.adresse, association?.codePostal, association?.ville].filter(Boolean).join(" ") + )}
Représentant${escapeHtml(association.nomRepresentant)}
Téléphone${escapeHtml(association.telephone)}
Email${escapeHtml(association.emailContact)}
+
+ +
+

Points clés du dossier

+ + + + + + ${request.montantDemande != null ? ` + + + + ` : ""} + ${htmlHighlights} + ${request.description ? ` + + + + ` : ""} +
Date de soumission${escapeHtml(formatDateTimeFr(request.dateSubmission))}
Montant demandé${escapeHtml(formatAmount(request.montantDemande))}
Description${escapeHtml(request.description).replace(/\n/g, "
")}
+
+ + ${htmlDocuments} + +
+

+ Le PDF récapitulatif est joint à cet email lorsqu’il est disponible pour ce type de demande. Tu peux l’utiliser pour la transmission interne, l’archivage ou l’impression. +

+
+
+
+
+ `; + + return { + title: subject, + content: textSections.filter(Boolean).join("\n"), + html, + }; +} diff --git a/server/internalAccess.ts b/server/internalAccess.ts new file mode 100644 index 0000000..01f0835 --- /dev/null +++ b/server/internalAccess.ts @@ -0,0 +1,108 @@ +import type { User } from "../drizzle/schema"; +import * as db from "./db"; +import { userHasLogisticsAccess } from "./logisticsGroup"; +import { + getDelegatedSignerIdsForDirectrice, + getDirectriceIdsDelegatingToUser, +} from "./salleSignatureDelegation"; + +export type AuthenticatedPortalUser = User & { + canManageLogistics: boolean; + canSignSalle: boolean; + salleSignatureDelegatedByUserIds: number[]; +}; + +type MinimalUser = Pick; + +export async function userHasSalleSignatureAccess(user: Pick) { + if (user.role === "super_admin" || user.role === "directrice") { + return true; + } + + const [internalUsers, delegatedBySetting] = await Promise.all([ + db.getAdminUsers(), + getDirectriceIdsDelegatingToUser(user.id), + ]); + + return internalUsers.some((entry) => { + if (entry.role !== "directrice" || !entry.isActive) return false; + return entry.delegatedSalleSignerUserId === user.id || delegatedBySetting.includes(entry.id); + }); +} + +export async function getSalleSignatureDelegatedByUserIds(userId: number) { + const [internalUsers, delegatedBySetting] = await Promise.all([ + db.getAdminUsers(), + getDirectriceIdsDelegatingToUser(userId), + ]); + + return Array.from( + new Set( + internalUsers + .filter( + (entry) => + entry.role === "directrice" + && entry.isActive + && entry.delegatedSalleSignerUserId === userId + ) + .map((entry) => entry.id) + .concat(delegatedBySetting) + ) + ); +} + +export async function withEffectiveInternalAccess( + user: TUser +): Promise<(AuthenticatedPortalUser & NonNullable) | null> { + if (!user) return null; + + const [effectiveLogisticsAccess, effectiveSalleSignatureAccess, delegatedByUserIds] = + await Promise.all([ + userHasLogisticsAccess(user as MinimalUser), + userHasSalleSignatureAccess(user), + getSalleSignatureDelegatedByUserIds(user.id), + ]); + + return { + ...user, + canManageLogistics: effectiveLogisticsAccess, + canSignSalle: effectiveSalleSignatureAccess, + salleSignatureDelegatedByUserIds: delegatedByUserIds, + } as AuthenticatedPortalUser & NonNullable; +} + +export async function getSalleSignatureDelegateCandidates(currentUserId: number) { + const internalUsers = await db.getAdminUsers(); + return internalUsers + .filter( + (entry) => + entry.isActive + && entry.id !== currentUserId + && ( + entry.role === "accueil" + || entry.role === "admin" + || entry.role === "directrice" + || entry.role === "super_admin" + ) + ) + .map((entry) => ({ + id: entry.id, + name: entry.name || "Sans nom", + email: entry.email || "", + role: entry.role, + })); +} + +export async function getSalleSignatureDelegates(directriceUserId: number) { + const delegateIds = await getDelegatedSignerIdsForDirectrice(directriceUserId); + const delegates = await Promise.all(delegateIds.map((id) => db.getUserById(id))); + + return delegates + .filter((entry): entry is NonNullable => Boolean(entry?.isActive)) + .map((entry) => ({ + id: entry.id, + name: entry.name || "Sans nom", + email: entry.email || "", + role: entry.role, + })); +} diff --git a/server/logisticsGroup.ts b/server/logisticsGroup.ts new file mode 100644 index 0000000..477731b --- /dev/null +++ b/server/logisticsGroup.ts @@ -0,0 +1,82 @@ +import type { User } from "../drizzle/schema"; +import * as db from "./db"; + +export const LOGISTICS_GROUP_SETTING_KEY = "system.logistics.group"; + +export type LogisticsGroupSettings = { + label: string; + memberUserIds: number[]; + updatedAt: string | null; +}; + +const defaultLogisticsGroup: LogisticsGroupSettings = { + label: "Groupe interne matériel CCDS - logistique et contrôle", + memberUserIds: [], + updatedAt: null, +}; + +function sanitizeMemberUserIds(value: unknown) { + if (!Array.isArray(value)) return []; + return Array.from( + new Set( + value + .map((entry) => Number(entry)) + .filter((entry) => Number.isInteger(entry) && entry > 0) + ) + ); +} + +export async function getLogisticsGroupSettings(): Promise { + const storedValue = await db.getPortalSetting(LOGISTICS_GROUP_SETTING_KEY); + if (!storedValue) return defaultLogisticsGroup; + + try { + const parsed = JSON.parse(storedValue) as Partial; + return { + label: typeof parsed.label === "string" && parsed.label.trim() + ? parsed.label.trim() + : defaultLogisticsGroup.label, + memberUserIds: sanitizeMemberUserIds(parsed.memberUserIds), + updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null, + }; + } catch { + return defaultLogisticsGroup; + } +} + +export async function saveLogisticsGroupSettings(input: { + label?: string; + memberUserIds?: number[]; +}) { + const nextValue: LogisticsGroupSettings = { + label: input.label?.trim() || defaultLogisticsGroup.label, + memberUserIds: sanitizeMemberUserIds(input.memberUserIds), + updatedAt: new Date().toISOString(), + }; + + await db.setPortalSetting( + LOGISTICS_GROUP_SETTING_KEY, + JSON.stringify(nextValue), + "Groupe interne à accès limité autorisé à traiter les demandes de matériel CCDS, piloter les restitutions et contrôler les retours" + ); + + return nextValue; +} + +export async function userHasLogisticsAccess(user: Pick) { + if (user.role === "super_admin" || user.canManageLogistics) { + return true; + } + + const group = await getLogisticsGroupSettings(); + return group.memberUserIds.includes(user.id); +} + +export async function withEffectiveLogisticsAccess(user: TUser): Promise { + if (!user) return user; + const effectiveAccess = await userHasLogisticsAccess(user); + return { + ...user, + canManageLogistics: effectiveAccess, + } as TUser; +} diff --git a/server/logisticsSettings.ts b/server/logisticsSettings.ts new file mode 100644 index 0000000..594b492 --- /dev/null +++ b/server/logisticsSettings.ts @@ -0,0 +1,161 @@ +import * as db from "./db"; +import { + materialEventInventory, + materialEventItems, + materialEventReplacementValues, + type MaterialEventItemKey, +} from "@shared/materialEvent"; + +export const LOGISTICS_SETTINGS_KEY = "system.logistics.settings"; + +export type LogisticsSettings = { + materialReturnLeadDays: number; + materialReturnGraceDays: number; + inventory: Record; + replacementValues: Record; + updatedAt: string | null; +}; + +export const defaultLogisticsSettings: Omit = { + materialReturnLeadDays: 4, + materialReturnGraceDays: 3, + inventory: { + ...materialEventInventory, + }, + replacementValues: { + ...materialEventReplacementValues, + }, +}; + +function clampWholeNumber(value: unknown, fallback: number, min: number, max: number) { + const parsed = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function sanitizeInventory(rawInventory: unknown) { + const nextInventory = { ...defaultLogisticsSettings.inventory } as Record; + + if (!rawInventory || typeof rawInventory !== "object") { + return nextInventory; + } + + for (const item of materialEventItems) { + const rawValue = (rawInventory as Record)[item.key]; + if (rawValue === null || rawValue === "") { + nextInventory[item.key] = null; + continue; + } + + const parsed = Number.parseInt(String(rawValue ?? ""), 10); + if (Number.isFinite(parsed) && parsed >= 0) { + nextInventory[item.key] = parsed; + } + } + + return nextInventory; +} + +function sanitizeReplacementValues(rawReplacementValues: unknown) { + const nextReplacementValues = { + ...defaultLogisticsSettings.replacementValues, + } as Record; + + if (!rawReplacementValues || typeof rawReplacementValues !== "object") { + return nextReplacementValues; + } + + for (const item of materialEventItems) { + const rawValue = (rawReplacementValues as Record)[item.key]; + if (rawValue === null || rawValue === "") { + nextReplacementValues[item.key] = null; + continue; + } + + const parsed = Number.parseInt(String(rawValue ?? ""), 10); + if (Number.isFinite(parsed) && parsed >= 0) { + nextReplacementValues[item.key] = parsed; + } + } + + return nextReplacementValues; +} + +function sanitizeLogisticsSettings(rawValue: unknown, updatedAt: string | null): LogisticsSettings { + const source = rawValue && typeof rawValue === "object" ? rawValue as Record : {}; + + return { + materialReturnLeadDays: clampWholeNumber( + source.materialReturnLeadDays, + defaultLogisticsSettings.materialReturnLeadDays, + 1, + 30 + ), + materialReturnGraceDays: clampWholeNumber( + source.materialReturnGraceDays, + defaultLogisticsSettings.materialReturnGraceDays, + 0, + 30 + ), + inventory: sanitizeInventory(source.inventory), + replacementValues: sanitizeReplacementValues(source.replacementValues), + updatedAt, + }; +} + +export async function getLogisticsSettings(): Promise { + const record = await db.getPortalSettingRecord(LOGISTICS_SETTINGS_KEY); + if (!record?.valeur) { + return { + ...defaultLogisticsSettings, + updatedAt: null, + }; + } + + try { + const parsed = JSON.parse(record.valeur); + return sanitizeLogisticsSettings(parsed, record.updatedAt?.toISOString?.() || null); + } catch { + return { + ...defaultLogisticsSettings, + updatedAt: record.updatedAt?.toISOString?.() || null, + }; + } +} + +export async function saveLogisticsSettings(input: { + materialReturnLeadDays?: number; + materialReturnGraceDays?: number; + inventory?: Partial>; + replacementValues?: Partial>; +}) { + const current = await getLogisticsSettings(); + const next = sanitizeLogisticsSettings( + { + materialReturnLeadDays: input.materialReturnLeadDays ?? current.materialReturnLeadDays, + materialReturnGraceDays: input.materialReturnGraceDays ?? current.materialReturnGraceDays, + inventory: { + ...current.inventory, + ...(input.inventory || {}), + }, + replacementValues: { + ...current.replacementValues, + ...(input.replacementValues || {}), + }, + }, + new Date().toISOString() + ); + + await db.setPortalSetting( + LOGISTICS_SETTINGS_KEY, + JSON.stringify({ + materialReturnLeadDays: next.materialReturnLeadDays, + materialReturnGraceDays: next.materialReturnGraceDays, + inventory: next.inventory, + replacementValues: next.replacementValues, + }), + "Réglages logistiques globaux du portail" + ); + + return getLogisticsSettings(); +} diff --git a/server/mailSettings.ts b/server/mailSettings.ts new file mode 100644 index 0000000..56055d8 --- /dev/null +++ b/server/mailSettings.ts @@ -0,0 +1,256 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import * as db from "./db"; +import { ENV } from "./_core/env"; +import { providerDefaults, resolveMailConfig, type MailEnvInput, type SupportedMailProvider } from "@shared/mailProviders"; + +export const MAIL_SETTINGS_KEY = "system.mail.settings"; + +type StoredMailSettings = { + smtpProvider: SupportedMailProvider; + smtpHost?: string; + smtpPort?: string; + smtpUser?: string; + smtpFrom?: string; + smtpSecure?: boolean; + smtpRequireTls?: boolean; + smtpPassEncrypted?: string; + updatedByUserId?: number; + updatedByUserName?: string | null; +}; + +export type MailSettingsFormInput = { + smtpProvider: SupportedMailProvider; + smtpHost?: string; + smtpPort?: string; + smtpUser?: string; + smtpFrom?: string; + smtpSecure?: boolean; + smtpRequireTls?: boolean; + smtpPass?: string; +}; + +export type AdminMailSettings = { + smtpProvider: SupportedMailProvider; + smtpHost: string; + smtpPort: string; + smtpUser: string; + smtpFrom: string; + smtpSecure: boolean; + smtpRequireTls: boolean; + smtpPassConfigured: boolean; + ready: boolean; + missing: string[]; + source: "environment" | "admin" | "admin+environment"; + hasSavedConfig: boolean; + updatedAt: string | null; + updatedByUserId: number | null; + updatedByUserName: string | null; +}; + +function buildCipherKey() { + return createHash("sha256") + .update(ENV.cookieSecret || "local-dev-mail-settings") + .digest(); +} + +function encryptSecret(value: string) { + const iv = randomBytes(12); + const key = buildCipherKey(); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString("base64")}.${tag.toString("base64")}.${encrypted.toString("base64")}`; +} + +function decryptSecret(value?: string) { + if (!value) return ""; + + const [ivBase64, tagBase64, encryptedBase64] = value.split("."); + if (!ivBase64 || !tagBase64 || !encryptedBase64) { + return ""; + } + + try { + const key = buildCipherKey(); + const decipher = createDecipheriv( + "aes-256-gcm", + key, + Buffer.from(ivBase64, "base64") + ); + decipher.setAuthTag(Buffer.from(tagBase64, "base64")); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(encryptedBase64, "base64")), + decipher.final(), + ]); + return decrypted.toString("utf8"); + } catch { + return ""; + } +} + +function trimOrEmpty(value?: string | null) { + return String(value || "").trim(); +} + +function normalizeStoredSettings(input: Partial | null | undefined): StoredMailSettings | null { + if (!input?.smtpProvider) return null; + + return { + smtpProvider: input.smtpProvider, + smtpHost: trimOrEmpty(input.smtpHost), + smtpPort: trimOrEmpty(input.smtpPort), + smtpUser: trimOrEmpty(input.smtpUser), + smtpFrom: trimOrEmpty(input.smtpFrom), + smtpSecure: Boolean(input.smtpSecure), + smtpRequireTls: Boolean(input.smtpRequireTls), + smtpPassEncrypted: trimOrEmpty(input.smtpPassEncrypted), + updatedByUserId: input.updatedByUserId ? Number(input.updatedByUserId) : undefined, + updatedByUserName: input.updatedByUserName?.trim() || null, + }; +} + +async function getStoredMailSettingsRecord() { + const setting = await db.getPortalSettingRecord(MAIL_SETTINGS_KEY); + if (!setting?.valeur) { + return { setting: null, payload: null as StoredMailSettings | null }; + } + + try { + const parsed = JSON.parse(setting.valeur); + return { + setting, + payload: normalizeStoredSettings(parsed), + }; + } catch { + return { + setting, + payload: null, + }; + } +} + +function mergeMailInput(stored: StoredMailSettings | null) { + const decryptedPassword = decryptSecret(stored?.smtpPassEncrypted); + const sourceEntries: Array = [ + "smtpProvider", + "smtpHost", + "smtpPort", + "smtpUser", + "smtpPass", + "smtpFrom", + "smtpSecure", + "smtpRequireTls", + ]; + + const storedInput: MailEnvInput = { + smtpProvider: stored?.smtpProvider, + smtpHost: stored?.smtpHost, + smtpPort: stored?.smtpPort, + smtpUser: stored?.smtpUser, + smtpPass: decryptedPassword, + smtpFrom: stored?.smtpFrom, + smtpSecure: stored ? String(Boolean(stored.smtpSecure)) : "", + smtpRequireTls: stored ? String(Boolean(stored.smtpRequireTls)) : "", + }; + + const envInput: MailEnvInput = { + smtpProvider: ENV.smtpProvider, + smtpHost: ENV.smtpHost, + smtpPort: ENV.smtpPort, + smtpUser: ENV.smtpUser, + smtpPass: ENV.smtpPass, + smtpFrom: ENV.smtpFrom, + smtpSecure: ENV.smtpSecure, + smtpRequireTls: ENV.smtpRequireTls, + }; + + const merged: MailEnvInput = {}; + let usesEnvironmentFallback = false; + for (const key of sourceEntries) { + const storedValue = trimOrEmpty(storedInput[key]); + const envValue = trimOrEmpty(envInput[key]); + if (storedValue) { + merged[key] = storedInput[key]; + } else if (envValue) { + merged[key] = envInput[key]; + if (stored) { + usesEnvironmentFallback = true; + } + } + } + + const source: AdminMailSettings["source"] = stored + ? usesEnvironmentFallback + ? "admin+environment" + : "admin" + : "environment"; + + return { merged, source }; +} + +export async function getResolvedMailConfiguration() { + const { setting, payload } = await getStoredMailSettingsRecord(); + const { merged, source } = mergeMailInput(payload); + return { + config: resolveMailConfig(merged), + source, + stored: payload, + updatedAt: setting?.updatedAt?.toISOString?.() ?? null, + }; +} + +export async function getAdminMailSettings(): Promise { + const { config, source, stored, updatedAt } = await getResolvedMailConfiguration(); + const defaults = stored?.smtpProvider && stored.smtpProvider !== "custom" + ? providerDefaults[stored.smtpProvider] + : null; + + return { + smtpProvider: config.provider, + smtpHost: stored?.smtpHost || config.host || defaults?.host || "", + smtpPort: stored?.smtpPort || (config.port ? String(config.port) : defaults?.port ? String(defaults.port) : ""), + smtpUser: stored?.smtpUser || config.user || "", + smtpFrom: stored?.smtpFrom || config.from || "", + smtpSecure: stored ? Boolean(stored.smtpSecure) : config.secure, + smtpRequireTls: stored ? Boolean(stored.smtpRequireTls) : config.requireTLS, + smtpPassConfigured: Boolean(stored?.smtpPassEncrypted || ENV.smtpPass), + ready: config.ready, + missing: config.missing, + source, + hasSavedConfig: Boolean(stored), + updatedAt, + updatedByUserId: stored?.updatedByUserId ?? null, + updatedByUserName: stored?.updatedByUserName ?? null, + }; +} + +export async function saveAdminMailSettings( + input: MailSettingsFormInput, + actor?: { id: number; name?: string | null } +) { + const existing = await getStoredMailSettingsRecord(); + const nextPassword = trimOrEmpty(input.smtpPass) + ? encryptSecret(trimOrEmpty(input.smtpPass)) + : existing.payload?.smtpPassEncrypted || ""; + + const payload: StoredMailSettings = { + smtpProvider: input.smtpProvider, + smtpHost: trimOrEmpty(input.smtpHost), + smtpPort: trimOrEmpty(input.smtpPort), + smtpUser: trimOrEmpty(input.smtpUser), + smtpFrom: trimOrEmpty(input.smtpFrom), + smtpSecure: Boolean(input.smtpSecure), + smtpRequireTls: Boolean(input.smtpRequireTls), + smtpPassEncrypted: nextPassword, + updatedByUserId: actor?.id, + updatedByUserName: actor?.name?.trim() || null, + }; + + await db.setPortalSetting( + MAIL_SETTINGS_KEY, + JSON.stringify(payload), + "Configuration SMTP et messagerie du portail" + ); + + return getAdminMailSettings(); +} diff --git a/server/mailer.ts b/server/mailer.ts new file mode 100644 index 0000000..2dd9344 --- /dev/null +++ b/server/mailer.ts @@ -0,0 +1,58 @@ +import nodemailer from "nodemailer"; +import { getResolvedMailConfiguration } from "./mailSettings"; + +type SendMailInput = { + to: string[]; + subject: string; + text: string; + html?: string; + replyTo?: string; + fromName?: string; + attachments?: Array<{ + filename: string; + content: Buffer; + contentType?: string; + }>; +}; + +export async function canSendOperationalEmails() { + const { config } = await getResolvedMailConfiguration(); + return config.ready; +} + +export async function sendOperationalEmail(input: SendMailInput): Promise<{ sent: boolean; reason?: string }> { + const { config } = await getResolvedMailConfiguration(); + if (!config.ready) { + return { + sent: false, + reason: config.missing.length > 0 + ? `Configuration SMTP incomplète: ${config.missing.join(", ")}` + : "SMTP non configuré", + }; + } + + const transporter = nodemailer.createTransport({ + host: config.host, + port: Number(config.port), + secure: config.secure, + requireTLS: config.requireTLS, + auth: config.user && config.pass + ? { + user: config.user, + pass: config.pass, + } + : undefined, + }); + + await transporter.sendMail({ + from: input.fromName ? `${input.fromName} <${config.from}>` : config.from, + to: input.to.join(", "), + replyTo: input.replyTo, + subject: input.subject, + text: input.text, + html: input.html, + attachments: input.attachments, + }); + + return { sent: true }; +} diff --git a/server/materialConventionPdf.ts b/server/materialConventionPdf.ts new file mode 100644 index 0000000..37df03c --- /dev/null +++ b/server/materialConventionPdf.ts @@ -0,0 +1,288 @@ +import PDFDocument from "pdfkit"; +import fs from "node:fs"; +import path from "node:path"; + +const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); + +export type MaterialContractFinancialMode = "gratuite" | "gratuite_avec_caution" | "location_payante"; +export type MaterialContractStatus = "a_generer" | "generee" | "signee" | "refusee" | "annulee"; + +type ContractRequestLike = { + id: number; + titre: string; + status: string; + formData?: string | null; +}; + +type ContractAssociationLike = { + nomAssociation?: string | null; + adresse?: string | null; + codePostal?: string | null; + ville?: string | null; + telephone?: string | null; + emailContact?: string | null; + nomRepresentant?: string | null; +} | null | undefined; + +type ContractDecision = { + financialMode: MaterialContractFinancialMode; + depositRequired: boolean; + depositAmountCents: number; + rentalAmountCents: number; + pricingNotes?: string | null; + contractStatus: MaterialContractStatus; + contractGeneratedAt?: string | Date | null; + contractValidatedByUserId?: number | null; +}; + +function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { + if (!fs.existsSync(CCDS_LOGO_PATH)) return; + try { + doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" }); + } catch (error) { + console.warn("[MaterialConventionPdf] Impossible de charger le logo CCDS:", error); + } +} + +function formatDateFr(date: string | Date | null | undefined) { + if (!date) return "-"; + try { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return String(date); + } +} + +function formatCurrency(cents: number | null | undefined) { + return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format((cents || 0) / 100); +} + +function getFinancialModeLabel(mode: MaterialContractFinancialMode) { + switch (mode) { + case "gratuite": + return "Mise à disposition gratuite"; + case "gratuite_avec_caution": + return "Mise à disposition gratuite avec caution"; + case "location_payante": + return "Location payante"; + default: + return mode; + } +} + +export async function generateMaterialConventionPdf(input: { + request: ContractRequestLike; + association?: ContractAssociationLike; + decision: ContractDecision; +}) { + const formData = (() => { + try { + return input.request.formData ? JSON.parse(input.request.formData) : {}; + } catch { + return {}; + } + })(); + + const requestedRows = Object.entries(formData.cadreDSU?.materielEvent?.itemsAccordes || {}) + .filter(([, granted]) => Boolean(granted)) + .map(([key]) => ({ + key, + label: + key === "tente3x3" + ? "Tente 3x3" + : key === "chapiteau5x5" + ? "Chapiteau 5x5" + : key === "podium" + ? "Podium" + : "Autres", + quantity: formData.cadreDSU?.materielEvent?.quantitesAccordees?.[key] || "-", + extra: key === "autres" ? formData.cadreDSU?.materielEvent?.autresPrecisions || "" : "", + })); + + const manifestationStart = formData.dateDebutManifestation || formData.dateManifestation; + const manifestationEnd = formData.dateFinManifestation || formData.dateManifestation; + + const pdfBuffer = await new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: "A4", + margins: { top: 38, bottom: 42, left: 42, right: 42 }, + info: { + Title: `Convention matériel CCDS - dossier ${input.request.id}`, + Author: "Communauté de Communes Des Savanes", + Subject: "Convention de mise à disposition / location du matériel événementiel", + }, + }); + + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + + doc.lineWidth(2).strokeColor("#efb100").moveTo(doc.page.margins.left, y).lineTo(doc.page.margins.left + pageWidth, y).stroke(); + y += 16; + drawLogo(doc, doc.page.margins.left + (pageWidth - 84) / 2, y, 84, 84); + y += 92; + + doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 18; + doc.font("Helvetica-Bold").fontSize(17).fillColor("#0f172a").text( + "CONVENTION DE MISE À DISPOSITION / LOCATION", + doc.page.margins.left, + y, + { width: pageWidth, align: "center" } + ); + y += 20; + doc.font("Helvetica-Bold").fontSize(14).text("DU MATÉRIEL ÉVÉNEMENTIEL DE LA CCDS", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 28; + + doc.roundedRect(doc.page.margins.left, y, pageWidth, 42, 8).fillAndStroke("#eef5ff", "#bfd2ef"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f2d63").text( + "Document généré à partir de la décision administrative et de l'annexe de référence CCDS", + doc.page.margins.left + 14, + y + 9, + { width: pageWidth - 28, align: "center" } + ); + doc.font("Helvetica").fontSize(9).fillColor("#334155").text( + `Dossier #${input.request.id} — statut contrat : ${input.decision.contractStatus}`, + doc.page.margins.left + 14, + y + 23, + { width: pageWidth - 28, align: "center" } + ); + y += 56; + + const infoRows: Array<[string, string]> = [ + ["Association", input.association?.nomAssociation || formData.nomAssociation || "-"], + ["Représentant", input.association?.nomRepresentant || formData.demandeurNomPrenom || "-"], + ["Adresse", [input.association?.adresse, input.association?.codePostal, input.association?.ville].filter(Boolean).join(" ") || "-"], + ["Téléphone", input.association?.telephone || formData.telephoneAssociation || "-"], + ["Email", input.association?.emailContact || formData.emailAssociation || "-"], + ["Commune / manifestation", formData.commune || "-"], + [ + "Période d'utilisation", + manifestationStart || manifestationEnd + ? `${formatDateFr(manifestationStart)}${manifestationEnd ? ` au ${formatDateFr(manifestationEnd)}` : ""}` + : "-", + ], + ["Prise en charge", formatDateFr(formData.datePriseEnCharge)], + ["Restitution", formatDateFr(formData.dateRestitution)], + ["Objet", formData.motifDemande || input.request.titre || "-"], + ]; + + for (const [label, value] of infoRows) { + doc.font("Helvetica-Bold").fontSize(9).fillColor("#334155").text(`${label} :`, doc.page.margins.left, y, { width: 140 }); + doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(value, doc.page.margins.left + 145, y - 1, { + width: pageWidth - 145, + }); + y += 18; + } + + y += 6; + doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel accordé", doc.page.margins.left, y); + y += 16; + + const cols = [pageWidth * 0.56, pageWidth * 0.14, pageWidth * 0.30]; + let x = doc.page.margins.left; + ["Équipement", "Qté", "Observation"].forEach((header, index) => { + doc.rect(x, y, cols[index], 22).fillAndStroke("#dce8f8", "#bfd2ef"); + doc.font("Helvetica-Bold").fontSize(9).fillColor("#0f2d63").text(header, x + 6, y + 7, { + width: cols[index] - 12, + align: index === 0 ? "left" : "center", + }); + x += cols[index]; + }); + y += 22; + + const rows = requestedRows.length > 0 ? requestedRows : [{ key: "none", label: "Aucun matériel accordé", quantity: "-", extra: "" }]; + rows.forEach((row) => { + const rowHeight = row.extra ? 34 : 26; + let rowX = doc.page.margins.left; + [row.label, row.quantity, row.extra || "—"].forEach((value, index) => { + doc.rect(rowX, y, cols[index], rowHeight).stroke("#cbd5e1"); + doc.font("Helvetica").fontSize(9).fillColor("#0f172a").text(String(value), rowX + 6, y + 7, { + width: cols[index] - 12, + align: index === 0 ? "left" : "center", + }); + rowX += cols[index]; + }); + y += rowHeight; + }); + + y += 18; + doc.roundedRect(doc.page.margins.left, y, pageWidth, 108, 8).fillAndStroke("#f8fafc", "#cbd5e1"); + doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Conditions financières", doc.page.margins.left + 14, y + 12); + doc.font("Helvetica").fontSize(10).fillColor("#334155") + .text(`Régime retenu : ${getFinancialModeLabel(input.decision.financialMode)}`, doc.page.margins.left + 14, y + 34, { + width: pageWidth - 28, + }) + .text(`Montant de location : ${formatCurrency(input.decision.rentalAmountCents)}`, doc.page.margins.left + 14, y + 52, { + width: pageWidth - 28, + }) + .text( + `Caution : ${input.decision.depositRequired ? formatCurrency(input.decision.depositAmountCents) : "Aucune caution exigée"}`, + doc.page.margins.left + 14, + y + 70, + { width: pageWidth - 28 } + ); + y += 122; + + if (input.decision.pricingNotes?.trim()) { + doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Clauses / réserves spécifiques", doc.page.margins.left, y); + y += 16; + doc.roundedRect(doc.page.margins.left, y, pageWidth, 70, 8).stroke("#cbd5e1"); + doc.font("Helvetica").fontSize(10).fillColor("#334155").text(input.decision.pricingNotes.trim(), doc.page.margins.left + 12, y + 12, { + width: pageWidth - 24, + }); + y += 84; + } + + y += 6; + doc.font("Helvetica").fontSize(9).fillColor("#475569").text( + "Cette convention formalise les conditions administratives retenues par la CCDS pour la mise à disposition ou la location du matériel événementiel.", + doc.page.margins.left, + y, + { width: pageWidth } + ); + y += 34; + + const signatureWidth = (pageWidth - 20) / 2; + doc.roundedRect(doc.page.margins.left, y, signatureWidth, 90, 8).stroke("#cbd5e1"); + doc.roundedRect(doc.page.margins.left + signatureWidth + 20, y, signatureWidth, 90, 8).stroke("#cbd5e1"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a") + .text("Pour l'association", doc.page.margins.left + 12, y + 12, { width: signatureWidth - 24 }) + .text("Pour la CCDS / DSU", doc.page.margins.left + signatureWidth + 32, y + 12, { width: signatureWidth - 24 }); + doc.font("Helvetica").fontSize(9).fillColor("#64748b") + .text("Nom, qualité, signature", doc.page.margins.left + 12, y + 32, { width: signatureWidth - 24 }) + .text("Nom, qualité, signature", doc.page.margins.left + signatureWidth + 32, y + 32, { width: signatureWidth - 24 }); + + doc.font("Helvetica").fontSize(8).fillColor("#64748b").text( + `Convention générée le ${formatDateFr(input.decision.contractGeneratedAt || new Date())}.`, + doc.page.margins.left, + doc.page.height - doc.page.margins.bottom - 12, + { width: pageWidth, align: "center" } + ); + + doc.end(); + } catch (error) { + reject(error); + } + }); + + return { + buffer: pdfBuffer, + fileName: `Convention_Materiel_CCDS_${input.request.id}.pdf`, + }; +} diff --git a/server/materialReturnEmail.ts b/server/materialReturnEmail.ts new file mode 100644 index 0000000..f64e3df --- /dev/null +++ b/server/materialReturnEmail.ts @@ -0,0 +1,238 @@ +function formatDateFr(date: Date | string | null | undefined) { + if (!date) return "Non renseignée"; + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "long", + year: "numeric", + }); +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function generateMaterialReturnReminderEmail(input: { + associationName: string; + serviceLabel?: string | null; + restitutionDate: Date | string; + uploadLink: string; + requestTitle: string; + tone?: "assignment" | "scheduled" | "overdue" | "reactivation"; +}) { + const restitutionLabel = formatDateFr(input.restitutionDate); + const serviceLabel = input.serviceLabel?.trim() || "service technique / logistique"; + const tone = input.tone || "scheduled"; + const toneContent = { + assignment: { + subject: `Mission de récupération assignée - ${input.associationName} - restitution prévue le ${restitutionLabel}`, + title: "Mission de récupération assignée", + intro: `Une mission de récupération du matériel a été attribuée à ${escapeHtml(serviceLabel)} pour ${escapeHtml(input.associationName)}.`, + actionTitle: "Action à préparer", + actionBody: + "La fiche d'état des lieux préremplie CCDS est jointe à cet email. Conservez ce lien terrain : il vous permettra de constater la restitution, signer sur smartphone et clôturer le dossier le moment venu.", + accent: "#0f2d63", + alertBg: "#f5f9ff", + alertBorder: "#cfe0ff", + alertTitleColor: "#0f2d63", + }, + scheduled: { + subject: `Récupération matériel à organiser - ${input.associationName} - restitution prévue le ${restitutionLabel}`, + title: "Récupération du matériel à organiser", + intro: `La restitution du matériel pour ${escapeHtml(input.associationName)} approche.`, + actionTitle: "Action attendue", + actionBody: + "La fiche d'état des lieux préremplie CCDS est jointe à cet email. Votre service dispose d'un délai maximal de 3 jours pour effectuer la récupération, compléter la fiche contradictoire puis la déposer sur le portail.", + accent: "#0f2d63", + alertBg: "#fff8e6", + alertBorder: "#efb100", + alertTitleColor: "#7c5a00", + }, + overdue: { + subject: `Retard de restitution - action requise - ${input.associationName} - ${restitutionLabel}`, + title: "Alerte retard de restitution", + intro: `La restitution du matériel pour ${escapeHtml(input.associationName)} n'a pas encore été validée dans les délais.`, + actionTitle: "Relance automatique", + actionBody: + "Merci d'intervenir en priorité. Le délai de 3 jours après la date de restitution est dépassé. Utilisez le lien ci-dessous pour finaliser le constat terrain et débloquer la clôture du dossier.", + accent: "#b45309", + alertBg: "#fff7ed", + alertBorder: "#fdba74", + alertTitleColor: "#9a3412", + }, + reactivation: { + subject: `Nouveau lien de restitution actif - ${input.associationName} - ${restitutionLabel}`, + title: "Lien de restitution réactivé", + intro: `Un nouveau lien terrain actif a été généré pour ${escapeHtml(input.associationName)}.`, + actionTitle: "Action immédiate", + actionBody: + "Utilisez exclusivement le nouveau lien ci-dessous pour constater la restitution, signer sur smartphone et clôturer le dossier. Les anciens liens de restitution ne sont plus valides.", + accent: "#0f2d63", + alertBg: "#eef6ff", + alertBorder: "#93c5fd", + alertTitleColor: "#1d4ed8", + }, + }[tone]; + const subject = toneContent.subject; + + const text = [ + tone === "assignment" + ? "Assignation logistique - récupération du matériel événementiel" + : tone === "overdue" + ? "Alerte automatique - restitution du matériel en retard" + : "Rappel automatique - récupération du matériel événementiel", + "", + `Association : ${input.associationName}`, + `Demande : ${input.requestTitle}`, + `Service concerné : ${serviceLabel}`, + `Date de restitution prévue : ${restitutionLabel}`, + "", + "La fiche d'état des lieux préremplie CCDS est jointe à cet email.", + tone === "assignment" + ? "Ce lien terrain devra être utilisé par l'agent mandaté pour effectuer le constat contradictoire, signer et clôturer la restitution." + : tone === "reactivation" + ? "Un nouveau lien actif vient d'être généré. Les anciens liens de restitution ne sont plus valides." + : tone === "overdue" + ? "Le délai de 3 jours après la restitution est dépassé. Merci de finaliser la récupération et de valider immédiatement la fiche sur le portail." + : "Vous disposez d'un délai maximal de 3 jours pour effectuer la récupération, compléter la fiche de manière contradictoire puis la téléverser sur le portail.", + "", + `Téléverser la fiche signée : ${input.uploadLink}`, + "", + "Ce message a été généré automatiquement par le Portail Associations.", + ].join("\n"); + + const html = ` +
+
+
+
Portail Associations
+

${toneContent.title}

+

+ ${toneContent.intro} +

+
+ +
+
+
+
Association
+
${escapeHtml(input.associationName)}
+
Demande
+
${escapeHtml(input.requestTitle)}
+
Service concerné
+
${escapeHtml(serviceLabel)}
+
Restitution prévue
+
${escapeHtml(restitutionLabel)}
+
+
+ +
+

${toneContent.actionTitle}

+

+ ${toneContent.actionBody} +

+
+ + + +
+

+ Lien terrain à utiliser +

+

+ ${escapeHtml(input.uploadLink)} +

+
+ +

+ Ce message a été généré automatiquement par le Portail Associations afin de sécuriser le retour du matériel événementiel. +

+
+
+
+ `; + + return { subject, text, html }; +} + +export function generateMaterialReturnCompletionEmail(input: { + associationName: string; + requestTitle: string; + restitutionDate: Date | string; + serviceLabel?: string | null; + issueFlag: boolean; + compliance: "conforme" | "non_conforme"; + discrepancyDetails?: string | null; +}) { + const restitutionLabel = formatDateFr(input.restitutionDate); + const serviceLabel = input.serviceLabel?.trim() || "service technique / logistique"; + const statusLabel = input.issueFlag ? "Alerte dégradation / litige" : "Matériel récupéré / dossier clôturé"; + const subject = `${input.issueFlag ? "Alerte restitution" : "Restitution clôturée"} - ${input.associationName} - ${restitutionLabel}`; + + const text = [ + "Clôture de restitution du matériel événementiel", + "", + `Association : ${input.associationName}`, + `Demande : ${input.requestTitle}`, + `Service concerné : ${serviceLabel}`, + `Restitution : ${restitutionLabel}`, + `Résultat : ${statusLabel}`, + `Conformité : ${input.compliance === "conforme" ? "Conforme" : "Non conforme / partielle"}`, + input.discrepancyDetails ? `Réserves : ${input.discrepancyDetails}` : "", + "", + "Le PDF officiel finalisé est joint à cet email pour archivage et suivi.", + "", + "Ce message a été généré automatiquement par le Portail Associations.", + ] + .filter(Boolean) + .join("\n"); + + const html = ` +
+
+
+
Portail Associations
+

${input.issueFlag ? "Alerte restitution" : "Restitution clôturée"}

+

+ ${input.issueFlag ? "Des réserves ont été constatées lors de la restitution." : "Le matériel a été récupéré et la fiche contradictoire est finalisée."} +

+
+
+
+
Association
+
${escapeHtml(input.associationName)}
+
Demande
+
${escapeHtml(input.requestTitle)}
+
Service concerné
+
${escapeHtml(serviceLabel)}
+
Restitution
+
${escapeHtml(restitutionLabel)}
+
Résultat
+
${escapeHtml(statusLabel)}
+
+
+

+ ${input.compliance === "conforme" ? "Restitution conforme" : "Restitution non conforme / partielle"} +

+

+ ${escapeHtml(input.discrepancyDetails || "Aucune réserve particulière n'a été déclarée.")} +

+
+

+ Le PDF officiel finalisé est joint à cet email pour archivage et suivi. +

+
+
+
+ `; + + return { subject, text, html }; +} diff --git a/server/materialReturnLitigationPdf.ts b/server/materialReturnLitigationPdf.ts new file mode 100644 index 0000000..acb6af8 --- /dev/null +++ b/server/materialReturnLitigationPdf.ts @@ -0,0 +1,202 @@ +import PDFDocument from "pdfkit"; +import fs from "node:fs"; +import path from "node:path"; +import { getMaterialEventLabel, materialEventItems, sanitizeMaterialEventQuantityMap, type MaterialEventItemKey } from "@shared/materialEvent"; + +const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); + +function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { + if (!fs.existsSync(CCDS_LOGO_PATH)) return; + try { + doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" }); + } catch (error) { + console.warn("[MaterialReturnLitigationPdf] Impossible de charger le logo CCDS:", error); + } +} + +function formatDateFr(date: string | Date | null | undefined) { + if (!date) return "-"; + try { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return String(date); + } +} + +function formatCurrency(amountCents: number | null | undefined) { + if (!Number.isFinite(amountCents as number)) return "-"; + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format((amountCents || 0) / 100); +} + +export async function generateMaterialReturnLitigationLetterPdf(input: { + request: { + id: number; + titre: string; + formData?: string | null; + }; + association?: { + nomAssociation?: string | null; + emailContact?: string | null; + telephone?: string | null; + ville?: string | null; + } | null; + followup: { + restitutionDate?: string | Date | null; + discrepancyCategories?: string[]; + discrepancyDetails?: string | null; + }; + arbitration: { + blockedItems: Record; + decision: "partial_retention" | "full_retention" | "dismissed"; + amountCents: number; + notes?: string | null; + }; +}) { + const formData = (() => { + try { + return input.request.formData ? JSON.parse(input.request.formData) : {}; + } catch { + return {}; + } + })(); + + const blockedItems = sanitizeMaterialEventQuantityMap(input.arbitration.blockedItems); + const blockedRows = materialEventItems + .map((item) => ({ ...item, quantity: blockedItems[item.key] || 0 })) + .filter((item) => item.quantity > 0); + + const decisionLabel = + input.arbitration.decision === "full_retention" + ? "Encaissement total de la caution" + : input.arbitration.decision === "partial_retention" + ? "Retenue partielle sur caution" + : "Classement sans suite"; + + const pdfBuffer = await new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: "A4", + margins: { top: 38, bottom: 42, left: 46, right: 46 }, + info: { + Title: `Courrier de litige - Demande ${input.request.id}`, + Author: "Communauté de Communes Des Savanes", + Subject: "Notification de litige sur restitution de matériel", + }, + }); + + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + + drawLogo(doc, doc.page.margins.left, y, 74, 74); + doc.font("Helvetica-Bold").fontSize(16).fillColor("#0f172a").text( + "Notification de litige et arbitrage matériel", + doc.page.margins.left + 92, + y + 14, + { width: pageWidth - 92 } + ); + doc.font("Helvetica").fontSize(10).fillColor("#475569").text( + "Communauté de Communes Des Savanes - Direction des Services aux Usagers", + doc.page.margins.left + 92, + y + 40, + { width: pageWidth - 92 } + ); + y += 96; + + const lines = [ + ["Association", input.association?.nomAssociation || "-"], + ["Demande", input.request.titre || `Dossier #${input.request.id}`], + ["Commune", formData.commune || input.association?.ville || "-"], + ["Date de restitution", formatDateFr(input.followup.restitutionDate)], + ["Décision d'arbitrage", decisionLabel], + ["Montant retenu", formatCurrency(input.arbitration.amountCents)], + ] as const; + + lines.forEach(([label, value]) => { + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text(`${label} :`, doc.page.margins.left, y, { + width: 170, + }); + doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(String(value || "-"), doc.page.margins.left + 170, y, { + width: pageWidth - 170, + }); + y += 18; + }); + + y += 8; + doc.roundedRect(doc.page.margins.left, y, pageWidth, 88, 10).fillAndStroke("#fff7ed", "#fdba74"); + doc.font("Helvetica-Bold").fontSize(11).fillColor("#9a3412").text("Constat de terrain", doc.page.margins.left + 14, y + 12); + doc.font("Helvetica").fontSize(10).fillColor("#7c2d12").text( + [ + input.followup.discrepancyCategories?.length + ? `Réserves : ${input.followup.discrepancyCategories.join(", ")}.` + : null, + input.followup.discrepancyDetails?.trim() || null, + ].filter(Boolean).join(" "), + doc.page.margins.left + 14, + y + 32, + { width: pageWidth - 28 } + ); + y += 108; + + doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel provisoirement bloqué", doc.page.margins.left, y); + y += 18; + + if (blockedRows.length === 0) { + doc.font("Helvetica").fontSize(10).fillColor("#475569").text( + "Aucun équipement n'est maintenu en indisponibilité après arbitrage.", + doc.page.margins.left, + y, + { width: pageWidth } + ); + y += 22; + } else { + blockedRows.forEach((item) => { + doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text( + `- ${getMaterialEventLabel(item.key)} : ${item.quantity}`, + doc.page.margins.left + 8, + y, + { width: pageWidth - 8 } + ); + y += 16; + }); + } + + if (input.arbitration.notes?.trim()) { + y += 12; + doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Observations administratives", doc.page.margins.left, y); + y += 18; + doc.font("Helvetica").fontSize(10).fillColor("#334155").text(input.arbitration.notes.trim(), doc.page.margins.left, y, { + width: pageWidth, + }); + y = doc.y + 10; + } + + doc.font("Helvetica").fontSize(9).fillColor("#64748b").text( + `Document généré automatiquement pour le dossier #${input.request.id}.`, + doc.page.margins.left, + Math.max(y + 28, doc.page.height - doc.page.margins.bottom - 18), + { width: pageWidth, align: "center" } + ); + + doc.end(); + } catch (error) { + reject(error); + } + }); + + return { + buffer: pdfBuffer, + fileName: `Courrier_Litige_${input.request.id}.pdf`, + }; +} diff --git a/server/materialReturnPdf.ts b/server/materialReturnPdf.ts new file mode 100644 index 0000000..5bc9814 --- /dev/null +++ b/server/materialReturnPdf.ts @@ -0,0 +1,462 @@ +import PDFDocument from "pdfkit"; +import fs from "node:fs"; +import path from "node:path"; + +type MaterialReturnRequest = { + id: number; + titre: string; + formData?: string | null; +}; + +type MaterialReturnAssociation = { + nomAssociation?: string | null; + telephone?: string | null; + emailContact?: string | null; + ville?: string | null; +} | null | undefined; + +const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); + +function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { + if (!fs.existsSync(CCDS_LOGO_PATH)) return; + try { + doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" }); + } catch (error) { + console.warn("[MaterialReturnPdf] Impossible de charger le logo CCDS:", error); + } +} + +function formatDateFr(date: string | Date | null | undefined) { + if (!date) return ""; + try { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return String(date); + } +} + +function addLabeledValue(doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) { + doc.font("Helvetica-Bold").fontSize(9).fillColor("#334155").text(label, x, y, { width }); + doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(value || "........................................................", x, y + 12, { width }); +} + +function addSignatureBlock(doc: PDFKit.PDFDocument, title: string, x: number, y: number, width: number) { + doc.roundedRect(x, y, width, 90, 8).stroke("#cbd5e1"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text(title, x + 12, y + 12, { width: width - 24 }); + doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("Nom, qualité, signature et date", x + 12, y + 32, { width: width - 24 }); +} + +export async function generateMaterialReturnStatementPdf(input: { + request: MaterialReturnRequest; + association?: MaterialReturnAssociation; +}) { + const formData = (() => { + try { + return input.request.formData ? JSON.parse(input.request.formData) : {}; + } catch { + return {}; + } + })(); + + const manifestationStart = formData.dateDebutManifestation || formData.dateManifestation; + const manifestationEnd = formData.dateFinManifestation || formData.dateManifestation; + const requestedItems = Object.entries(formData.materielsDemandes || {}) + .filter(([, checked]) => Boolean(checked)) + .map(([key]) => ({ + key, + label: + key === "tente3x3" + ? "Tente 3x3" + : key === "chapiteau5x5" + ? "Chapiteau 5x5" + : key === "podium" + ? "Podium" + : "Autres", + quantity: formData.quantitesDemandees?.[key] || "-", + extra: key === "autres" ? formData.autreMaterielPrecisions || "" : "", + })); + + const pdfBuffer = await new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: "A4", + margins: { top: 36, bottom: 40, left: 42, right: 42 }, + info: { + Title: `Fiche d'état des lieux - Demande ${input.request.id}`, + Author: "Communauté de Communes Des Savanes", + Subject: "Fiche d'état des lieux du matériel événementiel", + }, + }); + + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + + doc.moveTo(doc.page.margins.left, y).lineTo(doc.page.margins.left + pageWidth, y).lineWidth(2).stroke("#efb100"); + y += 14; + drawLogo(doc, doc.page.margins.left + (pageWidth - 76) / 2, y, 76, 76); + y += 82; + + doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 16; + doc.font("Helvetica-Bold").fontSize(18).fillColor("#0f172a").text("FICHE D'ÉTAT DES LIEUX", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 18; + doc.font("Helvetica-Bold").fontSize(14).text("DU MATÉRIEL ÉVÉNEMENTIEL DE LA CCDS", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 22; + + doc.roundedRect(doc.page.margins.left, y, pageWidth, 34, 8).fillAndStroke("#eef5ff", "#bfd2ef"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f2d63").text("Document de suivi prérempli à compléter contradictoirement", doc.page.margins.left + 14, y + 9, { + width: pageWidth - 28, + align: "center", + }); + y += 48; + + const leftColWidth = (pageWidth - 16) / 2; + addLabeledValue(doc, "Commune", formData.commune || input.association?.ville || "", doc.page.margins.left, y, leftColWidth); + addLabeledValue(doc, "Demandeur", formData.demandeurNomPrenom || input.association?.nomAssociation || "", doc.page.margins.left + leftColWidth + 16, y, leftColWidth); + y += 40; + addLabeledValue(doc, "Téléphone", formData.telephoneAssociation || input.association?.telephone || "", doc.page.margins.left, y, leftColWidth); + addLabeledValue(doc, "Intitulé de la manifestation", formData.motifDemande || input.request.titre || "", doc.page.margins.left + leftColWidth + 16, y, leftColWidth); + y += 40; + addLabeledValue( + doc, + "Date prévue du / au", + manifestationStart || manifestationEnd + ? `${formatDateFr(manifestationStart)}${manifestationEnd ? ` au ${formatDateFr(manifestationEnd)}` : ""}` + : "", + doc.page.margins.left, + y, + leftColWidth + ); + addLabeledValue(doc, "Date de restitution prévue", formatDateFr(formData.dateRestitution), doc.page.margins.left + leftColWidth + 16, y, leftColWidth); + y += 48; + + doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel concerné", doc.page.margins.left, y); + y += 14; + + const tableX = doc.page.margins.left; + const cols = [pageWidth * 0.46, pageWidth * 0.16, pageWidth * 0.19, pageWidth * 0.19]; + const headers = ["Équipement", "Qté", "Mise à disposition", "Restitution"]; + let cursorX = tableX; + headers.forEach((header, index) => { + doc.rect(cursorX, y, cols[index], 22).fillAndStroke("#dce8f8", "#bfd2ef"); + doc.font("Helvetica-Bold").fontSize(9).fillColor("#0f2d63").text(header, cursorX + 6, y + 7, { + width: cols[index] - 12, + align: index === 0 ? "left" : "center", + }); + cursorX += cols[index]; + }); + y += 22; + + const items = requestedItems.length > 0 ? requestedItems : [{ key: "none", label: "Aucun matériel renseigné", quantity: "-", extra: "" }]; + items.forEach((item) => { + const rowHeight = item.extra ? 36 : 28; + let rowX = tableX; + const values = [ + item.extra ? `${item.label} — ${item.extra}` : item.label, + item.quantity, + "À compléter", + "À compléter", + ]; + + values.forEach((value, index) => { + doc.rect(rowX, y, cols[index], rowHeight).stroke("#cbd5e1"); + doc.font(index < 2 ? "Helvetica" : "Helvetica-Oblique") + .fontSize(9) + .fillColor(index < 2 ? "#0f172a" : "#64748b") + .text(value, rowX + 6, y + 8, { + width: cols[index] - 12, + align: index === 0 ? "left" : "center", + }); + rowX += cols[index]; + }); + y += rowHeight; + }); + + y += 20; + doc.roundedRect(doc.page.margins.left, y, pageWidth, 76, 8).stroke("#cbd5e1"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text("Observations contradictoires", doc.page.margins.left + 12, y + 12); + doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("Préciser l'état général du matériel, les éventuelles anomalies, manques ou réserves constatées.", doc.page.margins.left + 12, y + 28, { + width: pageWidth - 24, + }); + y += 92; + + addSignatureBlock(doc, "L'agent CCDS / service récupérateur", doc.page.margins.left, y, (pageWidth - 20) / 2); + addSignatureBlock(doc, "L'emprunteur / représentant de l'association", doc.page.margins.left + (pageWidth - 20) / 2 + 20, y, (pageWidth - 20) / 2); + y += 104; + + doc.font("Helvetica").fontSize(8).fillColor("#64748b").text( + `Document préparé automatiquement pour la demande #${input.request.id} — à vérifier et compléter sur site lors de la restitution.`, + doc.page.margins.left, + y, + { width: pageWidth, align: "center" } + ); + + doc.end(); + } catch (error) { + reject(error); + } + }); + + return { + buffer: pdfBuffer, + fileName: `Fiche_Etat_Des_Lieux_${input.request.id}.pdf`, + }; +} + +export async function generateCompletedMaterialReturnStatementPdf(input: { + request: MaterialReturnRequest; + association?: MaterialReturnAssociation; + completion: { + compliance: "conforme" | "non_conforme"; + discrepancyLabels: string[]; + discrepancyDetails?: string | null; + agentName: string; + agentRole: string; + borrowerName: string; + borrowerRole: string; + validatedAt: Date; + geoLatitude?: string | null; + geoLongitude?: string | null; + geoStatus?: string | null; + geoFailureReason?: string | null; + locationReference?: string | null; + agentSignatureBuffer?: Buffer; + borrowerSignatureBuffer?: Buffer; + }; +}) { + const formData = (() => { + try { + return input.request.formData ? JSON.parse(input.request.formData) : {}; + } catch { + return {}; + } + })(); + + const manifestationStart = formData.dateDebutManifestation || formData.dateManifestation; + const manifestationEnd = formData.dateFinManifestation || formData.dateManifestation; + const requestedItems = Object.entries(formData.materielsDemandes || {}) + .filter(([, checked]) => Boolean(checked)) + .map(([key]) => ({ + key, + label: + key === "tente3x3" + ? "Tente 3x3" + : key === "chapiteau5x5" + ? "Chapiteau 5x5" + : key === "podium" + ? "Podium" + : "Autres", + quantity: formData.quantitesDemandees?.[key] || "-", + extra: key === "autres" ? formData.autreMaterielPrecisions || "" : "", + })); + + const pdfBuffer = await new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: "A4", + margins: { top: 36, bottom: 40, left: 42, right: 42 }, + info: { + Title: `Fiche d'état des lieux finalisée - Demande ${input.request.id}`, + Author: "Communauté de Communes Des Savanes", + Subject: "Fiche d'état des lieux contradictoire du matériel événementiel", + }, + }); + + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + + doc.moveTo(doc.page.margins.left, y).lineTo(doc.page.margins.left + pageWidth, y).lineWidth(2).stroke("#efb100"); + y += 14; + drawLogo(doc, doc.page.margins.left + (pageWidth - 76) / 2, y, 76, 76); + y += 82; + + doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 16; + doc.font("Helvetica-Bold").fontSize(18).fillColor("#0f172a").text("FICHE D'ÉTAT DES LIEUX FINALISÉE", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 18; + doc.font("Helvetica-Bold").fontSize(14).text("DU MATÉRIEL ÉVÉNEMENTIEL DE LA CCDS", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 22; + + doc.roundedRect(doc.page.margins.left, y, pageWidth, 44, 8).fillAndStroke( + input.completion.compliance === "conforme" ? "#ecfdf3" : "#fff7ed", + input.completion.compliance === "conforme" ? "#a7f3d0" : "#fdba74" + ); + doc.font("Helvetica-Bold").fontSize(11).fillColor(input.completion.compliance === "conforme" ? "#166534" : "#9a3412").text( + input.completion.compliance === "conforme" ? "Restitution conforme" : "Restitution non conforme / partielle", + doc.page.margins.left + 14, + y + 10, + { width: pageWidth - 28, align: "center" } + ); + doc.font("Helvetica").fontSize(9).fillColor("#475569").text( + `Validée le ${formatDateFr(input.completion.validatedAt)}`, + doc.page.margins.left + 14, + y + 25, + { width: pageWidth - 28, align: "center" } + ); + y += 58; + + const leftColWidth = (pageWidth - 16) / 2; + addLabeledValue(doc, "Commune", formData.commune || input.association?.ville || "", doc.page.margins.left, y, leftColWidth); + addLabeledValue(doc, "Association / demandeur", input.association?.nomAssociation || formData.demandeurNomPrenom || "", doc.page.margins.left + leftColWidth + 16, y, leftColWidth); + y += 40; + addLabeledValue(doc, "Téléphone", formData.telephoneAssociation || input.association?.telephone || "", doc.page.margins.left, y, leftColWidth); + addLabeledValue(doc, "Manifestation", formData.motifDemande || input.request.titre || "", doc.page.margins.left + leftColWidth + 16, y, leftColWidth); + y += 40; + addLabeledValue( + doc, + "Période", + manifestationStart || manifestationEnd + ? `${formatDateFr(manifestationStart)}${manifestationEnd ? ` au ${formatDateFr(manifestationEnd)}` : ""}` + : "", + doc.page.margins.left, + y, + leftColWidth + ); + addLabeledValue(doc, "Restitution prévue", formatDateFr(formData.dateRestitution), doc.page.margins.left + leftColWidth + 16, y, leftColWidth); + y += 48; + + doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel restitué", doc.page.margins.left, y); + y += 14; + requestedItems.forEach((item) => { + doc.rect(doc.page.margins.left, y, pageWidth, 24).stroke("#cbd5e1"); + doc.font("Helvetica").fontSize(9).fillColor("#0f172a").text( + `${item.label}${item.extra ? ` — ${item.extra}` : ""}`, + doc.page.margins.left + 8, + y + 7, + { width: pageWidth * 0.65 } + ); + doc.font("Helvetica-Bold").fontSize(9).text( + `Qté : ${item.quantity}`, + doc.page.margins.left + pageWidth - 120, + y + 7, + { width: 110, align: "right" } + ); + y += 24; + }); + + y += 16; + doc.roundedRect(doc.page.margins.left, y, pageWidth, 100, 8).stroke("#cbd5e1"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text("Observations contradictoires", doc.page.margins.left + 12, y + 12); + doc.font("Helvetica").fontSize(9).fillColor("#475569").text( + input.completion.discrepancyLabels.length > 0 + ? input.completion.discrepancyLabels.join(" • ") + : "Aucune réserve relevée.", + doc.page.margins.left + 12, + y + 30, + { width: pageWidth - 24 } + ); + doc.text( + input.completion.discrepancyDetails || "Aucune précision complémentaire.", + doc.page.margins.left + 12, + y + 50, + { width: pageWidth - 24 } + ); + y += 118; + + const geoLine = input.completion.geoLatitude && input.completion.geoLongitude + ? `Géolocalisation : ${input.completion.geoLatitude}, ${input.completion.geoLongitude}` + : input.completion.locationReference + ? `Référence de lieu : ${input.completion.locationReference}${ + input.completion.geoFailureReason ? ` • GPS indisponible (${input.completion.geoFailureReason})` : "" + }` + : input.completion.geoFailureReason + ? `GPS indisponible (${input.completion.geoFailureReason})` + : ""; + + doc.font("Helvetica").fontSize(8).fillColor("#64748b").text( + `Horodatage serveur : ${new Date(input.completion.validatedAt).toLocaleString("fr-FR")}${geoLine ? ` • ${geoLine}` : ""}`, + doc.page.margins.left, + y, + { width: pageWidth } + ); + y += 18; + + const signatureWidth = (pageWidth - 20) / 2; + doc.roundedRect(doc.page.margins.left, y, signatureWidth, 126, 8).stroke("#cbd5e1"); + doc.roundedRect(doc.page.margins.left + signatureWidth + 20, y, signatureWidth, 126, 8).stroke("#cbd5e1"); + + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text("Agent CCDS / service récupérateur", doc.page.margins.left + 12, y + 12, { + width: signatureWidth - 24, + }); + doc.font("Helvetica").fontSize(9).fillColor("#475569").text( + `${input.completion.agentName}\n${input.completion.agentRole}`, + doc.page.margins.left + 12, + y + 30, + { width: signatureWidth - 24 } + ); + if (input.completion.agentSignatureBuffer) { + doc.image(input.completion.agentSignatureBuffer, doc.page.margins.left + 12, y + 64, { + fit: [signatureWidth - 24, 42], + align: "center", + valign: "center", + }); + } + + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text("Emprunteur / représentant", doc.page.margins.left + signatureWidth + 32, y + 12, { + width: signatureWidth - 24, + }); + doc.font("Helvetica").fontSize(9).fillColor("#475569").text( + `${input.completion.borrowerName}\n${input.completion.borrowerRole}`, + doc.page.margins.left + signatureWidth + 32, + y + 30, + { width: signatureWidth - 24 } + ); + if (input.completion.borrowerSignatureBuffer) { + doc.image(input.completion.borrowerSignatureBuffer, doc.page.margins.left + signatureWidth + 32, y + 64, { + fit: [signatureWidth - 24, 42], + align: "center", + valign: "center", + }); + } + + y += 138; + doc.font("Helvetica").fontSize(8).fillColor("#64748b").text( + `Document finalisé automatiquement pour la demande #${input.request.id}.`, + doc.page.margins.left, + y, + { width: pageWidth, align: "center" } + ); + + doc.end(); + } catch (error) { + reject(error); + } + }); + + return { + buffer: pdfBuffer, + fileName: `Fiche_Etat_Des_Lieux_Finalisee_${input.request.id}.pdf`, + }; +} diff --git a/server/materialReturnWorkflow.test.ts b/server/materialReturnWorkflow.test.ts new file mode 100644 index 0000000..df9cd38 --- /dev/null +++ b/server/materialReturnWorkflow.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./db", () => ({ + getMaterialReturnFollowupByRequestId: vi.fn(), + upsertMaterialReturnFollowup: vi.fn(), +})); + +vi.mock("./mailer", () => ({ + sendOperationalEmail: vi.fn(), +})); + +vi.mock("./materialReturnEmail", () => ({ + generateMaterialReturnReminderEmail: vi.fn(), +})); + +vi.mock("./materialReturnPdf", () => ({ + generateMaterialReturnStatementPdf: vi.fn(), +})); + +vi.mock("./logisticsSettings", () => ({ + getLogisticsSettings: vi.fn(), +})); + +import * as db from "./db"; +import { getLogisticsSettings } from "./logisticsSettings"; +import { scheduleMaterialReturnFollowup } from "./materialReturnWorkflow"; + +describe("scheduleMaterialReturnFollowup", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getLogisticsSettings).mockResolvedValue({ + materialReturnLeadDays: 4, + materialReturnGraceDays: 3, + inventory: { + tente3x3: 12, + chapiteau5x5: 10, + podium: 1, + autres: null, + }, + replacementValues: { + tente3x3: 0, + chapiteau5x5: 0, + podium: 0, + autres: null, + }, + }); + }); + + it("keeps the standard expiry window for a fresh follow-up", async () => { + vi.mocked(db.getMaterialReturnFollowupByRequestId).mockResolvedValue(undefined); + vi.mocked(db.upsertMaterialReturnFollowup).mockResolvedValue(1); + + const request = { + id: 12, + associationId: 3, + formData: JSON.stringify({ dateRestitution: "2026-06-15" }), + } as any; + + await scheduleMaterialReturnFollowup({ + request, + serviceLabel: "Service technique", + recipientEmails: ["agent@example.com"], + }); + + expect(db.upsertMaterialReturnFollowup).toHaveBeenCalledTimes(1); + const payload = vi.mocked(db.upsertMaterialReturnFollowup).mock.calls[0]?.[1]; + expect(payload.status).toBe("planifie"); + expect(new Date(payload.uploadTokenExpiresAt).toISOString().startsWith("2026-06-18")).toBe(true); + }); + + it("reactivates an expired upload window when the follow-up is reassigned", async () => { + const expiredDate = new Date(); + expiredDate.setDate(expiredDate.getDate() - 2); + expiredDate.setHours(23, 59, 59, 999); + + vi.mocked(db.getMaterialReturnFollowupByRequestId).mockResolvedValue({ + id: 4, + requestId: 12, + associationId: 3, + status: "en_cours", + uploadToken: "existing-token", + uploadTokenExpiresAt: expiredDate, + } as any); + vi.mocked(db.upsertMaterialReturnFollowup).mockResolvedValue(4); + + const request = { + id: 12, + associationId: 3, + formData: JSON.stringify({ dateRestitution: "2026-05-01" }), + } as any; + + await scheduleMaterialReturnFollowup({ + request, + serviceLabel: "Service technique", + recipientEmails: ["agent@example.com"], + }); + + const payload = vi.mocked(db.upsertMaterialReturnFollowup).mock.calls[0]?.[1]; + expect(payload.uploadToken).toBe("existing-token"); + expect(new Date(payload.uploadTokenExpiresAt).getTime()).toBeGreaterThan(Date.now()); + expect(payload.status).toBe("planifie"); + }); + + it("generates a new token and a fresh upload window when reactivation is explicit", async () => { + const futureExpiry = new Date("2026-06-18T23:59:59.999Z"); + + vi.mocked(db.getMaterialReturnFollowupByRequestId).mockResolvedValue({ + id: 4, + requestId: 12, + associationId: 3, + status: "en_attente", + uploadToken: "existing-token", + uploadTokenExpiresAt: futureExpiry, + } as any); + vi.mocked(db.upsertMaterialReturnFollowup).mockResolvedValue(4); + + const request = { + id: 12, + associationId: 3, + formData: JSON.stringify({ dateRestitution: "2026-06-15" }), + } as any; + const reactivatedAt = new Date("2026-06-20T10:30:00.000Z"); + + await scheduleMaterialReturnFollowup({ + request, + serviceLabel: "Service technique", + recipientEmails: ["agent@example.com"], + }, { + forceNewToken: true, + forceImmediateSendWindow: true, + reactivatedByUserId: 9, + reactivatedAt, + reactivationReason: "Suivi rouvert", + }); + + const payload = vi.mocked(db.upsertMaterialReturnFollowup).mock.calls[0]?.[1]; + expect(payload.uploadToken).not.toBe("existing-token"); + expect(new Date(payload.uploadTokenExpiresAt).getTime()).toBeGreaterThan(futureExpiry.getTime()); + expect(new Date(payload.uploadTokenExpiresAt).getTime()).toBeGreaterThan(reactivatedAt.getTime()); + expect(payload.reactivatedByUserId).toBe(9); + expect(payload.reactivationReason).toBe("Suivi rouvert"); + }); +}); diff --git a/server/materialReturnWorkflow.ts b/server/materialReturnWorkflow.ts new file mode 100644 index 0000000..5b59b53 --- /dev/null +++ b/server/materialReturnWorkflow.ts @@ -0,0 +1,436 @@ +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>; + +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) { + 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) { + 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>>; + request: NonNullable; + association: Awaited>; + 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; + 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>) { + 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; + } + })() + ), + }; +} diff --git a/server/operationalTour.ts b/server/operationalTour.ts new file mode 100644 index 0000000..87d1787 --- /dev/null +++ b/server/operationalTour.ts @@ -0,0 +1,952 @@ +import { z } from "zod"; +import type { Request } from "express"; +import { COOKIE_NAME } from "@shared/const"; +import type { User } from "../drizzle/schema"; +import { createSessionToken, loginLocalUser } from "./_core/auth"; +import * as db from "./db"; + +export const OPERATIONAL_TOUR_SETTINGS_KEY = "system.operationalTour.settings"; +export const OPERATIONAL_TOUR_HISTORY_KEY = "system.operationalTour.history"; + +export const operationalTourEnvironmentSchema = z.enum(["production", "secondary"]); +export type OperationalTourEnvironment = z.infer; +export const operationalTourAuthModeSchema = z.enum(["local", "oauth"]); +export type OperationalTourAuthMode = z.infer; +export const operationalTourStepKindSchema = z.enum(["technical", "user"]); +export type OperationalTourStepKind = z.infer; + +const operationalTourTargetSchema = z.object({ + baseUrl: z.string().trim().optional().default(""), + associationEmail: z.string().trim().optional().default(""), + associationPassword: z.string().trim().optional().default(""), + associationAuthMode: operationalTourAuthModeSchema.optional().default("local"), + accueilEmail: z.string().trim().optional().default(""), + accueilPassword: z.string().trim().optional().default(""), + accueilAuthMode: operationalTourAuthModeSchema.optional().default("local"), + adminEmail: z.string().trim().optional().default(""), + adminPassword: z.string().trim().optional().default(""), + adminAuthMode: operationalTourAuthModeSchema.optional().default("local"), +}); + +const EMPTY_TARGET = { + baseUrl: "", + associationEmail: "", + associationPassword: "", + associationAuthMode: "local", + accueilEmail: "", + accueilPassword: "", + accueilAuthMode: "local", + adminEmail: "", + adminPassword: "", + adminAuthMode: "local", +} as const; + +const operationalTourSettingsSchema = z.object({ + defaultEnvironment: operationalTourEnvironmentSchema.default("production"), + targets: z.object({ + production: operationalTourTargetSchema.default(EMPTY_TARGET), + secondary: operationalTourTargetSchema.default(EMPTY_TARGET), + }), +}); + +export type OperationalTourSettings = z.infer; + +const operationalTourStepSchema = z.object({ + id: z.string(), + label: z.string(), + kind: operationalTourStepKindSchema.default("technical"), + status: z.enum(["ok", "warning", "error"]), + durationMs: z.number().int().nonnegative(), + message: z.string(), + details: z.string().optional(), + url: z.string().optional(), + critical: z.boolean().default(true), +}); + +export type OperationalTourStep = z.infer; + +const operationalTourReportSchema = z.object({ + id: z.string(), + environment: operationalTourEnvironmentSchema, + environmentLabel: z.string(), + baseUrl: z.string(), + status: z.enum(["ok", "warning", "error"]), + startedAt: z.string(), + finishedAt: z.string(), + durationMs: z.number().int().nonnegative(), + initiatedBy: z.object({ + userId: z.number().int().positive(), + name: z.string(), + email: z.string().optional(), + }), + summary: z.object({ + total: z.number().int().nonnegative(), + ok: z.number().int().nonnegative(), + warning: z.number().int().nonnegative(), + error: z.number().int().nonnegative(), + }), + steps: z.array(operationalTourStepSchema), +}); + +export type OperationalTourReport = z.infer; + +const OPERATIONAL_TOUR_HISTORY_MAX_BYTES = 60_000; +const OPERATIONAL_TOUR_HISTORY_MAX_REPORTS = 12; +const OPERATIONAL_TOUR_DUPLICATE_WINDOW_MS = 15_000; + +const EMPTY_SETTINGS: OperationalTourSettings = { + defaultEnvironment: "production", + targets: { + production: { + baseUrl: "https://www.portail-association973.com", + associationEmail: "", + associationPassword: "", + associationAuthMode: "local", + accueilEmail: "", + accueilPassword: "", + accueilAuthMode: "local", + adminEmail: "", + adminPassword: "", + adminAuthMode: "local", + }, + secondary: { + baseUrl: "", + associationEmail: "", + associationPassword: "", + associationAuthMode: "local", + accueilEmail: "", + accueilPassword: "", + accueilAuthMode: "local", + adminEmail: "", + adminPassword: "", + adminAuthMode: "local", + }, + }, +}; + +type PageCheckOptions = { + id: string; + label: string; + kind: OperationalTourStepKind; + url: string; + cookieHeader?: string | null; + markers?: string[]; + expectedPath?: string; + critical?: boolean; +}; + +type PageCheckResult = OperationalTourStep; + +function normalizeBaseUrl(value: string | undefined) { + return String(value || "").trim().replace(/\/+$/, ""); +} + +function normalizeEmail(value: string | undefined) { + return String(value || "").trim().toLowerCase(); +} + +function buildRequestOrigin(req: Request) { + const forwardedProto = typeof req.headers["x-forwarded-proto"] === "string" ? req.headers["x-forwarded-proto"] : undefined; + const forwardedHost = typeof req.headers["x-forwarded-host"] === "string" ? req.headers["x-forwarded-host"] : undefined; + const protocol = forwardedProto || req.protocol || "http"; + const host = forwardedHost || req.get?.("host") || ""; + return host ? `${protocol}://${host}` : ""; +} + +function isLoginPage(body: string, finalUrl: string) { + return finalUrl.includes("/login") || body.includes("Connexion requise") || body.includes("Se connecter"); +} + +function normalizePathname(url: string) { + try { + const pathname = new URL(url).pathname || "/"; + return pathname === "/" ? "/" : pathname.replace(/\/+$/, ""); + } catch { + return url; + } +} + +function hasReactShell(body: string, contentType: string | null) { + const lowerBody = body.toLowerCase(); + const lowerType = String(contentType || "").toLowerCase(); + return ( + lowerType.includes("text/html") && + (lowerBody.includes("") || lowerBody.includes("script type=\"module\"") || lowerBody.includes("id=\"manus-runtime\"")) + ); +} + +function buildStaticStep(input: Omit & { durationMs?: number }): OperationalTourStep { + return { + ...input, + durationMs: input.durationMs ?? 0, + }; +} + +async function fetchPageCheck(options: PageCheckOptions): Promise { + const started = Date.now(); + try { + const response = await fetch(options.url, { + headers: options.cookieHeader ? { cookie: options.cookieHeader } : undefined, + redirect: "follow", + }); + const body = await response.text(); + const finalUrl = response.url || options.url; + const contentType = response.headers.get("content-type"); + const expectedPath = normalizePathname(options.expectedPath || options.url); + const finalPath = normalizePathname(finalUrl); + const markers = options.markers || []; + const matchedMarker = markers.find((marker) => body.includes(marker)); + const routeMatches = expectedPath === "/" ? finalPath === "/" : finalPath === expectedPath || finalPath.startsWith(`${expectedPath}/`); + const reactShell = hasReactShell(body, contentType); + + if (!response.ok) { + return { + id: options.id, + label: options.label, + kind: options.kind, + status: "error", + durationMs: Date.now() - started, + message: `HTTP ${response.status}`, + details: finalUrl, + url: options.url, + critical: options.critical ?? true, + }; + } + + if (options.cookieHeader && isLoginPage(body, finalUrl)) { + return { + id: options.id, + label: options.label, + kind: options.kind, + status: "error", + durationMs: Date.now() - started, + message: "Authentification non reconnue", + details: finalUrl, + url: options.url, + critical: options.critical ?? true, + }; + } + + if (!routeMatches) { + return { + id: options.id, + label: options.label, + kind: options.kind, + status: "warning", + durationMs: Date.now() - started, + message: "Page servie sur un chemin inattendu", + details: `${finalUrl} (attendu: ${expectedPath})`, + url: options.url, + critical: options.critical ?? true, + }; + } + + if (matchedMarker) { + return { + id: options.id, + label: options.label, + kind: options.kind, + status: "ok", + durationMs: Date.now() - started, + message: `Contrôle réussi (${matchedMarker})`, + details: finalUrl, + url: options.url, + critical: options.critical ?? true, + }; + } + + if (reactShell) { + return { + id: options.id, + label: options.label, + kind: options.kind, + status: "ok", + durationMs: Date.now() - started, + message: "Page accessible, rendu client servi", + details: finalUrl, + url: options.url, + critical: options.critical ?? true, + }; + } + + return { + id: options.id, + label: options.label, + kind: options.kind, + status: markers.length > 0 ? "warning" : "ok", + durationMs: Date.now() - started, + message: markers.length > 0 ? "Page accessible, marqueur visuel non confirmé" : "Contrôle réussi", + details: finalUrl, + url: options.url, + critical: options.critical ?? true, + }; + } catch (error) { + return { + id: options.id, + label: options.label, + kind: options.kind, + status: "error", + durationMs: Date.now() - started, + message: error instanceof Error ? error.message : "Erreur réseau", + url: options.url, + critical: options.critical ?? true, + }; + } +} + +async function fetchJsonCheck(options: { + id: string; + label: string; + kind: OperationalTourStepKind; + url: string; + cookieHeader?: string | null; + markers?: string[]; + critical?: boolean; +}) { + const started = Date.now(); + try { + const response = await fetch(options.url, { + headers: { + accept: "application/json", + ...(options.cookieHeader ? { cookie: options.cookieHeader } : {}), + }, + redirect: "follow", + }); + const body = await response.text(); + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const matchesMarker = (options.markers || []).some((marker) => body.includes(marker)); + if (!response.ok) { + return buildStaticStep({ + id: options.id, + label: options.label, + kind: options.kind, + status: "error", + message: `HTTP ${response.status}`, + details: response.url || options.url, + url: options.url, + critical: options.critical ?? true, + durationMs: Date.now() - started, + }); + } + if (!contentType.includes("application/json")) { + return buildStaticStep({ + id: options.id, + label: options.label, + kind: options.kind, + status: "warning", + message: "Réponse non JSON", + details: response.url || options.url, + url: options.url, + critical: options.critical ?? true, + durationMs: Date.now() - started, + }); + } + return buildStaticStep({ + id: options.id, + label: options.label, + kind: options.kind, + status: matchesMarker || !(options.markers || []).length ? "ok" : "warning", + message: matchesMarker || !(options.markers || []).length ? "API accessible et réponse JSON valide" : "API accessible, signature JSON à confirmer", + details: response.url || options.url, + url: options.url, + critical: options.critical ?? true, + durationMs: Date.now() - started, + }); + } catch (error) { + return buildStaticStep({ + id: options.id, + label: options.label, + kind: options.kind, + status: "error", + message: error instanceof Error ? error.message : "Erreur réseau", + url: options.url, + critical: options.critical ?? true, + durationMs: Date.now() - started, + }); + } +} + +async function fetchHomeDiagnostics(baseUrl: string) { + const started = Date.now(); + try { + const response = await fetch(`${baseUrl}/`, { redirect: "follow" }); + const body = await response.text(); + return { + durationMs: Date.now() - started, + body, + headers: response.headers, + ok: response.ok, + finalUrl: response.url || `${baseUrl}/`, + }; + } catch (error) { + return { + durationMs: Date.now() - started, + body: "", + headers: new Headers(), + ok: false, + finalUrl: `${baseUrl}/`, + error: error instanceof Error ? error.message : "Erreur réseau", + }; + } +} + +async function createConfiguredSessionCookie(email: string, password?: string) { + const normalizedEmail = normalizeEmail(email); + const normalizedPassword = String(password || ""); + let user; + + if (normalizedPassword) { + user = await loginLocalUser({ email: normalizedEmail, password: normalizedPassword }); + } else { + const existingUser = await db.getUserByEmail(normalizedEmail); + if (!existingUser) { + throw new Error("Compte introuvable"); + } + if (!existingUser.isActive) { + throw new Error("Compte inactif"); + } + if (!existingUser.loginMethod || !existingUser.loginMethod.endsWith("_oauth")) { + throw new Error("Mot de passe requis pour ce compte"); + } + user = existingUser; + } + + const token = await createSessionToken(user); + return `${COOKIE_NAME}=${token}`; +} + +async function tryFindPublicProfileUrl(baseUrl: string) { + try { + const response = await fetch(`${baseUrl}/associations`, { redirect: "follow" }); + if (!response.ok) return null; + const body = await response.text(); + const match = body.match(/href="(\/associations\/[^"]+)"/); + if (match) return `${baseUrl}${match[1]}`; + } catch { + // fallback below + } + + try { + const directory = await db.listAssociationDirectoryMapEntries({ limit: 1 }); + const firstEntry = directory.data?.[0]; + return firstEntry?.id ? `${baseUrl}/associations/${firstEntry.id}` : null; + } catch { + return null; + } +} + +function computeReportStatus(steps: OperationalTourStep[]): OperationalTourReport["status"] { + const hasCriticalError = steps.some((step) => step.critical !== false && step.status === "error"); + if (hasCriticalError) return "error"; + const hasWarning = steps.some((step) => step.status !== "ok"); + return hasWarning ? "warning" : "ok"; +} + +function summarizeSteps(steps: OperationalTourStep[]) { + return { + total: steps.length, + ok: steps.filter((step) => step.status === "ok").length, + warning: steps.filter((step) => step.status === "warning").length, + error: steps.filter((step) => step.status === "error").length, + }; +} + +function summarizeStepsByKind(steps: OperationalTourStep[], kind: OperationalTourStepKind) { + return summarizeSteps(steps.filter((step) => step.kind === kind)); +} + +export function sanitizeOperationalTourSettings(input?: unknown): OperationalTourSettings { + const parsed = operationalTourSettingsSchema.safeParse(input); + if (!parsed.success) return EMPTY_SETTINGS; + return { + defaultEnvironment: parsed.data.defaultEnvironment, + targets: { + production: { + ...EMPTY_SETTINGS.targets.production, + ...parsed.data.targets.production, + baseUrl: normalizeBaseUrl(parsed.data.targets.production.baseUrl) || EMPTY_SETTINGS.targets.production.baseUrl, + associationAuthMode: parsed.data.targets.production.associationAuthMode || "local", + accueilAuthMode: parsed.data.targets.production.accueilAuthMode || "local", + adminAuthMode: parsed.data.targets.production.adminAuthMode || "local", + }, + secondary: { + ...EMPTY_SETTINGS.targets.secondary, + ...parsed.data.targets.secondary, + baseUrl: normalizeBaseUrl(parsed.data.targets.secondary.baseUrl), + associationAuthMode: parsed.data.targets.secondary.associationAuthMode || "local", + accueilAuthMode: parsed.data.targets.secondary.accueilAuthMode || "local", + adminAuthMode: parsed.data.targets.secondary.adminAuthMode || "local", + }, + }, + }; +} + +export async function getOperationalTourSettings() { + const raw = await db.getPortalSetting(OPERATIONAL_TOUR_SETTINGS_KEY); + if (!raw) return EMPTY_SETTINGS; + try { + return sanitizeOperationalTourSettings(JSON.parse(raw)); + } catch { + return EMPTY_SETTINGS; + } +} + +export async function saveOperationalTourSettings(settings: OperationalTourSettings) { + const sanitized = sanitizeOperationalTourSettings(settings); + await db.setPortalSetting( + OPERATIONAL_TOUR_SETTINGS_KEY, + JSON.stringify(sanitized), + "Configuration du tour opérationnel du site" + ); + return sanitized; +} + +export async function getOperationalTourHistory() { + const raw = await db.getPortalSetting(OPERATIONAL_TOUR_HISTORY_KEY); + if (!raw) return [] as OperationalTourReport[]; + try { + const parsed = z.array(operationalTourReportSchema).parse(JSON.parse(raw)); + return parsed; + } catch { + return [] as OperationalTourReport[]; + } +} + +function compactOperationalTourHistory(history: OperationalTourReport[]) { + const compactStep = (step: OperationalTourStep): OperationalTourStep => ({ + id: step.id, + label: step.label, + kind: step.kind, + status: step.status, + durationMs: step.durationMs, + message: step.message, + critical: step.critical ?? true, + ...(step.status !== "ok" && step.details ? { details: step.details } : {}), + ...(step.status !== "ok" && step.url ? { url: step.url } : {}), + }); + + const buildSignature = (report: OperationalTourReport) => JSON.stringify({ + environment: report.environment, + baseUrl: report.baseUrl, + status: report.status, + summary: report.summary, + steps: report.steps.map((step) => ({ + id: step.id, + kind: step.kind, + status: step.status, + message: step.message, + details: step.details || "", + })), + }); + + const dedupedHistory: OperationalTourReport[] = []; + for (const report of history) { + const previous = dedupedHistory[dedupedHistory.length - 1]; + if (previous && previous.environment === report.environment) { + const previousTime = new Date(previous.finishedAt).getTime(); + const currentTime = new Date(report.finishedAt).getTime(); + if ( + Number.isFinite(previousTime) && + Number.isFinite(currentTime) && + Math.abs(currentTime - previousTime) <= OPERATIONAL_TOUR_DUPLICATE_WINDOW_MS && + buildSignature(previous) === buildSignature(report) + ) { + dedupedHistory[dedupedHistory.length - 1] = report; + continue; + } + } + dedupedHistory.push(report); + } + + let reports = dedupedHistory.slice(0, OPERATIONAL_TOUR_HISTORY_MAX_REPORTS).map((report) => ({ + ...report, + steps: report.steps.map(compactStep), + })); + + while (reports.length > 0) { + const serialized = JSON.stringify(reports); + if (Buffer.byteLength(serialized, "utf8") <= OPERATIONAL_TOUR_HISTORY_MAX_BYTES) { + return reports; + } + reports = reports.slice(0, -1); + } + + return []; +} + +export async function saveOperationalTourHistory(history: OperationalTourReport[]) { + await db.setPortalSetting( + OPERATIONAL_TOUR_HISTORY_KEY, + JSON.stringify(compactOperationalTourHistory(history)), + "Historique du tour opérationnel du site" + ); +} + +export async function clearOperationalTourHistory() { + const history = await getOperationalTourHistory(); + const seen = new Set(); + const retained = history.filter((report) => { + if (seen.has(report.environment)) { + return false; + } + seen.add(report.environment); + return true; + }); + await saveOperationalTourHistory(retained); + return retained; +} + +export async function runOperationalTour(params: { + environment: OperationalTourEnvironment; + req: Request; + currentUser: User; +}) { + const startedAt = new Date(); + const settings = await getOperationalTourSettings(); + const target = settings.targets[params.environment]; + const baseUrl = normalizeBaseUrl(target.baseUrl); + const environmentLabel = params.environment === "production" ? "Production" : "Préproduction / Local"; + const steps: OperationalTourStep[] = []; + + if (!baseUrl) { + const report: OperationalTourReport = { + id: `tour-${Date.now()}`, + environment: params.environment, + environmentLabel, + baseUrl: "", + status: "error", + startedAt: startedAt.toISOString(), + finishedAt: new Date().toISOString(), + durationMs: 0, + initiatedBy: { + userId: params.currentUser.id, + name: params.currentUser.name || params.currentUser.email || `Utilisateur #${params.currentUser.id}`, + email: params.currentUser.email || undefined, + }, + summary: { total: 1, ok: 0, warning: 0, error: 1 }, + steps: [ + { + id: "configuration", + label: "Configuration de l’environnement", + kind: "technical", + status: "error", + durationMs: 0, + message: "URL cible manquante", + details: `Aucune URL n’est configurée pour ${environmentLabel.toLowerCase()}.`, + critical: true, + }, + ], + }; + const nextHistory = [report, ...(await getOperationalTourHistory())]; + await saveOperationalTourHistory(nextHistory); + return report; + } + + const currentOrigin = buildRequestOrigin(params.req); + const isSameOrigin = currentOrigin && normalizeBaseUrl(currentOrigin) === baseUrl; + const incomingCookieHeader = typeof params.req.headers.cookie === "string" ? params.req.headers.cookie : ""; + + const homeDiagnostics = await fetchHomeDiagnostics(baseUrl); + + if (homeDiagnostics.ok) { + steps.push( + buildStaticStep({ + id: "technical-performance-home", + label: "Performance de l’accueil", + kind: "technical", + status: homeDiagnostics.durationMs <= 1500 ? "ok" : homeDiagnostics.durationMs <= 3000 ? "warning" : "error", + message: + homeDiagnostics.durationMs <= 1500 + ? "Temps de réponse confortable" + : homeDiagnostics.durationMs <= 3000 + ? "Temps de réponse correct mais à surveiller" + : "Temps de réponse trop élevé", + details: `${homeDiagnostics.durationMs} ms`, + critical: false, + durationMs: homeDiagnostics.durationMs, + }), + buildStaticStep({ + id: "technical-security-baseline", + label: "Sécurité de base", + kind: "technical", + status: baseUrl.startsWith("https://") ? "ok" : "warning", + message: baseUrl.startsWith("https://") ? "Le site est servi en HTTPS" : "Le site n’utilise pas HTTPS", + details: homeDiagnostics.finalUrl, + critical: false, + }), + buildStaticStep({ + id: "technical-accessibility-baseline", + label: "Accessibilité de base", + kind: "technical", + status: + homeDiagnostics.body.includes("") + && /<html[^>]+lang=/i.test(homeDiagnostics.body) + ? "ok" + : "warning", + message: + homeDiagnostics.body.includes("<title>") + && /<html[^>]+lang=/i.test(homeDiagnostics.body) + ? "Titre de page et langue du document détectés" + : "Titre ou langue du document à confirmer", + details: homeDiagnostics.finalUrl, + critical: false, + }), + buildStaticStep({ + id: "technical-mobile-baseline", + label: "Compatibilité mobile", + kind: "technical", + status: /<meta[^>]+name=["']viewport["']/i.test(homeDiagnostics.body) ? "ok" : "warning", + message: /<meta[^>]+name=["']viewport["']/i.test(homeDiagnostics.body) + ? "Viewport mobile détecté" + : "Meta viewport absente ou non détectée", + details: homeDiagnostics.finalUrl, + critical: false, + }) + ); + } else { + steps.push( + buildStaticStep({ + id: "technical-performance-home", + label: "Performance de l’accueil", + kind: "technical", + status: "error", + message: homeDiagnostics.error || "Impossible de mesurer l’accueil", + details: homeDiagnostics.finalUrl, + critical: false, + durationMs: homeDiagnostics.durationMs, + }) + ); + } + + steps.push( + await fetchJsonCheck({ + id: "technical-api-public", + label: "API publique", + kind: "technical", + url: `${baseUrl}/api/trpc/auth.providers?input=%7B%7D`, + markers: ['"google"', '"facebook"'], + }) + ); + + let adminCookieHeader: string | null = isSameOrigin && incomingCookieHeader ? incomingCookieHeader : null; + const hasConfiguredAdminAccount = Boolean( + target.adminEmail && + (target.adminAuthMode === "oauth" || target.adminPassword) + ); + if (!adminCookieHeader && hasConfiguredAdminAccount) { + try { + adminCookieHeader = await createConfiguredSessionCookie(target.adminEmail, target.adminAuthMode === "oauth" ? "" : target.adminPassword); + } catch (error) { + const usesOAuthOnly = target.adminAuthMode === "oauth"; + steps.push({ + id: "admin-config", + label: "Configuration compte admin de contrôle", + kind: "user", + status: "warning", + durationMs: 0, + message: usesOAuthOnly ? "Impossible d’ouvrir une session admin OAuth" : "Impossible d’ouvrir une session admin dédiée", + details: error instanceof Error ? error.message : usesOAuthOnly ? "Compte OAuth admin introuvable" : "Identifiants admin invalides", + critical: false, + }); + } + } + + let associationCookieHeader: string | null = null; + const hasConfiguredAssociationAccount = Boolean( + target.associationEmail && + (target.associationAuthMode === "oauth" || target.associationPassword) + ); + if (hasConfiguredAssociationAccount) { + try { + associationCookieHeader = await createConfiguredSessionCookie( + target.associationEmail, + target.associationAuthMode === "oauth" ? "" : target.associationPassword + ); + } catch (error) { + const usesOAuthOnly = target.associationAuthMode === "oauth"; + steps.push({ + id: "association-config", + label: "Configuration compte association de test", + kind: "user", + status: "warning", + durationMs: 0, + message: usesOAuthOnly ? "Impossible d’ouvrir une session association OAuth" : "Impossible d’ouvrir une session association", + details: error instanceof Error ? error.message : usesOAuthOnly ? "Compte OAuth association introuvable" : "Identifiants association invalides", + critical: false, + }); + } + } else { + steps.push({ + id: "association-config", + label: "Configuration compte association de test", + kind: "user", + status: "warning", + durationMs: 0, + message: "Compte association non configuré", + details: "Les contrôles authentifiés association sont ignorés tant que l’email et le mot de passe ne sont pas renseignés.", + critical: false, + }); + } + + let accueilCookieHeader: string | null = null; + const hasConfiguredAccueilAccount = Boolean( + target.accueilEmail && + (target.accueilAuthMode === "oauth" || target.accueilPassword) + ); + if (hasConfiguredAccueilAccount) { + try { + accueilCookieHeader = await createConfiguredSessionCookie( + target.accueilEmail, + target.accueilAuthMode === "oauth" ? "" : target.accueilPassword + ); + } catch (error) { + const usesOAuthOnly = target.accueilAuthMode === "oauth"; + steps.push(buildStaticStep({ + id: "accueil-config", + label: "Configuration compte accueil / hôtesse", + kind: "user", + status: "warning", + message: usesOAuthOnly ? "Impossible d’ouvrir une session accueil OAuth" : "Impossible d’ouvrir une session accueil", + details: error instanceof Error ? error.message : usesOAuthOnly ? "Compte OAuth accueil introuvable" : "Identifiants accueil invalides", + critical: false, + })); + } + } else { + steps.push(buildStaticStep({ + id: "accueil-config", + label: "Configuration compte accueil / hôtesse", + kind: "user", + status: "warning", + message: "Compte accueil non configuré", + details: "Le parcours hôtesse reste ignoré tant qu’un compte accueil n’est pas renseigné.", + critical: false, + })); + } + + steps.push( + await fetchPageCheck({ id: "technical-public-home", label: "Navigation publique • Accueil", kind: "technical", url: `${baseUrl}/`, expectedPath: "/", markers: ["Portail Associations", "<title>Portail Associations"] }), + await fetchPageCheck({ id: "technical-public-login", label: "Navigation publique • Connexion", kind: "technical", url: `${baseUrl}/login`, expectedPath: "/login", markers: ["Connexion", "Se connecter"] }), + await fetchPageCheck({ id: "technical-directory", label: "Annuaire public des associations", kind: "technical", url: `${baseUrl}/associations`, expectedPath: "/associations", markers: ["Annuaire des associations", "associationDirectory.listPortal"] }), + await fetchPageCheck({ id: "technical-map", label: "Cartographie des associations", kind: "technical", url: `${baseUrl}/associations/carte`, expectedPath: "/associations/carte", markers: ["Cartographie interactive", "associationDirectory.listMap"] }), + ); + + const publicProfileUrl = await tryFindPublicProfileUrl(baseUrl); + if (publicProfileUrl) { + steps.push( + await fetchPageCheck({ + id: "technical-public-profile", + label: "Fiche publique association", + kind: "technical", + url: publicProfileUrl, + expectedPath: new URL(publicProfileUrl).pathname, + markers: ["Coordonnées", "actualités et publications"], + critical: false, + }) + ); + } else { + steps.push(buildStaticStep({ + id: "technical-public-profile", + label: "Fiche publique association", + kind: "technical", + status: "warning", + durationMs: 0, + message: "Aucune fiche publique détectée", + details: "Le tour n’a pas trouvé de lien de fiche association dans l’annuaire public.", + critical: false, + })); + } + + if (associationCookieHeader) { + steps.push( + await fetchJsonCheck({ + id: "technical-api-association", + label: "API association", + kind: "technical", + url: `${baseUrl}/api/trpc/auth.me?input=%7B%7D`, + cookieHeader: associationCookieHeader, + markers: ['"email"', '"role"'], + }), + await fetchPageCheck({ id: "user-association-dashboard", label: "Parcours association • Tableau de bord", kind: "user", url: `${baseUrl}/dashboard`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard", markers: ["Tableau de bord", "Portail Associations"] }), + await fetchPageCheck({ id: "user-association-requests", label: "Parcours association • Mes demandes", kind: "user", url: `${baseUrl}/dashboard/requests`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/requests", markers: ["Mes demandes"] }), + await fetchPageCheck({ id: "user-association-documents", label: "Parcours association • Mes documents", kind: "user", url: `${baseUrl}/dashboard/documents`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/documents", markers: ["Mes documents"] }), + await fetchPageCheck({ id: "user-association-directory", label: "Parcours association • Annuaire", kind: "user", url: `${baseUrl}/dashboard/associations`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/associations", markers: ["Annuaire des associations"], critical: false }), + await fetchPageCheck({ id: "user-association-form-salle", label: "Formulaire • Réservation de salle", kind: "user", url: `${baseUrl}/dashboard/reservation-salle`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/reservation-salle", markers: ["Réservation de salle", "Soumettre"] }), + await fetchPageCheck({ id: "user-association-form-request", label: "Formulaire • Nouvelle demande", kind: "user", url: `${baseUrl}/dashboard/requests/new`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/requests/new", markers: ["Nouvelle demande", "Soumettre"] }), + await fetchPageCheck({ id: "user-association-form-material", label: "Formulaire • Matériel événementiel", kind: "user", url: `${baseUrl}/dashboard/materiel-evenementiel`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/materiel-evenementiel", markers: ["matériel événementiel", "Soumettre"], critical: false }), + ); + } + + if (accueilCookieHeader) { + steps.push( + await fetchJsonCheck({ + id: "technical-api-accueil", + label: "API accueil / hôtesse", + kind: "technical", + url: `${baseUrl}/api/trpc/auth.me?input=%7B%7D`, + cookieHeader: accueilCookieHeader, + markers: ['"accueil"'], + critical: false, + }), + await fetchPageCheck({ id: "user-accueil-dashboard", label: "Parcours hôtesse • Administration", kind: "user", url: `${baseUrl}/admin`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Administration accueil", "Portail Associations"] }), + await fetchPageCheck({ id: "user-accueil-requests", label: "Parcours hôtesse • Gestion des demandes", kind: "user", url: `${baseUrl}/admin?tab=requests`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Gestion des demandes"] }), + await fetchPageCheck({ id: "user-accueil-calendar", label: "Parcours hôtesse • Calendrier", kind: "user", url: `${baseUrl}/admin?tab=calendrier`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Calendrier"] }), + await fetchPageCheck({ id: "user-accueil-notifications", label: "Parcours hôtesse • Notifications", kind: "user", url: `${baseUrl}/admin?tab=notifications`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Notifications"], critical: false }), + ); + } + + if (adminCookieHeader) { + steps.push( + await fetchJsonCheck({ + id: "technical-api-admin", + label: "API administration", + kind: "technical", + url: `${baseUrl}/api/internal/stats/reservations?period=month`, + cookieHeader: adminCookieHeader, + markers: ['"summary"', '"history"'], + }), + await fetchPageCheck({ id: "user-admin-dashboard", label: "Parcours administrateur • Administration", kind: "user", url: `${baseUrl}/admin`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Tableau de bord", "Portail Associations"] }), + await fetchPageCheck({ id: "user-admin-requests", label: "Parcours administrateur • Gestion des demandes", kind: "user", url: `${baseUrl}/admin?tab=requests`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Gestion des demandes"] }), + await fetchPageCheck({ id: "user-admin-calendar", label: "Parcours administrateur • Calendrier", kind: "user", url: `${baseUrl}/admin?tab=calendrier`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Calendrier"] }), + await fetchPageCheck({ id: "user-admin-analytics", label: "Parcours administrateur • Statistiques", kind: "user", url: `${baseUrl}/admin?tab=analytics`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Statistiques d’activité", "Tour opérationnel du site"] }), + await fetchPageCheck({ id: "user-admin-ccds-directory", label: "Parcours administrateur • Annuaire interne CCDS", kind: "user", url: `${baseUrl}/admin?tab=ccds-directory`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Annuaire interne CCDS"] }), + ); + } else { + steps.push(buildStaticStep({ + id: "admin-config", + label: "Configuration session admin", + kind: "user", + status: "warning", + durationMs: 0, + message: "Aucune session admin disponible pour cet environnement", + details: isSameOrigin + ? "Le navigateur n’a pas transmis de cookie de session exploitable." + : "Renseignez un compte admin dédié si vous voulez tester un autre environnement que celui où vous êtes connecté.", + critical: false, + })); + } + + const finishedAt = new Date(); + const report: OperationalTourReport = { + id: `tour-${finishedAt.getTime()}`, + environment: params.environment, + environmentLabel, + baseUrl, + status: computeReportStatus(steps), + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs: finishedAt.getTime() - startedAt.getTime(), + initiatedBy: { + userId: params.currentUser.id, + name: params.currentUser.name || params.currentUser.email || `Utilisateur #${params.currentUser.id}`, + email: params.currentUser.email || undefined, + }, + summary: summarizeSteps(steps), + steps, + }; + + const nextHistory = [report, ...(await getOperationalTourHistory())]; + await saveOperationalTourHistory(nextHistory); + + return report; +} diff --git a/server/pdfGenerator.test.ts b/server/pdfGenerator.test.ts new file mode 100644 index 0000000..6b0f1aa --- /dev/null +++ b/server/pdfGenerator.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest'; +import { generateMaterialEventPDF, generateReservationPDF } from './pdfGenerator'; + +describe('generateReservationPDF', () => { + it('should generate a PDF buffer for a complete reservation', async () => { + const data = { + titre: 'Réservation Salle TOUCAN - 15/04/2026', + status: 'soumise', + createdAt: new Date('2026-03-01'), + dateSubmission: new Date('2026-03-01'), + dateTraitement: null, + commentaireAdmin: null, + formData: { + nomAssociation: 'Association Test', + adresseAssociation: '12 rue de la Paix, 97300 Cayenne', + communeSiege: 'Cayenne', + representantLegal: 'Jean Dupont', + telephoneAssociation: '0694123456', + emailAssociation: 'test@asso.fr', + sallesSelectionnees: ['Salle TOUCAN', 'Salle IBIS'], + motifReservation: 'Assemblée générale annuelle', + dateReservation: '2026-04-15', + dateFinReservation: '2026-04-15', + heureDebut: '09:00', + heureFin: '17:00', + nombreParticipants: '50', + materielNecessaire: 'Tables, chaises, vidéoprojecteur', + observations: 'Prévoir un accès PMR', + }, + }; + + const buffer = await generateReservationPDF(data); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + // Verify PDF header + expect(buffer.slice(0, 5).toString()).toBe('%PDF-'); + }); + + it('should generate a PDF with DSU cadre filled', async () => { + const data = { + titre: 'Réservation Salle PELICAN', + status: 'validee', + createdAt: new Date('2026-03-01'), + dateSubmission: new Date('2026-03-01'), + dateTraitement: new Date('2026-03-10'), + commentaireAdmin: 'Demande acceptée sous conditions', + formData: { + nomAssociation: 'Association Sportive', + adresseAssociation: '5 avenue des Sports, 97360 Mana', + communeSiege: 'Mana', + representantLegal: 'Marie Martin', + telephoneAssociation: '0694567890', + emailAssociation: 'sport@asso.fr', + sallesSelectionnees: ['Salle PELICAN'], + motifReservation: 'Tournoi de basketball', + dateReservation: '2026-05-20', + dateFinReservation: '2026-05-21', + heureDebut: '08:00', + heureFin: '20:00', + nombreParticipants: '100', + materielNecessaire: 'Sono, tables de score', + observations: '', + cadreDSU: { + dateReception: '2026-03-02', + avisDSU: 'favorable_avec_reserves', + conditionsParticulieres: 'Nettoyage obligatoire après utilisation', + cautionRequise: true, + montantCaution: '500', + assuranceRequise: true, + horairesImposes: '08:00 - 20:00 strictement', + responsableDSU: 'Pierre Durand', + dateDecision: '2026-03-10', + observationsDSU: 'Veiller au respect des horaires de fermeture', + }, + }, + }; + + const buffer = await generateReservationPDF(data); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + expect(buffer.slice(0, 5).toString()).toBe('%PDF-'); + // PDF with DSU should be larger than without + expect(buffer.length).toBeGreaterThan(1000); + }); + + it('should generate a PDF with empty DSU cadre (pending)', async () => { + const data = { + titre: 'Réservation DOJO', + status: 'soumise', + createdAt: new Date('2026-03-05'), + formData: { + nomAssociation: 'Club Judo', + sallesSelectionnees: ['DOJO'], + motifReservation: 'Entraînement hebdomadaire', + dateReservation: '2026-04-01', + heureDebut: '18:00', + heureFin: '20:00', + }, + }; + + const buffer = await generateReservationPDF(data); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + expect(buffer.slice(0, 5).toString()).toBe('%PDF-'); + }); + + it('should handle missing optional fields gracefully', async () => { + const data = { + titre: 'Réservation minimale', + status: 'brouillon', + createdAt: new Date(), + formData: {}, + }; + + const buffer = await generateReservationPDF(data); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + expect(buffer.slice(0, 5).toString()).toBe('%PDF-'); + }); +}); + +describe('generateMaterialEventPDF', () => { + it('should generate a PDF buffer for an event material request', async () => { + const data = { + titre: 'Demande matériel événementiel', + status: 'validee', + createdAt: new Date('2026-03-01'), + dateSubmission: new Date('2026-03-01'), + dateTraitement: new Date('2026-03-10'), + commentaireAdmin: 'Accord partiel', + formData: { + nomAssociation: 'Association Test', + commune: 'Kourou', + direction: 'Sports', + service: 'Animation', + demandeurNomPrenom: 'Jean Dupont', + dateDemande: '2026-03-01', + dateManifestation: '2026-04-15', + dateDebutManifestation: '2026-04-15', + dateFinManifestation: '2026-04-16', + motifDemande: 'Fête communale', + datePriseEnCharge: '2026-04-15', + dateRestitution: '2026-04-19', + materielsDemandes: { tente3x3: true, podium: true, chapiteau5x5: false, autres: true }, + quantitesDemandees: { tente3x3: '5', podium: '1', chapiteau5x5: '', autres: '2' }, + autreMaterielPrecisions: 'Barrières', + cadreDSU: { + dateReception: '2026-03-02', + responsableDSU: 'Pierre Durand', + dateDecision: '2026-03-10', + observationsDSU: 'Prévoir le retrait la veille', + materielEvent: { + itemsAccordes: { tente3x3: true, podium: true, chapiteau5x5: false, autres: false }, + quantitesAccordees: { tente3x3: '3', podium: '1', chapiteau5x5: '', autres: '' }, + autresPrecisions: '', + }, + }, + }, + }; + + const buffer = await generateMaterialEventPDF(data); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + expect(buffer.slice(0, 5).toString()).toBe('%PDF-'); + }); +}); diff --git a/server/pdfGenerator.ts b/server/pdfGenerator.ts new file mode 100644 index 0000000..2b62d46 --- /dev/null +++ b/server/pdfGenerator.ts @@ -0,0 +1,958 @@ +import PDFDocument from 'pdfkit'; +import fs from 'node:fs'; +import path from 'node:path'; +import { SALLE_FREQUENCY_LABELS, SALLE_USAGE_TYPE_LABELS } from '@shared/sallePricing'; + +interface ReservationPDFData { + titre: string; + status: string; + createdAt: string | Date; + dateSubmission?: string | Date | null; + dateTraitement?: string | Date | null; + commentaireAdmin?: string | null; + formData: { + nomAssociation?: string; + adresseAssociation?: string; + communeSiege?: string; + representantLegal?: string; + telephoneAssociation?: string; + emailAssociation?: string; + sallesSelectionnees?: string[]; + motifReservation?: string; + dateReservation?: string; + dateFinReservation?: string; + heureDebut?: string; + heureFin?: string; + useDetailedSchedule?: boolean; + horairesParJour?: Array<{ + date?: string; + heureDebut?: string; + heureFin?: string; + }>; + typeUsage?: 'conventionne' | 'occasionnel'; + frequence?: 'demi_journee' | 'journee' | 'mensuel'; + nombreParticipants?: string; + besoinsComplementaires?: string; + materielNecessaire?: string; + observations?: string; + salleWorkflow?: { + pricing?: { + totalAmountCents?: number; + }; + directorSignedAt?: string; + directorSignedByName?: string; + }; + cadreDSU?: { + dateReception?: string; + avisDSU?: string; + conditionsParticulieres?: string; + cautionRequise?: boolean; + montantCaution?: string; + assuranceRequise?: boolean; + horairesImposes?: string; + responsableDSU?: string; + dateDecision?: string; + observationsDSU?: string; + materielEvent?: { + financialDecision?: { + financialMode?: 'gratuite' | 'gratuite_avec_caution' | 'location_payante'; + depositRequired?: boolean; + depositAmountCents?: number; + rentalAmountCents?: number; + pricingNotes?: string; + contractStatus?: 'a_generer' | 'generee' | 'signee' | 'refusee' | 'annulee'; + }; + }; + }; + }; +} + +interface MaterialEventPDFData { + titre: string; + status: string; + createdAt: string | Date; + dateSubmission?: string | Date | null; + dateTraitement?: string | Date | null; + commentaireAdmin?: string | null; + formData: { + nomAssociation?: string; + adresseAssociation?: string; + communeSiege?: string; + representantLegal?: string; + telephoneAssociation?: string; + emailAssociation?: string; + commune?: string; + direction?: string; + service?: string; + demandeurNomPrenom?: string; + dateDemande?: string; + dateManifestation?: string; + dateDebutManifestation?: string; + dateFinManifestation?: string; + motifDemande?: string; + datePriseEnCharge?: string; + dateRestitution?: string; + autreMaterielPrecisions?: string; + materielsDemandes?: Record; + quantitesDemandees?: Record; + cadreDSU?: { + dateReception?: string; + cautionRequise?: boolean; + montantCaution?: string; + responsableDSU?: string; + dateDecision?: string; + observationsDSU?: string; + materielEvent?: { + itemsAccordes?: Record; + quantitesAccordees?: Record; + autresPrecisions?: string; + financialDecision?: { + financialMode?: 'gratuite' | 'gratuite_avec_caution' | 'location_payante'; + depositRequired?: boolean; + depositAmountCents?: number; + rentalAmountCents?: number; + pricingNotes?: string; + contractStatus?: 'a_generer' | 'generee' | 'signee' | 'refusee' | 'annulee'; + contractGeneratedAt?: string | Date | null; + }; + }; + }; + }; +} + +const statusLabels: Record = { + brouillon: 'Brouillon', + soumise: 'Soumise', + en_cours_traitement: 'En cours de traitement', + information_complementaire: 'Information complémentaire requise', + validee: 'Validée', + refusee: 'Refusée', +}; + +const avisDSULabels: Record = { + favorable: 'Favorable', + defavorable: 'Défavorable', + favorable_avec_reserves: 'Favorable avec réserves', +}; + +const materialEventLabels: Record = { + tente3x3: 'Tente 3x3', + chapiteau5x5: 'Chapiteau 5x5', + podium: 'Podium', + autres: 'Autres', +}; + +const CCDS_LOGO_PATH = path.resolve(process.cwd(), 'client/src/assets/ccds.png'); + +function formatDateFr(dateStr: string | Date | null | undefined): string { + if (!dateStr) return '-'; + try { + const d = new Date(dateStr); + return d.toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } catch { + return String(dateStr); + } +} + +function formatCurrency(cents: number | null | undefined): string { + if (cents === null || cents === undefined || !Number.isFinite(cents)) return '-'; + return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(cents / 100); +} + +function getFinancialModeLabel(mode: string | null | undefined): string { + switch (mode) { + case 'gratuite': + return 'Mise à disposition gratuite'; + case 'gratuite_avec_caution': + return 'Mise à disposition gratuite avec caution'; + case 'location_payante': + return 'Location payante'; + default: + return '-'; + } +} + +function getContractStatusLabel(status: string | null | undefined): string { + switch (status) { + case 'a_generer': + return 'À générer'; + case 'generee': + return 'Générée'; + case 'signee': + return 'Signée'; + case 'refusee': + return 'Refusée'; + case 'annulee': + return 'Annulée'; + default: + return '-'; + } +} + +function formatMaterialEventPeriod(formData: { + dateManifestation?: string; + dateDebutManifestation?: string; + dateFinManifestation?: string; +}): string { + const start = formData.dateDebutManifestation || formData.dateManifestation; + const end = formData.dateFinManifestation || formData.dateManifestation; + + if (!start && !end) return '-'; + if (start && end && start !== end) { + return `${formatDateFr(start)} au ${formatDateFr(end)}`; + } + return formatDateFr(start || end); +} + +function drawCcdsLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { + if (!fs.existsSync(CCDS_LOGO_PATH)) return; + try { + doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: 'center', valign: 'center' }); + } catch (error) { + console.warn('[PDF] Impossible de charger le logo CCDS:', error); + } +} + +// Color definitions (RGB) +const COLORS = { + primary: [0, 0, 102] as [number, number, number], // oklch(0.25 0.05 265) approx + primaryLight: [230, 230, 245] as [number, number, number], + text: [30, 30, 30] as [number, number, number], + muted: [120, 120, 120] as [number, number, number], + border: [200, 200, 200] as [number, number, number], + white: [255, 255, 255] as [number, number, number], + green: [34, 139, 34] as [number, number, number], + red: [220, 20, 60] as [number, number, number], + amber: [200, 150, 0] as [number, number, number], + blueBg: [235, 245, 255] as [number, number, number], + blueBorder: [180, 210, 240] as [number, number, number], + blueText: [50, 80, 140] as [number, number, number], +}; + +export function generateReservationPDF(data: ReservationPDFData): Promise { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: 'A4', + margins: { top: 40, bottom: 40, left: 50, right: 50 }, + info: { + Title: `Formulaire de Réservation - ${data.formData.nomAssociation || 'Association'}`, + Author: 'Communauté de Communes Des Savanes', + Subject: 'Réservation de local - Maison de la Jeunesse des Savanes', + }, + }); + + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + const dateDebut = formatDateFr(data.formData.dateReservation); + const dateFin = data.formData.dateFinReservation && data.formData.dateFinReservation !== data.formData.dateReservation + ? formatDateFr(data.formData.dateFinReservation) + : dateDebut; + const dailySlots = Array.isArray(data.formData.horairesParJour) + ? data.formData.horairesParJour.filter((slot) => slot?.date) + : []; + const creneau = data.formData.heureDebut || data.formData.heureFin + ? `${data.formData.heureDebut || '?'} — ${data.formData.heureFin || '?'}` + : '-'; + + doc.save(); + doc.lineWidth(2) + .strokeColor('#f0c94b') + .moveTo(doc.page.margins.left, y) + .lineTo(doc.page.margins.left + pageWidth, y) + .stroke(); + doc.restore(); + + y += 16; + drawCcdsLogo(doc, doc.page.margins.left + (pageWidth - 90) / 2, y, 90, 90); + y += 96; + doc.fillColor(COLORS.muted) + .fontSize(9) + .font('Helvetica') + .text('COMMUNAUTÉ DE COMMUNES DES SAVANES', doc.page.margins.left, y, { + width: pageWidth, + align: 'center', + }); + y += 18; + doc.fillColor(COLORS.text) + .fontSize(18) + .font('Helvetica-Bold') + .text('FORMULAIRE ADMINISTRATIF DE RÉSERVATION', doc.page.margins.left, y, { + width: pageWidth, + align: 'center', + }); + y += 20; + doc.text('D\'UN LOCAL - MAISON DE LA JEUNESSE DES SAVANES', doc.page.margins.left, y, { + width: pageWidth, + align: 'center', + }); + y += 26; + + doc.save(); + doc.roundedRect(doc.page.margins.left + 18, y, pageWidth - 36, 34, 4) + .fill('#d9eefc') + .stroke('#f0c94b'); + doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold') + .text('Réservation à transmettre à la Direction des Services aux Usagers', doc.page.margins.left + 28, y + 7, { + width: pageWidth - 56, + align: 'center', + }); + doc.fontSize(9).font('Helvetica') + .text('Merci de compléter les informations demandées avant instruction', doc.page.margins.left + 28, y + 19, { + width: pageWidth - 56, + align: 'center', + }); + doc.restore(); + y += 50; + + // Statut de la demande + const statusLabel = statusLabels[data.status] || data.status; + doc.fillColor(COLORS.muted) + .fontSize(8) + .font('Helvetica') + .text(`Statut : ${statusLabel} | Créée le : ${formatDateFr(data.createdAt)}`, doc.page.margins.left, y, { + width: pageWidth, + align: 'right', + }); + y += 18; + + // ========================================== + // SECTION 1 : INFORMATIONS DE L'ASSOCIATION + // ========================================== + y = drawSectionTitle(doc, 'INFORMATIONS DE L\'ASSOCIATION', y, pageWidth); + + const assoRows = [ + ['Nom de l\'association', data.formData.nomAssociation || '-'], + ['Adresse', data.formData.adresseAssociation || '-'], + ['Commune du siège', data.formData.communeSiege || '-'], + ['Représentant légal', data.formData.representantLegal || '-'], + ['Téléphone', data.formData.telephoneAssociation || '-'], + ['Email', data.formData.emailAssociation || '-'], + ]; + y = drawTable(doc, assoRows, y, pageWidth); + y += 15; + + // ========================================== + // SECTION 2 : LOCAL(AUX) SOLLICITÉ(S) + // ========================================== + y = drawSectionTitle(doc, 'LOCAL(AUX) SOLLICITÉ(S)', y, pageWidth); + + const salles = data.formData.sallesSelectionnees?.join(', ') || '-'; + doc.fillColor(COLORS.text) + .fontSize(10) + .font('Helvetica') + .text(salles, doc.page.margins.left + 5, y, { width: pageWidth - 10 }); + y += doc.heightOfString(salles, { width: pageWidth - 10 }) + 15; + + y = checkPageBreak(doc, y, 200); + y = drawSectionTitle(doc, 'DÉTAILS DE LA RÉSERVATION', y, pageWidth); + y = drawTable(doc, [['Motif de la réservation', data.formData.motifReservation || '-']], y, pageWidth); + y += 8; + + const hasDetailedSchedule = Boolean(data.formData.useDetailedSchedule && dailySlots.length > 0); + const scheduleBoxHeight = hasDetailedSchedule ? 70 + (dailySlots.length * 18) : 70; + y = checkPageBreak(doc, y, scheduleBoxHeight + 18); + doc.save(); + doc.roundedRect(doc.page.margins.left, y, pageWidth, scheduleBoxHeight, 4) + .fill('#f8fafc') + .stroke(COLORS.border); + doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold') + .text('PÉRIODE ET CRÉNEAU SOLLICITÉS', doc.page.margins.left + 14, y + 10, { width: pageWidth - 28 }); + doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica') + .text('Du', doc.page.margins.left + 14, y + 34); + doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold') + .text(dateDebut, doc.page.margins.left + 34, y + 33); + doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica') + .text('Au', doc.page.margins.left + pageWidth / 2, y + 34); + doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold') + .text(dateFin, doc.page.margins.left + pageWidth / 2 + 20, y + 33, { + width: pageWidth / 2 - 34, + }); + if (hasDetailedSchedule) { + doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica') + .text('Organisation', doc.page.margins.left + 14, y + 52); + doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold') + .text(`Horaires détaillés sur ${dailySlots.length} jour(s)`, doc.page.margins.left + 72, y + 51, { + width: pageWidth - 86, + }); + + let slotY = y + 68; + dailySlots.forEach((slot) => { + doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica') + .text(formatDateFr(slot.date || ''), doc.page.margins.left + 28, slotY); + doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold') + .text(`${slot.heureDebut || '?'} — ${slot.heureFin || '?'}`, doc.page.margins.left + 160, slotY, { + width: pageWidth - 190, + }); + slotY += 16; + }); + } else { + doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica') + .text('Créneau', doc.page.margins.left + 14, y + 52); + doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold') + .text(creneau, doc.page.margins.left + 72, y + 51, { + width: pageWidth - 86, + }); + } + doc.restore(); + y += scheduleBoxHeight + 12; + + const detailRows = [ + ['Nombre prévisionnel de participants', data.formData.nombreParticipants ? `${data.formData.nombreParticipants} personnes` : '-'], + ['Type d’usage', data.formData.typeUsage ? SALLE_USAGE_TYPE_LABELS[data.formData.typeUsage] : '-'], + ['Fréquence', data.formData.frequence ? SALLE_FREQUENCY_LABELS[data.formData.frequence] : '-'], + ['Besoins complémentaires', data.formData.besoinsComplementaires || '-'], + ['Matériel nécessaire', data.formData.materielNecessaire || '-'], + ['Observations complémentaires', data.formData.observations || '-'], + ['Montant estimatif / validé', typeof data.formData.salleWorkflow?.pricing?.totalAmountCents === 'number' ? formatCurrency(data.formData.salleWorkflow.pricing.totalAmountCents) : '-'], + ]; + y = drawTable(doc, detailRows, y, pageWidth); + y += 18; + + // ========================================== + // SECTION 4 : CADRE RÉSERVÉ À LA DSU + // ========================================== + y = checkPageBreak(doc, y, 250); + y = drawSectionTitle(doc, 'CADRE RÉSERVÉ À LA DIRECTION DES SERVICES AUX USAGERS (DSU)', y, pageWidth); + + const dsu = data.formData.cadreDSU; + if (dsu) { + const financialDecision = dsu.materielEvent?.financialDecision; + // Avis DSU avec couleur + if (dsu.avisDSU) { + const avisLabel = avisDSULabels[dsu.avisDSU] || dsu.avisDSU; + const avisColor = dsu.avisDSU === 'favorable' ? COLORS.green + : dsu.avisDSU === 'defavorable' ? COLORS.red + : COLORS.amber; + + doc.fillColor(COLORS.muted) + .fontSize(9) + .font('Helvetica') + .text('Avis de la DSU : ', doc.page.margins.left + 5, y, { continued: true }); + doc.fillColor(avisColor) + .font('Helvetica-Bold') + .text(avisLabel); + y += 18; + } + + const dsuRows = [ + ['Date de réception', dsu.dateReception ? formatDateFr(dsu.dateReception) : '-'], + ['Date de la décision', dsu.dateDecision ? formatDateFr(dsu.dateDecision) : '-'], + ['Responsable DSU', dsu.responsableDSU || '-'], + ]; + y = drawTable(doc, dsuRows, y, pageWidth); + y += 10; + + // Conditions particulières + const conditions: string[] = []; + if (dsu.cautionRequise) { + conditions.push(`Caution requise${dsu.montantCaution ? ` : ${dsu.montantCaution} €` : ''}`); + } + if (dsu.assuranceRequise) { + conditions.push('Attestation d\'assurance requise'); + } + if (dsu.horairesImposes) { + conditions.push(`Horaires imposés : ${dsu.horairesImposes}`); + } + if (dsu.conditionsParticulieres) { + conditions.push(dsu.conditionsParticulieres); + } + + if (conditions.length > 0) { + y = checkPageBreak(doc, y, 80); + doc.fillColor(COLORS.primary) + .fontSize(9) + .font('Helvetica-Bold') + .text('Conditions particulières :', doc.page.margins.left + 5, y); + y += 14; + + for (const condition of conditions) { + y = checkPageBreak(doc, y, 20); + doc.fillColor(COLORS.text) + .fontSize(9) + .font('Helvetica') + .text(`• ${condition}`, doc.page.margins.left + 15, y, { width: pageWidth - 25 }); + y += doc.heightOfString(`• ${condition}`, { width: pageWidth - 25 }) + 4; + } + y += 5; + } + + if (financialDecision?.financialMode) { + y = checkPageBreak(doc, y, 96); + doc.fillColor(COLORS.primary) + .fontSize(9) + .font('Helvetica-Bold') + .text('Conditions financières / location :', doc.page.margins.left + 5, y); + y += 14; + + const financialRows = [ + ['Mode financier', getFinancialModeLabel(financialDecision.financialMode)], + ['Caution / dépôt', financialDecision.depositRequired ? formatCurrency(financialDecision.depositAmountCents ?? 0) : 'Aucune'], + ['Montant de location', financialDecision.financialMode === 'location_payante' ? formatCurrency(financialDecision.rentalAmountCents ?? 0) : 'Non applicable'], + ['Statut de la convention', getContractStatusLabel(financialDecision.contractStatus)], + ]; + y = drawTable(doc, financialRows, y, pageWidth); + y += 8; + + if (financialDecision.pricingNotes) { + y = checkPageBreak(doc, y, 50); + doc.fillColor(COLORS.primary) + .fontSize(9) + .font('Helvetica-Bold') + .text('Clauses spécifiques :', doc.page.margins.left + 5, y); + y += 14; + doc.fillColor(COLORS.text) + .fontSize(9) + .font('Helvetica') + .text(financialDecision.pricingNotes, doc.page.margins.left + 15, y, { width: pageWidth - 25 }); + y += doc.heightOfString(financialDecision.pricingNotes, { width: pageWidth - 25 }) + 10; + } + } + + // Observations DSU + if (dsu.observationsDSU) { + y = checkPageBreak(doc, y, 50); + doc.fillColor(COLORS.primary) + .fontSize(9) + .font('Helvetica-Bold') + .text('Observations de la DSU :', doc.page.margins.left + 5, y); + y += 14; + doc.fillColor(COLORS.text) + .fontSize(9) + .font('Helvetica') + .text(dsu.observationsDSU, doc.page.margins.left + 15, y, { width: pageWidth - 25 }); + y += doc.heightOfString(dsu.observationsDSU, { width: pageWidth - 25 }) + 10; + } + } else { + // Cadre vide avec lignes pointillées + const emptyRows = [ + ['Date de réception', ''], + ['Avis', ''], + ['Conditions particulières', ''], + ['Responsable DSU', ''], + ['Date de la décision', ''], + ['Observations', ''], + ]; + y = drawTable(doc, emptyRows, y, pageWidth, true); + } + + y += 18; + y = checkPageBreak(doc, y, 80); + doc.strokeColor(COLORS.border) + .lineWidth(0.5) + .moveTo(doc.page.margins.left + 10, y + 28) + .lineTo(doc.page.margins.left + pageWidth / 2 - 20, y + 28) + .stroke(); + doc.strokeColor(COLORS.border) + .lineWidth(0.5) + .moveTo(doc.page.margins.left + pageWidth / 2 + 20, y + 28) + .lineTo(doc.page.margins.left + pageWidth - 10, y + 28) + .stroke(); + doc.fillColor(COLORS.text).fontSize(9).font('Helvetica') + .text('Signature du représentant de l\'association', doc.page.margins.left + 10, y + 34, { + width: pageWidth / 2 - 30, + align: 'center', + }) + .text('Visa de la DSU', doc.page.margins.left + pageWidth / 2 + 20, y + 34, { + width: pageWidth / 2 - 30, + align: 'center', + }); + + const directorSignedAt = data.formData.salleWorkflow?.directorSignedAt; + const directorSignedByName = data.formData.salleWorkflow?.directorSignedByName; + if (directorSignedAt) { + doc.fillColor(COLORS.blueText).fontSize(8).font('Helvetica-Bold') + .text('Validé électroniquement', doc.page.margins.left + pageWidth / 2 + 20, y + 49, { + width: pageWidth / 2 - 30, + align: 'center', + }); + doc.fillColor(COLORS.text).fontSize(8).font('Helvetica') + .text( + `${formatDateFr(directorSignedAt)}${directorSignedByName ? ` · ${directorSignedByName}` : ''}`, + doc.page.margins.left + pageWidth / 2 + 20, + y + 61, + { + width: pageWidth / 2 - 30, + align: 'center', + } + ); + } + + // ========================================== + // COMMENTAIRE ADMIN (si présent) + // ========================================== + if (data.commentaireAdmin) { + y = checkPageBreak(doc, y, 60); + y += 10; + doc.fillColor(COLORS.primary) + .fontSize(9) + .font('Helvetica-Bold') + .text('Commentaire de l\'administration :', doc.page.margins.left + 5, y); + y += 14; + doc.fillColor(COLORS.text) + .fontSize(9) + .font('Helvetica') + .text(data.commentaireAdmin, doc.page.margins.left + 15, y, { width: pageWidth - 25 }); + y += doc.heightOfString(data.commentaireAdmin, { width: pageWidth - 25 }) + 10; + } + + // ========================================== + // PIED DE PAGE + // ========================================== + const footerY = doc.page.height - doc.page.margins.bottom - 30; + doc.strokeColor(COLORS.border) + .lineWidth(0.5) + .moveTo(doc.page.margins.left, footerY) + .lineTo(doc.page.margins.left + pageWidth, footerY) + .stroke(); + + doc.fillColor(COLORS.muted) + .fontSize(7) + .font('Helvetica') + .text( + `Document généré le ${formatDateFr(new Date())} — Communauté de Communes Des Savanes — Direction des Services aux Usagers`, + doc.page.margins.left, + footerY + 8, + { width: pageWidth, align: 'center' } + ); + + if (directorSignedAt) { + doc.text( + `Visa Direction enregistré le ${formatDateFr(directorSignedAt)}${directorSignedByName ? ` par ${directorSignedByName}` : ''}`, + doc.page.margins.left, + footerY + 18, + { width: pageWidth, align: 'center' } + ); + } + + doc.end(); + } catch (err) { + reject(err); + } + }); +} + +export function generateMaterialEventPDF(data: MaterialEventPDFData): Promise { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: 'A4', + margins: { top: 40, bottom: 40, left: 50, right: 50 }, + info: { + Title: `Demande matériel - ${data.formData.nomAssociation || data.formData.commune || 'Association'}`, + Author: 'Communauté de Communes Des Savanes', + Subject: 'Demande de mise à disposition du matériel événementiel', + }, + }); + + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + const manifestationStart = data.formData.dateDebutManifestation || data.formData.dateManifestation; + const manifestationEnd = data.formData.dateFinManifestation || data.formData.dateManifestation; + + doc.save(); + doc.lineWidth(2) + .strokeColor('#f0c94b') + .moveTo(doc.page.margins.left, y) + .lineTo(doc.page.margins.left + pageWidth, y) + .stroke(); + doc.restore(); + + y += 16; + drawCcdsLogo(doc, doc.page.margins.left + (pageWidth - 90) / 2, y, 90, 90); + y += 96; + doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica') + .text('COMMUNAUTÉ DE COMMUNES DES SAVANES', doc.page.margins.left, y, { width: pageWidth, align: 'center' }); + y += 18; + doc.fillColor(COLORS.text).fontSize(18).font('Helvetica-Bold') + .text('FICHE DE DEMANDE DE MISE À DISPOSITION', doc.page.margins.left, y, { width: pageWidth, align: 'center' }); + y += 20; + doc.text('DU MATÉRIEL ÉVÉNEMENTIEL', doc.page.margins.left, y, { width: pageWidth, align: 'center' }); + y += 26; + + doc.save(); + doc.roundedRect(doc.page.margins.left + 18, y, pageWidth - 36, 34, 4) + .fill('#d9eefc') + .stroke('#f0c94b'); + doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold') + .text('Fiche à remplir obligatoirement (1 mois avant la manifestation)', doc.page.margins.left + 28, y + 7, { + width: pageWidth - 56, + align: 'center', + }); + doc.fontSize(9).font('Helvetica') + .text('À transmettre à la Direction des Services aux Usagers', doc.page.margins.left + 28, y + 19, { + width: pageWidth - 56, + align: 'center', + }); + doc.restore(); + y += 50; + + const statusLabel = statusLabels[data.status] || data.status; + doc.fillColor(COLORS.muted).fontSize(8).font('Helvetica') + .text(`Statut : ${statusLabel} | Créée le : ${formatDateFr(data.createdAt)}`, doc.page.margins.left, y, { + width: pageWidth, + align: 'right', + }); + y += 18; + + y = drawSectionTitle(doc, 'INFORMATIONS DU DEMANDEUR', y, pageWidth); + y = drawTable(doc, [ + ['Association', data.formData.nomAssociation || '-'], + ['Commune', data.formData.commune || data.formData.communeSiege || '-'], + ['Direction', data.formData.direction || '-'], + ['Service', data.formData.service || '-'], + ['Nom et prénom du demandeur', data.formData.demandeurNomPrenom || '-'], + ['Téléphone', data.formData.telephoneAssociation || '-'], + ['Email', data.formData.emailAssociation || '-'], + ], y, pageWidth); + y += 15; + + y = drawSectionTitle(doc, 'MATÉRIEL DEMANDÉ', y, pageWidth); + const requestedRows = Object.entries(materialEventLabels) + .filter(([key]) => data.formData.materielsDemandes?.[key]) + .map(([key, label]) => [ + label, + `${data.formData.quantitesDemandees?.[key] || '-'}${key === 'autres' && data.formData.autreMaterielPrecisions ? ` — ${data.formData.autreMaterielPrecisions}` : ''}`, + ]); + y = drawTable(doc, requestedRows.length > 0 ? requestedRows : [['Aucun matériel demandé', '-']], y, pageWidth); + y += 15; + + y = drawSectionTitle(doc, 'DÉTAILS DE LA DEMANDE', y, pageWidth); + y = drawTable(doc, [['Date de la demande', formatDateFr(data.formData.dateDemande)]], y, pageWidth); + y += 8; + + y = checkPageBreak(doc, y, 88); + doc.save(); + doc.roundedRect(doc.page.margins.left, y, pageWidth, 70, 4) + .fill('#f8fafc') + .stroke(COLORS.border); + doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold') + .text('PÉRIODE DE LA MANIFESTATION', doc.page.margins.left + 14, y + 10, { width: pageWidth - 28 }); + doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica') + .text('Du', doc.page.margins.left + 14, y + 34); + doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold') + .text(formatDateFr(manifestationStart), doc.page.margins.left + 34, y + 33); + doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica') + .text('Au', doc.page.margins.left + pageWidth / 2, y + 34); + doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold') + .text(formatDateFr(manifestationEnd), doc.page.margins.left + pageWidth / 2 + 20, y + 33, { + width: pageWidth / 2 - 34, + }); + doc.restore(); + y += 82; + + y = drawTable(doc, [ + ['Date prévisionnelle de prise en charge du matériel', formatDateFr(data.formData.datePriseEnCharge)], + ['Date prévisionnelle de restitution', formatDateFr(data.formData.dateRestitution)], + ['Motif de la demande', data.formData.motifDemande || '-'], + ], y, pageWidth); + y += 18; + + y = checkPageBreak(doc, y, 220); + y = drawSectionTitle(doc, 'CADRE RÉSERVÉ À LA DSU', y, pageWidth); + const dsu = data.formData.cadreDSU; + if (dsu) { + y = drawTable(doc, [ + ['Date de réception', formatDateFr(dsu.dateReception)], + ['Date de la décision', formatDateFr(dsu.dateDecision)], + ['Responsable DSU', dsu.responsableDSU || '-'], + ], y, pageWidth); + y += 10; + + const grantedRows = Object.entries(materialEventLabels).map(([key, label]) => { + const granted = dsu.materielEvent?.itemsAccordes?.[key]; + const qty = dsu.materielEvent?.quantitesAccordees?.[key]; + const extra = key === 'autres' ? dsu.materielEvent?.autresPrecisions : ''; + return [label, granted ? `${qty || '-'} accordé(s)${extra ? ` — ${extra}` : ''}` : (data.status === 'refusee' ? 'Refusé' : 'Non renseigné')]; + }); + y = drawTable(doc, grantedRows, y, pageWidth); + + const financialDecision = dsu.materielEvent?.financialDecision; + if (financialDecision?.financialMode) { + y += 12; + y = checkPageBreak(doc, y, 120); + doc.fillColor(COLORS.primary).fontSize(9).font('Helvetica-Bold') + .text('Conditions financières / convention :', doc.page.margins.left + 5, y); + y += 14; + + const financialRows = [ + ['Mode financier', getFinancialModeLabel(financialDecision.financialMode)], + ['Caution / dépôt de garantie', financialDecision.depositRequired ? formatCurrency(financialDecision.depositAmountCents ?? 0) : 'Aucune'], + ['Montant de location', financialDecision.financialMode === 'location_payante' ? formatCurrency(financialDecision.rentalAmountCents ?? 0) : 'Non applicable'], + ['Statut de la convention', getContractStatusLabel(financialDecision.contractStatus)], + ['Convention générée le', formatDateFr(financialDecision.contractGeneratedAt)], + ]; + y = drawTable(doc, financialRows, y, pageWidth); + + if (financialDecision.pricingNotes) { + y += 10; + doc.fillColor(COLORS.primary).fontSize(9).font('Helvetica-Bold') + .text('Clauses spécifiques :', doc.page.margins.left + 5, y); + y += 14; + doc.fillColor(COLORS.text).fontSize(9).font('Helvetica') + .text(financialDecision.pricingNotes, doc.page.margins.left + 5, y, { width: pageWidth - 10 }); + y += doc.heightOfString(financialDecision.pricingNotes, { width: pageWidth - 10 }) + 6; + } + } + + if (dsu.observationsDSU) { + y += 12; + doc.fillColor(COLORS.primary).fontSize(9).font('Helvetica-Bold') + .text('Observations de la DSU :', doc.page.margins.left + 5, y); + y += 14; + doc.fillColor(COLORS.text).fontSize(9).font('Helvetica') + .text(dsu.observationsDSU, doc.page.margins.left + 5, y, { width: pageWidth - 10 }); + } + } else { + doc.fillColor(COLORS.muted).fontSize(10).font('Helvetica-Oblique') + .text('Cadre DSU non encore renseigné.', doc.page.margins.left + 5, y); + } + + y += 18; + y = checkPageBreak(doc, y, 80); + doc.strokeColor(COLORS.border) + .lineWidth(0.5) + .moveTo(doc.page.margins.left + 10, y + 28) + .lineTo(doc.page.margins.left + pageWidth / 2 - 20, y + 28) + .stroke(); + doc.strokeColor(COLORS.border) + .lineWidth(0.5) + .moveTo(doc.page.margins.left + pageWidth / 2 + 20, y + 28) + .lineTo(doc.page.margins.left + pageWidth - 10, y + 28) + .stroke(); + doc.fillColor(COLORS.text).fontSize(9).font('Helvetica') + .text('Signature du DGS de la commune', doc.page.margins.left + 10, y + 34, { + width: pageWidth / 2 - 30, + align: 'center', + }) + .text('Signature de la DSU', doc.page.margins.left + pageWidth / 2 + 20, y + 34, { + width: pageWidth / 2 - 30, + align: 'center', + }); + + const footerY = doc.page.height - doc.page.margins.bottom - 30; + doc.strokeColor(COLORS.border) + .lineWidth(0.5) + .moveTo(doc.page.margins.left, footerY) + .lineTo(doc.page.margins.left + pageWidth, footerY) + .stroke(); + + doc.fillColor(COLORS.muted) + .fontSize(7) + .font('Helvetica') + .text( + `Document généré le ${formatDateFr(new Date())} — Communauté de Communes Des Savanes — Direction des Services aux Usagers`, + doc.page.margins.left, + footerY + 8, + { width: pageWidth, align: 'center' } + ); + + doc.end(); + } catch (error) { + reject(error); + } + }); +} + +// ========================================== +// HELPER FUNCTIONS +// ========================================== + +function drawSectionTitle(doc: PDFKit.PDFDocument, title: string, y: number, pageWidth: number): number { + const leftMargin = (doc as any).page.margins.left; + + // Background bar + doc.save(); + doc.rect(leftMargin - 5, y, pageWidth + 10, 20) + .fill(COLORS.primaryLight); + + doc.fillColor(COLORS.primary) + .fontSize(9) + .font('Helvetica-Bold') + .text(title, leftMargin + 5, y + 5, { width: pageWidth }); + + doc.restore(); + return y + 28; +} + +function drawTable(doc: PDFKit.PDFDocument, rows: string[][], y: number, pageWidth: number, emptyStyle: boolean = false): number { + const leftMargin = (doc as any).page.margins.left; + const labelWidth = pageWidth * 0.4; + const valueWidth = pageWidth * 0.6; + const rowPadding = 5; + + for (const [label, value] of rows) { + y = checkPageBreak(doc, y, 20); + + // Label + doc.fillColor(COLORS.muted) + .fontSize(9) + .font('Helvetica') + .text(label + ' :', leftMargin + rowPadding, y, { + width: labelWidth - rowPadding * 2, + }); + + // Value + if (emptyStyle && !value) { + // Draw dotted line for empty fields + doc.strokeColor(COLORS.border) + .lineWidth(0.5) + .dash(3, { space: 2 }) + .moveTo(leftMargin + labelWidth + 5, y + 10) + .lineTo(leftMargin + pageWidth - 5, y + 10) + .stroke() + .undash(); + } else { + doc.fillColor(COLORS.text) + .fontSize(9) + .font('Helvetica-Bold') + .text(value || '-', leftMargin + labelWidth, y, { + width: valueWidth - rowPadding, + align: 'right', + }); + } + + // Calculate the height used + const labelH = doc.heightOfString(label + ' :', { width: labelWidth - rowPadding * 2 }); + const valueH = value ? doc.heightOfString(value, { width: valueWidth - rowPadding }) : 12; + const rowHeight = Math.max(labelH, valueH) + 4; + + y += rowHeight; + + // Separator line + doc.strokeColor(COLORS.border) + .lineWidth(0.3) + .dash(1, { space: 2 }) + .moveTo(leftMargin + 5, y) + .lineTo(leftMargin + pageWidth - 5, y) + .stroke() + .undash(); + + y += 4; + } + + return y; +} + +function checkPageBreak(doc: PDFKit.PDFDocument, y: number, requiredSpace: number): number { + const pageBottom = doc.page.height - doc.page.margins.bottom - 40; + if (y + requiredSpace > pageBottom) { + doc.addPage(); + return doc.page.margins.top; + } + return y; +} diff --git a/server/requestDetail.test.ts b/server/requestDetail.test.ts new file mode 100644 index 0000000..b8ca646 --- /dev/null +++ b/server/requestDetail.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock db module +vi.mock('./db', () => ({ + getRequestById: vi.fn(), + getAssociationByUserId: vi.fn(), + getAssociationById: vi.fn(), + getDocumentById: vi.fn(), + getRequestHistoryByRequestId: vi.fn(), + deleteRequest: vi.fn(), + createAuditLog: vi.fn(), +})); + +import * as db from './db'; + +describe('Request Detail & Delete Logic', () => { + describe('getById enrichment', () => { + it('should return associationInfo when association exists', async () => { + const mockAssociation = { + id: 1, + nomAssociation: 'Association Test', + siret: '12345678901234', + adresse: '10 rue de la Paix', + codePostal: '97300', + ville: 'Cayenne', + telephone: '0594123456', + emailContact: 'test@asso.fr', + nomRepresentant: 'Jean Dupont', + }; + + (db.getAssociationById as any).mockResolvedValue(mockAssociation); + + const result = await db.getAssociationById(1); + expect(result).toBeDefined(); + expect(result?.nomAssociation).toBe('Association Test'); + expect(result?.siret).toBe('12345678901234'); + expect(result?.telephone).toBe('0594123456'); + }); + + it('should return null when association does not exist', async () => { + (db.getAssociationById as any).mockResolvedValue(null); + + const result = await db.getAssociationById(999); + expect(result).toBeNull(); + }); + + it('should resolve attached documents from documentsJoints JSON', async () => { + const mockDoc1 = { id: 1, nom: 'Statuts', type: 'statuts', fileSize: 1024 }; + const mockDoc2 = { id: 2, nom: 'RIB', type: 'rib', fileSize: 2048 }; + + (db.getDocumentById as any) + .mockResolvedValueOnce(mockDoc1) + .mockResolvedValueOnce(mockDoc2); + + const documentsJoints = JSON.stringify([1, 2]); + const docIds = JSON.parse(documentsJoints) as number[]; + + const docs = await Promise.all( + docIds.map(async (docId: number) => { + const doc = await db.getDocumentById(docId); + return doc || null; + }) + ); + const attachedDocuments = docs.filter(Boolean); + + expect(attachedDocuments).toHaveLength(2); + expect(attachedDocuments[0]?.nom).toBe('Statuts'); + expect(attachedDocuments[1]?.nom).toBe('RIB'); + }); + + it('should handle empty documentsJoints gracefully', () => { + const documentsJoints = null; + let attachedDocuments: any[] = []; + + if (documentsJoints) { + try { + const docIds = JSON.parse(documentsJoints) as number[]; + // would process here + } catch { + // ignore + } + } + + expect(attachedDocuments).toHaveLength(0); + }); + + it('should handle invalid JSON in documentsJoints', () => { + const documentsJoints = 'not-json'; + let attachedDocuments: any[] = []; + + if (documentsJoints) { + try { + const docIds = JSON.parse(documentsJoints) as number[]; + attachedDocuments = docIds as any[]; + } catch { + // ignore parse errors + } + } + + expect(attachedDocuments).toHaveLength(0); + }); + }); + + describe('delete request', () => { + it('should allow deletion of a brouillon request by the owning association', async () => { + const mockRequest = { + id: 1, + associationId: 10, + status: 'brouillon', + titre: 'Ma demande', + type: 'subvention_fonctionnement', + }; + const mockAssociation = { id: 10, userId: 5 }; + + (db.getRequestById as any).mockResolvedValue(mockRequest); + (db.getAssociationByUserId as any).mockResolvedValue(mockAssociation); + (db.deleteRequest as any).mockResolvedValue(undefined); + + // Simulate the logic + const request = await db.getRequestById(1); + const association = await db.getAssociationByUserId(5); + + expect(request).toBeDefined(); + expect(association).toBeDefined(); + expect(request!.associationId).toBe(association!.id); + expect(request!.status).not.toBe('validee'); + expect(request!.status).not.toBe('refusee'); + + await db.deleteRequest(1); + expect(db.deleteRequest).toHaveBeenCalledWith(1); + }); + + it('should reject deletion of a validated request', async () => { + const mockRequest = { + id: 2, + associationId: 10, + status: 'validee', + titre: 'Demande validée', + type: 'subvention_projet', + }; + + (db.getRequestById as any).mockResolvedValue(mockRequest); + + const request = await db.getRequestById(2); + expect(request!.status).toBe('validee'); + + // The router would throw a TRPCError here + const canDelete = request!.status !== 'validee' && request!.status !== 'refusee'; + expect(canDelete).toBe(false); + }); + + it('should reject deletion of a refused request', async () => { + const mockRequest = { + id: 3, + associationId: 10, + status: 'refusee', + titre: 'Demande refusée', + type: 'demande_salle', + }; + + (db.getRequestById as any).mockResolvedValue(mockRequest); + + const request = await db.getRequestById(3); + const canDelete = request!.status !== 'validee' && request!.status !== 'refusee'; + expect(canDelete).toBe(false); + }); + + it('should reject deletion by a non-owning association', async () => { + const mockRequest = { + id: 4, + associationId: 10, + status: 'soumise', + titre: 'Demande autre asso', + type: 'autre', + }; + const mockAssociation = { id: 20, userId: 8 }; // Different association + + (db.getRequestById as any).mockResolvedValue(mockRequest); + (db.getAssociationByUserId as any).mockResolvedValue(mockAssociation); + + const request = await db.getRequestById(4); + const association = await db.getAssociationByUserId(8); + + // Association ID mismatch + expect(request!.associationId).not.toBe(association!.id); + }); + + it('should allow deletion of a submitted request', async () => { + const mockRequest = { + id: 5, + associationId: 10, + status: 'soumise', + titre: 'Demande soumise', + type: 'subvention_fonctionnement', + }; + + (db.getRequestById as any).mockResolvedValue(mockRequest); + (db.deleteRequest as any).mockResolvedValue(undefined); + + const request = await db.getRequestById(5); + const canDelete = request!.status !== 'validee' && request!.status !== 'refusee'; + expect(canDelete).toBe(true); + + await db.deleteRequest(5); + expect(db.deleteRequest).toHaveBeenCalledWith(5); + }); + + it('should allow deletion of a request in information_complementaire status', async () => { + const mockRequest = { + id: 6, + associationId: 10, + status: 'information_complementaire', + titre: 'Demande info comp', + type: 'agrement_sport', + }; + + (db.getRequestById as any).mockResolvedValue(mockRequest); + + const request = await db.getRequestById(6); + const canDelete = request!.status !== 'validee' && request!.status !== 'refusee'; + expect(canDelete).toBe(true); + }); + }); +}); diff --git a/server/requestPdf.ts b/server/requestPdf.ts new file mode 100644 index 0000000..bfeda11 --- /dev/null +++ b/server/requestPdf.ts @@ -0,0 +1,66 @@ +import { TRPCError } from "@trpc/server"; +import { generateMaterialEventPDF, generateReservationPDF } from "./pdfGenerator"; + +type RequestLike = { + id: number; + type: string; + titre: string; + status: string; + createdAt: Date | string; + dateSubmission?: Date | string | null; + dateTraitement?: Date | string | null; + commentaireAdmin?: string | null; + formData?: string | null; +}; + +export async function generateRequestPdfDocument(request: RequestLike) { + let formData: any = {}; + try { + formData = request.formData ? JSON.parse(request.formData) : {}; + } catch { + formData = {}; + } + + if (request.type === "demande_salle") { + const pdfBuffer = await generateReservationPDF({ + titre: request.titre, + status: request.status, + createdAt: request.createdAt, + dateSubmission: request.dateSubmission, + dateTraitement: request.dateTraitement, + commentaireAdmin: request.commentaireAdmin, + formData, + }); + + return { + buffer: pdfBuffer, + storageName: `reservations/pdf-${request.id}-${Date.now()}.pdf`, + fileName: `Reservation_${request.id}_${formData.nomAssociation || "association"}.pdf`, + contentType: "application/pdf", + }; + } + + if (request.type === "demande_materiel_evenementiel") { + const pdfBuffer = await generateMaterialEventPDF({ + titre: request.titre, + status: request.status, + createdAt: request.createdAt, + dateSubmission: request.dateSubmission, + dateTraitement: request.dateTraitement, + commentaireAdmin: request.commentaireAdmin, + formData, + }); + + return { + buffer: pdfBuffer, + storageName: `materiel-evenementiel/pdf-${request.id}-${Date.now()}.pdf`, + fileName: `Materiel_Evenementiel_${request.id}_${formData.nomAssociation || formData.commune || "association"}.pdf`, + contentType: "application/pdf", + }; + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Le PDF n'est disponible que pour les demandes de salle et de matériel événementiel", + }); +} diff --git a/server/routers.ts b/server/routers.ts new file mode 100644 index 0000000..520e595 --- /dev/null +++ b/server/routers.ts @@ -0,0 +1,10846 @@ +import { + cancelAuthenticatorSetup, + cancelScheduledAccountDeletion, + clearSessionCookie, + confirmAuthenticatorSetup, + createSessionToken, + getInternalMfaPolicy, + hashLocalPassword, + isInternalRole, + isAccountLockedError, + isMfaCodeRequiredError, + loginLocalUser, + registerLocalUser, + resendLocalUserMfaChallenge, + scheduleAccountDeletion, + setSessionCookie, + startAuthenticatorSetup, + verifyLocalUserMfa, +} from "./_core/auth"; +import { systemRouter } from "./_core/systemRouter"; +import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { nanoid } from "nanoid"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { storagePut, storageGet } from "./storage"; +import * as db from "./db"; +import { notifyOwner } from "./_core/notification"; +import { generateRequestPdfDocument } from "./requestPdf"; +import { generateRequestRecapEmail } from "./emailRecap"; +import { canSendOperationalEmails, sendOperationalEmail } from "./mailer"; +import { getOAuthProviderStatus } from "./socialAuth"; +import { createAssociationProfileFromDirectoryEntry, parseAssociationDirectoryWorkbook, resolveAssociationDirectoryImportRows } from "./associationDirectory"; +import { generateAssociationInvitationEmail } from "./associationInvitationEmail"; +import { computeAssociationDirectoryGeoUpdate, getPublicMapCoordinates } from "./associationGeo"; +import { computeAssociationDirectoryReferenceProposal } from "./associationReferenceSync"; +import { + HELLOASSO_SETTINGS_KEY, + HelloAssoSyncClient, + computeAssociationDirectoryHelloAssoUpdate, + sanitizeHelloAssoSettings, + serializeHelloAssoSettingsPublic, +} from "./associationHelloAssoSync"; +import type { AssociationDirectoryMatchResult } from "./associationDirectoryMatcher"; +import { getLogisticsSettings, saveLogisticsSettings } from "./logisticsSettings"; +import { getLogisticsGroupSettings, saveLogisticsGroupSettings } from "./logisticsGroup"; +import { + getSalleSignatureDelegateCandidates, + getSalleSignatureDelegates, + withEffectiveInternalAccess, +} from "./internalAccess"; +import { + addDelegatedSignerForDirectrice, + getDelegatedSignerIdsForDirectrice, + removeDelegatedSignerForDirectrice, + setDelegatedSignerIdsForDirectrice, +} from "./salleSignatureDelegation"; +import { getAdminMailSettings, saveAdminMailSettings } from "./mailSettings"; +import { + agentRoleOptions, + buildMaterialReturnUploadLink, + borrowerRoleOptions, + computeMaterialReturnBoardState, + getMaterialReturnUploadLinkState, + materialReturnDiscrepancyOptions, + materialReturnBoardStateLabels, + scheduleMaterialReturnFollowup, + sendMaterialReturnFollowupEmail, + serializeMaterialReturnFollowup, +} from "./materialReturnWorkflow"; +import { + generateCompletedMaterialReturnStatementPdf, + generateMaterialReturnStatementPdf, +} from "./materialReturnPdf"; +import { generateMaterialReturnLitigationLetterPdf } from "./materialReturnLitigationPdf"; +import { generateMaterialReturnCompletionEmail } from "./materialReturnEmail"; +import { generateMaterialConventionPdf, type MaterialContractFinancialMode, type MaterialContractStatus } from "./materialConventionPdf"; +import { generateSalleConventionPdf } from "./salleConventionPdf"; +import { generateSalleQuotePdf } from "./salleQuotePdf"; +import { generateSalleAdministrativeDecisionPdf } from "./salleAdministrativeDecisionPdf"; +import { generateSalleInvoicePdf } from "./salleInvoicePdf"; +import { generateSalleFinalEmail, generateSalleQuoteEmail } from "./salleReservationEmails"; +import { generateReservationAnalyticsExcel, generateReservationAnalyticsPdf } from "./statsReportArtifacts"; +import { + buildDefaultSalleWorkflowTexts, + computeSalleWorkflowPricing, + getSalleWorkflowData, + mergeSalleWorkflowData, + updateSalleWorkflowDirectorStatus, + updateSalleWorkflowQuoteStatus, +} from "./salleReservationWorkflow"; +import { + APPEARANCE_SETTING_KEY, + appearanceAssetFields, + appearanceCardDecorationAnchors, + appearanceDecorationScopes, + appearanceDecorationLayers, + appearanceBackgroundTargets, + appearanceBackgroundPositions, + appearanceBackgroundPresentationModes, + appearanceButtonStyles, + appearanceBackgroundModes, + appearanceCardStyles, + appearanceFontFamilies, + appearanceSidebarStyles, + appearanceTableStyles, + appearanceThemeModes, + appearanceInputStyles, + defaultPortalAppearance, + sanitizePortalAppearance, +} from "@shared/appearance"; +import { associationCommuneOptions } from "@shared/associationCommunes"; +import { + DATA_PRIVACY_NOTICE_VERSION, + dataPrivacyConsentSchema, + type DataPrivacyConsent, +} from "@shared/privacyCompliance"; +import { + buildDisplayName, + legalRepresentativeRoleValues, + governanceMemberRoleValues, + normalizeLegalRepresentativeRole, + parseAssociationGovernance, + sanitizeGovernance, + serializeAssociationGovernance, + type AssociationGovernance, +} from "@shared/associationGovernance"; +import { + associationThematicValues, + getAssociationThematicLabel, + parseAssociationThematics, + serializeAssociationThematics, +} from "@shared/associationThematics"; +import { + emptyMaterialEventQuantityMap, + getMaterialEventLabel, + materialEventItems, + parseMaterialEventQuantity, + sanitizeMaterialEventQuantityMap, + type MaterialEventItemKey, +} from "@shared/materialEvent"; +import { isValidFacebookUrl, isValidInstagramUrl, normalizeSocialUrl } from "@shared/socialLinks"; +import { supportedMailProviders } from "@shared/mailProviders"; +import { + CONDITIONS_FINANCIERES_SALLE_TEXT, + computeSallePricing, + SALLE_WORKFLOW_DIRECTOR_STATUS_LABELS, + SALLE_WORKFLOW_PAYMENT_STATUS_LABELS, + SALLE_WORKFLOW_QUOTE_STATUS_LABELS, +} from "@shared/sallePricing"; +import { + operationalTourAuthModeSchema, + clearOperationalTourHistory, + getOperationalTourHistory, + getOperationalTourSettings, + operationalTourEnvironmentSchema, + OPERATIONAL_TOUR_SETTINGS_KEY, + runOperationalTour, + saveOperationalTourSettings, +} from "./operationalTour"; + +const SALLE_BILLING_SETTING_KEY = "system.salle.billing"; +const INTERNAL_DIRECTORY_SETTING_KEY = "system.ccds.internalDirectory"; +const STATS_AUTOMATION_SETTING_KEY = "system.stats.automation"; +const STATS_REPORT_HISTORY_SETTING_KEY = "system.stats.reportHistory"; +const ASSOCIATION_MAP_SETTING_KEY = "system.associationDirectory.map"; +const USER_OPERATING_TOUR_SETTING_KEY = "system.userOperatingTour"; +const COMPLIANCE_SUPPLIERS_SETTING_KEY = "system.compliance.suppliers"; +const COMPLIANCE_DPA_SETTING_KEY = "system.compliance.dpa"; +const COMPLIANCE_BACKUP_SETTING_KEY = "system.compliance.backups"; +const COMPLIANCE_RETENTION_HISTORY_SETTING_KEY = "system.compliance.retentionHistory"; +const COMPLIANCE_EVIDENCE_SETTING_KEY = "system.compliance.evidenceCenter"; +const COMPLIANCE_ISO_DELIVERABLES_SETTING_KEY = "system.compliance.isoDeliverables"; +const ANALYTICS_PERIOD_VALUES = ["7d", "month", "quarter", "semester", "year"] as const; +type AnalyticsPeriod = (typeof ANALYTICS_PERIOD_VALUES)[number]; +const analyticsPeriodSchema = z.enum(ANALYTICS_PERIOD_VALUES); +const userOperatingTourSettingsSchema = z.object({ + enabled: z.boolean().default(true), +}); +type UserOperatingTourSettings = z.infer; +type ComplianceStatus = "implemented" | "partial" | "missing"; + +const defaultUserOperatingTourSettings: UserOperatingTourSettings = { + enabled: true, +}; + +const appearanceSettingsSchema = z.object({ + themeMode: z.enum(appearanceThemeModes), + activeSkinId: z.string().trim().min(1).max(80), + portalTitle: z.string().trim().min(1).max(120), + portalTagline: z.string().trim().min(1).max(220), + primaryColor: z.string(), + secondaryColor: z.string(), + accentColor: z.string(), + buttonColor: z.string(), + alertColor: z.string(), + fontFamily: z.enum(appearanceFontFamilies), + headingFontFamily: z.enum(appearanceFontFamilies), + headingScalePercent: z.number().min(85).max(130), + bodyScalePercent: z.number().min(90).max(115), + logoUrl: z.string().optional(), + faviconUrl: z.string().optional(), + heroImageUrl: z.string().optional(), + backgroundImageUrl: z.string().optional(), + backgroundEnabled: z.boolean().optional(), + backgroundMode: z.enum(appearanceBackgroundModes).optional(), + backgroundPresentationMode: z.enum(appearanceBackgroundPresentationModes).optional(), + backgroundOverlayOpacity: z.number().min(0).max(100).optional(), + backgroundBlurRadius: z.number().min(0).max(24).optional(), + backgroundTargets: z.array(z.enum(appearanceBackgroundTargets)).optional(), + backgroundTargetImageUrls: z.record(z.string(), z.string()).optional(), + backgroundTargetOverlayOpacities: z.record(z.string(), z.number().min(0).max(100)).optional(), + backgroundTargetBlurRadii: z.record(z.string(), z.number().min(0).max(24)).optional(), + backgroundTargetPositions: z.record(z.string(), z.enum(appearanceBackgroundPositions)).optional(), + backgroundTargetPresentationModes: z.record(z.string(), z.enum(appearanceBackgroundPresentationModes)).optional(), + heroImagePositionX: z.number().min(0).max(100).optional(), + heroImagePositionY: z.number().min(0).max(100).optional(), + heroOverlayOpacity: z.number().min(0).max(100).optional(), + heroTitlePositionX: z.number().min(-50).max(50).optional(), + heroTitlePositionY: z.number().min(-50).max(50).optional(), + heroTitleWidthPercent: z.number().min(30).max(100).optional(), + heroTaglinePositionX: z.number().min(-50).max(50).optional(), + heroTaglinePositionY: z.number().min(-50).max(50).optional(), + heroTaglineWidthPercent: z.number().min(30).max(100).optional(), + decorativeElements: z.array(z.object({ + id: z.string().trim().min(1).max(80), + imageUrl: z.string().trim().min(1), + scope: z.enum(appearanceDecorationScopes), + layer: z.enum(appearanceDecorationLayers), + widthPercent: z.number().min(6).max(38), + topPercent: z.number().min(0).max(100), + leftPercent: z.number().min(0).max(100), + opacity: z.number().min(4).max(28), + blurRadius: z.number().min(0).max(12), + cardAnchor: z.enum(appearanceCardDecorationAnchors), + cardInsetPercent: z.number().min(0).max(20), + })).optional(), + cardStyle: z.enum(appearanceCardStyles), + tableStyle: z.enum(appearanceTableStyles), + sidebarStyle: z.enum(appearanceSidebarStyles), + buttonStyle: z.enum(appearanceButtonStyles), + inputStyle: z.enum(appearanceInputStyles), +}); + +async function getUserOperatingTourSettings(): Promise { + const raw = await db.getPortalSetting(USER_OPERATING_TOUR_SETTING_KEY); + if (!raw) { + return defaultUserOperatingTourSettings; + } + + try { + return userOperatingTourSettingsSchema.parse(JSON.parse(raw)); + } catch { + return defaultUserOperatingTourSettings; + } +} + +function buildComplianceItem( + id: string, + label: string, + status: ComplianceStatus, + summary: string, + detail?: string, +) { + return { id, label, status, summary, detail }; +} + +const complianceSupplierSchema = z.object({ + id: z.string().trim().min(1).max(80), + supplierName: z.string().trim().min(1).max(200), + service: z.string().trim().min(1).max(200), + location: z.string().trim().min(1).max(120), + criticality: z.enum(["critique", "elevee", "moyenne"]), + dpaStatus: z.enum(["signed", "pending", "renew", "expired", "missing"]), + reviewDate: z.string().trim().optional().or(z.literal("")), + owner: z.string().trim().min(1).max(160), + notes: z.string().trim().max(2000).optional().or(z.literal("")), +}); +type ComplianceSupplier = z.infer; + +const complianceDpaSchema = z.object({ + id: z.string().trim().min(1).max(80), + supplierId: z.string().trim().optional().or(z.literal("")), + supplierName: z.string().trim().min(1).max(200), + status: z.enum(["signed", "pending", "renew", "expired"]), + signedAt: z.string().trim().optional().or(z.literal("")), + expiresAt: z.string().trim().optional().or(z.literal("")), + reviewDueAt: z.string().trim().optional().or(z.literal("")), + owner: z.string().trim().min(1).max(160), + notes: z.string().trim().max(2000).optional().or(z.literal("")), +}); +type ComplianceDpa = z.infer; + +const complianceBackupRecordSchema = z.object({ + id: z.string().trim().min(1).max(80), + scope: z.enum(["database", "documents", "system", "global"]), + environment: z.enum(["production", "preproduction", "staging", "local", "external"]).optional().catch("production"), + backupType: z.enum(["scheduled", "manual", "snapshot", "replication", "external"]).optional().catch("scheduled"), + lastSuccessAt: z.string().trim().optional().or(z.literal("")), + lastFailureAt: z.string().trim().optional().or(z.literal("")), + monthlySuccessRate: z.number().min(0).max(100), + volumeLabel: z.string().trim().max(120).optional().or(z.literal("")), + retentionLabel: z.string().trim().max(120).optional().or(z.literal("")), + proofLabel: z.string().trim().max(160).optional().or(z.literal("")), + proofUrl: z.string().trim().max(1000).optional().or(z.literal("")), + notes: z.string().trim().max(2000).optional().or(z.literal("")), +}); +type ComplianceBackupRecord = z.infer; + +const complianceRestoreTestSchema = z.object({ + id: z.string().trim().min(1).max(80), + testedAt: z.string().trim().optional().or(z.literal("")), + environment: z.enum(["production", "preproduction", "staging", "local", "external"]).optional().catch("preproduction"), + scopeLabel: z.string().trim().max(160).optional().or(z.literal("")), + result: z.enum(["success", "warning", "failed"]), + responsible: z.string().trim().max(160).optional().or(z.literal("")), + observations: z.string().trim().max(2000).optional().or(z.literal("")), + proofLabel: z.string().trim().max(160).optional().or(z.literal("")), + nextPlannedAt: z.string().trim().optional().or(z.literal("")), +}); +type ComplianceRestoreTest = z.infer; + +const complianceRetentionReportSchema = z.object({ + id: z.string().trim().min(1).max(80), + generatedAt: z.string().trim().optional().or(z.literal("")), + periodLabel: z.string().trim().max(120).optional().or(z.literal("")), + deletedAccounts: z.number().int().min(0), + pendingPurge: z.number().int().min(0), + anonymizedRecords: z.number().int().min(0), + executedPurges: z.number().int().min(0), + legalHoldCount: z.number().int().min(0), + incidentsCount: z.number().int().min(0), + notes: z.string().trim().max(2000).optional().or(z.literal("")), +}); +type ComplianceRetentionReport = z.infer; + +const complianceEvidenceSchema = z.object({ + id: z.string().trim().min(1).max(80), + category: z.enum([ + "security_policy", + "processing_register", + "dpa", + "audit_report", + "backup_report", + "retention_report", + "restore_test", + "incident_report", + "other", + ]), + title: z.string().trim().min(1).max(200), + reference: z.string().trim().max(120).optional().or(z.literal("")), + url: z.string().trim().optional().or(z.literal("")), + description: z.string().trim().max(2000).optional().or(z.literal("")), + updatedAt: z.string().trim().min(1), +}); +type ComplianceEvidence = z.infer; + +const complianceIsoDeliverableSectionSchema = z.object({ + id: z.string().trim().min(1).max(80), + title: z.string().trim().min(1).max(200), + content: z.string().trim().max(12000).optional().or(z.literal("")), + guidance: z.string().trim().max(1200).optional().or(z.literal("")), +}); +type ComplianceIsoDeliverableSection = z.infer; + +const complianceIsoDeliverableSchema = z.object({ + id: z.string().trim().min(1).max(80), + reference: z.string().trim().min(1).max(80), + title: z.string().trim().min(1).max(240), + domain: z.enum(["gouvernance", "acces", "incidents", "sauvegardes", "fournisseurs", "risques", "continuite", "rgpd", "audit"]), + status: z.enum(["a_rediger", "en_cours", "a_relire", "a_valider", "valide", "a_reviser"]), + owner: z.string().trim().max(160).optional().or(z.literal("")), + approverName: z.string().trim().max(160).optional().or(z.literal("")), + approverEmail: z.string().trim().max(320).optional().or(z.literal("")), + summary: z.string().trim().max(2000).optional().or(z.literal("")), + notes: z.string().trim().max(6000).optional().or(z.literal("")), + lastReviewedAt: z.string().trim().optional().or(z.literal("")), + validatedAt: z.string().trim().optional().or(z.literal("")), + revisionDueAt: z.string().trim().optional().or(z.literal("")), + sections: z.array(complianceIsoDeliverableSectionSchema).max(20), +}); +type ComplianceIsoDeliverable = z.infer; + +type ComplianceOperationsPayload = { + suppliers: ComplianceSupplier[]; + dpas: ComplianceDpa[]; + backupRecords: ComplianceBackupRecord[]; + restoreTests: ComplianceRestoreTest[]; + retentionReports: ComplianceRetentionReport[]; + evidenceCenter: ComplianceEvidence[]; + isoDeliverables: ComplianceIsoDeliverable[]; +}; + +type ComplianceBootstrapSummary = { + suppliersAdded: number; + dpasAdded: number; + evidenceAdded: number; + notes: string[]; +}; +type StatsAutomationSettings = { + enabled: boolean; + frequencies: Record | "weekly", boolean>; + recipientEmails: string[]; + lastGeneratedCycleKeys: Partial, string>>; +}; +type StatsReportHistoryEntry = { + id: string; + period: "weekly" | Exclude; + periodLabel: string; + startDate: string; + endDate: string; + generatedAt: string; + pdfUrl: string; + pdfName: string; + excelUrl: string; + excelName: string; + cycleKey: string; + generatedBy: "scheduler" | "manual"; +}; + +type AssociationMapSettings = { + publicStyleUrl: string; + adminStyleUrl: string; + styleUrl: string; +}; + +const salleAnalyticsCatalog = [ + { id: "bureau_11_17", name: "Bureau 11–17 m²" }, + { id: "salle_toucan", name: "Salle TOUCAN" }, + { id: "salle_ibis", name: "Salle IBIS" }, + { id: "salle_pelican", name: "Salle PELICAN" }, + { id: "dojo", name: "DOJO" }, + { id: "hall_amphitheatre", name: "Hall-Amphithéâtre" }, + { id: "vestiaires", name: "Vestiaires" }, +] as const; +type SalleAnalyticsRoomId = (typeof salleAnalyticsCatalog)[number]["id"]; + +function sanitizeStatsAutomationSettings(rawValue?: unknown): StatsAutomationSettings { + const source = rawValue && typeof rawValue === "object" ? rawValue as Record : {}; + const parseEmails = (value: unknown) => Array.isArray(value) + ? value.map((entry) => normalizeEmail(String(entry || ""))).filter(Boolean) + : []; + + return { + enabled: source.enabled !== false, + frequencies: { + weekly: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record).weekly) : true, + month: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record).month) : true, + quarter: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record).quarter) : true, + semester: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record).semester) : true, + year: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record).year) : true, + }, + recipientEmails: parseEmails(source.recipientEmails), + lastGeneratedCycleKeys: + source.lastGeneratedCycleKeys && typeof source.lastGeneratedCycleKeys === "object" + ? Object.fromEntries( + Object.entries(source.lastGeneratedCycleKeys as Record) + .filter(([, value]) => typeof value === "string" && value.trim().length > 0) + .map(([key, value]) => [key, String(value)]) + ) + : {}, + }; +} + +function sanitizeStatsReportHistory(rawValue?: unknown): StatsReportHistoryEntry[] { + if (!Array.isArray(rawValue)) return []; + return rawValue + .filter((entry) => entry && typeof entry === "object") + .map((entry) => entry as Record) + .filter((entry) => + typeof entry.id === "string" + && typeof entry.period === "string" + && typeof entry.periodLabel === "string" + && typeof entry.generatedAt === "string" + && typeof entry.pdfUrl === "string" + && typeof entry.excelUrl === "string" + ) + .map((entry) => ({ + id: String(entry.id), + period: entry.period as StatsReportHistoryEntry["period"], + periodLabel: String(entry.periodLabel), + startDate: String(entry.startDate || ""), + endDate: String(entry.endDate || ""), + generatedAt: String(entry.generatedAt), + pdfUrl: String(entry.pdfUrl), + pdfName: String(entry.pdfName || "rapport.pdf"), + excelUrl: String(entry.excelUrl), + excelName: String(entry.excelName || "rapport.xlsx"), + cycleKey: String(entry.cycleKey || ""), + generatedBy: entry.generatedBy === "manual" ? "manual" : "scheduler", + })); +} + +function sanitizeAssociationMapSettings(rawValue?: unknown): AssociationMapSettings { + const source = rawValue && typeof rawValue === "object" ? rawValue as Record : {}; + const fallbackStyleUrl = typeof source.styleUrl === "string" && source.styleUrl.trim().length > 0 + ? source.styleUrl.trim() + : "https://www.portail-association973.com/map-styles/openmaptiles-positron/style.json"; + const publicStyleUrl = typeof source.publicStyleUrl === "string" && source.publicStyleUrl.trim().length > 0 + ? source.publicStyleUrl.trim() + : fallbackStyleUrl; + const adminStyleUrl = typeof source.adminStyleUrl === "string" && source.adminStyleUrl.trim().length > 0 + ? source.adminStyleUrl.trim() + : "https://www.portail-association973.com/map-styles/openmaptiles-positron-admin/style.json"; + + return { + publicStyleUrl, + adminStyleUrl, + styleUrl: publicStyleUrl, + }; +} + +function parseStoredList(rawValue: string | null, schema: z.ZodType) { + if (!rawValue) return [] as T[]; + try { + const parsed = JSON.parse(rawValue); + if (!Array.isArray(parsed)) return [] as T[]; + return parsed + .map((entry) => { + try { + return schema.parse(entry); + } catch { + return null; + } + }) + .filter(Boolean) as T[]; + } catch { + return [] as T[]; + } +} + +function parseComplianceDate(input?: string | null) { + if (!input) return null; + const parsed = new Date(input); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function formatRecencyStatus(date: Date | null, warningAfterDays: number) { + if (!date) { + return { status: "missing" as const, ageDays: null }; + } + const ageMs = Date.now() - date.getTime(); + const ageDays = Math.max(0, Math.floor(ageMs / (1000 * 60 * 60 * 24))); + if (ageDays > warningAfterDays) { + return { status: "warning" as const, ageDays }; + } + return { status: "ok" as const, ageDays }; +} + +async function getComplianceOperations(): Promise { + const [ + suppliersRaw, + dpasRaw, + backupsRaw, + retentionHistoryRaw, + evidenceRaw, + retentionReportRaw, + isoDeliverablesRaw, + ] = await Promise.all([ + db.getPortalSetting(COMPLIANCE_SUPPLIERS_SETTING_KEY), + db.getPortalSetting(COMPLIANCE_DPA_SETTING_KEY), + db.getPortalSetting(COMPLIANCE_BACKUP_SETTING_KEY), + db.getPortalSetting(COMPLIANCE_RETENTION_HISTORY_SETTING_KEY), + db.getPortalSetting(COMPLIANCE_EVIDENCE_SETTING_KEY), + db.getPortalSetting("retention.lastReport"), + db.getPortalSetting(COMPLIANCE_ISO_DELIVERABLES_SETTING_KEY), + ]); + + const backupsPayload = backupsRaw ? (() => { + try { + const parsed = JSON.parse(backupsRaw) as Record; + return { + backupRecords: Array.isArray(parsed.backupRecords) + ? parsed.backupRecords.map((entry) => { + try { return complianceBackupRecordSchema.parse(entry); } catch { return null; } + }).filter(Boolean) as ComplianceBackupRecord[] + : [], + restoreTests: Array.isArray(parsed.restoreTests) + ? parsed.restoreTests.map((entry) => { + try { return complianceRestoreTestSchema.parse(entry); } catch { return null; } + }).filter(Boolean) as ComplianceRestoreTest[] + : [], + }; + } catch { + return { backupRecords: [], restoreTests: [] }; + } + })() : { backupRecords: [], restoreTests: [] }; + + const retentionReports = parseStoredList(retentionHistoryRaw, complianceRetentionReportSchema); + + if (retentionReportRaw) { + try { + const latest = JSON.parse(retentionReportRaw) as Record; + if (typeof latest.generatedAt === "string" && !retentionReports.some((entry) => entry.generatedAt === latest.generatedAt)) { + retentionReports.unshift(complianceRetentionReportSchema.parse({ + id: `auto-${latest.generatedAt}`, + generatedAt: String(latest.generatedAt), + periodLabel: "Rapport automatique quotidien", + deletedAccounts: Number(latest.purgedUsers || 0), + pendingPurge: Number(latest.pendingDeletionCount || 0), + anonymizedRecords: Number(latest.purgedUsers || 0), + executedPurges: Number(latest.purgedUsers || 0), + legalHoldCount: 0, + incidentsCount: 0, + notes: "", + })); + } + } catch { + // ignore malformed latest report + } + } + + return { + suppliers: parseStoredList(suppliersRaw, complianceSupplierSchema), + dpas: parseStoredList(dpasRaw, complianceDpaSchema), + backupRecords: backupsPayload.backupRecords, + restoreTests: backupsPayload.restoreTests, + retentionReports, + evidenceCenter: parseStoredList(evidenceRaw, complianceEvidenceSchema), + isoDeliverables: mergeComplianceEntriesById( + parseStoredList(isoDeliverablesRaw, complianceIsoDeliverableSchema), + buildDefaultIsoDeliverables(), + ).entries.sort((a, b) => a.reference.localeCompare(b.reference, "fr")), + }; +} + +async function saveComplianceBackups(payload: { + backupRecords: ComplianceBackupRecord[]; + restoreTests: ComplianceRestoreTest[]; +}) { + await db.setPortalSetting( + COMPLIANCE_BACKUP_SETTING_KEY, + JSON.stringify(payload), + "Supervision des sauvegardes, restaurations et preuves associées" + ); +} + +function buildDefaultIsoDeliverables(): ComplianceIsoDeliverable[] { + return [ + { + id: "iso-pol-001", + reference: "POL-001", + title: "Politique de securite de l'information", + domain: "gouvernance", + status: "a_rediger", + owner: "", + approverName: "", + approverEmail: "", + summary: "Document cadre du SMSI, perimetre, principes de protection et gouvernance.", + notes: "", + lastReviewedAt: "", + validatedAt: "", + revisionDueAt: "", + sections: [ + { id: "scope", title: "Perimetre", content: "", guidance: "Definir les activites, services et environnements couverts par le SMSI." }, + { id: "principles", title: "Principes directeurs", content: "", guidance: "Preciser confidentialite, integrite, disponibilite et tracabilite." }, + { id: "governance", title: "Gouvernance et responsabilites", content: "", guidance: "Nommer les roles de direction, RSSI, DPO et exploitation." }, + ], + }, + { + id: "iso-sec-001", + reference: "SEC-001", + title: "Gestion des acces et habilitations", + domain: "acces", + status: "en_cours", + owner: "", + approverName: "", + approverEmail: "", + summary: "Controle des acces, MFA, moindre privilege et revues d'habilitations.", + notes: "", + lastReviewedAt: "", + validatedAt: "", + revisionDueAt: "", + sections: [ + { id: "roles", title: "Roles et droits", content: "", guidance: "Documenter les profils internes et les niveaux d'acces." }, + { id: "mfa", title: "Authentification forte", content: "", guidance: "Definir les roles soumis a MFA obligatoire et les exceptions." }, + { id: "review", title: "Revue periodique", content: "", guidance: "Prevoir la frequence de revue et le circuit de validation." }, + ], + }, + { + id: "iso-sec-002", + reference: "SEC-002", + title: "Sauvegardes et restauration", + domain: "sauvegardes", + status: "en_cours", + owner: "", + approverName: "", + approverEmail: "", + summary: "Strategie de sauvegarde, retention, tests de restauration et preuves d'execution.", + notes: "", + lastReviewedAt: "", + validatedAt: "", + revisionDueAt: "", + sections: [ + { id: "strategy", title: "Strategie de sauvegarde", content: "", guidance: "Preciser la regle 3-2-1, les frequences et les supports." }, + { id: "restore", title: "Procedure de restauration", content: "", guidance: "Decrire la sequence de restauration et les responsables." }, + { id: "evidence", title: "Preuves et revues", content: "", guidance: "Lister les rapports, journaux et controles mensuels attendus." }, + ], + }, + { + id: "iso-sec-003", + reference: "SEC-003", + title: "Gestion des incidents de securite", + domain: "incidents", + status: "en_cours", + owner: "", + approverName: "", + approverEmail: "", + summary: "Detection, qualification, confinement, remediations et retours d'experience.", + notes: "", + lastReviewedAt: "", + validatedAt: "", + revisionDueAt: "", + sections: [ + { id: "classification", title: "Classification des incidents", content: "", guidance: "Definir les niveaux de gravite et les SLA internes." }, + { id: "workflow", title: "Workflow de traitement", content: "", guidance: "Decrire detection, confinement, investigation, correction et cloture." }, + { id: "rgpd", title: "Lien avec les violations RGPD", content: "", guidance: "Documenter l'escalade CNIL et l'information des personnes si necessaire." }, + ], + }, + { + id: "iso-sec-005", + reference: "SEC-005", + title: "Gestion des fournisseurs et sous-traitants", + domain: "fournisseurs", + status: "en_cours", + owner: "", + approverName: "", + approverEmail: "", + summary: "Registre fournisseurs, DPA, criticite, revues annuelles et preuves contractuelles.", + notes: "", + lastReviewedAt: "", + validatedAt: "", + revisionDueAt: "", + sections: [ + { id: "inventory", title: "Inventaire des fournisseurs", content: "", guidance: "Identifier hebergement, SMTP, analytics, sauvegardes et services tiers." }, + { id: "contracts", title: "Contrats et DPA", content: "", guidance: "Tracer les statuts signes, a renouveler et a recuperer." }, + { id: "annual-review", title: "Revue annuelle", content: "", guidance: "Definir le responsable, la date et les criteres de revue." }, + ], + }, + { + id: "iso-risk-001", + reference: "ISO27001-RISK-001", + title: "Analyse des risques", + domain: "risques", + status: "a_rediger", + owner: "", + approverName: "", + approverEmail: "", + summary: "Identification des actifs, menaces, vulnerabilites, impacts et plans de traitement.", + notes: "", + lastReviewedAt: "", + validatedAt: "", + revisionDueAt: "", + sections: [ + { id: "assets", title: "Actifs et perimetre", content: "", guidance: "Lister les actifs informationnels et les proprietaires." }, + { id: "risks", title: "Evaluation des risques", content: "", guidance: "Qualifier probabilite, impact et niveau de risque." }, + { id: "treatment", title: "Plan de traitement", content: "", guidance: "Associer les mesures, responsables et dates cibles." }, + ], + }, + { + id: "iso-bcp-001", + reference: "SEC-004", + title: "Continuite et reprise d'activite", + domain: "continuite", + status: "a_rediger", + owner: "", + approverName: "", + approverEmail: "", + summary: "PCA/PRA, RTO/RPO, scenarios majeurs et organisation de crise.", + notes: "", + lastReviewedAt: "", + validatedAt: "", + revisionDueAt: "", + sections: [ + { id: "scenarios", title: "Scenarios de rupture", content: "", guidance: "Panne serveur, corruption, cyberattaque, indisponibilite hebergeur." }, + { id: "targets", title: "Objectifs de reprise", content: "", guidance: "Fixer RTO, RPO et priorites de remise en service." }, + { id: "exercise", title: "Tests et exercices", content: "", guidance: "Prevoir la frequence des exercices et les comptes-rendus." }, + ], + }, + { + id: "iso-audit-001", + reference: "ISO27001-AUDIT-001", + title: "Preparation audit et declaration d'applicabilite", + domain: "audit", + status: "a_rediger", + owner: "", + approverName: "", + approverEmail: "", + summary: "Centralisation des preuves, ecarts, controles applicables et readiness audit.", + notes: "", + lastReviewedAt: "", + validatedAt: "", + revisionDueAt: "", + sections: [ + { id: "soa", title: "Declaration d'applicabilite", content: "", guidance: "Documenter les controles applicables, exclus et justifications." }, + { id: "evidence", title: "Corpus de preuves", content: "", guidance: "Lister les preuves documentaires et techniques a fournir." }, + { id: "gaps", title: "Ecarts residuels", content: "", guidance: "Consolider les points restants avant audit." }, + ], + }, + ]; +} + +function mergeComplianceEntriesById(existing: T[], additions: T[]) { + const knownIds = new Set(existing.map((entry) => entry.id)); + const merged = [...existing]; + let addedCount = 0; + + for (const entry of additions) { + if (knownIds.has(entry.id)) continue; + merged.push(entry); + knownIds.add(entry.id); + addedCount += 1; + } + + return { entries: merged, addedCount }; +} + +function todayIsoDate() { + return new Date().toISOString().slice(0, 10); +} + +function plusDaysIsoDate(days: number) { + const date = new Date(); + date.setDate(date.getDate() + days); + return date.toISOString().slice(0, 10); +} + +function toProviderLabel(input: string) { + const normalized = String(input || "").trim().toLowerCase(); + if (normalized === "ovh") return "OVHcloud"; + if (normalized === "gmail") return "Google Workspace / Gmail"; + if (normalized === "outlook") return "Microsoft 365 / Outlook"; + return normalized ? normalized : "Fournisseur à confirmer"; +} + +function toProviderLocation(input: string) { + const normalized = String(input || "").trim().toLowerCase(); + if (normalized === "ovh") return "UE"; + if (normalized === "gmail" || normalized === "outlook") { + return "À confirmer contractuellement"; + } + return "À confirmer"; +} + +async function getDocUpdatedAt(relativePath: string) { + try { + const absolutePath = path.resolve(process.cwd(), relativePath); + const stat = await fs.stat(absolutePath); + return stat.mtime.toISOString(); + } catch { + return new Date().toISOString(); + } +} + +async function buildComplianceBootstrapPayload(existing: ComplianceOperationsPayload): Promise<{ + suppliers: ComplianceSupplier[]; + dpas: ComplianceDpa[]; + evidenceCenter: ComplianceEvidence[]; + notes: string[]; +}> { + const [ + mailSettings, + oauthStatus, + helloAssoSettings, + operationalTourHistory, + retentionReportRaw, + sec001UpdatedAt, + sec002UpdatedAt, + sec002aUpdatedAt, + sec002bUpdatedAt, + sec003UpdatedAt, + sec005UpdatedAt, + rgpd002UpdatedAt, + rgpd005UpdatedAt, + rgpd006UpdatedAt, + rgpd007UpdatedAt, + rgpd008UpdatedAt, + ] = await Promise.all([ + getAdminMailSettings(), + Promise.resolve(getOAuthProviderStatus()), + getHelloAssoSettings(), + getOperationalTourHistory(), + db.getPortalSetting("retention.lastReport"), + getDocUpdatedAt("docs/compliance/SEC-001-gestion-des-acces.md"), + getDocUpdatedAt("docs/compliance/SEC-002-gestion-des-sauvegardes-et-restauration.md"), + getDocUpdatedAt("docs/compliance/SEC-002A-preuve-execution-sauvegarde.md"), + getDocUpdatedAt("docs/compliance/SEC-002B-test-restauration-historise.md"), + getDocUpdatedAt("docs/compliance/SEC-003-gestion-des-incidents.md"), + getDocUpdatedAt("docs/compliance/SEC-005-registre-fournisseurs-et-sous-traitants.md"), + getDocUpdatedAt("docs/compliance/RGPD-002-registre-des-traitements.md"), + getDocUpdatedAt("docs/compliance/RGPD-005-accord-de-traitement-des-donnees-dpa.md"), + getDocUpdatedAt("docs/compliance/RGPD-006-politique-de-retention-des-donnees.md"), + getDocUpdatedAt("docs/compliance/RGPD-007-procedure-suppression-et-purge-planifiee.md"), + getDocUpdatedAt("docs/compliance/RGPD-008-rapport-mensuel-dpo-exploitation.md"), + ]); + + const notes: string[] = []; + const suppliers: ComplianceSupplier[] = []; + const dpas: ComplianceDpa[] = []; + const evidenceCenter: ComplianceEvidence[] = []; + const reviewDate = todayIsoDate(); + + suppliers.push({ + id: "ovh-hosting", + supplierName: "OVHcloud", + service: "Hébergement principal du portail, DNS et exposition web", + location: "UE", + criticality: "critique", + dpaStatus: "pending", + reviewDate, + owner: "RSSI / exploitation CCDS", + notes: "Prestataire d’hébergement principal déclaré pour la production et la préproduction du portail.", + }); + + dpas.push({ + id: "dpa-ovh-hosting", + supplierId: "ovh-hosting", + supplierName: "OVHcloud", + status: "pending", + signedAt: "", + expiresAt: "", + reviewDueAt: plusDaysIsoDate(365), + owner: "DPO / RSSI", + notes: "DPA et clauses de sécurité OVH à centraliser dans le portail.", + }); + + if (mailSettings.hasSavedConfig || mailSettings.ready) { + const supplierId = `smtp-${mailSettings.smtpProvider}`; + const providerLabel = toProviderLabel(mailSettings.smtpProvider); + const providerLocation = toProviderLocation(mailSettings.smtpProvider); + const mailStatus = mailSettings.ready ? "pending" : "missing"; + suppliers.push({ + id: supplierId, + supplierName: providerLabel, + service: `Messagerie transactionnelle / SMTP (${mailSettings.smtpProvider === "custom" ? (mailSettings.smtpHost || "hôte personnalisé") : mailSettings.smtpProvider})`, + location: providerLocation, + criticality: "elevee", + dpaStatus: mailStatus, + reviewDate, + owner: "Administration du portail", + notes: mailSettings.ready + ? `Configuration SMTP détectée (${mailSettings.source}). Contrat DPA et revue annuelle à verser.` + : "Configuration SMTP partielle détectée. Finaliser les paramètres et centraliser le contrat avant validation.", + }); + dpas.push({ + id: `dpa-${supplierId}`, + supplierId, + supplierName: providerLabel, + status: mailSettings.ready ? "pending" : "expired", + signedAt: "", + expiresAt: "", + reviewDueAt: plusDaysIsoDate(180), + owner: "DPO / administration du portail", + notes: mailSettings.ready + ? "Ajouter le DPA réel du fournisseur de messagerie et sa date de révision." + : "Le fournisseur SMTP est détecté mais reste incomplet ou non validé contractuellement.", + }); + } else { + notes.push("Aucun fournisseur SMTP confirmé n’a été auto-détecté dans la configuration active."); + } + + if (oauthStatus.google) { + suppliers.push({ + id: "google-oauth", + supplierName: "Google", + service: "Authentification sociale Google", + location: "À confirmer contractuellement", + criticality: "elevee", + dpaStatus: "pending", + reviewDate, + owner: "RSSI / administration du portail", + notes: "Client OAuth Google configuré dans l’application.", + }); + dpas.push({ + id: "dpa-google-oauth", + supplierId: "google-oauth", + supplierName: "Google", + status: "pending", + signedAt: "", + expiresAt: "", + reviewDueAt: plusDaysIsoDate(180), + owner: "DPO / RSSI", + notes: "Vérifier le cadre contractuel et les clauses RGPD liées à la connexion Google.", + }); + } + + if (oauthStatus.facebook) { + suppliers.push({ + id: "meta-oauth", + supplierName: "Meta / Facebook", + service: "Authentification sociale Facebook", + location: "À confirmer contractuellement", + criticality: "moyenne", + dpaStatus: "pending", + reviewDate, + owner: "RSSI / administration du portail", + notes: "Client OAuth Facebook configuré dans l’application.", + }); + dpas.push({ + id: "dpa-meta-oauth", + supplierId: "meta-oauth", + supplierName: "Meta / Facebook", + status: "pending", + signedAt: "", + expiresAt: "", + reviewDueAt: plusDaysIsoDate(180), + owner: "DPO / RSSI", + notes: "Vérifier le cadre contractuel et la localisation effective des traitements.", + }); + } + + if (helloAssoSettings.enabled && helloAssoSettings.clientId) { + suppliers.push({ + id: "helloasso-api", + supplierName: "HelloAsso", + service: "API d’inscription et d’enrichissement des associations", + location: "À confirmer contractuellement", + criticality: "elevee", + dpaStatus: "pending", + reviewDate, + owner: "Administration associations / DPO", + notes: "Synchronisation HelloAsso activable depuis le portail. Validation manuelle des créations maintenue.", + }); + dpas.push({ + id: "dpa-helloasso-api", + supplierId: "helloasso-api", + supplierName: "HelloAsso", + status: "pending", + signedAt: "", + expiresAt: "", + reviewDueAt: plusDaysIsoDate(180), + owner: "DPO / administration associations", + notes: "Ajouter les engagements RGPD et le support contractuel de l’intégration HelloAsso.", + }); + } + + evidenceCenter.push( + { + id: "evidence-sec-001", + category: "security_policy", + title: "Politique MFA et gestion des accès", + reference: "SEC-001", + url: "", + description: "Document interne de gestion des accès et politique MFA obligatoire par rôle.", + updatedAt: sec001UpdatedAt, + }, + { + id: "evidence-sec-002", + category: "backup_report", + title: "Politique de sauvegardes et restauration", + reference: "SEC-002", + url: "", + description: "Procédure documentaire 3-2-1, RPO/RTO et cadre de restauration.", + updatedAt: sec002UpdatedAt, + }, + { + id: "evidence-sec-002a", + category: "backup_report", + title: "Fiche de preuve d'exécution des sauvegardes", + reference: "SEC-002A", + url: "", + description: "Modèle opérationnel pour rattacher date, statut, volume, rétention et source de preuve à chaque sauvegarde utile.", + updatedAt: sec002aUpdatedAt, + }, + { + id: "evidence-sec-002b", + category: "restore_test", + title: "Fiche de test de restauration historisé", + reference: "SEC-002B", + url: "", + description: "Modèle de trace pour démontrer qu'une restauration a réellement été testée, validée et commentée.", + updatedAt: sec002bUpdatedAt, + }, + { + id: "evidence-sec-003", + category: "incident_report", + title: "Procédure de gestion des incidents de sécurité", + reference: "SEC-003", + url: "", + description: "Classification, confinement, investigation, notification RGPD et revue post-incident.", + updatedAt: sec003UpdatedAt, + }, + { + id: "evidence-sec-005", + category: "dpa", + title: "Registre fournisseurs et sous-traitants", + reference: "SEC-005", + url: "", + description: "Référentiel documentaire de suivi des prestataires, criticités et révisions annuelles.", + updatedAt: sec005UpdatedAt, + }, + { + id: "evidence-rgpd-002", + category: "processing_register", + title: "Registre des traitements", + reference: "RGPD-002", + url: "", + description: "Base documentaire du registre des traitements du portail.", + updatedAt: rgpd002UpdatedAt, + }, + { + id: "evidence-rgpd-005", + category: "dpa", + title: "Modèle d’accord de traitement des données", + reference: "RGPD-005", + url: "", + description: "Gabarit DPA à rattacher aux fournisseurs réellement utilisés.", + updatedAt: rgpd005UpdatedAt, + }, + { + id: "evidence-rgpd-006", + category: "retention_report", + title: "Politique de rétention des données", + reference: "RGPD-006", + url: "", + description: "Politique de conservation, archivage et suppression des données personnelles.", + updatedAt: rgpd006UpdatedAt, + }, + { + id: "evidence-rgpd-007", + category: "retention_report", + title: "Procédure de suppression et purge planifiée", + reference: "RGPD-007", + url: "", + description: "Cycle de suppression différée, retention 30 jours, legal hold et audit de purge.", + updatedAt: rgpd007UpdatedAt, + }, + { + id: "evidence-rgpd-008", + category: "audit_report", + title: "Rapport mensuel DPO et exploitation", + reference: "RGPD-008", + url: "", + description: "Cadre mensuel pour suivre sauvegardes, restaurations, incidents, rétention, écarts et actions de conformité.", + updatedAt: rgpd008UpdatedAt, + }, + { + id: "evidence-public-privacy", + category: "security_policy", + title: "Politique de confidentialité publique", + reference: "Portail public", + url: "/confidentialite?returnTo=%2Fadmin%3Ftab%3Dconformite", + description: "Version publique exposée dans le portail.", + updatedAt: new Date().toISOString(), + }, + { + id: "evidence-public-mentions", + category: "security_policy", + title: "Mentions légales publiques", + reference: "Portail public", + url: "/mentions-legales?returnTo=%2Fadmin%3Ftab%3Dconformite", + description: "Version publique exposée dans le portail.", + updatedAt: new Date().toISOString(), + }, + { + id: "evidence-public-cookies", + category: "security_policy", + title: "Politique cookies publique", + reference: "Portail public", + url: "/cookies?returnTo=%2Fadmin%3Ftab%3Dconformite", + description: "Version publique exposée dans le portail.", + updatedAt: new Date().toISOString(), + }, + { + id: "evidence-public-cgu", + category: "security_policy", + title: "CGU publiques", + reference: "Portail public", + url: "/cgu?returnTo=%2Fadmin%3Ftab%3Dconformite", + description: "Version publique exposée dans le portail.", + updatedAt: new Date().toISOString(), + }, + ); + + const latestProductionTour = operationalTourHistory.find((entry) => entry.environment === "production"); + const latestSecondaryTour = operationalTourHistory.find((entry) => entry.environment === "secondary"); + + if (latestProductionTour) { + evidenceCenter.push({ + id: "evidence-tour-production", + category: "audit_report", + title: "Dernier tour opérationnel production", + reference: "Tour opérationnel", + url: "/admin?tab=dashboard", + description: `${latestProductionTour.summary.ok} contrôles OK, ${latestProductionTour.summary.warning} avertissement(s), ${latestProductionTour.summary.error} erreur(s).`, + updatedAt: latestProductionTour.finishedAt, + }); + } else { + notes.push("Aucun tour opérationnel de production n’a encore été trouvé pour l’archivage automatique."); + } + + if (latestSecondaryTour) { + evidenceCenter.push({ + id: "evidence-tour-secondary", + category: "audit_report", + title: "Dernier tour opérationnel préproduction", + reference: "Tour opérationnel", + url: "/admin?tab=dashboard", + description: `${latestSecondaryTour.summary.ok} contrôles OK, ${latestSecondaryTour.summary.warning} avertissement(s), ${latestSecondaryTour.summary.error} erreur(s).`, + updatedAt: latestSecondaryTour.finishedAt, + }); + } else { + notes.push("Aucun tour opérationnel de préproduction n’a encore été trouvé pour l’archivage automatique."); + } + + if (retentionReportRaw) { + try { + const latestRetention = JSON.parse(retentionReportRaw) as Record; + const generatedAt = typeof latestRetention.generatedAt === "string" ? latestRetention.generatedAt : new Date().toISOString(); + evidenceCenter.push({ + id: "evidence-retention-latest", + category: "retention_report", + title: "Dernier rapport automatique de rétention", + reference: "Retention auto", + url: "", + description: `Purges du run: ${Number(latestRetention.purgedUsers || 0)} • comptes en attente: ${Number(latestRetention.pendingDeletionCount || 0)}.`, + updatedAt: generatedAt, + }); + } catch { + notes.push("Le dernier rapport automatique de rétention existe mais n’a pas pu être interprété automatiquement."); + } + } else { + notes.push("Aucun rapport automatique de rétention n’a encore été détecté."); + } + + if (existing.backupRecords.length === 0) { + notes.push("Les preuves d’exécution des sauvegardes restent à saisir manuellement ou à brancher depuis l’exploitation."); + } + if (existing.restoreTests.length === 0) { + notes.push("Aucun test de restauration historisé n’est encore présent dans le tableau de pilotage."); + } + + return { suppliers, dpas, evidenceCenter, notes }; +} + +const optionalEmailFieldSchema = z + .string() + .trim() + .email("Adresse email invalide") + .optional() + .or(z.literal("")); + +type SalleBillingSettings = { + ribDocumentUrl: string; + ribDocumentName: string; + ribMimeType: string; + ribUploadedAt: string; +}; + +const internalDirectoryContactSchema = z.object({ + name: z.string().trim().min(1).max(160), + role: z + .string() + .trim() + .max(200) + .optional() + .or(z.literal("")) + .transform((value) => { + const normalized = String(value || "").trim(); + return normalized || "Fonction à compléter"; + }), + email: z.string().trim().email().optional().or(z.literal("")).transform((value) => value || undefined), + phone: z.string().trim().max(60).optional().or(z.literal("")).transform((value) => value || undefined), + mobile: z.string().trim().max(60).optional().or(z.literal("")).transform((value) => value || undefined), + extension: z.string().trim().max(20).optional().or(z.literal("")).transform((value) => value || undefined), +}); + +const internalDirectoryServiceSchema = z.object({ + id: z.string().trim().min(1).max(120), + title: z.string().trim().min(1).max(200), + description: z.string().trim().min(1).max(500), + contacts: z.array(internalDirectoryContactSchema).max(100), +}); + +const internalDirectoryDivisionSchema = z.object({ + id: z.string().trim().min(1).max(120), + title: z.string().trim().min(1).max(200), + description: z.string().trim().min(1).max(500), + accentClassName: z.string().trim().min(1).max(200), + services: z.array(internalDirectoryServiceSchema).max(100), +}); + +// Admin procedure - only for admin users +const adminProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.user.role !== 'admin' && ctx.user.role !== 'super_admin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux administrateurs' }); + } + return next({ ctx }); +}); + +// Super admin procedure +const superAdminProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.user.role !== 'super_admin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux super administrateurs' }); + } + return next({ ctx }); +}); + +function canAccessAccueilBackoffice(user: { role: string }) { + return user.role === "accueil" || user.role === "logistique_controle" || user.role === "admin" || user.role === "super_admin"; +} + +function canAccessTerrainWorkspace(user: { role: string }) { + return user.role === "service_terrain" || user.role === "super_admin"; +} + +function canReadInternalRequest(user: { role: string; canSignSalle?: boolean | null }) { + return canAccessAccueilBackoffice(user) || canSignSalle(user); +} + +const accueilAdminProcedure = protectedProcedure.use(({ ctx, next }) => { + if (!canAccessAccueilBackoffice(ctx.user)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Accès réservé à l’accueil, aux administrateurs et aux super administrateurs", + }); + } + return next({ ctx }); +}); + +const terrainProcedure = protectedProcedure.use(({ ctx, next }) => { + if (!canAccessTerrainWorkspace(ctx.user)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Accès réservé au service terrain désigné", + }); + } + return next({ ctx }); +}); + +const logisticsProcedure = protectedProcedure.use(({ ctx, next }) => { + assertLogisticsAccess(ctx.user); + return next({ ctx }); +}); + +function canManageLogistics(user: { role: string; canManageLogistics?: boolean | null }) { + return user.role === "super_admin" || user.role === "logistique_controle" || Boolean(user.canManageLogistics); +} + +function assertLogisticsAccess(user: { role: string; canManageLogistics?: boolean | null }) { + if (!canManageLogistics(user)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Accès réservé au service logistique et contrôle désigné par le super administrateur", + }); + } +} + +function canSignSalle(user: { role: string; canSignSalle?: boolean | null }) { + return user.role === "super_admin" || user.role === "directrice" || Boolean(user.canSignSalle); +} + +const salleSignerProcedure = protectedProcedure.use(({ ctx, next }) => { + if (!canSignSalle(ctx.user)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Accès réservé à la Directrice ou à son délégataire de signature", + }); + } + return next({ ctx }); +}); + +const directriceProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.user.role !== "directrice" && ctx.user.role !== "super_admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Accès réservé à la Directrice", + }); + } + return next({ ctx }); +}); + +const salleReadProcedure = protectedProcedure.use(({ ctx, next }) => { + if (!canAccessAccueilBackoffice(ctx.user) && !canSignSalle(ctx.user)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Accès réservé aux profils autorisés à consulter le suivi des salles", + }); + } + return next({ ctx }); +}); + +const ACTIVE_MATERIAL_AVAILABILITY_STATUSES = new Set([ + "soumise", + "en_cours_traitement", + "information_complementaire", + "validee", +]); + +// Helper to log admin actions +async function logAdminAction(userId: number, action: string, entityType: string, entityId?: number, details?: any) { + try { + await db.createAuditLog({ + userId, + action, + entityType, + entityId, + details: details ? JSON.stringify(details) : undefined, + ipAddress: typeof details?.ipAddress === "string" ? details.ipAddress : undefined, + }); + } catch (e) { + console.error('Failed to log admin action:', e); + } +} + +function extractClientIp(req: { headers?: Record; socket?: { remoteAddress?: string | null } }) { + const forwardedFor = req.headers?.["x-forwarded-for"]; + if (typeof forwardedFor === "string" && forwardedFor.trim()) { + return forwardedFor.split(",")[0]?.trim() || null; + } + const realIp = req.headers?.["x-real-ip"]; + if (typeof realIp === "string" && realIp.trim()) { + return realIp.trim(); + } + return req.socket?.remoteAddress || null; +} + +function assertDataPrivacyConsent(consent: DataPrivacyConsent | undefined) { + if (!consent?.accepted) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "La validation des données personnelles est requise pour poursuivre.", + }); + } + + if (consent.version !== DATA_PRIVACY_NOTICE_VERSION) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "La version d'information sur les données personnelles n'est plus à jour. Merci de relire puis de confirmer à nouveau.", + }); + } +} + +async function recordDataPrivacyConsent( + userId: number, + req: { headers?: Record; socket?: { remoteAddress?: string | null } }, + consent: DataPrivacyConsent +) { + await db.updateUser(userId, { + privacyConsentVersion: consent.version, + privacyConsentAcceptedAt: new Date(), + privacyConsentContext: consent.context, + }); + + try { + await db.createAuditLog({ + userId, + action: "privacy_consent", + entityType: "privacy", + details: JSON.stringify({ + version: consent.version, + context: consent.context, + }), + ipAddress: extractClientIp(req) || undefined, + }); + } catch (error) { + console.error("Failed to record privacy consent audit:", error); + } +} + +function validateReservationSalleFormData(rawFormData?: string) { + if (!rawFormData) return; + + let formData: any; + try { + formData = JSON.parse(rawFormData); + } catch { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Le formulaire de réservation de salle est invalide' }); + } + + if (formData?.formulaireType !== 'reservation_salle_mjs') { + return; + } + + const requiredFields = [ + { key: 'motifReservation', label: 'Motif de la réservation' }, + { key: 'dateReservation', label: 'Date de début' }, + { key: 'dateFinReservation', label: 'Date de fin' }, + { key: 'typeUsage', label: "Type d'usage" }, + { key: 'frequence', label: 'Fréquence' }, + { key: 'nombreParticipants', label: 'Nombre de participants' }, + ]; + + const missing = requiredFields.find(field => !String(formData?.[field.key] ?? '').trim()); + if (missing) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${missing.label} est obligatoire pour une demande de salle`, + }); + } + + if (String(formData.dateFinReservation) < String(formData.dateReservation)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'La date de fin doit être postérieure ou égale à la date de début', + }); + } + + if (formData.useDetailedSchedule) { + const slots = Array.isArray(formData.horairesParJour) ? formData.horairesParJour : []; + if (slots.length === 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Veuillez renseigner au moins un créneau détaillé pour cette demande de salle', + }); + } + + for (const slot of slots) { + if (!String(slot?.date || '').trim() || !String(slot?.heureDebut || '').trim() || !String(slot?.heureFin || '').trim()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Chaque créneau détaillé doit contenir une date, une heure de début et une heure de fin', + }); + } + if (String(slot.heureFin) <= String(slot.heureDebut)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Chaque créneau détaillé doit avoir une heure de fin postérieure à l’heure de début', + }); + } + if (String(slot.date) < String(formData.dateReservation) || String(slot.date) > String(formData.dateFinReservation)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Un créneau détaillé ne correspond pas à la période sélectionnée', + }); + } + } + return; + } + + if (!String(formData.heureDebut ?? '').trim() || !String(formData.heureFin ?? '').trim()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Le créneau horaire est obligatoire pour une demande de salle', + }); + } + + if (String(formData.heureFin) <= String(formData.heureDebut)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'L’heure de fin doit être postérieure à l’heure de début', + }); + } + + const pricing = computeSalleWorkflowPricing(rawFormData); + if (pricing?.unsupportedSalles?.length) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Aucune tarification automatique n'est disponible pour : ${pricing.unsupportedSalles.map((item) => item.salleNom).join(', ')}`, + }); + } +} + +function sanitizeDuplicatedRequestFormData(rawFormData?: string | null) { + if (!rawFormData) return undefined; + + try { + const formData = JSON.parse(rawFormData); + if (formData && typeof formData === 'object') { + delete formData.cadreDSU; + delete formData.notificationTrace; + delete formData.dateReception; + delete formData.dateDecision; + delete formData.responsableDSU; + delete formData.observationsDSU; + delete formData.commentaireAdmin; + if (formData.formulaireType === 'demande_materiel_evenementiel') { + delete formData.datePriseEnCharge; + } + } + return JSON.stringify(formData); + } catch { + return rawFormData; + } +} + +async function syncAssociationBackToDirectoryEntry(association: Awaited>) { + if (!association?.sourceDirectoryEntryId) { + return; + } + + const existingEntry = await db.getAssociationDirectoryEntryById(association.sourceDirectoryEntryId); + const parsedGovernance = association.gouvernance + ? parseAssociationGovernance(association.gouvernance) + : buildGovernanceFromLegacyRepresentative(association.nomRepresentant, association.fonctionRepresentant); + const representantLegal = parsedGovernance.representantLegal; + const representantLegalDisplayName = buildDisplayName(representantLegal) || association.nomRepresentant || null; + + await db.updateAssociationDirectoryEntry(association.sourceDirectoryEntryId, { + nomAssociation: association.nomAssociation, + siret: association.siret || existingEntry?.siret || null, + rna: association.rna || existingEntry?.rna || null, + adresse: association.adresse || existingEntry?.adresse || null, + codePostal: association.codePostal || existingEntry?.codePostal || null, + ville: association.ville || existingEntry?.ville || null, + telephone: association.telephone || existingEntry?.telephone || null, + emailOfficiel: association.emailContact || existingEntry?.emailOfficiel || null, + emailOfficielNormalise: association.emailContact + ? normalizeEmail(association.emailContact) + : existingEntry?.emailOfficielNormalise || null, + siteWeb: association.siteWeb || existingEntry?.siteWeb || null, + facebookUrl: association.facebookUrl || existingEntry?.facebookUrl || null, + instagramUrl: association.instagramUrl || existingEntry?.instagramUrl || null, + thematique: association.thematique || existingEntry?.thematique || null, + dateCreation: association.dateCreation || existingEntry?.dateCreation || null, + objetAssociation: association.objetAssociation || existingEntry?.objetAssociation || null, + statutJuridique: association.statutJuridique || existingEntry?.statutJuridique || undefined, + nomRepresentant: representantLegalDisplayName || existingEntry?.nomRepresentant || null, + fonctionRepresentant: representantLegal?.fonction || association.fonctionRepresentant || existingEntry?.fonctionRepresentant || null, + gouvernance: association.gouvernance || existingEntry?.gouvernance || null, + updatedAt: new Date(), + }); +} + +function buildPublicAssociationDirectoryPayload(details: NonNullable>>) { + const coordinates = getPublicMapCoordinates(details.entry); + if (!coordinates) { + return null; + } + + return { + id: details.entry.id, + nomAssociation: details.entry.nomAssociation, + ville: details.entry.ville || details.association?.ville, + siret: details.entry.siret || details.association?.siret, + rna: details.entry.rna || details.association?.rna, + thematiques: parseAssociationThematics(details.entry.thematique || details.association?.thematique), + objetAssociation: details.entry.objetAssociation || details.association?.objetAssociation, + siteWeb: details.entry.siteWeb || details.association?.siteWeb, + facebookUrl: details.entry.facebookUrl || details.association?.facebookUrl, + instagramUrl: details.entry.instagramUrl || details.association?.instagramUrl, + registered: Boolean(details.association), + latitude: coordinates.latitude, + longitude: coordinates.longitude, + geoPrecision: coordinates.precision, + publicUrl: `/associations/${details.entry.id}`, + }; +} + +function parseRecipients(input?: string[]) { + return (input || []) + .map(value => value.trim().toLowerCase()) + .filter(Boolean); +} + +function mergeRecipients(...recipientSets: Array) { + return Array.from( + new Set( + recipientSets + .flatMap(values => values || []) + .map(value => value.trim().toLowerCase()) + .filter(Boolean) + ) + ); +} + +function parseStoredRecipientEmails(value?: string | null): string[] { + if (!value) return []; + + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) return []; + return parsed + .map((entry) => String(entry || "").trim().toLowerCase()) + .filter(Boolean); + } catch { + return []; + } +} + +function parseMaterialFinancialDecision(rawFormData?: string | null) { + const formData = parseRequestFormData(rawFormData); + const decision = formData?.cadreDSU?.materielEvent?.financialDecision; + if (!decision || typeof decision !== "object") return null; + + return { + financialMode: (["gratuite", "gratuite_avec_caution", "location_payante"].includes(decision.financialMode) + ? decision.financialMode + : "gratuite") as MaterialContractFinancialMode, + depositRequired: Boolean(decision.depositRequired), + depositAmountCents: Number.isFinite(Number(decision.depositAmountCents)) ? Math.max(0, Number(decision.depositAmountCents)) : 0, + rentalAmountCents: Number.isFinite(Number(decision.rentalAmountCents)) ? Math.max(0, Number(decision.rentalAmountCents)) : 0, + pricingNotes: decision.pricingNotes ? String(decision.pricingNotes) : "", + contractStatus: (["a_generer", "generee", "signee", "refusee", "annulee"].includes(decision.contractStatus) + ? decision.contractStatus + : "a_generer") as MaterialContractStatus, + contractPdfUrl: decision.contractPdfUrl ? String(decision.contractPdfUrl) : "", + contractPdfName: decision.contractPdfName ? String(decision.contractPdfName) : "", + contractGeneratedAt: decision.contractGeneratedAt ? String(decision.contractGeneratedAt) : "", + contractValidatedByUserId: Number.isFinite(Number(decision.contractValidatedByUserId)) + ? Number(decision.contractValidatedByUserId) + : null, + }; +} + +function mergeMaterialFinancialDecision(input: { + rawFormData?: string | null; + decision: { + financialMode: MaterialContractFinancialMode; + depositRequired: boolean; + depositAmountCents: number; + rentalAmountCents: number; + pricingNotes?: string | null; + contractStatus: MaterialContractStatus; + contractPdfUrl?: string | null; + contractPdfName?: string | null; + contractGeneratedAt?: string | Date | null; + contractValidatedByUserId?: number | null; + }; +}) { + const formData = parseRequestFormData(input.rawFormData); + formData.cadreDSU = formData.cadreDSU && typeof formData.cadreDSU === "object" ? formData.cadreDSU : {}; + formData.cadreDSU.materielEvent = formData.cadreDSU.materielEvent && typeof formData.cadreDSU.materielEvent === "object" + ? formData.cadreDSU.materielEvent + : {}; + formData.cadreDSU.materielEvent.financialDecision = { + financialMode: input.decision.financialMode, + depositRequired: input.decision.depositRequired, + depositAmountCents: input.decision.depositAmountCents, + rentalAmountCents: input.decision.rentalAmountCents, + pricingNotes: input.decision.pricingNotes || "", + contractStatus: input.decision.contractStatus, + contractPdfUrl: input.decision.contractPdfUrl || "", + contractPdfName: input.decision.contractPdfName || "", + contractGeneratedAt: input.decision.contractGeneratedAt + ? new Date(input.decision.contractGeneratedAt).toISOString() + : "", + contractValidatedByUserId: input.decision.contractValidatedByUserId ?? null, + }; + return JSON.stringify(formData); +} + +function buildSalleDatesLabel(formData: any) { + if (formData?.dateReservation === formData?.dateFinReservation || !formData?.dateFinReservation) { + return formatDateFr(formData?.dateReservation); + } + return `${formatDateFr(formData?.dateReservation)} au ${formatDateFr(formData?.dateFinReservation)}`; +} + +function getSallePaymentTracking(rawFormData?: string | null) { + const workflow = getSalleWorkflowData(rawFormData); + const formData = parseRequestFormData(rawFormData); + const paymentStatus = ["en_attente_paiement", "paiement_partiel", "paiement_recu", "paiement_valide"].includes(String(workflow.paymentStatus)) + ? String(workflow.paymentStatus) + : ""; + + return { + paymentStatus: paymentStatus as "" | "en_attente_paiement" | "paiement_partiel" | "paiement_recu" | "paiement_valide", + paymentDueDate: normalizeDateString(workflow.paymentDueDate || formData?.dateReservation || ""), + amountReceivedCents: Number.isFinite(Number(workflow.amountReceivedCents)) ? Math.max(0, Number(workflow.amountReceivedCents)) : 0, + paymentReceivedAt: typeof workflow.paymentReceivedAt === "string" ? workflow.paymentReceivedAt : "", + paymentValidatedAt: typeof workflow.paymentValidatedAt === "string" ? workflow.paymentValidatedAt : "", + paymentReference: typeof workflow.paymentReference === "string" ? workflow.paymentReference : "", + paymentNotes: typeof workflow.paymentNotes === "string" ? workflow.paymentNotes : "", + }; +} + +function ensureSallePaymentTracking(input: { + rawFormData?: string | null; + billed: boolean; + totalAmountCents: number; +}) { + if (!input.billed) { + return input.rawFormData || ""; + } + + const current = getSallePaymentTracking(input.rawFormData); + return mergeSalleWorkflowData(input.rawFormData, { + paymentStatus: current.paymentStatus || "en_attente_paiement", + paymentDueDate: current.paymentDueDate || normalizeDateString(parseRequestFormData(input.rawFormData)?.dateReservation || ""), + amountReceivedCents: current.amountReceivedCents, + paymentReceivedAt: current.paymentReceivedAt, + paymentValidatedAt: current.paymentValidatedAt, + paymentReference: current.paymentReference, + paymentNotes: current.paymentNotes, + }); +} + +function sanitizeSalleBillingSettings(value: unknown): SalleBillingSettings { + const record = value && typeof value === "object" ? value as Record : {}; + return { + ribDocumentUrl: typeof record.ribDocumentUrl === "string" ? record.ribDocumentUrl : "", + ribDocumentName: typeof record.ribDocumentName === "string" ? record.ribDocumentName : "", + ribMimeType: typeof record.ribMimeType === "string" ? record.ribMimeType : "", + ribUploadedAt: typeof record.ribUploadedAt === "string" ? record.ribUploadedAt : "", + }; +} + +async function getSalleBillingSettings() { + const storedValue = await db.getPortalSetting(SALLE_BILLING_SETTING_KEY); + if (!storedValue) { + return sanitizeSalleBillingSettings(null); + } + try { + return sanitizeSalleBillingSettings(JSON.parse(storedValue)); + } catch { + return sanitizeSalleBillingSettings(null); + } +} + +async function getHelloAssoSettings() { + const storedValue = await db.getPortalSetting(HELLOASSO_SETTINGS_KEY); + if (!storedValue) return sanitizeHelloAssoSettings(); + try { + return sanitizeHelloAssoSettings(JSON.parse(storedValue)); + } catch { + return sanitizeHelloAssoSettings(); + } +} + +async function getAssociationMapSettings() { + const storedValue = await db.getPortalSetting(ASSOCIATION_MAP_SETTING_KEY); + if (!storedValue) return sanitizeAssociationMapSettings(); + try { + return sanitizeAssociationMapSettings(JSON.parse(storedValue)); + } catch { + return sanitizeAssociationMapSettings(); + } +} + +async function saveHelloAssoSettings(nextValue: { + enabled: boolean; + clientId: string; + clientSecret?: string; +}) { + const current = await getHelloAssoSettings(); + const normalized = sanitizeHelloAssoSettings({ + enabled: nextValue.enabled, + clientId: nextValue.clientId, + clientSecret: nextValue.clientSecret?.trim() ? nextValue.clientSecret : current.clientSecret, + }); + + await db.setPortalSetting( + HELLOASSO_SETTINGS_KEY, + JSON.stringify(normalized), + "Synchronisation contrôlée du bordereau des associations avec HelloAsso" + ); + + return normalized; +} + +async function readStoredUploadBufferFromUrl(url: string) { + const normalized = String(url || "").trim(); + if (!normalized.startsWith("/uploads/")) return null; + const relativePath = normalized.replace(/^\/uploads\//, ""); + const absolutePath = path.resolve(process.cwd(), "uploads", relativePath); + try { + return await fs.readFile(absolutePath); + } catch { + return null; + } +} + +function serializeOperationalRecapService( + service: + | Awaited> + | Awaited>[number] + | null + | undefined +) { + if (!service) return null; + + return { + ...service, + usage: service.usage === "controle" ? "controle" : "terrain", + recipientEmails: parseStoredRecipientEmails(service.recipientEmails), + }; +} + +function normalizeEmail(value: string) { + return value.trim().toLowerCase(); +} + +function normalizeSiretValue(value?: string | null) { + const digits = String(value || "").replace(/\D/g, ""); + return digits || null; +} + +function normalizeRnaValue(value?: string | null) { + const normalized = String(value || "").replace(/\s+/g, "").trim().toUpperCase(); + return normalized || null; +} + +async function ensureAssociationLinkedToDirectoryEntry(userId: number, directoryEntryId: number) { + const [existingProfile, directoryEntry, alreadyLinkedProfile] = await Promise.all([ + db.getAssociationByUserId(userId), + db.getAssociationDirectoryEntryById(directoryEntryId), + db.getAssociationBySourceDirectoryEntryId(directoryEntryId), + ]); + + if (!directoryEntry) { + throw new Error("Association du bordereau introuvable"); + } + + if (alreadyLinkedProfile && alreadyLinkedProfile.userId !== userId) { + throw new Error("Cette fiche du bordereau est déjà rattachée à une autre association portail"); + } + + if (existingProfile) { + if (existingProfile.sourceDirectoryEntryId !== directoryEntryId) { + await db.updateAssociation(existingProfile.id, { sourceDirectoryEntryId: directoryEntryId }); + } + return existingProfile.id; + } + + return db.createAssociation(createAssociationProfileFromDirectoryEntry(userId, directoryEntry)); +} + +const socialUrlSchema = z.string().optional().or(z.literal('')); + +async function queueAssociationDirectoryReview(input: { + userId?: number | null; + sourceType: "portal_signup" | "helloasso"; + sourceLabel?: string | null; + nomAssociation: string; + email?: string | null; + siret?: string | null; + rna?: string | null; + ville?: string | null; + telephone?: string | null; + nomRepresentant?: string | null; + matchResult: AssociationDirectoryMatchResult; +}) { + const payload = JSON.stringify({ + reason: input.matchResult.reason, + proposedTelephone: input.telephone || null, + proposedNomRepresentant: input.nomRepresentant || null, + candidates: input.matchResult.candidates.map((candidate) => ({ + id: candidate.id, + nomAssociation: candidate.nomAssociation, + ville: candidate.ville, + siret: candidate.siret, + rna: candidate.rna, + emailOfficiel: candidate.emailOfficiel, + telephone: candidate.telephone, + nomRepresentant: candidate.nomRepresentant, + })), + }); + + const reviewPayload = { + sourceType: input.sourceType, + sourceLabel: input.sourceLabel || null, + userId: input.userId || null, + proposedNomAssociation: input.nomAssociation, + proposedEmail: input.email || null, + proposedEmailNormalise: input.email ? normalizeEmail(input.email) : null, + proposedSiret: normalizeSiretValue(input.siret), + proposedRna: normalizeRnaValue(input.rna), + proposedVille: input.ville || null, + matchStatus: input.matchResult.status, + payload, + status: "pending" as const, + resolutionNote: null, + }; + + const existing = input.userId ? await db.getPendingAssociationDirectoryReviewByUserId(input.userId) : undefined; + if (existing) { + await db.updateAssociationDirectoryReview(existing.id, reviewPayload); + return existing.id; + } + + return db.createAssociationDirectoryReview(reviewPayload); +} + +async function resolvePendingAssociationDirectoryReviewAfterAutoLink(input: { + userId: number; + directoryEntryId: number; + sourceLabel?: string | null; +}) { + const pendingReview = await db.getPendingAssociationDirectoryReviewByUserId(input.userId); + if (!pendingReview) { + return; + } + + await db.updateAssociationDirectoryReview(pendingReview.id, { + status: "linked", + resolvedDirectoryEntryId: input.directoryEntryId, + resolutionNote: + input.sourceLabel + ? `Rapprochement validé automatiquement après ${input.sourceLabel}.` + : "Rapprochement validé automatiquement après mise à jour du profil portail.", + }); +} + +const associationDirectoryManualInputSchema = z.object({ + reviewId: z.number().optional(), + nomAssociation: z.string().min(1, "Le nom de l'association est obligatoire"), + emailOfficiel: z.string().email("Adresse email invalide").optional().or(z.literal("")), + siret: z.string().optional(), + rna: z.string().optional(), + thematiques: z.array(z.enum(associationThematicValues)).optional(), + adresse: z.string().optional(), + codePostal: z.string().optional(), + ville: z.string().optional(), + telephone: z.string().optional(), + siteWeb: z.string().url().optional().or(z.literal("")), + facebookUrl: socialUrlSchema, + instagramUrl: socialUrlSchema, + dateCreation: z.string().optional(), + objetAssociation: z.string().optional(), + statutJuridique: z.enum(['association_loi_1901', 'association_reconnue_utilite_publique', 'fondation', 'autre']).optional(), + nomRepresentant: z.string().optional(), + fonctionRepresentant: z.string().optional(), + isActive: z.boolean().optional(), +}); + +function splitRepresentativeName(fullName: string | null | undefined) { + const cleaned = String(fullName || "").trim().replace(/\s+/g, " "); + if (!cleaned) { + return { prenom: "", nom: "" }; + } + + const parts = cleaned.split(" "); + if (parts.length === 1) { + return { prenom: "", nom: parts[0] }; + } + + return { + prenom: parts.slice(0, -1).join(" "), + nom: parts.at(-1) || "", + }; +} + +function buildGovernanceFromLegacyRepresentative( + nomRepresentant: string | null | undefined, + fonctionRepresentant: string | null | undefined +): AssociationGovernance { + const parts = splitRepresentativeName(nomRepresentant); + const hasRepresentative = Boolean(parts.nom || parts.prenom || fonctionRepresentant); + + return { + representantLegal: hasRepresentative + ? { + nom: parts.nom, + prenom: parts.prenom, + email: "", + telephone: "", + fonction: normalizeLegalRepresentativeRole(fonctionRepresentant), + } + : null, + membres: [], + }; +} + +function validateSocialUrl(field: "facebookUrl" | "instagramUrl", value?: string) { + const normalized = normalizeSocialUrl(value); + if (!normalized) return; + + const isValid = field === "facebookUrl" ? isValidFacebookUrl(normalized) : isValidInstagramUrl(normalized); + if (!isValid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + field === "facebookUrl" + ? "Le lien Facebook doit être une URL HTTPS valide vers facebook.com" + : "Le lien Instagram doit être une URL HTTPS valide vers instagram.com", + }); + } +} + +function buildAppBaseUrl(req: { protocol?: string; headers?: Record; get?: (name: string) => string | undefined }) { + const configuredBaseUrl = process.env.APP_BASE_URL?.trim(); + if (configuredBaseUrl) { + return configuredBaseUrl.replace(/\/$/, ""); + } + + const forwardedProto = typeof req.headers?.["x-forwarded-proto"] === "string" + ? req.headers["x-forwarded-proto"] + : undefined; + const forwardedHost = typeof req.headers?.["x-forwarded-host"] === "string" + ? req.headers["x-forwarded-host"] + : undefined; + const host = forwardedHost || req.get?.("host") || "localhost:3000"; + const protocol = forwardedProto || req.protocol || "http"; + return `${protocol}://${host}`.replace(/\/$/, ""); +} + +function buildAssociationInvitationLink(req: Parameters[0], token: string) { + return `${buildAppBaseUrl(req)}/invite/${token}`; +} + +function formatDateFr(date: Date | string | null | undefined) { + if (!date) return ""; + try { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return String(date); + } +} + +function parseRequestFormData(rawFormData?: string | null) { + if (!rawFormData) return {}; + try { + return JSON.parse(rawFormData); + } catch { + return {}; + } +} + +function normalizeDateString(value?: string | null) { + if (!value) return ""; + const normalized = String(value).trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : ""; +} + +function dateRangesOverlap(startA?: string | null, endA?: string | null, startB?: string | null, endB?: string | null) { + const aStart = normalizeDateString(startA); + const aEnd = normalizeDateString(endA || startA); + const bStart = normalizeDateString(startB); + const bEnd = normalizeDateString(endB || startB); + if (!aStart || !aEnd || !bStart || !bEnd) return false; + return aStart <= bEnd && bStart <= aEnd; +} + +function addDays(date: Date, days: number) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function startOfDay(date: Date) { + const next = new Date(date); + next.setHours(0, 0, 0, 0); + return next; +} + +function toIsoDay(date: Date) { + return date.toISOString().slice(0, 10); +} + +function daysBetweenInclusive(startIso?: string | null, endIso?: string | null) { + const start = normalizeDateString(startIso); + const end = normalizeDateString(endIso || startIso); + if (!start || !end) return 0; + const startDate = new Date(`${start}T00:00:00`); + const endDate = new Date(`${end}T00:00:00`); + const diff = endDate.getTime() - startDate.getTime(); + if (Number.isNaN(diff) || diff < 0) return 0; + return Math.floor(diff / 86_400_000) + 1; +} + +function getOverlapDays(startA?: string | null, endA?: string | null, startB?: string | null, endB?: string | null) { + if (!dateRangesOverlap(startA, endA, startB, endB)) return 0; + const left = new Date(`${normalizeDateString(startA)!}T00:00:00`); + const right = new Date(`${normalizeDateString(endA || startA)!}T00:00:00`); + const periodStart = new Date(`${normalizeDateString(startB)!}T00:00:00`); + const periodEnd = new Date(`${normalizeDateString(endB || startB)!}T00:00:00`); + const overlapStart = left > periodStart ? left : periodStart; + const overlapEnd = right < periodEnd ? right : periodEnd; + const diff = overlapEnd.getTime() - overlapStart.getTime(); + return diff < 0 ? 0 : Math.floor(diff / 86_400_000) + 1; +} + +function buildAnalyticsRange(period: AnalyticsPeriod) { + const today = startOfDay(new Date()); + const end = today; + let start = today; + let label = "7 derniers jours"; + let comparisonLabel = "période précédente"; + let bucketMode: "day" | "week" | "month" = "day"; + + if (period === "7d") { + start = addDays(today, -6); + label = "7 derniers jours"; + comparisonLabel = "7 jours précédents"; + bucketMode = "day"; + } else if (period === "month") { + start = new Date(today.getFullYear(), today.getMonth(), 1); + label = "Mois en cours"; + comparisonLabel = "Mois précédent"; + bucketMode = "week"; + } else if (period === "quarter") { + const quarterStartMonth = Math.floor(today.getMonth() / 3) * 3; + start = new Date(today.getFullYear(), quarterStartMonth, 1); + label = "Trimestre en cours"; + comparisonLabel = "Trimestre précédent"; + bucketMode = "month"; + } else if (period === "semester") { + const semesterStartMonth = today.getMonth() < 6 ? 0 : 6; + start = new Date(today.getFullYear(), semesterStartMonth, 1); + label = "Semestre en cours"; + comparisonLabel = "Semestre précédent"; + bucketMode = "month"; + } else { + start = new Date(today.getFullYear(), 0, 1); + label = "Année en cours"; + comparisonLabel = "Année précédente"; + bucketMode = "month"; + } + + const spanDays = daysBetweenInclusive(toIsoDay(start), toIsoDay(end)); + const previousEnd = addDays(start, -1); + const previousStart = addDays(previousEnd, -(spanDays - 1)); + + return { + start, + end, + startIso: toIsoDay(start), + endIso: toIsoDay(end), + previousStartIso: toIsoDay(previousStart), + previousEndIso: toIsoDay(previousEnd), + label, + comparisonLabel, + bucketMode, + spanDays, + }; +} + +function buildAnalyticsRangeFromReference(period: AnalyticsPeriod, referenceDate: Date, mode: "current" | "completed" = "current") { + if (mode === "current") { + return buildAnalyticsRange(period); + } + + const today = startOfDay(referenceDate); + if (period === "7d") { + const end = addDays(today, -1); + const start = addDays(end, -6); + return { + start, + end, + startIso: toIsoDay(start), + endIso: toIsoDay(end), + previousStartIso: toIsoDay(addDays(start, -7)), + previousEndIso: toIsoDay(addDays(end, -7)), + label: "7 jours précédents", + comparisonLabel: "7 jours antérieurs", + bucketMode: "day" as const, + spanDays: 7, + }; + } + + if (period === "month") { + const end = new Date(today.getFullYear(), today.getMonth(), 0); + const start = new Date(end.getFullYear(), end.getMonth(), 1); + const previousEnd = new Date(start.getFullYear(), start.getMonth(), 0); + const previousStart = new Date(previousEnd.getFullYear(), previousEnd.getMonth(), 1); + return { + start, + end, + startIso: toIsoDay(start), + endIso: toIsoDay(end), + previousStartIso: toIsoDay(previousStart), + previousEndIso: toIsoDay(previousEnd), + label: "Mois précédent", + comparisonLabel: "Mois antérieur", + bucketMode: "week" as const, + spanDays: daysBetweenInclusive(toIsoDay(start), toIsoDay(end)), + }; + } + + if (period === "quarter") { + const currentQuarterStartMonth = Math.floor(today.getMonth() / 3) * 3; + const currentQuarterStart = new Date(today.getFullYear(), currentQuarterStartMonth, 1); + const end = new Date(currentQuarterStart.getFullYear(), currentQuarterStart.getMonth(), 0); + const start = new Date(end.getFullYear(), end.getMonth() - 2, 1); + const previousEnd = new Date(start.getFullYear(), start.getMonth(), 0); + const previousStart = new Date(previousEnd.getFullYear(), previousEnd.getMonth() - 2, 1); + return { + start, + end, + startIso: toIsoDay(start), + endIso: toIsoDay(end), + previousStartIso: toIsoDay(previousStart), + previousEndIso: toIsoDay(previousEnd), + label: "Trimestre précédent", + comparisonLabel: "Trimestre antérieur", + bucketMode: "month" as const, + spanDays: daysBetweenInclusive(toIsoDay(start), toIsoDay(end)), + }; + } + + if (period === "semester") { + const currentSemesterStartMonth = today.getMonth() < 6 ? 0 : 6; + const currentSemesterStart = new Date(today.getFullYear(), currentSemesterStartMonth, 1); + const end = new Date(currentSemesterStart.getFullYear(), currentSemesterStart.getMonth(), 0); + const start = new Date(end.getFullYear(), end.getMonth() - 5, 1); + const previousEnd = new Date(start.getFullYear(), start.getMonth(), 0); + const previousStart = new Date(previousEnd.getFullYear(), previousEnd.getMonth() - 5, 1); + return { + start, + end, + startIso: toIsoDay(start), + endIso: toIsoDay(end), + previousStartIso: toIsoDay(previousStart), + previousEndIso: toIsoDay(previousEnd), + label: "Semestre précédent", + comparisonLabel: "Semestre antérieur", + bucketMode: "month" as const, + spanDays: daysBetweenInclusive(toIsoDay(start), toIsoDay(end)), + }; + } + + const end = new Date(today.getFullYear(), 0, 0); + const start = new Date(end.getFullYear(), 0, 1); + const previousStart = new Date(start.getFullYear() - 1, 0, 1); + const previousEnd = new Date(start.getFullYear() - 1, 11, 31); + return { + start, + end, + startIso: toIsoDay(start), + endIso: toIsoDay(end), + previousStartIso: toIsoDay(previousStart), + previousEndIso: toIsoDay(previousEnd), + label: "Année précédente", + comparisonLabel: "Année antérieure", + bucketMode: "month" as const, + spanDays: daysBetweenInclusive(toIsoDay(start), toIsoDay(end)), + }; +} + +async function getStatsAutomationSettings() { + const raw = await db.getPortalSetting(STATS_AUTOMATION_SETTING_KEY); + if (!raw) return sanitizeStatsAutomationSettings(); + try { + return sanitizeStatsAutomationSettings(JSON.parse(raw)); + } catch { + return sanitizeStatsAutomationSettings(); + } +} + +async function saveStatsAutomationSettings(settings: StatsAutomationSettings) { + await db.setPortalSetting( + STATS_AUTOMATION_SETTING_KEY, + JSON.stringify(settings), + "Automatisation des rapports statistiques" + ); + return settings; +} + +async function getStatsReportHistory() { + const raw = await db.getPortalSetting(STATS_REPORT_HISTORY_SETTING_KEY); + if (!raw) return [] as StatsReportHistoryEntry[]; + try { + return sanitizeStatsReportHistory(JSON.parse(raw)); + } catch { + return []; + } +} + +async function saveStatsReportHistory(history: StatsReportHistoryEntry[]) { + await db.setPortalSetting( + STATS_REPORT_HISTORY_SETTING_KEY, + JSON.stringify(history.slice(0, 30)), + "Historique des rapports statistiques générés automatiquement" + ); +} + +function getCayenneDateParts(date: Date) { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: "America/Cayenne", + year: "numeric", + month: "2-digit", + day: "2-digit", + weekday: "short", + }); + const parts = formatter.formatToParts(date); + const get = (type: string) => parts.find((part) => part.type === type)?.value || ""; + const year = Number(get("year")); + const month = Number(get("month")); + const day = Number(get("day")); + const weekdayLabel = get("weekday"); + const weekdayIndexMap: Record = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 0 }; + return { + year, + month, + day, + isoDate: `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`, + weekday: weekdayIndexMap[weekdayLabel] ?? 0, + }; +} + +function getStatsCycleForToday(period: StatsReportHistoryEntry["period"], now: Date) { + const parts = getCayenneDateParts(now); + if (period === "weekly") { + return { + due: parts.weekday === 1, + cycleKey: `weekly-${parts.isoDate}`, + analyticsPeriod: "7d" as const, + }; + } + if (period === "month") { + return { + due: parts.day === 1, + cycleKey: `month-${parts.year}-${String(parts.month - 1 || 12).padStart(2, "0")}`, + analyticsPeriod: "month" as const, + }; + } + if (period === "quarter") { + return { + due: parts.day === 1 && [1, 4, 7, 10].includes(parts.month), + cycleKey: `quarter-${parts.year}-${Math.max(1, Math.ceil(((parts.month - 1) || 12) / 3))}`, + analyticsPeriod: "quarter" as const, + }; + } + if (period === "semester") { + return { + due: parts.day === 1 && [1, 7].includes(parts.month), + cycleKey: `semester-${parts.year}-${parts.month === 1 ? "S2-prev" : "S1"}`, + analyticsPeriod: "semester" as const, + }; + } + return { + due: parts.day === 1 && parts.month === 1, + cycleKey: `year-${parts.year - 1}`, + analyticsPeriod: "year" as const, + }; +} + +function formatAnalyticsBucketLabel(start: Date, end: Date, mode: "day" | "week" | "month") { + if (mode === "day") { + return start.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" }); + } + if (mode === "month") { + return start.toLocaleDateString("fr-FR", { month: "short", year: "2-digit" }); + } + return `${start.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" })} - ${end.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" })}`; +} + +function buildAnalyticsBuckets(range: ReturnType) { + const buckets: Array<{ key: string; label: string; startIso: string; endIso: string }> = []; + + if (range.bucketMode === "day") { + for (let cursor = new Date(range.start); cursor <= range.end; cursor = addDays(cursor, 1)) { + const startIso = toIsoDay(cursor); + buckets.push({ + key: startIso, + label: formatAnalyticsBucketLabel(cursor, cursor, "day"), + startIso, + endIso: startIso, + }); + } + return buckets; + } + + if (range.bucketMode === "week") { + for (let cursor = new Date(range.start); cursor <= range.end; cursor = addDays(cursor, 7)) { + const bucketStart = new Date(cursor); + const bucketEnd = addDays(bucketStart, 6); + const effectiveEnd = bucketEnd > range.end ? range.end : bucketEnd; + buckets.push({ + key: `${toIsoDay(bucketStart)}-${toIsoDay(effectiveEnd)}`, + label: formatAnalyticsBucketLabel(bucketStart, effectiveEnd, "week"), + startIso: toIsoDay(bucketStart), + endIso: toIsoDay(effectiveEnd), + }); + } + return buckets; + } + + for (let cursor = new Date(range.start.getFullYear(), range.start.getMonth(), 1); cursor <= range.end; cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) { + const bucketStart = new Date(cursor); + const bucketEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0); + const effectiveStart = bucketStart < range.start ? range.start : bucketStart; + const effectiveEnd = bucketEnd > range.end ? range.end : bucketEnd; + buckets.push({ + key: `${toIsoDay(effectiveStart)}-${toIsoDay(effectiveEnd)}`, + label: formatAnalyticsBucketLabel(effectiveStart, effectiveEnd, "month"), + startIso: toIsoDay(effectiveStart), + endIso: toIsoDay(effectiveEnd), + }); + } + + return buckets; +} + +export async function computeReservationAnalytics(period: AnalyticsPeriod, mode: "current" | "completed" = "current", referenceDate: Date = new Date()) { + const range = buildAnalyticsRangeFromReference(period, referenceDate, mode); + const buckets = buildAnalyticsBuckets(range); + const allRequests = await db.getAllRequests(); + const associations = await db.getAllAssociations(); + const associationMap = new Map(associations.map((association) => [association.id, association])); + + const roomUsage = new Map( + salleAnalyticsCatalog.map((room) => [room.id, { roomId: room.id, roomName: room.name, bookedDays: 0 }]) + ); + const materialUsage = new Map( + materialEventItems.map((item) => [item.key, { key: item.key, label: getMaterialEventLabel(item.key), quantity: 0, requests: 0 }]) + ); + const categoryUsage = new Map(); + const associationUsage = new Map(); + const timeline = buckets.map((bucket) => ({ + label: bucket.label, + salle: 0, + materiel: 0, + total: 0, + })); + const history: Array<{ + id: number; + title: string; + type: "demande_salle" | "demande_materiel_evenementiel"; + associationName: string; + status: string; + startDate: string; + endDate: string; + resources: string; + createdAt: Date; + }> = []; + + let salleReservations = 0; + let materialReservations = 0; + let totalMaterialUnits = 0; + let previousSalleReservations = 0; + let previousMaterialReservations = 0; + const currentAssociationIds = new Set(); + const previousAssociationIds = new Set(); + + const includeInUsage = (status: string) => !["brouillon", "refusee", "annulee"].includes(status); + + for (const request of allRequests) { + if (request.type !== "demande_salle" && request.type !== "demande_materiel_evenementiel") continue; + if (!includeInUsage(request.status)) continue; + + const association = associationMap.get(request.associationId); + const thematicValues = parseAssociationThematics(association?.thematique).map((value) => getAssociationThematicLabel(value)); + const categoryLabels = thematicValues.length > 0 ? thematicValues : ["Non renseignée"]; + + if (request.type === "demande_salle") { + const formData = parseRequestFormData(request.formData); + const startDate = normalizeDateString(formData.dateReservation); + const endDate = normalizeDateString(formData.dateFinReservation || formData.dateReservation || startDate); + if (!startDate || !endDate) continue; + + const inCurrentRange = dateRangesOverlap(startDate, endDate, range.startIso, range.endIso); + const inPreviousRange = dateRangesOverlap(startDate, endDate, range.previousStartIso, range.previousEndIso); + if (!inCurrentRange && !inPreviousRange) continue; + + if (inPreviousRange) { + previousSalleReservations += 1; + previousAssociationIds.add(request.associationId); + } + + if (!inCurrentRange) continue; + + salleReservations += 1; + currentAssociationIds.add(request.associationId); + categoryLabels.forEach((label) => { + const current = categoryUsage.get(label) || { key: label, label, count: 0 }; + current.count += 1; + categoryUsage.set(label, current); + }); + + const associationEntry = associationUsage.get(request.associationId) || { + associationId: request.associationId, + associationName: association?.nomAssociation || formData.nomAssociation || `Association #${request.associationId}`, + totalReservations: 0, + salleReservations: 0, + materialReservations: 0, + lastReservationAt: endDate, + thematics: categoryLabels, + }; + associationEntry.totalReservations += 1; + associationEntry.salleReservations += 1; + associationEntry.lastReservationAt = associationEntry.lastReservationAt > endDate ? associationEntry.lastReservationAt : endDate; + associationEntry.thematics = categoryLabels; + associationUsage.set(request.associationId, associationEntry); + + const salleIds = deriveSalleIdsForAnalytics(formData); + salleIds.forEach((salleId) => { + const room = roomUsage.get(salleId); + if (!room) return; + room.bookedDays += getOverlapDays(startDate, endDate, range.startIso, range.endIso); + }); + + buckets.forEach((bucket, index) => { + if (!dateRangesOverlap(startDate, endDate, bucket.startIso, bucket.endIso)) return; + timeline[index].salle += 1; + timeline[index].total += 1; + }); + + history.push({ + id: request.id, + title: request.titre, + type: "demande_salle", + associationName: association?.nomAssociation || formData.nomAssociation || `Association #${request.associationId}`, + status: request.status, + startDate, + endDate, + resources: Array.isArray(formData.sallesSelectionnees) ? formData.sallesSelectionnees.join(", ") : "", + createdAt: request.createdAt, + }); + continue; + } + + const materialEvent = getMaterialRequestEventData(request, association || null); + const startDate = materialEvent.useStartDate; + const endDate = materialEvent.useEndDate; + if (!startDate || !endDate) continue; + + const inCurrentRange = dateRangesOverlap(startDate, endDate, range.startIso, range.endIso); + const inPreviousRange = dateRangesOverlap(startDate, endDate, range.previousStartIso, range.previousEndIso); + if (!inCurrentRange && !inPreviousRange) continue; + + if (inPreviousRange) { + previousMaterialReservations += 1; + previousAssociationIds.add(request.associationId); + } + + if (!inCurrentRange) continue; + + materialReservations += 1; + currentAssociationIds.add(request.associationId); + categoryLabels.forEach((label) => { + const current = categoryUsage.get(label) || { key: label, label, count: 0 }; + current.count += 1; + categoryUsage.set(label, current); + }); + + const associationEntry = associationUsage.get(request.associationId) || { + associationId: request.associationId, + associationName: materialEvent.associationName, + totalReservations: 0, + salleReservations: 0, + materialReservations: 0, + lastReservationAt: endDate, + thematics: categoryLabels, + }; + associationEntry.totalReservations += 1; + associationEntry.materialReservations += 1; + associationEntry.lastReservationAt = associationEntry.lastReservationAt > endDate ? associationEntry.lastReservationAt : endDate; + associationEntry.thematics = categoryLabels; + associationUsage.set(request.associationId, associationEntry); + + materialEvent.requestedItems.forEach((item) => { + const current = materialUsage.get(item.key); + if (!current) return; + current.quantity += item.quantity; + if (item.quantity > 0) { + current.requests += 1; + totalMaterialUnits += item.quantity; + } + }); + + buckets.forEach((bucket, index) => { + if (!dateRangesOverlap(startDate, endDate, bucket.startIso, bucket.endIso)) return; + timeline[index].materiel += 1; + timeline[index].total += 1; + }); + + history.push({ + id: request.id, + title: request.titre, + type: "demande_materiel_evenementiel", + associationName: materialEvent.associationName, + status: request.status, + startDate, + endDate, + resources: materialEvent.requestedItems.map((item) => `${item.label}${item.quantity > 0 ? ` x${item.quantity}` : ""}`).join(", "), + createdAt: request.createdAt, + }); + } + + const totalRoomDays = salleAnalyticsCatalog.length * range.spanDays; + const bookedRoomDays = Array.from(roomUsage.values()).reduce((sum, room) => sum + room.bookedDays, 0); + const occupancyRate = totalRoomDays > 0 ? Math.round((bookedRoomDays / totalRoomDays) * 1000) / 10 : 0; + const previousTotalReservations = previousSalleReservations + previousMaterialReservations; + const currentTotalReservations = salleReservations + materialReservations; + const currentActiveAssociations = currentAssociationIds.size; + const previousActiveAssociations = previousAssociationIds.size; + const comparePercent = (current: number, previous: number) => { + if (previous === 0) return current > 0 ? 100 : 0; + return Math.round(((current - previous) / previous) * 1000) / 10; + }; + + return { + period, + periodLabel: range.label, + comparisonLabel: range.comparisonLabel, + startDate: range.startIso, + endDate: range.endIso, + summary: { + totalReservations: currentTotalReservations, + salleReservations, + materialReservations, + occupancyRate, + activeAssociations: currentActiveAssociations, + totalMaterialUnits, + averageReservationsPerAssociation: currentActiveAssociations > 0 + ? Math.round((currentTotalReservations / currentActiveAssociations) * 10) / 10 + : 0, + }, + comparison: { + totalReservationsPct: comparePercent(currentTotalReservations, previousTotalReservations), + salleReservationsPct: comparePercent(salleReservations, previousSalleReservations), + materialReservationsPct: comparePercent(materialReservations, previousMaterialReservations), + activeAssociationsPct: comparePercent(currentActiveAssociations, previousActiveAssociations), + }, + timeline, + roomOccupancy: Array.from(roomUsage.values()) + .map((room) => ({ + ...room, + occupancyRate: range.spanDays > 0 ? Math.round((room.bookedDays / range.spanDays) * 1000) / 10 : 0, + })) + .sort((left, right) => right.bookedDays - left.bookedDays), + materialUsage: Array.from(materialUsage.values()) + .filter((item) => item.quantity > 0 || item.requests > 0) + .sort((left, right) => right.quantity - left.quantity), + categoryUsage: Array.from(categoryUsage.values()).sort((left, right) => right.count - left.count), + associationUsage: Array.from(associationUsage.values()) + .sort((left, right) => right.totalReservations - left.totalReservations || right.lastReservationAt.localeCompare(left.lastReservationAt)) + .slice(0, 20), + history: history + .sort((left, right) => new Date(right.startDate || right.createdAt).getTime() - new Date(left.startDate || left.createdAt).getTime()) + .slice(0, 50), + }; +} + +async function generateAndArchiveStatsReport(input: { + period: StatsReportHistoryEntry["period"]; + analyticsPeriod: AnalyticsPeriod; + generatedBy: "scheduler" | "manual"; + cycleKey: string; + now?: Date; +}) { + const analytics = await computeReservationAnalytics(input.analyticsPeriod, input.generatedBy === "scheduler" ? "completed" : "current", input.now || new Date()); + const pdf = await generateReservationAnalyticsPdf(analytics); + const excel = generateReservationAnalyticsExcel(analytics); + const now = input.now || new Date(); + const pdfStored = await storagePut( + `portal/stats-reports/${input.period}/${nanoid()}-${pdf.fileName}`, + pdf.buffer, + pdf.contentType + ); + const excelStored = await storagePut( + `portal/stats-reports/${input.period}/${nanoid()}-${excel.fileName}`, + excel.buffer, + excel.contentType + ); + + const entry: StatsReportHistoryEntry = { + id: nanoid(), + period: input.period, + periodLabel: analytics.periodLabel, + startDate: analytics.startDate, + endDate: analytics.endDate, + generatedAt: now.toISOString(), + pdfUrl: pdfStored.url, + pdfName: pdf.fileName, + excelUrl: excelStored.url, + excelName: excel.fileName, + cycleKey: input.cycleKey, + generatedBy: input.generatedBy, + }; + + const currentHistory = await getStatsReportHistory(); + await saveStatsReportHistory([entry, ...currentHistory]); + return { entry, analytics, pdf, excel }; +} + +export async function runStatsReportScheduler(baseUrl: string) { + const settings = await getStatsAutomationSettings(); + if (!settings.enabled) return; + + const periods: Array = ["weekly", "month", "quarter", "semester", "year"]; + const now = new Date(); + + for (const period of periods) { + const enabled = settings.frequencies[period]; + if (!enabled) continue; + + const cycle = getStatsCycleForToday(period, now); + if (!cycle.due) continue; + if (settings.lastGeneratedCycleKeys[period] === cycle.cycleKey) continue; + + const { entry, pdf, excel } = await generateAndArchiveStatsReport({ + period, + analyticsPeriod: cycle.analyticsPeriod, + generatedBy: "scheduler", + cycleKey: cycle.cycleKey, + now, + }); + + settings.lastGeneratedCycleKeys[period] = cycle.cycleKey; + await saveStatsAutomationSettings(settings); + + await db.createAdminNotification({ + type: "systeme", + titre: `Rapport statistiques ${entry.periodLabel}`, + message: `Le rapport automatique ${entry.periodLabel.toLowerCase()} est prêt. PDF et Excel sont archivés dans le module Statistiques.`, + lien: "/admin?tab=analytics", + }); + + if (settings.recipientEmails.length > 0 && await canSendOperationalEmails()) { + await sendOperationalEmail({ + to: settings.recipientEmails, + subject: `Rapport statistiques CCDS - ${entry.periodLabel}`, + text: [ + `Bonjour,`, + ``, + `Le rapport automatique ${entry.periodLabel.toLowerCase()} a été généré.`, + `Période couverte : ${entry.startDate} au ${entry.endDate}.`, + `Consulter le module : ${baseUrl.replace(/\/$/, "")}/admin?tab=analytics`, + ].join("\n"), + attachments: [ + { filename: pdf.fileName, content: pdf.buffer, contentType: pdf.contentType }, + { filename: excel.fileName, content: excel.buffer, contentType: excel.contentType }, + ], + fromName: "Portail Associations CCDS", + }).catch((error) => { + console.error("[StatsScheduler] failed to send report email:", error); + }); + } + } +} + +function deriveSalleIdsForAnalytics(formData: Record): SalleAnalyticsRoomId[] { + const explicitIds = Array.isArray(formData?.sallesIds) + ? formData.sallesIds.filter((value: unknown): value is SalleAnalyticsRoomId => + typeof value === "string" && salleAnalyticsCatalog.some((entry) => entry.id === value) + ) + : []; + if (explicitIds.length > 0) { + return explicitIds; + } + + const names = Array.isArray(formData?.sallesSelectionnees) + ? formData.sallesSelectionnees.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0) + : []; + + return names + .map((name) => { + const normalizedName = name.toLowerCase(); + const byId = salleAnalyticsCatalog.find((entry) => normalizedName.includes(entry.id.toLowerCase())); + if (byId) return byId.id; + const byName = salleAnalyticsCatalog.find((entry) => normalizedName.includes(entry.name.toLowerCase())); + if (byName) return byName.id; + if (normalizedName.includes("toucan")) return "salle_toucan"; + if (normalizedName.includes("ibis")) return "salle_ibis"; + if (normalizedName.includes("pelican") || normalizedName.includes("pélican")) return "salle_pelican"; + if (normalizedName.includes("dojo")) return "dojo"; + if (normalizedName.includes("hall")) return "hall_amphitheatre"; + if (normalizedName.includes("vestiaire")) return "vestiaires"; + if (normalizedName.includes("bureau")) return "bureau_11_17"; + return null; + }) + .filter((value): value is SalleAnalyticsRoomId => value !== null); +} + +function getMaterialRequestEventData(request: Awaited> | Awaited>[number], association?: Awaited> | null) { + const formData = parseRequestFormData(request?.formData); + const startDate = normalizeDateString(formData.dateDebutManifestation || formData.dateManifestation); + const manifestationEnd = normalizeDateString(formData.dateFinManifestation || formData.dateManifestation || startDate); + const restitutionDate = normalizeDateString(formData.dateRestitution || manifestationEnd); + const pickupDate = normalizeDateString(formData.datePriseEnCharge || startDate); + const grantedItems = formData.cadreDSU?.materielEvent?.itemsAccordes || {}; + const grantedQuantities = formData.cadreDSU?.materielEvent?.quantitesAccordees || {}; + const requestedItems = formData.materielsDemandes || {}; + const requestedQuantities = formData.quantitesDemandees || {}; + + const items = materialEventItems + .map((item) => { + const granted = Boolean(grantedItems[item.key]); + const requested = Boolean(requestedItems[item.key]); + if (!granted && !requested) return null; + const quantity = parseMaterialEventQuantity(granted ? grantedQuantities[item.key] : requestedQuantities[item.key]); + return { + key: item.key, + label: getMaterialEventLabel(item.key), + quantity, + granted, + requested, + extra: item.key === "autres" ? String(formData.autreMaterielPrecisions || formData.cadreDSU?.materielEvent?.autresPrecisions || "") : "", + }; + }) + .filter(Boolean) as Array<{ + key: MaterialEventItemKey; + label: string; + quantity: number; + granted: boolean; + requested: boolean; + extra: string; + }>; + + return { + requestId: request?.id, + title: request?.titre || "", + associationId: request?.associationId, + associationName: association?.nomAssociation || formData.nomAssociation || `Association #${request?.associationId}`, + commune: formData.commune || association?.ville || "", + manifestationStart: startDate, + manifestationEnd, + pickupDate, + restitutionDate, + useStartDate: pickupDate || startDate, + useEndDate: restitutionDate || manifestationEnd, + requestedItems: items, + motif: String(formData.motifDemande || request?.description || ""), + }; +} + +function getRequestedMaterialQuantityMap(request: Awaited> | Awaited>[number]) { + const event = getMaterialRequestEventData(request, null); + const next = emptyMaterialEventQuantityMap(); + + event.requestedItems.forEach((item) => { + next[item.key] = item.quantity; + }); + + return next; +} + +function parseBlockedItems(rawValue?: string | null) { + if (!rawValue) { + return emptyMaterialEventQuantityMap(); + } + + try { + return sanitizeMaterialEventQuantityMap(JSON.parse(rawValue)); + } catch { + return emptyMaterialEventQuantityMap(); + } +} + +function serializeBlockedItems(value: Partial>) { + return JSON.stringify(sanitizeMaterialEventQuantityMap(value)); +} + +function sumBlockedInventory( + followups: Array< + | Awaited> + | null + | undefined + > +) { + const totals = emptyMaterialEventQuantityMap(); + + followups.forEach((followup) => { + if (!followup || followup.litigationStatus !== "pending") return; + const blockedItems = parseBlockedItems(followup.blockedItems); + materialEventItems.forEach((item) => { + totals[item.key] += blockedItems[item.key] || 0; + }); + }); + + return totals; +} + +function decodeBase64DataUrl(input: string) { + const raw = input.includes(",") ? input.split(",")[1] || "" : input; + return Buffer.from(raw, "base64"); +} + +async function generateAndStoreMaterialConventionForRequest(input: { + request: NonNullable>>; + association: Awaited>; + decision: { + financialMode: MaterialContractFinancialMode; + depositRequired: boolean; + depositAmountCents: number; + rentalAmountCents: number; + pricingNotes?: string | null; + }; + validatedByUserId: number; +}) { + const contractGeneratedAt = new Date(); + const pdf = await generateMaterialConventionPdf({ + request: input.request, + association: input.association, + decision: { + ...input.decision, + contractStatus: "generee", + contractGeneratedAt, + contractValidatedByUserId: input.validatedByUserId, + }, + }); + + const { key, url } = await storagePut( + `materiel-evenementiel/contract-${input.request.id}-${Date.now()}.pdf`, + pdf.buffer, + "application/pdf" + ); + + return { + contractGeneratedAt, + contractPdfKey: key, + contractPdfUrl: url, + contractPdfName: pdf.fileName, + buffer: pdf.buffer, + }; +} + +async function generateAndStoreSalleConventionForRequest(input: { + request: NonNullable>>; + association: Awaited>; + decision: { + financialMode: MaterialContractFinancialMode; + depositRequired: boolean; + depositAmountCents: number; + rentalAmountCents: number; + pricingNotes?: string | null; + }; + validatedByUserId: number; +}) { + const contractGeneratedAt = new Date(); + const pdf = await generateSalleConventionPdf({ + request: input.request, + association: input.association, + decision: { + ...input.decision, + contractStatus: "generee", + contractGeneratedAt, + contractValidatedByUserId: input.validatedByUserId, + }, + }); + + const { key, url } = await storagePut( + `reservation-salle/contract-${input.request.id}-${Date.now()}.pdf`, + pdf.buffer, + "application/pdf" + ); + + return { + contractGeneratedAt, + contractPdfKey: key, + contractPdfUrl: url, + contractPdfName: pdf.fileName, + buffer: pdf.buffer, + }; +} + +async function generateAndStoreSalleQuoteForRequest(input: { + requestId: number; + requestTitle: string; + formData: any; + association: Awaited>; + pricing: NonNullable>; +}) { + const pdf = await generateSalleQuotePdf({ + requestId: input.requestId, + requestTitle: input.requestTitle, + formData: input.formData, + association: input.association, + pricing: input.pricing, + }); + const { key, url } = await storagePut( + `reservation-salle/devis-${input.requestId}-${Date.now()}.pdf`, + pdf.buffer, + "application/pdf" + ); + return { + quotePdfKey: key, + quotePdfUrl: url, + quotePdfName: pdf.fileName, + buffer: pdf.buffer, + }; +} + +async function generateAndStoreSalleAdministrativeDecisionForRequest(input: { + requestId: number; + decisionText: string; + signedAt?: string | Date | null; + signedByLabel?: string | null; +}) { + const pdf = await generateSalleAdministrativeDecisionPdf({ + requestId: input.requestId, + decisionText: input.decisionText, + signedAt: input.signedAt, + signedByLabel: input.signedByLabel, + }); + const { key, url } = await storagePut( + `reservation-salle/decision-${input.requestId}-${Date.now()}.pdf`, + pdf.buffer, + "application/pdf" + ); + return { + decisionPdfKey: key, + decisionPdfUrl: url, + decisionPdfName: pdf.fileName, + buffer: pdf.buffer, + }; +} + +async function generateAndStoreSalleInvoiceForRequest(input: { + requestId: number; + requestTitle: string; + associationName: string; + datesLabel: string; + pricing: NonNullable>; +}) { + const pdf = await generateSalleInvoicePdf({ + requestId: input.requestId, + requestTitle: input.requestTitle, + associationName: input.associationName, + datesLabel: input.datesLabel, + pricing: input.pricing, + }); + const { key, url } = await storagePut( + `reservation-salle/facture-${input.requestId}-${Date.now()}.pdf`, + pdf.buffer, + "application/pdf" + ); + return { + invoicePdfKey: key, + invoicePdfUrl: url, + invoicePdfName: pdf.fileName, + buffer: pdf.buffer, + }; +} + +async function generateSignedSalleWorkflowPackage(input: { + request: NonNullable>>; + association: Awaited>; + pricing: NonNullable>; + financialDecision: NonNullable>; + signedByUserId: number; + signedByName: string; +}) { + const signedAt = new Date().toISOString(); + let nextFormData = updateSalleWorkflowDirectorStatus(input.request.formData, "signee"); + nextFormData = mergeSalleWorkflowData(nextFormData, { + directorSignedAt: signedAt, + directorSignedByUserId: input.signedByUserId, + directorSignedByName: input.signedByName, + directorReturnComment: "", + directorReturnedAt: "", + }); + + const refreshedRequest = { + ...input.request, + formData: nextFormData, + }; + + const texts = buildDefaultSalleWorkflowTexts(nextFormData); + const decisionPdf = await generateAndStoreSalleAdministrativeDecisionForRequest({ + requestId: input.request.id, + decisionText: texts.decisionAdministrativeText, + signedAt, + signedByLabel: input.signedByName, + }); + const quotePdf = input.financialDecision.financialMode === "location_payante" + ? await generateAndStoreSalleQuoteForRequest({ + requestId: input.request.id, + requestTitle: input.request.titre, + formData: parseRequestFormData(nextFormData), + association: input.association, + pricing: input.pricing, + }) + : null; + + let signedQuoteAttachment: + | { + filename: string; + content: Buffer; + contentType: string; + } + | undefined; + if (quotePdf) { + const signedQuotePdf = await generateSalleQuotePdf({ + requestId: input.request.id, + requestTitle: input.request.titre, + formData: parseRequestFormData(nextFormData), + association: input.association, + pricing: input.pricing, + signedAt, + signedByLabel: input.signedByName, + }); + const { url } = await storagePut( + `reservation-salle/devis-signe-${input.request.id}-${Date.now()}.pdf`, + signedQuotePdf.buffer, + "application/pdf" + ); + nextFormData = mergeSalleWorkflowData(nextFormData, { + signedQuotePdfUrl: url, + signedQuotePdfName: signedQuotePdf.fileName, + }); + signedQuoteAttachment = { + filename: signedQuotePdf.fileName, + content: signedQuotePdf.buffer, + contentType: "application/pdf", + }; + } + + const signedRequestPdf = await generateRequestPdfDocument({ + ...refreshedRequest, + status: "en_cours_traitement", + dateTraitement: new Date(), + formData: nextFormData, + }); + const signedRequestStored = await storagePut( + `reservation-salle/formulaire-signe-${input.request.id}-${Date.now()}.pdf`, + signedRequestPdf.buffer, + signedRequestPdf.contentType + ); + nextFormData = mergeSalleWorkflowData(nextFormData, { + signedDecisionPdfUrl: decisionPdf.decisionPdfUrl, + signedDecisionPdfName: decisionPdf.decisionPdfName, + signedRequestPdfUrl: signedRequestStored.url, + signedRequestPdfName: signedRequestPdf.fileName, + }); + + let generatedContractAttachment: + | { + filename: string; + content: Buffer; + contentType?: string; + } + | undefined; + let invoiceAttachment: + | { + filename: string; + content: Buffer; + contentType: string; + } + | undefined; + const generatedContract = await generateAndStoreSalleConventionForRequest({ + request: { + ...input.request, + formData: nextFormData, + }, + association: input.association, + decision: { + financialMode: input.financialDecision.financialMode, + depositRequired: input.financialDecision.depositRequired, + depositAmountCents: input.financialDecision.depositAmountCents, + rentalAmountCents: input.financialDecision.rentalAmountCents, + pricingNotes: input.financialDecision.pricingNotes, + }, + validatedByUserId: input.signedByUserId, + }); + nextFormData = mergeMaterialFinancialDecision({ + rawFormData: nextFormData, + decision: { + financialMode: input.financialDecision.financialMode, + depositRequired: input.financialDecision.depositRequired, + depositAmountCents: input.financialDecision.depositAmountCents, + rentalAmountCents: input.financialDecision.rentalAmountCents, + pricingNotes: input.financialDecision.pricingNotes, + contractStatus: "signee", + contractPdfUrl: generatedContract.contractPdfUrl, + contractPdfName: generatedContract.contractPdfName, + contractGeneratedAt: generatedContract.contractGeneratedAt, + contractValidatedByUserId: input.signedByUserId, + }, + }); + generatedContractAttachment = { + filename: generatedContract.contractPdfName, + content: generatedContract.buffer, + contentType: "application/pdf", + }; + + if (input.financialDecision.financialMode === "location_payante") { + const invoiceGeneratedAt = new Date().toISOString(); + const invoice = await generateAndStoreSalleInvoiceForRequest({ + requestId: input.request.id, + requestTitle: input.request.titre, + associationName: + input.association?.nomAssociation + || parseRequestFormData(nextFormData).nomAssociation + || "Association", + datesLabel: buildSalleDatesLabel(parseRequestFormData(nextFormData)), + pricing: input.pricing, + }); + nextFormData = mergeSalleWorkflowData(nextFormData, { + invoicePdfUrl: invoice.invoicePdfUrl, + invoicePdfName: invoice.invoicePdfName, + invoiceGeneratedAt, + }); + invoiceAttachment = { + filename: invoice.invoicePdfName, + content: invoice.buffer, + contentType: "application/pdf", + }; + } + + nextFormData = ensureSallePaymentTracking({ + rawFormData: nextFormData, + billed: input.financialDecision.financialMode === "location_payante", + totalAmountCents: input.pricing.totalAmountCents, + }); + + return { + nextFormData, + signedAt, + quotePdfUrl: quotePdf?.quotePdfUrl || "", + quotePdfName: quotePdf?.quotePdfName || "", + signedQuoteAttachment, + signedDecisionAttachment: { + filename: decisionPdf.decisionPdfName, + content: decisionPdf.buffer, + contentType: "application/pdf", + }, + signedRequestAttachment: { + filename: signedRequestPdf.fileName, + content: signedRequestPdf.buffer, + contentType: signedRequestPdf.contentType, + }, + generatedContractAttachment, + invoiceAttachment, + }; +} + +async function ensureSalleInvoicePrepared(input: { + request: NonNullable>>; + association: Awaited>; + pricing: NonNullable>; + billed: boolean; +}) { + if (!input.billed) { + return { + nextFormData: input.request.formData || "", + invoicePdfUrl: "", + invoicePdfName: "", + invoiceGeneratedAt: "", + buffer: null as Buffer | null, + }; + } + + const workflow = getSalleWorkflowData(input.request.formData); + if (workflow.invoicePdfUrl && workflow.invoicePdfName) { + return { + nextFormData: input.request.formData || "", + invoicePdfUrl: workflow.invoicePdfUrl, + invoicePdfName: workflow.invoicePdfName, + invoiceGeneratedAt: workflow.invoiceGeneratedAt || "", + buffer: null as Buffer | null, + }; + } + + const formData = parseRequestFormData(input.request.formData); + const invoice = await generateAndStoreSalleInvoiceForRequest({ + requestId: input.request.id, + requestTitle: input.request.titre, + associationName: input.association?.nomAssociation || formData.nomAssociation || "Association", + datesLabel: buildSalleDatesLabel(formData), + pricing: input.pricing, + }); + const nextFormData = mergeSalleWorkflowData(input.request.formData, { + invoicePdfUrl: invoice.invoicePdfUrl, + invoicePdfName: invoice.invoicePdfName, + invoiceGeneratedAt: new Date().toISOString(), + }); + const nextFormDataWithPayment = ensureSallePaymentTracking({ + rawFormData: nextFormData, + billed: input.billed, + totalAmountCents: input.pricing.totalAmountCents, + }); + + return { + nextFormData: nextFormDataWithPayment, + invoicePdfUrl: invoice.invoicePdfUrl, + invoicePdfName: invoice.invoicePdfName, + invoiceGeneratedAt: new Date().toISOString(), + buffer: invoice.buffer, + }; +} + +async function sendSalleQuoteWorkflow(input: { + request: NonNullable>>; + association: Awaited>; + req: Parameters[0]; + user: { + id: number; + email?: string | null; + }; +}) { + const request = input.request; + if (request.type !== "demande_salle") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Seules les demandes de salle peuvent envoyer un devis" }); + } + + const associationRecipient = input.association?.emailContact?.trim() || parseRequestFormData(request.formData).emailAssociation?.trim(); + if (!associationRecipient) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun email d'association n'est disponible pour envoyer le devis" }); + } + + const financialDecision = parseMaterialFinancialDecision(request.formData); + if (!financialDecision) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Les conditions financières doivent être renseignées avant l'envoi du devis" }); + } + + const pricing = computeSalleWorkflowPricing(request.formData); + if (!pricing || pricing.unsupportedSalles.length > 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Tarification automatique indisponible pour : ${pricing?.unsupportedSalles.map((item) => item.salleNom).join(", ") || "cette demande"}`, + }); + } + + const formData = parseRequestFormData(request.formData); + const texts = buildDefaultSalleWorkflowTexts(request.formData); + const decisionPdf = await generateAndStoreSalleAdministrativeDecisionForRequest({ + requestId: request.id, + decisionText: texts.decisionAdministrativeText, + }); + const quotePdf = financialDecision.financialMode === "location_payante" + ? await generateAndStoreSalleQuoteForRequest({ + requestId: request.id, + requestTitle: request.titre, + formData, + association: input.association, + pricing, + }) + : null; + + const acceptToken = nanoid(48); + const refuseToken = nanoid(48); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + await Promise.all([ + db.createEmailActionToken({ token: acceptToken, requestId: request.id, action: "acceptation_devis_salle", expiresAt }), + db.createEmailActionToken({ token: refuseToken, requestId: request.id, action: "refus_devis_salle", expiresAt }), + ]); + + const baseUrl = buildAppBaseUrl(input.req); + const acceptUrl = `${baseUrl}/api/email-action/${acceptToken}`; + const refuseUrl = `${baseUrl}/api/email-action/${refuseToken}`; + const email = generateSalleQuoteEmail({ + associationName: input.association?.nomAssociation || formData.nomAssociation || "association", + requestTitle: request.titre, + pricing, + acceptUrl, + refuseUrl, + decisionText: texts.decisionAdministrativeText, + }); + + const sentAt = new Date(); + let updatedFormData = mergeSalleWorkflowData(request.formData, { + usageType: pricing.usageType, + frequency: pricing.frequency, + conditionsFinancieresText: CONDITIONS_FINANCIERES_SALLE_TEXT, + decisionAdministrativeText: texts.decisionAdministrativeText, + pricing, + quoteStatus: "en_attente_association", + quoteSentAt: sentAt.toISOString(), + quotePdfUrl: quotePdf?.quotePdfUrl || "", + quotePdfName: quotePdf?.quotePdfName || "", + administrativeDecisionPdfUrl: decisionPdf.decisionPdfUrl, + administrativeDecisionPdfName: decisionPdf.decisionPdfName, + directorStatus: "a_transmettre", + }); + updatedFormData = ensureSallePaymentTracking({ + rawFormData: updatedFormData, + billed: financialDecision.financialMode === "location_payante", + totalAmountCents: pricing.totalAmountCents, + }); + + const result = await sendOperationalEmail({ + to: [associationRecipient], + subject: email.subject, + text: email.text, + html: email.html, + replyTo: input.user.email || undefined, + fromName: "Maison de la Jeunesse des Savanes - CCDS", + attachments: [ + ...(quotePdf ? [{ filename: quotePdf.quotePdfName, content: quotePdf.buffer, contentType: "application/pdf" }] : []), + { filename: decisionPdf.decisionPdfName, content: decisionPdf.buffer, contentType: "application/pdf" }, + ], + }); + + if (!result.sent) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Le mail n'a pas pu être envoyé (${result.reason || "raison inconnue"})` }); + } + + return { + updatedFormData, + pricing, + financialMode: financialDecision.financialMode, + quotePdfUrl: quotePdf?.quotePdfUrl || null, + decisionPdfUrl: decisionPdf.decisionPdfUrl, + }; +} + +function getInvitationExpiry() { + return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); +} + +function getInvitationStatusReason(invitation: { + usedAt: Date | null; + revokedAt: Date | null; + expiresAt: Date; +}) { + if (invitation.usedAt) return "used"; + if (invitation.revokedAt) return "revoked"; + if (new Date() > invitation.expiresAt) return "expired"; + return null; +} + +export const appRouter = router({ + system: systemRouter, + + auth: router({ + me: publicProcedure.query(async opts => withEffectiveInternalAccess(opts.ctx.user)), + databaseStatus: publicProcedure.query(() => ({ + configured: db.isDatabaseConfigured(), + })), + providers: publicProcedure.query(() => getOAuthProviderStatus()), + register: publicProcedure + .input(z.object({ + name: z.string().min(2, "Le nom est requis"), + email: z.string().email("Adresse email invalide"), + password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caracteres"), + invitationToken: z.string().min(1).optional(), + privacyConsent: dataPrivacyConsentSchema, + })) + .mutation(async ({ ctx, input }) => { + try { + assertDataPrivacyConsent(input.privacyConsent); + let registrationEmail = input.email; + let invitation: Awaited> | null = null; + let directoryEntry: Awaited> | undefined; + + if (input.invitationToken) { + invitation = await db.getAssociationInvitationByToken(input.invitationToken); + if (!invitation) { + throw new Error("Invitation introuvable"); + } + + const invalidReason = getInvitationStatusReason(invitation); + if (invalidReason === "used") { + throw new Error("Cette invitation a déjà été utilisée"); + } + if (invalidReason === "revoked") { + throw new Error("Cette invitation a été remplacée par une nouvelle"); + } + if (invalidReason === "expired") { + throw new Error("Cette invitation a expiré"); + } + + directoryEntry = await db.getAssociationDirectoryEntryById(invitation.directoryEntryId); + if (!directoryEntry) { + throw new Error("Association du bordereau introuvable"); + } + + const existingAssociation = await db.getAssociationBySourceDirectoryEntryId(invitation.directoryEntryId); + if (existingAssociation) { + throw new Error("Cette association a déjà activé son espace"); + } + + if (normalizeEmail(input.email) !== invitation.emailOfficielNormalise) { + throw new Error("L'email saisi ne correspond pas à l'invitation"); + } + + registrationEmail = invitation.emailOfficiel; + } + + const user = await registerLocalUser({ + name: input.name, + email: registrationEmail, + password: input.password, + }); + await recordDataPrivacyConsent(user.id, ctx.req, input.privacyConsent); + + const matchedDirectoryEntry = directoryEntry + || await db.getAssociationDirectoryEntryByNormalizedEmail(normalizeEmail(registrationEmail)); + + if (matchedDirectoryEntry) { + await ensureAssociationLinkedToDirectoryEntry(user.id, matchedDirectoryEntry.id); + await resolvePendingAssociationDirectoryReviewAfterAutoLink({ + userId: user.id, + directoryEntryId: matchedDirectoryEntry.id, + sourceLabel: "l'inscription portail", + }); + } else if (!invitation) { + const matchResult = await db.findAssociationDirectoryMatch({ + nomAssociation: input.name, + email: registrationEmail, + }); + await queueAssociationDirectoryReview({ + userId: user.id, + sourceType: "portal_signup", + sourceLabel: "Inscription portail", + nomAssociation: input.name, + email: registrationEmail, + matchResult, + }); + } + + if (invitation && matchedDirectoryEntry) { + await db.markAssociationInvitationUsed(invitation.token, user.id); + } + + const token = await createSessionToken(user); + setSessionCookie(ctx.req, ctx.res, token); + return withEffectiveInternalAccess(user); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error instanceof Error ? error.message : "Inscription impossible", + }); + } + }), + login: publicProcedure + .input(z.object({ + email: z.string().email("Adresse email invalide"), + password: z.string().min(1, "Le mot de passe est requis"), + })) + .mutation(async ({ ctx, input }) => { + try { + const user = await loginLocalUser(input); + const token = await createSessionToken(user); + setSessionCookie(ctx.req, ctx.res, token); + return { + mfaRequired: false as const, + user: await withEffectiveInternalAccess(user), + }; + } catch (error) { + if (isMfaCodeRequiredError(error)) { + return { + mfaRequired: true as const, + challengeType: error.challengeType, + challengeToken: error.challengeToken, + expiresAt: error.expiresAt, + maskedEmail: error.maskedEmail, + }; + } + + if (isAccountLockedError(error)) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: error.message, + }); + } + + throw new TRPCError({ + code: "UNAUTHORIZED", + message: error instanceof Error ? error.message : "Email ou mot de passe incorrect", + }); + } + }), + verifyLoginMfa: publicProcedure + .input(z.object({ + challengeToken: z.string().min(12), + code: z.string().trim().regex(/^\d{6}$/, "Le code doit contenir 6 chiffres"), + })) + .mutation(async ({ ctx, input }) => { + try { + const user = await verifyLocalUserMfa(input); + const token = await createSessionToken(user); + setSessionCookie(ctx.req, ctx.res, token); + return await withEffectiveInternalAccess(user); + } catch (error) { + if (isAccountLockedError(error)) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: error.message, + }); + } + + throw new TRPCError({ + code: "UNAUTHORIZED", + message: error instanceof Error ? error.message : "Verification impossible", + }); + } + }), + resendLoginMfa: publicProcedure + .input(z.object({ + challengeToken: z.string().min(12), + })) + .mutation(async ({ input }) => { + try { + const payload = await resendLocalUserMfaChallenge(input.challengeToken); + return { + challengeType: "email" as const, + ...payload, + }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error instanceof Error ? error.message : "Impossible de renvoyer le code", + }); + } + }), + logout: publicProcedure.mutation(({ ctx }) => { + clearSessionCookie(ctx.req, ctx.res); + return { success: true } as const; + }), + getSecuritySettings: protectedProcedure.query(async ({ ctx }) => { + const freshUser = await db.getUserById(ctx.user.id); + const loginMethod = freshUser?.loginMethod || ctx.user.loginMethod || null; + const smtpReady = await canSendOperationalEmails(); + const canManageMfa = loginMethod === "local_jwt" && Boolean(freshUser?.email); + const effectiveRole = freshUser?.role || ctx.user.role; + const mfaPolicy = getInternalMfaPolicy(effectiveRole); + const mfaRequiredByRole = Boolean(mfaPolicy?.required); + const hasAuthenticator = Boolean( + freshUser?.mfaMethod === "authenticator_app" && freshUser?.mfaTotpSecretEncrypted + ); + const emailMfaActive = Boolean((freshUser?.mfaEnabled || mfaRequiredByRole) && freshUser?.email); + + return { + email: freshUser?.email || ctx.user.email || "", + role: effectiveRole, + loginMethod, + mfaEnabled: Boolean(freshUser?.mfaEnabled || mfaRequiredByRole), + canManageMfa, + smtpReady, + passwordHashAlgorithm: freshUser?.passwordHash?.split(":")[0] || null, + mfaMethod: hasAuthenticator ? "authenticator_app" : emailMfaActive ? "email_otp" : null, + mfaRequiredByRole, + hasPendingAuthenticatorSetup: Boolean(freshUser?.mfaTotpPendingSecretEncrypted), + mfaPolicy: { + required: mfaRequiredByRole, + preferredMethod: mfaPolicy?.preferredMethod || null, + enforcedMethod: mfaPolicy?.enforcedMethod || null, + emailFallbackAllowed: mfaPolicy?.emailFallbackAllowed ?? true, + phaseLabel: mfaPolicy?.phaseLabel || null, + currentCompliance: + hasAuthenticator + ? "aligned" + : mfaPolicy?.enforcedMethod === "authenticator_app" + ? "transition" + : emailMfaActive + ? "aligned" + : mfaRequiredByRole + ? "missing" + : "optional", + }, + deletionRequestedAt: freshUser?.deletionRequestedAt || null, + purgeScheduledAt: freshUser?.purgeScheduledAt || null, + lockoutPolicy: { + maxAttempts: 5, + lockMinutes: 15, + }, + }; + }), + setMfaEnabled: protectedProcedure + .input(z.object({ + enabled: z.boolean(), + })) + .mutation(async ({ ctx, input }) => { + const freshUser = await db.getUserById(ctx.user.id); + if (!freshUser) { + throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" }); + } + + if (freshUser.loginMethod !== "local_jwt" || !freshUser.email) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Le MFA par email est disponible uniquement pour les comptes locaux avec une adresse email valide.", + }); + } + + if (input.enabled && !(await canSendOperationalEmails())) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Le SMTP doit etre configure avant d'activer le MFA par email.", + }); + } + + const rolePolicy = getInternalMfaPolicy(freshUser.role); + const isRequiredByRole = Boolean(rolePolicy?.required); + + if (!input.enabled && isRequiredByRole) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Le MFA est obligatoire pour ce rôle interne. Passez sur Authenticator ou gardez le code email.", + }); + } + + if (input.enabled && rolePolicy?.emailFallbackAllowed === false) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Ce rôle interne doit utiliser l'application Authenticator. Le code email n'est plus autorisé comme méthode normale.", + }); + } + + await db.updateUser(ctx.user.id, { + mfaEnabled: input.enabled, + mfaMethod: input.enabled ? "email_otp" : null, + mfaChallengeToken: null, + mfaCodeHash: null, + mfaCodeExpiresAt: null, + mfaCodeAttempts: 0, + mfaTotpSecretEncrypted: null, + mfaTotpPendingSecretEncrypted: null, + }); + + await db.createAuditLog({ + userId: ctx.user.id, + action: input.enabled ? "activation_mfa" : "desactivation_mfa", + entityType: "user", + entityId: ctx.user.id, + details: JSON.stringify({ + method: input.enabled ? "email_otp" : null, + }), + ipAddress: extractClientIp(ctx.req) || undefined, + }); + + return { success: true }; + }), + startAuthenticatorSetup: protectedProcedure.mutation(async ({ ctx }) => { + const user = await db.getUserById(ctx.user.id); + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" }); + } + try { + return await startAuthenticatorSetup(user); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error instanceof Error ? error.message : "Impossible de démarrer la configuration Authenticator", + }); + } + }), + confirmAuthenticatorSetup: protectedProcedure + .input(z.object({ + code: z.string().trim().regex(/^\d{6}$/, "Le code doit contenir 6 chiffres"), + })) + .mutation(async ({ ctx, input }) => { + const user = await db.getUserById(ctx.user.id); + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" }); + } + try { + await confirmAuthenticatorSetup(user, input.code); + return { success: true }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error instanceof Error ? error.message : "Impossible de confirmer Authenticator", + }); + } + }), + cancelAuthenticatorSetup: protectedProcedure.mutation(async ({ ctx }) => { + const user = await db.getUserById(ctx.user.id); + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" }); + } + await cancelAuthenticatorSetup(user); + return { success: true }; + }), + exportMyData: protectedProcedure.query(async ({ ctx }) => { + const [user, association] = await Promise.all([ + db.getUserById(ctx.user.id), + db.getAssociationByUserId(ctx.user.id), + ]); + + const documents = association ? await db.getDocumentsByAssociationId(association.id) : []; + const requests = association ? await db.getRequestsByAssociationId(association.id) : []; + const requestHistories = await Promise.all( + requests.map(async (request) => ({ + requestId: request.id, + history: await db.getRequestHistoryByRequestId(request.id), + })) + ); + + return { + generatedAt: new Date().toISOString(), + exportVersion: "2026-06-12", + user: user + ? { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + loginMethod: user.loginMethod, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastSignedIn: user.lastSignedIn, + privacyConsentVersion: user.privacyConsentVersion, + privacyConsentAcceptedAt: user.privacyConsentAcceptedAt, + privacyConsentContext: user.privacyConsentContext, + } + : null, + association, + documents: documents.map((document) => ({ + id: document.id, + nom: document.nom, + type: document.type, + description: document.description, + fileUrl: document.fileUrl, + mimeType: document.mimeType, + fileSize: document.fileSize, + uploadedAt: document.uploadedAt, + updatedAt: document.updatedAt, + })), + requests, + requestHistories, + }; + }), + deleteMyAccount: protectedProcedure + .input(z.object({ + confirm: z.literal(true), + })) + .mutation(async ({ ctx }) => { + if (ctx.user.role !== "user") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Les comptes internes doivent etre clotures par l'administration.", + }); + } + + const user = await db.getUserById(ctx.user.id); + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" }); + } + + let deletion; + try { + deletion = await scheduleAccountDeletion(user); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error instanceof Error ? error.message : "Impossible de planifier la suppression du compte", + }); + } + + await db.createAuditLog({ + userId: ctx.user.id, + action: "demande_suppression_compte_utilisateur", + entityType: "user", + entityId: ctx.user.id, + details: JSON.stringify({ + purgeScheduledAt: deletion.purgeScheduledAt, + ipAddress: extractClientIp(ctx.req), + }), + ipAddress: extractClientIp(ctx.req) || undefined, + }); + + clearSessionCookie(ctx.req, ctx.res); + + return { success: true, purgeScheduledAt: deletion.purgeScheduledAt }; + }), + cancelAccountDeletion: protectedProcedure.mutation(async ({ ctx }) => { + const user = await db.getUserById(ctx.user.id); + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" }); + } + await cancelScheduledAccountDeletion(user); + await db.createAuditLog({ + userId: ctx.user.id, + action: "annulation_suppression_compte_utilisateur", + entityType: "user", + entityId: ctx.user.id, + details: JSON.stringify({ + ipAddress: extractClientIp(ctx.req), + }), + ipAddress: extractClientIp(ctx.req) || undefined, + }); + return { success: true }; + }), + }), + + // ============== ASSOCIATION ROUTES ============== + association: router({ + getMyProfile: protectedProcedure.query(async ({ ctx }) => { + const existing = await db.getAssociationByUserId(ctx.user.id); + if (existing) { + return existing; + } + + if (!ctx.user.email) { + return null; + } + + const directoryEntry = await db.getAssociationDirectoryEntryByNormalizedEmail(normalizeEmail(ctx.user.email)); + if (!directoryEntry) { + return null; + } + + const id = await db.createAssociation(createAssociationProfileFromDirectoryEntry(ctx.user.id, directoryEntry)); + return db.getAssociationById(id); + }), + + upsertProfile: protectedProcedure + .input(z.object({ + nomAssociation: z.string().min(1), + siret: z.string().optional(), + rna: z.string().optional(), + thematiques: z.array(z.enum(associationThematicValues)).optional(), + adresse: z.string().optional(), + codePostal: z.string().optional(), + ville: z.string().optional(), + telephone: z.string().optional(), + emailContact: optionalEmailFieldSchema, + siteWeb: z.string().url().optional().or(z.literal('')), + facebookUrl: socialUrlSchema, + instagramUrl: socialUrlSchema, + dateCreation: z.string().optional(), + objetAssociation: z.string().optional(), + statutJuridique: z.enum(['association_loi_1901', 'association_reconnue_utilite_publique', 'fondation', 'autre']).optional(), + nomRepresentant: z.string().optional(), + fonctionRepresentant: z.string().optional(), + gouvernance: z.object({ + representantLegal: z.object({ + nom: z.string().min(1), + prenom: z.string().min(1), + email: z.string().email().optional().or(z.literal("")), + telephone: z.string().optional(), + fonction: z.enum(legalRepresentativeRoleValues), + }), + membres: z.array( + z.object({ + nom: z.string().optional(), + prenom: z.string().optional(), + email: z.string().email().optional().or(z.literal("")), + telephone: z.string().optional(), + fonction: z.enum(governanceMemberRoleValues), + }) + ).optional(), + }).optional(), + privacyConsent: dataPrivacyConsentSchema, + })) + .mutation(async ({ ctx, input }) => { + assertDataPrivacyConsent(input.privacyConsent); + validateSocialUrl("facebookUrl", input.facebookUrl); + validateSocialUrl("instagramUrl", input.instagramUrl); + + const existing = await db.getAssociationByUserId(ctx.user.id); + const rawGovernance: AssociationGovernance = input.gouvernance + ? { + representantLegal: { + nom: input.gouvernance.representantLegal.nom, + prenom: input.gouvernance.representantLegal.prenom, + email: input.gouvernance.representantLegal.email || "", + telephone: input.gouvernance.representantLegal.telephone || "", + fonction: input.gouvernance.representantLegal.fonction, + }, + membres: (input.gouvernance.membres ?? []).map((member) => ({ + nom: member.nom || "", + prenom: member.prenom || "", + email: member.email || "", + telephone: member.telephone || "", + fonction: member.fonction, + })), + } + : buildGovernanceFromLegacyRepresentative(input.nomRepresentant, input.fonctionRepresentant); + const governance = sanitizeGovernance(rawGovernance); + const representantLegal = governance.representantLegal; + if (!representantLegal?.nom || !representantLegal?.prenom || !representantLegal?.fonction) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Le représentant légal principal est obligatoire.", + }); + } + const nomRepresentant = buildDisplayName(representantLegal); + + const data = { + nomAssociation: input.nomAssociation, + siret: input.siret, + rna: input.rna, + adresse: input.adresse, + codePostal: input.codePostal, + ville: input.ville, + telephone: input.telephone, + emailContact: input.emailContact?.trim() || null, + objetAssociation: input.objetAssociation, + statutJuridique: input.statutJuridique, + userId: ctx.user.id, + dateCreation: input.dateCreation ? new Date(input.dateCreation) : undefined, + siteWeb: input.siteWeb || null, + facebookUrl: normalizeSocialUrl(input.facebookUrl) || null, + instagramUrl: normalizeSocialUrl(input.instagramUrl) || null, + thematique: serializeAssociationThematics(input.thematiques), + nomRepresentant, + fonctionRepresentant: representantLegal.fonction, + gouvernance: serializeAssociationGovernance(governance), + profileComplete: !!(input.nomAssociation && input.adresse && input.ville && nomRepresentant), + }; + + if (existing) { + const sourceDirectoryEntryId = existing.sourceDirectoryEntryId || null; + let nextSourceDirectoryEntryId = sourceDirectoryEntryId; + + if (!sourceDirectoryEntryId) { + const matchResult = await db.findAssociationDirectoryMatch({ + nomAssociation: input.nomAssociation, + email: input.emailContact || ctx.user.email || null, + siret: input.siret, + rna: input.rna, + ville: input.ville, + telephone: input.telephone, + nomRepresentant, + }); + + if (matchResult.status === "matched" && matchResult.matchedEntry) { + const occupied = await db.getAssociationBySourceDirectoryEntryId(matchResult.matchedEntry.id); + if (!occupied || occupied.id === existing.id) { + nextSourceDirectoryEntryId = matchResult.matchedEntry.id; + } else { + await queueAssociationDirectoryReview({ + userId: ctx.user.id, + sourceType: "portal_signup", + sourceLabel: "Profil portail", + nomAssociation: input.nomAssociation, + email: input.emailContact || ctx.user.email || null, + siret: input.siret, + rna: input.rna, + ville: input.ville, + telephone: input.telephone, + nomRepresentant, + matchResult: { + ...matchResult, + status: "ambiguous", + reason: "Une fiche du bordereau compatible existe déjà, mais elle est rattachée à un autre compte portail.", + }, + }); + } + } else { + await queueAssociationDirectoryReview({ + userId: ctx.user.id, + sourceType: "portal_signup", + sourceLabel: "Profil portail", + nomAssociation: input.nomAssociation, + email: input.emailContact || ctx.user.email || null, + siret: input.siret, + rna: input.rna, + ville: input.ville, + telephone: input.telephone, + nomRepresentant, + matchResult, + }); + } + } + + if (nextSourceDirectoryEntryId) { + await resolvePendingAssociationDirectoryReviewAfterAutoLink({ + userId: ctx.user.id, + directoryEntryId: nextSourceDirectoryEntryId, + sourceLabel: "la mise à jour de la fiche association", + }); + } + + await db.updateAssociation(existing.id, { + ...data, + ...(nextSourceDirectoryEntryId ? { sourceDirectoryEntryId: nextSourceDirectoryEntryId } : {}), + }); + const updatedAssociation = await db.getAssociationById(existing.id); + await syncAssociationBackToDirectoryEntry(updatedAssociation); + return { id: existing.id, updated: true }; + } else { + let sourceDirectoryEntryId: number | undefined; + const matchResult = await db.findAssociationDirectoryMatch({ + nomAssociation: input.nomAssociation, + email: input.emailContact || ctx.user.email || null, + siret: input.siret, + rna: input.rna, + ville: input.ville, + telephone: input.telephone, + nomRepresentant, + }); + if (matchResult.status === "matched" && matchResult.matchedEntry) { + const occupied = await db.getAssociationBySourceDirectoryEntryId(matchResult.matchedEntry.id); + if (!occupied) { + sourceDirectoryEntryId = matchResult.matchedEntry.id; + } else { + await queueAssociationDirectoryReview({ + userId: ctx.user.id, + sourceType: "portal_signup", + sourceLabel: "Profil portail", + nomAssociation: input.nomAssociation, + email: input.emailContact || ctx.user.email || null, + siret: input.siret, + rna: input.rna, + ville: input.ville, + telephone: input.telephone, + nomRepresentant, + matchResult: { + ...matchResult, + status: "ambiguous", + reason: "Une fiche du bordereau compatible existe déjà, mais elle est déjà rattachée à un autre compte portail.", + }, + }); + } + } else { + await queueAssociationDirectoryReview({ + userId: ctx.user.id, + sourceType: "portal_signup", + sourceLabel: "Profil portail", + nomAssociation: input.nomAssociation, + email: input.emailContact || ctx.user.email || null, + siret: input.siret, + rna: input.rna, + ville: input.ville, + telephone: input.telephone, + nomRepresentant, + matchResult, + }); + } + + if (sourceDirectoryEntryId) { + await resolvePendingAssociationDirectoryReviewAfterAutoLink({ + userId: ctx.user.id, + directoryEntryId: sourceDirectoryEntryId, + sourceLabel: "la création de la fiche association", + }); + } + + const id = await db.createAssociation({ + ...(data as any), + ...(sourceDirectoryEntryId ? { sourceDirectoryEntryId } : {}), + }); + const createdAssociation = await db.getAssociationById(id); + await syncAssociationBackToDirectoryEntry(createdAssociation); + // Create notification for new association + await db.createAdminNotification({ + type: 'nouvelle_association', + titre: 'Nouvelle association inscrite', + message: `L'association "${input.nomAssociation}" vient de s'inscrire sur le portail.`, + lien: `/admin?tab=associations`, + }); + return { id, updated: false }; + } + + await recordDataPrivacyConsent(ctx.user.id, ctx.req, input.privacyConsent); + }), + + getById: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const association = await db.getAssociationById(input.id); + if (!association) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Association non trouvée' }); + } + // Get related data + const [documents, requests] = await Promise.all([ + db.getDocumentsByAssociationId(input.id), + db.getRequestsByAssociationId(input.id), + ]); + return { ...association, documents, requests }; + }), + + listAll: adminProcedure.query(async () => { + return db.getAllAssociations(); + }), + + search: adminProcedure + .input(z.object({ + search: z.string().optional(), + ville: z.string().optional(), + profileComplete: z.boolean().optional(), + isActive: z.boolean().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + })) + .query(async ({ input }) => { + return db.searchAssociations(input); + }), + + getCommuneCounts: adminProcedure.query(async () => { + return db.getAssociationCommuneCounts(); + }), + + toggleStatus: adminProcedure + .input(z.object({ + id: z.number(), + isActive: z.boolean(), + })) + .mutation(async ({ ctx, input }) => { + await db.toggleAssociationStatus(input.id, input.isActive); + await logAdminAction(ctx.user.id, input.isActive ? 'activation' : 'desactivation', 'association', input.id); + return { success: true }; + }), + + export: adminProcedure.query(async () => { + const associations = await db.getAllAssociations(); + return associations.map(a => ({ + id: a.id, + sourceDirectoryEntryId: a.sourceDirectoryEntryId, + nomAssociation: a.nomAssociation, + siret: a.siret || '', + rna: a.rna || '', + thematique: a.thematique || '', + adresse: a.adresse || '', + codePostal: a.codePostal || '', + ville: a.ville || '', + telephone: a.telephone || '', + emailContact: a.emailContact || '', + statutJuridique: a.statutJuridique || '', + nomRepresentant: a.nomRepresentant || '', + fonctionRepresentant: a.fonctionRepresentant || '', + profileComplete: a.profileComplete ? 'Oui' : 'Non', + isActive: a.isActive ? 'Actif' : 'Inactif', + createdAt: a.createdAt?.toISOString() || '', + })); + }), + }), + + associationDirectory: router({ + getSummary: adminProcedure.query(async () => { + return db.getAssociationDirectoryEntriesSummary(); + }), + + listReviewQueue: adminProcedure + .input(z.object({ + status: z.enum(["pending", "linked", "created", "ignored"]).optional(), + sourceType: z.enum(["portal_signup", "helloasso"]).optional(), + limit: z.number().min(1).max(100).optional(), + }).optional()) + .query(async ({ input }) => { + const reviews = await db.listAssociationDirectoryReviews({ + status: input?.status, + sourceType: input?.sourceType, + limit: input?.limit || 50, + }); + + return reviews.map((review) => { + let payload: Record | null = null; + try { + payload = review.payload ? JSON.parse(review.payload) : null; + } catch { + payload = null; + } + + return { + ...review, + candidates: Array.isArray(payload?.candidates) ? payload?.candidates : [], + reason: typeof payload?.reason === "string" ? payload.reason : "", + proposedTelephone: typeof payload?.proposedTelephone === "string" ? payload.proposedTelephone : "", + proposedNomRepresentant: typeof payload?.proposedNomRepresentant === "string" ? payload.proposedNomRepresentant : "", + }; + }); + }), + + getPendingUpdateProposal: adminProcedure + .input(z.object({ + directoryEntryId: z.number(), + sourceType: z.enum(["official_registry", "helloasso"]).optional(), + })) + .query(async ({ input }) => { + const proposal = await db.getLatestPendingAssociationDirectoryUpdateProposalByEntryId( + input.directoryEntryId, + input.sourceType + ); + + if (!proposal) return null; + + let payload: Record | null = null; + try { + payload = proposal.payload ? JSON.parse(proposal.payload) : null; + } catch { + payload = null; + } + + return { + ...proposal, + updates: payload?.updates ?? {}, + changes: Array.isArray(payload?.changes) ? payload.changes : [], + }; + }), + + getById: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const details = await db.getAssociationDirectoryEntryDetails(input.id); + if (!details) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + + return details; + }), + + createManual: adminProcedure + .input(associationDirectoryManualInputSchema) + .mutation(async ({ ctx, input }) => { + validateSocialUrl("facebookUrl", input.facebookUrl); + validateSocialUrl("instagramUrl", input.instagramUrl); + + const entryId = await db.createAssociationDirectoryEntry({ + nomAssociation: input.nomAssociation, + emailOfficiel: input.emailOfficiel || null, + emailOfficielNormalise: input.emailOfficiel ? normalizeEmail(input.emailOfficiel) : null, + siret: normalizeSiretValue(input.siret), + rna: normalizeRnaValue(input.rna), + thematique: serializeAssociationThematics(input.thematiques), + adresse: input.adresse || null, + codePostal: input.codePostal || null, + ville: input.ville || null, + telephone: input.telephone || null, + siteWeb: input.siteWeb || null, + facebookUrl: normalizeSocialUrl(input.facebookUrl) || null, + instagramUrl: normalizeSocialUrl(input.instagramUrl) || null, + dateCreation: input.dateCreation ? new Date(input.dateCreation) : null, + objetAssociation: input.objetAssociation || null, + statutJuridique: input.statutJuridique || "association_loi_1901", + nomRepresentant: input.nomRepresentant || null, + fonctionRepresentant: input.fonctionRepresentant || null, + sourceFileName: "saisie_manuelle_admin", + sourceRowNumber: null, + sourceFingerprint: `manual:${Date.now()}:${normalizeEmail(ctx.user.email || String(ctx.user.id))}`, + isActive: input.isActive ?? true, + importedAt: new Date(), + externalSourceStatus: "manual_entry", + externalSourceLabel: "Saisie manuelle admin", + }); + + if (input.reviewId) { + const review = await db.getAssociationDirectoryReviewById(input.reviewId); + if (review) { + await db.updateAssociationDirectoryReview(input.reviewId, { + status: "created", + resolvedDirectoryEntryId: entryId, + resolutionNote: `Fiche créée manuellement par ${ctx.user.email || ctx.user.name || "admin"}.`, + }); + if (review.userId) { + await ensureAssociationLinkedToDirectoryEntry(review.userId, entryId); + } + } + } + + await logAdminAction(ctx.user.id, "create_manual_directory_entry", "association_directory", entryId, { + reviewId: input.reviewId || null, + nomAssociation: input.nomAssociation, + }); + + return { success: true, id: entryId } as const; + }), + + updateManual: adminProcedure + .input(associationDirectoryManualInputSchema.extend({ + id: z.number(), + })) + .mutation(async ({ ctx, input }) => { + validateSocialUrl("facebookUrl", input.facebookUrl); + validateSocialUrl("instagramUrl", input.instagramUrl); + + const existing = await db.getAssociationDirectoryEntryById(input.id); + if (!existing) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + + await db.updateAssociationDirectoryEntry(input.id, { + nomAssociation: input.nomAssociation, + emailOfficiel: input.emailOfficiel || null, + emailOfficielNormalise: input.emailOfficiel ? normalizeEmail(input.emailOfficiel) : null, + siret: normalizeSiretValue(input.siret), + rna: normalizeRnaValue(input.rna), + thematique: serializeAssociationThematics(input.thematiques), + adresse: input.adresse || null, + codePostal: input.codePostal || null, + ville: input.ville || null, + telephone: input.telephone || null, + siteWeb: input.siteWeb || null, + facebookUrl: normalizeSocialUrl(input.facebookUrl) || null, + instagramUrl: normalizeSocialUrl(input.instagramUrl) || null, + dateCreation: input.dateCreation ? new Date(input.dateCreation) : null, + objetAssociation: input.objetAssociation || null, + statutJuridique: input.statutJuridique || existing.statutJuridique || "association_loi_1901", + nomRepresentant: input.nomRepresentant || null, + fonctionRepresentant: input.fonctionRepresentant || null, + isActive: input.isActive ?? existing.isActive, + externalSourceStatus: "manual_entry_updated", + externalSourceLabel: "Fiche bordereau modifiée dans l'admin", + }); + + await logAdminAction(ctx.user.id, "update_manual_directory_entry", "association_directory", input.id, { + nomAssociation: input.nomAssociation, + }); + + return { success: true } as const; + }), + + listLatest: adminProcedure + .input(z.object({ + search: z.string().optional(), + registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(), + commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(), + thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(), + limit: z.number().min(1).max(200).optional(), + }).optional()) + .query(async ({ input }) => { + const result = await db.listAssociationDirectoryEntriesWithStatus({ + search: input?.search, + registrationStatus: input?.registrationStatus, + commune: input?.commune as any, + thematique: input?.thematique as any, + limit: input?.limit || 50, + }); + + const invitationSummaries = await db.getAssociationInvitationSummariesForDirectoryEntryIds( + result.data.map(entry => entry.id) + ); + + return { + total: result.total, + data: result.data.map((entry) => ({ + ...entry, + thematiques: parseAssociationThematics(entry.thematique), + invitationStatus: invitationSummaries[entry.id], + })), + }; + }), + + listPortal: publicProcedure + .input(z.object({ + search: z.string().optional(), + registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(), + commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(), + thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(), + limit: z.number().min(1).max(200).optional(), + }).optional()) + .query(async ({ input }) => { + const result = await db.listAssociationDirectoryEntriesWithStatus({ + search: input?.search, + registrationStatus: input?.registrationStatus, + commune: input?.commune as any, + thematique: input?.thematique as any, + limit: input?.limit || 50, + }); + const invitationSummaries = await db.getAssociationInvitationSummariesForDirectoryEntryIds( + result.data.map(entry => entry.id) + ); + + return { + total: result.total, + data: result.data.map((entry) => ({ + id: entry.id, + nomAssociation: entry.nomAssociation, + emailOfficiel: entry.emailOfficiel, + siret: entry.siret, + rna: entry.rna, + thematiques: parseAssociationThematics(entry.thematique), + adresse: entry.adresse, + codePostal: entry.codePostal, + ville: entry.ville, + geoSource: entry.geoSource, + geoPrecision: entry.geoPrecision, + siteWeb: entry.siteWeb, + facebookUrl: entry.facebookUrl, + instagramUrl: entry.instagramUrl, + importedAt: entry.importedAt, + registered: entry.registered, + registeredAt: entry.registeredAt, + invitationStatus: invitationSummaries[entry.id], + })), + }; + }), + + listMap: publicProcedure + .input(z.object({ + search: z.string().optional(), + registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(), + commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(), + thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(), + limit: z.number().min(1).max(1000).optional(), + }).optional()) + .query(async ({ input }) => { + const result = await db.listAssociationDirectoryMapEntries({ + search: input?.search, + registrationStatus: input?.registrationStatus, + commune: input?.commune as any, + thematique: input?.thematique as any, + limit: input?.limit || 500, + }); + + return { + total: result.total, + data: result.data.map((entry: any) => ({ + ...entry, + thematiques: parseAssociationThematics(entry.thematique), + })), + }; + }), + + getMapEntry: publicProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const details = await db.getAssociationDirectoryEntryDetails(input.id); + if (!details) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association introuvable" }); + } + + const payload = buildPublicAssociationDirectoryPayload(details); + if (!payload) { + throw new TRPCError({ code: "FORBIDDEN", message: "Cette association n'est pas visible sur la carte publique" }); + } + + return { + ...payload, + sourceLabel: details.entry.externalSourceLabel, + geoSource: details.entry.geoSource, + }; + }), + + setManualCoordinates: adminProcedure + .input(z.object({ + id: z.number(), + latitude: z.number().min(-90).max(90).nullable(), + longitude: z.number().min(-180).max(180).nullable(), + geoPrecision: z.enum(["exact_address", "commune_center", "hidden"]), + })) + .mutation(async ({ ctx, input }) => { + const details = await db.getAssociationDirectoryEntryDetails(input.id); + if (!details) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + + const hasCoordinates = input.latitude !== null && input.longitude !== null; + await db.updateAssociationDirectoryEntry(input.id, { + latitude: hasCoordinates && input.latitude !== null ? input.latitude.toFixed(6) : null, + longitude: hasCoordinates && input.longitude !== null ? input.longitude.toFixed(6) : null, + geoPrecision: input.geoPrecision, + geoSource: hasCoordinates ? "manual" : null, + geoLastSyncedAt: new Date(), + externalSourceStatus: hasCoordinates ? "manual_coordinates" : "manual_coordinates_cleared", + externalSourceLabel: hasCoordinates ? "Coordonnées définies manuellement" : null, + }); + + await logAdminAction(ctx.user.id, "set_manual_geo", "association_directory", input.id, { + latitude: input.latitude, + longitude: input.longitude, + geoPrecision: input.geoPrecision, + }); + + return { success: true }; + }), + + syncExternalData: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const details = await db.getAssociationDirectoryEntryDetails(input.id); + if (!details) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + + const updates = await computeAssociationDirectoryGeoUpdate(details.entry, details.association); + await db.updateAssociationDirectoryEntry(input.id, updates); + await logAdminAction(ctx.user.id, "sync_geo", "association_directory", input.id, { + externalSourceStatus: updates.externalSourceStatus, + geoSource: updates.geoSource, + }); + + return { + success: true, + externalSourceStatus: updates.externalSourceStatus, + geoSource: updates.geoSource, + }; + }), + + syncExternalDataBatch: adminProcedure + .input(z.object({ + commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(), + limit: z.number().min(1).max(500).optional(), + }).optional()) + .mutation(async ({ ctx, input }) => { + const result = await db.listAssociationDirectoryEntriesWithStatus({ + commune: input?.commune as any, + limit: input?.limit || 200, + registrationStatus: "all", + }); + + let updated = 0; + let unresolved = 0; + + for (const entry of result.data) { + const details = await db.getAssociationDirectoryEntryDetails(entry.id); + if (!details) continue; + + const updates = await computeAssociationDirectoryGeoUpdate(details.entry, details.association); + await db.updateAssociationDirectoryEntry(entry.id, updates); + if (updates.latitude && updates.longitude) { + updated += 1; + } else { + unresolved += 1; + } + } + + await logAdminAction(ctx.user.id, "sync_geo_batch", "association_directory", undefined, { + commune: input?.commune || "all", + updated, + unresolved, + total: result.data.length, + }); + + return { + success: true, + total: result.data.length, + updated, + unresolved, + }; + }), + + syncReferenceData: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const details = await db.getAssociationDirectoryEntryDetails(input.id); + if (!details) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + + const proposal = await computeAssociationDirectoryReferenceProposal(details.entry, details.association); + + if (!proposal.hasChanges) { + await logAdminAction(ctx.user.id, "scan_reference_data", "association_directory", input.id, { + hasChanges: false, + sourceLabel: proposal.sourceLabel, + summary: proposal.summary, + }); + + return { + success: true, + hasChanges: false, + proposalId: null, + summary: proposal.summary, + sourceLabel: proposal.sourceLabel, + changes: [], + }; + } + + const existingPending = await db.getLatestPendingAssociationDirectoryUpdateProposalByEntryId( + input.id, + "official_registry" + ); + + const payload = JSON.stringify({ + updates: proposal.updates, + changes: proposal.changes, + }); + + let proposalId = existingPending?.id || null; + if (existingPending) { + await db.updateAssociationDirectoryUpdateProposal(existingPending.id, { + sourceLabel: proposal.sourceLabel, + summary: proposal.summary, + payload, + status: "pending", + dismissedAt: null, + appliedAt: null, + }); + } else { + proposalId = await db.createAssociationDirectoryUpdateProposal({ + directoryEntryId: input.id, + sourceType: "official_registry", + sourceLabel: proposal.sourceLabel, + summary: proposal.summary, + payload, + status: "pending", + }); + } + + await logAdminAction(ctx.user.id, "sync_reference_data", "association_directory", input.id, { + hasChanges: true, + proposalId, + sourceLabel: proposal.sourceLabel, + changedFields: proposal.changes.map((change) => change.field), + }); + + return { + success: true, + hasChanges: true, + proposalId, + summary: proposal.summary, + sourceLabel: proposal.sourceLabel, + changes: proposal.changes, + }; + }), + + syncReferenceDataBatch: adminProcedure + .input(z.object({ + search: z.string().optional(), + registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(), + commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(), + thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(), + limit: z.number().min(1).max(200).optional(), + }).optional()) + .mutation(async ({ ctx, input }) => { + const result = await db.listAssociationDirectoryEntriesWithStatus({ + search: input?.search, + registrationStatus: input?.registrationStatus, + commune: input?.commune as any, + thematique: input?.thematique as any, + limit: input?.limit || 50, + }); + + let proposalsCreated = 0; + let unchanged = 0; + let failed = 0; + + for (const row of result.data) { + try { + const details = await db.getAssociationDirectoryEntryDetails(row.id); + if (!details) continue; + + const proposal = await computeAssociationDirectoryReferenceProposal(details.entry, details.association); + if (!proposal.hasChanges) { + unchanged += 1; + continue; + } + + const existingPending = await db.getLatestPendingAssociationDirectoryUpdateProposalByEntryId( + row.id, + "official_registry" + ); + const payload = JSON.stringify({ + updates: proposal.updates, + changes: proposal.changes, + }); + + if (existingPending) { + await db.updateAssociationDirectoryUpdateProposal(existingPending.id, { + sourceLabel: proposal.sourceLabel, + summary: proposal.summary, + payload, + status: "pending", + dismissedAt: null, + appliedAt: null, + }); + } else { + await db.createAssociationDirectoryUpdateProposal({ + directoryEntryId: row.id, + sourceType: "official_registry", + sourceLabel: proposal.sourceLabel, + summary: proposal.summary, + payload, + status: "pending", + }); + } + + proposalsCreated += 1; + } catch { + failed += 1; + } + } + + await logAdminAction(ctx.user.id, "sync_reference_data_batch", "association_directory", undefined, { + search: input?.search || "", + registrationStatus: input?.registrationStatus || "all", + commune: input?.commune || "all", + thematique: input?.thematique || "all", + total: result.data.length, + proposalsCreated, + unchanged, + failed, + }); + + return { + success: true, + total: result.data.length, + proposalsCreated, + unchanged, + failed, + }; + }), + + applyUpdateProposal: adminProcedure + .input(z.object({ proposalId: z.number() })) + .mutation(async ({ ctx, input }) => { + const proposal = await db.getAssociationDirectoryUpdateProposalById(input.proposalId); + if (!proposal) { + throw new TRPCError({ code: "NOT_FOUND", message: "Proposition d’enrichissement introuvable" }); + } + if (proposal.status !== "pending") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette proposition a déjà été traitée" }); + } + + let payload: Record | null = null; + try { + payload = proposal.payload ? JSON.parse(proposal.payload) : null; + } catch { + payload = null; + } + + const updates = payload?.updates; + if (!updates || typeof updates !== "object") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le contenu de la proposition est invalide" }); + } + + await db.updateAssociationDirectoryEntry(proposal.directoryEntryId, updates); + await db.updateAssociationDirectoryUpdateProposal(proposal.id, { + status: "applied", + appliedAt: new Date(), + }); + + await logAdminAction(ctx.user.id, "apply_directory_update_proposal", "association_directory", proposal.directoryEntryId, { + proposalId: proposal.id, + sourceType: proposal.sourceType, + sourceLabel: proposal.sourceLabel, + }); + + return { success: true } as const; + }), + + dismissUpdateProposal: adminProcedure + .input(z.object({ proposalId: z.number() })) + .mutation(async ({ ctx, input }) => { + const proposal = await db.getAssociationDirectoryUpdateProposalById(input.proposalId); + if (!proposal) { + throw new TRPCError({ code: "NOT_FOUND", message: "Proposition d’enrichissement introuvable" }); + } + if (proposal.status !== "pending") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette proposition a déjà été traitée" }); + } + + await db.updateAssociationDirectoryUpdateProposal(proposal.id, { + status: "dismissed", + dismissedAt: new Date(), + }); + + await logAdminAction(ctx.user.id, "dismiss_directory_update_proposal", "association_directory", proposal.directoryEntryId, { + proposalId: proposal.id, + sourceType: proposal.sourceType, + sourceLabel: proposal.sourceLabel, + }); + + return { success: true } as const; + }), + + syncHelloAssoById: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const details = await db.getAssociationDirectoryEntryDetails(input.id); + if (!details) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + + const settings = await getHelloAssoSettings(); + const result = await computeAssociationDirectoryHelloAssoUpdate(details.entry, details.association, settings); + await db.updateAssociationDirectoryEntry(input.id, result.updates); + + await logAdminAction(ctx.user.id, "sync_helloasso", "association_directory", input.id, { + matched: result.matched, + reason: result.reason, + slug: result.slug, + candidateCount: result.candidateCount, + externalSourceStatus: result.updates.externalSourceStatus, + }); + + return { + success: true, + ...result, + }; + }), + + syncHelloAssoBatch: adminProcedure + .input(z.object({ + search: z.string().optional(), + registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(), + commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(), + thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(), + limit: z.number().min(1).max(200).optional(), + }).optional()) + .mutation(async ({ ctx, input }) => { + const settings = await getHelloAssoSettings(); + const client = new HelloAssoSyncClient(settings); + const result = await db.listAssociationDirectoryEntriesWithStatus({ + search: input?.search, + registrationStatus: input?.registrationStatus, + commune: input?.commune as any, + thematique: input?.thematique as any, + limit: input?.limit || 50, + }); + + let updated = 0; + let skipped = 0; + let matched = 0; + + for (const entry of result.data) { + const details = await db.getAssociationDirectoryEntryDetails(entry.id); + if (!details) continue; + + const syncResult = await computeAssociationDirectoryHelloAssoUpdate( + details.entry, + details.association, + settings, + client + ); + await db.updateAssociationDirectoryEntry(entry.id, syncResult.updates); + if (syncResult.matched) { + matched += 1; + updated += 1; + } else { + skipped += 1; + } + } + + await logAdminAction(ctx.user.id, "sync_helloasso_batch", "association_directory", undefined, { + search: input?.search || "", + registrationStatus: input?.registrationStatus || "all", + commune: input?.commune || "all", + thematique: input?.thematique || "all", + total: result.data.length, + updated, + skipped, + }); + + return { + success: true, + total: result.data.length, + updated, + matched, + skipped, + }; + }), + + resolveReviewLink: adminProcedure + .input(z.object({ + reviewId: z.number(), + directoryEntryId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + const [review, entry] = await Promise.all([ + db.getAssociationDirectoryReviewById(input.reviewId), + db.getAssociationDirectoryEntryById(input.directoryEntryId), + ]); + + if (!review) { + throw new TRPCError({ code: "NOT_FOUND", message: "Correspondance à valider introuvable" }); + } + if (!entry) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + + await db.updateAssociationDirectoryReview(input.reviewId, { + status: "linked", + resolvedDirectoryEntryId: input.directoryEntryId, + resolutionNote: `Rapprochement validé manuellement par ${ctx.user.email || ctx.user.name || "admin"}.`, + }); + + if (review.userId) { + await ensureAssociationLinkedToDirectoryEntry(review.userId, input.directoryEntryId); + } + + await logAdminAction(ctx.user.id, "resolve_directory_review_link", "association_directory_review", input.reviewId, { + directoryEntryId: input.directoryEntryId, + }); + + return { success: true } as const; + }), + + ignoreReview: adminProcedure + .input(z.object({ + reviewId: z.number(), + resolutionNote: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const review = await db.getAssociationDirectoryReviewById(input.reviewId); + if (!review) { + throw new TRPCError({ code: "NOT_FOUND", message: "Correspondance à valider introuvable" }); + } + + await db.updateAssociationDirectoryReview(input.reviewId, { + status: "ignored", + resolutionNote: input.resolutionNote || `Revue ignorée par ${ctx.user.email || ctx.user.name || "admin"}.`, + }); + + await logAdminAction(ctx.user.id, "ignore_directory_review", "association_directory_review", input.reviewId); + return { success: true } as const; + }), + + previewImport: superAdminProcedure + .input(z.object({ + fileData: z.string(), + fileName: z.string(), + mimeType: z.string(), + })) + .mutation(async ({ input }) => { + const fileBuffer = Buffer.from(input.fileData, "base64"); + const preview = parseAssociationDirectoryWorkbook(fileBuffer, input.fileName); + return { + fileName: preview.fileName, + totalRows: preview.totalRows, + validRows: preview.validRows, + missingEmailRows: preview.missingEmailRows, + duplicateEmailRows: preview.duplicateEmailRows, + duplicateGroups: preview.duplicateGroups, + previewRows: preview.previewRows.slice(0, 100), + }; + }), + + importWorkbook: superAdminProcedure + .input(z.object({ + fileData: z.string(), + fileName: z.string(), + mimeType: z.string(), + duplicateSelections: z.record(z.string(), z.number()).optional(), + })) + .mutation(async ({ ctx, input }) => { + const fileBuffer = Buffer.from(input.fileData, "base64"); + const preview = parseAssociationDirectoryWorkbook(fileBuffer, input.fileName); + const unresolvedDuplicateGroups = preview.duplicateGroups.filter( + group => !input.duplicateSelections?.[group.emailOfficielNormalise] + ); + + if (unresolvedDuplicateGroups.length > 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Choisis une ligne à conserver pour chaque email dupliqué avant de lancer l'import", + }); + } + + const rowsToImport = resolveAssociationDirectoryImportRows(preview, input.duplicateSelections); + + let created = 0; + let updated = 0; + + for (const row of rowsToImport) { + const result = await db.upsertAssociationDirectoryEntry({ + nomAssociation: row.nomAssociation, + emailOfficiel: row.emailOfficiel, + emailOfficielNormalise: row.emailOfficielNormalise, + siret: row.siret, + rna: row.rna, + adresse: row.adresse, + codePostal: row.codePostal, + ville: row.ville, + telephone: row.telephone, + siteWeb: row.siteWeb, + facebookUrl: row.facebookUrl, + instagramUrl: row.instagramUrl, + dateCreation: row.dateCreation, + objetAssociation: row.objetAssociation, + statutJuridique: row.statutJuridique, + nomRepresentant: row.nomRepresentant, + fonctionRepresentant: row.fonctionRepresentant, + sourceFileName: input.fileName, + sourceRowNumber: row.rowNumber, + sourceFingerprint: row.sourceFingerprint, + isActive: true, + importedAt: new Date(), + }); + + if (result === "created") created += 1; + if (result === "updated") updated += 1; + } + + await logAdminAction(ctx.user.id, "import", "association_directory", undefined, { + fileName: input.fileName, + totalRows: preview.totalRows, + validRows: preview.validRows, + missingEmailRows: preview.missingEmailRows, + duplicateEmailRows: preview.duplicateEmailRows, + duplicateSelections: input.duplicateSelections || {}, + created, + updated, + }); + + return { + fileName: input.fileName, + totalRows: preview.totalRows, + validRows: preview.validRows, + missingEmailRows: preview.missingEmailRows, + duplicateEmailRows: preview.duplicateEmailRows, + importedRows: rowsToImport.length, + created, + updated, + }; + }), + }), + + associationInvitation: router({ + getPublic: publicProcedure + .input(z.object({ + token: z.string().min(1), + })) + .query(async ({ input }) => { + const invitation = await db.getAssociationInvitationByToken(input.token); + if (!invitation) { + throw new TRPCError({ code: "NOT_FOUND", message: "Invitation introuvable" }); + } + + const directoryEntry = await db.getAssociationDirectoryEntryById(invitation.directoryEntryId); + if (!directoryEntry) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + + const existingAssociation = await db.getAssociationBySourceDirectoryEntryId(invitation.directoryEntryId); + const invalidReason = getInvitationStatusReason(invitation); + + return { + token: invitation.token, + valid: !invalidReason && !existingAssociation, + invalidReason: existingAssociation ? "already_registered" : invalidReason, + association: { + id: directoryEntry.id, + nomAssociation: directoryEntry.nomAssociation, + emailOfficiel: directoryEntry.emailOfficiel, + nomRepresentant: directoryEntry.nomRepresentant, + ville: directoryEntry.ville, + }, + expiresAt: invitation.expiresAt, + }; + }), + + send: adminProcedure + .input(z.object({ + directoryEntryId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + const directoryEntry = await db.getAssociationDirectoryEntryById(input.directoryEntryId); + if (!directoryEntry) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + if (!directoryEntry.emailOfficiel || !directoryEntry.emailOfficielNormalise) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun email officiel n'est renseigné pour cette association" }); + } + + const existingAssociation = await db.getAssociationBySourceDirectoryEntryId(directoryEntry.id); + if (existingAssociation) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette association a déjà activé son espace" }); + } + + await db.revokeAssociationInvitationsByDirectoryEntryId(directoryEntry.id); + + const token = randomBytes(32).toString("hex"); + const expiresAt = getInvitationExpiry(); + const invitationLink = buildAssociationInvitationLink(ctx.req, token); + const canSend = await canSendOperationalEmails(); + let emailSent = false; + + if (canSend) { + try { + const email = generateAssociationInvitationEmail({ + associationName: directoryEntry.nomAssociation, + invitationLink, + expiresAt, + }); + const delivery = await sendOperationalEmail({ + to: [directoryEntry.emailOfficiel], + subject: email.subject, + text: email.text, + html: email.html, + }); + emailSent = delivery.sent; + } catch (error) { + console.error("[AssociationInvitation] SMTP send failed:", error); + emailSent = false; + } + } + + await db.createAssociationInvitation({ + directoryEntryId: directoryEntry.id, + emailOfficiel: directoryEntry.emailOfficiel, + emailOfficielNormalise: directoryEntry.emailOfficielNormalise, + token, + deliveryMode: "email", + emailSent, + sentByUserId: ctx.user.id, + sentAt: new Date(), + expiresAt, + }); + + await logAdminAction(ctx.user.id, "invitation_association", "association_directory", directoryEntry.id, { + emailOfficiel: directoryEntry.emailOfficiel, + invitationLink, + emailSent, + expiresAt, + }); + + return { + invitationLink, + emailSent, + expiresAt, + }; + }), + + getSecureLink: adminProcedure + .input(z.object({ + directoryEntryId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + const directoryEntry = await db.getAssociationDirectoryEntryById(input.directoryEntryId); + if (!directoryEntry) { + throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" }); + } + if (!directoryEntry.emailOfficiel || !directoryEntry.emailOfficielNormalise) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun email officiel n'est renseigné pour cette association" }); + } + + const existingAssociation = await db.getAssociationBySourceDirectoryEntryId(directoryEntry.id); + if (existingAssociation) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette association a déjà activé son espace" }); + } + + let invitation = await db.getActiveAssociationInvitationByDirectoryEntryId(directoryEntry.id); + if (!invitation) { + const token = randomBytes(32).toString("hex"); + const expiresAt = getInvitationExpiry(); + const invitationId = await db.createAssociationInvitation({ + directoryEntryId: directoryEntry.id, + emailOfficiel: directoryEntry.emailOfficiel, + emailOfficielNormalise: directoryEntry.emailOfficielNormalise, + token, + deliveryMode: "manual_link", + emailSent: false, + sentByUserId: ctx.user.id, + sentAt: new Date(), + expiresAt, + }); + invitation = await db.getAssociationInvitationByToken(token); + await logAdminAction(ctx.user.id, "copie_invitation_association", "association_directory", directoryEntry.id, { + invitationId, + expiresAt, + }); + } + + if (!invitation) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Impossible de générer le lien d'invitation" }); + } + + return { + invitationLink: buildAssociationInvitationLink(ctx.req, invitation.token), + expiresAt: invitation.expiresAt, + }; + }), + + revoke: adminProcedure + .input(z.object({ + directoryEntryId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + await db.revokeAssociationInvitationsByDirectoryEntryId(input.directoryEntryId); + await logAdminAction(ctx.user.id, "revocation_invitation_association", "association_directory", input.directoryEntryId); + return { success: true } as const; + }), + }), + + materialReturn: router({ + listMine: terrainProcedure.query(async ({ ctx }) => { + const currentEmail = normalizeEmail(String(ctx.user.email || "")); + if (!currentEmail) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Le compte terrain doit disposer d'une adresse email pour retrouver ses missions", + }); + } + + const logisticsSettings = await getLogisticsSettings(); + const requests = (await db.searchRequests({ + type: "demande_materiel_evenementiel", + status: "validee", + limit: 500, + })).data; + + const entries = await Promise.all( + requests.map(async (request) => { + const association = await db.getAssociationById(request.associationId); + const followup = await db.getMaterialReturnFollowupByRequestId(request.id); + const payload = serializeMaterialReturnFollowup(followup); + if (!payload) return null; + + const isAssignedRecipient = payload.recipientEmails.some((email) => normalizeEmail(email) === currentEmail); + const hasAlreadyWorkedOnMission = normalizeEmail(String(payload.uploadedByEmail || "")) === currentEmail; + if (!isAssignedRecipient && !hasAlreadyWorkedOnMission) return null; + + const formData = (() => { + try { + return request.formData ? JSON.parse(request.formData) : {}; + } catch { + return {}; + } + })(); + + const restitutionDate = payload.restitutionDate || formData.dateRestitution || null; + const boardState = computeMaterialReturnBoardState({ + requestStatus: request.status, + restitutionDate, + followup, + graceDays: logisticsSettings.materialReturnGraceDays, + }); + + return { + requestId: request.id, + title: request.titre, + associationName: association?.nomAssociation || `Association #${request.associationId}`, + commune: formData.commune || association?.ville || "", + pickupDate: formData.datePriseEnCharge || formData.dateDebutManifestation || formData.dateManifestation || null, + restitutionDate, + boardState, + boardStateLabel: materialReturnBoardStateLabels[boardState], + serviceLabel: payload.serviceLabel || "", + plannedSendAt: payload.plannedSendAt || null, + sentAt: payload.sentAt || null, + uploadedAt: payload.uploadedAt || null, + validatedAt: payload.validatedAt || null, + closedAt: payload.closedAt || null, + issueFlag: Boolean(payload.issueFlag), + compliance: payload.compliance || null, + finalPdfUrl: payload.finalPdfUrl || payload.signedFileUrl || null, + finalPdfName: payload.finalPdfName || payload.signedFileName || null, + uploadLink: payload.uploadToken + ? buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), payload.uploadToken) + : null, + uploadLinkState: getMaterialReturnUploadLinkState({ + status: payload.status, + uploadTokenExpiresAt: payload.uploadTokenExpiresAt || null, + }), + uploadTokenExpiresAt: payload.uploadTokenExpiresAt || null, + lastReminderSentAt: payload.lastReminderSentAt || null, + uploadedByName: payload.uploadedByName || "", + uploadedByEmail: payload.uploadedByEmail || "", + }; + }) + ); + + const boardOrder = { + terrain: 0, + retard: 1, + planifie: 2, + litige: 3, + conforme: 4, + a_attribuer: 5, + } as const; + + return entries + .filter((entry): entry is NonNullable => Boolean(entry)) + .sort((left, right) => { + const orderDiff = boardOrder[left.boardState] - boardOrder[right.boardState]; + if (orderDiff !== 0) return orderDiff; + const leftDate = left.restitutionDate ? new Date(left.restitutionDate).getTime() : 0; + const rightDate = right.restitutionDate ? new Date(right.restitutionDate).getTime() : 0; + return leftDate - rightDate; + }); + }), + + getByRequestId: logisticsProcedure + .input(z.object({ requestId: z.number() })) + .query(async ({ input, ctx }) => { + const logisticsSettings = await getLogisticsSettings(); + const followup = await db.getMaterialReturnFollowupByRequestId(input.requestId); + const payload = serializeMaterialReturnFollowup(followup); + if (!payload) return null; + const request = await db.getRequestById(input.requestId); + const reactivatedByUser = payload.reactivatedByUserId + ? await db.getUserById(payload.reactivatedByUserId) + : null; + const boardState = computeMaterialReturnBoardState({ + requestStatus: request?.status, + restitutionDate: payload.restitutionDate, + followup, + graceDays: logisticsSettings.materialReturnGraceDays, + }); + return { + ...payload, + boardState, + boardStateLabel: materialReturnBoardStateLabels[boardState], + uploadLink: payload.uploadToken + ? buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), payload.uploadToken) + : null, + uploadLinkState: getMaterialReturnUploadLinkState({ + status: payload.status, + uploadTokenExpiresAt: payload.uploadTokenExpiresAt, + }), + reactivatedByName: reactivatedByUser?.name || null, + }; + }), + + listAdminBoard: logisticsProcedure + .query(async ({ ctx }) => { + const logisticsSettings = await getLogisticsSettings(); + const requests = (await db.searchRequests({ + type: "demande_materiel_evenementiel", + status: "validee", + limit: 500, + })).data; + + const entries = await Promise.all( + requests.map(async (request) => { + const association = await db.getAssociationById(request.associationId); + const followup = await db.getMaterialReturnFollowupByRequestId(request.id); + const payload = serializeMaterialReturnFollowup(followup); + const formData = (() => { + try { + return request.formData ? JSON.parse(request.formData) : {}; + } catch { + return {}; + } + })(); + + const restitutionDate = payload?.restitutionDate || formData.dateRestitution || null; + const boardState = computeMaterialReturnBoardState({ + requestStatus: request.status, + restitutionDate, + followup, + graceDays: logisticsSettings.materialReturnGraceDays, + }); + + return { + requestId: request.id, + title: request.titre, + status: request.status, + associationId: request.associationId, + associationName: association?.nomAssociation || `Association #${request.associationId}`, + commune: formData.commune || association?.ville || "", + pickupDate: formData.datePriseEnCharge || formData.dateDebutManifestation || formData.dateManifestation || null, + restitutionDate, + boardState, + boardStateLabel: materialReturnBoardStateLabels[boardState], + serviceLabel: payload?.serviceLabel || "", + recipientEmails: payload?.recipientEmails || [], + supervisionServiceLabel: payload?.supervisionServiceLabel || "", + supervisionRecipientEmails: payload?.supervisionRecipientEmails || [], + plannedSendAt: payload?.plannedSendAt || null, + sentAt: payload?.sentAt || null, + uploadedAt: payload?.uploadedAt || null, + validatedAt: payload?.validatedAt || null, + closedAt: payload?.closedAt || null, + issueFlag: Boolean(payload?.issueFlag), + compliance: payload?.compliance || null, + finalPdfUrl: payload?.finalPdfUrl || payload?.signedFileUrl || null, + uploadLink: payload?.uploadToken + ? buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), payload.uploadToken) + : null, + uploadLinkState: getMaterialReturnUploadLinkState({ + status: payload?.status, + uploadTokenExpiresAt: payload?.uploadTokenExpiresAt || null, + }), + uploadTokenExpiresAt: payload?.uploadTokenExpiresAt || null, + reactivatedAt: payload?.reactivatedAt || null, + lastReminderSentAt: payload?.lastReminderSentAt || null, + }; + }) + ); + + const boardOrder = { + a_attribuer: 0, + planifie: 1, + terrain: 2, + retard: 3, + litige: 4, + conforme: 5, + } as const; + + return entries.sort((a, b) => { + const orderDiff = boardOrder[a.boardState] - boardOrder[b.boardState]; + if (orderDiff !== 0) return orderDiff; + const dateA = a.restitutionDate ? new Date(a.restitutionDate).getTime() : 0; + const dateB = b.restitutionDate ? new Date(b.restitutionDate).getTime() : 0; + return dateA - dateB; + }); + }), + + assign: logisticsProcedure + .input( + z.object({ + requestId: z.number(), + recapServiceId: z.number().optional(), + recapServiceLabel: z.string().optional(), + recapRecipients: z.array(z.string()).optional(), + supervisionServiceId: z.number().optional(), + supervisionServiceLabel: z.string().optional(), + supervisionRecipients: z.array(z.string()).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.requestId); + if (!request || request.type !== "demande_materiel_evenementiel") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de matériel introuvable" }); + } + if (request.status !== "validee") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Seules les demandes validées peuvent être assignées à la logistique" }); + } + const existingFollowup = await db.getMaterialReturnFollowupByRequestId(request.id); + if (existingFollowup?.status === "cloture") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette restitution est déjà clôturée" }); + } + + const selectedRecapService = input.recapServiceId + ? await db.getOperationalRecapServiceById(input.recapServiceId) + : undefined; + const selectedSupervisionService = input.supervisionServiceId + ? await db.getOperationalRecapServiceById(input.supervisionServiceId) + : undefined; + const resolvedRecipients = mergeRecipients( + parseStoredRecipientEmails(selectedRecapService?.recipientEmails), + parseRecipients(input.recapRecipients) + ); + const resolvedServiceLabel = input.recapServiceLabel?.trim() || selectedRecapService?.label || undefined; + const resolvedSupervisionRecipients = mergeRecipients( + parseStoredRecipientEmails(selectedSupervisionService?.recipientEmails), + parseRecipients(input.supervisionRecipients) + ); + const resolvedSupervisionServiceLabel = + input.supervisionServiceLabel?.trim() + || selectedSupervisionService?.label + || undefined; + + if (resolvedRecipients.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Sélectionne un service ou ajoute au moins une adresse email de récupération", + }); + } + + let updatedFormData = request.formData; + try { + const existingFormData = request.formData ? JSON.parse(request.formData) : {}; + existingFormData.notificationTrace = { + serviceId: selectedRecapService?.id, + serviceLabel: resolvedServiceLabel, + recipients: resolvedRecipients, + supervisionServiceId: selectedSupervisionService?.id, + supervisionServiceLabel: resolvedSupervisionServiceLabel, + supervisionRecipients: resolvedSupervisionRecipients, + processedBy: ctx.user.name || `Admin #${ctx.user.id}`, + processedAt: new Date().toISOString(), + channel: "smtp", + }; + updatedFormData = JSON.stringify(existingFormData); + await db.updateRequest(request.id, { formData: updatedFormData }); + } catch (error) { + console.error("Failed to persist logistics assignment trace:", error); + } + + const scheduledFollowup = await scheduleMaterialReturnFollowup({ + request: { + ...request, + formData: updatedFormData, + }, + serviceLabel: resolvedServiceLabel, + recipientEmails: resolvedRecipients, + supervisionServiceLabel: resolvedSupervisionServiceLabel, + supervisionRecipientEmails: resolvedSupervisionRecipients, + }, { + forceNewToken: Boolean(existingFollowup), + forceImmediateSendWindow: Boolean(existingFollowup), + reactivatedByUserId: existingFollowup ? ctx.user.id : null, + reactivatedAt: existingFollowup ? new Date() : null, + reactivationReason: existingFollowup ? "Assignation logistique mise à jour" : null, + }); + + if (!scheduledFollowup) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Impossible de planifier la restitution : la date de restitution est manquante sur la demande", + }); + } + + const association = await db.getAssociationById(request.associationId); + const mailResult = await sendMaterialReturnFollowupEmail({ + followup: scheduledFollowup, + request: { + ...request, + formData: updatedFormData, + }, + association, + baseUrl: buildAppBaseUrl(ctx.req), + tone: existingFollowup ? "reactivation" : "assignment", + }); + + if (mailResult.sent) { + await db.updateMaterialReturnFollowup(scheduledFollowup.id, { + status: "en_attente", + sentAt: new Date(), + lastReminderSentAt: new Date(), + }); + } + + await db.createRequestHistory({ + requestId: request.id, + action: "ajout_commentaire", + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: existingFollowup + ? `Assignation logistique mise à jour — ${resolvedServiceLabel || resolvedRecipients.join(", ")}` + : `Récupération assignée à ${resolvedServiceLabel || resolvedRecipients.join(", ")}`, + }); + + await logAdminAction(ctx.user.id, "assignation_logistique", "request", request.id, { + recapServiceId: selectedRecapService?.id, + recapRecipients: resolvedRecipients, + recapServiceLabel: resolvedServiceLabel, + supervisionServiceId: selectedSupervisionService?.id, + supervisionRecipients: resolvedSupervisionRecipients, + supervisionServiceLabel: resolvedSupervisionServiceLabel, + emailSent: mailResult.sent, + reason: mailResult.sent ? undefined : mailResult.reason, + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true as const, + emailSent: mailResult.sent, + reason: mailResult.sent ? null : mailResult.reason, + }; + }), + + reactivate: logisticsProcedure + .input( + z.object({ + requestId: z.number(), + recapServiceId: z.number().optional(), + recapServiceLabel: z.string().optional(), + recapRecipients: z.array(z.string()).optional(), + supervisionServiceId: z.number().optional(), + supervisionServiceLabel: z.string().optional(), + supervisionRecipients: z.array(z.string()).optional(), + reason: z.string().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.requestId); + if (!request || request.type !== "demande_materiel_evenementiel") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de matériel introuvable" }); + } + if (request.status !== "validee") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Seules les demandes validées peuvent être réactivées" }); + } + + const existingFollowup = await db.getMaterialReturnFollowupByRequestId(request.id); + if (!existingFollowup) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun suivi de restitution n'existe encore pour cette demande" }); + } + if (existingFollowup.status === "cloture") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette restitution est déjà clôturée" }); + } + if (existingFollowup.litigationStatus === "pending") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier est en litige et ne peut pas être réactivé tant que l'arbitrage n'est pas clôturé" }); + } + + const selectedRecapService = input.recapServiceId + ? await db.getOperationalRecapServiceById(input.recapServiceId) + : undefined; + const selectedSupervisionService = input.supervisionServiceId + ? await db.getOperationalRecapServiceById(input.supervisionServiceId) + : undefined; + const resolvedRecipients = mergeRecipients( + parseStoredRecipientEmails(existingFollowup.recipientEmails), + parseStoredRecipientEmails(selectedRecapService?.recipientEmails), + parseRecipients(input.recapRecipients) + ); + const resolvedServiceLabel = + input.recapServiceLabel?.trim() + || selectedRecapService?.label + || existingFollowup.serviceLabel + || undefined; + const resolvedSupervisionRecipients = mergeRecipients( + parseStoredRecipientEmails(existingFollowup.supervisionRecipientEmails), + parseStoredRecipientEmails(selectedSupervisionService?.recipientEmails), + parseRecipients(input.supervisionRecipients) + ); + const resolvedSupervisionServiceLabel = + input.supervisionServiceLabel?.trim() + || selectedSupervisionService?.label + || existingFollowup.supervisionServiceLabel + || undefined; + + if (resolvedRecipients.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Ajoute au moins une adresse email pour réactiver la restitution", + }); + } + + const reactivatedAt = new Date(); + const reactivationReason = input.reason?.trim() || "Suivi rouvert"; + + const scheduledFollowup = await scheduleMaterialReturnFollowup({ + request, + serviceLabel: resolvedServiceLabel, + recipientEmails: resolvedRecipients, + supervisionServiceLabel: resolvedSupervisionServiceLabel, + supervisionRecipientEmails: resolvedSupervisionRecipients, + }, { + forceNewToken: true, + forceImmediateSendWindow: true, + reactivatedByUserId: ctx.user.id, + reactivatedAt, + reactivationReason, + }); + + if (!scheduledFollowup) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Impossible de réactiver la restitution : la date de restitution est manquante sur la demande", + }); + } + + const association = await db.getAssociationById(request.associationId); + const mailResult = await sendMaterialReturnFollowupEmail({ + followup: scheduledFollowup, + request, + association, + baseUrl: buildAppBaseUrl(ctx.req), + tone: "reactivation", + }); + + if (mailResult.sent) { + await db.updateMaterialReturnFollowup(scheduledFollowup.id, { + status: "en_attente", + sentAt: reactivatedAt, + lastReminderSentAt: reactivatedAt, + }); + } + + await db.createRequestHistory({ + requestId: request.id, + action: "ajout_commentaire", + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: `${reactivationReason} — nouvelle échéance de dépôt : ${formatDateFr(scheduledFollowup.uploadTokenExpiresAt)}`, + }); + + await logAdminAction(ctx.user.id, "reactivation_restitution", "request", request.id, { + recapServiceId: selectedRecapService?.id, + recapRecipients: resolvedRecipients, + recapServiceLabel: resolvedServiceLabel, + supervisionServiceId: selectedSupervisionService?.id, + supervisionRecipients: resolvedSupervisionRecipients, + supervisionServiceLabel: resolvedSupervisionServiceLabel, + reason: reactivationReason, + emailSent: mailResult.sent, + emailFailureReason: mailResult.sent ? undefined : mailResult.reason, + uploadTokenExpiresAt: scheduledFollowup.uploadTokenExpiresAt, + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true as const, + emailSent: mailResult.sent, + uploadTokenExpiresAt: scheduledFollowup.uploadTokenExpiresAt, + uploadLink: buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), scheduledFollowup.uploadToken), + }; + }), + + getPublic: publicProcedure + .input(z.object({ token: z.string().min(1) })) + .query(async ({ input, ctx }) => { + const followup = await db.getMaterialReturnFollowupByToken(input.token); + if (!followup) { + throw new TRPCError({ code: "NOT_FOUND", message: "Lien de restitution introuvable" }); + } + + const request = await db.getRequestById(followup.requestId); + if (!request || request.type !== "demande_materiel_evenementiel") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande liée introuvable" }); + } + + const association = await db.getAssociationById(followup.associationId); + const payload = serializeMaterialReturnFollowup(followup); + const formData = (() => { + try { + return request.formData ? JSON.parse(request.formData) : {}; + } catch { + return {}; + } + })(); + const requestedItems = Object.entries(formData.materielsDemandes || {}) + .filter(([, checked]) => Boolean(checked)) + .map(([key]) => ({ + key, + label: + key === "tente3x3" + ? "Tente 3x3" + : key === "chapiteau5x5" + ? "Chapiteau 5x5" + : key === "podium" + ? "Podium" + : "Autres", + quantity: String(formData.quantitesDemandees?.[key] || ""), + extra: key === "autres" ? String(formData.autreMaterielPrecisions || "") : "", + })); + + return { + ...payload, + request: { + id: request.id, + titre: request.titre, + commune: formData.commune || association?.ville || "", + manifestationStart: formData.dateDebutManifestation || formData.dateManifestation || "", + manifestationEnd: formData.dateFinManifestation || formData.dateManifestation || "", + requestedItems, + }, + association: association + ? { + nomAssociation: association.nomAssociation, + telephone: association.telephone, + emailContact: association.emailContact, + } + : null, + currentUser: ctx.user + ? { + id: ctx.user.id, + name: ctx.user.name, + email: ctx.user.email, + role: ctx.user.role, + } + : null, + optionSets: { + agentRoles: [...agentRoleOptions], + borrowerRoles: [...borrowerRoleOptions], + discrepancyCategories: materialReturnDiscrepancyOptions, + }, + canUpload: + followup.status !== "cloture" && + new Date() <= new Date(followup.uploadTokenExpiresAt), + uploadDeadlineLabel: formatDateFr(followup.uploadTokenExpiresAt), + uploadLink: buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), input.token), + }; + }), + + complete: publicProcedure + .input( + z.object({ + token: z.string().min(1), + agentName: z.string().min(2), + agentEmail: z.string().email().optional().or(z.literal("")), + agentRole: z.enum(agentRoleOptions), + borrowerName: z.string().min(2), + borrowerRole: z.enum(borrowerRoleOptions), + compliance: z.enum(["conforme", "non_conforme"]), + discrepancyCategories: z.array( + z.enum([ + "materiel_manquant", + "accessoires_manquants", + "structure_deformee", + "toile_dechiree", + "choc_important", + "materiel_sale", + "materiel_humide", + ]) + ), + discrepancyDetails: z.string().optional(), + agentSignatureData: z.string().min(1), + borrowerSignatureData: z.string().min(1), + geoLatitude: z.string().optional(), + geoLongitude: z.string().optional(), + geoStatus: z.enum(["available", "unavailable"]).optional(), + geoFailureReason: z.string().optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const followup = await db.getMaterialReturnFollowupByToken(input.token); + if (!followup) { + throw new TRPCError({ code: "NOT_FOUND", message: "Lien de restitution introuvable" }); + } + if (followup.status === "cloture") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette restitution a déjà été validée" }); + } + if (new Date() > new Date(followup.uploadTokenExpiresAt)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le délai de validation est dépassé" }); + } + if ((input.compliance === "non_conforme" || input.discrepancyCategories.length > 0) + && !input.discrepancyDetails?.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Précise les réserves constatées pour une restitution non conforme", + }); + } + + const request = await db.getRequestById(followup.requestId); + if (!request || request.type !== "demande_materiel_evenementiel") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande liée introuvable" }); + } + const association = await db.getAssociationById(followup.associationId); + const requestFormData = (() => { + try { + return request.formData ? JSON.parse(request.formData) : {}; + } catch { + return {}; + } + })(); + const validatedAt = new Date(); + const issueFlag = input.compliance === "non_conforme"; + const blockedItems = issueFlag ? getRequestedMaterialQuantityMap(request) : emptyMaterialEventQuantityMap(); + const geoLatitude = input.geoLatitude?.trim() || null; + const geoLongitude = input.geoLongitude?.trim() || null; + const hasGeolocation = Boolean(geoLatitude && geoLongitude); + const geoStatus = hasGeolocation ? "available" : "unavailable"; + const geoFailureReason = hasGeolocation + ? null + : input.geoFailureReason?.trim() + || (input.geoStatus === "unavailable" ? "Signal faible, refus d'autorisation ou GPS indisponible" : null); + const locationReference = [ + requestFormData.commune, + requestFormData.direction, + requestFormData.service, + ] + .map((value: unknown) => String(value || "").trim()) + .filter(Boolean) + .join(" • ") || association?.ville || request.titre; + + const agentSignatureBuffer = decodeBase64DataUrl(input.agentSignatureData); + const borrowerSignatureBuffer = decodeBase64DataUrl(input.borrowerSignatureData); + const { key: agentSignatureKey, url: agentSignatureUrl } = await storagePut( + `materiel-restitution/${followup.requestId}/signature-agent-${Date.now()}.png`, + agentSignatureBuffer, + "image/png" + ); + const { key: borrowerSignatureKey, url: borrowerSignatureUrl } = await storagePut( + `materiel-restitution/${followup.requestId}/signature-emprunteur-${Date.now()}.png`, + borrowerSignatureBuffer, + "image/png" + ); + + const discrepancyLabelMap = new Map( + materialReturnDiscrepancyOptions.map((entry) => [entry.value, entry.label]) + ); + const discrepancyLabels = input.discrepancyCategories.map( + (value) => discrepancyLabelMap.get(value) || value + ); + + const pdf = await generateCompletedMaterialReturnStatementPdf({ + request, + association, + completion: { + compliance: input.compliance, + discrepancyLabels, + discrepancyDetails: input.discrepancyDetails?.trim() || null, + agentName: input.agentName.trim(), + agentRole: input.agentRole, + borrowerName: input.borrowerName.trim(), + borrowerRole: input.borrowerRole, + validatedAt, + geoLatitude, + geoLongitude, + geoStatus, + geoFailureReason, + locationReference, + agentSignatureBuffer, + borrowerSignatureBuffer, + }, + }); + const { key: finalPdfKey, url: finalPdfUrl } = await storagePut( + `materiel-restitution/${followup.requestId}/fiche-finalisee-${Date.now()}.pdf`, + pdf.buffer, + "application/pdf" + ); + + await db.updateMaterialReturnFollowup(followup.id, { + status: "cloture", + uploadedByName: input.agentName.trim(), + uploadedByEmail: input.agentEmail?.trim() || null, + agentUserId: ctx.user?.id || null, + agentRole: input.agentRole, + borrowerName: input.borrowerName.trim(), + borrowerRole: input.borrowerRole, + compliance: input.compliance, + discrepancyCategories: JSON.stringify(input.discrepancyCategories), + discrepancyDetails: input.discrepancyDetails?.trim() || null, + issueFlag, + litigationStatus: issueFlag ? "pending" : "none", + blockedItems: issueFlag ? serializeBlockedItems(blockedItems) : null, + estimatedDamageAmount: null, + estimatedDamageSource: null, + arbitrationDecision: null, + arbitrationAmount: null, + arbitrationNotes: null, + arbitratedByUserId: null, + arbitratedAt: null, + litigationLetterKey: null, + litigationLetterUrl: null, + litigationLetterName: null, + agentSignatureKey, + agentSignatureUrl, + borrowerSignatureKey, + borrowerSignatureUrl, + finalPdfKey, + finalPdfUrl, + finalPdfName: pdf.fileName, + signedFileKey: finalPdfKey, + signedFileUrl: finalPdfUrl, + signedFileName: pdf.fileName, + signedMimeType: "application/pdf", + geoLatitude, + geoLongitude, + geoStatus, + geoFailureReason, + uploadedAt: validatedAt, + validatedAt, + closedAt: validatedAt, + }); + + await db.createRequestHistory({ + requestId: request.id, + action: "ajout_commentaire", + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user?.id || 0, + commentaire: issueFlag + ? `Restitution finalisée avec réserves par ${input.agentName.trim()}` + : `Restitution finalisée sans réserve par ${input.agentName.trim()}`, + }); + + await db.createAdminNotification({ + type: "systeme", + titre: issueFlag + ? `Alerte dégradation / litige - ${request.titre}` + : `Matériel récupéré / dossier clôturé - ${request.titre}`, + message: issueFlag + ? `Des réserves ont été enregistrées sur la restitution du matériel pour "${request.titre}".` + : `La restitution du matériel a été finalisée pour "${request.titre}".`, + lien: `/dashboard/requests/${request.id}`, + }); + + if (await canSendOperationalEmails()) { + const completionEmail = generateMaterialReturnCompletionEmail({ + associationName: association?.nomAssociation || "Association", + requestTitle: request.titre, + restitutionDate: followup.restitutionDate, + serviceLabel: followup.serviceLabel, + issueFlag, + compliance: input.compliance, + discrepancyDetails: input.discrepancyDetails?.trim() || null, + }); + const attachment = { + filename: pdf.fileName, + content: pdf.buffer, + contentType: "application/pdf", + }; + + const internalRecipients = mergeRecipients( + parseStoredRecipientEmails(followup.supervisionRecipientEmails), + parseStoredRecipientEmails(followup.recipientEmails) + ); + const associationRecipient = association?.emailContact?.trim() + || (() => { + try { + const formData = request.formData ? JSON.parse(request.formData) : {}; + return String(formData.emailAssociation || "").trim(); + } catch { + return ""; + } + })(); + + try { + if (internalRecipients.length > 0) { + await sendOperationalEmail({ + to: internalRecipients, + subject: completionEmail.subject, + text: completionEmail.text, + html: completionEmail.html, + attachments: [attachment], + replyTo: input.agentEmail?.trim() || undefined, + fromName: input.agentName.trim() ? `${input.agentName.trim()} via Portail Associations` : "Portail Associations", + }); + } + + if (associationRecipient) { + await sendOperationalEmail({ + to: [associationRecipient], + subject: completionEmail.subject, + text: completionEmail.text, + html: completionEmail.html, + attachments: [attachment], + replyTo: input.agentEmail?.trim() || undefined, + fromName: "Portail Associations", + }); + } + } catch (error) { + console.error("[MaterialReturn] Completion email failed:", error); + } + } + + return { + success: true, + issueFlag, + finalPdfUrl, + }; + }), + + resolveLitigation: logisticsProcedure + .input( + z.object({ + requestId: z.number(), + blockedItems: z.record(z.string(), z.number().int().min(0)), + estimatedDamageAmount: z.number().int().min(0).nullable().optional(), + estimatedDamageSource: z.enum(["manual", "replacement_value"]).default("manual"), + arbitrationDecision: z.enum(["partial_retention", "full_retention", "dismissed"]), + arbitrationAmount: z.number().int().min(0), + arbitrationNotes: z.string().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.requestId); + if (!request || request.type !== "demande_materiel_evenementiel") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de matériel introuvable" }); + } + + const followup = await db.getMaterialReturnFollowupByRequestId(input.requestId); + if (!followup || !followup.issueFlag) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun litige actif n'est disponible sur ce dossier" }); + } + if (followup.litigationStatus !== "pending") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le litige a déjà été arbitré" }); + } + + const logisticsSettings = await getLogisticsSettings(); + const blockedItems = sanitizeMaterialEventQuantityMap(input.blockedItems); + const calculatedReplacementAmount = materialEventItems.reduce((sum, item) => { + const replacementValue = logisticsSettings.replacementValues[item.key]; + if (replacementValue === null) return sum; + return sum + ((blockedItems[item.key] || 0) * replacementValue); + }, 0); + + const estimatedDamageAmount = input.estimatedDamageSource === "replacement_value" + ? calculatedReplacementAmount + : Math.max(0, input.estimatedDamageAmount || 0); + + const association = await db.getAssociationById(request.associationId); + const serializedFollowup = serializeMaterialReturnFollowup(followup); + const litigationLetterPdf = await generateMaterialReturnLitigationLetterPdf({ + request, + association, + followup: { + restitutionDate: followup.restitutionDate, + discrepancyCategories: serializedFollowup?.discrepancyCategories || [], + discrepancyDetails: followup.discrepancyDetails, + }, + arbitration: { + blockedItems, + decision: input.arbitrationDecision, + amountCents: input.arbitrationAmount, + notes: input.arbitrationNotes?.trim() || null, + }, + }); + const { key: litigationLetterKey, url: litigationLetterUrl } = await storagePut( + `materiel-restitution/${followup.requestId}/courrier-litige-${Date.now()}.pdf`, + litigationLetterPdf.buffer, + "application/pdf" + ); + + const arbitratedAt = new Date(); + await db.updateMaterialReturnFollowup(followup.id, { + litigationStatus: "resolved", + blockedItems: serializeBlockedItems(blockedItems), + estimatedDamageAmount, + estimatedDamageSource: input.estimatedDamageSource, + arbitrationDecision: input.arbitrationDecision, + arbitrationAmount: input.arbitrationAmount, + arbitrationNotes: input.arbitrationNotes?.trim() || null, + arbitratedByUserId: ctx.user.id, + arbitratedAt, + litigationLetterKey, + litigationLetterUrl, + litigationLetterName: litigationLetterPdf.fileName, + }); + + await db.createRequestHistory({ + requestId: request.id, + action: "ajout_commentaire", + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: `Arbitrage litige finalisé (${input.arbitrationDecision}) par ${ctx.user.name || `Admin #${ctx.user.id}`}`, + }); + + await db.createAdminNotification({ + type: "systeme", + titre: `Arbitrage clôturé - ${request.titre}`, + message: `Le litige matériel de "${request.titre}" a été arbitré et archivé.`, + lien: `/dashboard/requests/${request.id}`, + }); + + await logAdminAction(ctx.user.id, "arbitrage", "request", request.id, { + decision: input.arbitrationDecision, + arbitrationAmount: input.arbitrationAmount, + estimatedDamageAmount, + blockedItems, + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true, + litigationLetterUrl, + litigationLetterName: litigationLetterPdf.fileName, + }; + }), + + uploadSigned: publicProcedure + .input( + z.object({ + token: z.string().min(1), + uploadedByName: z.string().min(2, "Le nom du déposant est requis"), + uploadedByEmail: z.string().email("Adresse email invalide").optional().or(z.literal("")), + fileData: z.string().min(1), + fileName: z.string().min(1), + mimeType: z.string().min(1), + }) + ) + .mutation(async ({ input }) => { + const followup = await db.getMaterialReturnFollowupByToken(input.token); + if (!followup) { + throw new TRPCError({ code: "NOT_FOUND", message: "Lien de restitution introuvable" }); + } + + if (followup.status === "cloture") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette restitution a déjà été clôturée" }); + } + + if (new Date() > new Date(followup.uploadTokenExpiresAt)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le délai de dépôt de la fiche est dépassé" }); + } + + const allowedMimeTypes = [ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + ]; + if (!allowedMimeTypes.includes(input.mimeType)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "La fiche signée doit être un PDF ou une photo", + }); + } + + const request = await db.getRequestById(followup.requestId); + if (!request) { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande liée introuvable" }); + } + + const fileBuffer = Buffer.from(input.fileData, "base64"); + const storageName = `materiel-restitution/${followup.requestId}/${Date.now()}-${input.fileName}`; + const { key, url } = await storagePut(storageName, fileBuffer, input.mimeType); + + await db.updateMaterialReturnFollowup(followup.id, { + status: "cloture", + signedFileKey: key, + signedFileUrl: url, + signedFileName: input.fileName, + signedMimeType: input.mimeType, + uploadedByName: input.uploadedByName.trim(), + uploadedByEmail: input.uploadedByEmail?.trim() || null, + uploadedAt: new Date(), + closedAt: new Date(), + }); + + await db.createAdminNotification({ + type: "systeme", + titre: `Restitution clôturée - ${request.titre}`, + message: `La fiche signée de restitution du matériel a été déposée pour la demande "${request.titre}".`, + lien: `/dashboard/requests/${request.id}`, + }); + + return { + success: true, + fileUrl: url, + }; + }), + + generatePdf: publicProcedure + .input(z.object({ token: z.string().min(1) })) + .mutation(async ({ input }) => { + const followup = await db.getMaterialReturnFollowupByToken(input.token); + if (!followup) { + throw new TRPCError({ code: "NOT_FOUND", message: "Lien de restitution introuvable" }); + } + + const request = await db.getRequestById(followup.requestId); + if (!request) { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande liée introuvable" }); + } + + const association = await db.getAssociationById(followup.associationId); + const pdf = await generateMaterialReturnStatementPdf({ + request, + association, + }); + const storageName = `materiel-restitution/${followup.requestId}/fiche-pre-remplie-${Date.now()}.pdf`; + const { url } = await storagePut(storageName, pdf.buffer, "application/pdf"); + + return { + url, + fileName: pdf.fileName, + }; + }), + }), + + // ============== DOCUMENT ROUTES ============== + document: router({ + getMyDocuments: protectedProcedure.query(async ({ ctx }) => { + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association) return []; + return db.getDocumentsByAssociationId(association.id); + }), + + upload: protectedProcedure + .input(z.object({ + nom: z.string().min(1), + type: z.enum(['statuts', 'recepisse_declaration', 'rib', 'rapport_activite', 'rapport_financier', 'pv_assemblee', 'liste_dirigeants', 'attestation_assurance', 'autre']), + description: z.string().optional(), + fileData: z.string(), + fileName: z.string(), + mimeType: z.string(), + })) + .mutation(async ({ ctx, input }) => { + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Veuillez d\'abord créer votre profil association' }); + } + + const fileBuffer = Buffer.from(input.fileData, 'base64'); + const fileKey = `associations/${association.id}/documents/${nanoid()}-${input.fileName}`; + + const { url } = await storagePut(fileKey, fileBuffer, input.mimeType); + + const docId = await db.createDocument({ + associationId: association.id, + nom: input.nom, + type: input.type, + description: input.description, + fileKey, + fileUrl: url, + mimeType: input.mimeType, + fileSize: fileBuffer.length, + }); + + return { id: docId, url }; + }), + + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Association non trouvée' }); + } + + const doc = await db.getDocumentById(input.id); + if (!doc || doc.associationId !== association.id) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Document non trouvé' }); + } + + await db.deleteDocument(input.id); + return { success: true }; + }), + + getByAssociationId: adminProcedure + .input(z.object({ associationId: z.number() })) + .query(async ({ input }) => { + return db.getDocumentsByAssociationId(input.associationId); + }), + + getDownloadUrl: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ ctx, input }) => { + const association = await db.getAssociationByUserId(ctx.user.id); + const doc = await db.getDocumentById(input.id); + + if (!doc) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Document non trouvé' }); + } + + if (!canReadInternalRequest(ctx.user) && (!association || doc.associationId !== association.id)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); + } + + const { url } = await storageGet(doc.fileKey); + return { url }; + }), + }), + + salleSignature: router({ + getDelegation: directriceProcedure.query(async ({ ctx }) => { + const [candidates, delegatedUsers, legacyDelegatedUser] = await Promise.all([ + getSalleSignatureDelegateCandidates(ctx.user.id), + getSalleSignatureDelegates(ctx.user.id), + ctx.user.delegatedSalleSignerUserId + ? db.getUserById(ctx.user.delegatedSalleSignerUserId) + : Promise.resolve(undefined), + ]); + + const normalizedDelegatedUsers = legacyDelegatedUser && !delegatedUsers.some((entry) => entry.id === legacyDelegatedUser.id) + ? delegatedUsers.concat({ + id: legacyDelegatedUser.id, + name: legacyDelegatedUser.name || "Sans nom", + email: legacyDelegatedUser.email || "", + role: legacyDelegatedUser.role, + }) + : delegatedUsers; + + return { + delegatedUserId: normalizedDelegatedUsers[0]?.id ?? ctx.user.delegatedSalleSignerUserId ?? null, + delegatedUserName: normalizedDelegatedUsers[0]?.name || legacyDelegatedUser?.name || null, + delegatedUserEmail: normalizedDelegatedUsers[0]?.email || legacyDelegatedUser?.email || null, + delegatedUsers: normalizedDelegatedUsers, + candidates, + }; + }), + + setDelegation: directriceProcedure + .input(z.object({ + delegateUserIds: z.array(z.number()).max(20), + })) + .mutation(async ({ ctx, input }) => { + if (ctx.user.role !== "directrice") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Seule la Directrice peut choisir son délégataire de signature", + }); + } + + const candidates = await getSalleSignatureDelegateCandidates(ctx.user.id); + const candidateIds = new Set(candidates.map((entry) => entry.id)); + const invalidUserId = input.delegateUserIds.find((userId) => !candidateIds.has(userId)); + if (invalidUserId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "La personne choisie ne peut pas recevoir la délégation de signature", + }); + } + + await db.updateUser(ctx.user.id, { + delegatedSalleSignerUserId: input.delegateUserIds[0] ?? null, + }); + await setDelegatedSignerIdsForDirectrice(ctx.user.id, input.delegateUserIds); + + await logAdminAction(ctx.user.id, "delegation_signature_salle", "user", ctx.user.id, { + delegatedSalleSignerUserIds: input.delegateUserIds, + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true, + delegatedUsers: candidates.filter((entry) => input.delegateUserIds.includes(entry.id)), + }; + }), + + addDelegateByEmail: directriceProcedure + .input(z.object({ + email: z.string().trim().email("Adresse email invalide"), + name: z.string().trim().min(2).optional(), + })) + .mutation(async ({ ctx, input }) => { + if (ctx.user.role !== "directrice") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Seule la Directrice peut ajouter un signataire remplaçant", + }); + } + + const normalizedEmail = input.email.trim().toLowerCase(); + let user = await db.getUserByEmail(normalizedEmail); + + if (!user) { + const generatedPassword = randomBytes(24).toString("base64url"); + const fallbackName = normalizedEmail.split("@")[0]?.replace(/[._-]+/g, " ").trim() || "Signataire"; + user = await registerLocalUser({ + name: input.name?.trim() || fallbackName, + email: normalizedEmail, + password: generatedPassword, + }); + await db.updateUser(user.id, { + name: input.name?.trim() || user.name || fallbackName, + email: normalizedEmail, + role: "user", + canManageLogistics: false, + isActive: true, + loginMethod: "local_jwt", + }); + } else { + await db.updateUser(user.id, { + name: input.name?.trim() || user.name || normalizedEmail, + email: normalizedEmail, + isActive: true, + }); + } + + const candidates = await getSalleSignatureDelegateCandidates(ctx.user.id); + const candidate = candidates.find((entry) => entry.id === user.id); + if (!candidate) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cette adresse ne peut pas être utilisée pour la délégation de signature", + }); + } + + const delegateUserIds = await addDelegatedSignerForDirectrice(ctx.user.id, user.id); + await db.updateUser(ctx.user.id, { + delegatedSalleSignerUserId: delegateUserIds[0] ?? null, + }); + + await logAdminAction(ctx.user.id, "ajout_delegataire_signature_salle", "user", user.id, { + email: normalizedEmail, + delegatedSalleSignerUserIds: delegateUserIds, + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true, + addedUser: candidate, + }; + }), + + removeDelegate: directriceProcedure + .input(z.object({ + userId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + if (ctx.user.role !== "directrice") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Seule la Directrice peut retirer un signataire remplaçant", + }); + } + + const currentDelegateIds = await getDelegatedSignerIdsForDirectrice(ctx.user.id); + if (!currentDelegateIds.includes(input.userId)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cette personne n'est pas dans la délégation active", + }); + } + + const nextDelegateIds = await removeDelegatedSignerForDirectrice(ctx.user.id, input.userId); + await db.updateUser(ctx.user.id, { + delegatedSalleSignerUserId: nextDelegateIds[0] ?? null, + }); + + await logAdminAction(ctx.user.id, "suppression_delegataire_signature_salle", "user", input.userId, { + delegatedSalleSignerUserIds: nextDelegateIds, + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true, + delegateUserIds: nextDelegateIds, + }; + }), + }), + + // ============== REQUEST ROUTES ============== + request: router({ + getMyRequests: protectedProcedure.query(async ({ ctx }) => { + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association) return []; + return db.getRequestsByAssociationId(association.id); + }), + + getById: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + if (!canReadInternalRequest(ctx.user)) { + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association || request.associationId !== association.id) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); + } + } + + // Get history for admins + let history: any[] = []; + if (canReadInternalRequest(ctx.user)) { + history = await db.getRequestHistoryByRequestId(input.id); + } + + // Get association info + const association = await db.getAssociationById(request.associationId); + + // Get attached documents + let attachedDocuments: any[] = []; + if (request.documentsJoints) { + try { + const docIds = JSON.parse(request.documentsJoints) as number[]; + if (Array.isArray(docIds) && docIds.length > 0) { + const docs = await Promise.all( + docIds.map(async (docId) => { + const doc = await db.getDocumentById(docId); + return doc || null; + }) + ); + attachedDocuments = docs.filter(Boolean); + } + } catch { + // ignore parse errors + } + } + + return { ...request, history, associationInfo: association || null, attachedDocuments }; + }), + + generatePdf: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + // Vérifier l'accès : admin ou propriétaire + if (!canReadInternalRequest(ctx.user)) { + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association || request.associationId !== association.id) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); + } + } + + const pdfDocument = await generateRequestPdfDocument(request); + const { url } = await storagePut(pdfDocument.storageName, pdfDocument.buffer, pdfDocument.contentType); + return { url, fileName: pdfDocument.fileName }; + }), + + generateMaterialConvention: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== 'demande_materiel_evenementiel') { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande de matériel introuvable' }); + } + if (request.status !== 'validee') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'La convention ne peut être générée que pour une demande validée' }); + } + + const financialDecision = parseMaterialFinancialDecision(request.formData); + if (!financialDecision) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Les conditions financières ne sont pas encore renseignées' }); + } + + const association = await db.getAssociationById(request.associationId); + const generatedContract = await generateAndStoreMaterialConventionForRequest({ + request, + association, + decision: { + financialMode: financialDecision.financialMode, + depositRequired: financialDecision.depositRequired, + depositAmountCents: financialDecision.depositAmountCents, + rentalAmountCents: financialDecision.rentalAmountCents, + pricingNotes: financialDecision.pricingNotes, + }, + validatedByUserId: ctx.user.id, + }); + + const nextFormData = mergeMaterialFinancialDecision({ + rawFormData: request.formData, + decision: { + financialMode: financialDecision.financialMode, + depositRequired: financialDecision.depositRequired, + depositAmountCents: financialDecision.depositAmountCents, + rentalAmountCents: financialDecision.rentalAmountCents, + pricingNotes: financialDecision.pricingNotes, + contractStatus: 'generee', + contractPdfUrl: generatedContract.contractPdfUrl, + contractPdfName: generatedContract.contractPdfName, + contractGeneratedAt: generatedContract.contractGeneratedAt, + contractValidatedByUserId: ctx.user.id, + }, + }); + + await db.updateRequest(input.id, { + formData: nextFormData, + }); + + await db.createRequestHistory({ + requestId: input.id, + action: 'modification', + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: 'Convention de mise à disposition / location générée', + }); + + await logAdminAction(ctx.user.id, 'generation_convention', 'request', input.id, { + financialMode: financialDecision.financialMode, + depositAmountCents: financialDecision.depositAmountCents, + rentalAmountCents: financialDecision.rentalAmountCents, + contractPdfName: generatedContract.contractPdfName, + ipAddress: extractClientIp(ctx.req), + }); + + return { + url: generatedContract.contractPdfUrl, + fileName: generatedContract.contractPdfName, + }; + }), + + generateSalleConvention: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== 'demande_salle') { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande de salle introuvable' }); + } + if (request.status !== 'validee') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'La convention ne peut être générée que pour une demande validée' }); + } + + const financialDecision = parseMaterialFinancialDecision(request.formData); + if (!financialDecision) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Les conditions financières ne sont pas encore renseignées' }); + } + + const association = await db.getAssociationById(request.associationId); + const generatedContract = await generateAndStoreSalleConventionForRequest({ + request, + association, + decision: { + financialMode: financialDecision.financialMode, + depositRequired: financialDecision.depositRequired, + depositAmountCents: financialDecision.depositAmountCents, + rentalAmountCents: financialDecision.rentalAmountCents, + pricingNotes: financialDecision.pricingNotes, + }, + validatedByUserId: ctx.user.id, + }); + + const nextFormData = mergeMaterialFinancialDecision({ + rawFormData: request.formData, + decision: { + financialMode: financialDecision.financialMode, + depositRequired: financialDecision.depositRequired, + depositAmountCents: financialDecision.depositAmountCents, + rentalAmountCents: financialDecision.rentalAmountCents, + pricingNotes: financialDecision.pricingNotes, + contractStatus: 'generee', + contractPdfUrl: generatedContract.contractPdfUrl, + contractPdfName: generatedContract.contractPdfName, + contractGeneratedAt: generatedContract.contractGeneratedAt, + contractValidatedByUserId: ctx.user.id, + }, + }); + + await db.updateRequest(input.id, { + formData: nextFormData, + }); + + await db.createRequestHistory({ + requestId: input.id, + action: 'modification', + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: 'Convention de mise à disposition / location de salle générée', + }); + + await logAdminAction(ctx.user.id, 'generation_convention_salle', 'request', input.id, { + financialMode: financialDecision.financialMode, + depositAmountCents: financialDecision.depositAmountCents, + rentalAmountCents: financialDecision.rentalAmountCents, + contractPdfName: generatedContract.contractPdfName, + ipAddress: extractClientIp(ctx.req), + }); + + return { + url: generatedContract.contractPdfUrl, + fileName: generatedContract.contractPdfName, + }; + }), + + sendSalleQuote: accueilAdminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== "demande_salle") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" }); + } + if (request.status === "validee" || request.status === "refusee" || request.status === "annulee") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette demande n'est plus éligible à l'envoi du devis" }); + } + + const association = await db.getAssociationById(request.associationId); + const quoteWorkflow = await sendSalleQuoteWorkflow({ + request, + association, + req: ctx.req, + user: ctx.user, + }); + + await db.updateRequest(request.id, { + status: request.status === "soumise" ? "en_cours_traitement" : request.status, + formData: quoteWorkflow.updatedFormData, + traitePar: ctx.user.id, + }); + + await db.createRequestHistory({ + requestId: request.id, + action: "modification", + ancienStatut: request.status, + nouveauStatut: request.status === "soumise" ? "en_cours_traitement" : request.status, + userId: ctx.user.id, + commentaire: "Devis et décision administrative prioritaire envoyés à l'association", + }); + + await logAdminAction(ctx.user.id, "envoi_devis_salle", "request", request.id, { + pricingTotalCents: quoteWorkflow.pricing.totalAmountCents, + financialMode: quoteWorkflow.financialMode, + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true, + quotePdfUrl: quoteWorkflow.quotePdfUrl, + decisionPdfUrl: quoteWorkflow.decisionPdfUrl, + quoteStatusLabel: SALLE_WORKFLOW_QUOTE_STATUS_LABELS.en_attente_association, + }; + }), + + respondSalleQuote: protectedProcedure + .input(z.object({ + id: z.number(), + decision: z.enum(["accepte", "refuse"]), + })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== "demande_salle") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" }); + } + + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association || request.associationId !== association.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "Accès non autorisé" }); + } + + if (request.status === "validee" || request.status === "refusee" || request.status === "annulee") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cette demande ne peut plus recevoir de réponse" }); + } + + const workflow = getSalleWorkflowData(request.formData); + if (workflow.quoteStatus !== "en_attente_association") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "La réponse de l'association a déjà été enregistrée pour ce dossier", + }); + } + + const isAccepted = input.decision === "accepte"; + const nextStatus = isAccepted ? request.status : "refusee"; + const nextFormData = updateSalleWorkflowQuoteStatus(request.formData, input.decision); + + await db.updateRequest(request.id, { + formData: nextFormData, + status: nextStatus, + dateTraitement: isAccepted ? request.dateTraitement : new Date(), + commentaireAdmin: isAccepted + ? request.commentaireAdmin + : ((request.commentaireAdmin ? `${request.commentaireAdmin}\n\n` : "") + "Devis refusé par l'association depuis le portail"), + }); + + await db.createRequestHistory({ + requestId: request.id, + action: isAccepted ? "modification" : "refus", + ancienStatut: request.status, + nouveauStatut: nextStatus, + userId: ctx.user.id, + commentaire: isAccepted + ? "Devis accepté par l'association depuis le portail" + : "Devis refusé par l'association depuis le portail", + }); + + 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}" depuis son espace.` + : `L'association a refusé le devis pour la demande "${request.titre}" depuis son espace.`, + lien: `/dashboard/requests/${request.id}`, + }); + + return { + success: true, + quoteStatus: input.decision, + quoteStatusLabel: isAccepted + ? SALLE_WORKFLOW_QUOTE_STATUS_LABELS.accepte + : SALLE_WORKFLOW_QUOTE_STATUS_LABELS.refuse, + nextStatus, + }; + }), + + sendSalleToDirector: accueilAdminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== "demande_salle") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" }); + } + + const workflow = getSalleWorkflowData(request.formData); + if (workflow.quoteStatus !== "accepte") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier doit d'abord être accepté par l'association" }); + } + + const nextFormData = updateSalleWorkflowDirectorStatus(request.formData, "en_attente_signature"); + await db.updateRequest(request.id, { formData: nextFormData, traitePar: ctx.user.id }); + await db.createRequestHistory({ + requestId: request.id, + action: "modification", + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: "Dossier transmis à la Directrice pour signature", + }); + await db.createAdminNotification({ + type: "systeme", + titre: "Une demande est en attente de signature.", + message: `Le dossier "${request.titre}" a été transmis à la Direction et attend maintenant une signature.`, + lien: `/dashboard/requests/${request.id}`, + }); + await logAdminAction(ctx.user.id, "transmission_directrice_salle", "request", request.id, { + directorStatus: "en_attente_signature", + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true, + directorStatusLabel: SALLE_WORKFLOW_DIRECTOR_STATUS_LABELS.en_attente_signature, + }; + }), + + returnSalleToHostess: salleSignerProcedure + .input(z.object({ + id: z.number(), + comment: z.string().trim().max(1000).optional(), + })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== "demande_salle") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" }); + } + + const workflow = getSalleWorkflowData(request.formData); + if (workflow.directorStatus !== "en_attente_signature") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Ce dossier n'est pas en attente de signature", + }); + } + + const nextFormData = mergeSalleWorkflowData( + updateSalleWorkflowDirectorStatus(request.formData, "a_transmettre"), + { + directorReturnComment: input.comment?.trim() || "", + directorReturnedAt: new Date().toISOString(), + } + ); + await db.updateRequest(request.id, { + formData: nextFormData, + }); + + const comment = input.comment?.trim(); + await db.createRequestHistory({ + requestId: request.id, + action: "modification", + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: comment + ? `Dossier renvoyé à l’hôtesse par la Directrice : ${comment}` + : "Dossier renvoyé à l’hôtesse par la Directrice", + }); + + await db.createAdminNotification({ + type: "systeme", + titre: `Retour Directrice : ${request.titre}`, + message: comment + ? `La Directrice a renvoyé le dossier "${request.titre}" à l’hôtesse avec la note suivante : ${comment}` + : `La Directrice a renvoyé le dossier "${request.titre}" à l’hôtesse pour reprise.`, + lien: `/dashboard/requests/${request.id}`, + }); + + await logAdminAction(ctx.user.id, "retour_hotesse_salle", "request", request.id, { + comment: comment || null, + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true, + directorStatusLabel: SALLE_WORKFLOW_DIRECTOR_STATUS_LABELS.a_transmettre, + }; + }), + + finalizeSalleReservation: salleSignerProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== "demande_salle") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" }); + } + + const workflow = getSalleWorkflowData(request.formData); + if (workflow.quoteStatus !== "accepte") { + throw new TRPCError({ code: "BAD_REQUEST", message: "La demande doit être acceptée par l'association avant finalisation" }); + } + if (workflow.directorStatus !== "en_attente_signature") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier doit d'abord être transmis à la Directrice" }); + } + + const association = await db.getAssociationById(request.associationId); + const pricing = workflow.pricing || computeSalleWorkflowPricing(request.formData); + const financialDecision = parseMaterialFinancialDecision(request.formData); + if (!pricing || !financialDecision) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier ne contient pas les données financières nécessaires à la finalisation" }); + } + + const signaturePackage = await generateSignedSalleWorkflowPackage({ + request, + association, + pricing, + financialDecision, + signedByUserId: ctx.user.id, + signedByName: ctx.user.name || `Directrice #${ctx.user.id}`, + }); + await db.updateRequest(request.id, { + formData: signaturePackage.nextFormData, + }); + + await db.createRequestHistory({ + requestId: request.id, + action: "validation", + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: "Dossier signé par la Direction et renvoyé à l’hôtesse pour confirmation finale", + }); + + await db.createAdminNotification({ + type: "systeme", + titre: `Dossier signé : ${request.titre}`, + message: `La Direction a signé le dossier "${request.titre}". L’hôtesse retrouve maintenant le devis signé, la décision signée, le formulaire signé et la facture générée pour préparer l’envoi final à l’association.`, + lien: `/dashboard/requests/${request.id}`, + }); + + await logAdminAction(ctx.user.id, "signature_direction_salle", "request", request.id, { + directorStatus: "signee", + pricingTotalCents: pricing.totalAmountCents, + financialMode: financialDecision.financialMode, + ipAddress: extractClientIp(ctx.req), + }); + + return { success: true }; + }), + + prepareSalleFinalNotification: accueilAdminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== "demande_salle") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" }); + } + + const workflow = getSalleWorkflowData(request.formData); + if (workflow.quoteStatus !== "accepte" || workflow.directorStatus !== "signee") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Le dossier doit d'abord être signé par la Direction avant l'envoi final", + }); + } + if (workflow.finalNotificationSentAt) { + throw new TRPCError({ code: "BAD_REQUEST", message: "La confirmation finale a déjà été envoyée" }); + } + + const association = await db.getAssociationById(request.associationId); + const pricing = workflow.pricing || computeSalleWorkflowPricing(request.formData); + const financialDecision = parseMaterialFinancialDecision(request.formData); + const salleBilling = await getSalleBillingSettings(); + if (!pricing || !financialDecision) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Les données financières de ce dossier sont incomplètes" }); + } + + const invoicePreview = await ensureSalleInvoicePrepared({ + request, + association, + pricing, + billed: financialDecision.financialMode === "location_payante", + }); + if (invoicePreview.nextFormData !== request.formData) { + await db.updateRequest(request.id, { + formData: invoicePreview.nextFormData, + }); + } + + const effectiveFormData = parseRequestFormData(invoicePreview.nextFormData); + const finalEmail = generateSalleFinalEmail({ + associationName: association?.nomAssociation || effectiveFormData.nomAssociation || "association", + datesLabel: buildSalleDatesLabel(effectiveFormData), + pricingTotalCents: pricing.totalAmountCents, + billed: financialDecision.financialMode === "location_payante", + }); + + return { + success: true, + associationName: association?.nomAssociation || effectiveFormData.nomAssociation || "Association", + datesLabel: buildSalleDatesLabel(effectiveFormData), + billed: financialDecision.financialMode === "location_payante", + pricingTotalCents: pricing.totalAmountCents, + quotePdfUrl: workflow.signedQuotePdfUrl || workflow.quotePdfUrl || "", + quotePdfName: workflow.signedQuotePdfName || workflow.quotePdfName || "", + decisionPdfUrl: workflow.signedDecisionPdfUrl || workflow.administrativeDecisionPdfUrl || "", + decisionPdfName: workflow.signedDecisionPdfName || workflow.administrativeDecisionPdfName || "", + requestPdfUrl: workflow.signedRequestPdfUrl || "", + requestPdfName: workflow.signedRequestPdfName || "", + invoicePdfUrl: invoicePreview.invoicePdfUrl, + invoicePdfName: invoicePreview.invoicePdfName, + invoiceGeneratedAt: invoicePreview.invoiceGeneratedAt, + ribDocumentUrl: salleBilling.ribDocumentUrl, + ribDocumentName: salleBilling.ribDocumentName, + mailPreview: finalEmail.text, + }; + }), + + sendSalleFinalNotification: accueilAdminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== "demande_salle") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" }); + } + + const workflow = getSalleWorkflowData(request.formData); + if (workflow.quoteStatus !== "accepte" || workflow.directorStatus !== "signee") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Le dossier doit être signé par la Direction avant l'envoi final", + }); + } + if (workflow.finalNotificationSentAt) { + throw new TRPCError({ code: "BAD_REQUEST", message: "La confirmation finale a déjà été envoyée" }); + } + + const association = await db.getAssociationById(request.associationId); + const associationRecipient = association?.emailContact?.trim() || parseRequestFormData(request.formData).emailAssociation?.trim(); + if (!associationRecipient) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun email d'association n'est disponible pour la notification finale" }); + } + + const pricing = workflow.pricing || computeSalleWorkflowPricing(request.formData); + const financialDecision = parseMaterialFinancialDecision(request.formData); + const salleBilling = await getSalleBillingSettings(); + if (!pricing || !financialDecision) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier ne contient pas les données financières nécessaires à l'envoi final" }); + } + + let effectiveFormDataRaw = request.formData || ""; + const invoicePreview = await ensureSalleInvoicePrepared({ + request, + association, + pricing, + billed: financialDecision.financialMode === "location_payante", + }); + effectiveFormDataRaw = invoicePreview.nextFormData; + + const signedWorkflow = getSalleWorkflowData(effectiveFormDataRaw); + const finalEmail = generateSalleFinalEmail({ + associationName: association?.nomAssociation || parseRequestFormData(effectiveFormDataRaw).nomAssociation || "association", + datesLabel: buildSalleDatesLabel(parseRequestFormData(effectiveFormDataRaw)), + pricingTotalCents: pricing.totalAmountCents, + billed: financialDecision.financialMode === "location_payante", + }); + + const attachments: Array<{ filename: string; content: Buffer; contentType?: string }> = []; + + const pushStoredPdf = async (url: string | undefined, filename: string | undefined) => { + if (!url || !filename) return; + const stored = await readStoredUploadBufferFromUrl(url); + if (!stored) return; + attachments.push({ + filename, + content: stored, + contentType: "application/pdf", + }); + }; + + if (financialDecision.financialMode === "location_payante") { + await pushStoredPdf(signedWorkflow.signedQuotePdfUrl || workflow.signedQuotePdfUrl, signedWorkflow.signedQuotePdfName || workflow.signedQuotePdfName); + } + await pushStoredPdf(signedWorkflow.signedDecisionPdfUrl || workflow.signedDecisionPdfUrl, signedWorkflow.signedDecisionPdfName || workflow.signedDecisionPdfName); + await pushStoredPdf(signedWorkflow.signedRequestPdfUrl || workflow.signedRequestPdfUrl, signedWorkflow.signedRequestPdfName || workflow.signedRequestPdfName); + + if (invoicePreview.buffer) { + attachments.push({ + filename: invoicePreview.invoicePdfName, + content: invoicePreview.buffer, + contentType: "application/pdf", + }); + } else { + await pushStoredPdf(invoicePreview.invoicePdfUrl, invoicePreview.invoicePdfName); + } + if (financialDecision.financialMode === "location_payante" && salleBilling.ribDocumentUrl && salleBilling.ribDocumentName) { + const ribBuffer = await readStoredUploadBufferFromUrl(salleBilling.ribDocumentUrl); + if (ribBuffer) { + attachments.push({ + filename: salleBilling.ribDocumentName, + content: ribBuffer, + contentType: salleBilling.ribMimeType || "application/pdf", + }); + } + } + + const result = await sendOperationalEmail({ + to: [associationRecipient], + subject: finalEmail.subject, + text: finalEmail.text, + html: finalEmail.html, + replyTo: ctx.user.email || undefined, + fromName: "Maison de la Jeunesse des Savanes - CCDS", + attachments, + }); + + if (!result.sent) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Le mail final n'a pas pu être envoyé (${result.reason || "raison inconnue"})` }); + } + + const nextFormData = mergeSalleWorkflowData(effectiveFormDataRaw, { + finalNotificationSentAt: new Date().toISOString(), + }); + + await db.updateRequest(request.id, { + status: "validee", + dateTraitement: new Date(), + traitePar: ctx.user.id, + formData: nextFormData, + }); + + await db.createRequestHistory({ + requestId: request.id, + action: "validation", + ancienStatut: request.status, + nouveauStatut: "validee", + userId: ctx.user.id, + commentaire: "Confirmation finale envoyée à l'association avec les documents signés et la facture", + }); + + await logAdminAction(ctx.user.id, "envoi_final_salle", "request", request.id, { + directorStatus: "signee", + pricingTotalCents: pricing.totalAmountCents, + financialMode: financialDecision.financialMode, + ipAddress: extractClientIp(ctx.req), + }); + + return { success: true }; + }), + + updateSallePaymentTracking: accueilAdminProcedure + .input(z.object({ + id: z.number(), + paymentStatus: z.enum(["en_attente_paiement", "paiement_partiel", "paiement_recu", "paiement_valide"]), + amountReceivedCents: z.number().min(0).optional(), + paymentReceivedAt: z.string().optional(), + paymentReference: z.string().trim().max(255).optional(), + paymentNotes: z.string().trim().max(2000).optional(), + })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request || request.type !== "demande_salle") { + throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" }); + } + + const financialDecision = parseMaterialFinancialDecision(request.formData); + if (!financialDecision || financialDecision.financialMode !== "location_payante") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Le suivi de paiement est réservé aux locations de salle payantes", + }); + } + + const existingPayment = getSallePaymentTracking(request.formData); + const nextPaymentReceivedAt = + input.paymentStatus === "paiement_recu" || input.paymentStatus === "paiement_valide" + ? (input.paymentReceivedAt?.trim() + || existingPayment.paymentReceivedAt + || new Date().toISOString()) + : ""; + const nextPaymentValidatedAt = + input.paymentStatus === "paiement_valide" + ? (existingPayment.paymentValidatedAt || new Date().toISOString()) + : ""; + + const nextFormData = mergeSalleWorkflowData( + ensureSallePaymentTracking({ + rawFormData: request.formData, + billed: true, + totalAmountCents: Number.isFinite(Number(getSalleWorkflowData(request.formData).pricing?.totalAmountCents)) + ? Number(getSalleWorkflowData(request.formData).pricing?.totalAmountCents) + : financialDecision.rentalAmountCents, + }), + { + paymentStatus: input.paymentStatus, + amountReceivedCents: input.amountReceivedCents ?? existingPayment.amountReceivedCents, + paymentReceivedAt: nextPaymentReceivedAt, + paymentValidatedAt: nextPaymentValidatedAt, + paymentReference: input.paymentReference?.trim() || "", + paymentNotes: input.paymentNotes?.trim() || "", + } + ); + + await db.updateRequest(request.id, { + formData: nextFormData, + traitePar: ctx.user.id, + }); + + await db.createRequestHistory({ + requestId: request.id, + action: "modification", + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: `Suivi de paiement mis à jour : ${SALLE_WORKFLOW_PAYMENT_STATUS_LABELS[input.paymentStatus]}`, + }); + + await logAdminAction(ctx.user.id, "mise_a_jour_paiement_salle", "request", request.id, { + paymentStatus: input.paymentStatus, + amountReceivedCents: input.amountReceivedCents ?? existingPayment.amountReceivedCents, + ipAddress: extractClientIp(ctx.req), + }); + + return { + success: true, + paymentStatusLabel: SALLE_WORKFLOW_PAYMENT_STATUS_LABELS[input.paymentStatus], + }; + }), + + create: protectedProcedure + .input(z.object({ + type: z.enum(['subvention_fonctionnement', 'subvention_projet', 'agrement_jeunesse_education', 'agrement_sport', 'autorisation_occupation', 'demande_salle', 'demande_materiel_evenementiel', 'autre']), + titre: z.string().min(1), + description: z.string().optional(), + formData: z.string().optional(), + montantDemande: z.number().optional(), + documentsJoints: z.array(z.number()).optional(), + submitImmediately: z.boolean().optional(), // true = créer ET soumettre en une seule opération + privacyConsent: dataPrivacyConsentSchema, + })) + .mutation(async ({ ctx, input }) => { + assertDataPrivacyConsent(input.privacyConsent); + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Veuillez d\'abord créer votre profil association' }); + } + await recordDataPrivacyConsent(ctx.user.id, ctx.req, input.privacyConsent); + + // Enrichir le formData avec les infos association actuelles pour garantir la persistance + let enrichedFormData = input.formData; + if (input.formData) { + try { + const fd = JSON.parse(input.formData); + // S'assurer que les infos association sont toujours présentes depuis la source de vérité + fd.nomAssociation = fd.nomAssociation || association.nomAssociation || ''; + fd.adresseAssociation = fd.adresseAssociation || (association.adresse ? `${association.adresse}, ${association.codePostal || ''} ${association.ville || ''}`.trim() : ''); + fd.communeSiege = fd.communeSiege || association.ville || ''; + fd.representantLegal = fd.representantLegal || association.nomRepresentant || ''; + fd.telephoneAssociation = fd.telephoneAssociation || association.telephone || ''; + fd.emailAssociation = fd.emailAssociation || association.emailContact || ''; + enrichedFormData = JSON.stringify(fd); + } catch (e) { + // keep original if parse fails + } + } + + if (input.type === 'demande_salle') { + validateReservationSalleFormData(enrichedFormData); + } + + // Calculate deadline (30 days by default) + const dateLimite = new Date(); + dateLimite.setDate(dateLimite.getDate() + 30); + + const requestId = await db.createRequest({ + associationId: association.id, + type: input.type, + titre: input.titre, + description: input.description, + formData: enrichedFormData, + montantDemande: input.montantDemande, + documentsJoints: input.documentsJoints ? JSON.stringify(input.documentsJoints) : null, + status: 'brouillon', + dateLimiteTraitement: dateLimite, + }); + + // Log creation + await db.createRequestHistory({ + requestId, + action: 'creation', + userId: ctx.user.id, + commentaire: 'Création de la demande', + }); + + // Si submitImmediately, soumettre directement + if (input.submitImmediately) { + await db.updateRequest(requestId, { + status: 'soumise', + dateSubmission: new Date(), + }); + + await db.createRequestHistory({ + requestId, + action: 'soumission', + ancienStatut: 'brouillon', + nouveauStatut: 'soumise', + userId: ctx.user.id, + commentaire: 'Soumission immédiate de la demande', + }); + + // Notification admin + await db.createAdminNotification({ + type: 'nouvelle_demande', + titre: `Nouvelle demande: ${input.titre}`, + message: `L'association "${association.nomAssociation}" a soumis une demande de type "${input.type}".`, + lien: `/dashboard/requests/${requestId}`, + }); + + // Generate email action tokens (valid for 7 days) + const validateToken = nanoid(48); + const refuseToken = nanoid(48); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + await Promise.all([ + db.createEmailActionToken({ token: validateToken, requestId, action: 'validee', expiresAt }), + db.createEmailActionToken({ token: refuseToken, requestId, action: 'refusee', expiresAt }), + ]); + + const baseUrl = ctx.req.headers.origin || `${ctx.req.protocol}://${ctx.req.headers.host}`; + const validateUrl = `${baseUrl}/api/email-action/${validateToken}`; + const refuseUrl = `${baseUrl}/api/email-action/${refuseToken}`; + const viewUrl = `${baseUrl}/dashboard/requests/${requestId}`; + + const requestTypeLabels: Record = { + subvention_fonctionnement: 'Subvention de fonctionnement', + subvention_projet: 'Subvention de projet', + agrement_jeunesse_education: 'Agrément Jeunesse et Éducation Populaire', + 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 demande', + }; + const typeLabel = requestTypeLabels[input.type] || input.type; + + try { + await notifyOwner({ + title: `📨 Nouvelle demande: ${input.titre}`, + content: `**Association:** ${association.nomAssociation}\n**Type:** ${typeLabel}\n**Description:** ${input.description || 'Non spécifiée'}\n\n---\n\n✅ **Valider la demande:** ${validateUrl}\n\n❌ **Refuser la demande:** ${refuseUrl}\n\n🔍 **Voir le détail:** ${viewUrl}\n\n_Ces liens sont valides pendant 7 jours._`, + }); + } catch (e) { + console.error('Failed to notify owner:', e); + } + + return { id: requestId, submitted: true }; + } + + return { id: requestId, submitted: false }; + }), + + update: protectedProcedure + .input(z.object({ + id: z.number(), + titre: z.string().optional(), + description: z.string().optional(), + formData: z.string().optional(), + montantDemande: z.number().optional(), + documentsJoints: z.array(z.number()).optional(), + })) + .mutation(async ({ ctx, input }) => { + const association = await db.getAssociationByUserId(ctx.user.id); + const request = await db.getRequestById(input.id); + + if (!request || !association || request.associationId !== association.id) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + if (request.status !== 'brouillon' && request.status !== 'information_complementaire') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Seules les demandes en brouillon ou revenues pour complément peuvent être modifiées' }); + } + + if (request.type === 'demande_salle' && input.formData) { + validateReservationSalleFormData(input.formData); + } + + await db.updateRequest(input.id, { + titre: input.titre, + description: input.description, + formData: input.formData, + montantDemande: input.montantDemande, + documentsJoints: input.documentsJoints ? JSON.stringify(input.documentsJoints) : undefined, + }); + + await db.createRequestHistory({ + requestId: input.id, + action: 'modification', + userId: ctx.user.id, + commentaire: 'Modification de la demande', + }); + + // Notify admin when the request comes back with requested complementary information + if (request.status === 'information_complementaire') { + await db.createAdminNotification({ + type: 'modification_demande', + titre: `Demande modifiée : ${request.titre}`, + message: `L'association "${association.nomAssociation}" a modifié sa demande "${request.titre}" (statut : ${request.status}).`, + lien: `/dashboard/requests/${input.id}`, + }); + + try { + await notifyOwner({ + title: `✏️ Demande modifiée : ${request.titre}`, + content: `L'association "${association.nomAssociation}" a modifié sa demande "${request.titre}".\n\nType : ${request.type}\nStatut actuel : ${request.status}`, + }); + } catch (e) { + console.error('Failed to notify owner about modification:', e); + } + } + + return { success: true }; + }), + + submit: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const association = await db.getAssociationByUserId(ctx.user.id); + const request = await db.getRequestById(input.id); + + if (!request || !association || request.associationId !== association.id) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + if (request.status !== 'brouillon' && request.status !== 'information_complementaire') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cette demande ne peut pas être soumise dans son état actuel' }); + } + + await db.updateRequest(input.id, { + status: 'soumise', + dateSubmission: new Date(), + }); + + await db.createRequestHistory({ + requestId: input.id, + action: 'soumission', + ancienStatut: request.status, + nouveauStatut: 'soumise', + userId: ctx.user.id, + commentaire: 'Soumission de la demande', + }); + + // Create admin notification + await db.createAdminNotification({ + type: 'nouvelle_demande', + titre: `Nouvelle demande: ${request.titre}`, + message: `L'association "${association.nomAssociation}" a soumis une demande de type "${request.type}".`, + lien: `/dashboard/requests/${input.id}`, + }); + + // Generate email action tokens (valid for 7 days) + const validateToken = nanoid(48); + const refuseToken = nanoid(48); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + await Promise.all([ + db.createEmailActionToken({ token: validateToken, requestId: input.id, action: 'validee', expiresAt }), + db.createEmailActionToken({ token: refuseToken, requestId: input.id, action: 'refusee', expiresAt }), + ]); + + // Build base URL for email links + const baseUrl = ctx.req.headers.origin || `${ctx.req.protocol}://${ctx.req.headers.host}`; + const validateUrl = `${baseUrl}/api/email-action/${validateToken}`; + const refuseUrl = `${baseUrl}/api/email-action/${refuseToken}`; + const viewUrl = `${baseUrl}/dashboard/requests/${input.id}`; + + // Notify owner with action buttons + const requestTypeLabels: Record = { + subvention_fonctionnement: 'Subvention de fonctionnement', + subvention_projet: 'Subvention de projet', + agrement_jeunesse_education: 'Agrément Jeunesse et Éducation Populaire', + 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 demande', + }; + const typeLabel = requestTypeLabels[request.type] || request.type; + const montantStr = request.montantDemande ? `${(request.montantDemande / 100).toLocaleString('fr-FR')} €` : 'Non spécifié'; + + await notifyOwner({ + title: `📨 Nouvelle demande: ${request.titre}`, + content: `**Association:** ${association.nomAssociation}\n**Type:** ${typeLabel}\n**Montant demandé:** ${montantStr}\n**Description:** ${request.description || 'Non spécifiée'}\n\n---\n\n✅ **Valider la demande:** ${validateUrl}\n\n❌ **Refuser la demande:** ${refuseUrl}\n\n🔍 **Voir le détail:** ${viewUrl}\n\n_Ces liens sont valides pendant 7 jours._`, + }); + + return { success: true }; + }), + + duplicate: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const association = await db.getAssociationByUserId(ctx.user.id); + const request = await db.getRequestById(input.id); + + if (!request || !association || request.associationId !== association.id) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + const dateLimite = new Date(); + dateLimite.setDate(dateLimite.getDate() + 30); + + const duplicatedRequestId = await db.createRequest({ + associationId: association.id, + type: request.type, + titre: `Copie - ${request.titre}`, + description: request.description, + formData: sanitizeDuplicatedRequestFormData(request.formData), + montantDemande: request.montantDemande, + documentsJoints: request.documentsJoints || null, + status: 'brouillon', + priority: request.priority, + dateLimiteTraitement: dateLimite, + }); + + await db.createRequestHistory({ + requestId: duplicatedRequestId, + action: 'creation', + userId: ctx.user.id, + commentaire: `Demande dupliquée à partir du dossier #${request.id}`, + }); + + return { id: duplicatedRequestId, type: request.type }; + }), + + listAll: adminProcedure.query(async () => { + return db.getAllRequests(); + }), + + search: accueilAdminProcedure + .input(z.object({ + search: z.string().optional(), + type: z.string().optional(), + status: z.string().optional(), + priority: z.string().optional(), + assigneA: z.number().optional(), + associationId: z.number().optional(), + dateFrom: z.string().optional(), + dateTo: z.string().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + })) + .query(async ({ input }) => { + return db.searchRequests({ + ...input, + dateFrom: input.dateFrom ? new Date(input.dateFrom) : undefined, + dateTo: input.dateTo ? new Date(input.dateTo) : undefined, + }); + }), + + getPending: accueilAdminProcedure.query(async () => { + return db.getPendingRequests(); + }), + + getOverdue: accueilAdminProcedure.query(async () => { + return db.getOverdueRequests(); + }), + + getSalleSignatureQueue: salleSignerProcedure.query(async () => { + const requests = await db.getAllRequests(); + const salleRequests = requests.filter((request) => { + if (request.type !== "demande_salle") return false; + if (request.status === "refusee" || request.status === "annulee") return false; + const workflow = getSalleWorkflowData(request.formData); + return workflow.quoteStatus === "accepte" && workflow.directorStatus === "en_attente_signature"; + }); + + const queue = await Promise.all( + salleRequests.map(async (request) => { + const association = await db.getAssociationById(request.associationId); + const formData = parseRequestFormData(request.formData); + const workflow = getSalleWorkflowData(request.formData); + const financialDecision = parseMaterialFinancialDecision(request.formData); + const paymentTracking = getSallePaymentTracking(request.formData); + + let attachedDocuments: Array<{ + id: number; + nom: string; + type: string | null; + fileUrl: string; + }> = []; + if (request.documentsJoints) { + try { + const docIds = JSON.parse(request.documentsJoints) as number[]; + if (Array.isArray(docIds) && docIds.length > 0) { + const docs = await Promise.all(docIds.map((docId) => db.getDocumentById(docId))); + attachedDocuments = docs + .filter((doc): doc is NonNullable => Boolean(doc)) + .map((doc) => ({ + id: doc.id, + nom: doc.nom, + type: doc.type ?? null, + fileUrl: doc.fileUrl, + })); + } + } catch { + attachedDocuments = []; + } + } + + return { + id: request.id, + titre: request.titre, + status: request.status, + createdAt: request.createdAt, + associationName: association?.nomAssociation || formData.nomAssociation || `Association #${request.associationId}`, + sallesSelectionnees: Array.isArray(formData.sallesSelectionnees) ? formData.sallesSelectionnees : [], + dateReservation: String(formData.dateReservation || ""), + dateFinReservation: String(formData.dateFinReservation || ""), + heureDebut: String(formData.heureDebut || ""), + heureFin: String(formData.heureFin || ""), + useDetailedSchedule: Boolean(formData.useDetailedSchedule), + horairesParJour: Array.isArray(formData.horairesParJour) ? formData.horairesParJour : [], + quotePdfUrl: typeof workflow.quotePdfUrl === "string" ? workflow.quotePdfUrl : "", + quotePdfName: typeof workflow.quotePdfName === "string" ? workflow.quotePdfName : "", + administrativeDecisionPdfUrl: + typeof workflow.administrativeDecisionPdfUrl === "string" ? workflow.administrativeDecisionPdfUrl : "", + administrativeDecisionPdfName: + typeof workflow.administrativeDecisionPdfName === "string" ? workflow.administrativeDecisionPdfName : "", + directorTransmissionAt: typeof workflow.directorTransmissionAt === "string" ? workflow.directorTransmissionAt : "", + pricing: workflow.pricing || computeSalleWorkflowPricing(request.formData), + financialMode: financialDecision?.financialMode || null, + paymentStatus: paymentTracking.paymentStatus || null, + paymentDueDate: paymentTracking.paymentDueDate || null, + amountReceivedCents: paymentTracking.amountReceivedCents || 0, + paymentReceivedAt: paymentTracking.paymentReceivedAt || null, + attachedDocuments, + }; + }) + ); + + return queue.sort((left, right) => { + const leftDate = left.directorTransmissionAt ? new Date(left.directorTransmissionAt).getTime() : 0; + const rightDate = right.directorTransmissionAt ? new Date(right.directorTransmissionAt).getTime() : 0; + return rightDate - leftDate; + }); + }), + + getPastForSignature: salleReadProcedure.query(async () => { + const allRequests = await db.getAllRequests(); + const pastStatuses = new Set(["validee", "refusee", "annulee"]); + + return Promise.all( + allRequests + .filter((request) => pastStatuses.has(request.status)) + .slice(0, 25) + .map(async (request) => { + const association = await db.getAssociationById(request.associationId); + const formData = parseRequestFormData(request.formData); + const workflow = request.type === "demande_salle" ? getSalleWorkflowData(request.formData) : {}; + const paymentTracking = request.type === "demande_salle" ? getSallePaymentTracking(request.formData) : null; + const financialDecision = request.type === "demande_salle" ? parseMaterialFinancialDecision(request.formData) : null; + + return { + id: request.id, + type: request.type, + titre: request.titre, + status: request.status, + associationName: association?.nomAssociation || formData.nomAssociation || "Association inconnue", + createdAt: request.createdAt, + updatedAt: request.updatedAt, + dateReservation: formData.dateReservation || null, + dateFinReservation: formData.dateFinReservation || null, + sallesSelectionnees: Array.isArray(formData.sallesSelectionnees) ? formData.sallesSelectionnees : [], + quoteStatus: workflow.quoteStatus || null, + directorStatus: workflow.directorStatus || null, + financialMode: financialDecision?.financialMode || null, + paymentStatus: paymentTracking?.paymentStatus || null, + }; + }) + ); + }), + + getSalleBillingBoard: salleReadProcedure.query(async () => { + const allRequests = await db.getAllRequests(); + + const entries = await Promise.all( + allRequests + .filter((request) => request.type === "demande_salle") + .map(async (request) => { + const formData = parseRequestFormData(request.formData); + const workflow = getSalleWorkflowData(request.formData); + const financialDecision = parseMaterialFinancialDecision(request.formData); + if (financialDecision?.financialMode !== "location_payante") { + return null; + } + + const association = await db.getAssociationById(request.associationId); + const paymentTracking = getSallePaymentTracking(request.formData); + const amountDueCents = + workflow.pricing?.totalAmountCents + || financialDecision.rentalAmountCents + || 0; + const paymentStatus = paymentTracking.paymentStatus || "en_attente_paiement"; + const paymentDueDate = paymentTracking.paymentDueDate || null; + const isLate = Boolean( + paymentDueDate + && paymentStatus !== "paiement_recu" + && paymentStatus !== "paiement_valide" + && paymentDueDate < new Date().toISOString().slice(0, 10) + ); + + return { + id: request.id, + titre: request.titre, + status: request.status, + createdAt: request.createdAt, + updatedAt: request.updatedAt, + associationName: association?.nomAssociation || formData.nomAssociation || "Association inconnue", + sallesSelectionnees: Array.isArray(formData.sallesSelectionnees) ? formData.sallesSelectionnees : [], + dateReservation: formData.dateReservation || null, + dateFinReservation: formData.dateFinReservation || null, + amountDueCents, + amountReceivedCents: paymentTracking.amountReceivedCents || 0, + paymentStatus, + paymentDueDate, + paymentReceivedAt: paymentTracking.paymentReceivedAt || null, + paymentValidatedAt: paymentTracking.paymentValidatedAt || null, + paymentReference: paymentTracking.paymentReference || null, + finalNotificationSentAt: + typeof workflow.finalNotificationSentAt === "string" ? workflow.finalNotificationSentAt : null, + isLate, + }; + }) + ); + + return entries + .filter((entry): entry is NonNullable => Boolean(entry)) + .sort((left, right) => { + if (left.isLate !== right.isLate) { + return left.isLate ? -1 : 1; + } + if (left.paymentDueDate && right.paymentDueDate) { + return new Date(left.paymentDueDate).getTime() - new Date(right.paymentDueDate).getTime(); + } + return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(); + }); + }), + + assign: adminProcedure + .input(z.object({ + id: z.number(), + adminId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + await db.assignRequest(input.id, input.adminId); + + await db.createRequestHistory({ + requestId: input.id, + action: 'assignation', + ancienStatut: request.status, + nouveauStatut: 'en_cours_traitement', + userId: ctx.user.id, + commentaire: `Assignation à l'agent #${input.adminId}`, + }); + + await logAdminAction(ctx.user.id, 'assignation', 'request', input.id, { adminId: input.adminId }); + + return { success: true }; + }), + + process: accueilAdminProcedure + .input(z.object({ + id: z.number(), + status: z.enum(['en_cours_traitement', 'information_complementaire', 'validee', 'refusee']), + commentaireAdmin: z.string().optional(), + montantAccorde: z.number().optional(), + recapServiceId: z.number().optional(), + recapRecipients: z.array(z.string().email()).optional(), + recapServiceLabel: z.string().optional(), + supervisionServiceId: z.number().optional(), + supervisionRecipients: z.array(z.string().email()).optional(), + supervisionServiceLabel: z.string().optional(), + // Cadre DSU pour les demandes de salle et de matériel événementiel + dsuData: z.object({ + dateReception: z.string().optional(), + avisDSU: z.enum(['favorable', 'defavorable', 'favorable_avec_reserves']).optional(), + conditionsParticulieres: z.string().optional(), + cautionRequise: z.boolean().optional(), + montantCaution: z.string().optional(), + assuranceRequise: z.boolean().optional(), + horairesImposes: z.string().optional(), + responsableDSU: z.string().optional(), + dateDecision: z.string().optional(), + observationsDSU: z.string().optional(), + salleReservation: z.object({ + usageType: z.enum(["conventionne", "occasionnel"]).optional(), + frequency: z.enum(["demi_journee", "journee", "mensuel"]).optional(), + }).optional(), + materielEvent: z.object({ + itemsAccordes: z.object({ + tente3x3: z.boolean().optional(), + chapiteau5x5: z.boolean().optional(), + podium: z.boolean().optional(), + autres: z.boolean().optional(), + }).optional(), + quantitesAccordees: z.object({ + tente3x3: z.string().optional(), + chapiteau5x5: z.string().optional(), + podium: z.string().optional(), + autres: z.string().optional(), + }).optional(), + autresPrecisions: z.string().optional(), + financialDecision: z.object({ + financialMode: z.enum(["gratuite", "gratuite_avec_caution", "location_payante"]).optional(), + depositRequired: z.boolean().optional(), + depositAmountCents: z.number().int().min(0).optional(), + rentalAmountCents: z.number().int().min(0).optional(), + pricingNotes: z.string().optional(), + contractStatus: z.enum(["a_generer", "generee", "signee", "refusee", "annulee"]).optional(), + }).optional(), + }).optional(), + }).optional(), + generateContract: z.boolean().optional(), + })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + if (ctx.user.role === "accueil" && request.type !== "demande_salle") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Le rôle accueil ne peut traiter que les demandes de salle", + }); + } + if (ctx.user.role === "logistique_controle" && request.type !== "demande_materiel_evenementiel") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Le rôle matériel CCDS - logistique et contrôle ne peut traiter que les demandes de matériel", + }); + } + if (request.type === "demande_materiel_evenementiel" && !canManageLogistics(ctx.user)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Les demandes de matériel sont gérées par le service logistique et contrôle", + }); + } + if (request.type === "demande_salle") { + const workflow = getSalleWorkflowData(request.formData); + if (workflow.quoteStatus === "en_attente_association") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cette demande est en attente de réponse de l'association. Elle ne peut plus être traitée tant que le devis n'est pas accepté.", + }); + } + } + + const selectedRecapService = input.recapServiceId + ? await db.getOperationalRecapServiceById(input.recapServiceId) + : undefined; + const selectedSupervisionService = input.supervisionServiceId + ? await db.getOperationalRecapServiceById(input.supervisionServiceId) + : undefined; + const resolvedServiceRecipients = parseStoredRecipientEmails(selectedRecapService?.recipientEmails); + const resolvedRecipients = mergeRecipients(resolvedServiceRecipients, parseRecipients(input.recapRecipients)); + const resolvedServiceLabel = input.recapServiceLabel?.trim() || selectedRecapService?.label || undefined; + const resolvedSupervisionServiceRecipients = parseStoredRecipientEmails(selectedSupervisionService?.recipientEmails); + const resolvedSupervisionRecipients = mergeRecipients( + resolvedSupervisionServiceRecipients, + parseRecipients(input.supervisionRecipients) + ); + const resolvedSupervisionServiceLabel = + input.supervisionServiceLabel?.trim() || selectedSupervisionService?.label || undefined; + + if ((request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel') + && !input.dsuData?.responsableDSU?.trim()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Le responsable DSU est obligatoire pour traiter cette demande', + }); + } + + if ((request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel') && input.status === 'validee') { + const financialDecision = input.dsuData?.materielEvent?.financialDecision; + if (!financialDecision?.financialMode) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Le mode financier est obligatoire pour valider une ${request.type === 'demande_salle' ? 'demande de salle' : 'demande de matériel'}`, + }); + } + } + + if (request.type === 'demande_materiel_evenementiel' && input.status === 'validee') { + const grantedItems = input.dsuData?.materielEvent?.itemsAccordes || {}; + const grantedQuantities = input.dsuData?.materielEvent?.quantitesAccordees || {}; + const invalidGrantedItem = materialEventItems.find((item) => { + if (!grantedItems[item.key]) return false; + return parseMaterialEventQuantity(grantedQuantities[item.key]) <= 0; + }); + + if (invalidGrantedItem) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `La quantité accordée est obligatoire pour ${invalidGrantedItem.label}`, + }); + } + } + + const canSendRecapEmails = await canSendOperationalEmails(); + const existingFinancialDecision = parseMaterialFinancialDecision(request.formData); + const parsedRequestFormData = parseRequestFormData(request.formData); + const resolvedSalleUsageType = request.type === 'demande_salle' + ? (input.dsuData?.salleReservation?.usageType || parsedRequestFormData.typeUsage || 'conventionne') + : undefined; + const resolvedSalleFrequency = request.type === 'demande_salle' + ? (input.dsuData?.salleReservation?.frequency || parsedRequestFormData.frequence || 'journee') + : undefined; + const computedSallePricing = request.type === 'demande_salle' + ? computeSallePricing({ + sallesIds: Array.isArray(parsedRequestFormData.sallesIds) + ? parsedRequestFormData.sallesIds.filter((value: unknown): value is string => typeof value === 'string' && value.trim().length > 0) + : [], + usageType: resolvedSalleUsageType as "conventionne" | "occasionnel", + frequency: resolvedSalleFrequency as "demi_journee" | "journee" | "mensuel", + dateReservation: parsedRequestFormData.dateReservation, + dateFinReservation: parsedRequestFormData.dateFinReservation, + }) + : null; + + if ( + request.type === 'demande_salle' + && input.dsuData?.materielEvent?.financialDecision?.financialMode === 'location_payante' + && (!computedSallePricing || computedSallePricing.unsupportedSalles.length > 0) + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: computedSallePricing?.unsupportedSalles?.length + ? `Tarification automatique indisponible pour : ${computedSallePricing.unsupportedSalles.map((item: { salleNom: string }) => item.salleNom).join(', ')}` + : 'La tarification automatique est indisponible pour cette demande de salle', + }); + } + + // If DSU data is provided, merge it into the existing formData + let updatedFormData: string | undefined; + if (input.dsuData && (request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel')) { + try { + const existingFormData = request.formData ? JSON.parse(request.formData) : {}; + if (request.type === 'demande_salle') { + existingFormData.typeUsage = resolvedSalleUsageType; + existingFormData.frequence = resolvedSalleFrequency; + } + const nextFinancialDecision = (request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel') + ? (() => { + const inputFinancial = input.dsuData?.materielEvent?.financialDecision; + if (input.status === 'refusee') { + return { + ...(existingFinancialDecision || { + financialMode: 'gratuite' as const, + depositRequired: false, + depositAmountCents: 0, + rentalAmountCents: 0, + pricingNotes: '', + }), + contractStatus: 'refusee' as const, + }; + } + if (!inputFinancial?.financialMode) { + return existingFinancialDecision; + } + const depositRequired = Boolean(inputFinancial.depositRequired); + const depositAmountCents = depositRequired ? Math.max(0, inputFinancial.depositAmountCents || 0) : 0; + const rentalAmountCents = inputFinancial.financialMode === 'location_payante' + ? request.type === 'demande_salle' && computedSallePricing + ? computedSallePricing.totalAmountCents + : Math.max(0, inputFinancial.rentalAmountCents || 0) + : 0; + return { + financialMode: inputFinancial.financialMode, + depositRequired, + depositAmountCents, + rentalAmountCents, + pricingNotes: inputFinancial.pricingNotes || '', + contractStatus: inputFinancial.contractStatus || (input.status === 'validee' ? 'a_generer' : 'annulee'), + }; + })() + : null; + + existingFormData.cadreDSU = { + ...input.dsuData, + ...(request.type === 'demande_salle' ? { + salleReservation: { + usageType: resolvedSalleUsageType, + frequency: resolvedSalleFrequency, + }, + } : {}), + responsableDSU: input.dsuData.responsableDSU?.trim(), + cautionRequise: nextFinancialDecision ? nextFinancialDecision.depositRequired : input.dsuData.cautionRequise, + montantCaution: nextFinancialDecision?.depositRequired + ? String((nextFinancialDecision.depositAmountCents || 0) / 100) + : input.dsuData.montantCaution, + rempliPar: ctx.user.id, + dateRemplissage: new Date().toISOString(), + ...((request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel') ? { + materielEvent: { + ...input.dsuData.materielEvent, + financialDecision: nextFinancialDecision || undefined, + }, + } : {}), + }; + if ( + resolvedRecipients.length + || resolvedServiceLabel + || resolvedSupervisionRecipients.length + || resolvedSupervisionServiceLabel + ) { + existingFormData.notificationTrace = { + serviceId: selectedRecapService?.id, + serviceLabel: resolvedServiceLabel, + recipients: resolvedRecipients, + supervisionServiceId: selectedSupervisionService?.id, + supervisionServiceLabel: resolvedSupervisionServiceLabel, + supervisionRecipients: resolvedSupervisionRecipients, + processedBy: ctx.user.name || `Admin #${ctx.user.id}`, + processedAt: new Date().toISOString(), + channel: canSendRecapEmails ? 'smtp' : 'notification_only', + }; + } + updatedFormData = JSON.stringify(existingFormData); + if (request.type === 'demande_salle' && nextFinancialDecision) { + const texts = buildDefaultSalleWorkflowTexts(updatedFormData); + updatedFormData = mergeSalleWorkflowData(updatedFormData, { + usageType: resolvedSalleUsageType, + frequency: resolvedSalleFrequency, + pricing: computedSallePricing || undefined, + conditionsFinancieresText: CONDITIONS_FINANCIERES_SALLE_TEXT, + decisionAdministrativeText: texts.decisionAdministrativeText, + quoteStatus: existingFormData.salleWorkflow?.quoteStatus || 'a_preparer', + directorStatus: existingFormData.salleWorkflow?.directorStatus || 'a_transmettre', + }); + } + } catch (e) { + console.error('Failed to merge DSU data:', e); + } + } else if ( + resolvedRecipients.length + || resolvedServiceLabel + || resolvedSupervisionRecipients.length + || resolvedSupervisionServiceLabel + ) { + try { + const existingFormData = request.formData ? JSON.parse(request.formData) : {}; + existingFormData.notificationTrace = { + serviceId: selectedRecapService?.id, + serviceLabel: resolvedServiceLabel, + recipients: resolvedRecipients, + supervisionServiceId: selectedSupervisionService?.id, + supervisionServiceLabel: resolvedSupervisionServiceLabel, + supervisionRecipients: resolvedSupervisionRecipients, + processedBy: ctx.user.name || `Admin #${ctx.user.id}`, + processedAt: new Date().toISOString(), + channel: canSendRecapEmails ? 'smtp' : 'notification_only', + }; + updatedFormData = JSON.stringify(existingFormData); + } catch (e) { + console.error('Failed to merge notification trace:', e); + } + } + + const association = request.associationId ? await db.getAssociationById(request.associationId) : null; + let effectiveStatus = input.status; + if (request.type === 'demande_salle' && input.status === 'validee') { + effectiveStatus = 'en_cours_traitement'; + } + + await db.updateRequest(input.id, { + status: effectiveStatus, + commentaireAdmin: input.commentaireAdmin, + montantAccorde: input.montantAccorde, + traitePar: ctx.user.id, + dateTraitement: input.status === 'refusee' ? new Date() : undefined, + ...(updatedFormData ? { formData: updatedFormData } : {}), + }); + + if (request.type === 'demande_salle' && input.status === 'validee') { + const refreshedRequest = await db.getRequestById(input.id); + if (!refreshedRequest) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande de salle introuvable après traitement' }); + } + const quoteWorkflow = await sendSalleQuoteWorkflow({ + request: refreshedRequest, + association: association || undefined, + req: ctx.req, + user: ctx.user, + }); + updatedFormData = quoteWorkflow.updatedFormData; + await db.updateRequest(input.id, { + status: effectiveStatus, + formData: updatedFormData, + traitePar: ctx.user.id, + }); + } + + let generatedContract: { + contractGeneratedAt: Date; + contractPdfKey: string; + contractPdfUrl: string; + contractPdfName: string; + } | null = null; + if ( + (request.type === 'demande_materiel_evenementiel' || request.type === 'demande_salle') + && input.status === 'validee' + && input.generateContract + && updatedFormData + && request.type !== 'demande_salle' + ) { + const refreshedRequest = await db.getRequestById(input.id); + const financialDecision = parseMaterialFinancialDecision(updatedFormData); + + if (refreshedRequest && financialDecision) { + generatedContract = await generateAndStoreMaterialConventionForRequest({ + request: { + ...refreshedRequest, + formData: updatedFormData, + }, + association: association || undefined, + decision: { + financialMode: financialDecision.financialMode, + depositRequired: financialDecision.depositRequired, + depositAmountCents: financialDecision.depositAmountCents, + rentalAmountCents: financialDecision.rentalAmountCents, + pricingNotes: financialDecision.pricingNotes, + }, + validatedByUserId: ctx.user.id, + }); + + updatedFormData = mergeMaterialFinancialDecision({ + rawFormData: updatedFormData, + decision: { + financialMode: financialDecision.financialMode, + depositRequired: financialDecision.depositRequired, + depositAmountCents: financialDecision.depositAmountCents, + rentalAmountCents: financialDecision.rentalAmountCents, + pricingNotes: financialDecision.pricingNotes, + contractStatus: 'generee', + contractPdfUrl: generatedContract.contractPdfUrl, + contractPdfName: generatedContract.contractPdfName, + contractGeneratedAt: generatedContract.contractGeneratedAt, + contractValidatedByUserId: ctx.user.id, + }, + }); + + await db.updateRequest(input.id, { + formData: updatedFormData, + }); + } + } + + const actionType = input.status === 'validee' ? 'validation' : input.status === 'refusee' ? 'refus' : 'changement_statut'; + await db.createRequestHistory({ + requestId: input.id, + action: actionType, + ancienStatut: request.status, + nouveauStatut: effectiveStatus, + userId: ctx.user.id, + commentaire: request.type === 'demande_salle' && input.status === 'validee' + ? (input.commentaireAdmin + ? `${input.commentaireAdmin}\n\nDevis et décision administrative prioritaire envoyés à l'association pour validation.` + : "Devis et décision administrative prioritaire envoyés à l'association pour validation.") + : input.commentaireAdmin, + }); + + await logAdminAction(ctx.user.id, actionType, 'request', input.id, { + status: effectiveStatus, + montantAccorde: input.montantAccorde, + dsuData: input.dsuData, + generateContract: Boolean(input.generateContract), + contractPdfName: generatedContract?.contractPdfName, + recapServiceId: selectedRecapService?.id, + recapRecipients: resolvedRecipients, + recapServiceLabel: resolvedServiceLabel, + supervisionServiceId: selectedSupervisionService?.id, + supervisionRecipients: resolvedSupervisionRecipients, + supervisionServiceLabel: resolvedSupervisionServiceLabel, + }); + + // Envoyer le récapitulatif complet par email à tous les administrateurs + if ((input.status === 'validee' || input.status === 'refusee') && request.type !== 'demande_salle') { + try { + // Récupérer les documents joints + const allDocs = request.associationId ? await db.getDocumentsByAssociationId(request.associationId) : []; + let requestDocs: { name: string; type: string }[] = []; + if (request.formData) { + try { + const fd = JSON.parse(request.formData); + if (fd.documentsJoints && Array.isArray(fd.documentsJoints)) { + requestDocs = fd.documentsJoints.map((docId: number) => { + const doc = allDocs.find((d: any) => d.id === docId); + return doc ? { name: doc.nom, type: doc.type || 'Document' } : { name: `Document #${docId}`, type: 'Document' }; + }); + } + } catch (e) { /* ignore */ } + } + + // Récupérer la demande mise à jour (avec les données DSU mises à jour) + const updatedRequest = await db.getRequestById(input.id); + const reqForRecap = updatedRequest || request; + let pdfAttachment: + | { + filename: string; + content: Buffer; + contentType?: string; + } + | undefined; + + if (input.status === 'validee') { + try { + const pdfDocument = await generateRequestPdfDocument({ + ...reqForRecap, + status: input.status, + commentaireAdmin: input.commentaireAdmin ?? reqForRecap.commentaireAdmin, + dateTraitement: new Date(), + }); + + pdfAttachment = { + filename: pdfDocument.fileName, + content: pdfDocument.buffer, + contentType: pdfDocument.contentType, + }; + } catch (pdfError) { + console.warn( + `PDF non joint au mail opérationnel pour la demande #${input.id}:`, + pdfError + ); + } + } + + const recap = generateRequestRecapEmail({ + request: { + id: reqForRecap.id, + titre: reqForRecap.titre, + type: reqForRecap.type, + status: effectiveStatus, + description: reqForRecap.description, + montantDemande: reqForRecap.montantDemande, + montantAccorde: input.montantAccorde ?? reqForRecap.montantAccorde, + commentaireAdmin: input.commentaireAdmin ?? reqForRecap.commentaireAdmin, + formData: reqForRecap.formData, + dateSubmission: reqForRecap.dateSubmission, + dateTraitement: new Date(), + createdAt: reqForRecap.createdAt, + }, + association: association ? { + nomAssociation: association.nomAssociation, + siret: association.siret, + adresse: association.adresse, + codePostal: association.codePostal, + ville: association.ville, + telephone: association.telephone, + emailContact: association.emailContact, + nomRepresentant: association.nomRepresentant, + } : null, + traitePar: ctx.user.name || `Admin #${ctx.user.id}`, + documents: requestDocs.length > 0 ? requestDocs : undefined, + serviceLabel: resolvedServiceLabel, + }); + + try { + await notifyOwner(recap); + } catch (notificationError) { + console.warn('Notification propriétaire non envoyée:', notificationError); + } + + const recipients = resolvedRecipients; + if (recipients.length > 0) { + try { + const result = await sendOperationalEmail({ + to: recipients, + subject: recap.title, + text: recap.content, + html: recap.html, + replyTo: ctx.user.email || undefined, + fromName: ctx.user.name ? `${ctx.user.name} via Portail Associations` : "Portail Associations", + attachments: pdfAttachment ? [pdfAttachment] : undefined, + }); + + if (!result.sent) { + console.warn('Envoi email opérationnel non effectué:', result.reason); + } + } catch (mailError) { + console.error('Erreur lors de l’envoi du mail opérationnel:', mailError); + } + } + } catch (e) { + console.error('Erreur lors de l\'envoi du récapitulatif par email:', e); + // Ne pas bloquer le traitement si l'envoi échoue + } + } + + if (request.type === 'demande_materiel_evenementiel' && input.status === 'validee') { + await scheduleMaterialReturnFollowup({ + request: { + ...request, + status: input.status, + formData: updatedFormData || request.formData, + }, + serviceLabel: resolvedServiceLabel, + recipientEmails: resolvedRecipients, + supervisionServiceLabel: resolvedSupervisionServiceLabel, + supervisionRecipientEmails: resolvedSupervisionRecipients, + }); + } + + return { success: true }; + }), + + reopen: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + if (request.status !== 'validee' && request.status !== 'refusee') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Seules les demandes traitées peuvent être rouvertes' }); + } + + const reopenedComment = request.commentaireAdmin + ? `${request.commentaireAdmin}\n\nDemande rouverte par l'administration pour modification.` + : 'Demande rouverte par l\'administration pour modification.'; + + await db.updateRequest(input.id, { + status: 'information_complementaire', + commentaireAdmin: reopenedComment, + dateTraitement: null, + traitePar: null, + }); + + await db.createRequestHistory({ + requestId: input.id, + action: 'changement_statut', + ancienStatut: request.status, + nouveauStatut: 'information_complementaire', + userId: ctx.user.id, + commentaire: 'Réouverture de la demande pour permettre une nouvelle modification par l’association', + }); + + await logAdminAction(ctx.user.id, 'reopen_request', 'request', input.id, { + ancienStatut: request.status, + nouveauStatut: 'information_complementaire', + }); + + return { success: true }; + }), + + getHistory: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + return db.getRequestHistoryByRequestId(input.id); + }), + + // Calendrier : récupérer toutes les réservations accordées + getApprovedReservations: salleReadProcedure.query(async () => { + return db.getApprovedReservations(); + }), + + getMaterialCalendar: salleReadProcedure.query(async () => { + const logisticsSettings = await getLogisticsSettings(); + const inventory = logisticsSettings.inventory; + const materialRequests = (await db.searchRequests({ + type: "demande_materiel_evenementiel", + status: "validee", + limit: 500, + })).data; + + const events = await Promise.all( + materialRequests.map(async (request) => { + const association = await db.getAssociationById(request.associationId); + const followup = await db.getMaterialReturnFollowupByRequestId(request.id); + const event = getMaterialRequestEventData(request, association); + const boardState = computeMaterialReturnBoardState({ + requestStatus: request.status, + restitutionDate: event.restitutionDate, + followup, + graceDays: logisticsSettings.materialReturnGraceDays, + }); + + return { + ...event, + boardState, + boardStateLabel: materialReturnBoardStateLabels[boardState], + logisticServiceLabel: followup?.serviceLabel || "", + logisticRecipients: parseStoredRecipientEmails(followup?.recipientEmails), + followupStatus: followup?.status || null, + issueFlag: Boolean(followup?.issueFlag), + litigationStatus: followup?.litigationStatus || "none", + finalPdfUrl: followup?.finalPdfUrl || followup?.signedFileUrl || null, + uploadLink: followup?.uploadToken ? `/materiel/restitution/${followup.uploadToken}` : null, + }; + }) + ); + + const followups = await Promise.all(materialRequests.map((request) => db.getMaterialReturnFollowupByRequestId(request.id))); + const blockedTotals = sumBlockedInventory(followups); + + const conflicts = events.flatMap((event) => + event.requestedItems.flatMap((item) => { + const capacity = inventory[item.key]; + const blockedQuantity = blockedTotals[item.key] || 0; + const effectiveCapacity = capacity === null ? null : Math.max(capacity - blockedQuantity, 0); + const overlaps = events.filter((other) => + other.requestId !== event.requestId + && other.requestedItems.some((otherItem) => otherItem.key === item.key && otherItem.quantity > 0) + && dateRangesOverlap(event.useStartDate, event.useEndDate, other.useStartDate, other.useEndDate) + ); + + const stockConflicts = effectiveCapacity === null || item.quantity <= 0 + ? [] + : (() => { + const reservedQuantity = overlaps.reduce((sum, other) => { + const overlappingItem = other.requestedItems.find((entry) => entry.key === item.key); + return sum + (overlappingItem?.quantity || 0); + }, item.quantity); + + if (reservedQuantity <= effectiveCapacity) return []; + return [{ + requestId: event.requestId, + materialKey: item.key, + materialLabel: item.label, + reservedQuantity, + capacity: effectiveCapacity, + message: `${item.label} réservé au-delà du stock disponible (${reservedQuantity}/${effectiveCapacity})`, + }]; + })(); + + const litigeConflicts = overlaps + .filter((other) => other.litigationStatus === "pending") + .map((other) => ({ + requestId: event.requestId, + materialKey: item.key, + materialLabel: item.label, + reservedQuantity: item.quantity, + capacity: effectiveCapacity, + message: `Attention, ${item.label} est planifié pour ce dossier alors qu'un prêt précédent (#${other.requestId}) est déclaré en litige ou dégradation.`, + })); + + return [...stockConflicts, ...litigeConflicts]; + }) + ); + + return { + inventory: materialEventItems.map((item) => ({ + key: item.key, + label: item.label, + capacity: inventory[item.key], + replacementValue: logisticsSettings.replacementValues[item.key], + blocked: blockedTotals[item.key] || 0, + effectiveCapacity: inventory[item.key] === null ? null : Math.max((inventory[item.key] || 0) - (blockedTotals[item.key] || 0), 0), + })), + events, + conflicts, + }; + }), + + getLogisticsPolicy: protectedProcedure.query(async () => { + return getLogisticsSettings(); + }), + + getMaterialAvailability: protectedProcedure + .input(z.object({ + dateDebut: z.string().optional(), + dateFin: z.string().optional(), + })) + .query(async ({ input }) => { + const logisticsSettings = await getLogisticsSettings(); + const inventory = logisticsSettings.inventory; + const allMaterialRequests = (await db.searchRequests({ + type: "demande_materiel_evenementiel", + limit: 500, + })).data; + const materialRequests = allMaterialRequests.filter((request) => + ACTIVE_MATERIAL_AVAILABILITY_STATUSES.has(request.status) + ); + const followups = await Promise.all(materialRequests.map((request) => db.getMaterialReturnFollowupByRequestId(request.id))); + const blockedTotals = sumBlockedInventory(followups); + const dateDebut = normalizeDateString(input.dateDebut); + const dateFin = normalizeDateString(input.dateFin || input.dateDebut); + if (!dateDebut || !dateFin) { + return { + graceDays: logisticsSettings.materialReturnGraceDays, + items: materialEventItems.map((item) => ({ + key: item.key, + label: item.label, + capacity: inventory[item.key], + replacementValue: logisticsSettings.replacementValues[item.key], + reserved: 0, + available: inventory[item.key] === null ? null : Math.max((inventory[item.key] || 0) - (blockedTotals[item.key] || 0), 0), + blocked: (blockedTotals[item.key] || 0) > 0, + blockedQuantity: blockedTotals[item.key] || 0, + })), + }; + } + + const reservations = materialRequests.map((request) => getMaterialRequestEventData(request, null)); + const items = materialEventItems.map((item) => { + const capacity = inventory[item.key]; + const blockedQuantity = blockedTotals[item.key] || 0; + const effectiveCapacity = capacity === null ? null : Math.max(capacity - blockedQuantity, 0); + const reserved = reservations.reduce((sum, reservation) => { + if (!dateRangesOverlap(dateDebut, dateFin, reservation.useStartDate, reservation.useEndDate)) { + return sum; + } + const reservedItem = reservation.requestedItems.find((entry) => entry.key === item.key); + return sum + (reservedItem?.quantity || 0); + }, 0); + const available = effectiveCapacity === null ? null : Math.max(effectiveCapacity - reserved, 0); + return { + key: item.key, + label: item.label, + capacity, + replacementValue: logisticsSettings.replacementValues[item.key], + reserved, + available, + blocked: (blockedQuantity > 0) || (effectiveCapacity !== null ? (available ?? 0) <= 0 : false), + blockedQuantity, + }; + }); + + return { items, graceDays: logisticsSettings.materialReturnGraceDays }; + }), + + // Modifier l'attribution des salles d'une réservation + changeSalles: adminProcedure + .input(z.object({ + id: z.number(), + newSallesIds: z.array(z.string()), + newSallesNoms: z.array(z.string()), + raison: z.string().min(1, 'Veuillez indiquer la raison du changement'), + })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + if (request.type !== 'demande_salle') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cette demande n\'est pas une demande de salle' }); + } + + // Parse existing formData + let formData: any = {}; + try { + formData = request.formData ? JSON.parse(request.formData) : {}; + } catch { + formData = {}; + } + + // Save old salles for notification + const anciennesSalles = formData.sallesSelectionnees || []; + + // Update salles + formData.sallesSelectionnees = input.newSallesNoms; + formData.sallesIds = input.newSallesIds; + + // Add change history to formData + if (!formData.historiqueChangementsSalles) { + formData.historiqueChangementsSalles = []; + } + formData.historiqueChangementsSalles.push({ + date: new Date().toISOString(), + anciennesSalles, + nouvellesSalles: input.newSallesNoms, + raison: input.raison, + parAdmin: ctx.user.id, + }); + + // Update in DB + await db.updateRequestSalles(input.id, JSON.stringify(formData)); + + // Log in request history + await db.createRequestHistory({ + requestId: input.id, + action: 'changement_salle', + ancienStatut: request.status, + nouveauStatut: request.status, + userId: ctx.user.id, + commentaire: `Changement de salle(s) : ${anciennesSalles.join(', ')} → ${input.newSallesNoms.join(', ')}. Raison : ${input.raison}`, + }); + + // Audit log + await logAdminAction(ctx.user.id, 'changement_salle', 'request', input.id, { + anciennesSalles, + nouvellesSalles: input.newSallesNoms, + raison: input.raison, + }); + + // Create admin notification for record + await db.createAdminNotification({ + type: 'changement_salle', + titre: `Changement de salle : ${request.titre}`, + message: `Salles modifiées de ${anciennesSalles.join(', ')} vers ${input.newSallesNoms.join(', ')}. Raison : ${input.raison}`, + lien: `/dashboard/requests/${input.id}`, + }); + + // Notify the association owner via platform notification + try { + const association = await db.getAssociationById(request.associationId); + const notifTitle = `Changement de salle pour votre réservation`; + const notifContent = `Bonjour ${association?.nomAssociation || 'Cher adhérent'},\n\nVotre réservation "${request.titre}" a fait l'objet d'un changement de salle.\n\nAnciennes salles : ${anciennesSalles.join(', ')}\nNouvelles salles : ${input.newSallesNoms.join(', ')}\n\nRaison : ${input.raison}\n\nCordialement,\nLa Direction des Services aux Usagers`; + + await notifyOwner({ title: notifTitle, content: notifContent }); + } catch (e) { + console.error('Failed to send notification for salle change:', e); + } + + return { success: true, anciennesSalles, nouvellesSalles: input.newSallesNoms }; + }), + + cancel: protectedProcedure + .input(z.object({ + id: z.number(), + raison: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association || request.associationId !== association.id) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Vous ne pouvez annuler que vos propres demandes' }); + } + + if (request.status === 'validee' || request.status === 'refusee' || request.status === 'annulee') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cette demande ne peut plus être annulée' }); + } + + const ancienStatut = request.status; + + await db.updateRequest(input.id, { + status: 'annulee', + dateTraitement: new Date(), + }); + + await db.createRequestHistory({ + requestId: input.id, + action: 'annulation', + ancienStatut: ancienStatut, + nouveauStatut: 'annulee', + userId: ctx.user.id, + commentaire: input.raison || 'Annulation par l\'association', + }); + + // Notify admin + await db.createAdminNotification({ + type: 'annulation_demande', + titre: `Demande annulée : ${request.titre}`, + message: `L'association "${association.nomAssociation}" a annulé sa demande "${request.titre}".${input.raison ? ` Raison : ${input.raison}` : ''}`, + lien: `/dashboard/requests/${input.id}`, + }); + + // Notify owner + try { + await notifyOwner({ + title: `❌ Demande annulée : ${request.titre}`, + content: `L'association "${association.nomAssociation}" a annulé sa demande "${request.titre}".\n\nType : ${request.type}\nAncien statut : ${ancienStatut}${input.raison ? `\nRaison : ${input.raison}` : ''}`, + }); + } catch (e) { + console.error('Failed to notify owner about cancellation:', e); + } + + await logAdminAction(ctx.user.id, 'annulation_demande', 'request', input.id, { + titre: request.titre, + type: request.type, + ancienStatut, + raison: input.raison, + }); + + return { success: true }; + }), + + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const request = await db.getRequestById(input.id); + if (!request) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' }); + } + + const association = await db.getAssociationByUserId(ctx.user.id); + if (!association || request.associationId !== association.id) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Vous ne pouvez supprimer que vos propres demandes' }); + } + + if (request.status === 'validee' || request.status === 'refusee') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Une demande déjà traitée ne peut pas être supprimée' }); + } + + await db.deleteRequest(input.id); + + await logAdminAction(ctx.user.id, 'suppression_demande', 'request', input.id, { + titre: request.titre, + type: request.type, + status: request.status, + }); + + return { success: true }; + }), + }), + + // ============== REQUEST TEMPLATE ROUTES ============== + template: router({ + listAll: publicProcedure.query(async () => { + return db.getAllRequestTemplates(); + }), + + getById: publicProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + return db.getRequestTemplateById(input.id); + }), + + getByType: publicProcedure + .input(z.object({ type: z.string() })) + .query(async ({ input }) => { + return db.getRequestTemplateByType(input.type); + }), + + create: adminProcedure + .input(z.object({ + type: z.enum(['subvention_fonctionnement', 'subvention_projet', 'agrement_jeunesse_education', 'agrement_sport', 'autorisation_occupation', 'demande_salle', 'demande_materiel_evenementiel', 'autre']), + nom: z.string().min(1), + description: z.string().optional(), + formSchema: z.string(), + documentsRequis: z.array(z.string()).optional(), + serviceDestinataire: z.string().optional(), + emailDestinataire: z.string().email().optional(), + delaiTraitementJours: z.number().optional(), + })) + .mutation(async ({ ctx, input }) => { + const id = await db.createRequestTemplate({ + ...input, + documentsRequis: input.documentsRequis ? JSON.stringify(input.documentsRequis) : null, + }); + await logAdminAction(ctx.user.id, 'creation', 'template', id); + return { id }; + }), + + update: adminProcedure + .input(z.object({ + id: z.number(), + nom: z.string().optional(), + description: z.string().optional(), + formSchema: z.string().optional(), + documentsRequis: z.array(z.string()).optional(), + serviceDestinataire: z.string().optional(), + emailDestinataire: z.string().email().optional(), + delaiTraitementJours: z.number().optional(), + actif: z.boolean().optional(), + })) + .mutation(async ({ ctx, input }) => { + await db.updateRequestTemplate(input.id, { + ...input, + documentsRequis: input.documentsRequis ? JSON.stringify(input.documentsRequis) : undefined, + }); + await logAdminAction(ctx.user.id, 'modification', 'template', input.id); + return { success: true }; + }), + }), + + // ============== RESPONSE TEMPLATE ROUTES ============== + responseTemplate: router({ + listAll: adminProcedure.query(async () => { + return db.getAllResponseTemplates(); + }), + + getById: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + return db.getResponseTemplateById(input.id); + }), + + create: adminProcedure + .input(z.object({ + nom: z.string().min(1), + type: z.enum(['validation', 'refus', 'information_complementaire', 'autre']), + sujet: z.string().optional(), + contenu: z.string().min(1), + })) + .mutation(async ({ ctx, input }) => { + const id = await db.createResponseTemplate(input); + await logAdminAction(ctx.user.id, 'creation', 'response_template', id); + return { id }; + }), + + update: adminProcedure + .input(z.object({ + id: z.number(), + nom: z.string().optional(), + type: z.enum(['validation', 'refus', 'information_complementaire', 'autre']).optional(), + sujet: z.string().optional(), + contenu: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + await db.updateResponseTemplate(input.id, input); + await logAdminAction(ctx.user.id, 'modification', 'response_template', input.id); + return { success: true }; + }), + + delete: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + await db.deleteResponseTemplate(input.id); + await logAdminAction(ctx.user.id, 'suppression', 'response_template', input.id); + return { success: true }; + }), + }), + + operationalRecapService: router({ + listAll: logisticsProcedure.query(async () => { + // Répertoire utilisé pour l'assignation terrain et le suivi interne. + // On le réserve aux administrateurs logistiques et au super administrateur. + const services = await db.getAllOperationalRecapServices(); + return services.map(service => ({ + ...service, + usage: service.usage === "controle" ? "controle" : "terrain", + recipientEmails: parseStoredRecipientEmails(service.recipientEmails), + })); + }), + + getById: logisticsProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const service = await db.getOperationalRecapServiceById(input.id); + return serializeOperationalRecapService(service); + }), + + create: logisticsProcedure + .input(z.object({ + label: z.string().min(1), + usage: z.enum(["terrain", "controle"]), + description: z.string().optional(), + recipientEmails: z.array(z.string().email()).min(1), + actif: z.boolean().optional(), + })) + .mutation(async ({ ctx, input }) => { + const id = await db.createOperationalRecapService({ + label: input.label.trim(), + usage: input.usage, + description: input.description?.trim() || null, + recipientEmails: JSON.stringify(parseRecipients(input.recipientEmails)), + actif: input.actif ?? true, + }); + await logAdminAction(ctx.user.id, 'creation', 'operational_recap_service', id, { + label: input.label.trim(), + usage: input.usage, + recipientEmails: parseRecipients(input.recipientEmails), + }); + return { id }; + }), + + update: logisticsProcedure + .input(z.object({ + id: z.number(), + label: z.string().min(1).optional(), + usage: z.enum(["terrain", "controle"]).optional(), + description: z.string().optional(), + recipientEmails: z.array(z.string().email()).optional(), + actif: z.boolean().optional(), + })) + .mutation(async ({ ctx, input }) => { + await db.updateOperationalRecapService(input.id, { + ...(input.label !== undefined ? { label: input.label.trim() } : {}), + ...(input.usage !== undefined ? { usage: input.usage } : {}), + ...(input.description !== undefined ? { description: input.description.trim() || null } : {}), + ...(input.recipientEmails !== undefined ? { recipientEmails: JSON.stringify(parseRecipients(input.recipientEmails)) } : {}), + ...(input.actif !== undefined ? { actif: input.actif } : {}), + }); + await logAdminAction(ctx.user.id, 'modification', 'operational_recap_service', input.id, { + label: input.label?.trim(), + usage: input.usage, + recipientEmails: input.recipientEmails ? parseRecipients(input.recipientEmails) : undefined, + actif: input.actif, + }); + return { success: true }; + }), + + delete: logisticsProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + await db.deleteOperationalRecapService(input.id); + await logAdminAction(ctx.user.id, 'suppression', 'operational_recap_service', input.id); + return { success: true }; + }), + }), + + // ============== ADMIN USER MANAGEMENT ============== + adminUsers: router({ + listAll: superAdminProcedure.query(async () => { + return db.getAdminUsers(); + }), + + create: superAdminProcedure + .input(z.object({ + name: z.string().trim().min(2, "Le nom est obligatoire"), + email: z.string().trim().email("Adresse email invalide"), + password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères").optional(), + role: z.enum(['accueil', 'service_terrain', 'logistique_controle', 'admin', 'directrice', 'super_admin']), + canManageLogistics: z.boolean().optional(), + })) + .mutation(async ({ ctx, input }) => { + const normalizedEmail = input.email.trim().toLowerCase(); + const existingUser = await db.getUserByEmail(normalizedEmail); + + if (existingUser) { + const updatePayload: Record = { + name: input.name.trim(), + email: normalizedEmail, + role: input.role, + mfaEnabled: isInternalRole(input.role) ? true : existingUser.mfaEnabled, + canManageLogistics: + input.role === "super_admin" + || input.role === "logistique_controle" + ? true + : input.role === "admin" + ? Boolean(input.canManageLogistics) + : false, + delegatedSalleSignerUserId: null, + isActive: true, + }; + + if (existingUser.loginMethod === "local_jwt" || existingUser.loginMethod === "bootstrap_jwt" || existingUser.passwordHash) { + updatePayload.openId = normalizedEmail; + } + + await db.updateUser(existingUser.id, updatePayload); + + await logAdminAction(ctx.user.id, 'creation', 'user', existingUser.id, { + email: normalizedEmail, + role: input.role, + canManageLogistics: + input.role === "super_admin" + || input.role === "logistique_controle" + ? true + : input.role === "admin" + ? Boolean(input.canManageLogistics) + : false, + reusedExistingAccount: true, + }); + + return { success: true, id: existingUser.id, reusedExistingAccount: true }; + } + + const generatedPassword = randomBytes(24).toString("base64url"); + const createdUser = await registerLocalUser({ + name: input.name, + email: normalizedEmail, + password: input.password?.trim() || generatedPassword, + }); + + await db.updateUser(createdUser.id, { + name: input.name.trim(), + email: normalizedEmail, + role: input.role, + mfaEnabled: isInternalRole(input.role), + canManageLogistics: + input.role === "super_admin" + || input.role === "logistique_controle" + ? true + : input.role === "admin" + ? Boolean(input.canManageLogistics) + : false, + delegatedSalleSignerUserId: null, + isActive: true, + loginMethod: 'local_jwt', + }); + + await logAdminAction(ctx.user.id, 'creation', 'user', createdUser.id, { + email: normalizedEmail, + role: input.role, + canManageLogistics: + input.role === "super_admin" + || input.role === "logistique_controle" + ? true + : input.role === "admin" + ? Boolean(input.canManageLogistics) + : false, + reusedExistingAccount: false, + }); + + return { success: true, id: createdUser.id, reusedExistingAccount: false }; + }), + + update: superAdminProcedure + .input(z.object({ + id: z.number(), + name: z.string().trim().min(2, "Le nom est obligatoire"), + email: z.string().trim().email("Adresse email invalide"), + role: z.enum(['user', 'accueil', 'service_terrain', 'logistique_controle', 'admin', 'directrice', 'super_admin']), + isActive: z.boolean(), + canManageLogistics: z.boolean(), + password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères").optional(), + })) + .mutation(async ({ ctx, input }) => { + const existingUser = await db.getUserById(input.id); + if (!existingUser) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Utilisateur introuvable' }); + } + + if (input.id === ctx.user.id && (!input.isActive || input.role !== 'super_admin')) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Tu ne peux pas retirer tes propres droits super administrateur ni désactiver ton compte.', + }); + } + + if (existingUser.role === 'super_admin' && (input.role !== 'super_admin' || !input.isActive)) { + const adminUsers = await db.getAdminUsers(); + const activeSuperAdmins = adminUsers.filter((user) => user.role === 'super_admin' && user.isActive); + if (activeSuperAdmins.length <= 1 && activeSuperAdmins.some((user) => user.id === input.id)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Le dernier super administrateur actif ne peut pas être rétrogradé ou désactivé.', + }); + } + } + + const normalizedEmail = input.email.trim().toLowerCase(); + const existingByEmail = await db.getUserByEmail(normalizedEmail); + if (existingByEmail && existingByEmail.id !== input.id) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Cette adresse email est déjà utilisée par un autre compte.', + }); + } + + const updatePayload: Record = { + name: input.name.trim(), + email: normalizedEmail, + role: input.role, + mfaEnabled: isInternalRole(input.role) ? true : existingUser.mfaEnabled, + canManageLogistics: + input.role === "super_admin" + || input.role === "logistique_controle" + ? true + : input.role === "admin" + ? input.canManageLogistics + : false, + delegatedSalleSignerUserId: input.role === "directrice" ? existingUser.delegatedSalleSignerUserId ?? null : null, + isActive: input.isActive, + }; + + if (existingUser.loginMethod === 'local_jwt' || existingUser.loginMethod === 'bootstrap_jwt' || existingUser.passwordHash) { + updatePayload.openId = normalizedEmail; + } + + if (input.password?.trim()) { + updatePayload.passwordHash = await hashLocalPassword(input.password.trim()); + updatePayload.loginMethod = 'local_jwt'; + } + + await db.updateUser(input.id, updatePayload); + + await logAdminAction(ctx.user.id, 'modification', 'user', input.id, { + email: normalizedEmail, + role: input.role, + canManageLogistics: + input.role === "super_admin" + || input.role === "logistique_controle" + ? true + : input.role === "admin" + ? input.canManageLogistics + : false, + isActive: input.isActive, + passwordReset: Boolean(input.password?.trim()), + }); + + return { success: true }; + }), + + updateRole: superAdminProcedure + .input(z.object({ + id: z.number(), + role: z.enum(['user', 'accueil', 'service_terrain', 'logistique_controle', 'admin', 'directrice', 'super_admin']), + })) + .mutation(async ({ ctx, input }) => { + if (input.id === ctx.user.id && input.role !== 'super_admin') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Tu ne peux pas retirer tes propres droits super administrateur.', + }); + } + const existingUser = await db.getUserById(input.id); + if (!existingUser) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Utilisateur introuvable' }); + } + if (existingUser.role === 'super_admin' && input.role !== 'super_admin') { + const adminUsers = await db.getAdminUsers(); + const activeSuperAdmins = adminUsers.filter((user) => user.role === 'super_admin' && user.isActive); + if (activeSuperAdmins.length <= 1 && activeSuperAdmins.some((user) => user.id === input.id)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Le dernier super administrateur actif ne peut pas être rétrogradé.', + }); + } + } + await db.updateUser(input.id, { + role: input.role, + mfaEnabled: isInternalRole(input.role) ? true : existingUser.mfaEnabled, + canManageLogistics: + input.role === "super_admin" + || input.role === "logistique_controle" + ? true + : input.role === "admin" + ? existingUser.canManageLogistics + : false, + delegatedSalleSignerUserId: input.role === "directrice" ? existingUser.delegatedSalleSignerUserId ?? null : null, + }); + await logAdminAction(ctx.user.id, 'changement_role', 'user', input.id, { + role: input.role, + canManageLogistics: + input.role === "super_admin" + || input.role === "logistique_controle" + ? true + : input.role === "admin" + ? existingUser.canManageLogistics + : false, + }); + return { success: true }; + }), + + toggleActive: superAdminProcedure + .input(z.object({ + id: z.number(), + isActive: z.boolean(), + })) + .mutation(async ({ ctx, input }) => { + if (input.id === ctx.user.id && !input.isActive) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Tu ne peux pas désactiver ton propre compte.', + }); + } + const existingUser = await db.getUserById(input.id); + if (!existingUser) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Utilisateur introuvable' }); + } + if (existingUser.role === 'super_admin' && !input.isActive) { + const adminUsers = await db.getAdminUsers(); + const activeSuperAdmins = adminUsers.filter((user) => user.role === 'super_admin' && user.isActive); + if (activeSuperAdmins.length <= 1 && activeSuperAdmins.some((user) => user.id === input.id)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Le dernier super administrateur actif ne peut pas être désactivé.', + }); + } + } + await db.updateUser(input.id, { isActive: input.isActive }); + await logAdminAction(ctx.user.id, input.isActive ? 'activation' : 'desactivation', 'user', input.id); + return { success: true }; + }), + + delete: superAdminProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ ctx, input }) => { + if (input.id === ctx.user.id) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Tu ne peux pas supprimer ton propre compte.', + }); + } + + const existingUser = await db.getUserById(input.id); + if (!existingUser) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Utilisateur introuvable' }); + } + + if (existingUser.role === 'super_admin' && existingUser.isActive) { + const adminUsers = await db.getAdminUsers(); + const activeSuperAdmins = adminUsers.filter((user) => user.role === 'super_admin' && user.isActive); + if (activeSuperAdmins.length <= 1 && activeSuperAdmins.some((user) => user.id === input.id)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Le dernier super administrateur actif ne peut pas être supprimé.', + }); + } + } + + await db.deleteUser(input.id); + await logAdminAction(ctx.user.id, 'suppression', 'user', input.id, { + email: existingUser.email, + role: existingUser.role, + ipAddress: extractClientIp(ctx.req), + }); + return { success: true }; + }), + }), + + // ============== AUDIT LOG ============== + auditLog: router({ + list: adminProcedure + .input(z.object({ + userId: z.number().optional(), + entityType: z.string().optional(), + entityId: z.number().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + })) + .query(async ({ input }) => { + return db.getAuditLogs(input); + }), + }), + + compliance: router({ + getSnapshot: adminProcedure.query(async () => { + const [auditSummary, adminUsers, allUsers, operationalTourSettings, retentionReportRaw] = await Promise.all([ + db.getAuditLogs({ limit: 1 }), + db.getAdminUsers(), + db.getAllUsers(), + getOperationalTourSettings(), + db.getPortalSetting("retention.lastReport"), + ]); + + const activeAdmins = adminUsers.filter((user) => user.isActive); + const internalUsers = allUsers.filter((user) => isInternalRole(user.role)); + const activeInternalUsers = internalUsers.filter((user) => user.isActive); + const internalUsersWithTotp = activeInternalUsers.filter( + (user) => user.mfaMethod === "authenticator_app" && user.mfaTotpSecretEncrypted + ); + const superAdminUsers = activeInternalUsers.filter((user) => user.role === "super_admin"); + const superAdminUsersWithTotp = superAdminUsers.filter( + (user) => user.mfaMethod === "authenticator_app" && user.mfaTotpSecretEncrypted + ); + const retentionReport = retentionReportRaw ? JSON.parse(retentionReportRaw) as { + generatedAt?: string; + purgedUsers?: number; + pendingDeletionCount?: number; + purgedTotalCount?: number; + } : null; + const secondaryTarget = operationalTourSettings.targets.secondary.baseUrl.trim(); + const checks = { + technique: [ + buildComplianceItem( + "audit-log", + "Journalisation des actions sensibles", + auditSummary.total > 0 ? "implemented" : "partial", + auditSummary.total > 0 + ? "Le portail dispose d'un journal d'audit exploitable dans l'administration." + : "Le mecanisme existe mais aucun evenement n'a encore ete confirme dans l'historique.", + `Entrees constatees: ${auditSummary.total}.` + ), + buildComplianceItem( + "preprod", + "Separation production / preproduction", + secondaryTarget ? "implemented" : "partial", + secondaryTarget + ? "Une cible secondaire est configuree pour les controles." + : "La production est pilotee mais la cible secondaire doit encore etre renseignee." + ), + buildComplianceItem( + "password-hash", + "Hachage des mots de passe", + "partial", + "Le portail gere la migration de hachage et preferera Argon2id quand le runtime serveur le permet, avec repli securise sur scrypt pour l'environnement actuel.", + "Compatibilite legacy maintenue pour les anciens hachages scrypt." + ), + buildComplianceItem( + "mfa", + "Authentification multifacteur", + superAdminUsers.length === superAdminUsersWithTotp.length && activeInternalUsers.length > 0 + ? "implemented" + : activeInternalUsers.length > 0 + ? "partial" + : "missing", + "Le MFA est impose pour les roles internes. Authenticator est la methode cible, avec repli email encore tolere pour certains roles en phase transitoire.", + `Comptes internes actifs: ${activeInternalUsers.length}. Authenticator actif: ${internalUsersWithTotp.length}. Super administrateurs alignes TOTP: ${superAdminUsersWithTotp.length}/${superAdminUsers.length}.` + ), + buildComplianceItem( + "lockout", + "Blocage apres tentatives repeteees", + "implemented", + "Le portail verrouille temporairement un compte apres plusieurs echecs de connexion ou de code MFA." + ), + buildComplianceItem( + "backup-governance", + "Sauvegardes et restauration", + "partial", + "La politique de sauvegarde, la restauration et les objectifs RPO/RTO sont documentes. Les revues hors application et les tests trimestriels restent a tracer regulierement.", + "Strategie cible: 3-2-1, RPO 1 heure, RTO 4 heures." + ), + ], + juridique: [ + buildComplianceItem( + "privacy-pages", + "Pages legales de base publiees", + "implemented", + "La confidentialite, les mentions legales, les cookies et les CGU sont maintenant exposes dans le portail." + ), + buildComplianceItem( + "consent", + "Recueil de consentement explicite", + "implemented", + "Le portail journalise une validation explicite de confidentialite sur plusieurs parcours critiques." + ), + buildComplianceItem( + "retention", + "Politique de conservation formalisee", + "partial", + "Une politique de retention et une procedure de purge planifiee sont maintenant documentees. Le moteur de purge produit un rapport automatique minimal, mais la couverture complete par categorie reste a etendre." + , + retentionReport + ? `Dernier rapport retention: ${retentionReport.generatedAt || "n/a"} • purges du run: ${retentionReport.purgedUsers ?? 0} • comptes en attente: ${retentionReport.pendingDeletionCount ?? 0}.` + : "Aucun rapport automatique de retention n'a encore ete genere." + ), + buildComplianceItem( + "user-rights", + "Export et suppression en libre-service", + "implemented", + "L'espace utilisateur permet l'export JSON des donnees et une suppression planifiee avec purge differée a 30 jours." + ), + ], + organisation: [ + buildComplianceItem( + "roles", + "Gestion des roles internes", + activeAdmins.length > 0 ? "implemented" : "partial", + "Le portail segmente deja les acces par roles et habilitations internes.", + `Comptes internes actifs observes: ${activeAdmins.length}.` + ), + buildComplianceItem( + "incident-procedure", + "Procedure de gestion des incidents", + "partial", + "La procedure incidents est documentee avec classification, confinement, investigation, violation RGPD et revue post-incident. Elle doit encore etre adoptee et testee en exploitation." + ), + buildComplianceItem( + "register-processing", + "Registre des traitements", + "partial", + "Le portail dispose d'un socle de registre des traitements. Les traitements réels, responsables, durées, bases légales et justificatifs doivent encore être complétés et validés par la CCDS." + ), + buildComplianceItem( + "supplier-register", + "Registre des sous-traitants", + "partial", + "Le registre fournisseurs, le modele DPA et la politique de revue annuelle sont documentes. Les fournisseurs reels et justificatifs contractuels doivent encore etre completes." + ), + buildComplianceItem( + "mfa-policy", + "Politique MFA obligatoire par role", + "implemented", + "Une politique MFA interne explicite distingue les roles soumis a MFA obligatoire, la methode cible Authenticator et les phases de transition.", + "Super administrateur: Authenticator uniquement cible. Autres roles internes: MFA obligatoire avec bascule progressive vers TOTP." + ), + ], + }; + + const allChecks = [...checks.technique, ...checks.juridique, ...checks.organisation]; + const implementedCount = allChecks.filter((item) => item.status === "implemented").length; + const partialCount = allChecks.filter((item) => item.status === "partial").length; + const missingCount = allChecks.filter((item) => item.status === "missing").length; + + return { + generatedAt: new Date().toISOString(), + platform: { + totalUsers: allUsers.length, + activeInternalUsers: activeAdmins.length, + auditEntries: auditSummary.total, + secondaryConfigured: Boolean(secondaryTarget), + }, + mfaPolicy: { + targetMethod: "authenticator_app", + fallbackMethod: "email_otp", + internalRoles: activeInternalUsers.map((user) => user.role), + totpCoverage: { + activeInternalUsers: activeInternalUsers.length, + authenticatorEnabled: internalUsersWithTotp.length, + superAdmins: superAdminUsers.length, + superAdminsAligned: superAdminUsersWithTotp.length, + }, + phases: [ + "Phase 1 : MFA obligatoire pour tous les roles internes, Authenticator cible, email encore tolere hors super_admin.", + "Phase 2 : Authenticator obligatoire pour super_admin, admin et directrice.", + "Phase 3 : Authenticator obligatoire pour tous les roles internes.", + ], + }, + summary: { + implementedCount, + partialCount, + missingCount, + readinessLabel: + missingCount === 0 + ? "Socle avance mais encore a faire auditer" + : missingCount <= 3 + ? "Socle partiellement aligne" + : "Programme de mise en conformite a poursuivre", + }, + checks, + nextSteps: [ + "Initialiser Authenticator sur les comptes super_admin, admin et directrice avant enforcement complet en phase 2.", + "Renseigner les fournisseurs reels, signer les DPA et consigner la revue annuelle de chaque sous-traitant.", + "Etendre la retention automatisee aux autres categories documentaires et produire un rapport mensuel DPO.", + "Industrialiser les tests de restauration, les revues de sauvegarde et la revue post-incident hors application.", + ], + disclaimer: "Cette vue aide au pilotage interne. Elle ne remplace ni un audit juridique RGPD ni une certification ISO 27001.", + }; + }), + getOperations: adminProcedure.query(async () => { + const [operations, allUsers] = await Promise.all([ + getComplianceOperations(), + db.getAllUsers(), + ]); + + const legalHoldCount = allUsers.filter((user) => user.legalHold).length; + const pendingPurgeCount = allUsers.filter((user) => user.deletionRequestedAt && !user.purgedAt).length; + const purgedCount = allUsers.filter((user) => user.purgedAt).length; + const sortedBackupRecords = [...operations.backupRecords] + .filter((entry) => Boolean(entry.lastSuccessAt)) + .sort((a, b) => (b.lastSuccessAt || "").localeCompare(a.lastSuccessAt || "", "fr")); + const sortedRestoreTests = operations.restoreTests + .filter((entry) => entry.testedAt) + .sort((a, b) => (b.testedAt || "").localeCompare(a.testedAt || "", "fr")); + const latestBackupSuccess = sortedBackupRecords[0] || null; + const latestBackupFailure = [...operations.backupRecords] + .filter((entry) => entry.lastFailureAt) + .sort((a, b) => (b.lastFailureAt || "").localeCompare(a.lastFailureAt || "", "fr"))[0] || null; + const latestRestoreTest = sortedRestoreTests[0] || null; + const latestSuccessfulRestore = sortedRestoreTests.find((entry) => entry.result === "success") || null; + const latestBackupStatus = formatRecencyStatus(parseComplianceDate(latestBackupSuccess?.lastSuccessAt || null), 7); + const latestRestoreStatus = formatRecencyStatus(parseComplianceDate(latestRestoreTest?.testedAt || null), 90); + const proofCoverageCount = operations.backupRecords.filter((entry) => Boolean(entry.proofLabel || entry.proofUrl)).length; + const restoreProofCoverageCount = operations.restoreTests.filter((entry) => Boolean(entry.proofLabel)).length; + const confidenceStatus = + latestBackupStatus.status === "missing" || latestRestoreStatus.status === "missing" + ? "partial" + : latestBackupStatus.status === "warning" || latestRestoreStatus.status === "warning" + ? "warning" + : "ok"; + + return { + ...operations, + metrics: { + legalHoldCount, + pendingPurgeCount, + purgedCount, + }, + exploitation: { + latestBackupSuccess, + latestBackupFailure, + latestRestoreTest, + latestSuccessfulRestore, + latestBackupStatus, + latestRestoreStatus, + proofCoverageCount, + restoreProofCoverageCount, + confidenceStatus, + alerts: [ + operations.backupRecords.length === 0 + ? "Aucune preuve de sauvegarde n’est encore historisée dans le portail." + : null, + operations.restoreTests.length === 0 + ? "Aucun test de restauration n’est encore historisé dans le pilotage." + : null, + latestBackupStatus.status === "warning" && latestBackupStatus.ageDays !== null + ? `La dernière sauvegarde réussie visible date de ${latestBackupStatus.ageDays} jour(s).` + : null, + latestRestoreStatus.status === "warning" && latestRestoreStatus.ageDays !== null + ? `Le dernier test de restauration visible date de ${latestRestoreStatus.ageDays} jour(s).` + : null, + ].filter((value): value is string => Boolean(value)), + }, + }; + }), + bootstrapOperations: adminProcedure.mutation(async ({ ctx }) => { + const existing = await getComplianceOperations(); + const bootstrap = await buildComplianceBootstrapPayload(existing); + + const mergedSuppliers = mergeComplianceEntriesById(existing.suppliers, bootstrap.suppliers); + const mergedDpas = mergeComplianceEntriesById(existing.dpas, bootstrap.dpas); + const mergedEvidence = mergeComplianceEntriesById(existing.evidenceCenter, bootstrap.evidenceCenter); + + await Promise.all([ + db.setPortalSetting( + COMPLIANCE_SUPPLIERS_SETTING_KEY, + JSON.stringify(mergedSuppliers.entries.sort((a, b) => a.supplierName.localeCompare(b.supplierName, "fr"))), + "Registre fournisseurs et sous-traitants" + ), + db.setPortalSetting( + COMPLIANCE_DPA_SETTING_KEY, + JSON.stringify(mergedDpas.entries.sort((a, b) => a.supplierName.localeCompare(b.supplierName, "fr"))), + "Suivi des DPA et obligations sous-traitants" + ), + db.setPortalSetting( + COMPLIANCE_EVIDENCE_SETTING_KEY, + JSON.stringify(mergedEvidence.entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt, "fr"))), + "Centre de conformité et preuves d'audit" + ), + ]); + + const summary: ComplianceBootstrapSummary = { + suppliersAdded: mergedSuppliers.addedCount, + dpasAdded: mergedDpas.addedCount, + evidenceAdded: mergedEvidence.addedCount, + notes: bootstrap.notes, + }; + + await logAdminAction(ctx.user.id, "modification", "compliance_bootstrap", undefined, { + ...summary, + ipAddress: extractClientIp(ctx.req), + }); + + return summary; + }), + upsertSupplier: adminProcedure + .input(complianceSupplierSchema.omit({ id: true }).extend({ id: z.string().trim().optional() })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + const nextEntry = complianceSupplierSchema.parse({ + ...input, + id: input.id?.trim() || nanoid(10), + }); + const nextEntries = [ + nextEntry, + ...operations.suppliers.filter((entry) => entry.id !== nextEntry.id), + ].sort((a, b) => a.supplierName.localeCompare(b.supplierName, "fr")); + await db.setPortalSetting( + COMPLIANCE_SUPPLIERS_SETTING_KEY, + JSON.stringify(nextEntries), + "Registre fournisseurs et sous-traitants" + ); + await logAdminAction(ctx.user.id, "modification", "compliance_supplier", undefined, { + supplierId: nextEntry.id, + supplierName: nextEntry.supplierName, + ipAddress: extractClientIp(ctx.req), + }); + return nextEntry; + }), + deleteSupplier: adminProcedure + .input(z.object({ id: z.string().trim().min(1) })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + await db.setPortalSetting( + COMPLIANCE_SUPPLIERS_SETTING_KEY, + JSON.stringify(operations.suppliers.filter((entry) => entry.id !== input.id)), + "Registre fournisseurs et sous-traitants" + ); + await logAdminAction(ctx.user.id, "suppression", "compliance_supplier", undefined, { + supplierId: input.id, + ipAddress: extractClientIp(ctx.req), + }); + return { success: true }; + }), + upsertDpa: adminProcedure + .input(complianceDpaSchema.omit({ id: true }).extend({ id: z.string().trim().optional() })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + const nextEntry = complianceDpaSchema.parse({ + ...input, + id: input.id?.trim() || nanoid(10), + }); + const nextEntries = [ + nextEntry, + ...operations.dpas.filter((entry) => entry.id !== nextEntry.id), + ].sort((a, b) => a.supplierName.localeCompare(b.supplierName, "fr")); + await db.setPortalSetting( + COMPLIANCE_DPA_SETTING_KEY, + JSON.stringify(nextEntries), + "Suivi des DPA et obligations sous-traitants" + ); + await logAdminAction(ctx.user.id, "modification", "compliance_dpa", undefined, { + dpaId: nextEntry.id, + supplierName: nextEntry.supplierName, + status: nextEntry.status, + ipAddress: extractClientIp(ctx.req), + }); + return nextEntry; + }), + deleteDpa: adminProcedure + .input(z.object({ id: z.string().trim().min(1) })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + await db.setPortalSetting( + COMPLIANCE_DPA_SETTING_KEY, + JSON.stringify(operations.dpas.filter((entry) => entry.id !== input.id)), + "Suivi des DPA et obligations sous-traitants" + ); + await logAdminAction(ctx.user.id, "suppression", "compliance_dpa", undefined, { + dpaId: input.id, + ipAddress: extractClientIp(ctx.req), + }); + return { success: true }; + }), + upsertBackupRecord: adminProcedure + .input(complianceBackupRecordSchema.omit({ id: true }).extend({ id: z.string().trim().optional() })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + const nextEntry = complianceBackupRecordSchema.parse({ + ...input, + id: input.id?.trim() || nanoid(10), + }); + const backupRecords = [ + nextEntry, + ...operations.backupRecords.filter((entry) => entry.id !== nextEntry.id), + ].sort((a, b) => a.scope.localeCompare(b.scope, "fr")); + await saveComplianceBackups({ + backupRecords, + restoreTests: operations.restoreTests, + }); + await logAdminAction(ctx.user.id, "modification", "compliance_backup", undefined, { + backupId: nextEntry.id, + scope: nextEntry.scope, + ipAddress: extractClientIp(ctx.req), + }); + return nextEntry; + }), + deleteBackupRecord: adminProcedure + .input(z.object({ id: z.string().trim().min(1) })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + await saveComplianceBackups({ + backupRecords: operations.backupRecords.filter((entry) => entry.id !== input.id), + restoreTests: operations.restoreTests, + }); + await logAdminAction(ctx.user.id, "suppression", "compliance_backup", undefined, { + backupId: input.id, + ipAddress: extractClientIp(ctx.req), + }); + return { success: true }; + }), + upsertRestoreTest: adminProcedure + .input(complianceRestoreTestSchema.omit({ id: true }).extend({ id: z.string().trim().optional() })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + const nextEntry = complianceRestoreTestSchema.parse({ + ...input, + id: input.id?.trim() || nanoid(10), + }); + const restoreTests = [ + nextEntry, + ...operations.restoreTests.filter((entry) => entry.id !== nextEntry.id), + ].sort((a, b) => (b.testedAt || "").localeCompare(a.testedAt || "", "fr")); + await saveComplianceBackups({ + backupRecords: operations.backupRecords, + restoreTests, + }); + await logAdminAction(ctx.user.id, "modification", "compliance_restore_test", undefined, { + restoreTestId: nextEntry.id, + result: nextEntry.result, + ipAddress: extractClientIp(ctx.req), + }); + return nextEntry; + }), + deleteRestoreTest: adminProcedure + .input(z.object({ id: z.string().trim().min(1) })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + await saveComplianceBackups({ + backupRecords: operations.backupRecords, + restoreTests: operations.restoreTests.filter((entry) => entry.id !== input.id), + }); + await logAdminAction(ctx.user.id, "suppression", "compliance_restore_test", undefined, { + restoreTestId: input.id, + ipAddress: extractClientIp(ctx.req), + }); + return { success: true }; + }), + upsertRetentionReport: adminProcedure + .input(complianceRetentionReportSchema.omit({ id: true }).extend({ id: z.string().trim().optional() })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + const nextEntry = complianceRetentionReportSchema.parse({ + ...input, + id: input.id?.trim() || nanoid(10), + }); + const nextEntries = [ + nextEntry, + ...operations.retentionReports.filter((entry) => entry.id !== nextEntry.id && entry.generatedAt !== nextEntry.generatedAt), + ].sort((a, b) => (b.generatedAt || "").localeCompare(a.generatedAt || "", "fr")); + await db.setPortalSetting( + COMPLIANCE_RETENTION_HISTORY_SETTING_KEY, + JSON.stringify(nextEntries), + "Historique des rapports de retention et purge" + ); + await logAdminAction(ctx.user.id, "modification", "compliance_retention_report", undefined, { + reportId: nextEntry.id, + generatedAt: nextEntry.generatedAt, + ipAddress: extractClientIp(ctx.req), + }); + return nextEntry; + }), + deleteRetentionReport: adminProcedure + .input(z.object({ id: z.string().trim().min(1) })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + await db.setPortalSetting( + COMPLIANCE_RETENTION_HISTORY_SETTING_KEY, + JSON.stringify(operations.retentionReports.filter((entry) => entry.id !== input.id)), + "Historique des rapports de retention et purge" + ); + await logAdminAction(ctx.user.id, "suppression", "compliance_retention_report", undefined, { + reportId: input.id, + ipAddress: extractClientIp(ctx.req), + }); + return { success: true }; + }), + upsertEvidence: adminProcedure + .input(complianceEvidenceSchema.omit({ id: true }).extend({ id: z.string().trim().optional() })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + const nextEntry = complianceEvidenceSchema.parse({ + ...input, + id: input.id?.trim() || nanoid(10), + }); + const nextEntries = [ + nextEntry, + ...operations.evidenceCenter.filter((entry) => entry.id !== nextEntry.id), + ].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt, "fr")); + await db.setPortalSetting( + COMPLIANCE_EVIDENCE_SETTING_KEY, + JSON.stringify(nextEntries), + "Centre de conformité et preuves d'audit" + ); + await logAdminAction(ctx.user.id, "modification", "compliance_evidence", undefined, { + evidenceId: nextEntry.id, + category: nextEntry.category, + title: nextEntry.title, + ipAddress: extractClientIp(ctx.req), + }); + return nextEntry; + }), + deleteEvidence: adminProcedure + .input(z.object({ id: z.string().trim().min(1) })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + await db.setPortalSetting( + COMPLIANCE_EVIDENCE_SETTING_KEY, + JSON.stringify(operations.evidenceCenter.filter((entry) => entry.id !== input.id)), + "Centre de conformité et preuves d'audit" + ); + await logAdminAction(ctx.user.id, "suppression", "compliance_evidence", undefined, { + evidenceId: input.id, + ipAddress: extractClientIp(ctx.req), + }); + return { success: true }; + }), + upsertIsoDeliverable: adminProcedure + .input(complianceIsoDeliverableSchema.omit({ id: true }).extend({ id: z.string().trim().optional() })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + const nextEntry = complianceIsoDeliverableSchema.parse({ + ...input, + id: input.id?.trim() || nanoid(10), + }); + const nextEntries = [ + nextEntry, + ...operations.isoDeliverables.filter((entry) => entry.id !== nextEntry.id), + ].sort((a, b) => a.reference.localeCompare(b.reference, "fr")); + await db.setPortalSetting( + COMPLIANCE_ISO_DELIVERABLES_SETTING_KEY, + JSON.stringify(nextEntries), + "Pilotage des livrables ISO 27001 et validation interne" + ); + await logAdminAction(ctx.user.id, "modification", "compliance_iso_deliverable", undefined, { + deliverableId: nextEntry.id, + reference: nextEntry.reference, + status: nextEntry.status, + ipAddress: extractClientIp(ctx.req), + }); + return nextEntry; + }), + setIsoDeliverableStatus: adminProcedure + .input(z.object({ + id: z.string().trim().min(1), + status: z.enum(["a_rediger", "en_cours", "a_relire", "a_valider", "valide", "a_reviser"]), + approverName: z.string().trim().max(160).optional().or(z.literal("")), + approverEmail: z.string().trim().max(320).optional().or(z.literal("")), + note: z.string().trim().max(1000).optional().or(z.literal("")), + })) + .mutation(async ({ ctx, input }) => { + const operations = await getComplianceOperations(); + const existing = operations.isoDeliverables.find((entry) => entry.id === input.id); + if (!existing) { + throw new TRPCError({ code: "NOT_FOUND", message: "Livrable ISO introuvable" }); + } + + const now = new Date().toISOString(); + const nextEntry = complianceIsoDeliverableSchema.parse({ + ...existing, + status: input.status, + approverName: input.approverName || existing.approverName, + approverEmail: input.approverEmail || existing.approverEmail, + lastReviewedAt: now, + validatedAt: input.status === "valide" ? now : input.status === "a_reviser" ? "" : existing.validatedAt, + notes: [existing.notes, input.note].filter(Boolean).join(existing.notes && input.note ? "\n\n" : ""), + }); + + const nextEntries = [ + nextEntry, + ...operations.isoDeliverables.filter((entry) => entry.id !== nextEntry.id), + ].sort((a, b) => a.reference.localeCompare(b.reference, "fr")); + + await db.setPortalSetting( + COMPLIANCE_ISO_DELIVERABLES_SETTING_KEY, + JSON.stringify(nextEntries), + "Pilotage des livrables ISO 27001 et validation interne" + ); + await logAdminAction(ctx.user.id, "modification", "compliance_iso_deliverable_status", undefined, { + deliverableId: nextEntry.id, + reference: nextEntry.reference, + status: nextEntry.status, + ipAddress: extractClientIp(ctx.req), + }); + return nextEntry; + }), + }), + + // ============== NOTIFICATIONS ============== + notifications: router({ + getAll: accueilAdminProcedure + .input(z.object({ unreadOnly: z.boolean().optional() })) + .query(async ({ ctx, input }) => { + return db.getAdminNotifications(ctx.user.id, input.unreadOnly); + }), + + getUnreadCount: accueilAdminProcedure.query(async ({ ctx }) => { + return db.getUnreadNotificationCount(ctx.user.id); + }), + + markAsRead: accueilAdminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + await db.markNotificationAsRead(input.id); + return { success: true }; + }), + + markAllAsRead: accueilAdminProcedure.mutation(async ({ ctx }) => { + await db.markAllNotificationsAsRead(ctx.user.id); + return { success: true }; + }), + }), + + internalDirectory: router({ + get: salleReadProcedure.query(async () => { + const storedValue = await db.getPortalSetting(INTERNAL_DIRECTORY_SETTING_KEY); + if (!storedValue) { + return null; + } + + try { + return z.array(internalDirectoryDivisionSchema).parse(JSON.parse(storedValue)); + } catch (error) { + console.error("Failed to parse internal directory setting:", error); + return null; + } + }), + + set: superAdminProcedure + .input(z.object({ + divisions: z.array(internalDirectoryDivisionSchema).min(1).max(50), + })) + .mutation(async ({ ctx, input }) => { + await db.setPortalSetting( + INTERNAL_DIRECTORY_SETTING_KEY, + JSON.stringify(input.divisions), + "Annuaire interne CCDS editable depuis l'administration" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: INTERNAL_DIRECTORY_SETTING_KEY, + divisionCount: input.divisions.length, + ipAddress: extractClientIp(ctx.req), + }); + return { success: true }; + }), + }), + + // ============== PORTAL SETTINGS ============== + settings: router({ + getAppearance: publicProcedure.query(async () => { + const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY); + + if (!storedValue) { + return defaultPortalAppearance; + } + + try { + return sanitizePortalAppearance(JSON.parse(storedValue)); + } catch { + return defaultPortalAppearance; + } + }), + + getMail: adminProcedure.query(async () => { + return getAdminMailSettings(); + }), + + getAssociationMap: publicProcedure.query(async () => { + return getAssociationMapSettings(); + }), + + getUserOperatingTour: publicProcedure.query(async () => { + return getUserOperatingTourSettings(); + }), + + getLogistics: adminProcedure.query(async ({ ctx }) => { + assertLogisticsAccess(ctx.user); + return getLogisticsSettings(); + }), + + getLogisticsGroup: adminProcedure.query(async () => { + return getLogisticsGroupSettings(); + }), + + getSalleBilling: adminProcedure.query(async () => { + return getSalleBillingSettings(); + }), + + getHelloAsso: adminProcedure.query(async () => { + const settings = await getHelloAssoSettings(); + return serializeHelloAssoSettingsPublic(settings); + }), + + getAll: adminProcedure.query(async () => { + return db.getAllPortalSettings(); + }), + + get: adminProcedure + .input(z.object({ key: z.string() })) + .query(async ({ input }) => { + return db.getPortalSetting(input.key); + }), + + set: superAdminProcedure + .input(z.object({ + key: z.string(), + value: z.string(), + description: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + await db.setPortalSetting(input.key, input.value, input.description); + await logAdminAction(ctx.user.id, 'modification', 'setting', undefined, { key: input.key }); + return { success: true }; + }), + + setAssociationMap: superAdminProcedure + .input(z.object({ + publicStyleUrl: z.string().trim().url(), + adminStyleUrl: z.string().trim().url(), + })) + .mutation(async ({ ctx, input }) => { + const settings = sanitizeAssociationMapSettings(input); + await db.setPortalSetting( + ASSOCIATION_MAP_SETTING_KEY, + JSON.stringify(settings), + "Affichage cartographique MapLibre / tuiles vectorielles de l'annuaire public" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: ASSOCIATION_MAP_SETTING_KEY, + publicStyleUrl: settings.publicStyleUrl, + adminStyleUrl: settings.adminStyleUrl, + ipAddress: extractClientIp(ctx.req), + }); + return settings; + }), + + setUserOperatingTour: superAdminProcedure + .input(userOperatingTourSettingsSchema) + .mutation(async ({ ctx, input }) => { + const settings = userOperatingTourSettingsSchema.parse(input); + await db.setPortalSetting( + USER_OPERATING_TOUR_SETTING_KEY, + JSON.stringify(settings), + "Activation globale du tour guide utilisateur" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: USER_OPERATING_TOUR_SETTING_KEY, + enabled: settings.enabled, + ipAddress: extractClientIp(ctx.req), + }); + return settings; + }), + + setHelloAsso: superAdminProcedure + .input(z.object({ + enabled: z.boolean(), + clientId: z.string().trim().min(1).max(200), + clientSecret: z.string().trim().max(400).optional().default(""), + })) + .mutation(async ({ ctx, input }) => { + const saved = await saveHelloAssoSettings(input); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: HELLOASSO_SETTINGS_KEY, + enabled: saved.enabled, + clientId: saved.clientId, + clientSecretUpdated: Boolean(input.clientSecret?.trim()), + ipAddress: extractClientIp(ctx.req), + }); + return serializeHelloAssoSettingsPublic(saved); + }), + + setMail: superAdminProcedure + .input(z.object({ + smtpProvider: z.enum(supportedMailProviders), + smtpHost: z.string().optional(), + smtpPort: z.string().optional(), + smtpUser: z.string().optional(), + smtpFrom: z.string().email(), + smtpSecure: z.boolean().optional(), + smtpRequireTls: z.boolean().optional(), + smtpPass: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const saved = await saveAdminMailSettings( + { + smtpProvider: input.smtpProvider, + smtpHost: input.smtpHost, + smtpPort: input.smtpPort, + smtpUser: input.smtpUser, + smtpFrom: input.smtpFrom, + smtpSecure: input.smtpSecure, + smtpRequireTls: input.smtpRequireTls, + smtpPass: input.smtpPass, + }, + { id: ctx.user.id, name: ctx.user.name } + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: "system.mail.settings", + smtpProvider: input.smtpProvider, + smtpHost: input.smtpHost, + smtpPort: input.smtpPort, + smtpFrom: input.smtpFrom, + smtpUser: input.smtpUser, + smtpSecure: input.smtpSecure, + smtpRequireTls: input.smtpRequireTls, + passwordUpdated: Boolean(input.smtpPass?.trim()), + ipAddress: extractClientIp(ctx.req), + }); + return saved; + }), + + setLogistics: adminProcedure + .input(z.object({ + materialReturnLeadDays: z.number().int().min(1).max(30), + materialReturnGraceDays: z.number().int().min(0).max(30), + inventory: z.record(z.string(), z.number().int().min(0).nullable()).optional().default({}), + replacementValues: z.record(z.string(), z.number().int().min(0).nullable()).optional().default({}), + })) + .mutation(async ({ ctx, input }) => { + assertLogisticsAccess(ctx.user); + const saved = await saveLogisticsSettings({ + materialReturnLeadDays: input.materialReturnLeadDays, + materialReturnGraceDays: input.materialReturnGraceDays, + inventory: input.inventory as Partial>, + replacementValues: input.replacementValues as Partial>, + }); + + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: "system.logistics.settings", + materialReturnLeadDays: saved.materialReturnLeadDays, + materialReturnGraceDays: saved.materialReturnGraceDays, + inventory: saved.inventory, + replacementValues: saved.replacementValues, + ipAddress: extractClientIp(ctx.req), + }); + + return saved; + }), + + setLogisticsGroup: superAdminProcedure + .input(z.object({ + label: z.string().trim().min(1).max(120), + memberUserIds: z.array(z.number().int().positive()), + })) + .mutation(async ({ ctx, input }) => { + const saved = await saveLogisticsGroupSettings(input); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: "system.logistics.group", + label: saved.label, + memberUserIds: saved.memberUserIds, + ipAddress: extractClientIp(ctx.req), + }); + return saved; + }), + + sendTestMail: superAdminProcedure + .input(z.object({ + to: z.string().email(), + })) + .mutation(async ({ ctx, input }) => { + const result = await sendOperationalEmail({ + to: [input.to], + subject: "Test de configuration mail - Portail Associations", + text: [ + "Bonjour,", + "", + "Ceci est un email de test envoyé depuis l’espace d’administration du Portail Associations.", + `Configuration testée par : ${ctx.user.name || ctx.user.email || `Admin #${ctx.user.id}`}`, + `Date : ${new Date().toLocaleString("fr-FR")}`, + "", + "Si tu reçois ce message, la configuration SMTP du portail fonctionne.", + ].join("\n"), + replyTo: ctx.user.email || undefined, + fromName: ctx.user.name ? `${ctx.user.name} via Portail Associations` : "Portail Associations", + }); + + if (!result.sent) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: result.reason || "Impossible d’envoyer l’email de test", + }); + } + + await logAdminAction(ctx.user.id, "test", "setting", undefined, { + key: "system.mail.settings", + to: input.to, + ipAddress: extractClientIp(ctx.req), + }); + + return { success: true }; + }), + + setAppearance: superAdminProcedure + .input(appearanceSettingsSchema) + .mutation(async ({ ctx, input }) => { + const appearance = sanitizePortalAppearance(input); + await db.setPortalSetting( + APPEARANCE_SETTING_KEY, + JSON.stringify(appearance), + "Configuration visuelle du portail" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: APPEARANCE_SETTING_KEY, + ipAddress: extractClientIp(ctx.req), + }); + return appearance; + }), + + uploadAppearanceAsset: superAdminProcedure + .input(z.object({ + field: z.enum(appearanceAssetFields), + fileData: z.string(), + fileName: z.string(), + mimeType: z.string(), + })) + .mutation(async ({ ctx, input }) => { + const normalizedMimeType = input.mimeType.toLowerCase(); + const isImage = normalizedMimeType.startsWith("image/"); + const isIco = normalizedMimeType === "image/x-icon" || normalizedMimeType === "image/vnd.microsoft.icon"; + if (!isImage && !isIco) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier doit être une image compatible" }); + } + + const fileBuffer = Buffer.from(input.fileData, "base64"); + const storageName = `portal/appearance/${input.field}/${nanoid()}-${input.fileName}`; + const { url } = await storagePut(storageName, fileBuffer, input.mimeType); + + const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY); + let currentAppearance = defaultPortalAppearance; + if (storedValue) { + try { + currentAppearance = sanitizePortalAppearance(JSON.parse(storedValue)); + } catch { + currentAppearance = defaultPortalAppearance; + } + } + + const nextAppearance = sanitizePortalAppearance({ + ...currentAppearance, + [input.field]: url, + ...(input.field === "backgroundImageUrl" ? { backgroundEnabled: true } : {}), + }); + + await db.setPortalSetting( + APPEARANCE_SETTING_KEY, + JSON.stringify(nextAppearance), + "Configuration visuelle du portail" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: APPEARANCE_SETTING_KEY, + field: input.field, + ipAddress: extractClientIp(ctx.req), + }); + return nextAppearance; + }), + + uploadAppearanceTargetAsset: superAdminProcedure + .input(z.object({ + target: z.enum(appearanceBackgroundTargets), + fileData: z.string(), + fileName: z.string(), + mimeType: z.string(), + })) + .mutation(async ({ ctx, input }) => { + if (!input.mimeType.startsWith("image/")) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier doit etre une image" }); + } + + const fileBuffer = Buffer.from(input.fileData, "base64"); + const storageName = `portal/appearance/background-targets/${input.target}/${nanoid()}-${input.fileName}`; + const { url } = await storagePut(storageName, fileBuffer, input.mimeType); + + const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY); + let currentAppearance = defaultPortalAppearance; + if (storedValue) { + try { + currentAppearance = sanitizePortalAppearance(JSON.parse(storedValue)); + } catch { + currentAppearance = defaultPortalAppearance; + } + } + + const nextAppearance = sanitizePortalAppearance({ + ...currentAppearance, + backgroundEnabled: true, + backgroundTargets: Array.from(new Set([...currentAppearance.backgroundTargets, input.target])), + backgroundTargetImageUrls: { + ...(currentAppearance.backgroundTargetImageUrls || {}), + [input.target]: url, + }, + }); + + await db.setPortalSetting( + APPEARANCE_SETTING_KEY, + JSON.stringify(nextAppearance), + "Configuration visuelle du portail" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: APPEARANCE_SETTING_KEY, + field: `backgroundTargetImageUrls.${input.target}`, + ipAddress: extractClientIp(ctx.req), + }); + + return nextAppearance; + }), + + uploadAppearanceDecorationAsset: superAdminProcedure + .input(z.object({ + decorationId: z.string().trim().min(1).max(80), + fileData: z.string(), + fileName: z.string(), + mimeType: z.string(), + decoration: z.object({ + id: z.string().trim().min(1).max(80), + imageUrl: z.string().optional(), + scope: z.enum(appearanceDecorationScopes), + layer: z.enum(appearanceDecorationLayers), + widthPercent: z.number().min(6).max(38), + topPercent: z.number().min(0).max(100), + leftPercent: z.number().min(0).max(100), + opacity: z.number().min(4).max(28), + blurRadius: z.number().min(0).max(12), + cardAnchor: z.enum(appearanceCardDecorationAnchors), + cardInsetPercent: z.number().min(0).max(20), + }).optional(), + })) + .mutation(async ({ ctx, input }) => { + if (!input.mimeType.startsWith("image/")) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier doit être une image" }); + } + + const fileBuffer = Buffer.from(input.fileData, "base64"); + const storageName = `portal/appearance/decorations/${input.decorationId}/${nanoid()}-${input.fileName}`; + const { url } = await storagePut(storageName, fileBuffer, input.mimeType); + + const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY); + let currentAppearance = defaultPortalAppearance; + if (storedValue) { + try { + currentAppearance = sanitizePortalAppearance(JSON.parse(storedValue)); + } catch { + currentAppearance = defaultPortalAppearance; + } + } + + const currentDecorativeElements = currentAppearance.decorativeElements || []; + const hasExistingDecoration = currentDecorativeElements.some((entry) => entry.id === input.decorationId); + const nextDecorativeElements = hasExistingDecoration + ? currentDecorativeElements.map((entry) => + entry.id === input.decorationId ? { ...entry, imageUrl: url } : entry + ) + : input.decoration + ? [ + ...currentDecorativeElements, + { + ...input.decoration, + imageUrl: url, + }, + ] + : currentDecorativeElements; + + const nextAppearance = sanitizePortalAppearance({ + ...currentAppearance, + decorativeElements: nextDecorativeElements, + }); + + await db.setPortalSetting( + APPEARANCE_SETTING_KEY, + JSON.stringify(nextAppearance), + "Configuration visuelle du portail" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: APPEARANCE_SETTING_KEY, + field: `decorativeElements.${input.decorationId}.imageUrl`, + ipAddress: extractClientIp(ctx.req), + }); + + return nextAppearance; + }), + + uploadHeroImage: superAdminProcedure + .input(z.object({ + fileData: z.string(), + fileName: z.string(), + mimeType: z.string(), + })) + .mutation(async ({ ctx, input }) => { + if (!input.mimeType.startsWith("image/")) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier doit être une image" }); + } + + const fileBuffer = Buffer.from(input.fileData, "base64"); + const storageName = `portal/appearance/hero/${nanoid()}-${input.fileName}`; + const { url } = await storagePut(storageName, fileBuffer, input.mimeType); + + const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY); + let currentAppearance = defaultPortalAppearance; + if (storedValue) { + try { + currentAppearance = sanitizePortalAppearance(JSON.parse(storedValue)); + } catch { + currentAppearance = defaultPortalAppearance; + } + } + const nextAppearance = sanitizePortalAppearance({ + ...currentAppearance, + heroImageUrl: url, + }); + + await db.setPortalSetting( + APPEARANCE_SETTING_KEY, + JSON.stringify(nextAppearance), + "Configuration visuelle du portail" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: APPEARANCE_SETTING_KEY, + field: "heroImageUrl", + ipAddress: extractClientIp(ctx.req), + }); + return nextAppearance; + }), + + uploadSalleRib: adminProcedure + .input(z.object({ + fileData: z.string(), + fileName: z.string(), + mimeType: z.string(), + })) + .mutation(async ({ ctx, input }) => { + const allowedMimeTypes = new Set([ + "application/pdf", + "image/png", + "image/jpeg", + "image/webp", + ]); + if (!allowedMimeTypes.has(input.mimeType)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Le RIB doit être un PDF, PNG, JPG ou WebP" }); + } + + const fileBuffer = Buffer.from(input.fileData, "base64"); + const storageName = `portal/salle-billing/rib/${nanoid()}-${input.fileName}`; + const { url } = await storagePut(storageName, fileBuffer, input.mimeType); + const nextSettings = sanitizeSalleBillingSettings({ + ribDocumentUrl: url, + ribDocumentName: input.fileName, + ribMimeType: input.mimeType, + ribUploadedAt: new Date().toISOString(), + }); + + await db.setPortalSetting( + SALLE_BILLING_SETTING_KEY, + JSON.stringify(nextSettings), + "RIB MJS utilisé pour les factures salle" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: SALLE_BILLING_SETTING_KEY, + fileName: input.fileName, + mimeType: input.mimeType, + ipAddress: extractClientIp(ctx.req), + }); + return nextSettings; + }), + + clearSalleRib: adminProcedure + .mutation(async ({ ctx }) => { + const nextSettings = sanitizeSalleBillingSettings(null); + await db.setPortalSetting( + SALLE_BILLING_SETTING_KEY, + JSON.stringify(nextSettings), + "RIB MJS utilisé pour les factures salle" + ); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: SALLE_BILLING_SETTING_KEY, + cleared: true, + ipAddress: extractClientIp(ctx.req), + }); + return nextSettings; + }), + }), + + // ============== STATS ROUTES (ADMIN) ============== + stats: router({ + getDashboard: accueilAdminProcedure.query(async () => { + const stats = await db.getDashboardStats(); + if (!stats) { + return { + totalAssociations: 0, + activeAssociations: 0, + totalRequests: 0, + pendingRequests: 0, + validatedRequests: 0, + rejectedRequests: 0, + newAssociationsThisMonth: 0, + newRequestsThisMonth: 0, + requestsThisWeek: 0, + overdueRequests: 0, + acceptanceRate: 0, + requestsByStatus: {}, + }; + } + return stats; + }), + + getRequestsPerMonth: accueilAdminProcedure + .input(z.object({ months: z.number().optional() })) + .query(async ({ input }) => { + return db.getRequestsPerMonth(input.months || 12); + }), + + getAssociationsPerMonth: accueilAdminProcedure + .input(z.object({ months: z.number().optional() })) + .query(async ({ input }) => { + return db.getAssociationsPerMonth(input.months || 12); + }), + + getRequestsByType: accueilAdminProcedure.query(async () => { + return db.getRequestsByTypeStats(); + }), + + getAverageProcessingTime: accueilAdminProcedure.query(async () => { + return db.getAverageProcessingTime(); + }), + + getAutomationSettings: accueilAdminProcedure.query(async () => { + return getStatsAutomationSettings(); + }), + + getReportHistory: accueilAdminProcedure.query(async () => { + return getStatsReportHistory(); + }), + + updateAutomationSettings: adminProcedure + .input(z.object({ + enabled: z.boolean(), + recipientEmails: z.array(z.string().email()).default([]), + frequencies: z.object({ + weekly: z.boolean(), + month: z.boolean(), + quarter: z.boolean(), + semester: z.boolean(), + year: z.boolean(), + }), + })) + .mutation(async ({ ctx, input }) => { + const current = await getStatsAutomationSettings(); + const saved = await saveStatsAutomationSettings({ + ...current, + enabled: input.enabled, + recipientEmails: input.recipientEmails.map((entry) => normalizeEmail(entry)), + frequencies: input.frequencies, + }); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: STATS_AUTOMATION_SETTING_KEY, + enabled: saved.enabled, + frequencies: saved.frequencies, + recipientEmails: saved.recipientEmails, + ipAddress: extractClientIp(ctx.req), + }); + return saved; + }), + + generateReportNow: adminProcedure + .input(z.object({ + period: z.enum(["weekly", "month", "quarter", "semester", "year"]), + })) + .mutation(async ({ ctx, input }) => { + const analyticsPeriod = input.period === "weekly" ? "7d" : input.period; + const cycleKey = `manual-${input.period}-${new Date().toISOString()}`; + const result = await generateAndArchiveStatsReport({ + period: input.period, + analyticsPeriod, + generatedBy: "manual", + cycleKey, + now: new Date(), + }); + await db.createAdminNotification({ + type: "systeme", + titre: `Rapport statistiques généré`, + message: `Un rapport ${result.entry.periodLabel.toLowerCase()} a été généré manuellement depuis l’espace statistiques.`, + lien: "/admin?tab=analytics", + }); + await logAdminAction(ctx.user.id, "generation_rapport_statistiques", "setting", undefined, { + period: input.period, + cycleKey, + ipAddress: extractClientIp(ctx.req), + }); + return result.entry; + }), + + getReservationAnalytics: accueilAdminProcedure + .input(z.object({ + period: analyticsPeriodSchema.default("month"), + })) + .query(async ({ input }) => { + return computeReservationAnalytics(input.period); + }), + }), + + operationalTour: router({ + getSettings: adminProcedure.query(async () => { + const settings = await getOperationalTourSettings(); + return { + ...settings, + targets: { + production: { + ...settings.targets.production, + associationPassword: "", + accueilPassword: "", + adminPassword: "", + }, + secondary: { + ...settings.targets.secondary, + associationPassword: "", + accueilPassword: "", + adminPassword: "", + }, + }, + }; + }), + + saveSettings: superAdminProcedure + .input(z.object({ + defaultEnvironment: operationalTourEnvironmentSchema, + targets: z.object({ + production: z.object({ + baseUrl: z.string().trim(), + associationEmail: z.string().trim(), + associationPassword: z.string().trim(), + associationAuthMode: operationalTourAuthModeSchema, + accueilEmail: z.string().trim(), + accueilPassword: z.string().trim(), + accueilAuthMode: operationalTourAuthModeSchema, + adminEmail: z.string().trim(), + adminPassword: z.string().trim(), + adminAuthMode: operationalTourAuthModeSchema, + }), + secondary: z.object({ + baseUrl: z.string().trim(), + associationEmail: z.string().trim(), + associationPassword: z.string().trim(), + associationAuthMode: operationalTourAuthModeSchema, + accueilEmail: z.string().trim(), + accueilPassword: z.string().trim(), + accueilAuthMode: operationalTourAuthModeSchema, + adminEmail: z.string().trim(), + adminPassword: z.string().trim(), + adminAuthMode: operationalTourAuthModeSchema, + }), + }), + })) + .mutation(async ({ ctx, input }) => { + const current = await getOperationalTourSettings(); + const saved = await saveOperationalTourSettings({ + defaultEnvironment: input.defaultEnvironment, + targets: { + production: { + ...current.targets.production, + ...input.targets.production, + associationPassword: + input.targets.production.associationAuthMode === "oauth" + ? "" + : input.targets.production.associationPassword.trim() || current.targets.production.associationPassword, + accueilPassword: + input.targets.production.accueilAuthMode === "oauth" + ? "" + : input.targets.production.accueilPassword.trim() || current.targets.production.accueilPassword, + adminPassword: + input.targets.production.adminAuthMode === "oauth" + ? "" + : input.targets.production.adminPassword.trim() || current.targets.production.adminPassword, + }, + secondary: { + ...current.targets.secondary, + ...input.targets.secondary, + associationPassword: + input.targets.secondary.associationAuthMode === "oauth" + ? "" + : input.targets.secondary.associationPassword.trim() || current.targets.secondary.associationPassword, + accueilPassword: + input.targets.secondary.accueilAuthMode === "oauth" + ? "" + : input.targets.secondary.accueilPassword.trim() || current.targets.secondary.accueilPassword, + adminPassword: + input.targets.secondary.adminAuthMode === "oauth" + ? "" + : input.targets.secondary.adminPassword.trim() || current.targets.secondary.adminPassword, + }, + }, + }); + await logAdminAction(ctx.user.id, "modification", "setting", undefined, { + key: OPERATIONAL_TOUR_SETTINGS_KEY, + defaultEnvironment: saved.defaultEnvironment, + productionBaseUrl: saved.targets.production.baseUrl, + secondaryBaseUrl: saved.targets.secondary.baseUrl, + productionAssociationConfigured: Boolean(saved.targets.production.associationEmail && (saved.targets.production.associationAuthMode === "oauth" || saved.targets.production.associationPassword)), + secondaryAssociationConfigured: Boolean(saved.targets.secondary.associationEmail && (saved.targets.secondary.associationAuthMode === "oauth" || saved.targets.secondary.associationPassword)), + productionAccueilConfigured: Boolean(saved.targets.production.accueilEmail && (saved.targets.production.accueilAuthMode === "oauth" || saved.targets.production.accueilPassword)), + secondaryAccueilConfigured: Boolean(saved.targets.secondary.accueilEmail && (saved.targets.secondary.accueilAuthMode === "oauth" || saved.targets.secondary.accueilPassword)), + productionAdminConfigured: Boolean(saved.targets.production.adminEmail && (saved.targets.production.adminAuthMode === "oauth" || saved.targets.production.adminPassword)), + secondaryAdminConfigured: Boolean(saved.targets.secondary.adminEmail && (saved.targets.secondary.adminAuthMode === "oauth" || saved.targets.secondary.adminPassword)), + ipAddress: extractClientIp(ctx.req), + }); + return saved; + }), + + getHistory: adminProcedure.query(async () => { + return getOperationalTourHistory(); + }), + + getLatest: adminProcedure.query(async () => { + const history = await getOperationalTourHistory(); + return history[0] ?? null; + }), + + clearHistory: superAdminProcedure.mutation(async ({ ctx }) => { + const retained = await clearOperationalTourHistory(); + await logAdminAction(ctx.user.id, "nettoyage_historique_tour_operationnel", "setting", undefined, { + key: OPERATIONAL_TOUR_SETTINGS_KEY, + retainedReports: retained.map((report) => ({ + id: report.id, + environment: report.environment, + finishedAt: report.finishedAt, + status: report.status, + })), + ipAddress: extractClientIp(ctx.req), + }); + return retained; + }), + + run: adminProcedure + .input(z.object({ + environment: operationalTourEnvironmentSchema.optional(), + })) + .mutation(async ({ ctx, input }) => { + const settings = await getOperationalTourSettings(); + const environment = input.environment ?? settings.defaultEnvironment; + const report = await runOperationalTour({ + environment, + req: ctx.req, + currentUser: ctx.user, + }); + await logAdminAction(ctx.user.id, "tour_operationnel", "setting", undefined, { + key: OPERATIONAL_TOUR_SETTINGS_KEY, + environment, + status: report.status, + summary: report.summary, + ipAddress: extractClientIp(ctx.req), + }); + return report; + }), + }), +}); + +export type AppRouter = typeof appRouter; diff --git a/server/salleAdministrativeDecisionPdf.ts b/server/salleAdministrativeDecisionPdf.ts new file mode 100644 index 0000000..4a11871 --- /dev/null +++ b/server/salleAdministrativeDecisionPdf.ts @@ -0,0 +1,89 @@ +import PDFDocument from "pdfkit"; +import fs from "node:fs"; +import path from "node:path"; + +const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); + +function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { + if (!fs.existsSync(CCDS_LOGO_PATH)) return; + try { + doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" }); + } catch (error) { + console.warn("[SalleAdministrativeDecisionPdf] Impossible de charger le logo CCDS:", error); + } +} + +export async function generateSalleAdministrativeDecisionPdf(input: { + requestId: number; + decisionText: string; + signedAt?: string | Date | null; + signedByLabel?: string | null; +}) { + const pdfBuffer = await new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: "A4", + margins: { top: 42, bottom: 42, left: 42, right: 42 }, + }); + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + + const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + drawLogo(doc, doc.page.margins.left + (width - 90) / 2, y, 90, 90); + y += 102; + + doc.font("Helvetica-Bold").fontSize(18).fillColor("#0f2d63").text( + "DÉCISION ADMINISTRATIVE PRIORITAIRE", + doc.page.margins.left, + y, + { width, align: "center" } + ); + y += 22; + doc.font("Helvetica").fontSize(10).fillColor("#475569").text( + `Dossier salle #${input.requestId}`, + doc.page.margins.left, + y, + { width, align: "center" } + ); + y += 34; + + const bodyHeight = input.signedAt ? 360 : 420; + doc.roundedRect(doc.page.margins.left, y, width, bodyHeight, 10).stroke("#cbd5e1"); + doc.font("Helvetica").fontSize(11).fillColor("#1f2937").text( + input.decisionText, + doc.page.margins.left + 18, + y + 18, + { width: width - 36, lineGap: 4 } + ); + + if (input.signedAt) { + const signatureY = y + bodyHeight + 18; + doc.roundedRect(doc.page.margins.left, signatureY, width, 62, 8).fillAndStroke("#eef6ff", "#93c5fd"); + doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text( + "Visa de la Direction", + doc.page.margins.left + 16, + signatureY + 12, + { width: width - 32 } + ); + doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text( + `Validation électronique le ${new Date(input.signedAt).toLocaleString("fr-FR")}${input.signedByLabel ? ` par ${input.signedByLabel}` : ""}.`, + doc.page.margins.left + 16, + signatureY + 32, + { width: width - 32 } + ); + } + + doc.end(); + } catch (error) { + reject(error); + } + }); + + return { + buffer: pdfBuffer, + fileName: `decision-administrative-salle-${input.requestId}.pdf`, + }; +} diff --git a/server/salleConventionPdf.ts b/server/salleConventionPdf.ts new file mode 100644 index 0000000..63e4854 --- /dev/null +++ b/server/salleConventionPdf.ts @@ -0,0 +1,254 @@ +import PDFDocument from "pdfkit"; +import fs from "node:fs"; +import path from "node:path"; +import type { MaterialContractFinancialMode, MaterialContractStatus } from "./materialConventionPdf"; + +const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); + +type ContractRequestLike = { + id: number; + titre: string; + status: string; + formData?: string | null; +}; + +type ContractAssociationLike = { + nomAssociation?: string | null; + adresse?: string | null; + codePostal?: string | null; + ville?: string | null; + telephone?: string | null; + emailContact?: string | null; + nomRepresentant?: string | null; +} | null | undefined; + +type ContractDecision = { + financialMode: MaterialContractFinancialMode; + depositRequired: boolean; + depositAmountCents: number; + rentalAmountCents: number; + pricingNotes?: string | null; + contractStatus: MaterialContractStatus; + contractGeneratedAt?: string | Date | null; + contractValidatedByUserId?: number | null; +}; + +function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { + if (!fs.existsSync(CCDS_LOGO_PATH)) return; + try { + doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" }); + } catch (error) { + console.warn("[SalleConventionPdf] Impossible de charger le logo CCDS:", error); + } +} + +function formatDateFr(date: string | Date | null | undefined) { + if (!date) return "-"; + try { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return String(date); + } +} + +function formatCurrency(cents: number | null | undefined) { + return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format((cents || 0) / 100); +} + +function getFinancialModeLabel(mode: MaterialContractFinancialMode) { + switch (mode) { + case "gratuite": + return "Mise à disposition gratuite"; + case "gratuite_avec_caution": + return "Mise à disposition gratuite avec caution"; + case "location_payante": + return "Location payante"; + default: + return mode; + } +} + +function getScheduleSummary(formData: any) { + const dailySlots = Array.isArray(formData?.horairesParJour) + ? formData.horairesParJour.filter((slot: any) => slot?.date) + : []; + + if (Boolean(formData?.useDetailedSchedule) && dailySlots.length > 0) { + return dailySlots + .map((slot: any) => `${formatDateFr(slot.date)} : ${slot.heureDebut || "?"} - ${slot.heureFin || "?"}`) + .join(" | "); + } + + if (formData?.heureDebut || formData?.heureFin) { + return `${formData.heureDebut || "?"} - ${formData.heureFin || "?"}`; + } + + return "-"; +} + +export async function generateSalleConventionPdf(input: { + request: ContractRequestLike; + association?: ContractAssociationLike; + decision: ContractDecision; +}) { + const formData = (() => { + try { + return input.request.formData ? JSON.parse(input.request.formData) : {}; + } catch { + return {}; + } + })(); + + const sallesSelectionnees = Array.isArray(formData.sallesSelectionnees) && formData.sallesSelectionnees.length > 0 + ? formData.sallesSelectionnees.join(", ") + : "-"; + + const reservationStart = formData.dateReservation; + const reservationEnd = formData.dateFinReservation || formData.dateReservation; + const scheduleSummary = getScheduleSummary(formData); + + const pdfBuffer = await new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: "A4", + margins: { top: 38, bottom: 42, left: 42, right: 42 }, + info: { + Title: `Convention salle CCDS - dossier ${input.request.id}`, + Author: "Communauté de Communes Des Savanes", + Subject: "Convention de mise à disposition / location d'un local CCDS", + }, + }); + + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + + doc.lineWidth(2).strokeColor("#efb100").moveTo(doc.page.margins.left, y).lineTo(doc.page.margins.left + pageWidth, y).stroke(); + y += 16; + drawLogo(doc, doc.page.margins.left + (pageWidth - 84) / 2, y, 84, 84); + y += 92; + + doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 18; + doc.font("Helvetica-Bold").fontSize(17).fillColor("#0f172a").text( + "CONVENTION DE MISE À DISPOSITION / LOCATION", + doc.page.margins.left, + y, + { width: pageWidth, align: "center" } + ); + y += 20; + doc.font("Helvetica-Bold").fontSize(14).text("D'UN LOCAL - MAISON DE LA JEUNESSE DES SAVANES", doc.page.margins.left, y, { + width: pageWidth, + align: "center", + }); + y += 28; + + doc.roundedRect(doc.page.margins.left, y, pageWidth, 42, 8).fillAndStroke("#eef5ff", "#bfd2ef"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f2d63").text( + "Document généré à partir de la décision administrative CCDS", + doc.page.margins.left + 14, + y + 9, + { width: pageWidth - 28, align: "center" } + ); + doc.font("Helvetica").fontSize(9).fillColor("#334155").text( + `Dossier #${input.request.id} — statut contrat : ${input.decision.contractStatus}`, + doc.page.margins.left + 14, + y + 23, + { width: pageWidth - 28, align: "center" } + ); + y += 56; + + const infoRows: Array<[string, string]> = [ + ["Association", input.association?.nomAssociation || formData.nomAssociation || "-"], + ["Représentant", input.association?.nomRepresentant || formData.representantLegal || "-"], + ["Adresse", [input.association?.adresse, input.association?.codePostal, input.association?.ville].filter(Boolean).join(" ") || "-"], + ["Téléphone", input.association?.telephone || formData.telephoneAssociation || "-"], + ["Email", input.association?.emailContact || formData.emailAssociation || "-"], + ["Local réservé", sallesSelectionnees], + [ + "Période d'utilisation", + reservationStart || reservationEnd + ? `${formatDateFr(reservationStart)}${reservationEnd ? ` au ${formatDateFr(reservationEnd)}` : ""}` + : "-", + ], + ["Créneau / horaires", scheduleSummary], + ["Objet", formData.motifReservation || input.request.titre || "-"], + ]; + + for (const [label, value] of infoRows) { + doc.font("Helvetica-Bold").fontSize(9).fillColor("#334155").text(`${label} :`, doc.page.margins.left, y, { width: 140 }); + doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(value, doc.page.margins.left + 145, y - 1, { + width: pageWidth - 145, + }); + y += 18; + } + + y += 10; + doc.roundedRect(doc.page.margins.left, y, pageWidth, 108, 8).fillAndStroke("#f8fafc", "#cbd5e1"); + doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Conditions financières", doc.page.margins.left + 14, y + 12); + doc.font("Helvetica").fontSize(10).fillColor("#334155") + .text(`Régime retenu : ${getFinancialModeLabel(input.decision.financialMode)}`, doc.page.margins.left + 14, y + 34, { + width: pageWidth - 28, + }) + .text(`Montant de location : ${formatCurrency(input.decision.rentalAmountCents)}`, doc.page.margins.left + 14, y + 52, { + width: pageWidth - 28, + }) + .text( + `Caution : ${input.decision.depositRequired ? formatCurrency(input.decision.depositAmountCents) : "Aucune caution exigée"}`, + doc.page.margins.left + 14, + y + 70, + { width: pageWidth - 28 } + ); + y += 122; + + if (input.decision.pricingNotes?.trim()) { + doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Clauses / réserves spécifiques", doc.page.margins.left, y); + y += 16; + doc.roundedRect(doc.page.margins.left, y, pageWidth, 70, 8).stroke("#cbd5e1"); + doc.font("Helvetica").fontSize(10).fillColor("#334155").text(input.decision.pricingNotes.trim(), doc.page.margins.left + 12, y + 12, { + width: pageWidth - 24, + }); + y += 84; + } + + y += 8; + doc.font("Helvetica").fontSize(9).fillColor("#475569").text( + "Cette convention formalise les conditions administratives retenues par la CCDS pour la mise à disposition ou la location du local sollicité.", + doc.page.margins.left, + y, + { width: pageWidth } + ); + y += 36; + + const signatureWidth = (pageWidth - 16) / 2; + doc.roundedRect(doc.page.margins.left, y, signatureWidth, 84, 8).stroke("#cbd5e1"); + doc.roundedRect(doc.page.margins.left + signatureWidth + 16, y, signatureWidth, 84, 8).stroke("#cbd5e1"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a") + .text("Association emprunteuse", doc.page.margins.left + 12, y + 12, { width: signatureWidth - 24, align: "center" }) + .text("CCDS / DSU", doc.page.margins.left + signatureWidth + 28, y + 12, { width: signatureWidth - 24, align: "center" }); + doc.font("Helvetica").fontSize(9).fillColor("#64748b") + .text("Nom, qualité et signature", doc.page.margins.left + 12, y + 56, { width: signatureWidth - 24, align: "center" }) + .text("Visa administratif", doc.page.margins.left + signatureWidth + 28, y + 56, { width: signatureWidth - 24, align: "center" }); + + doc.end(); + } catch (error) { + reject(error); + } + }); + + return { + buffer: pdfBuffer, + fileName: `convention-salle-ccds-${input.request.id}.pdf`, + }; +} diff --git a/server/salleInvoicePdf.ts b/server/salleInvoicePdf.ts new file mode 100644 index 0000000..41a6e1b --- /dev/null +++ b/server/salleInvoicePdf.ts @@ -0,0 +1,150 @@ +import PDFDocument from "pdfkit"; +import fs from "node:fs"; +import path from "node:path"; +import type { SallePricingSummary } from "@shared/sallePricing"; + +const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); + +function formatCurrency(cents: number) { + return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(cents / 100); +} + +function formatDateFr(date: string | Date | null | undefined) { + if (!date) return "-"; + try { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return String(date); + } +} + +function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { + if (!fs.existsSync(CCDS_LOGO_PATH)) return; + try { + doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" }); + } catch (error) { + console.warn("[SalleInvoicePdf] Impossible de charger le logo CCDS:", error); + } +} + +export async function generateSalleInvoicePdf(input: { + requestId: number; + requestTitle: string; + associationName: string; + datesLabel: string; + pricing: SallePricingSummary; +}) { + const pdfBuffer = await new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: "A4", + margins: { top: 36, bottom: 42, left: 34, right: 34 }, + }); + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + + const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + + drawLogo(doc, doc.page.margins.left, y, 180, 70); + doc.font("Helvetica-Bold").fontSize(18).fillColor("#244f7a").text( + `Facture N° ${String(input.requestId).padStart(3, "0")}/${new Date().getFullYear()}`, + doc.page.margins.left, + y + 34, + { width, align: "right" } + ); + y += 82; + + const infoRows: Array<[string, string]> = [ + ["Date", formatDateFr(new Date())], + ["Référence dossier", `Réservation salle #${input.requestId}`], + ["Émis par", "Maison de la Jeunesse des Savanes - CCDS"], + ["Association", input.associationName], + ["Période", input.datesLabel], + ]; + + const labelWidth = 160; + infoRows.forEach(([label, value]) => { + doc.rect(doc.page.margins.left, y, labelWidth, 36).stroke("#d9d9d9"); + doc.rect(doc.page.margins.left + labelWidth, y, width - labelWidth, 36).stroke("#d9d9d9"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#404040").text(label, doc.page.margins.left + 10, y + 12, { width: labelWidth - 20 }); + doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text(value, doc.page.margins.left + labelWidth + 10, y + 12, { + width: width - labelWidth - 20, + }); + y += 36; + }); + + y += 26; + const col1 = width * 0.52; + const col2 = width * 0.13; + const col3 = width * 0.17; + const col4 = width - col1 - col2 - col3; + doc.rect(doc.page.margins.left, y, width, 40).fillAndStroke("#244f7a", "#244f7a"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#ffffff") + .text("DESCRIPTION", doc.page.margins.left + 14, y + 14, { width: col1 - 20 }) + .text("QTÉ", doc.page.margins.left + col1 + 10, y + 14, { width: col2 - 20, align: "center" }) + .text("PRIX UNITAIRE", doc.page.margins.left + col1 + col2 + 10, y + 14, { width: col3 - 20, align: "center" }) + .text("TOTAL", doc.page.margins.left + col1 + col2 + col3 + 10, y + 14, { width: col4 - 20, align: "center" }); + y += 40; + + input.pricing.lineItems.forEach((line) => { + const rowHeight = 88; + doc.rect(doc.page.margins.left, y, col1, rowHeight).stroke("#d9d9d9"); + doc.rect(doc.page.margins.left + col1, y, col2, rowHeight).stroke("#d9d9d9"); + doc.rect(doc.page.margins.left + col1 + col2, y, col3, rowHeight).stroke("#d9d9d9"); + doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, rowHeight).stroke("#d9d9d9"); + + doc.font("Helvetica-Bold").fontSize(11).fillColor("#1f2937").text( + `Location ${line.salleNom}`, + doc.page.margins.left + 14, + y + 12, + { width: col1 - 24 } + ); + doc.font("Helvetica").fontSize(10).fillColor("#334155").text( + `${line.categoryLabel}\n${input.datesLabel}\n${input.requestTitle}`, + doc.page.margins.left + 14, + y + 32, + { width: col1 - 24 } + ); + doc.font("Helvetica-Bold").fontSize(12).fillColor("#1f2937") + .text(String(line.quantity), doc.page.margins.left + col1, y + 34, { width: col2, align: "center" }) + .text(formatCurrency(line.unitAmountCents), doc.page.margins.left + col1 + col2, y + 34, { width: col3, align: "center" }) + .text(formatCurrency(line.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 34, { width: col4, align: "center" }); + y += rowHeight; + }); + + doc.rect(doc.page.margins.left, y, col1 + col2 + col3, 44).stroke("#244f7a"); + doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, 44).stroke("#244f7a"); + doc.font("Helvetica-Bold").fontSize(12).fillColor("#244f7a") + .text("TOTAL À RÉGLER", doc.page.margins.left, y + 14, { width: col1 + col2 + col3 - 16, align: "right" }) + .fillColor("#c2410c") + .text(formatCurrency(input.pricing.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 14, { width: col4, align: "center" }); + y += 60; + + doc.roundedRect(doc.page.margins.left, y, width, 110, 8).fillAndStroke("#f8fafc", "#cbd5e1"); + doc.font("Helvetica-Bold").fontSize(11).fillColor("#1f2937").text("Récapitulatif pour l’envoi final", doc.page.margins.left + 16, y + 14); + doc.font("Helvetica").fontSize(10).fillColor("#475569") + .text("Cette facture accompagne la validation finale de la réservation. Le règlement est attendu selon les modalités précisées par la Maison de la Jeunesse des Savanes.", doc.page.margins.left + 16, y + 36, { + width: width - 32, + }) + .text("RIB MJS : à joindre ou communiquer depuis l’accueil si nécessaire.", doc.page.margins.left + 16, y + 72, { + width: width - 32, + }); + + doc.end(); + } catch (error) { + reject(error); + } + }); + + return { + buffer: pdfBuffer, + fileName: `facture-salle-ccds-${input.requestId}.pdf`, + }; +} diff --git a/server/salleQuotePdf.ts b/server/salleQuotePdf.ts new file mode 100644 index 0000000..c7aff93 --- /dev/null +++ b/server/salleQuotePdf.ts @@ -0,0 +1,182 @@ +import PDFDocument from "pdfkit"; +import fs from "node:fs"; +import path from "node:path"; +import type { SallePricingSummary } from "@shared/sallePricing"; + +const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); + +function formatCurrency(cents: number) { + return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(cents / 100); +} + +function formatDateFr(date: string | Date | null | undefined) { + if (!date) return "-"; + try { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return String(date); + } +} + +function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { + if (!fs.existsSync(CCDS_LOGO_PATH)) return; + try { + doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" }); + } catch (error) { + console.warn("[SalleQuotePdf] Impossible de charger le logo CCDS:", error); + } +} + +function getScheduleSummary(formData: any) { + const dailySlots = Array.isArray(formData?.horairesParJour) + ? formData.horairesParJour.filter((slot: any) => slot?.date) + : []; + if (Boolean(formData?.useDetailedSchedule) && dailySlots.length > 0) { + const start = dailySlots[0]; + const end = dailySlots[dailySlots.length - 1]; + return `${formatDateFr(start.date)} au ${formatDateFr(end.date)}`; + } + return `${formData?.heureDebut || "?"} à ${formData?.heureFin || "?"}`; +} + +export async function generateSalleQuotePdf(input: { + requestId: number; + requestTitle: string; + formData: any; + association: { + nomAssociation?: string | null; + adresse?: string | null; + codePostal?: string | null; + ville?: string | null; + } | null | undefined; + pricing: SallePricingSummary; + signedAt?: string | Date | null; + signedByLabel?: string | null; +}) { + const pdfBuffer = await new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: "A4", + margins: { top: 36, bottom: 42, left: 34, right: 34 }, + }); + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + + const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; + let y = doc.page.margins.top; + + drawLogo(doc, doc.page.margins.left, y, 180, 70); + doc.font("Helvetica-Bold").fontSize(18).fillColor("#244f7a").text( + `Devis N° ${String(input.requestId).padStart(3, "0")}/${new Date().getFullYear()}`, + doc.page.margins.left, + y + 34, + { width, align: "right" } + ); + y += 82; + + const clientAddress = [input.association?.adresse, input.association?.codePostal, input.association?.ville].filter(Boolean).join(" "); + const infoRows: Array<[string, string]> = [ + ["Date", formatDateFr(new Date())], + ["Référence", `${String(input.requestId).padStart(3, "0")}/${new Date().getFullYear()}`], + ["Émis par", "Communauté de Communes Des Savanes"], + ["Client", input.association?.nomAssociation || input.formData.nomAssociation || "-"], + ["Adresse", clientAddress || "-"], + ]; + + const labelWidth = 160; + infoRows.forEach(([label, value]) => { + doc.rect(doc.page.margins.left, y, labelWidth, 36).stroke("#d9d9d9"); + doc.rect(doc.page.margins.left + labelWidth, y, width - labelWidth, 36).stroke("#d9d9d9"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#404040").text(label, doc.page.margins.left + 10, y + 12, { width: labelWidth - 20 }); + doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text(value, doc.page.margins.left + labelWidth + 10, y + 12, { + width: width - labelWidth - 20, + }); + y += 36; + }); + + y += 26; + const col1 = width * 0.54; + const col2 = width * 0.13; + const col3 = width * 0.15; + const col4 = width - col1 - col2 - col3; + doc.rect(doc.page.margins.left, y, width, 40).fillAndStroke("#244f7a", "#244f7a"); + doc.font("Helvetica-Bold").fontSize(10).fillColor("#ffffff") + .text("DESCRIPTION", doc.page.margins.left + 14, y + 14, { width: col1 - 20 }) + .text("QUANTITÉ", doc.page.margins.left + col1 + 10, y + 14, { width: col2 - 20, align: "center" }) + .text("PRIX UNITAIRE HT", doc.page.margins.left + col1 + col2 + 10, y + 8, { width: col3 - 20, align: "center" }) + .text("TOTAL TTC", doc.page.margins.left + col1 + col2 + col3 + 10, y + 14, { width: col4 - 20, align: "center" }); + y += 40; + + input.pricing.lineItems.forEach((line) => { + const rowHeight = 92; + doc.rect(doc.page.margins.left, y, col1, rowHeight).stroke("#d9d9d9"); + doc.rect(doc.page.margins.left + col1, y, col2, rowHeight).stroke("#d9d9d9"); + doc.rect(doc.page.margins.left + col1 + col2, y, col3, rowHeight).stroke("#d9d9d9"); + doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, rowHeight).stroke("#d9d9d9"); + doc.font("Helvetica-Bold").fontSize(11).fillColor("#1f2937").text( + `location de la salle "${line.salleNom.replace("Salle ", "")}"`, + doc.page.margins.left + 14, + y + 12, + { width: col1 - 24 } + ); + doc.font("Helvetica").fontSize(10).fillColor("#334155").text( + `${line.categoryLabel}\n${input.formData.dateReservation ? `Période du ${formatDateFr(input.formData.dateReservation)}${input.formData.dateFinReservation && input.formData.dateFinReservation !== input.formData.dateReservation ? ` au ${formatDateFr(input.formData.dateFinReservation)}` : ""}` : ""}\n${getScheduleSummary(input.formData)}`, + doc.page.margins.left + 14, + y + 34, + { width: col1 - 24 } + ); + doc.font("Helvetica-Bold").fontSize(12).fillColor("#1f2937") + .text(String(line.quantity), doc.page.margins.left + col1, y + 38, { width: col2, align: "center" }) + .text(formatCurrency(line.unitAmountCents), doc.page.margins.left + col1 + col2, y + 38, { width: col3, align: "center" }) + .text(formatCurrency(line.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 38, { width: col4, align: "center" }); + y += rowHeight; + }); + + doc.rect(doc.page.margins.left, y, col1 + col2 + col3, 44).stroke("#244f7a"); + doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, 44).stroke("#244f7a"); + doc.font("Helvetica-Bold").fontSize(12).fillColor("#244f7a") + .text("TOTAL TTC", doc.page.margins.left, y + 14, { width: col1 + col2 + col3 - 16, align: "right" }) + .fillColor("#c2410c") + .text(formatCurrency(input.pricing.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 14, { width: col4, align: "center" }); + y += 64; + + doc.font("Helvetica-Bold").fontSize(12).fillColor("#111827").text("Arrêter le présent devis à la somme de :", doc.page.margins.left, y); + y += 22; + doc.font("Helvetica").fontSize(11).fillColor("#374151").text(formatCurrency(input.pricing.totalAmountCents), doc.page.margins.left, y); + y += 48; + + doc.font("Helvetica-Bold").fontSize(10).fillColor("#244f7a").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, { width, align: "center" }); + y += 16; + doc.font("Helvetica").fontSize(10).fillColor("#475569").text("GESTION ADMINISTRATIVE ET FACTURATION", doc.page.margins.left, y, { width, align: "center" }); + + if (input.signedAt) { + y += 26; + doc.roundedRect(doc.page.margins.left, y, width, 56, 8).fillAndStroke("#eef6ff", "#93c5fd"); + doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text("Devis validé pour signature Direction", doc.page.margins.left + 16, y + 12, { + width: width - 32, + }); + doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text( + `Validation électronique le ${formatDateFr(input.signedAt)}${input.signedByLabel ? ` par ${input.signedByLabel}` : ""}.`, + doc.page.margins.left + 16, + y + 30, + { width: width - 32 } + ); + } + + doc.end(); + } catch (error) { + reject(error); + } + }); + + return { + buffer: pdfBuffer, + fileName: `devis-salle-ccds-${input.requestId}.pdf`, + }; +} diff --git a/server/salleReservationEmails.ts b/server/salleReservationEmails.ts new file mode 100644 index 0000000..a0bdd7c --- /dev/null +++ b/server/salleReservationEmails.ts @@ -0,0 +1,137 @@ +import { CONDITIONS_FINANCIERES_SALLE_TEXT, SALLE_FREQUENCY_LABELS, SALLE_USAGE_TYPE_LABELS, type SallePricingSummary } from "@shared/sallePricing"; + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function formatCurrency(cents: number) { + return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(cents / 100); +} + +export function generateSalleQuoteEmail(input: { + associationName: string; + requestTitle: string; + pricing: SallePricingSummary; + acceptUrl: string; + refuseUrl: string; + decisionText: string; +}) { + const total = formatCurrency(input.pricing.totalAmountCents); + const conditionLines = CONDITIONS_FINANCIERES_SALLE_TEXT.split("\n").filter(Boolean); + const decisionLines = input.decisionText.split("\n").filter(Boolean); + const isBilled = input.pricing.totalAmountCents > 0; + const subject = isBilled + ? `Validation du devis - ${input.requestTitle}` + : `Validation de la mise à disposition - ${input.requestTitle}`; + + const intro = isBilled + ? `Bonjour ${input.associationName},\n\nVous trouverez ci-joint le devis, les conditions financières et la décision administrative prioritaire relatives à votre demande de réservation.` + : `Bonjour ${input.associationName},\n\nVous trouverez ci-joint les conditions financières et la décision administrative prioritaire relatives à votre demande de réservation.`; + + const amountBlock = isBilled + ? `Type d'usage : ${SALLE_USAGE_TYPE_LABELS[input.pricing.usageType]} +Fréquence : ${SALLE_FREQUENCY_LABELS[input.pricing.frequency]} +Montant calculé automatiquement : ${total}` + : `Type d'usage : ${SALLE_USAGE_TYPE_LABELS[input.pricing.usageType]} +Fréquence : ${SALLE_FREQUENCY_LABELS[input.pricing.frequency]} +Mise à disposition sans facturation.`; + + const text = `${intro} + +${amountBlock} + +Pour poursuivre le traitement de votre dossier, merci d'utiliser l'un des liens suivants : +- J'accepte : ${input.acceptUrl} +- Je refuse : ${input.refuseUrl} + +Conditions financières : +${conditionLines.join("\n")} + +Décision administrative : +${decisionLines.join("\n")} + +Maison de la Jeunesse des Savanes - Mélissa ALVES`; + + const html = ` +
+

Bonjour ${escapeHtml(input.associationName)},

+

${escapeHtml(isBilled + ? "Vous trouverez ci-joint le devis, les conditions financières et la décision administrative prioritaire relatives à votre demande de réservation." + : "Vous trouverez ci-joint les conditions financières et la décision administrative prioritaire relatives à votre demande de réservation.")}

+
+

Type d'usage : ${escapeHtml(SALLE_USAGE_TYPE_LABELS[input.pricing.usageType])}

+

Fréquence : ${escapeHtml(SALLE_FREQUENCY_LABELS[input.pricing.frequency])}

+

${escapeHtml(isBilled ? "Montant calculé automatiquement :" : "Conditions financières :")} ${escapeHtml(isBilled ? total : "Mise à disposition sans facturation")}

+
+

Merci de nous confirmer votre position :

+ +

Si les boutons ne fonctionnent pas, utilisez directement ces liens :
${escapeHtml(input.acceptUrl)}
${escapeHtml(input.refuseUrl)}

+
+

Maison de la Jeunesse des Savanes - Mélissa ALVES

+
+ `; + + return { subject, text, html }; +} + +export function generateSalleFinalEmail(input: { + associationName: string; + datesLabel: string; + pricingTotalCents: number; + billed: boolean; +}) { + const subject = input.billed + ? `Réservation validée - location de salle` + : `Réservation validée - mise à disposition de salle`; + + const text = input.billed + ? `Bonjour ${input.associationName}, + +Votre demande de réservation d’un espace pour le ${input.datesLabel} a reçu un avis favorable. +Nous vous confirmons que votre événement se tiendra au sein de la Maison de la Jeunesse des Savanes « Mélissa ALVES ». + +Le montant de la location s’élève à ${formatCurrency(input.pricingTotalCents)} conformément à la tarification en vigueur et au devis validé. + +Nous vous rappelons que vous êtes seule responsable de tout ce qui se passera dans les locaux, à l’intérieur comme à l’extérieur, concernant les participants et le public durant la période de l’événement. + +Merci de bien vouloir nous transmettre une attestation de responsabilité civile couvrant cette journée, ainsi que le règlement du montant indiqué selon les modalités précisées dans le devis. + +Il est formellement interdit de manger à l’intérieur des espaces utilisés et nous vous demandons de veiller à ce que les lieux soient laissés aussi propres à la fin de l’événement qu’à votre arrivée. + +Vous trouverez en pièce jointe : +- le devis validé +- la décision administrative +- le formulaire signé +- la facture à régler + +Bien cordialement, +Maison de la Jeunesse des Savanes - Mélissa ALVES` + : `Bonjour ${input.associationName}, + +Votre demande de réservation d’un espace pour le ${input.datesLabel} a reçu un avis favorable. +Nous vous confirmons que votre événement se tiendra au sein de la Maison de la Jeunesse des Savanes « Mélissa ALVES ». + +Nous vous rappelons que vous êtes seule responsable de tout ce qui se passera dans les locaux, à l’intérieur comme à l’extérieur, concernant les participants et le public durant la période de l’événement. +Merci de bien vouloir nous transmettre une attestation de responsabilité civile couvrant cette journée. +Il est formellement interdit de manger à l’intérieur des espaces utilisés. +Nous vous demandons également de veiller à laisser les lieux propres et en bon état à la fin de votre événement. + +Vous trouverez en pièce jointe le formulaire validé et signé. + +Bien cordialement, +Maison de la Jeunesse des Savanes - Mélissa ALVES`; + + return { + subject, + text, + html: `
${escapeHtml(text)}
`, + }; +} diff --git a/server/salleReservationWorkflow.ts b/server/salleReservationWorkflow.ts new file mode 100644 index 0000000..b990e01 --- /dev/null +++ b/server/salleReservationWorkflow.ts @@ -0,0 +1,123 @@ +import { + buildDecisionAdministrativeText, + computeSallePricing, + CONDITIONS_FINANCIERES_SALLE_TEXT, + type SallePricingSummary, + type SalleReservationFrequency, + type SalleUsageType, + type SalleWorkflowData, + type SalleWorkflowDirectorStatus, + type SalleWorkflowQuoteStatus, +} from "@shared/sallePricing"; + +function formatDateFr(date: string | Date | null | undefined) { + if (!date) return "-"; + try { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return String(date); + } +} + +function getScheduleSummary(formData: any) { + const dailySlots = Array.isArray(formData?.horairesParJour) + ? formData.horairesParJour.filter((slot: any) => slot?.date) + : []; + + if (Boolean(formData?.useDetailedSchedule) && dailySlots.length > 0) { + return dailySlots + .map((slot: any) => `${formatDateFr(slot.date)} : ${slot.heureDebut || "?"} - ${slot.heureFin || "?"}`) + .join(" | "); + } + + if (formData?.heureDebut || formData?.heureFin) { + return `${formData.heureDebut || "?"} - ${formData.heureFin || "?"}`; + } + + return "-"; +} + +export function parseRequestFormData(rawFormData?: string | null) { + if (!rawFormData) return {}; + try { + return JSON.parse(rawFormData); + } catch { + return {}; + } +} + +export function getSalleWorkflowData(rawFormData?: string | null): SalleWorkflowData { + const formData = parseRequestFormData(rawFormData); + return formData?.salleWorkflow && typeof formData.salleWorkflow === "object" ? formData.salleWorkflow : {}; +} + +export function computeSalleWorkflowPricing(rawFormData?: string | null): SallePricingSummary | null { + const formData = parseRequestFormData(rawFormData); + const usageType = formData?.typeUsage as SalleUsageType | undefined; + const frequency = formData?.frequence as SalleReservationFrequency | undefined; + const sallesIds = Array.isArray(formData?.sallesIds) ? formData.sallesIds : []; + if (!usageType || !frequency || sallesIds.length === 0) { + return null; + } + + return computeSallePricing({ + sallesIds, + usageType, + frequency, + dateReservation: formData.dateReservation, + dateFinReservation: formData.dateFinReservation, + }); +} + +export function mergeSalleWorkflowData(rawFormData: string | null | undefined, workflowPatch: Partial) { + const formData = parseRequestFormData(rawFormData); + const currentWorkflow = getSalleWorkflowData(rawFormData); + formData.salleWorkflow = { + ...currentWorkflow, + ...workflowPatch, + }; + return JSON.stringify(formData); +} + +export function buildDefaultSalleWorkflowTexts(rawFormData?: string | null) { + const formData = parseRequestFormData(rawFormData); + const salles = Array.isArray(formData?.sallesSelectionnees) && formData.sallesSelectionnees.length > 0 + ? formData.sallesSelectionnees.join(", ") + : "-"; + const dates = formData?.dateReservation + ? `${formatDateFr(formData.dateReservation)}${formData?.dateFinReservation && formData.dateFinReservation !== formData.dateReservation ? ` au ${formatDateFr(formData.dateFinReservation)}` : ""}` + : "-"; + const horaires = getScheduleSummary(formData); + const association = formData?.nomAssociation || "-"; + + return { + conditionsFinancieresText: CONDITIONS_FINANCIERES_SALLE_TEXT, + decisionAdministrativeText: buildDecisionAdministrativeText({ + espace: salles, + dates, + horaires, + association, + }), + }; +} + +export function updateSalleWorkflowQuoteStatus(rawFormData: string | null | undefined, status: SalleWorkflowQuoteStatus) { + return mergeSalleWorkflowData(rawFormData, { + quoteStatus: status, + quoteRespondedAt: status === "accepte" || status === "refuse" ? new Date().toISOString() : undefined, + }); +} + +export function updateSalleWorkflowDirectorStatus(rawFormData: string | null | undefined, status: SalleWorkflowDirectorStatus) { + return mergeSalleWorkflowData(rawFormData, { + directorStatus: status, + directorTransmissionAt: status === "en_attente_signature" ? new Date().toISOString() : undefined, + directorSignedAt: status === "signee" ? new Date().toISOString() : undefined, + directorReturnComment: status === "en_attente_signature" ? "" : undefined, + directorReturnedAt: status === "en_attente_signature" ? "" : undefined, + }); +} diff --git a/server/salleSignatureDelegation.ts b/server/salleSignatureDelegation.ts new file mode 100644 index 0000000..3696462 --- /dev/null +++ b/server/salleSignatureDelegation.ts @@ -0,0 +1,102 @@ +import * as db from "./db"; + +const SALLE_SIGNATURE_DELEGATION_SETTING_KEY = "system.salle_signature.delegations"; + +type DirectriceDelegationEntry = { + delegateUserIds: number[]; +}; + +type SalleSignatureDelegationSettings = { + directriceDelegations: Record; +}; + +function sanitizeUserIds(values: unknown): number[] { + if (!Array.isArray(values)) return []; + return Array.from( + new Set( + values + .map((value) => Number(value)) + .filter((value) => Number.isInteger(value) && value > 0) + ) + ); +} + +function sanitizeSettings(value: unknown): SalleSignatureDelegationSettings { + if (!value || typeof value !== "object") { + return { directriceDelegations: {} }; + } + + const record = (value as { directriceDelegations?: unknown }).directriceDelegations; + if (!record || typeof record !== "object") { + return { directriceDelegations: {} }; + } + + const entries = Object.entries(record as Record).reduce>( + (acc, [directriceId, entry]) => { + const userId = Number(directriceId); + if (!Number.isInteger(userId) || userId <= 0) return acc; + const delegateUserIds = sanitizeUserIds((entry as { delegateUserIds?: unknown })?.delegateUserIds); + acc[String(userId)] = { delegateUserIds }; + return acc; + }, + {} + ); + + return { directriceDelegations: entries }; +} + +export async function getSalleSignatureDelegationSettings(): Promise { + const raw = await db.getPortalSetting(SALLE_SIGNATURE_DELEGATION_SETTING_KEY); + if (!raw) { + return { directriceDelegations: {} }; + } + + try { + return sanitizeSettings(JSON.parse(raw)); + } catch { + return { directriceDelegations: {} }; + } +} + +export async function saveSalleSignatureDelegationSettings(settings: SalleSignatureDelegationSettings) { + await db.setPortalSetting( + SALLE_SIGNATURE_DELEGATION_SETTING_KEY, + JSON.stringify(sanitizeSettings(settings)), + "Délégations de signature des demandes de salle" + ); +} + +export async function getDelegatedSignerIdsForDirectrice(directriceUserId: number) { + const settings = await getSalleSignatureDelegationSettings(); + return settings.directriceDelegations[String(directriceUserId)]?.delegateUserIds || []; +} + +export async function setDelegatedSignerIdsForDirectrice(directriceUserId: number, delegateUserIds: number[]) { + const settings = await getSalleSignatureDelegationSettings(); + settings.directriceDelegations[String(directriceUserId)] = { + delegateUserIds: sanitizeUserIds(delegateUserIds), + }; + await saveSalleSignatureDelegationSettings(settings); +} + +export async function addDelegatedSignerForDirectrice(directriceUserId: number, delegateUserId: number) { + const current = await getDelegatedSignerIdsForDirectrice(directriceUserId); + const next = Array.from(new Set([...current, delegateUserId])); + await setDelegatedSignerIdsForDirectrice(directriceUserId, next); + return next; +} + +export async function removeDelegatedSignerForDirectrice(directriceUserId: number, delegateUserId: number) { + const current = await getDelegatedSignerIdsForDirectrice(directriceUserId); + const next = current.filter((entry) => entry !== delegateUserId); + await setDelegatedSignerIdsForDirectrice(directriceUserId, next); + return next; +} + +export async function getDirectriceIdsDelegatingToUser(userId: number) { + const settings = await getSalleSignatureDelegationSettings(); + return Object.entries(settings.directriceDelegations) + .filter(([, entry]) => sanitizeUserIds(entry.delegateUserIds).includes(userId)) + .map(([directriceId]) => Number(directriceId)) + .filter((value) => Number.isInteger(value) && value > 0); +} diff --git a/server/socialAuth.ts b/server/socialAuth.ts new file mode 100644 index 0000000..43836d7 --- /dev/null +++ b/server/socialAuth.ts @@ -0,0 +1,281 @@ +import { randomBytes } from "node:crypto"; +import type { Express, Request, Response } from "express"; +import { parse as parseCookieHeader } from "cookie"; +import { createSessionToken, loginOAuthUser, setSessionCookie } from "./_core/auth"; +import { getSessionCookieOptions } from "./_core/cookies"; +import { ENV } from "./_core/env"; + +const GOOGLE_STATE_COOKIE = "oauth_state_google"; +const FACEBOOK_STATE_COOKIE = "oauth_state_facebook"; +const OAUTH_STATE_MAX_AGE_MS = 10 * 60 * 1000; + +function parseCookies(cookieHeader: string | undefined) { + if (!cookieHeader) return new Map(); + return new Map(Object.entries(parseCookieHeader(cookieHeader))); +} + +function buildBaseUrl(req: Request) { + if (ENV.appBaseUrl) { + return ENV.appBaseUrl.replace(/\/$/, ""); + } + + const forwardedProto = req.headers["x-forwarded-proto"]; + const forwardedHost = req.headers["x-forwarded-host"]; + const proto = typeof forwardedProto === "string" + ? forwardedProto.split(",")[0]?.trim() + : req.protocol; + const host = typeof forwardedHost === "string" + ? forwardedHost.split(",")[0]?.trim() + : req.get("host"); + + return `${proto || "http"}://${host}`; +} + +function getCallbackUrl(req: Request, provider: "google" | "facebook") { + return `${buildBaseUrl(req)}/auth/${provider}/callback`; +} + +function redirectToLogin(res: Response, message: string) { + res.redirect(`/login?authError=${encodeURIComponent(message)}`); +} + +function setOAuthStateCookie(req: Request, res: Response, name: string, value: string) { + res.cookie(name, value, { + ...getSessionCookieOptions(req), + maxAge: OAUTH_STATE_MAX_AGE_MS, + }); +} + +function clearOAuthStateCookie(req: Request, res: Response, name: string) { + res.clearCookie(name, { + ...getSessionCookieOptions(req), + maxAge: -1, + }); +} + +function readAndValidateState(req: Request, res: Response, cookieName: string, receivedState: string | null) { + const cookies = parseCookies(req.headers.cookie); + const expectedState = cookies.get(cookieName) ?? ""; + clearOAuthStateCookie(req, res, cookieName); + return Boolean(receivedState && expectedState && receivedState === expectedState); +} + +function createOAuthState() { + return randomBytes(24).toString("hex"); +} + +function isGoogleConfigured() { + return Boolean(ENV.googleClientId && ENV.googleClientSecret); +} + +function isFacebookConfigured() { + return Boolean(ENV.facebookAppId && ENV.facebookAppSecret); +} + +async function finishLogin(req: Request, res: Response, userData: { + provider: "google" | "facebook"; + providerUserId: string; + email: string; + name?: string | null; +}) { + const user = await loginOAuthUser(userData); + const token = await createSessionToken(user); + setSessionCookie(req, res, token); + res.redirect(user.role === "service_terrain" ? "/terrain" : "/dashboard"); +} + +export function getOAuthProviderStatus() { + return { + google: isGoogleConfigured(), + facebook: isFacebookConfigured(), + }; +} + +export function registerSocialAuthRoutes(app: Express) { + app.get("/auth/google/start", (req, res) => { + if (!isGoogleConfigured()) { + redirectToLogin(res, "Connexion Google non configurée"); + return; + } + + const state = createOAuthState(); + setOAuthStateCookie(req, res, GOOGLE_STATE_COOKIE, state); + + const params = new URLSearchParams({ + client_id: ENV.googleClientId, + redirect_uri: getCallbackUrl(req, "google"), + response_type: "code", + scope: "openid email profile", + state, + access_type: "online", + include_granted_scopes: "true", + prompt: "select_account", + }); + + res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`); + }); + + app.get("/auth/google/callback", async (req, res) => { + try { + if (!isGoogleConfigured()) { + redirectToLogin(res, "Connexion Google non configurée"); + return; + } + + const code = typeof req.query.code === "string" ? req.query.code : ""; + const state = typeof req.query.state === "string" ? req.query.state : null; + + if (!code || !readAndValidateState(req, res, GOOGLE_STATE_COOKIE, state)) { + redirectToLogin(res, "Connexion Google invalide ou expirée"); + return; + } + + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code, + client_id: ENV.googleClientId, + client_secret: ENV.googleClientSecret, + redirect_uri: getCallbackUrl(req, "google"), + grant_type: "authorization_code", + }), + }); + + if (!tokenResponse.ok) { + redirectToLogin(res, "Google a refusé la connexion"); + return; + } + + const tokenData = await tokenResponse.json() as { access_token?: string }; + if (!tokenData.access_token) { + redirectToLogin(res, "Jeton Google manquant"); + return; + } + + const userInfoResponse = await fetch("https://openidconnect.googleapis.com/v1/userinfo", { + headers: { + authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + if (!userInfoResponse.ok) { + redirectToLogin(res, "Impossible de récupérer le profil Google"); + return; + } + + const userInfo = await userInfoResponse.json() as { + sub?: string; + email?: string; + email_verified?: boolean; + name?: string; + }; + + if (!userInfo.sub || !userInfo.email || userInfo.email_verified === false) { + redirectToLogin(res, "Le compte Google doit fournir une adresse email vérifiée"); + return; + } + + await finishLogin(req, res, { + provider: "google", + providerUserId: userInfo.sub, + email: userInfo.email, + name: userInfo.name, + }); + } catch (error) { + console.error("[OAuth] Google callback failed:", error); + redirectToLogin(res, "Connexion Google impossible pour le moment"); + } + }); + + app.get("/auth/facebook/start", (req, res) => { + if (!isFacebookConfigured()) { + redirectToLogin(res, "Connexion Facebook non configurée"); + return; + } + + const state = createOAuthState(); + setOAuthStateCookie(req, res, FACEBOOK_STATE_COOKIE, state); + + const params = new URLSearchParams({ + client_id: ENV.facebookAppId, + redirect_uri: getCallbackUrl(req, "facebook"), + state, + scope: "email,public_profile", + }); + + res.redirect(`https://www.facebook.com/dialog/oauth?${params.toString()}`); + }); + + app.get("/auth/facebook/callback", async (req, res) => { + try { + if (!isFacebookConfigured()) { + redirectToLogin(res, "Connexion Facebook non configurée"); + return; + } + + const code = typeof req.query.code === "string" ? req.query.code : ""; + const state = typeof req.query.state === "string" ? req.query.state : null; + + if (!code || !readAndValidateState(req, res, FACEBOOK_STATE_COOKIE, state)) { + redirectToLogin(res, "Connexion Facebook invalide ou expirée"); + return; + } + + const tokenUrl = new URL("https://graph.facebook.com/oauth/access_token"); + tokenUrl.search = new URLSearchParams({ + client_id: ENV.facebookAppId, + client_secret: ENV.facebookAppSecret, + redirect_uri: getCallbackUrl(req, "facebook"), + code, + }).toString(); + + const tokenResponse = await fetch(tokenUrl); + if (!tokenResponse.ok) { + redirectToLogin(res, "Facebook a refusé la connexion"); + return; + } + + const tokenData = await tokenResponse.json() as { access_token?: string }; + if (!tokenData.access_token) { + redirectToLogin(res, "Jeton Facebook manquant"); + return; + } + + const profileUrl = new URL("https://graph.facebook.com/me"); + profileUrl.search = new URLSearchParams({ + fields: "id,name,email", + access_token: tokenData.access_token, + }).toString(); + + const profileResponse = await fetch(profileUrl); + if (!profileResponse.ok) { + redirectToLogin(res, "Impossible de récupérer le profil Facebook"); + return; + } + + const profile = await profileResponse.json() as { + id?: string; + name?: string; + email?: string; + }; + + if (!profile.id || !profile.email) { + redirectToLogin(res, "Le compte Facebook doit partager une adresse email"); + return; + } + + await finishLogin(req, res, { + provider: "facebook", + providerUserId: profile.id, + email: profile.email, + name: profile.name, + }); + } catch (error) { + console.error("[OAuth] Facebook callback failed:", error); + redirectToLogin(res, "Connexion Facebook impossible pour le moment"); + } + }); +} diff --git a/server/statsReportArtifacts.ts b/server/statsReportArtifacts.ts new file mode 100644 index 0000000..87a275f --- /dev/null +++ b/server/statsReportArtifacts.ts @@ -0,0 +1,231 @@ +import PDFDocument from "pdfkit"; +import * as XLSX from "xlsx"; + +type AnalyticsPayload = { + period: string; + periodLabel: string; + comparisonLabel: string; + startDate: string; + endDate: string; + summary: { + totalReservations: number; + salleReservations: number; + materialReservations: number; + occupancyRate: number; + activeAssociations: number; + totalMaterialUnits: number; + averageReservationsPerAssociation: number; + }; + associationUsage: Array<{ + associationName: string; + thematics: string[]; + totalReservations: number; + salleReservations: number; + materialReservations: number; + lastReservationAt: string; + }>; + roomOccupancy: Array<{ + roomName: string; + bookedDays: number; + occupancyRate: number; + }>; + materialUsage: Array<{ + label: string; + quantity: number; + requests: number; + }>; + history: Array<{ + type: string; + associationName: string; + title: string; + startDate: string; + endDate: string; + resources: string; + status: string; + createdAt: Date; + }>; +}; + +const requestTypeLabels: Record = { + demande_salle: "Demande salle", + demande_materiel_evenementiel: "Matériel événementiel", +}; + +const statusLabels: Record = { + brouillon: "Brouillon", + soumise: "Soumise", + en_cours_traitement: "En cours", + information_complementaire: "Info requise", + validee: "Validée", + refusee: "Refusée", + annulee: "Annulée", +}; + +function formatDateFr(value: string | Date | null | undefined) { + if (!value) return "-"; + try { + return new Date(value).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return String(value); + } +} + +function buildFileBaseName(data: AnalyticsPayload) { + return `rapport-statistiques-ccds-${data.period}-${data.endDate}`; +} + +export async function generateReservationAnalyticsPdf(data: AnalyticsPayload) { + const doc = new PDFDocument({ + size: "A4", + margin: 40, + info: { + Title: `Rapport statistiques CCDS - ${data.periodLabel}`, + Author: "Portail Associations CCDS", + }, + }); + + const chunks: Buffer[] = []; + doc.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + + const done = new Promise((resolve) => { + doc.on("end", () => resolve(Buffer.concat(chunks))); + }); + + doc.fontSize(20).fillColor("#000091").text("Rapport statistiques CCDS"); + doc.moveDown(0.3); + doc.fontSize(11).fillColor("#475569").text(`${data.periodLabel} - ${formatDateFr(data.startDate)} au ${formatDateFr(data.endDate)}`); + doc.moveDown(1); + + doc.fontSize(14).fillColor("#0f172a").text("Synthèse"); + doc.moveDown(0.5); + [ + ["Réservations totales", String(data.summary.totalReservations)], + ["Réservations de salles", String(data.summary.salleReservations)], + ["Réservations de matériel", String(data.summary.materialReservations)], + ["Taux d'occupation des salles", `${data.summary.occupancyRate}%`], + ["Associations utilisatrices", String(data.summary.activeAssociations)], + ["Unités matérielles demandées", String(data.summary.totalMaterialUnits)], + ["Fréquence moyenne par association", String(data.summary.averageReservationsPerAssociation)], + ].forEach(([label, value]) => { + doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text(`${label} : `, { continued: true }); + doc.font("Helvetica").text(value); + }); + + doc.moveDown(1); + doc.font("Helvetica-Bold").fontSize(14).text("Top associations"); + doc.moveDown(0.5); + data.associationUsage.slice(0, 10).forEach((entry) => { + doc.fontSize(10).font("Helvetica-Bold").text(entry.associationName); + doc.font("Helvetica").text( + `Catégories: ${entry.thematics.join(" / ") || "Non renseignée"} • Total: ${entry.totalReservations} • Salles: ${entry.salleReservations} • Matériel: ${entry.materialReservations} • Dernière réservation: ${formatDateFr(entry.lastReservationAt)}` + ); + doc.moveDown(0.4); + }); + + doc.addPage(); + doc.font("Helvetica-Bold").fontSize(14).text("Occupation des salles"); + doc.moveDown(0.5); + data.roomOccupancy.forEach((entry) => { + doc.fontSize(10).font("Helvetica-Bold").text(entry.roomName, { continued: true }); + doc.font("Helvetica").text(` - ${entry.bookedDays} jour(s) réservé(s), ${entry.occupancyRate}% d'occupation`); + }); + + doc.moveDown(1); + doc.font("Helvetica-Bold").fontSize(14).text("Matériels les plus demandés"); + doc.moveDown(0.5); + data.materialUsage.forEach((entry) => { + doc.fontSize(10).font("Helvetica-Bold").text(entry.label, { continued: true }); + doc.font("Helvetica").text(` - Quantité: ${entry.quantity} • Dossiers: ${entry.requests}`); + }); + + doc.moveDown(1); + doc.font("Helvetica-Bold").fontSize(14).text("Historique récent"); + doc.moveDown(0.5); + data.history.slice(0, 20).forEach((entry) => { + const period = entry.startDate === entry.endDate + ? formatDateFr(entry.startDate) + : `${formatDateFr(entry.startDate)} au ${formatDateFr(entry.endDate)}`; + doc.fontSize(10).font("Helvetica-Bold").text(`${requestTypeLabels[entry.type] || entry.type} - ${entry.associationName}`); + doc.font("Helvetica").text(`${entry.title} • ${period} • ${entry.resources || "-"} • ${statusLabels[entry.status] || entry.status}`); + doc.moveDown(0.35); + }); + + doc.end(); + const buffer = await done; + return { + buffer, + fileName: `${buildFileBaseName(data)}.pdf`, + contentType: "application/pdf", + }; +} + +export function generateReservationAnalyticsExcel(data: AnalyticsPayload) { + const workbook = XLSX.utils.book_new(); + + const summaryRows = [ + ["Rapport statistiques CCDS", ""], + ["Période", `${data.periodLabel} (${data.startDate} au ${data.endDate})`], + ["Comparaison", data.comparisonLabel], + [], + ["Indicateur", "Valeur"], + ["Réservations totales", data.summary.totalReservations], + ["Réservations de salles", data.summary.salleReservations], + ["Réservations de matériel", data.summary.materialReservations], + ["Taux d'occupation des salles", `${data.summary.occupancyRate}%`], + ["Associations utilisatrices", data.summary.activeAssociations], + ["Unités matérielles demandées", data.summary.totalMaterialUnits], + ["Fréquence moyenne par association", data.summary.averageReservationsPerAssociation], + ]; + + const associationRows = [ + ["Association", "Catégories", "Total", "Salles", "Matériel", "Dernière réservation"], + ...data.associationUsage.map((item) => [ + item.associationName, + item.thematics.join(" / "), + item.totalReservations, + item.salleReservations, + item.materialReservations, + item.lastReservationAt, + ]), + ]; + + const roomRows = [ + ["Salle", "Jours réservés", "Taux d'occupation"], + ...data.roomOccupancy.map((item) => [item.roomName, item.bookedDays, `${item.occupancyRate}%`]), + ]; + + const materialRows = [ + ["Matériel", "Quantité", "Dossiers"], + ...data.materialUsage.map((item) => [item.label, item.quantity, item.requests]), + ]; + + const historyRows = [ + ["Type", "Association", "Dossier", "Début", "Fin", "Ressources", "Statut"], + ...data.history.map((item) => [ + requestTypeLabels[item.type] || item.type, + item.associationName, + item.title, + item.startDate, + item.endDate, + item.resources, + statusLabels[item.status] || item.status, + ]), + ]; + + XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(summaryRows), "Synthèse"); + XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(associationRows), "Associations"); + XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(roomRows), "Salles"); + XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(materialRows), "Matériel"); + XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(historyRows), "Historique"); + + const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer; + return { + buffer, + fileName: `${buildFileBaseName(data)}.xlsx`, + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }; +} diff --git a/server/storage.ts b/server/storage.ts new file mode 100644 index 0000000..2c5ca33 --- /dev/null +++ b/server/storage.ts @@ -0,0 +1,39 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +function normalizeKey(relKey: string): string { + return relKey + .replace(/^\/+/, "") + .split("/") + .filter(Boolean) + .map(segment => segment.replace(/[^a-zA-Z0-9._-]/g, "_")) + .join("/"); +} + +function getUploadRoot() { + return path.resolve(process.cwd(), "uploads"); +} + +function getPublicUrl(key: string) { + return `/uploads/${key}`; +} + +export async function storagePut( + relKey: string, + data: Buffer | Uint8Array | string, + contentType = "application/octet-stream" +): Promise<{ key: string; url: string }> { + const key = normalizeKey(relKey); + const filePath = path.join(getUploadRoot(), key); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, data); + return { key, url: getPublicUrl(key) }; +} + +export async function storageGet(relKey: string): Promise<{ key: string; url: string; }> { + const key = normalizeKey(relKey); + return { + key, + url: getPublicUrl(key), + }; +} diff --git a/server/templates/fiche_etat_des_lieux_officielle.pdf b/server/templates/fiche_etat_des_lieux_officielle.pdf new file mode 100644 index 0000000..9593187 Binary files /dev/null and b/server/templates/fiche_etat_des_lieux_officielle.pdf differ diff --git a/shared/_core/errors.ts b/shared/_core/errors.ts new file mode 100644 index 0000000..15fa7ed --- /dev/null +++ b/shared/_core/errors.ts @@ -0,0 +1,19 @@ +/** + * Base HTTP error class with status code. + * Throw this from route handlers to send specific HTTP errors. + */ +export class HttpError extends Error { + constructor( + public statusCode: number, + message: string + ) { + super(message); + this.name = "HttpError"; + } +} + +// Convenience constructors +export const BadRequestError = (msg: string) => new HttpError(400, msg); +export const UnauthorizedError = (msg: string) => new HttpError(401, msg); +export const ForbiddenError = (msg: string) => new HttpError(403, msg); +export const NotFoundError = (msg: string) => new HttpError(404, msg); diff --git a/shared/appearance.ts b/shared/appearance.ts new file mode 100644 index 0000000..7026120 --- /dev/null +++ b/shared/appearance.ts @@ -0,0 +1,764 @@ +export const appearanceThemeModes = ["light", "dark", "system"] as const; +export const appearanceFontFamilies = ["marianne", "system", "rounded", "serif"] as const; +export const appearanceCardStyles = ["soft", "outlined", "elevated"] as const; +export const appearanceTableStyles = ["comfortable", "compact", "striped"] as const; +export const appearanceDisplayDensities = ["comfortable", "compact", "detailed"] as const; +export const appearanceSidebarStyles = ["soft", "contrast", "floating"] as const; +export const appearanceButtonStyles = ["rounded", "pill", "square"] as const; +export const appearanceInputStyles = ["outline", "soft", "filled"] as const; +export const appearanceBackgroundModes = ["cover", "repeat", "fixed"] as const; +export const appearanceBackgroundPresentationModes = ["full", "edge_frame"] as const; +export const appearanceDecorationScopes = [ + "all", + "home", + "public", + "public_directory", + "public_map", + "dashboard", + "dashboard_home", + "dashboard_profile", + "dashboard_documents", + "dashboard_requests", + "dashboard_new_request", + "dashboard_reservation_salle", + "dashboard_material", + "dashboard_directory", + "admin", + "admin_pilotage", + "admin_logistics", + "admin_directory", + "admin_compliance", +] as const; +export const appearanceDecorationLayers = ["background", "card"] as const; +export const appearanceCardDecorationAnchors = ["top_left", "top_right", "bottom_left", "bottom_right"] as const; +export const appearanceBackgroundPositions = [ + "center", + "top", + "bottom", + "left", + "right", + "top_left", + "top_right", + "bottom_left", + "bottom_right", +] as const; +export const appearanceBackgroundTargets = [ + "home", + "public", + "public_directory", + "public_map", + "dashboard", + "dashboard_home", + "dashboard_profile", + "dashboard_documents", + "dashboard_requests", + "dashboard_new_request", + "dashboard_reservation_salle", + "dashboard_material", + "dashboard_directory", + "admin", + "admin_pilotage", + "admin_logistics", + "admin_directory", + "admin_compliance", +] as const; +export const appearanceAssetFields = ["logoUrl", "faviconUrl", "heroImageUrl", "backgroundImageUrl"] as const; + +export type AppearanceThemeMode = (typeof appearanceThemeModes)[number]; +export type AppearanceFontFamily = (typeof appearanceFontFamilies)[number]; +export type AppearanceCardStyle = (typeof appearanceCardStyles)[number]; +export type AppearanceTableStyle = (typeof appearanceTableStyles)[number]; +export type AppearanceDisplayDensity = (typeof appearanceDisplayDensities)[number]; +export type AppearanceSidebarStyle = (typeof appearanceSidebarStyles)[number]; +export type AppearanceButtonStyle = (typeof appearanceButtonStyles)[number]; +export type AppearanceInputStyle = (typeof appearanceInputStyles)[number]; +export type AppearanceBackgroundMode = (typeof appearanceBackgroundModes)[number]; +export type AppearanceBackgroundPresentationMode = (typeof appearanceBackgroundPresentationModes)[number]; +export type AppearanceDecorationScope = (typeof appearanceDecorationScopes)[number]; +export type AppearanceDecorationLayer = (typeof appearanceDecorationLayers)[number]; +export type AppearanceCardDecorationAnchor = (typeof appearanceCardDecorationAnchors)[number]; +export type AppearanceBackgroundPosition = (typeof appearanceBackgroundPositions)[number]; +export type AppearanceBackgroundTarget = (typeof appearanceBackgroundTargets)[number]; +export type AppearanceAssetField = (typeof appearanceAssetFields)[number]; + +export interface PortalAppearanceDecoration { + id: string; + imageUrl: string; + scope: AppearanceDecorationScope; + layer: AppearanceDecorationLayer; + widthPercent: number; + topPercent: number; + leftPercent: number; + opacity: number; + blurRadius: number; + cardAnchor: AppearanceCardDecorationAnchor; + cardInsetPercent: number; +} + +export interface PortalAppearance { + themeMode: AppearanceThemeMode; + activeSkinId: string; + portalTitle: string; + portalTagline: string; + primaryColor: string; + secondaryColor: string; + accentColor: string; + buttonColor: string; + alertColor: string; + fontFamily: AppearanceFontFamily; + headingFontFamily: AppearanceFontFamily; + headingScalePercent: number; + bodyScalePercent: number; + logoUrl: string; + faviconUrl: string; + heroImageUrl: string; + backgroundImageUrl: string; + backgroundEnabled: boolean; + backgroundMode: AppearanceBackgroundMode; + backgroundPresentationMode: AppearanceBackgroundPresentationMode; + backgroundOverlayOpacity: number; + backgroundBlurRadius: number; + backgroundTargets: AppearanceBackgroundTarget[]; + backgroundTargetImageUrls: Partial>; + backgroundTargetOverlayOpacities: Partial>; + backgroundTargetBlurRadii: Partial>; + backgroundTargetPositions: Partial>; + backgroundTargetPresentationModes: Partial>; + heroImagePositionX: number; + heroImagePositionY: number; + heroOverlayOpacity: number; + heroTitlePositionX: number; + heroTitlePositionY: number; + heroTitleWidthPercent: number; + heroTaglinePositionX: number; + heroTaglinePositionY: number; + heroTaglineWidthPercent: number; + decorativeElements: PortalAppearanceDecoration[]; + cardStyle: AppearanceCardStyle; + tableStyle: AppearanceTableStyle; + displayDensity: AppearanceDisplayDensity; + sidebarStyle: AppearanceSidebarStyle; + buttonStyle: AppearanceButtonStyle; + inputStyle: AppearanceInputStyle; +} + +export interface AppearanceSkinPreset { + id: string; + label: string; + description: string; + category: string; + usage: string[]; + value: Partial; +} + +export const APPEARANCE_SETTING_KEY = "appearance"; + +export const defaultPortalAppearance: PortalAppearance = { + themeMode: "light", + activeSkinId: "guyane-institutionnelle", + portalTitle: "Portail Associations 973", + portalTagline: "Démarches, annuaire et services du tissu associatif guyanais", + primaryColor: "#2e7d32", + secondaryColor: "#1565c0", + accentColor: "#f9a825", + buttonColor: "#1565c0", + alertColor: "#dc2626", + fontFamily: "marianne", + headingFontFamily: "marianne", + headingScalePercent: 100, + bodyScalePercent: 100, + logoUrl: "", + faviconUrl: "", + heroImageUrl: "", + backgroundImageUrl: "", + backgroundEnabled: false, + backgroundMode: "cover", + backgroundPresentationMode: "full", + backgroundOverlayOpacity: 72, + backgroundBlurRadius: 0, + backgroundTargets: ["home", "public", "dashboard", "admin"], + backgroundTargetImageUrls: {}, + backgroundTargetOverlayOpacities: {}, + backgroundTargetBlurRadii: {}, + backgroundTargetPositions: {}, + backgroundTargetPresentationModes: {}, + heroImagePositionX: 50, + heroImagePositionY: 50, + heroOverlayOpacity: 74, + heroTitlePositionX: 0, + heroTitlePositionY: 0, + heroTitleWidthPercent: 50, + heroTaglinePositionX: 0, + heroTaglinePositionY: 0, + heroTaglineWidthPercent: 80, + decorativeElements: [], + cardStyle: "soft", + tableStyle: "comfortable", + displayDensity: "comfortable", + sidebarStyle: "soft", + buttonStyle: "rounded", + inputStyle: "outline", +}; + +export const appearanceSkinPresets: AppearanceSkinPreset[] = [ + { + id: "guyane-institutionnelle", + label: "Guyane institutionnelle", + description: "Palette ancrée dans le territoire, pensée pour les collectivités, services publics et fédérations.", + category: "Institutionnel", + usage: ["collectivités", "mairies", "fédérations", "services publics"], + value: { + activeSkinId: "guyane-institutionnelle", + portalTitle: "Portail Associations 973", + portalTagline: "L’espace territorial des démarches associatives en Guyane", + primaryColor: "#2e7d32", + secondaryColor: "#1565c0", + accentColor: "#f9a825", + buttonColor: "#1565c0", + alertColor: "#c62828", + fontFamily: "marianne", + headingFontFamily: "marianne", + cardStyle: "soft", + tableStyle: "comfortable", + displayDensity: "comfortable", + sidebarStyle: "soft", + buttonStyle: "rounded", + inputStyle: "outline", + backgroundEnabled: false, + backgroundMode: "cover", + backgroundPresentationMode: "full", + backgroundOverlayOpacity: 72, + backgroundBlurRadius: 0, + backgroundTargets: ["home", "public", "dashboard", "admin"], + backgroundTargetImageUrls: {}, + themeMode: "light", + }, + }, + { + id: "centre-spatial-guyanais", + label: "Centre spatial guyanais", + description: "Ambiance technologique et institutionnelle, inspirée du spatial, de l’innovation et des grands projets.", + category: "Spatial", + usage: ["innovation", "recherche", "éducation", "projets scientifiques"], + value: { + activeSkinId: "centre-spatial-guyanais", + portalTitle: "Portail Associations 973", + portalTagline: "Innovation, projets et coopérations du territoire guyanais", + primaryColor: "#0b3d91", + secondaryColor: "#1f5fbf", + accentColor: "#f6b317", + buttonColor: "#155eef", + alertColor: "#d92d20", + fontFamily: "system", + headingFontFamily: "rounded", + cardStyle: "elevated", + tableStyle: "striped", + displayDensity: "compact", + sidebarStyle: "contrast", + buttonStyle: "pill", + inputStyle: "soft", + backgroundEnabled: false, + backgroundMode: "cover", + backgroundPresentationMode: "full", + backgroundOverlayOpacity: 68, + backgroundBlurRadius: 0, + backgroundTargets: ["home", "public", "dashboard", "admin"], + backgroundTargetImageUrls: {}, + themeMode: "light", + }, + }, + { + id: "nature-amazonienne", + label: "Nature amazonienne", + description: "Univers plus organique, centré sur la biodiversité, l’environnement et les initiatives de terrain.", + category: "Territoire", + usage: ["environnement", "tourisme", "biodiversité", "éducation populaire"], + value: { + activeSkinId: "nature-amazonienne", + portalTitle: "Portail Associations 973", + portalTagline: "Les initiatives guyanaises entre nature, territoire et engagement", + primaryColor: "#1b5e20", + secondaryColor: "#4caf50", + accentColor: "#0277bd", + buttonColor: "#2e7d32", + alertColor: "#d84315", + fontFamily: "serif", + headingFontFamily: "serif", + cardStyle: "outlined", + tableStyle: "comfortable", + displayDensity: "detailed", + sidebarStyle: "floating", + buttonStyle: "rounded", + inputStyle: "filled", + backgroundEnabled: false, + backgroundMode: "cover", + backgroundPresentationMode: "full", + backgroundOverlayOpacity: 70, + backgroundBlurRadius: 0, + backgroundTargets: ["home", "public", "dashboard", "admin"], + backgroundTargetImageUrls: {}, + themeMode: "light", + }, + }, + { + id: "republicain", + label: "Républicain", + description: "Lecture claire, signal institutionnel fort, pour les espaces administratifs et les usages plus officiels.", + category: "Administration", + usage: ["préfecture", "administration", "services publics", "collectivités"], + value: { + activeSkinId: "republicain", + portalTitle: "Portail Associations 973", + portalTagline: "Un portail administratif lisible, fiable et immédiatement identifiable", + primaryColor: "#0055a4", + secondaryColor: "#ffffff", + accentColor: "#ef4135", + buttonColor: "#0055a4", + alertColor: "#b42318", + fontFamily: "marianne", + headingFontFamily: "marianne", + cardStyle: "outlined", + tableStyle: "compact", + displayDensity: "compact", + sidebarStyle: "contrast", + buttonStyle: "square", + inputStyle: "outline", + backgroundEnabled: false, + backgroundMode: "cover", + backgroundPresentationMode: "full", + backgroundOverlayOpacity: 78, + backgroundBlurRadius: 0, + backgroundTargets: ["home", "public", "dashboard", "admin"], + backgroundTargetImageUrls: {}, + themeMode: "light", + }, + }, + { + id: "institution-immersif", + label: "Institution immersif", + description: "Preset prêt à l’emploi pour un fond premium lisible, avec centre neutre et bords illustrés renforcés.", + category: "Immersif", + usage: ["dashboard", "portail institutionnel", "parcours connectés", "habillage premium"], + value: { + activeSkinId: "institution-immersif", + portalTitle: "Portail Associations 973", + portalTagline: "Un portail territorial clair, immersif et immédiatement lisible", + primaryColor: "#2e7d32", + secondaryColor: "#1565c0", + accentColor: "#f9a825", + buttonColor: "#1565c0", + alertColor: "#c62828", + fontFamily: "marianne", + headingFontFamily: "marianne", + cardStyle: "soft", + tableStyle: "comfortable", + displayDensity: "compact", + sidebarStyle: "soft", + buttonStyle: "rounded", + inputStyle: "outline", + backgroundEnabled: true, + backgroundMode: "cover", + backgroundPresentationMode: "edge_frame", + backgroundOverlayOpacity: 62, + backgroundBlurRadius: 0, + backgroundTargets: ["home", "public", "dashboard", "admin"], + backgroundTargetImageUrls: {}, + backgroundTargetOverlayOpacities: { + home: 58, + public: 64, + dashboard: 60, + admin: 68, + }, + backgroundTargetPositions: { + home: "center", + public: "center", + dashboard: "right", + admin: "right", + }, + backgroundTargetPresentationModes: { + home: "edge_frame", + public: "edge_frame", + dashboard: "edge_frame", + admin: "edge_frame", + }, + themeMode: "light", + }, + }, + { + id: "parcours-guide-immersif", + label: "Parcours guidé immersif", + description: "Met en avant les parcours, les formulaires et les cartes avec un habillage plus narratif et très lisible.", + category: "Immersif", + usage: ["nouvelle demande", "réservation", "matériel", "parcours guidés"], + value: { + activeSkinId: "parcours-guide-immersif", + portalTitle: "Portail Associations 973", + portalTagline: "Des démarches plus claires, étape par étape", + primaryColor: "#2f9e44", + secondaryColor: "#1d4ed8", + accentColor: "#f6c453", + buttonColor: "#2563eb", + alertColor: "#c2410c", + fontFamily: "marianne", + headingFontFamily: "rounded", + cardStyle: "soft", + tableStyle: "comfortable", + displayDensity: "compact", + sidebarStyle: "floating", + buttonStyle: "pill", + inputStyle: "soft", + backgroundEnabled: true, + backgroundMode: "cover", + backgroundPresentationMode: "edge_frame", + backgroundOverlayOpacity: 56, + backgroundBlurRadius: 1, + backgroundTargets: [ + "dashboard", + "dashboard_home", + "dashboard_new_request", + "dashboard_reservation_salle", + "dashboard_material", + ], + backgroundTargetImageUrls: {}, + backgroundTargetOverlayOpacities: { + dashboard: 58, + dashboard_home: 54, + dashboard_new_request: 52, + dashboard_reservation_salle: 56, + dashboard_material: 56, + }, + backgroundTargetPositions: { + dashboard: "right", + dashboard_home: "right", + dashboard_new_request: "right", + dashboard_reservation_salle: "bottom_right", + dashboard_material: "bottom_left", + }, + backgroundTargetPresentationModes: { + dashboard: "edge_frame", + dashboard_home: "edge_frame", + dashboard_new_request: "edge_frame", + dashboard_reservation_salle: "edge_frame", + dashboard_material: "edge_frame", + }, + themeMode: "light", + }, + }, + { + id: "pilotage-territorial", + label: "Pilotage territorial", + description: "Preset plus sobre et plus dense, pensé pour le bordereau, la logistique et les référentiels admin.", + category: "Immersif", + usage: ["bordereau", "logistique", "référentiels", "pilotage admin"], + value: { + activeSkinId: "pilotage-territorial", + portalTitle: "Portail Associations 973", + portalTagline: "Référentiels, logistique et pilotage du territoire", + primaryColor: "#256f5c", + secondaryColor: "#0f4c81", + accentColor: "#d97706", + buttonColor: "#0f4c81", + alertColor: "#b91c1c", + fontFamily: "marianne", + headingFontFamily: "marianne", + cardStyle: "outlined", + tableStyle: "compact", + displayDensity: "compact", + sidebarStyle: "soft", + buttonStyle: "rounded", + inputStyle: "outline", + backgroundEnabled: true, + backgroundMode: "cover", + backgroundPresentationMode: "edge_frame", + backgroundOverlayOpacity: 68, + backgroundBlurRadius: 0, + backgroundTargets: ["admin", "admin_pilotage", "admin_logistics", "admin_directory", "admin_compliance"], + backgroundTargetImageUrls: {}, + backgroundTargetOverlayOpacities: { + admin: 68, + admin_pilotage: 70, + admin_logistics: 66, + admin_directory: 64, + admin_compliance: 72, + }, + backgroundTargetPositions: { + admin: "right", + admin_pilotage: "center", + admin_logistics: "bottom_right", + admin_directory: "left", + admin_compliance: "top_right", + }, + backgroundTargetPresentationModes: { + admin: "edge_frame", + admin_pilotage: "edge_frame", + admin_logistics: "edge_frame", + admin_directory: "edge_frame", + admin_compliance: "edge_frame", + }, + themeMode: "light", + }, + }, +]; + +const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/; + +function normalizeHexColor(value: string | undefined, fallback: string) { + const trimmed = value?.trim(); + if (!trimmed || !HEX_COLOR_REGEX.test(trimmed)) { + return fallback; + } + + if (trimmed.length === 4) { + return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`.toLowerCase(); + } + + return trimmed.toLowerCase(); +} + +function normalizeUrl(value: string | undefined) { + return value?.trim() || ""; +} + +function normalizeText(value: string | undefined, fallback: string, maxLength: number) { + const trimmed = value?.trim(); + if (!trimmed) { + return fallback; + } + return trimmed.slice(0, maxLength); +} + +function normalizePercent(value: number | undefined, fallback: number, min = 0, max = 100) { + if (typeof value !== "number" || Number.isNaN(value)) { + return fallback; + } + + return Math.min(max, Math.max(min, Math.round(value))); +} + +function normalizeOption(value: string | undefined, allowed: T, fallback: T[number]) { + if (value && allowed.includes(value)) { + return value as T[number]; + } + return fallback; +} + +function normalizeOptionsArray(value: string[] | undefined, allowed: T, fallback: readonly T[number][]) { + if (!Array.isArray(value)) { + return [...fallback] as T[number][]; + } + + const filtered = value.filter((entry): entry is T[number] => allowed.includes(entry as T[number])); + return filtered.length ? Array.from(new Set(filtered)) : [...fallback] as T[number][]; +} + +function normalizeTargetImageUrls( + value: Partial> | undefined, + allowed: readonly AppearanceBackgroundTarget[], +) { + const next: Partial> = {}; + if (!value || typeof value !== "object") { + return next; + } + + for (const target of allowed) { + const normalized = normalizeUrl(value[target]); + if (normalized) { + next[target] = normalized; + } + } + + return next; +} + +function normalizeTargetOverlayOpacities( + value: Partial> | undefined, + allowed: readonly AppearanceBackgroundTarget[], +) { + const next: Partial> = {}; + if (!value || typeof value !== "object") { + return next; + } + + for (const target of allowed) { + const raw = value[target]; + if (typeof raw === "number" && !Number.isNaN(raw)) { + next[target] = Math.min(100, Math.max(0, Math.round(raw))); + } + } + + return next; +} + +function normalizeTargetBlurRadii( + value: Partial> | undefined, + allowed: readonly AppearanceBackgroundTarget[], +) { + const next: Partial> = {}; + if (!value || typeof value !== "object") { + return next; + } + + for (const target of allowed) { + const raw = value[target]; + if (typeof raw === "number" && !Number.isNaN(raw)) { + next[target] = Math.min(24, Math.max(0, Math.round(raw))); + } + } + + return next; +} + +function normalizeTargetPositions( + value: Partial> | undefined, + allowed: readonly AppearanceBackgroundTarget[], +) { + const next: Partial> = {}; + if (!value || typeof value !== "object") { + return next; + } + + for (const target of allowed) { + const raw = value[target]; + if (raw && appearanceBackgroundPositions.includes(raw as AppearanceBackgroundPosition)) { + next[target] = raw as AppearanceBackgroundPosition; + } + } + + return next; +} + +function normalizeTargetPresentationModes( + value: Partial> | undefined, + allowed: readonly AppearanceBackgroundTarget[], +) { + const next: Partial> = {}; + if (!value || typeof value !== "object") { + return next; + } + + for (const target of allowed) { + const raw = value[target]; + if (raw && appearanceBackgroundPresentationModes.includes(raw as AppearanceBackgroundPresentationMode)) { + next[target] = raw as AppearanceBackgroundPresentationMode; + } + } + + return next; +} + +function normalizeDecorativeElements(value: unknown): PortalAppearanceDecoration[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry, index) => { + const raw = entry && typeof entry === "object" ? entry as Partial : {}; + const imageUrl = normalizeUrl(raw.imageUrl); + if (!imageUrl) { + return null; + } + + return { + id: normalizeText(raw.id, `decor-${index + 1}`, 80), + imageUrl, + scope: normalizeOption(raw.scope, appearanceDecorationScopes, "all"), + layer: normalizeOption(raw.layer, appearanceDecorationLayers, "background"), + widthPercent: normalizePercent(raw.widthPercent, 18, 6, 38), + topPercent: normalizePercent(raw.topPercent, 20, 0, 100), + leftPercent: normalizePercent(raw.leftPercent, 10, 0, 100), + opacity: normalizePercent(raw.opacity, 12, 4, 28), + blurRadius: normalizePercent(raw.blurRadius, 2, 0, 12), + cardAnchor: normalizeOption(raw.cardAnchor, appearanceCardDecorationAnchors, "top_right"), + cardInsetPercent: normalizePercent(raw.cardInsetPercent, 6, 0, 20), + } satisfies PortalAppearanceDecoration; + }) + .filter((entry): entry is PortalAppearanceDecoration => Boolean(entry)) + .slice(0, 6); +} + +export function getAppearanceSkinPreset(presetId: string | undefined) { + return appearanceSkinPresets.find((preset) => preset.id === presetId) || null; +} + +export function sanitizePortalAppearance(value?: Partial | null): PortalAppearance { + const preset = getAppearanceSkinPreset(value?.activeSkinId) || getAppearanceSkinPreset(defaultPortalAppearance.activeSkinId); + const presetValue = preset?.value || {}; + + const base: PortalAppearance = { + ...defaultPortalAppearance, + ...presetValue, + }; + const normalizedBackgroundImageUrl = normalizeUrl(value?.backgroundImageUrl); + const normalizedBackgroundTargetImageUrls = normalizeTargetImageUrls(value?.backgroundTargetImageUrls, appearanceBackgroundTargets); + const normalizedBackgroundTargetOverlayOpacities = normalizeTargetOverlayOpacities( + value?.backgroundTargetOverlayOpacities, + appearanceBackgroundTargets + ); + const normalizedBackgroundTargetBlurRadii = normalizeTargetBlurRadii( + value?.backgroundTargetBlurRadii, + appearanceBackgroundTargets + ); + const normalizedBackgroundTargetPositions = normalizeTargetPositions( + value?.backgroundTargetPositions, + appearanceBackgroundTargets + ); + const normalizedBackgroundTargetPresentationModes = normalizeTargetPresentationModes( + value?.backgroundTargetPresentationModes, + appearanceBackgroundTargets + ); + const normalizedDecorativeElements = normalizeDecorativeElements(value?.decorativeElements); + const hasAnyBackgroundAsset = + Boolean(normalizedBackgroundImageUrl) || Object.values(normalizedBackgroundTargetImageUrls).some(Boolean); + + return { + themeMode: normalizeOption(value?.themeMode, appearanceThemeModes, base.themeMode), + activeSkinId: normalizeText(value?.activeSkinId, base.activeSkinId, 80), + portalTitle: normalizeText(value?.portalTitle, base.portalTitle, 120), + portalTagline: normalizeText(value?.portalTagline, base.portalTagline, 220), + primaryColor: normalizeHexColor(value?.primaryColor, base.primaryColor), + secondaryColor: normalizeHexColor(value?.secondaryColor, base.secondaryColor), + accentColor: normalizeHexColor(value?.accentColor, base.accentColor), + buttonColor: normalizeHexColor(value?.buttonColor, base.buttonColor), + alertColor: normalizeHexColor(value?.alertColor, base.alertColor), + fontFamily: normalizeOption(value?.fontFamily, appearanceFontFamilies, base.fontFamily), + headingFontFamily: normalizeOption(value?.headingFontFamily, appearanceFontFamilies, base.headingFontFamily), + headingScalePercent: normalizePercent(value?.headingScalePercent, base.headingScalePercent, 85, 130), + bodyScalePercent: normalizePercent(value?.bodyScalePercent, base.bodyScalePercent, 90, 115), + logoUrl: normalizeUrl(value?.logoUrl), + faviconUrl: normalizeUrl(value?.faviconUrl), + heroImageUrl: normalizeUrl(value?.heroImageUrl), + backgroundImageUrl: normalizedBackgroundImageUrl, + backgroundEnabled: Boolean(value?.backgroundEnabled) && hasAnyBackgroundAsset, + backgroundMode: normalizeOption(value?.backgroundMode, appearanceBackgroundModes, base.backgroundMode), + backgroundPresentationMode: normalizeOption( + value?.backgroundPresentationMode, + appearanceBackgroundPresentationModes, + base.backgroundPresentationMode + ), + backgroundOverlayOpacity: normalizePercent(value?.backgroundOverlayOpacity, base.backgroundOverlayOpacity, 0, 100), + backgroundBlurRadius: normalizePercent(value?.backgroundBlurRadius, base.backgroundBlurRadius, 0, 24), + backgroundTargets: normalizeOptionsArray(value?.backgroundTargets, appearanceBackgroundTargets, base.backgroundTargets), + backgroundTargetImageUrls: normalizedBackgroundTargetImageUrls, + backgroundTargetOverlayOpacities: normalizedBackgroundTargetOverlayOpacities, + backgroundTargetBlurRadii: normalizedBackgroundTargetBlurRadii, + backgroundTargetPositions: normalizedBackgroundTargetPositions, + backgroundTargetPresentationModes: normalizedBackgroundTargetPresentationModes, + heroImagePositionX: normalizePercent(value?.heroImagePositionX, base.heroImagePositionX), + heroImagePositionY: normalizePercent(value?.heroImagePositionY, base.heroImagePositionY), + heroOverlayOpacity: normalizePercent(value?.heroOverlayOpacity, base.heroOverlayOpacity), + heroTitlePositionX: normalizePercent(value?.heroTitlePositionX, base.heroTitlePositionX, -50, 50), + heroTitlePositionY: normalizePercent(value?.heroTitlePositionY, base.heroTitlePositionY, -50, 50), + heroTitleWidthPercent: normalizePercent(value?.heroTitleWidthPercent, base.heroTitleWidthPercent, 30, 100), + heroTaglinePositionX: normalizePercent(value?.heroTaglinePositionX, base.heroTaglinePositionX, -50, 50), + heroTaglinePositionY: normalizePercent(value?.heroTaglinePositionY, base.heroTaglinePositionY, -50, 50), + heroTaglineWidthPercent: normalizePercent(value?.heroTaglineWidthPercent, base.heroTaglineWidthPercent, 30, 100), + decorativeElements: normalizedDecorativeElements, + cardStyle: normalizeOption(value?.cardStyle, appearanceCardStyles, base.cardStyle), + tableStyle: normalizeOption(value?.tableStyle, appearanceTableStyles, base.tableStyle), + displayDensity: normalizeOption(value?.displayDensity, appearanceDisplayDensities, base.displayDensity), + sidebarStyle: normalizeOption(value?.sidebarStyle, appearanceSidebarStyles, base.sidebarStyle), + buttonStyle: normalizeOption(value?.buttonStyle, appearanceButtonStyles, base.buttonStyle), + inputStyle: normalizeOption(value?.inputStyle, appearanceInputStyles, base.inputStyle), + }; +} diff --git a/shared/associationCommunes.ts b/shared/associationCommunes.ts new file mode 100644 index 0000000..53024e9 --- /dev/null +++ b/shared/associationCommunes.ts @@ -0,0 +1,44 @@ +export const associationCommuneOptions = [ + { value: "all", label: "Toutes" }, + { value: "kourou", label: "Kourou" }, + { value: "sinnamary", label: "Sinnamary" }, + { value: "iracoubo", label: "Iracoubo" }, + { value: "saint_elie", label: "Saint-Élie" }, +] as const; + +export type AssociationCommuneFilter = (typeof associationCommuneOptions)[number]["value"]; + +const communeVariantMap: Record, string[]> = { + kourou: ["Kourou"], + sinnamary: ["Sinnamary"], + iracoubo: ["Iracoubo"], + saint_elie: ["Saint-Élie", "Saint Elie", "ST ELIE", "St Elie", "St-Élie", "Saint-Elie"], +}; + +export function getAssociationCommuneLabel(value: AssociationCommuneFilter) { + return associationCommuneOptions.find(option => option.value === value)?.label ?? "Toutes"; +} + +export function normalizeAssociationCommune(value: string | null | undefined): AssociationCommuneFilter { + const normalized = (value || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[-_]/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + + if (normalized.includes("saint elie") || normalized.includes("st elie")) return "saint_elie"; + if (normalized.includes("kourou")) return "kourou"; + if (normalized.includes("sinnamary")) return "sinnamary"; + if (normalized.includes("iracoubo")) return "iracoubo"; + return "all"; +} + +export function getAssociationCommuneVariants(value?: string | null) { + if (!value || value === "all") { + return []; + } + + return communeVariantMap[value as Exclude] || []; +} diff --git a/shared/associationGeo.ts b/shared/associationGeo.ts new file mode 100644 index 0000000..924e4dc --- /dev/null +++ b/shared/associationGeo.ts @@ -0,0 +1,29 @@ +export const associationGeoSources = [ + "manual", + "adresse_gouv", + "dataasso", + "commune_center", +] as const; + +export type AssociationGeoSource = (typeof associationGeoSources)[number]; + +export const associationGeoPrecisions = [ + "exact_address", + "commune_center", + "hidden", +] as const; + +export type AssociationGeoPrecision = (typeof associationGeoPrecisions)[number]; + +export const associationGeoSourceLabels: Record = { + manual: "Position définie manuellement", + adresse_gouv: "Adresse.data.gouv.fr", + dataasso: "DataAsso / référentiel association", + commune_center: "Centre de commune", +}; + +export const associationGeoPrecisionLabels: Record = { + exact_address: "Adresse exacte", + commune_center: "Centre de commune", + hidden: "Masquée du public", +}; diff --git a/shared/associationGovernance.ts b/shared/associationGovernance.ts new file mode 100644 index 0000000..7e3a579 --- /dev/null +++ b/shared/associationGovernance.ts @@ -0,0 +1,194 @@ +export const legalRepresentativeRoleValues = [ + "president", + "co_president", + "secretaire_general", + "directeur_gerant", +] as const; + +export type LegalRepresentativeRole = typeof legalRepresentativeRoleValues[number]; + +export const governanceMemberRoleValues = [ + "tresorier", + "tresorier_adjoint", + "secretaire", + "secretaire_adjoint", + "administrateur", + "charge_de_mission_referent", + "adherent_benevole", +] as const; + +export type GovernanceMemberRole = typeof governanceMemberRoleValues[number]; + +export const legalRepresentativeRoleLabels: Record = { + president: "Président", + co_president: "Co-président", + secretaire_general: "Secrétaire Général", + directeur_gerant: "Directeur / Gérant", +}; + +export const governanceMemberRoleLabels: Record = { + tresorier: "Trésorier", + tresorier_adjoint: "Trésorier adjoint", + secretaire: "Secrétaire", + secretaire_adjoint: "Secrétaire adjoint", + administrateur: "Membre du conseil d'administration / Administrateur", + charge_de_mission_referent: "Chargé de mission / Référent", + adherent_benevole: "Simple adhérent / Bénévole", +}; + +export const legalRepresentativeRoleOptions = legalRepresentativeRoleValues.map((value) => ({ + value, + label: legalRepresentativeRoleLabels[value], +})); + +export const governanceMemberRoleOptions = governanceMemberRoleValues.map((value) => ({ + value, + label: governanceMemberRoleLabels[value], +})); + +function normalizeRoleKey(value: string | null | undefined) { + return String(value || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +export function normalizeLegalRepresentativeRole(value: string | null | undefined): LegalRepresentativeRole { + const normalized = normalizeRoleKey(value); + switch (normalized) { + case "president": + case "presidente": + return "president"; + case "co_president": + case "copresident": + case "co_presidente": + return "co_president"; + case "secretaire_general": + return "secretaire_general"; + case "directeur": + case "gerant": + case "directeur_gerant": + return "directeur_gerant"; + default: + return "president"; + } +} + +export function normalizeGovernanceMemberRole(value: string | null | undefined): GovernanceMemberRole { + const normalized = normalizeRoleKey(value); + switch (normalized) { + case "tresorier": + return "tresorier"; + case "tresorier_adjoint": + return "tresorier_adjoint"; + case "secretaire": + return "secretaire"; + case "secretaire_adjoint": + return "secretaire_adjoint"; + case "administrateur": + case "membre_du_conseil_d_administration_administrateur": + return "administrateur"; + case "charge_de_mission": + case "referent": + case "charge_de_mission_referent": + return "charge_de_mission_referent"; + default: + return "adherent_benevole"; + } +} + +export type GovernancePerson = { + nom: string; + prenom: string; + email?: string | null; + telephone?: string | null; + fonction: string; +}; + +export type AssociationGovernance = { + representantLegal: GovernancePerson | null; + membres: GovernancePerson[]; +}; + +export function createEmptyGovernancePerson(fonction = ""): GovernancePerson { + return { + nom: "", + prenom: "", + email: "", + telephone: "", + fonction, + }; +} + +export function parseAssociationGovernance(value: string | null | undefined): AssociationGovernance { + if (!value) { + return { representantLegal: null, membres: [] }; + } + + try { + const parsed = JSON.parse(value) as Partial; + const normalizePerson = (person: any): GovernancePerson | null => { + if (!person || typeof person !== "object") return null; + return { + nom: typeof person.nom === "string" ? person.nom : "", + prenom: typeof person.prenom === "string" ? person.prenom : "", + email: typeof person.email === "string" ? person.email : "", + telephone: typeof person.telephone === "string" ? person.telephone : "", + fonction: typeof person.fonction === "string" ? person.fonction : "", + }; + }; + + return { + representantLegal: normalizePerson(parsed.representantLegal), + membres: Array.isArray(parsed.membres) + ? parsed.membres.map(normalizePerson).filter(Boolean) as GovernancePerson[] + : [], + }; + } catch { + return { representantLegal: null, membres: [] }; + } +} + +export function serializeAssociationGovernance( + governance: AssociationGovernance | null | undefined +) { + if (!governance?.representantLegal) return null; + return JSON.stringify(governance); +} + +export function buildDisplayName(person: GovernancePerson | null | undefined) { + if (!person) return ""; + return [person.prenom?.trim(), person.nom?.trim()].filter(Boolean).join(" ").trim(); +} + +export function sanitizeGovernance(governance: AssociationGovernance): AssociationGovernance { + const trimPerson = ( + person: GovernancePerson | null, + roleNormalizer?: (value: string | null | undefined) => string + ): GovernancePerson | null => { + if (!person) return null; + const normalized: GovernancePerson = { + nom: person.nom.trim(), + prenom: person.prenom.trim(), + email: person.email?.trim() || "", + telephone: person.telephone?.trim() || "", + fonction: roleNormalizer ? roleNormalizer(person.fonction) : person.fonction.trim(), + }; + + if (!normalized.nom && !normalized.prenom && !normalized.email && !normalized.telephone && !normalized.fonction) { + return null; + } + + return normalized; + }; + + return { + representantLegal: trimPerson(governance.representantLegal, normalizeLegalRepresentativeRole), + membres: governance.membres + .map((member) => trimPerson(member, normalizeGovernanceMemberRole)) + .filter((member): member is GovernancePerson => Boolean(member)), + }; +} diff --git a/shared/associationThematics.ts b/shared/associationThematics.ts new file mode 100644 index 0000000..ea39366 --- /dev/null +++ b/shared/associationThematics.ts @@ -0,0 +1,88 @@ +export const associationThematicValues = [ + "culture_loisirs", + "social_sante", + "education_formation", + "economie_territoire", + "environnement_patrimoine", + "institutions_divers", +] as const; + +export type AssociationThematic = typeof associationThematicValues[number]; + +export const associationThematicDefinitions: Record< + AssociationThematic, + { label: string; description: string } +> = { + culture_loisirs: { + label: "Culture & Loisirs", + description: "Arts, sport, chasse, pêche, activités civiques et religieuses.", + }, + social_sante: { + label: "Social & Santé", + description: "Caritatif, humanitaire, aide aux seniors, santé, services aux familles.", + }, + education_formation: { + label: "Éducation et formation", + description: "Écoles, formation continue, apprentissage.", + }, + economie_territoire: { + label: "Économie & Territoire", + description: "Emploi, insertion, logement, tourisme, défense d'intérêts économiques.", + }, + environnement_patrimoine: { + label: "Environnement et patrimoine", + description: "Écologie, cadre de vie, protection des monuments.", + }, + institutions_divers: { + label: "Institutions & Divers", + description: "Justice, sécurité civile, recherche, activités politiques.", + }, +}; + +export const associationThematicOptions = associationThematicValues.map((value) => ({ + value, + label: associationThematicDefinitions[value].label, + description: associationThematicDefinitions[value].description, +})); + +export function getAssociationThematicLabel(value: AssociationThematic | string | null | undefined) { + if (!value) return "Non renseignée"; + return associationThematicDefinitions[value as AssociationThematic]?.label ?? value; +} + +export function getAssociationThematicDescription(value: AssociationThematic | string | null | undefined) { + if (!value) return ""; + return associationThematicDefinitions[value as AssociationThematic]?.description ?? ""; +} + +export function parseAssociationThematics(value: string | null | undefined): AssociationThematic[] { + if (!value) return []; + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.filter((item): item is AssociationThematic => + associationThematicValues.includes(item as AssociationThematic) + ); + } + } catch { + if (associationThematicValues.includes(value as AssociationThematic)) { + return [value as AssociationThematic]; + } + } + return []; +} + +export function serializeAssociationThematics(values: Array | null | undefined) { + const normalized = Array.from( + new Set( + (values || []).filter((item): item is AssociationThematic => + associationThematicValues.includes(item as AssociationThematic) + ) + ) + ); + return normalized.length > 0 ? JSON.stringify(normalized) : null; +} + +export function getAssociationThematicLabels(values: Array | null | undefined) { + return (values || []).map((value) => getAssociationThematicLabel(value)); +} diff --git a/shared/const.ts b/shared/const.ts new file mode 100644 index 0000000..112ffc5 --- /dev/null +++ b/shared/const.ts @@ -0,0 +1,5 @@ +export const COOKIE_NAME = "app_session_id"; +export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365; +export const AXIOS_TIMEOUT_MS = 30_000; +export const UNAUTHED_ERR_MSG = 'Please login (10001)'; +export const NOT_ADMIN_ERR_MSG = 'You do not have required permission (10002)'; diff --git a/shared/mailProviders.test.ts b/shared/mailProviders.test.ts new file mode 100644 index 0000000..6d6744c --- /dev/null +++ b/shared/mailProviders.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { resolveMailConfig } from "./mailProviders"; + +describe("resolveMailConfig", () => { + it("uses Gmail defaults when provider is gmail", () => { + const config = resolveMailConfig({ + smtpProvider: "gmail", + smtpFrom: "notifications@example.com", + smtpUser: "notifications@example.com", + smtpPass: "secret", + }); + + expect(config.provider).toBe("gmail"); + expect(config.host).toBe("smtp.gmail.com"); + expect(config.port).toBe(465); + expect(config.secure).toBe(true); + expect(config.ready).toBe(true); + }); + + it("uses OVH defaults when provider is ovh", () => { + const config = resolveMailConfig({ + smtpProvider: "ovh", + smtpFrom: "notifications@example.com", + }); + + expect(config.provider).toBe("ovh"); + expect(config.host).toBe("smtp.mail.ovh.net"); + expect(config.port).toBe(465); + expect(config.secure).toBe(true); + expect(config.requireTLS).toBe(false); + expect(config.ready).toBe(true); + }); + + it("supports custom SMTP host and port", () => { + const config = resolveMailConfig({ + smtpHost: "mail.example.org", + smtpPort: "2525", + smtpFrom: "notifications@example.org", + }); + + expect(config.provider).toBe("custom"); + expect(config.host).toBe("mail.example.org"); + expect(config.port).toBe(2525); + expect(config.ready).toBe(true); + }); + + it("reports missing SMTP_PASS when only SMTP_USER is present", () => { + const config = resolveMailConfig({ + smtpProvider: "outlook", + smtpFrom: "notifications@example.com", + smtpUser: "notifications@example.com", + }); + + expect(config.ready).toBe(false); + expect(config.missing).toContain("SMTP_PASS"); + }); +}); diff --git a/shared/mailProviders.ts b/shared/mailProviders.ts new file mode 100644 index 0000000..e009400 --- /dev/null +++ b/shared/mailProviders.ts @@ -0,0 +1,125 @@ +export const supportedMailProviders = ["custom", "ovh", "gmail", "outlook"] as const; + +export type SupportedMailProvider = typeof supportedMailProviders[number]; + +export type MailEnvInput = { + smtpProvider?: string; + smtpHost?: string; + smtpPort?: string; + smtpUser?: string; + smtpPass?: string; + smtpFrom?: string; + smtpSecure?: string; + smtpRequireTls?: string; +}; + +export type ResolvedMailConfig = { + provider: SupportedMailProvider; + host: string; + port: number | null; + secure: boolean; + requireTLS: boolean; + user: string; + pass: string; + from: string; + missing: string[]; + ready: boolean; +}; + +export const providerDefaults: Record< + Exclude, + { host: string; port: number; secure: boolean; requireTLS: boolean } +> = { + ovh: { + host: "smtp.mail.ovh.net", + port: 465, + secure: true, + requireTLS: false, + }, + gmail: { + host: "smtp.gmail.com", + port: 465, + secure: true, + requireTLS: false, + }, + outlook: { + host: "smtp.office365.com", + port: 587, + secure: false, + requireTLS: true, + }, +}; + +function normalizeProvider(value?: string): SupportedMailProvider { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "ovh" || normalized === "gmail" || normalized === "outlook") { + return normalized; + } + return "custom"; +} + +function parseBoolean(value?: string, fallback = false): boolean { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (!normalized) { + return fallback; + } + return ["1", "true", "yes", "oui", "on"].includes(normalized); +} + +function parsePort(value?: string, fallback: number | null = null): number | null { + const trimmed = String(value || "").trim(); + if (!trimmed) { + return fallback; + } + const port = Number(trimmed); + if (!Number.isFinite(port) || port <= 0) { + return fallback; + } + return port; +} + +export function resolveMailConfig(input: MailEnvInput): ResolvedMailConfig { + const provider = normalizeProvider(input.smtpProvider); + const defaults = provider === "custom" ? null : providerDefaults[provider]; + const host = String(input.smtpHost || defaults?.host || "").trim(); + const port = parsePort(input.smtpPort, defaults?.port ?? null); + const secure = parseBoolean(input.smtpSecure, defaults?.secure ?? false); + const requireTLS = parseBoolean(input.smtpRequireTls, defaults?.requireTLS ?? false); + const user = String(input.smtpUser || "").trim(); + const pass = String(input.smtpPass || "").trim(); + const from = String(input.smtpFrom || "").trim(); + + const missing: string[] = []; + if (!host) { + missing.push("SMTP_HOST"); + } + if (!port) { + missing.push("SMTP_PORT"); + } + if (!from) { + missing.push("SMTP_FROM"); + } + if (user && !pass) { + missing.push("SMTP_PASS"); + } + if (pass && !user) { + missing.push("SMTP_USER"); + } + + return { + provider, + host, + port, + secure, + requireTLS, + user, + pass, + from, + missing, + ready: missing.length === 0, + }; +} diff --git a/shared/materialEvent.ts b/shared/materialEvent.ts new file mode 100644 index 0000000..49fb58f --- /dev/null +++ b/shared/materialEvent.ts @@ -0,0 +1,72 @@ +export const materialEventItems = [ + { key: "tente3x3", label: "Tente 3*3" }, + { key: "chapiteau5x5", label: "Chapiteau 5*5" }, + { key: "podium", label: "Podium" }, + { key: "autres", label: "Autres" }, +] as const; + +export const materialEventInventory: Record<(typeof materialEventItems)[number]["key"], number | null> = { + tente3x3: 12, + chapiteau5x5: 6, + podium: 2, + autres: null, +}; + +export const materialEventReplacementValues: Record<(typeof materialEventItems)[number]["key"], number | null> = { + tente3x3: 45000, + chapiteau5x5: 120000, + podium: 250000, + autres: null, +}; + +export type MaterialEventItemKey = (typeof materialEventItems)[number]["key"]; +export type MaterialEventSelection = Record; +export type MaterialEventQuantities = Record; +export type MaterialEventQuantityMap = Record; + +export const emptyMaterialEventSelection = (): MaterialEventSelection => ({ + tente3x3: false, + chapiteau5x5: false, + podium: false, + autres: false, +}); + +export const emptyMaterialEventQuantities = (): MaterialEventQuantities => ({ + tente3x3: "", + chapiteau5x5: "", + podium: "", + autres: "", +}); + +export function getMaterialEventLabel(key: MaterialEventItemKey | string) { + return materialEventItems.find((item) => item.key === key)?.label || String(key); +} + +export function parseMaterialEventQuantity(value: unknown) { + const parsed = Number.parseInt(String(value ?? "").trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +export function emptyMaterialEventQuantityMap(initialValue: number = 0): MaterialEventQuantityMap { + return { + tente3x3: initialValue, + chapiteau5x5: initialValue, + podium: initialValue, + autres: initialValue, + }; +} + +export function sanitizeMaterialEventQuantityMap(rawValue: unknown): MaterialEventQuantityMap { + const next = emptyMaterialEventQuantityMap(); + + if (!rawValue || typeof rawValue !== "object") { + return next; + } + + for (const item of materialEventItems) { + const value = (rawValue as Record)[item.key]; + next[item.key] = parseMaterialEventQuantity(value); + } + + return next; +} diff --git a/shared/privacyCompliance.ts b/shared/privacyCompliance.ts new file mode 100644 index 0000000..e9f8edb --- /dev/null +++ b/shared/privacyCompliance.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +export const DATA_PRIVACY_NOTICE_VERSION = "2026-06-11"; + +export const dataPrivacyConsentContextValues = [ + "register_account", + "save_profile", + "create_request", + "update_request", +] as const; + +export type DataPrivacyConsentContext = (typeof dataPrivacyConsentContextValues)[number]; + +export const dataPrivacyConsentContextLabels: Record = { + register_account: "Création de compte", + save_profile: "Enregistrement de la fiche association", + create_request: "Création d'une demande", + update_request: "Mise à jour d'une demande", +}; + +export const dataPrivacyConsentSchema = z.object({ + accepted: z.literal(true), + version: z.string().min(1), + context: z.enum(dataPrivacyConsentContextValues), +}); + +export type DataPrivacyConsent = z.infer; + +export function buildDataPrivacyConsent(context: DataPrivacyConsentContext): DataPrivacyConsent { + return { + accepted: true, + version: DATA_PRIVACY_NOTICE_VERSION, + context, + }; +} + diff --git a/shared/sallePricing.ts b/shared/sallePricing.ts new file mode 100644 index 0000000..da6c311 --- /dev/null +++ b/shared/sallePricing.ts @@ -0,0 +1,298 @@ +export type SalleUsageType = "conventionne" | "occasionnel"; +export type SalleReservationFrequency = "demi_journee" | "journee" | "mensuel"; +export type SalleWorkflowQuoteStatus = "a_preparer" | "en_attente_association" | "accepte" | "refuse"; +export type SalleWorkflowDirectorStatus = "a_transmettre" | "en_attente_signature" | "signee"; +export type SalleWorkflowPaymentStatus = "en_attente_paiement" | "paiement_partiel" | "paiement_recu" | "paiement_valide"; + +export type SallePricingLineItem = { + salleId: string; + salleNom: string; + categoryLabel: string; + usageType: SalleUsageType; + frequency: SalleReservationFrequency; + quantity: number; + unitAmountCents: number; + totalAmountCents: number; +}; + +export type SallePricingSummary = { + usageType: SalleUsageType; + frequency: SalleReservationFrequency; + quantity: number; + lineItems: SallePricingLineItem[]; + totalAmountCents: number; + unsupportedSalles: Array<{ + salleId: string; + salleNom: string; + }>; +}; + +export type SalleWorkflowData = { + usageType?: SalleUsageType; + frequency?: SalleReservationFrequency; + conditionsFinancieresText?: string; + decisionAdministrativeText?: string; + pricing?: SallePricingSummary; + quoteStatus?: SalleWorkflowQuoteStatus; + quoteSentAt?: string; + quoteRespondedAt?: string; + quotePdfUrl?: string; + quotePdfName?: string; + administrativeDecisionPdfUrl?: string; + administrativeDecisionPdfName?: string; + directorStatus?: SalleWorkflowDirectorStatus; + directorTransmissionAt?: string; + directorSignedAt?: string; + directorSignedByUserId?: number; + directorSignedByName?: string; + directorReturnComment?: string; + directorReturnedAt?: string; + signedQuotePdfUrl?: string; + signedQuotePdfName?: string; + signedDecisionPdfUrl?: string; + signedDecisionPdfName?: string; + signedRequestPdfUrl?: string; + signedRequestPdfName?: string; + invoicePdfUrl?: string; + invoicePdfName?: string; + invoiceGeneratedAt?: string; + paymentStatus?: SalleWorkflowPaymentStatus; + paymentDueDate?: string; + amountReceivedCents?: number; + paymentReceivedAt?: string; + paymentValidatedAt?: string; + paymentReference?: string; + paymentNotes?: string; + finalNotificationSentAt?: string; +}; + +type SalleTariffDefinition = { + salleId: string; + salleNom: string; + categoryLabel: string; + pricing: Partial>>>; +}; + +export const SALLE_USAGE_TYPE_LABELS: Record = { + conventionne: "Conventionné", + occasionnel: "Occasionnel", +}; + +export const SALLE_FREQUENCY_LABELS: Record = { + demi_journee: "Demi-journée", + journee: "Journée", + mensuel: "Mensuel", +}; + +export const SALLE_WORKFLOW_QUOTE_STATUS_LABELS: Record = { + a_preparer: "À préparer", + en_attente_association: "En attente de l'association", + accepte: "Accepté par l'association", + refuse: "Refusé par l'association", +}; + +export const SALLE_WORKFLOW_DIRECTOR_STATUS_LABELS: Record = { + a_transmettre: "À transmettre à la Directrice", + en_attente_signature: "En attente de signature", + signee: "Signée", +}; + +export const SALLE_WORKFLOW_PAYMENT_STATUS_LABELS: Record = { + en_attente_paiement: "En attente de paiement", + paiement_partiel: "Paiement partiel", + paiement_recu: "Paiement reçu", + paiement_valide: "Paiement validé", +}; + +const SALLE_TARIFFS: SalleTariffDefinition[] = [ + { + salleId: "bureau_11_17", + salleNom: "Bureau 11–17 m²", + categoryLabel: "Bureau 11–17 m²", + pricing: { + conventionne: { + journee: 1216, + mensuel: 18819, + }, + }, + }, + { + salleId: "salle_toucan", + salleNom: "Salle TOUCAN", + categoryLabel: "Salle de conférence - Formation", + pricing: { + conventionne: { + demi_journee: 7673, + journee: 15345, + }, + }, + }, + { + salleId: "salle_ibis", + salleNom: "Salle IBIS", + categoryLabel: "Espace formation", + pricing: { + conventionne: { + demi_journee: 8173, + journee: 15845, + mensuel: 25000, + }, + }, + }, + { + salleId: "salle_pelican", + salleNom: "Salle PELICAN", + categoryLabel: "Espace formation", + pricing: { + conventionne: { + demi_journee: 8173, + journee: 15845, + mensuel: 25000, + }, + }, + }, + { + salleId: "hall_amphitheatre", + salleNom: "Hall-Amphithéâtre", + categoryLabel: "Hall ouvert - 343,34 m²", + pricing: { + conventionne: { + demi_journee: 5000, + journee: 10000, + }, + }, + }, + { + salleId: "dojo", + salleNom: "DOJO", + categoryLabel: "Dojo", + pricing: { + conventionne: { + mensuel: 65000, + }, + occasionnel: { + mensuel: 25000, + }, + }, + }, +]; + +export const CONDITIONS_FINANCIERES_SALLE_TEXT = `CONDITIONS FINANCIERES - LOCATION DES ESPACES +La mise a disposition d'un espace au sein de la Maison de la Jeunesse des Savanes « Melissa ALVES » est soumise aux conditions suivantes : +1. Tarification +Les tarifs appliques sont ceux votes par le Conseil Communautaire, actualises au 1er septembre 2025. +Le montant est calcule automatiquement en fonction : +de l'espace choisi +de la duree (journee, demi-journee, mensuel) +du type d'utilisateur (conventionne / occasionnel) +2. Acompte +Un acompte peut etre demande selon la nature de l'evenement. +3. Annulation +Toute annulation doit etre signalee 48 heures a l'avance pour un eventuel remboursement de l'acompte. +4. Responsabilite +L'association est responsable : +des participants +du public +des dommages eventuels +du respect des regles de securite +Une attestation de responsabilite civile couvrant la periode doit etre fournie. +5. Entretien des locaux +Il est interdit de manger dans les salles. +Les espaces doivent etre rendus propres et en bon etat.`; + +export function buildDecisionAdministrativeText(input: { + espace: string; + dates: string; + horaires: string; + association: string; +}) { + return `DECISION ADMINISTRATIVE PRIORITAIRE +La presente decision atteste que la Maison de la Jeunesse des Savanes « Melissa ALVES » donne un avis favorable a la mise a disposition de l'espace suivant : +Espace : ${input.espace} +Dates : ${input.dates} +Horaires : ${input.horaires} +Association : ${input.association} +Cette decision est transmise avant validation finale et ne vaut pas autorisation definitive. +La mise a disposition devient effective uniquement apres : +validation du devis (si facturation) +signature de la Directrice +reception de l'attestation de responsabilite civile`; +} + +export function getSalleTariffDefinition(salleId: string) { + return SALLE_TARIFFS.find((entry) => entry.salleId === salleId) || null; +} + +export function listReservationDates(start: string, end: string) { + if (!start || !end) return [] as string[]; + const startDate = new Date(`${start}T12:00:00`); + const endDate = new Date(`${end}T12:00:00`); + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || startDate > endDate) { + return [] as string[]; + } + + const values: string[] = []; + const cursor = new Date(startDate); + while (cursor <= endDate) { + values.push(cursor.toISOString().slice(0, 10)); + cursor.setDate(cursor.getDate() + 1); + } + return values; +} + +export function getReservationUnitQuantity(input: { + frequency: SalleReservationFrequency; + dateReservation?: string; + dateFinReservation?: string; +}) { + if (input.frequency === "mensuel") return 1; + const dates = listReservationDates(input.dateReservation || "", input.dateFinReservation || input.dateReservation || ""); + return Math.max(dates.length, 1); +} + +export function computeSallePricing(input: { + sallesIds: string[]; + usageType: SalleUsageType; + frequency: SalleReservationFrequency; + dateReservation?: string; + dateFinReservation?: string; +}) { + const quantity = getReservationUnitQuantity(input); + const summary: SallePricingSummary = { + usageType: input.usageType, + frequency: input.frequency, + quantity, + lineItems: [], + totalAmountCents: 0, + unsupportedSalles: [], + }; + + input.sallesIds.forEach((salleId) => { + const salle = getSalleTariffDefinition(salleId); + if (!salle) { + summary.unsupportedSalles.push({ salleId, salleNom: salleId }); + return; + } + + const unitAmountCents = salle.pricing[input.usageType]?.[input.frequency]; + if (typeof unitAmountCents !== "number") { + summary.unsupportedSalles.push({ salleId, salleNom: salle.salleNom }); + return; + } + + const lineItem: SallePricingLineItem = { + salleId, + salleNom: salle.salleNom, + categoryLabel: salle.categoryLabel, + usageType: input.usageType, + frequency: input.frequency, + quantity, + unitAmountCents, + totalAmountCents: unitAmountCents * quantity, + }; + summary.lineItems.push(lineItem); + summary.totalAmountCents += lineItem.totalAmountCents; + }); + + return summary; +} diff --git a/shared/socialLinks.ts b/shared/socialLinks.ts new file mode 100644 index 0000000..4a7f5b0 --- /dev/null +++ b/shared/socialLinks.ts @@ -0,0 +1,62 @@ +export function normalizeSocialUrl(value: string | null | undefined) { + const trimmed = (value || "").trim(); + if (!trimmed) return ""; + return trimmed; +} + +function parseSocialUrl(value: string | null | undefined) { + const trimmed = normalizeSocialUrl(value); + if (!trimmed) return null; + + try { + return new URL(trimmed); + } catch { + return null; + } +} + +function isAllowedSocialHostname(hostname: string, allowed: string[]) { + return allowed.some((domain) => hostname === domain || hostname === `www.${domain}`); +} + +export function isValidFacebookUrl(value: string | null | undefined) { + const url = parseSocialUrl(value); + if (!value || !String(value).trim()) return true; + if (!url) return false; + return url.protocol === "https:" && isAllowedSocialHostname(url.hostname.toLowerCase(), ["facebook.com", "fb.com"]); +} + +export function isValidInstagramUrl(value: string | null | undefined) { + const url = parseSocialUrl(value); + if (!value || !String(value).trim()) return true; + if (!url) return false; + return url.protocol === "https:" && isAllowedSocialHostname(url.hostname.toLowerCase(), ["instagram.com"]); +} + +export function getFacebookEmbedUrl(value: string | null | undefined) { + const trimmed = normalizeSocialUrl(value); + if (!isValidFacebookUrl(trimmed)) return null; + const url = new URL(trimmed); + const embedUrl = new URL("https://www.facebook.com/plugins/page.php"); + embedUrl.searchParams.set("href", url.toString()); + embedUrl.searchParams.set("tabs", "timeline"); + embedUrl.searchParams.set("width", "500"); + embedUrl.searchParams.set("height", "380"); + embedUrl.searchParams.set("small_header", "true"); + embedUrl.searchParams.set("adapt_container_width", "true"); + embedUrl.searchParams.set("hide_cover", "false"); + embedUrl.searchParams.set("show_facepile", "false"); + return embedUrl.toString(); +} + +export function getSocialHandle(value: string | null | undefined) { + const url = parseSocialUrl(value); + if (!url) return null; + + const handle = url.pathname + .split("/") + .map((part) => part.trim()) + .filter(Boolean)[0]; + + return handle ? `@${handle}` : null; +} diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..47ca6e0 --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,7 @@ +/** + * Unified type exports + * Import shared types from this single entry point. + */ + +export type * from "../drizzle/schema"; +export * from "./_core/errors"; diff --git a/tmp-documents-entry.png b/tmp-documents-entry.png new file mode 100644 index 0000000..95889ba Binary files /dev/null and b/tmp-documents-entry.png differ diff --git a/tmp-documents-header-mobile.png b/tmp-documents-header-mobile.png new file mode 100644 index 0000000..aa209fa Binary files /dev/null and b/tmp-documents-header-mobile.png differ diff --git a/tmp-material-entry.png b/tmp-material-entry.png new file mode 100644 index 0000000..06be1c5 Binary files /dev/null and b/tmp-material-entry.png differ diff --git a/tmp-mes-demandes-mobile.png b/tmp-mes-demandes-mobile.png new file mode 100644 index 0000000..29d5a50 Binary files /dev/null and b/tmp-mes-demandes-mobile.png differ diff --git a/tmp-mes-demandes-tabs-narrow.png b/tmp-mes-demandes-tabs-narrow.png new file mode 100644 index 0000000..8f6c18f Binary files /dev/null and b/tmp-mes-demandes-tabs-narrow.png differ diff --git a/tmp-new-request-entry.png b/tmp-new-request-entry.png new file mode 100644 index 0000000..925dea9 Binary files /dev/null and b/tmp-new-request-entry.png differ diff --git a/tmp-new-request-header-mobile.png b/tmp-new-request-header-mobile.png new file mode 100644 index 0000000..8b0da54 Binary files /dev/null and b/tmp-new-request-header-mobile.png differ diff --git a/tmp-profile-entry.png b/tmp-profile-entry.png new file mode 100644 index 0000000..b53d7cd Binary files /dev/null and b/tmp-profile-entry.png differ diff --git a/tmp-profile-header-mobile.png b/tmp-profile-header-mobile.png new file mode 100644 index 0000000..5cd2f2b Binary files /dev/null and b/tmp-profile-header-mobile.png differ diff --git a/tmp-requests-entry.png b/tmp-requests-entry.png new file mode 100644 index 0000000..e77cceb Binary files /dev/null and b/tmp-requests-entry.png differ diff --git a/tmp-requests-header-mobile.png b/tmp-requests-header-mobile.png new file mode 100644 index 0000000..ed50128 Binary files /dev/null and b/tmp-requests-header-mobile.png differ diff --git a/tmp-reservation-entry.png b/tmp-reservation-entry.png new file mode 100644 index 0000000..ab28c24 Binary files /dev/null and b/tmp-reservation-entry.png differ diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..5c13327 --- /dev/null +++ b/todo.md @@ -0,0 +1,125 @@ +# Portail Associations - TODO + +## Authentification et Utilisateurs +- [x] Système d'authentification avec OAuth Manus +- [x] Page de connexion avec design DSFR +- [x] Gestion des rôles (association, admin) + +## Profil Association +- [x] Formulaire de profil complet (nom, SIRET, adresse, statuts juridiques) +- [x] Validation des données SIRET +- [x] Sauvegarde et mise à jour du profil + +## Gestion des Documents +- [x] Upload de documents vers S3 (statuts, récépissé, RIB) +- [x] Liste des documents avec téléchargement +- [x] Suppression de documents +- [x] Catégorisation des documents par type + +## Formulaires de Demande +- [x] Bibliothèque de formulaires pré-configurés (subventions, agréments, autorisations) +- [x] Formulaires intelligents pré-remplis avec données association +- [x] Validation des formulaires avant soumission +- [x] Système d'envoi aux services dédiés + +## Suivi des Demandes +- [x] Tableau de bord avec liste des demandes +- [x] Statuts des demandes (en cours, validée, refusée) +- [x] Historique des demandes + +## Espace Administrateur +- [x] Liste des associations inscrites +- [x] Gestion des demandes reçues +- [x] Validation/refus des demandes +- [x] Notifications aux associations + +## Pages Informatives +- [x] Page d'accueil avec présentation +- [x] Page FAQ et guide des démarches +- [x] Page de contact + +## Design et UX +- [x] Design DSFR (bleu marine, navigation claire) +- [x] Responsive design +- [x] Accessibilité + + +## Interface Administration Enrichie + +### Tableau de bord analytique +- [x] Statistiques en temps réel (associations, demandes par statut, taux d'acceptation) +- [x] Graphiques d'évolution (demandes par mois, inscriptions, montants accordés) +- [x] Indicateurs de performance (délai moyen, demandes en attente) + +### Gestion avancée des associations +- [x] Liste paginée avec recherche et filtres +- [x] Fiche détaillée avec historique des demandes +- [x] Désactivation/réactivation de compte +- [x] Export CSV/Excel + +### Gestion complète des demandes +- [x] File d'attente priorisée +- [x] Filtres avancés (type, statut, montant, date) +- [x] Assignation à un agent traitant +- [x] Historique des actions (qui, quand, commentaires) +- [x] Modèles de réponses pré-enregistrés + +### Gestion des utilisateurs admin +- [x] Liste des administrateurs avec rôles +- [x] Création/modification de comptes admin +- [x] Journal d'audit des actions + +### Paramètres du portail +- [ ] Configuration des types de demandes +- [ ] Gestion des documents requis par type +- [ ] Personnalisation des emails de notification +- [ ] Paramètres des délais de traitement + +### Notifications et alertes +- [x] Centre de notifications nouvelles demandes +- [x] Alertes demandes en retard +- [x] Rappels dossiers incomplets + + +## Bugs corrigés +- [x] Menu Administration non visible pour les super_admin (condition vérifiait uniquement 'admin') + +- [x] Erreur React #310 lors de la visualisation d'une demande pour validation +- [x] Erreur Select.Item avec valeur vide dans les filtres admin +- [x] Les administrateurs ne peuvent pas voir les documents des associations +- [x] Clic sur notification dans l'onglet admin mène à une erreur 404 +- [x] Bouton de traitement direct sur la page de détail des demandes pour les admins +- [x] Option d'afficher les documents en plus du téléchargement pour les admins +- [x] Permettre de reprendre une demande en cours pour la compléter ou ajouter des documents +- [x] Corriger l'erreur de hooks dans Admin.tsx (return conditionnel avant tous les hooks) +- [x] Bouton Éditer visible à côté de Voir sur TOUTES les pages pour les demandes non validées/refusées +- [x] Boutons Valider/Refuser dans les emails de notification pour traiter depuis Gmail +- [x] Routes API sécurisées pour traitement via lien email (token unique) +- [x] Pages de confirmation après action depuis email +- [x] Premier onglet de la page Admin non cliquable / non interactif +- [x] Formulaire de réservation de salle MJS intégré avec les 6 salles disponibles +- [x] Pré-remplissage automatique des infos association (nom, adresse, commune) +- [x] Envoi de la demande de réservation à l'administration depuis l'espace membre +- [x] Notification à l'admin lors d'une nouvelle réservation de salle +- [ ] Premier onglet admin (Tableau de bord) non cliquable - bug récurrent des hooks React +- [ ] Formulaire de réservation de salle introuvable dans le menu +- [x] Cadre réservé à la Direction des Services aux Usagers pour les réservations de salle (champs admin à remplir lors du traitement) +- [x] Affichage complet des détails de réservation de salle (salles, dates, créneaux, motif, participants, matériel, observations) visible pour l'association ET l'administration à toutes les étapes +- [x] Cadre DSU visible après remplissage pour l'association et l'administration +- [x] Pré-remplissage automatique des informations de l'association dans le formulaire de réservation de salle +- [x] Téléchargement PDF du formulaire de réservation complet (association + administration) +- [x] Calendrier interactif des réservations accordées dans l'administration (vue temps réel) +- [x] Modification d'attribution des salles par l'administration depuis le calendrier +- [x] Notification automatique aux associations lors d'un changement de salle +- [x] Affichage complet de toutes les demandes pour l'administration (description, montant demandé, documents joints, informations association) +- [x] Suppression de demande par l'association (si elle ne veut plus l'éditer) +- [x] Bug 404 : clic sur "Voir la demande" depuis le calendrier des réservations admin mène à une page introuvable +- [x] Annulation de demande par l'association (demandes soumises ou en édition) avec statut "annulée" +- [x] Notification à l'administration lors de l'annulation d'une demande +- [x] Modification de demande par l'association (demandes soumises ou en édition) avec notification admin +- [x] Mise à jour en temps réel du calendrier admin après annulation/modification d'une réservation de salle +- [x] Bouton "Annuler la demande" à côté du bouton Soumettre dans les formulaires de demande (supprime la demande en cours) +- [x] Envoi automatique par email de la demande intégrale à tous les administrateurs après traitement (validation/refus) +- [x] Bug : impossible de passer de l'étape 2 (détails) à l'étape 3 (récapitulatif) dans le formulaire de réservation de salle +- [x] Bug : les informations de la demande de réservation de salle affichent "-" dans le détail après soumission (infos association, dates, salles, créneaux) +- [x] Bug critique : données de réservation perdues (dates, créneaux, salles, participants, matériel, observations) entre création/brouillon et affichage final pour association et administration diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a0203ee --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["client/src/**/*", "shared/**/*", "server/**/*"], + "exclude": ["node_modules", "build", "dist", "**/*.test.ts"], + "compilerOptions": { + "incremental": true, + "tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo", + "noEmit": true, + "module": "ESNext", + "strict": true, + "lib": ["esnext", "dom", "dom.iterable"], + "jsx": "preserve", + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "moduleResolution": "bundler", + "baseUrl": ".", + "types": ["node", "vite/client"], + "paths": { + "@/*": ["./client/src/*"], + "@shared/*": ["./shared/*"] + } + } +} diff --git a/uploads/portal/stats-reports/weekly/6YN2tyVLHpcywrfeIZArg-rapport-statistiques-ccds-7d-2026-06-14.pdf b/uploads/portal/stats-reports/weekly/6YN2tyVLHpcywrfeIZArg-rapport-statistiques-ccds-7d-2026-06-14.pdf new file mode 100644 index 0000000..0ad766e Binary files /dev/null and b/uploads/portal/stats-reports/weekly/6YN2tyVLHpcywrfeIZArg-rapport-statistiques-ccds-7d-2026-06-14.pdf differ diff --git a/uploads/portal/stats-reports/weekly/M4T20z4sa-myDunNJhP8W-rapport-statistiques-ccds-7d-2026-06-14.xlsx b/uploads/portal/stats-reports/weekly/M4T20z4sa-myDunNJhP8W-rapport-statistiques-ccds-7d-2026-06-14.xlsx new file mode 100644 index 0000000..7e49edd Binary files /dev/null and b/uploads/portal/stats-reports/weekly/M4T20z4sa-myDunNJhP8W-rapport-statistiques-ccds-7d-2026-06-14.xlsx differ diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c86f840 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,218 @@ +import { jsxLocPlugin } from "@builder.io/vite-plugin-jsx-loc"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import fs from "node:fs"; +import path from "node:path"; +import { defineConfig, type Plugin, type ViteDevServer } from "vite"; +import { vitePluginManusRuntime } from "vite-plugin-manus-runtime"; + +// ============================================================================= +// Manus Debug Collector - Vite Plugin +// Writes browser logs directly to files, trimmed when exceeding size limit +// ============================================================================= + +const PROJECT_ROOT = import.meta.dirname; +const LOG_DIR = path.join(PROJECT_ROOT, ".manus-logs"); +const MAX_LOG_SIZE_BYTES = 1 * 1024 * 1024; // 1MB per log file +const TRIM_TARGET_BYTES = Math.floor(MAX_LOG_SIZE_BYTES * 0.6); // Trim to 60% to avoid constant re-trimming + +type LogSource = "browserConsole" | "networkRequests" | "sessionReplay"; + +function ensureLogDir() { + if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); + } +} + +function trimLogFile(logPath: string, maxSize: number) { + try { + if (!fs.existsSync(logPath) || fs.statSync(logPath).size <= maxSize) { + return; + } + + const lines = fs.readFileSync(logPath, "utf-8").split("\n"); + const keptLines: string[] = []; + let keptBytes = 0; + + // Keep newest lines (from end) that fit within 60% of maxSize + const targetSize = TRIM_TARGET_BYTES; + for (let i = lines.length - 1; i >= 0; i--) { + const lineBytes = Buffer.byteLength(`${lines[i]}\n`, "utf-8"); + if (keptBytes + lineBytes > targetSize) break; + keptLines.unshift(lines[i]); + keptBytes += lineBytes; + } + + fs.writeFileSync(logPath, keptLines.join("\n"), "utf-8"); + } catch { + /* ignore trim errors */ + } +} + +function writeToLogFile(source: LogSource, entries: unknown[]) { + if (entries.length === 0) return; + + ensureLogDir(); + const logPath = path.join(LOG_DIR, `${source}.log`); + + // Format entries with timestamps + const lines = entries.map((entry) => { + const ts = new Date().toISOString(); + return `[${ts}] ${JSON.stringify(entry)}`; + }); + + // Append to log file + fs.appendFileSync(logPath, `${lines.join("\n")}\n`, "utf-8"); + + // Trim if exceeds max size + trimLogFile(logPath, MAX_LOG_SIZE_BYTES); +} + +/** + * Vite plugin to collect browser debug logs + * - POST /__manus__/logs: Browser sends logs, written directly to files + * - Files: browserConsole.log, networkRequests.log, sessionReplay.log + * - Auto-trimmed when exceeding 1MB (keeps newest entries) + */ +function vitePluginManusDebugCollector(): Plugin { + return { + name: "manus-debug-collector", + + transformIndexHtml(html) { + if (process.env.NODE_ENV === "production") { + return html; + } + return { + html, + tags: [ + { + tag: "script", + attrs: { + src: "/__manus__/debug-collector.js", + defer: true, + }, + injectTo: "head", + }, + ], + }; + }, + + configureServer(server: ViteDevServer) { + // POST /__manus__/logs: Browser sends logs (written directly to files) + server.middlewares.use("/__manus__/logs", (req, res, next) => { + if (req.method !== "POST") { + return next(); + } + + const handlePayload = (payload: any) => { + // Write logs directly to files + if (payload.consoleLogs?.length > 0) { + writeToLogFile("browserConsole", payload.consoleLogs); + } + if (payload.networkRequests?.length > 0) { + writeToLogFile("networkRequests", payload.networkRequests); + } + if (payload.sessionEvents?.length > 0) { + writeToLogFile("sessionReplay", payload.sessionEvents); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true })); + }; + + const reqBody = (req as { body?: unknown }).body; + if (reqBody && typeof reqBody === "object") { + try { + handlePayload(reqBody); + } catch (e) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: false, error: String(e) })); + } + return; + } + + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + + req.on("end", () => { + try { + const payload = JSON.parse(body); + handlePayload(payload); + } catch (e) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: false, error: String(e) })); + } + }); + }); + }, + }; +} + +const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), vitePluginManusDebugCollector()]; + +export default defineConfig({ + plugins, + resolve: { + alias: { + "@": path.resolve(import.meta.dirname, "client", "src"), + "@shared": path.resolve(import.meta.dirname, "shared"), + "@assets": path.resolve(import.meta.dirname, "attached_assets"), + }, + }, + envDir: path.resolve(import.meta.dirname), + root: path.resolve(import.meta.dirname, "client"), + publicDir: path.resolve(import.meta.dirname, "client", "public"), + build: { + outDir: path.resolve(import.meta.dirname, "dist/public"), + emptyOutDir: true, + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes("node_modules")) { + return undefined; + } + + if (id.includes("/xlsx/")) { + return "vendor-xlsx"; + } + + if (id.includes("/maplibre-gl/") || id.includes("/leaflet/")) { + return "vendor-maps"; + } + + if (id.includes("/recharts/")) { + return "vendor-charts"; + } + + if (id.includes("/react-hook-form/") || id.includes("/@hookform/")) { + return "vendor-forms"; + } + + if (id.includes("/@radix-ui/") || id.includes("/vaul/")) { + return "vendor-radix"; + } + + return "vendor-misc"; + }, + }, + }, + }, + server: { + host: true, + allowedHosts: [ + ".manuspre.computer", + ".manus.computer", + ".manus-asia.computer", + ".manuscomputer.ai", + ".manusvm.computer", + "localhost", + "127.0.0.1", + ], + fs: { + strict: true, + deny: ["**/.*"], + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..c549003 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +const templateRoot = path.resolve(import.meta.dirname); + +export default defineConfig({ + root: templateRoot, + resolve: { + alias: { + "@": path.resolve(templateRoot, "client", "src"), + "@shared": path.resolve(templateRoot, "shared"), + "@assets": path.resolve(templateRoot, "attached_assets"), + }, + }, + test: { + environment: "node", + include: ["server/**/*.test.ts", "server/**/*.spec.ts"], + }, +});