diff --git a/40_decisions_et_archi.md b/40_decisions_et_archi.md index ce3ec6a..9a851fb 100644 --- a/40_decisions_et_archi.md +++ b/40_decisions_et_archi.md @@ -30,6 +30,7 @@ Dernière mise à jour : 2026-03-12 - [Le front-end est un logiciel en production](#decision-frontend-production) - [Single source of truth des contrats — schémas runtime partagés (Zod) + z.infer (No-DTO)](#decision-contrats-sso-zod) - [User views — User public par défaut + MeUser explicite](#decision-user-views) +- [Mono-tenant déployable vs SaaS multi-tenant](#decision-mono-tenant-deployable) ### 2. Infra @@ -293,6 +294,48 @@ Règles associées : --- + + +## Mono-tenant déployable vs SaaS multi-tenant + +- Date : 2026-04-27 +- Statut : Accepted +- Périmètre : global + +### Contexte + +Pour une app vendable à plusieurs clients qui fonctionnent chacun individuellement (ex : loges, cabinets, écoles), le choix d'architecture multi-tenant a un impact majeur sur la complexité, la sécurité et le coût opérationnel. Trois architectures possibles, à trancher tôt. + +### Options envisagées + +| Approche | Description | Pour | Contre | +| -------- | ----------- | ---- | ------ | +| **Mono-tenant déployable** | Chaque client = son instance app + DB + uploads + domaine | Pas d'isolation cross-tenant à coder, complexité ÷10, sécurité plus simple | Coût opérationnel par déploiement, pas de cross-tenant report | +| **SaaS multi-tenant logique** | Une app, une DB, `tenantId` partout | Coût opérationnel ÷N, cross-tenant analytics | Isolation à coder partout (RLS, scoping, tests cross-tenant), risque leak data | +| **SaaS multi-tenant physique** | Une app, plusieurs DBs (1 par tenant) | Isolation physique, scaling fin | Routing complexe, ops par tenant, migration cross-DB | + +### Décision + +Pour un projet dont la cible est constituée de clients indépendants qui veulent leur autonomie, **préférer le mono-tenant déployable** (Option 1) tant que : + +- pas de feature cross-tenant attendue (pas de "voir les statistiques tous clients") +- l'admin technique de chaque instance peut être différent +- la simplicité de la sécurité (pas de scoping `tenantId` partout, pas de RLS Postgres, pas de tests "leak cross-tenant") prime sur l'économie d'opérations + +### Justification + +- Population cible = clients indépendants qui veulent leur autonomie +- Sécurité plus simple : pas de scoping multi-tenant à valider partout +- Conséquence : modélisation des configurations en singleton (cf. `pattern-singleton-db-config-globale` dans `knowledge/backend/patterns/general.md`) + +### Conséquences + +- Chaque déploiement a sa propre DB, ses propres uploads, son propre domaine +- Pipeline CI/CD par instance ou via template (cf. `pattern-pipeline-cicd-github-actions-vps` dans `knowledge/backend/patterns/general.md`) +- Migration vers Option 2 si la cible évolue (back-office central pour toutes les instances) → **réécriture explicite** plutôt que rétro-fit (ajouter `tenantId` partout est non-trivial) + +--- + ## 2. Infra diff --git a/90_debug_et_postmortem.md b/90_debug_et_postmortem.md index ac68a9c..3e37f10 100644 --- a/90_debug_et_postmortem.md +++ b/90_debug_et_postmortem.md @@ -220,3 +220,113 @@ npm install -g @latest - `npm root -g` - `npm ls -g --depth=0 ` | npm list -g @openai/codex --depth=0 - --version + +--- + +## Sub-agents Claude Code — `Write` indisponible dans la sandbox `Explore` + +### Contexte + +Workflow BMAD `testarch-test-review` sur RL799_V2 (24-04-2026) utilisant 4 sub-agents `subagent_type=Explore` pour évaluer 4 dimensions qualité en parallèle. Chaque sub-agent devait écrire un fichier JSON dans `/tmp/`. + +### Symptômes + +- Les 4 sub-agents ont terminé leur analyse avec succès mais **aucun n'a réussi à écrire son fichier JSON** +- Messages de retour : *"Je rencontre une limitation d'outillage… je suis en mode READ-ONLY… je génère le rapport directement en texte."* + +### Cause + +Le sub-agent type `Explore` n'a pas accès à l'outil `Write` dans sa sandbox (spec : "Tools: All tools except Agent, ExitPlanMode, Edit, Write, NotebookEdit"). Non documenté clairement dans les workflows TEA qui demandent pourtant d'écrire en JSON. + +### Correctif / règle à retenir + +1. **Ne pas demander aux sub-agents `Explore` d'utiliser `Write`** — briefer explicitement "retourne le JSON en bloc dans ta réponse finale" +2. **L'orchestrateur matérialise** les fichiers de sortie pour le compte des sub-agents +3. **Alternative** : utiliser `subagent_type=general-purpose` qui a accès à tous les tools (mais plus cher en tokens et moins spécialisé pour l'exploration) + +Extrait de brief corrigé pour futur usage : + +``` +Ta mission : analyse X dans les fichiers Y. +Format de sortie : JSON structuré selon le schéma ci-dessous. +IMPORTANT : retourne le JSON directement dans ta réponse finale, entre blocs ```json```. +Ne tente pas d'écrire de fichier (Write indisponible dans ta sandbox). +L'orchestrateur matérialisera le fichier à partir de ton retour. +``` + +--- + +## Effet iceberg en CI — patcher en cascade jusqu'au fond du puits + +### Contexte + +Quand un fix CI structurant rétablit un pipeline qui foirait depuis longtemps, **plusieurs bugs latents en aval peuvent apparaître en cascade** : ils étaient tous présents avant, juste invisibles parce que le runner s'arrêtait à l'échec amont. Vécu sur RL799_V2 le 30-04 / 01-05-2026, 8 étages d'iceberg fixés en cascade. + +### Symptômes + +| # | Phase | Symptôme | Cause | Fix | +|---|---|---|---|---| +| 1 | CI tests | `Cannot find module '@org/shared'` | `dist/lib` non bâti avant `test:api` | Build workspace en amont | +| 2 | CI tests | `Module '@prisma/client' has no exported member 'X'` | Client Prisma non généré | Inverser `prisma generate` → `pnpm build` | +| 3 | CI tests | `Seed incomplet : 0 users / N attendus` | Étape seed manquante | Ajouter `prisma db seed` après `prisma migrate deploy` | +| 4 | CI tests | ` non configuré (requis hors dev)` | Variable d'env applicative manquante en CI | Définir au bloc `env:` du job | +| 5 | CI tests | 14×500 sur endpoints qui chiffrent | `ENCRYPTION_KEY` manquante | Idem | +| 6 | CI tests (PDF) | `Could not find Chrome` | Puppeteer cherche son cache local absent du runner | `PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable` | +| 7 | CD prod (migrate) | `Cannot find module '/app/scripts/check-node-version.mjs'` | `pnpm run prisma:migrate` appelle un script absent de l'image API | Appel direct du binaire Prisma | +| 8 | CI tests | Test attend `50,00 €` reçoit `1,19 €` | `waitForNotification` mal scopé (filtre par `type` mais pas par `recipientId`) — masquée par les étages 1-7 | Re-run OU patch chirurgical du `where:` | + +Chaque étage masquait le suivant. Aucun n'était nouveau — tous présents avant la session, mais invisibles à cause des étages amont. + +### Cause + +- **Local ≠ CI** : en local, `dist/` traîne, le client Prisma est généré, la DB est seedée d'une session précédente, le `.env` est complet. Le bug est invisible +- **Pipeline early-exit** : un échec à l'étape N ne laisse rien tourner aux étapes N+1, N+2, … +- **Effet additif des sessions** : plus le pipeline est cassé depuis longtemps, plus le code applicatif a évolué sans validation CI + +### Correctif / règle à retenir + +1. **Validation locale stricte avant push CI structurant** : simuler les conditions CI vierges (`rm -rf node_modules/.prisma packages/*/dist apps/*/.next` + relancer la chaîne complète) +2. **Lecture honnête des nouveaux failures** : après un fix CI structurant, ne pas présumer que les nouveaux failures sont des régressions du fix. Probablement des bugs latents +3. **Tableau iceberg** : noter au fil de la session le tableau (étage / symptôme / cause / fix). Ne pas se laisser submerger par "ça casse encore" +4. **Push après chaque étage** : ne pas attendre d'avoir tout fixé. Chaque fix structurant mérite son commit thématique +5. **Ne pas stopper trop tôt** : un seul push ne révèle qu'un étage. Tant qu'il y a des bugs latents, le pipeline cassera + +### Signal pour repérer un effet iceberg + +- Le pipeline était cassé depuis ≥ 1 semaine +- Le fix d'aujourd'hui touche une étape **précoce** du workflow (install, build, generate, migrate) +- Les commits récents ont ajouté des features sans valider en CI +- Sentiment vague de "ça pourrait casser plein d'autres trucs" — c'est probablement vrai + +--- + +## Prisma migrate inclut les diffs cosmétiques (RenameIndex) + +### Contexte + +`prisma migrate dev --create-only --name add_lodge_settings` peut générer une migration qui contient (1) le changement attendu mais aussi (2) un side-effect cosmétique pré-existant entre le schema Prisma et la DB qui n'avait jamais été nettoyé. RL799_V2 — migration `20260427120920_add_lodge_settings` qui ramassait un `ALTER INDEX … RENAME TO …` orphelin. + +### Symptômes + +- Migration thématique qui contient un rename d'index sans rapport avec le scope de la story +- Un dev qui regarde la migration ne comprend pas pourquoi cet `ALTER INDEX` est là + +### Options et décision + +| Option | Pro | Con | +|---|---|---| +| Garder le rename dans la migration thématique avec commentaire | la prochaine `prisma migrate dev` ne re-générera pas ce rename | le commit "thématique" contient un side-effect cosmétique | +| Retirer le rename | commit propre | la prochaine migration thématique l'inclura à nouveau → piège pour le prochain dev | +| Migration de cleanup séparée | plus propre | nécessite 2 migrations + 2 PRs | + +**Décision recommandée** : option 1 avec commentaire explicite dans le `.sql` : + +```sql +-- RenameIndex (réalignement DB ↔ schema, dérive cosmétique pré-existante) +ALTER INDEX "tronc_entries_tenue_idx" RENAME TO "tronc_entries_tenue_id_idx"; +``` + +### Correctif / règle à retenir + +- **Préventif** : `prisma migrate diff` régulièrement (CI/CD ou pré-commit) pour détecter la dérive AVANT qu'elle ne pollue une migration thématique +- **Curatif** : inspecter manuellement le SQL généré par `--create-only` avant de l'appliquer en migration thématique diff --git a/95_a_capitaliser.md b/95_a_capitaliser.md index 0995975..0389eb4 100644 --- a/95_a_capitaliser.md +++ b/95_a_capitaliser.md @@ -20,6 +20,7 @@ vers les fichiers appropriés : - `knowledge/n8n/risques/general.md` - `knowledge/product/patterns/general.md` - `knowledge/product/risques/.md` +- `knowledge/workflow/patterns/general.md` - `knowledge/workflow/risques/story-tracking.md` - `10_conventions_redaction.md` - `40_decisions_et_archi.md` @@ -37,7 +38,7 @@ Chaque proposition doit suivre ce format : DATE — PROJET FILE_UPDATE_PROPOSAL -Fichier cible : .md | knowledge/backend/risques/.md | knowledge/frontend/patterns/.md | knowledge/frontend/risques/.md | knowledge/ux/patterns/.md | knowledge/ux/risques/.md | knowledge/n8n/patterns/general.md | knowledge/n8n/risques/general.md | knowledge/product/patterns/general.md | knowledge/product/risques/.md | knowledge/workflow/risques/story-tracking.md | 10_conventions_redaction.md | 40_decisions_et_archi.md | 90_debug_et_postmortem.md> +Fichier cible : .md | knowledge/backend/risques/.md | knowledge/frontend/patterns/.md | knowledge/frontend/risques/.md | knowledge/ux/patterns/.md | knowledge/ux/risques/.md | knowledge/n8n/patterns/general.md | knowledge/n8n/risques/general.md | knowledge/product/patterns/general.md | knowledge/product/risques/.md | knowledge/workflow/patterns/general.md | knowledge/workflow/risques/story-tracking.md | 10_conventions_redaction.md | 40_decisions_et_archi.md | 90_debug_et_postmortem.md> Pourquoi : @@ -75,7 +76,7 @@ Description courte, factuelle, orientée réutilisation. 3. La validation et l'intégration finale dans `Lead_tech` sont faites **manuellement**. 4. Une fois intégrée, la proposition doit être **supprimée de ce fichier**. -5. La structure de ce fichier est **restaurée à son état initial** (voir `70_templates/template_a_capitaliser.md`). +5. La structure de ce fichier est **restaurée à son état initial** (voir `70_templates/tempate_a_capitaliser.md`). --- diff --git a/knowledge/backend/patterns/README.md b/knowledge/backend/patterns/README.md index 73c13e2..3d30c72 100644 --- a/knowledge/backend/patterns/README.md +++ b/knowledge/backend/patterns/README.md @@ -15,5 +15,6 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript | `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique | | `multi-tenant.md` | Multi-tenant, isolation, feature flags | 403 vs 404, repository tenant-aware, tenantId dans updates, helper tenant partagé, feature flag tenant, EN enforcement | | `nextjs.md` | Next.js App Router, Server Actions, isolation | Runtime-only logique pure, server-only isolation, utilitaires purs sans server-only, réutiliser champ V1, validation URL externe | -| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents | -| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable | +| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents, hooks fire-and-forget après création DB, fanout notification avec filtre grade, auto-purge fenêtre temporelle SQL | +| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable, ordre canonique des gates HTTP, délégation agrégat → endpoint agrégé, anti-énumération DELETE 204, lazy init memoizé, cap LRU par-user, convention dot-notation audit, whitelist explicite audit, singleton DB config, invalidation cache avant mutation, pipeline CI/CD GitHub Actions → VPS | +| `tests.md` | Tests d'intégration DB, isolation, atomicité | `cleanup.track()` LIFO, `globalSetup` purge, template database Postgres, helper `waitForX()` polling-borné, test d'atomicité transaction, convention `describe()` 2 niveaux, refactor itératif d'un fichier monolithe | diff --git a/knowledge/backend/patterns/async.md b/knowledge/backend/patterns/async.md index fff315b..98568bd 100644 --- a/knowledge/backend/patterns/async.md +++ b/knowledge/backend/patterns/async.md @@ -77,3 +77,177 @@ - Dead-letter ou statut FAILED visible - Idempotence documentée - Logs corrélés (requestId/traceId) + +--- + + +## Pattern : Hooks fire-and-forget après création DB critique + +- Objectif : déclencher des hooks secondaires (mail accusé réception, notification, invalidation cache) après une création DB sans bloquer la réponse HTTP au client. +- Contexte : endpoint POST qui crée une ressource en DB et déclenche en cascade des hooks impliquant des appels réseau (Resend, FCM, Redis cache). +- Quand l'utiliser : hooks **rapides** (< 1-2 s) qui peuvent vivre dans le même process que la requête HTTP. +- Quand l'éviter : tâches lourdes (génération PDF, batch envoi sur 100 destinataires) — utiliser un vrai job queue (BullMQ, pg-boss). +- Avantage : + - la 201 part dès la création DB (l'AC critique de la route) + - chaque hook logge ses propres échecs sans bloquer le caller + - `Promise.allSettled` détaché → robustesse même si un hook futur ajoute un comportement async +- Limites / vigilance : + - dans Next.js 15+, préférer `after()` (cf. `knowledge/backend/patterns/nextjs.md`) qui garantit l'exécution post-réponse même en serverless + - `Promise.all` reject au premier échec — `allSettled` attend toutes les promesses + - tests : poll DB borné (`waitForX`) plutôt que `setTimeout(50)` (cf. `knowledge/backend/patterns/tests.md`) +- Validé le : 30-04-2026 +- Contexte technique : Node.js — RL799_V2 + +### Implémentation + +```typescript +// ✅ La 201 part dès la création DB ; les hooks tournent en parallèle +const created = await prisma.registration.create({ data }); + +// Promise.allSettled détaché : ne reject jamais, on capture quand même +// au cas où le service de log lui-même bug +void Promise.allSettled([ + sendAcknowledgmentMail(data.email), + notifyObservers(created.id), + invalidateCache(`stats:${data.scope}`), +]).catch((err) => { + logger.error({ type: 'hooks', event: 'unexpected_error', err: String(err) }); +}); + +return jsonResponse(201, { data: created }); +``` + +### Règles d'utilisation + +1. **L'AC critique doit être atteint avant** : la création DB doit réussir (await) — c'est le seul résultat que le client attend. +2. **Chaque hook doit logger ses propres échecs** : le service mail doit avoir son propre `logger.error` sur status=failed. Le `.catch()` du `Promise.allSettled` est un filet, pas le canal d'audit primaire. +3. **`Promise.allSettled` (pas `Promise.all`)** : robuste si un hook futur ajoute un comportement asynchrone derrière. +4. **Côté tests** : helper `waitForX` polling-borné plutôt que `setTimeout(N)` arbitraire. + +--- + + +## Pattern : Notification fanout fire-and-forget avec filtre grade plancher + +- Objectif : notifier N destinataires éligibles (filtrage par grade plancher) après une mutation, sans bloquer la réponse HTTP et sans rollback de la création principale si la notif échoue. +- Contexte : action métier qui crée une ressource + doit notifier les membres dont le grade ≥ grade plancher de la ressource (`SOIREE_CANCELLED` à tous les membres, `COMMUNICATION_PUBLISHED` aux membres de grade ≥ X, etc.). +- Quand l'utiliser : fanout multi-rôles avec filtrage métier sur le profil destinataire. +- Quand l'éviter : si la notif est critique (la ressource ne doit pas exister sans notif) — utiliser une transaction. +- Avantage : + - seuil monotone `gradeRank(member) >= gradeRank(resource)` aligné sur les filtres `list*` consommateurs + - exclusion du créateur via `id: { not: userId }` pour éviter de se notifier soi-même + - log explicite sur `catch` du fire-and-forget — pas de perte silencieuse +- Limites / vigilance : + - pas de transaction avec la création principale : best-effort, dégradation acceptable + - le `linkUrl` doit être rôle-aware (cf. `knowledge/backend/risques/general.md` risque-notif-linkurl-non-role-aware) +- Validé le : 23-04-2026 +- Contexte technique : Prisma — RL799_V2 + +### Implémentation + +```typescript +const createResourceNotifications = async (input: { + resourceId: string; + grade: string; // plancher (seuil monotone) + excludeUserId?: string; +}): Promise => { + const thresholdRank = gradeRank(input.grade); + + const recipients = await prisma.user.findMany({ + where: { + isActive: true, + role: { in: [...ROLES_ALL_ACTIVE] }, + id: input.excludeUserId ? { not: input.excludeUserId } : undefined, + profile: { is: {} }, + }, + select: { + id: true, + role: true, // pour linkUrl rôle-aware si multi-rôles + profile: { select: { grade: true } }, + }, + }); + + const eligibleIds = recipients + .filter((r) => { + const g = r.profile?.grade; + if (!g) return false; + return gradeRank(g) >= thresholdRank; + }) + .map((r) => r.id); + + if (eligibleIds.length === 0) return; + + await prisma.notification.createMany({ + data: eligibleIds.map((recipientId) => ({ + type: NotificationType.RESOURCE_CREATED, + recipientId, + // … + linkUrl: ..., // rôle-aware si nécessaire + })), + }); +}; + +// Côté handler +try { + const resource = await createResource({ ... }); + logAction(userId, 'resource:create', ...); + + // Fire-and-forget + void createResourceNotifications({ + resourceId: resource.id, + ...minimumDataForNotif, + }).catch((err) => { + console.error('[resource:create] notification fanout failed:', err); + }); + + return jsonResponse(201, { data: serialize(resource) }); +} catch { + return errorResponse(500, ...); +} +``` + +### Pourquoi un seuil monotone + +`gradeRank(member) >= gradeRank(resource)` = "à partir du grade X", aligné sur les filtres `list*` consommateurs. Évite les sélections non-contiguës (A+M sans C) qui sont pénibles à représenter. + +--- + + +## Pattern : Auto-purge côté vue via fenêtre temporelle SQL + +- Objectif : faire porter la rétention courte par le filtre de lecture plutôt que par un cron de purge réelle, quand une donnée a deux publics avec des besoins de rétention différents. +- Contexte : donnée consultée à long terme côté admin/historique mais utile uniquement sur fenêtre courte côté consommateur final (membre lambda). +- Quand l'utiliser : 2 publics, rétention courte côté consommateur, rétention longue côté admin, volumétrie raisonnable. +- Quand l'éviter : + - volumétrie très élevée (millions de rows) — finir par un vrai archivage si le volume explose + - RGPD / obligations légales de suppression — il faut **vraiment** supprimer la donnée, pas la masquer + - données avec coût de stockage significatif (PDF, blobs, logs verbeux) — purge réelle + archivage externe +- Avantage : + - pas de cron à écrire, déployer, monitorer + - zéro risque de purge destructive : la donnée reste en DB + - rétention courte est **déclarative** (paramètre de query), pas cachée dans un job planifié + - l'admin conserve l'accès complet via un autre endpoint +- Limites / vigilance : + - index sur `createdAt` indispensable dès que la table grossit +- Validé le : 23-04-2026 +- Contexte technique : Prisma / Postgres — RL799_V2 + +### Implémentation + +```typescript +export const listRecentXxxForMember = async ( + ...filters, + sinceDays = 30, +) => { + const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000); + return prisma.xxx.findMany({ + where: { + ...filters, + createdAt: { gte: since }, + }, + orderBy: { createdAt: 'desc' }, + }); +}; +``` + +L'admin garde un endpoint distinct sans le filtre temporel pour l'accès historique complet. diff --git a/knowledge/backend/patterns/auth.md b/knowledge/backend/patterns/auth.md index 1c1b767..46edce7 100644 --- a/knowledge/backend/patterns/auth.md +++ b/knowledge/backend/patterns/auth.md @@ -342,3 +342,338 @@ Le helper `requireRoleAccess` doit retourner : - Contexte technique : auth / audit — RL799_V2 --- + + +## Pattern : Consume single-use = pas de session implicite, force re-login + +- Objectif : éviter qu'un endpoint qui consume un magic link (invitation, reset password) émette une session implicite — défense en profondeur contre l'interception d'email. +- Contexte : flows magic-link qui aboutissent à un `consume` (set du nouveau mot de passe ou activation du compte). +- Quand l'utiliser : tous les flows single-use qui touchent au cycle d'authentification. +- Quand l'éviter : magic-link "passwordless" pur (sans étape de saisie de mot de passe) où le link EST le facteur d'auth. +- Avantage : + - un attaquant qui intercepte le mail obtient un consume, pas une session + - cohérence cross-flow : un seul pattern post-consume, surface d'audit réduite + - factorisation page consume facilitée (les modes diffèrent par wording, pas par flow) +- Limites / vigilance : + - friction perçue d'un re-login → minime, l'utilisateur vient de saisir son mot de passe +- Validé le : 28-04-2026 +- Contexte technique : auth / magic-link — RL799_V2 + +### Règle + +Réponse minimale post-consume : `{ data: { ok: true, email } }`. Pas de cookie JWT, pas d'access token. Le frontend redirige vers `/login?email=…` avec bouton "Se connecter" bien placé sur la page de succès. + +### Anti-pattern + +- "Auto-login pour invitation, re-login pour reset" sous prétexte UX — divergence asymétrique = anti-pattern de sécurité +- Cookie JWT émis avant que l'utilisateur ait validé qu'il connaît son nouveau mot de passe + +--- + + +## Pattern : Magic-link consume — mécanique partagée sans fusion table + +- Objectif : factoriser la mécanique sécu d'un consume single-use (hash SHA-256, transaction atomique, révocation refresh tokens) sans fusionner les modèles DB des deux domaines. +- Contexte : projet avec deux flows partageant la même mécanique (invitation magic-link + reset password) mais des objets métier différents (invitation = audit riche, reset = purement technique). +- Quand l'utiliser : factorisation au niveau **service**, pas au niveau table. +- Quand l'éviter : un seul flow magic-link dans le projet — pas de factorisation prématurée. +- Avantage : + - audit historique riche pour les invitations (statut traçable) + - transaction atomique partagée (rotation/consume) + - rate limiter dédié par endpoint sans pollution cross-domaine +- Limites / vigilance : + - duplication contrôlée des modèles DB (acceptable — chaque domaine a son cycle de vie) +- Validé le : 28-04-2026 +- Contexte technique : auth / magic-link / Prisma — RL799_V2 + +### Règles d'or + +1. **Tokens stockés en hash SHA-256 uniquement** (`tokenHash @unique`). Le raw token n'est transmis que dans l'URL email, jamais persisté. Helper `hashXxxToken(raw: string): string` réutilisable côté service ET tests. + +2. **PK technique `id String @id @default(uuid())`**, pas le token comme PK. Évite le couplage PK/sécurité, permet la rotation d'un token sans créer une nouvelle row. + +3. **Transaction atomique consume** : + - Lock implicite via `findUnique({ where: { tokenHash }, include: { user } })` + - Check `status === 'active'`, `consumedAt === null`, `revokedAt === null`, `expiresAt > now`, `user.isActive === true` + - UPDATE token `status='consumed', consumedAt=now()` + - UPDATE user (password si applicable) + - UPDATE refresh_tokens `revokedAt=now()` (force re-login propre) + - UPDATE autres tokens actifs `status='revoked'` (anti-réutilisation) + - INSERT audit dans la transaction (atomicité stricte) + +4. **Retour discriminé** : + +```typescript +export type ConsumeResult = + | { ok: true; userId: string; email: string } + | { ok: false; reason: 'NOT_FOUND' | 'ALREADY_USED' | 'EXPIRED' | 'REVOKED' | 'USER_INACTIVE' }; +``` + +Le frontend ne connaît qu'un code générique côté UX (`INVALID_X_TOKEN`) pour ne pas exposer d'oracle. + +5. **Rate limiter dédié par endpoint** : + - `validate` : 30 req / 15 min / IP (oracle d'énumération) + - `consume` : 10 req / 15 min / IP (modifie le mot de passe) + - `resend` (admin) : 20 req / heure / admin + +6. **Helpers transverses** : `revokeAndIssueXxx(input)` en transaction unique (couplé à un index unique partiel `WHERE status='active'`), `revokeXxxForUser(userId)` (appelé par `setUserActive(false)`), `getLatestXxxByUserIds(userIds[])` batch (anti N+1). + +--- + + +## Pattern : Sentinelle non-hashable pour user en attente de mot de passe + +- Objectif : éviter à la fois un `password: String?` nullable qui casse les chemins login/test/audit qui font tous des `select: { password }`, et un appel scrypt inutile (~100 ms par user invité). +- Contexte : user créé via invitation qui n'a pas encore défini son mot de passe. +- Quand l'utiliser : tout flow d'invitation où le user existe en base avant le set du password. +- Quand l'éviter : projet où le user n'est créé qu'au consume du magic link (pas de placeholder nécessaire). +- Avantage : + - le champ `password` reste `NOT NULL` en DB → aucun chemin code ne casse + - `verifyPassword` détecte le préfixe en early-return → 0 ms scrypt + - garantie cryptographique (pas conventionnelle) : le préfixe `!` est absent d'une sortie hex +- Limites / vigilance : + - la sentinelle est lisible en clair par un admin DB — acceptable car c'est un placeholder identifié, pas un secret +- Validé le : 28-04-2026 +- Contexte technique : auth / scrypt — RL799_V2 + +### Implémentation + +```typescript +// services/.../inviteService.ts +const buildInvitedPlaceholderPassword = (): string => + `!INVITED_PENDING_${crypto.randomUUID()}`; + +await prisma.user.create({ + data: { email, password: buildInvitedPlaceholderPassword(), ... }, +}); + +// lib/passwords.ts +export const verifyPassword = (password: string, stored: StoredPassword): boolean => { + if (typeof stored !== 'string' || typeof password !== 'string') return false; + if (stored.startsWith('!')) return false; // placeholder réservé, jamais matchable + // …logique scrypt habituelle +}; +``` + +### Checklist + +- [ ] Préfixe `!` (caractère **garanti** absent de l'alphabet hex `0-9a-f`) +- [ ] UUID embarqué pour l'unicité par user (utile pour debug audit, pas un secret) +- [ ] Test : `verifyPassword('anything', '!INVITED_PENDING_xxx') === false` +- [ ] AC dédié : `verifyPassword` rejette en 0 ms observable (pas d'appel scrypt) + +--- + + +## Pattern : TTL court + bouton resend admin > TTL longue + +- Objectif : minimiser la fenêtre d'exposition d'un token sensible sans dégrader l'UX dans les cas marginaux (user en vacances). +- Contexte : tokens d'authentification sensibles (invitation, reset password) où la tentation est d'allonger la TTL pour couvrir les cas de longue absence. +- Quand l'utiliser : tout token sensible avec un canal admin disponible pour relancer. +- Quand l'éviter : tokens où le resend est impossible (signed URLs publiques sans admin). +- Avantage : + - surface d'attaque réduite (token intercepté n'est utilisable que pendant la TTL courte) + - granularité opérationnelle : l'admin trace dans l'audit qui demande un resend + - invariant "1 active par user" reste applicable (le resend révoque l'ancien et émet un nouveau) +- Limites / vigilance : + - rate limiter sur le bouton resend (20/h/admin) pour éviter le spam involontaire + - pas d'extension d'`expiresAt` au resend — émettre un nouveau token, pas patcher l'ancien +- Validé le : 28-04-2026 +- Contexte technique : auth / magic-link — RL799_V2 + +### Règles d'or + +- TTL en constante explicite côté service (`7 * 24 * 60 * 60 * 1000`), pas en magic number éparpillé +- Fenêtres recommandées : invitation ≤ 7 jours, reset password ≤ 24 h +- Audit `xxx.resend` sur le bouton admin + audit `xxx.revoke` (cohérence avec resend qui révoque l'ancien) +- AC dédié : `expiresAt - createdAt ≈ N * 24h ± 1s` (tolérance fixture) + +--- + + +## Pattern : Hook `setUserActive(false)` → revoke side-tokens + +- Objectif : éviter qu'un user désactivé puisse continuer à consommer un magic link reçu juste avant la désactivation et reprendre la main. +- Contexte : opération admin de désactivation user dans un projet avec tokens secondaires actifs (refresh tokens, invitations, futurs reset password tokens). +- Quand l'utiliser : tout endpoint qui transitionne `user.isActive` de `true` à `false`. +- Quand l'éviter : si la désactivation est purement métier (suspension UI) sans portée auth. +- Avantage : + - défense en profondeur (le checker du consume vérifie aussi `user.isActive` — ceinture + bretelles) + - les tokens orphelins ne survivent pas à la désactivation +- Limites / vigilance : + - best-effort tracé : les revokes ne doivent pas bloquer la désactivation (l'admin attend un retour immédiat) +- Validé le : 28-04-2026 +- Contexte technique : auth / lifecycle user — RL799_V2 + +### Implémentation + +```typescript +if (!body.isActive) { + try { + await revokeAllRefreshTokensForUser(userId); + } catch (err) { + console.error('[admin.users] refresh token revoke failed', err); + refreshTokenWarning = '...'; + } + try { + await revokeInvitationsForUser(userId); + } catch (err) { + console.error('[admin.users] invitation revoke failed', err); + } + // À ajouter quand pertinent : reset password tokens, OAuth states, etc. +} +``` + +### Règles d'or + +- **Best-effort tracé**, pas silencieux : `try { ... } catch { console.error(...) }` +- Warning UX en cas d'échec partiel : la réponse JSON peut contenir un champ optionnel `warning` que le frontend affiche +- **Double protection consume** : le checker du consume vérifie également `user.isActive === true` — même si le revoke échoue, l'user désactivé ne peut pas consommer +- AC dédié : "user actif avec invitation pending → admin désactive → consume échoue avec INVALID_TOKEN, pas de session créée" + +--- + + +## Pattern : Magic link "URL clean" — token signé HMAC + `history.replaceState` + +- Objectif : ouvrir une page web qui pré-charge un contexte serveur (soireeId, eventId, inviteId) sans que l'identifiant ne reste visible dans l'URL navigable. +- Contexte : mail (convocation, invitation, RSVP) avec un bouton qui mène vers une landing page PWA. On veut éviter forward, capture, indexation involontaire. +- Quand l'utiliser : magic links publics vers une page qui pré-charge un contexte sensible. +- Quand l'éviter : magic links d'authentification membre — utiliser les patterns auth classiques (refresh + access httpOnly). +- Avantage : + - URL visible côté utilisateur ne contient pas l'identifiant + - HMAC garantit l'intégrité (custom claim `purpose` pour rejeter un token signé pour un autre usage) + - `sessionStorage` plutôt que `localStorage` → contexte meurt avec l'onglet +- Limites / vigilance : + - `algorithms: ['HS256']` imposé côté `jwt.verify` pour bloquer les "alg: none" attacks + - sécurité dépendante de l'isolation cryptographique du secret (cf. pattern dérivation HMAC) +- Validé le : 30-04-2026 +- Contexte technique : Node.js crypto / Vue / Vite PWA — RL799_V2 + +### Architecture + +``` +Mail HTML + ↓ Bouton (GET https://app/visit?t=) +Landing PWA /visit + ↓ JS lit le token, POST /api/.../redeem { token } + ↓ Backend valide HMAC + extrait contextId, retourne metadata + ↓ JS stocke contextId en sessionStorage + ↓ history.replaceState(null, '', '/inscription') + ↓ router.replace() pour synchroniser le router SPA +Page d'inscription (URL clean) +``` + +### Backend — signature + +```typescript +import jwt from 'jsonwebtoken'; + +export function signAccessToken(contextId: string, expiresAt: Date): string { + const expSeconds = Math.floor(expiresAt.getTime() / 1000); + return jwt.sign( + { sub: contextId, purpose: 'rsvp-v1', exp: expSeconds }, + getDerivedSecret(), + { algorithm: 'HS256' }, + ); +} + +export function redeemAccessToken(token: string): + | { ok: true; contextId: string } + | { ok: false; reason: 'expired' | 'invalid' } { + try { + const decoded = jwt.verify(token, getDerivedSecret(), { + algorithms: ['HS256'], + }) as { sub?: string; purpose?: string }; + if (decoded.purpose !== 'rsvp-v1') return { ok: false, reason: 'invalid' }; + if (typeof decoded.sub !== 'string') return { ok: false, reason: 'invalid' }; + return { ok: true, contextId: decoded.sub }; + } catch (err) { + if (err instanceof jwt.TokenExpiredError) return { ok: false, reason: 'expired' }; + return { ok: false, reason: 'invalid' }; + } +} +``` + +### Frontend — landing minimaliste + +```typescript +onMounted(async () => { + const token = route.query.t as string; + if (!token) return showError('Lien incomplet'); + + const result = await redeemToken(token); + saveSessionContext(result); // sessionStorage + + if (typeof window !== 'undefined' && window.history?.replaceState) { + window.history.replaceState(null, '', '/inscription'); + } + await router.replace({ name: 'inscription' }); +}); +``` + +### Choix d'expiration + +- Magic link auth : court (15 min – 24 h), token consommable une fois +- RSVP événement : long (jusqu'à la date) +- Invitation one-shot : moyen (7-30 jours), invalidable à l'usage + +--- + + +## Pattern : Isolation cryptographique — secret dérivé via HMAC + +- Objectif : signer plusieurs types de tokens isolés cryptographiquement sans ajouter une nouvelle env var par usage. +- Contexte : projet avec un `JWT_SECRET` racine (auth membre) qui doit signer un autre type de token (magic link, RSVP, webhook) sans cross-domain attack possible. +- Quand l'utiliser : besoin d'un secret de signature isolé sans nouvelle clé à provisionner et à tourner. +- Quand l'éviter : si le projet supporte déjà un système de gestion de clés multiples (KMS, Vault) — utiliser le mécanisme natif. +- Avantage : + - une seule env var racine à provisionner et tourner + - HMAC est one-way : un attaquant qui obtient le secret dérivé ne peut pas remonter au racine + - reproductible : `(JWT_SECRET, purpose)` → même secret, pas d'état à persister + - versionnable via le purpose (`-v1`, `-v2`) : rotation possible en bumpant le purpose +- Limites / vigilance : + - n'est PAS un substitut à la rotation de clés : si `JWT_SECRET` est compromis, tous les secrets dérivés le sont aussi + - HKDF est plus rigoureux pour la dérivation formelle ; pour un simple isolement d'usage, `HMAC(secret, purpose)` suffit +- Validé le : 30-04-2026 +- Contexte technique : Node.js crypto — RL799_V2 + +### Implémentation + +```typescript +import { createHmac } from 'node:crypto'; + +const TOKEN_PURPOSE = 'magic-link-v1'; +let cachedSecret: Buffer | null = null; + +function getDerivedSecret(): Buffer { + if (cachedSecret) return cachedSecret; + const root = process.env.JWT_SECRET; + if (!root) throw new Error('JWT_SECRET requis'); + cachedSecret = createHmac('sha256', root).update(TOKEN_PURPOSE).digest(); + return cachedSecret; +} +``` + +### Test d'isolation + +```typescript +test('rejette un token signé avec un autre purpose', async () => { + const tokenAsMember = jwt.sign( + { sub: 'x' }, + process.env.JWT_SECRET!, + { algorithm: 'HS256', expiresIn: '15m' }, + ); + const result = redeemToken(tokenAsMember); + expect(result.ok).toBe(false); +}); +``` + +### Applications + +- Tokens magic link / RSVP / invitation +- Signature de webhooks sortants +- Tokens d'unsubscribe email (lien direct sans login) +- Tokens d'accès aux ressources publiques limitées dans le temps + +--- diff --git a/knowledge/backend/patterns/contracts.md b/knowledge/backend/patterns/contracts.md index 4c2294e..df08f10 100644 --- a/knowledge/backend/patterns/contracts.md +++ b/knowledge/backend/patterns/contracts.md @@ -187,3 +187,268 @@ Quand une fonction crypto travaille en base64 pour la sérialisation, prévoir u ### Signal review - `buffer.toString('base64')` suivi immédiatement de `decrypt(base64String)` qui fait `Buffer.from(str, 'base64')` → round-trip inutile + +--- + + +## Pattern : Zod `.strict()` systématique sur les schémas de mutation + +- Objectif : bloquer la pollution de champs internes via PATCH/POST/PUT en rejetant tout champ supplémentaire non listé dans le schéma. +- Contexte : tout schéma Zod qui valide un payload de mutation côté API. +- Quand l'utiliser : systématiquement sur tous les schémas de mutation. +- Quand l'éviter : schémas de réponse (où l'API est l'émetteur) ou schémas d'enrichissement intentionnel. +- Avantage : + - première ligne de défense contre la pollution de payload (`uploadedBy`, `createdAt`, `isAdmin` injectés par un client malveillant) + - rejet à 400 avant d'atteindre Prisma → pas de risque de spread accidentel dans `data: parsed.data` +- Limites / vigilance : + - ne dispense pas de la deuxième ligne de défense : ne JAMAIS spread `parsed.data` directement dans `prisma.update`, construire `data` au champ près +- Validé le : 20-04-2026 +- Contexte technique : TypeScript / Zod — RL799_V2 + +### Implémentation + +```typescript +export const updateXxxSchema = z.object({ + name: z.string().min(1).optional(), + status: z.enum(['active', 'inactive']).optional(), +}).strict(); +``` + +### Combiné avec le repo + +```typescript +const data: Partial = {}; +if (parsed.data.name !== undefined) data.name = parsed.data.name; +if (parsed.data.status !== undefined) data.status = parsed.data.status; +// …jamais `data: parsed.data` brut +``` + +### Test à ajouter + +```typescript +test('PATCH .strict() rejette les champs hors-whitelist', async () => { + const r = await PATCH(makeReq({ name: 'OK', uploadedBy: 'attacker' })); + expect(r.status).toBe(400); +}); +``` + +--- + + +## Pattern : Rigidification Zod en 2 phases (données d'abord, schémas ensuite) + +- Objectif : rigidifier un schéma Zod artificiellement laxiste sans casser la suite de tests en cascade. +- Contexte : schéma qui accepte une forme large (`z.string().min(1).max(128)`) pour compenser une donnée hétérogène en base (slugs + UUIDs cohabitent), avant d'avoir uniformisé la donnée. +- Quand l'utiliser : tout chantier de rigidification (`.uuid()`, `.email()`, `.enum()`) sur un champ dont la base contient encore l'ancien format. +- Quand l'éviter : si la donnée est déjà uniforme — rigidifier directement. +- Avantage : + - diagnostic séparé : si le commit 2 casse un test, on sait que c'est la rigidification, pas la migration + - rollback granulaire : on peut rollback la rigidification sans reperdre la migration + - revue plus lisible : un reviewer valide indépendamment "migration correcte" puis "rigidification sûre" +- Limites / vigilance : + - tentation de tout faire d'un coup → écarter +- Validé le : 24-04-2026 +- Contexte technique : Zod / Prisma — RL799_V2 + +### Séquence obligatoire (2 commits séparés) + +**Phase 1 — Normalisation des données** : +- Migrer la base (seed, fixtures, lignes legacy via `prisma migrate`) +- Adapter tous les consommateurs qui référencent l'ancien format (tests, helpers E2E, scripts admin) +- Le schéma Zod reste laxiste à ce stade — il accepte les deux formats pendant la transition +- Ajouter un test d'invariant qui valide que la base ne contient plus que le format cible +- Commit : `feat(): migration + adaptation tests` + +**Phase 2 — Rigidification du schéma** : +- Remplacer `z.string()` par `z.uuid()` / `z.email()` / `z.enum()` sur les champs concernés +- Adapter les quelques tests qui reposaient sur l'ancienne sémantique laxiste +- Vérifier par grep final qu'aucun autre schéma n'a le même pattern laxiste oublié +- Commit : `feat(): rigidification Zod sur ` + +### Signaux de dérive + +- Schéma avec un commentaire "accepte toute chaîne pour compatibilité avec X" → dette à rigidifier dès que X est migré +- `.min(1).max(128)` sur un champ conceptuellement UUID/email/enum → forme laxiste en attente de rigidification + +--- + + +## Pattern : Enum canonique + sous-ensembles nommés (vs flags par usage) + +- Objectif : factoriser les règles métier sur une enum partagée par plusieurs domaines fonctionnels sans alourdir l'enum elle-même de flags. +- Contexte : enum (rôles, statuts, types) qui sert plusieurs usages avec des règles différentes (annuaire, pointage rituel, mandats administratifs). +- Quand l'utiliser : dès qu'un même `enum.filter(r => …)` apparaît à plusieurs endroits avec une règle métier explicite. +- Quand l'éviter : si le filtre n'apparaît qu'une fois — laisser inline, l'extraction est prématurée. +- Avantage : + - chaque sous-ensemble a un nom métier explicite — le lecteur comprend sans chercher + - les règles sont localisées au point de définition, pas éparpillées en flags + - ajouter un usage = ajouter un sous-ensemble, pas modifier la structure de l'enum +- Limites / vigilance : + - les sous-ensembles doivent être typés `readonly Role[]` pour bénéficier du narrowing + - propagation côté front ET côté Zod backend (defense-in-depth) +- Validé le : 21-04-2026 +- Contexte technique : TypeScript / Zod — RL799_V2 + +### Anti-pattern + +```typescript +// ❌ Flag par usage, multipliable, illisible +export const OFFICER_ROLES = [ + { code: 'venerable', label: '...', isRitual: true, isAdmin: true }, + { code: 'archiviste', label: '...', isRitual: false, isAdmin: true }, + // … 12 rôles × 3-4 flags +]; +``` + +### Pattern correct + +```typescript +export const OFFICER_ROLES = [ + 'venerable', 'premier-surveillant', /* … */ 'archiviste', +] as const; +type OfficerRole = (typeof OFFICER_ROLES)[number]; + +/** Officiers avec fonction rituelle pendant la tenue (pointage). */ +export const RITUAL_OFFICER_ROLES: readonly OfficerRole[] = + OFFICER_ROLES.filter((role) => role !== 'archiviste'); + +/** Officiers éligibles à un mandat administratif. */ +export const MANDATABLE_OFFICER_ROLES = OFFICER_ROLES; +``` + +### Propagation Zod backend + +```typescript +// Le sous-ensemble est utilisé côté front ET côté Zod +export const tenueOfficerAssignmentSchema = z.object({ + role: z.enum(RITUAL_OFFICER_ROLES as readonly [OfficerRole, ...OfficerRole[]]), +}); +// → POST avec role: 'archiviste' = 400, sans duplication de la règle +``` + +--- + + +## Pattern : Constantes par variant figé + sélecteur enum strict + +- Objectif : figer dans le code des règles ou textes versionnés via Git tout en sélectionnant l'implémentation à l'exécution via un champ DB (tenant, pays, juridiction). +- Contexte : règles métier figées (CGV par juridiction, formats de facture par pays, libellés réglementaires par régulateur) qui doivent rester typées strictement et versionnées via Git, mais sélectionnées au runtime. +- Quand l'utiliser : préparation multi-variant **avant** d'avoir réellement plusieurs implémentations, OU cas où on veut des diffs visibles dans la PR à chaque modification (texte à autorité). +- Quand l'éviter : règles métier admin-éditables runtime — ces données appartiennent à la DB, pas au code. +- Avantage : + - une seule source de vérité par variant, typée strictement + - étendre l'union à `'A' | 'B'` propage automatiquement la nouvelle option (Zod, UI, tests) + - diff visible dans la PR à chaque modification — review éclate sur un mot changé +- Limites / vigilance : + - throw explicite dans le sélecteur (pas de fallback silencieux) — un drift DB doit échouer fort + - pour du texte à autorité, préférer `expect(X).toBe(...)` à `toMatchSnapshot` — diff visible vs snapshot file rarement lu +- Validé le : 28-04-2026 +- Contexte technique : TypeScript / Zod — RL799_V2 + +### Structure type + +``` +packages/shared/src// + types.ts ← SupportedXCode union fermée + SUPPORTED_X_CODES tuple runtime + .ts ← Constantes du variant A (typées ) + index.ts ← getXConstants(code) + isSupportedXCode + UnsupportedXError +``` + +### Source de vérité unique pour le code + +```typescript +// types.ts +export type SupportedRiteCode = 'REAA'; +export const SUPPORTED_RITE_CODES = ['REAA'] as const + satisfies readonly SupportedRiteCode[]; +``` + +`SUPPORTED_RITE_CODES` est consommé partout : +- `z.enum([...SUPPORTED_RITE_CODES] as [...])` côté validation +- `` / `` sans wrapper JS + +### Synthèse + +- **Objectif** : aligner les inputs date HTML5 sur l'identité visuelle du thème via une règle CSS globale, sans wrapper JS custom. +- **Contexte** : projet avec un thème dark/light custom où les inputs date natifs (icône calendrier blanche, placeholder gris OS, popover light par défaut) cassent le design. +- **Quand l'utiliser** : 80 % des cas (audit log, formulaires admin, profils) où la datepicker custom serait du sur-engineering. +- **Quand l'éviter** : + - validation custom synchrone (range, dates blackout, format spécifique) + - format d'affichage différent (DD/MM vs MM/DD vs ISO) + - intégration profonde dans un design system + +### Analyse + +- **Avantages** : + - `color-scheme` + `accent-color` donnent un popover natif cohérent gratuitement + - filtre SVG pour teinter l'icône calendrier + - zéro JavaScript, zéro bundle additionnel +- **Limites / vigilance** : + - **Firefox** : `accent-color` respecté, mais pas de pseudo-element pour customiser le placeholder + - **Safari iOS** : popover sheet OS, peu personnalisable. Acceptable car cohérent avec le reste des UI iOS natives + - filtre `filter()` à calibrer pour matcher la couleur du thème — chaque thème nécessite son tuning + +### Validation + +- Validé le : 27-04-2026 +- Contexte technique : CSS / inputs date HTML5 — RL799_V2 + +### Pattern minimal + +```css +/* Toutes les variantes de pickers HTML5 */ +input[type='date'], +input[type='datetime-local'], +input[type='time'], +input[type='month'], +input[type='week'] { + color-scheme: dark; /* ou 'light' selon le thème de l'app */ + accent-color: var(--color-accent); +} + +/* Place-holder OS (jj/mm/aaaa) — couleur soft */ +input[type='date']::-webkit-datetime-edit-fields-wrapper, +input[type='datetime-local']::-webkit-datetime-edit-fields-wrapper { + color: var(--color-text-soft); +} + +/* Une fois saisie, couleur normale */ +input[type='date']:not(:placeholder-shown)::-webkit-datetime-edit-fields-wrapper { + color: var(--color-text-primary); +} + +/* Icône calendrier teintée (filtre SVG noir → couleur d'accent soft) */ +input[type='date']::-webkit-calendar-picker-indicator, +input[type='datetime-local']::-webkit-calendar-picker-indicator, +input[type='time']::-webkit-calendar-picker-indicator, +input[type='month']::-webkit-calendar-picker-indicator, +input[type='week']::-webkit-calendar-picker-indicator { + /* Calibrer pour matcher la couleur du thème */ + filter: invert(70%) sepia(40%) saturate(450%) hue-rotate(5deg) brightness(95%); + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s ease; +} +``` + +### Anti-patterns + +- Construire un mini calendrier JS custom pour gagner 5 % d'esthétique → effort énorme (a11y clavier, focus management, mobile, edge cases), bénéfice marginal +- Hardcoder les couleurs `filter()` au lieu d'utiliser des tokens du thème +- Restyler sans `color-scheme: dark/light` → le popover natif reste en mode clair sur thème sombre + +--- + + +## Pattern : UI pour journaux / audit logs / timelines + +### Synthèse + +- **Objectif** : passer d'un rendu naïf en cards uniformes "acteur · code · cible (uuid) · metadata" à une lecture rapide pour l'admin en surveillance, sans toucher au backend. +- **Contexte** : tout projet finit par afficher un journal d'événements (audit, activité, historique, timeline) avec metadata variable. +- **Quand l'utiliser** : journal avec ≥ 10 types d'actions et besoin de scanner rapidement. +- **Quand l'éviter** : log technique brut destiné aux devs (un `
` peut suffire).
+
+### Analyse
+
+- **Avantages** :
+  - 5 patterns combinables qui améliorent radicalement la scanabilité
+  - aucun changement backend (le DTO reste plat)
+- **Limites / vigilance** :
+  - reset de l'état d'expansion à chaque rechargement (l'expansion est éphémère, pas une préférence durable)
+
+### Validation
+
+- Validé le : 27-04-2026
+- Contexte technique : Vue 3 / CSS — RL799_V2 (Journal d'audit admin, 45+ types d'actions)
+
+### 1. `` dérivé du préfixe label
+
+Quand l'API retourne un catalogue d'actions avec convention `Catégorie — Libellé` (ex : `Soirée — annulation`, `Tenue — création`), dériver les groupes côté front au lieu de modifier le DTO.
+
+```typescript
+const groupsFromLabels = computed(() => {
+  const groups = new Map();
+  for (const opt of catalog.value) {
+    const sep = opt.label.indexOf(' — ');
+    const category = sep > 0 ? opt.label.slice(0, sep) : 'Divers';
+    groups.set(category, [...(groups.get(category) ?? []), opt]);
+  }
+  return Array.from(groups.entries())
+    .sort(([a], [b]) => a.localeCompare(b, 'fr', { sensitivity: 'base' }));
+});
+```
+
+```html
+
+```
+
+Bénéfice : 1 seul clic pour filtrer (vs cascade 2 selects), accessible natif, zéro modif backend.
+
+### 2. Code couleur sémantique par catégorie
+
+Barre de couleur de 3 px à gauche de chaque card de la liste, mappée sur la catégorie de l'événement. Transforme un mur de cards uniformes en lecture instantanée.
+
+```css
+.log-item {
+  border-left: 3px solid var(--log-cat-color, var(--color-border));
+}
+.log-item--cat-soiree { --log-cat-color: var(--color-accent-primary); }
+.log-item--cat-rgpd   { --log-cat-color: var(--color-accent-danger); }
+```
+
+Pourquoi pas le fond complet : trop bruyant, perd la sobriété d'un journal admin. La barre latérale signale sans crier.
+
+### 3. UUIDs rétrogradés en monospace soft
+
+Les UUIDs / IDs techniques affichés en plein texte cassent la lecture humaine. Les détecter via regex et les rendre en font monospace + couleur soft + taille réduite, sans les masquer (utiles pour forensics).
+
+```typescript
+const UUID_RE = /[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;
+```
+
+```css
+.uuid {
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 0.78em;
+  color: var(--color-text-soft);
+  word-break: break-all;
+}
+```
+
+### 4. Date relative + absolue en tooltip
+
+Pour la lecture humaine, "il y a 5 min" / "hier" / "il y a 3 j" bat toujours "27 avril 2026 à 08:37". La date absolue reste accessible en `title` du `