capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)

Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MaksTinyWorkshop
2026-05-02 22:12:44 +02:00
parent 02ad0de258
commit b3417ad77b
31 changed files with 5370 additions and 12 deletions

View File

@@ -30,6 +30,7 @@ Dernière mise à jour : 2026-03-12
- [Le front-end est un logiciel en production](#decision-frontend-production) - [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) - [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) - [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 ### 2. Infra
@@ -293,6 +294,48 @@ Règles associées :
--- ---
<a id="decision-mono-tenant-deployable"></a>
## 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 ## 2. Infra
<a id="decision-structure-docker"></a> <a id="decision-structure-docker"></a>

View File

@@ -220,3 +220,113 @@ npm install -g <package>@latest
- `npm root -g` - `npm root -g`
- `npm ls -g --depth=0 <package>` | npm list -g @openai/codex --depth=0 - `npm ls -g --depth=0 <package>` | npm list -g @openai/codex --depth=0
- <package> --version - <package> --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 | `<env> 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

View File

@@ -20,6 +20,7 @@ vers les fichiers appropriés :
- `knowledge/n8n/risques/general.md` - `knowledge/n8n/risques/general.md`
- `knowledge/product/patterns/general.md` - `knowledge/product/patterns/general.md`
- `knowledge/product/risques/<thème>.md` - `knowledge/product/risques/<thème>.md`
- `knowledge/workflow/patterns/general.md`
- `knowledge/workflow/risques/story-tracking.md` - `knowledge/workflow/risques/story-tracking.md`
- `10_conventions_redaction.md` - `10_conventions_redaction.md`
- `40_decisions_et_archi.md` - `40_decisions_et_archi.md`
@@ -37,7 +38,7 @@ Chaque proposition doit suivre ce format :
DATE — PROJET DATE — PROJET
FILE_UPDATE_PROPOSAL FILE_UPDATE_PROPOSAL
Fichier cible : <knowledge/backend/patterns/<thème>.md | knowledge/backend/risques/<thème>.md | knowledge/frontend/patterns/<thème>.md | knowledge/frontend/risques/<thème>.md | knowledge/ux/patterns/<thème>.md | knowledge/ux/risques/<thème>.md | knowledge/n8n/patterns/general.md | knowledge/n8n/risques/general.md | knowledge/product/patterns/general.md | knowledge/product/risques/<thème>.md | knowledge/workflow/risques/story-tracking.md | 10_conventions_redaction.md | 40_decisions_et_archi.md | 90_debug_et_postmortem.md> Fichier cible : <knowledge/backend/patterns/<thème>.md | knowledge/backend/risques/<thème>.md | knowledge/frontend/patterns/<thème>.md | knowledge/frontend/risques/<thème>.md | knowledge/ux/patterns/<thème>.md | knowledge/ux/risques/<thème>.md | knowledge/n8n/patterns/general.md | knowledge/n8n/risques/general.md | knowledge/product/patterns/general.md | knowledge/product/risques/<thème>.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 : Pourquoi :
<raison pour laquelle ce savoir mérite d'être capitalisé> <raison pour laquelle ce savoir mérite d'être capitalisé>
@@ -75,7 +76,7 @@ Description courte, factuelle, orientée réutilisation.
3. La validation et l'intégration finale dans `Lead_tech` 3. La validation et l'intégration finale dans `Lead_tech`
sont faites **manuellement**. sont faites **manuellement**.
4. Une fois intégrée, la proposition doit être **supprimée de ce fichier**. 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`).
--- ---

View File

@@ -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 | | `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 | | `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 | | `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 | | `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 | | `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 |

View File

@@ -77,3 +77,177 @@
- Dead-letter ou statut FAILED visible - Dead-letter ou statut FAILED visible
- Idempotence documentée - Idempotence documentée
- Logs corrélés (requestId/traceId) - Logs corrélés (requestId/traceId)
---
<a id="pattern-hooks-fire-and-forget-creation-db"></a>
## 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.
---
<a id="pattern-fanout-notification-grade-plancher"></a>
## 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<void> => {
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.
---
<a id="pattern-auto-purge-fenetre-temporelle-sql"></a>
## 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.

View File

@@ -342,3 +342,338 @@ Le helper `requireRoleAccess` doit retourner :
- Contexte technique : auth / audit — RL799_V2 - Contexte technique : auth / audit — RL799_V2
--- ---
<a id="pattern-consume-single-use-pas-session-implicite"></a>
## 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
---
<a id="pattern-magic-link-consume-sans-fusion-table"></a>
## 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).
---
<a id="pattern-sentinelle-non-hashable-user-invite"></a>
## 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)
---
<a id="pattern-ttl-court-bouton-resend"></a>
## 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)
---
<a id="pattern-hook-setuseractive-revoke-side-tokens"></a>
## 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"
---
<a id="pattern-magic-link-url-clean"></a>
## 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=<token-signé>)
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
---
<a id="pattern-isolation-cryptographique-hmac"></a>
## 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
---

View File

@@ -187,3 +187,268 @@ Quand une fonction crypto travaille en base64 pour la sérialisation, prévoir u
### Signal review ### Signal review
- `buffer.toString('base64')` suivi immédiatement de `decrypt(base64String)` qui fait `Buffer.from(str, 'base64')` → round-trip inutile - `buffer.toString('base64')` suivi immédiatement de `decrypt(base64String)` qui fait `Buffer.from(str, 'base64')` → round-trip inutile
---
<a id="pattern-zod-strict-mutations"></a>
## 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<UpdateXxxData> = {};
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);
});
```
---
<a id="pattern-rigidification-zod-2-phases"></a>
## 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(<domaine>): migration <X> + 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(<domaine>): rigidification Zod sur <X>`
### 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
---
<a id="pattern-enum-canonique-sous-ensembles-nommes"></a>
## 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
```
---
<a id="pattern-constantes-variant-fige-selecteur-strict"></a>
## 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/<domain>/
types.ts ← SupportedXCode union fermée + SUPPORTED_X_CODES tuple runtime
<variantA>.ts ← Constantes du variant A (typées <Constants>)
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
- `<select v-for="code in SUPPORTED_RITE_CODES">` côté UI
- `switch` exhaustif dans `getXConstants`
### Sélecteur avec narrowing runtime
```typescript
export class UnsupportedRiteError extends Error { /* … */ }
export const isSupportedRiteCode = (v: string): v is SupportedRiteCode =>
(SUPPORTED_RITE_CODES as readonly string[]).includes(v);
export const getRitualConstants = (code: SupportedRiteCode): RitualConstants => {
switch (code) {
case 'REAA': return REAA_RITUAL;
}
throw new UnsupportedRiteError(code); // garde-fou DB drift
};
```
Côté service backend, `assertSupportedX(record.code)` AVANT d'exposer dans le DTO public — protège contre une row DB qui aurait drift.
### Tests : assertions explicites pour texte à autorité
```typescript
expect(X.formule).toBe('chaîne exacte'); // diff visible en review
// Avec glyphes Unicode à risque de swap (ex : ' U+0027 vs U+2019)
expect([...X.formule].map(c => c.codePointAt(0)!)).toEqual([0x41, 0x2234, /* … */]);
expect(X.formule).not.toMatch(/'/); // anti-régression typographique
```
### Anti-patterns
- Stocker le texte figé en DB "pour pouvoir l'éditer plus tard" — si le texte est versionné, il appartient au code
- Hardcoder le code variant dans la validation UI (`if (code === 'REAA')`) — toujours dériver de `SUPPORTED_X_CODES` runtime
- Fallback silencieux dans le sélecteur (`switch (code) { default: return DEFAULT }`) — throw explicite
---
<a id="pattern-regex-critique-partagee-anti-divergence"></a>
## Pattern : Regex critique partagée serveur ↔ client (anti-divergence)
- Objectif : éviter qu'une règle de validation critique (regex anti open-redirect, format de slug) ne dérive entre serveur (Zod) et client (composant, store, Service Worker).
- Contexte : règle de sécurité ou d'intégrité qui doit s'appliquer identiquement des deux côtés.
- Quand l'utiliser : règle où une divergence côté un seul des deux mène à un trou (anti open-redirect, anti SQL injection visible client-side, format de path/URL).
- Quand l'éviter : règle UX uniquement (pattern d'email pour autocomplétion live).
- Avantage :
- une seule source de vérité — `packages/shared` ou équivalent
- dérive impossible (ou détectée au build TS) si l'import partagé est possible
- Limites / vigilance :
- si le client ne peut PAS importer le package partagé (cas Service Worker en mode `injectManifest`), DUPLIQUER avec un commentaire `⚠️ DOIT correspondre à <chemin>` + un test croisé qui vérifie l'alignement string-wise
- Validé le : 28-04-2026
- Contexte technique : monorepo TypeScript — RL799_V2
### Implémentation (cas idéal — import partagé)
```typescript
// packages/shared/src/dto/push.ts (source de vérité)
/**
* Regex unique anti open-redirect : démarre par '/' simple (pas '//'),
* caractères alphanum + '/_-?&=%.', pas de ':' (bloque 'javascript:').
*/
export const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
export const pushPayloadSchema = z.object({
linkUrl: z.string().regex(INTERNAL_PATH_REGEX).optional(),
});
```
### Cas duplication contrôlée (SW mode `injectManifest`)
```typescript
// apps/frontend/src/sw-helpers.ts
/**
* ⚠️ DOIT correspondre à INTERNAL_PATH_REGEX de packages/shared/src/dto/push.ts.
* Le SW (mode injectManifest) ne peut pas importer le package partagé directement.
* Test croisé : apps/frontend/src/__tests__/regex-alignment.test.ts
*/
const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
```
### Checklist
- [ ] Une seule source de vérité, idéalement dans `packages/shared`
- [ ] Si duplication forcée : commentaire `⚠️ DOIT correspondre à <chemin>` des deux côtés
- [ ] Test croisé qui assert l'alignement string-wise des deux regex
- [ ] JSDoc qui rappelle que c'est un contrat de cohérence (revue obligatoire si modif)

View File

@@ -59,3 +59,541 @@ Préférer étendre le service avec un nouveau type (enum/Set) et ajuster les re
### Signal review ### Signal review
- Nouveau service qui réplique le CRUD d'un service existant avec un filtre additionnel → candidat à la fusion par type - Nouveau service qui réplique le CRUD d'un service existant avec un filtre additionnel → candidat à la fusion par type
---
<a id="pattern-build-packages-workspace-amont-tests-ci"></a>
## Pattern : Builder les packages workspace en amont des tests CI (monorepo pnpm)
- Objectif : garantir qu'un package workspace compilé (TypeScript → `dist/`) est construit avant que les apps consommatrices lancent leurs tests dans le CI.
- Contexte : monorepo pnpm où `apps/*` consomme `packages/<lib>` via `"workspace:*"`, et où le `package.json` du package pointe sur un build artifact (`"main": "dist/index.js"`, `"types": "dist/index.d.ts"`).
- Quand l'utiliser : dans tout pipeline CI/CD qui lance des tests sur les apps consommatrices d'un package compilé.
- Quand l'éviter : si tous les packages workspace sont consommés en source directement (TS sans build, ex. via `tsx`, `vite-node`, `@swc-node`).
- Validé le : 30-04-2026
- Contexte technique : pnpm monorepo / GitHub Actions / Node 22 / TypeScript — RL799_V2
### Règle
Ajouter une étape `Build workspace packages` entre `pnpm install` et le premier `pnpm run test:*` du pipeline. Préférer un build complet du workspace plutôt qu'un build ciblé : `pnpm -r --filter '<scope>/*' build` (ou `pnpm run build` si un script racine équivalent existe). `pnpm -r` respecte le graphe de dépendances et bâtit les libs avant les apps.
### Anti-pattern à éviter
Ne pas placer le `build` du package partagé **dans** la commande de tests de ce package (`"test:shared": "pnpm -C packages/shared build && pnpm -C packages/shared test"`). Si le script `test:shared` tourne après `test:api` dans la séquence CI, le build arrive trop tard pour les tests qui consomment `dist/`. Garder le build comme étape CI explicite et amont, et garder `test:<package>` minimaliste.
### Signal review
- Pipeline CI qui enchaîne `pnpm install` puis directement `pnpm run test:api` (ou équivalent) sans étape de build intermédiaire, alors que le package workspace consommé pointe sur un `dist/` compilé.
- Bug type : `Cannot find module '<scope>/<package>'` ou `Cannot find module '<scope>/<package>/dist/...'` au démarrage des tests CI, alors que les tests passent en local.
### Pourquoi ce bug est silencieux en local
En local, `dist/` existe presque toujours (build précédent) — donc les tests passent. Le pipeline CI part d'un environnement vierge et casse. Test de fumée local avant un push CI sensible : `rm -rf packages/<lib>/dist && pnpm run build && pnpm run test:<app>`.
---
<a id="pattern-ordre-canonique-gates-http"></a>
## Pattern : Ordre canonique des gates dans un handler HTTP
- Objectif : éviter les fuites d'information via les codes HTTP — un user non authentifié ne doit jamais distinguer "ressource n'existe pas" de "ressource existe mais non autorisée".
- Contexte : tout handler HTTP qui combine validation de payload, authentification, autorisation, vérification d'existence de ressource et gates métier.
- Quand l'utiliser : systématique sur tout handler HTTP exposé.
- Quand l'éviter : jamais.
- Avantage :
- la sémantique HTTP reste cohérente entre endpoints
- aucune énumération possible via les codes 4xx
- Limites / vigilance :
- test "non auth + payload problématique → 401, pas 400" obligatoire pour verrouiller l'ordre
- Validé le : 27-04-2026
- Contexte technique : Next.js / NestJS / API HTTP — RL799_V2
### Ordre canonique (du plus permissif au plus restrictif)
1. **Parsing du body** (400 VALIDATION_ERROR si malformé)
2. **Validation du schéma** (400 VALIDATION_ERROR si payload invalide)
3. **Auth** (401 si non authentifié)
4. **Autz** (403 si rôle insuffisant)
5. **Existence ressource** (404 si l'id n'existe pas)
6. **Gates métier** (400/403 si règle business violée)
7. **Mutation**
### Anti-pattern
```typescript
// ❌ Gate métier AVANT auth — leak l'existence de la route + de la règle
if (payload.contains_locked_field) return 400 LOCKED;
const auth = requireAuth(...); // jamais atteint si payload contient le champ
// ✅ Auth AVANT gate métier
const auth = requireAuth(...);
if (auth instanceof Response) return auth;
if (payload.contains_locked_field) return 400 LOCKED;
```
### Test associé
Ajouter systématiquement un test `non auth + payload problématique → 401, pas 400`. Sans ce test, la régression passe.
---
<a id="pattern-delegation-endpoint-agrege"></a>
## Pattern : Délégation au niveau d'un agrégat → endpoint agrégé serveur
- Objectif : éviter les cascades de 403 silencieux côté client quand un user "délégué" doit accéder à des entités liées hors de l'agrégat parent.
- Contexte : user avec un rôle "délégué" rattaché à un agrégat parent (`Soiree.secretaireDeSeanceId`), qui doit pouvoir lire/écrire sur des entités liées au-delà de l'agrégat (tenues précédentes du même grade).
- Quand l'utiliser : la délégation ouvre l'accès à plusieurs entités liées qui sont rendues ensemble dans une vue unique.
- Quand l'éviter : si le frontend a vraiment besoin des entités séparément (rare).
- Avantage :
- une seule réponse hydrate toute la vue → pas de cascade de 403
- guard centrale au niveau du parent réutilisée en lecture ET en écriture
- source de vérité unique de la "fenêtre légitime" (un repo helper)
- Limites / vigilance :
- les codes d'erreur restent standards (`403 FORBIDDEN`), pas de codes ad hoc `FORBIDDEN_OUT_OF_DELEGATION_SCOPE`
- Validé le : 27-04-2026
- Contexte technique : Next.js / API HTTP — RL799_V2
### Le pattern
1. **Endpoint agrégé côté serveur** : `GET /api/<parent>/[id]/<vue-agrégée>` qui hydrate en une seule réponse toutes les entités liées dont la vue a besoin.
2. **Guard centrale au niveau du parent** : `requireAccessForParent(request, parentId, { roleSet })` retourne `{ userId, role, viaDelegation, delegatedParentId? }` ou `Response 403/404`.
3. **Si la guard sur l'entité enfant doit aussi reconnaître la délégation** (cas d'écriture) : étendre avec un slow-path qui appelle la résolution serveur — *« cet enfant fait-il partie de la fenêtre légitime ouverte par la délégation ? »*. Pas de re-vérification dispersée dans chaque service.
4. **Single source of truth de la "fenêtre légitime"** : la même fonction de résolution est utilisée par l'endpoint agrégé (lecture) ET par la guard d'écriture.
### Ce qu'on évite
- Cascade de N requêtes côté client → autant de chances de 403/erreurs silencieuses
- Logique métier dupliquée dans la guard et dans le service de lecture (dérive garantie)
---
<a id="pattern-anti-enumeration-delete-204"></a>
## Pattern : Anti-énumération sur DELETE — 204 systématique
- Objectif : empêcher un user authentifié d'énumérer les ressources d'autres users via les codes HTTP du DELETE (204 vs 404 fuite l'existence).
- Contexte : endpoint DELETE qui révoque/supprime une ressource identifiée par un identifiant connu uniquement de son propriétaire (push endpoint, refresh token, magic link, OAuth state).
- Quand l'utiliser : la ressource est identifiée par un secret/identifiant non énumérable.
- Quand l'éviter : ressource identifiée par un ID séquentiel public — fixer d'abord l'autorisation.
- Avantage :
- aucune information ne fuit (inconnue / à un autre / déjà révoquée → indistinguables)
- simplifie le code : pas de branchement 404 vs 204
- idempotence naturelle (rejouer un DELETE n'a aucun effet observable)
- Limites / vigilance :
- garder une validation de format en amont (400 si body malformé) — c'est le format qui ne fuit rien, pas l'existence
- le filtre SQL DOIT inclure `userId = currentUser` — sinon on révoque la ressource d'un autre user
- Validé le : 28-04-2026
- Contexte technique : API HTTP / Prisma — RL799_V2
### Implémentation
```typescript
export const handleDelete = async (request: Request): Promise<Response> => {
const auth = requireAuthenticatedUser(request, ROLES);
if ('status' in auth) return auth;
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
return errorResponse(400, 'VALIDATION_ERROR', 'Body invalide');
}
try {
await revokeByOwner({ userId: auth.userId, ...parsed.data });
} catch {
/* logger côté serveur, retourner 204 quand même */
}
// 204 systématique : inconnu / autre user / déjà révoqué → indistinguables
return new Response(null, { status: 204 });
};
// Repository
export const revokeByOwner = async (input: {
userId: string;
endpoint: string;
}): Promise<void> => {
await prisma.subscription.updateMany({
where: {
userId: input.userId, // filtre propriétaire OBLIGATOIRE
endpoint: input.endpoint,
revokedAt: null, // idempotent
},
data: { revokedAt: new Date() },
});
};
```
### Anti-patterns
- `findUnique` + `if (!sub) return 404` : fuit l'existence
- `findUnique` + check `userId === auth.userId` + 403 : fuit l'existence (la 403 vs 404 vs 204 distingue)
- `prisma.delete({ where: { endpoint } })` sans filtre `userId` : un user peut révoquer la sub d'un autre
### Tests minimaux
DELETE inconnu / DELETE autre user / DELETE déjà révoqué → tous 204, état inchangé pour les autres users.
---
<a id="pattern-lazy-init-memoize-libs-config-globale"></a>
## Pattern : Lazy init memoizé pour libs avec config globale
- Objectif : initialiser une lib qui exige une config globale (clés API, credentials) seulement au premier appel utile pour permettre feature flag, tests isolés et démarrage gracieux sans env complet.
- Contexte : libs externes type `web-push.setVapidDetails`, `Stripe(secret)`, `S3Client({ region })`, `Resend(apiKey)`.
- Quand l'utiliser : lib avec init globale + feature flag possible + tests qui doivent reset l'état.
- Quand l'éviter : lib qui réclame son init au boot (ex : connexion persistante TCP), libs trivialement instanciables par appel.
- Avantage :
- le service ne crash pas au boot si l'env n'est pas encore configuré
- feature flag respecté de bout en bout (`isEnabled()` court-circuite avant init)
- tests parallèles peuvent reset l'état via `__resetForTests()`
- `setX` n'est appelé qu'une fois par process (memoization)
- Limites / vigilance :
- state module-level partagé entre tous les imports — bien isoler la lib derrière un service
- `__resetForTests` doit être gardé par `NODE_ENV === 'test'` pour éviter l'usage en prod
- Validé le : 28-04-2026
- Contexte technique : Node.js / SDK avec config globale — RL799_V2
### Implémentation
```typescript
let initialized = false;
const isEnabled = (): boolean =>
process.env.FEATURE_FLAG === 'true' &&
Boolean(process.env.API_KEY && process.env.SECRET);
const ensureInit = (): boolean => {
if (initialized) return true;
if (!isEnabled()) return false;
externalLib.configure({
apiKey: process.env.API_KEY!,
secret: process.env.SECRET!,
});
initialized = true;
return true;
};
export const callExternal = async (input: Input): Promise<void> => {
if (!ensureInit()) return; // no-op silencieux si flag off ou env manquant
await externalLib.send(input);
};
/** Reset interne — usage tests uniquement. */
export const __resetInitForTests = (): void => {
if (process.env.NODE_ENV !== 'test') return;
initialized = false;
};
```
### Checklist
- `isEnabled()` vérifie flag ET présence env complète
- `ensureInit()` retourne booléen, no-op silencieux si non activé
- `__resetForTests` gardé par `NODE_ENV === 'test'`
- Pas de log "skipped" en cas de flag off (silencieux par design — c'est le contrat du flag)
---
<a id="pattern-cap-lru-ressources-par-user"></a>
## Pattern : Cap LRU sur ressources par-user avec contrainte d'unicité externe
- Objectif : empêcher un user (malveillant ou bugué) de générer un nombre illimité de ressources par-user en exploitant l'unicité d'un identifiant externe.
- Contexte : ressources où chaque insert produit une row unique côté DB (push subscription endpoint, refresh token, device fingerprint, OAuth state).
- Quand l'utiliser : ressource sans limite naturelle côté usage, où chaque action utilisateur peut créer une nouvelle row.
- Quand l'éviter : ressource intrinsèquement bornée (1 par user — utiliser une PK composite), ou ressource où l'historique compte (audit, logs).
- Avantage :
- cap dur prévisible (10 actives max, p.ex.) — borne supérieure de coût stockage connue
- LRU eviction naturelle : les anciennes subs (devices oubliés, browsers réinstallés) sont nettoyées automatiquement
- pas besoin de TTL global, le user peut garder ses N appareils légitimes
- Limites / vigilance :
- faire le check + révocation **avant** l'insert, pas après (sinon `unique constraint` violation possible si race)
- choisir entre `revoked` (soft delete) et `delete` selon les besoins audit
- le cap doit être au-dessus de l'usage légitime max (10 pour push = laptop + perso + pro + mobile + tablette + marges)
- Validé le : 28-04-2026
- Contexte technique : Prisma — RL799_V2
### Implémentation
```typescript
const MAX_ACTIVE_PER_USER = 10;
export const handleCreate = async (userId: string, input: CreateInput) => {
const active = await prisma.resource.count({
where: { userId, revokedAt: null },
});
if (active >= MAX_ACTIVE_PER_USER) {
const oldest = await prisma.resource.findFirst({
where: { userId, revokedAt: null },
orderBy: { lastSeenAt: 'asc' },
select: { id: true },
});
if (oldest) {
await prisma.resource.update({
where: { id: oldest.id },
data: { revokedAt: new Date() },
});
}
}
return prisma.resource.upsert({
where: { externalKey: input.externalKey },
create: { userId, ...input },
update: { userId, revokedAt: null, lastSeenAt: new Date() },
});
};
```
### Checklist
- [ ] Cap (constante) défini + commenté avec justification du chiffre
- [ ] Eviction LRU faite **avant** l'insert
- [ ] `lastSeenAt` bumpé à chaque usage légitime, pas juste à la création
- [ ] Test : seed cap-1 actives + 1 nouvel insert → cap respecté, plus ancienne révoquée
---
<a id="pattern-convention-dot-notation-audit"></a>
## Pattern : Convention dot-notation pour audit events
- Objectif : aligner le nommage des audit events avec la convention des outils observables (segment.io, datadog, posthog) qui utilisent tous la dot notation.
- Contexte : projet avec un `AUDIT_ACTION_CATALOG` ou équivalent listant les actions auditées.
- Quand l'utiliser : tout nouvel audit event, et migration progressive des events legacy en colon (`document:delete`).
- Quand l'éviter : projets dont l'outil d'observabilité impose un autre séparateur.
- Avantage :
- cohérence inter-outils (segment.io, datadog, posthog)
- lecture humaine plus fluide (`document.soft_delete` > `document:soft-delete`)
- multi-niveaux possible (`planche.tronc.admin_override`) sans confusion avec un séparateur de namespace JS
- Limites / vigilance :
- migration en PR atomique (call sites + catalog ensemble) pour éviter les events orphelins en filtrage UI
- Validé le : 20-04-2026
- Contexte technique : audit / observabilité — RL799_V2
### Convention
- **Préférer la dot notation** : `<entity>.<action_detail>` (ex : `document.update_metadata`, `document.soft_delete`, `cotisation_payment.created`)
- **Legacy colon** (`document:delete`) toléré pour rétrocompatibilité — migration encouragée lors du prochain touchement du module concerné
### Comment migrer
1. Vérifier qu'il n'y a plus de call site (`grep -rn 'document:delete'`)
2. Retirer l'entrée du `AUDIT_ACTION_CATALOG`
3. Si des call sites existent, les migrer en même temps que le retrait (PR atomique)
---
<a id="pattern-whitelist-explicite-audit-fields"></a>
## Pattern : Whitelist explicite pour audit metadata fields
- Objectif : empêcher qu'un futur dev ajoutant un champ secret au schema (`backupPassword`, `apiToken`) ne le voie automatiquement loggé dans le journal d'audit.
- Contexte : PATCH d'admin qui logge `metadata: { fields: Object.keys(payload) }` pour tracer ce qui a changé.
- Quand l'utiliser : tout audit qui veut tracer "quels champs ont été modifiés".
- Quand l'éviter : audit qui ne logge que l'action et l'id cible (pas les champs).
- Avantage :
- pas de fuite silencieuse dans le journal d'audit
- `satisfies readonly (keyof typeof baseShape)[]` garantit que la whitelist ne peut pas contenir de champ inexistant (typo-safe)
- Limites / vigilance :
- test "PATCH multi-champs valides → tous présents dans `metadata.fields`" pour vérifier que la whitelist couvre 100 % des champs PATCH-ables légitimes (silence par omission, pas de fuite)
- Validé le : 27-04-2026
- Contexte technique : audit / observabilité — RL799_V2
### Implémentation
```typescript
// packages/shared/src/validation/<entity>Schemas.ts
export const AUDITABLE_LODGE_SETTINGS_FIELDS = [
'nameLong',
'nameShort',
// … uniquement les champs SAFE à logger
] as const satisfies readonly (keyof typeof baseShape)[];
// Côté service
const fields = AUDITABLE_LODGE_SETTINGS_FIELDS.filter((k) => k in payload);
await logActionSync(tx, userId, '<entity>.update', '<entity>', id, { fields });
```
### Test obligatoire
```typescript
test('AUDITABLE_<X>_FIELDS couvre tous les champs PATCH-ables légitimes', () => {
const allPatchableFields = Object.keys(updateXxxSchema.shape);
const sensitiveFields = ['secretToken', 'backupPassword'];
const expected = allPatchableFields.filter((k) => !sensitiveFields.includes(k));
expect([...AUDITABLE_X_FIELDS]).toEqual(expected);
});
```
---
<a id="pattern-singleton-db-config-globale"></a>
## Pattern : Singleton DB pour config globale d'instance
- Objectif : stocker une configuration applicative qui doit être éditable runtime, unique pour l'instance, et protégée contre toute création accidentelle de doublon.
- Contexte : application mono-tenant déployable où chaque instance a sa propre DB et expose une config globale (paramètres de l'instance, branding, identité).
- Quand l'utiliser : config (a) en DB pour être éditable runtime sans rebuild, (b) unique pour l'instance, (c) protégée par contrainte DB.
- Quand l'éviter : SaaS multi-tenant — chaque tenant a sa propre row.
- Avantage :
- le `CHECK` SQL est la dernière ligne de défense même si un dev contourne le repo
- le repo centralise le `where: { id: 'singleton' }` — première ligne d'abstraction
- Limites / vigilance :
- `@default("singleton")` seul ne suffit pas — un dev peut créer une row avec `id: 'other'`
- cache mémoire à invalider AVANT mutation (cf. pattern `pattern-invalidation-cache-avant-mutation`)
- Validé le : 27-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```prisma
model LodgeSettings {
id String @id @default("singleton")
// …champs
@@map("lodge_settings")
}
```
Garde-fou SQL au niveau migration (édition manuelle du `.sql` après `prisma migrate dev --create-only`) :
```sql
ALTER TABLE "lodge_settings"
ADD CONSTRAINT "lodge_settings_singleton_check"
CHECK (id = 'singleton');
```
Repository centralisé : tout accès passe par `getXxx()` / `updateXxx()` qui prennent un client transactionnel optionnel pour permettre l'atomicité mutation + audit. Jamais de `prisma.xxx.create()` direct depuis ailleurs.
### Tests d'intégration
```typescript
test('rejette la création d\'une 2e row', async () => {
await expect(
prisma.lodgeSettings.create({ data: { id: 'other', ... } }),
).rejects.toThrow('lodge_settings_singleton_check');
});
```
---
<a id="pattern-invalidation-cache-avant-mutation"></a>
## Pattern : Invalidation cache mémoire AVANT mutation atomique
- Objectif : éviter qu'un GET parallèle pendant la transaction re-cache l'ancienne valeur jusqu'à expiration TTL.
- Contexte : config en cache mémoire (settings, feature flags, catalog) modifiée par une mutation atomique.
- Quand l'utiliser : tout flow `cache + mutation + audit` où le cache vit dans le même process que la mutation.
- Quand l'éviter : cache distribué Redis avec `SETEX` — la TTL gère naturellement.
- Avantage :
- cohérence forte (au pire, GET parallèle bloque sur fetch DB → trade-off latence acceptable)
- pas de fenêtre où le client a vu une valeur déjà obsolète après ACK serveur
- Limites / vigilance :
- garde-fou `cacheVersion` (compteur incrémenté à chaque `invalidate()`) recommandé : tout fetch en vol capture la version au démarrage et n'écrit le cache que si la version est inchangée au retour
- Validé le : 27-04-2026
- Contexte technique : Node.js / cache mémoire — RL799_V2
### Séquence sûre
```
1. invalidateCache() // AVANT toute mutation
2. transaction { // Prisma $transaction
update(...)
logActionSync(tx, ...)
}
3. return // Le prochain GET re-fetch fresh DB
```
### Pourquoi avant et pas après
Si on invalide après mutation :
1. GET parallèle pendant la transaction hit le cache (ancienne valeur) → return cachedValue
2. Si la transaction commit, le cache contient l'ancienne valeur jusqu'à TTL
3. Le client a vu une valeur déjà obsolète après ACK serveur → race condition
Si on invalide avant mutation :
1. Cache vide pendant la transaction
2. GET parallèle fait un fetch DB (peut renvoyer l'ancienne ou la nouvelle selon timing)
3. Au pire, GET parallèle bloque sur fetch DB → cohérence forte
### Anti-pattern à éviter
- Auto-chaînage entre invalidations couplées de caches dont les timings doivent diverger (ex : cache settings DTO à invalider AVANT la mutation, cache logo filesystem à invalider APRÈS pour éviter un re-cache d'un fichier supprimé). Chaque cache expose son `invalidate()` propre, le caller décide explicitement du moment.
### TTL recommandé
TTL court (60 s) plutôt que long (5 min) : fenêtre de stale plus courte si plusieurs admins éditent.
---
<a id="pattern-pipeline-cicd-github-actions-vps"></a>
## Pattern : Pipeline CI/CD GitHub Actions → VPS (compose externe + GHCR + SSH)
- Objectif : déployer automatiquement un monorepo Node + Postgres + Docker à chaque merge sur main vers un VPS hébergeant déjà des stacks globales (Traefik + Postgres).
- Contexte : VPS multi-apps avec Traefik global + Postgres global sur réseaux Docker externes (`traefik`, `stack`). Repo GitHub privé, image GHCR privée, user SSH dédié `deploy` (pas superuser).
- Quand l'utiliser : projet ≥ 1 app + ≥ 1 service partagé sur un VPS, sans Kubernetes ni service externe (pas de Vercel/Render/Railway).
- Quand l'éviter : déploiement sur un seul app/VPS dédié (compose simple suffit), ou besoin de blue/green strict (k8s ou Nomad plus adaptés).
- Avantage :
- réutilisation des stacks Traefik + Postgres existantes via réseaux externes
- pas de superuser, juste membre du groupe `docker`
- migrations exécutées AVANT le redémarrage applicatif (séquence `pull → migrate → up -d`)
- Limites / vigilance :
- plan GitHub gratuit pour repos privés : pas de gating manuel possible — compenser par `workflow_dispatch` only sur le job deploy
- `pnpm run <script>` dans le service `migrate` peut casser si le script dépend de `scripts/` non embarqué dans l'image — appeler les binaires directement
- Validé le : 02-05-2026
- Contexte technique : pnpm monorepo / Next.js / Vue / Prisma / Postgres / Docker — RL799_V2
### `compose.vps.yml` — compose dédié CI/CD
```yaml
services:
api:
image: ${API_IMAGE:?API_IMAGE is required}
env_file: [.env]
networks: [app_net, stack_net, traefik_net]
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${APP_DOMAIN}`) && PathPrefix(`/api`)
frontend:
image: ${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}
networks: [app_net, traefik_net]
migrate:
image: ${API_IMAGE:?API_IMAGE is required}
profiles: [ops]
# Appel DIRECT du binaire Prisma — éviter `pnpm run prisma:migrate` qui
# exécute scripts/check-node-version.mjs absent de l'image API
command: ['pnpm', '-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy']
networks: [stack_net]
networks:
traefik_net:
external: true
name: traefik
stack_net:
external: true
name: stack
```
### Workflow CI/CD en 2 jobs
- `build-and-push` : `docker buildx` → push image sur GHCR (tag = SHA court)
- `deploy` : SSH au VPS, `scp compose.vps.yml`, `pull` + `migrate` + `up -d` + healthcheck retries
Trigger : `workflow_run` du workflow `Tests` quand vert sur main, OU `workflow_dispatch` manuel.
### Secrets GitHub à configurer
| Secret | Rôle | Exemple |
|---|---|---|
| `VPS_HOST` | hostname réel (pas alias `~/.ssh/config`) | `82.x.x.x` |
| `VPS_USER` | `deploy` |
| `VPS_SSH_PORT` | port custom éventuel | `2287` |
| `VPS_SSH_PRIVATE_KEY` | clé privée multi-lignes | `-----BEGIN OPENSSH...` |
| `VPS_SSH_KNOWN_HOSTS` | `ssh-keyscan -p <port> <host>` | 3 lignes |
| `VPS_APP_DIR` | chemin app sur VPS | `/srv/sites/<app>` |
### Pièges anticipés
1. **`pnpm run <script>` dans le service `migrate`** : si le script appelle un wrapper (`pnpm run env:node`) qui exécute `scripts/check-node-version.mjs`, et que `scripts/` n'est pas embarqué dans l'image API → 500 au boot du job migrate. **Toujours appeler les binaires directement** dans les services Docker.
2. **Image GHCR privée + `docker login` côté VPS** : credentials par-user (`~/.docker/config.json`). Faire `docker login` **en tant que `deploy`** via SSH, pas via `sudo -u deploy`.
3. **Hostname réel vs alias `~/.ssh/config`** : `VPS_HOST` doit être l'hostname résolu (`ssh -G <alias> | grep ^hostname`), pas l'alias.
4. **Permissions dossier `/srv/sites/<app>/`** : si owner historique = autre user, `deploy` ne peut pas `scp`. Solution propre = groupe partagé avec `setgid`.
5. **Healthcheck timeout** : si l'API met longtemps à boot (Chromium/Puppeteer lazy load, migrations longues), augmenter au-delà du défaut 60 s.

View File

@@ -164,3 +164,124 @@ if (mediaUrl) {
- [ ] Validation format (`new URL()`) + protocole + longueur max - [ ] Validation format (`new URL()`) + protocole + longueur max
- [ ] Retourner `null` si invalide, jamais passer la string brute - [ ] Retourner `null` si invalide, jamais passer la string brute
- [ ] Composant UI reçoit `string | null`, jamais une string non vérifiée - [ ] Composant UI reçoit `string | null`, jamais une string non vérifiée
---
<a id="pattern-nextjs-after-fanout-post-reponse"></a>
## Pattern : Next.js `after()` pour fanout post-réponse
- Objectif : exécuter du travail accessoire (notif push, audit, webhook) APRÈS avoir renvoyé la réponse HTTP, sans bloquer la latence métier ni risquer la fermeture lambda du fire-and-forget naïf.
- Contexte : route Next.js 15+ (App Router) qui termine une mutation métier (ex : `prisma.$transaction`) et veut déclencher un effet de bord non critique.
- Quand l'utiliser : effet de bord best-effort qui ne doit JAMAIS bloquer la réponse principale (push, log audit asynchrone, webhook sortant).
- Quand l'éviter : effet de bord qui DOIT réussir avant de répondre 200 (validation transactionnelle, écriture critique).
- Avantage :
- réponse HTTP immédiate (latence métier préservée)
- le runtime Node attend la fin du callback avant de fermer le process — pas de fire-and-forget orphelin
- les exceptions du callback sont attrapables localement, ne propagent pas au caller
- Limites / vigilance :
- **JAMAIS placer `after()` à l'intérieur d'une `prisma.$transaction(async tx => { ... after(...) })`** : si la transaction rollback, le callback `after()` reste planifié et s'exécute sur des données qui n'existent pas en DB
- construire le payload SYNCHRONE avant `after()` et attraper les erreurs là, sinon une exception dans le callback async devient silencieuse
- comportement en serverless (Vercel, Lambda) vs Node standalone peut différer — tester en cible
- Validé le : 28-04-2026
- Contexte technique : Next.js 15+ App Router — RL799_V2
### Implémentation
```typescript
import { after } from 'next/server';
export const notifyConvocationPublished = async (
tenueId: string,
recipientIds: string[],
): Promise<void> => {
// 1. Mutation métier (transactionnelle)
await prisma.$transaction(async (tx) => {
await tx.notification.createMany({ data: ... });
});
// 2. Construction payload SYNCHRONE — toute exception attrapée ici
let payload: PushPayload;
try {
payload = buildPushPayload(tenueId);
} catch (err) {
log.warn('payload build failed', { err });
return;
}
// 3. Hook après la transaction — JAMAIS dedans
after(async () => {
try {
await pushService.sendPushToUsers(recipientIds, payload);
} catch (err) {
log.warn('fanout failed', { err }); // jamais throw vers le caller
}
});
};
```
### Anti-patterns
-`await pushService.sendPushToUsers(...)` dans le service métier (bloque la latence + propage les erreurs)
-`after(() => { ... })` à l'intérieur d'une `prisma.$transaction(async tx => { ... after(...); })`
- ❌ Construire le payload DANS le callback `after()` async — une exception y devient silencieuse
### Checklist
- [ ] `after()` appelé APRÈS le `await prisma.$transaction(...)`, jamais à l'intérieur
- [ ] Payload construit synchrone avant `after()`, exceptions attrapées localement
- [ ] Le callback `after()` n'a pas le droit de throw (best-effort wrapping)
- [ ] La route métier renvoie 2xx même si le fanout échoue
---
<a id="pattern-gate-agir-au-nom-de"></a>
## Pattern : Gate "agir au nom de X" (3 étages : rôle → type/scope → cible actif)
- Objectif : valider correctement une autorisation d'override d'attribution (`uploadedByOverride`, `createdByOverride`) pour qu'un rôle élevé puisse agir "au nom de" un autre user, sans laisser de faille.
- Contexte : endpoint API qui accepte un override d'attribution (archiviste upload pour un Frère, admin crée une entité pour X).
- Quand l'utiliser : tout endpoint avec un override d'attribution sensible.
- Quand l'éviter : si la délégation est implicite et déjà couverte par un guard centralisé.
- Avantage :
- validation strictement séquentielle et défensive — chaque étage a son code HTTP propre
- le check "actif" combine toutes les dimensions disponibles (pas un seul flag)
- Limites / vigilance :
- ordre impératif : rôle EN PREMIER (plus sensible). Un membre lambda envoyant un payload avec override doit recevoir 403 même si la cible est valide
- Validé le : 20-04-2026
- Contexte technique : Next.js / API HTTP — RL799_V2
### Les 3 étages
1. **Rôle** : le user courant a-t-il la capacité ? Sinon **403 FORBIDDEN**.
2. **Type/scope** : l'override est-il pertinent pour ce type d'entité ? Sinon **400 VALIDATION_ERROR**.
3. **Cible** : la cible existe-t-elle ET est-elle active ? Sinon **400 VALIDATION_ERROR** si introuvable OU inactive OU démissionnée OU décédée.
### Implémentation
```typescript
let effectiveUploadedBy = currentUser.id;
if (metadata.uploadedByOverride) {
// Étage 1 : rôle
if (userRole !== 'archiviste' && userRole !== 'admin') {
return errorResponse(403, 'FORBIDDEN', '...');
}
// Étage 2 : type/scope
if (metadata.type !== 'planche') {
return errorResponse(400, 'VALIDATION_ERROR', '...');
}
// Étage 3 : cible actif (toutes les dimensions disponibles)
const targetUser = await getUserById(metadata.uploadedByOverride);
const isInactive =
!targetUser
|| !targetUser.isActive
|| !!targetUser.profile?.resignedAt
|| !!targetUser.profile?.deceasedAt;
if (isInactive) {
return errorResponse(400, 'VALIDATION_ERROR', '...');
}
effectiveUploadedBy = metadata.uploadedByOverride;
}
```
### Pourquoi vérifier toutes les dimensions
Un user peut être `isActive: true` dans le système mais avoir une `resignedAt` antérieure (désactivation non-synchrone). Le check "actif" doit combiner **toutes** les dimensions disponibles du modèle, pas un seul flag.

View File

@@ -42,6 +42,29 @@ source_projects: [app-template-resto, app-alexandrie]
- Index DB tenant compte du soft delete - Index DB tenant compte du soft delete
``` ```
### Piège — `include` ne filtre pas `deletedAt` automatiquement
`include: { related: true }` n'applique pas le filtre soft delete sur la relation. Si la relation pointe vers une entité elle-même soft-deletable, le doc caché reste exposé via la relation → fuite systématique.
Mitigations :
- relations to-many : `include: { related: { where: { deletedAt: null } } }`
- relations to-one (Prisma ne supporte pas `where` dans un `include` to-one) : `include: { related: { select: { deletedAt: true, ... } } }` puis filtrer post-query côté repo (`if (entity.related?.deletedAt) entity.related = null`)
Toujours `grep -rn "include.*<relationName>"` après l'ajout d'un soft delete pour identifier les sites à fixer.
### Pattern atomique anti-race delete/restore
```typescript
const result = await prisma.<model>.updateMany({
where: { id, deletedAt: null }, // ou { not: null } pour restore
data: { deletedAt: new Date(), deletedById: actorId },
});
if (result.count === 0) return notFound(); // idempotent, pas de double-audit
```
`updateMany` + `where: { id, deletedAt: null }` permet de transformer un check-then-update non atomique en un update atomique conditionnel — le `count === 0` distingue "déjà supprimé" de "introuvable" sans risque de double effet de bord.
### Checklist ### Checklist
- Filtrage soft delete par défaut - Filtrage soft delete par défaut
@@ -49,6 +72,7 @@ source_projects: [app-template-resto, app-alexandrie]
- Purge maîtrisée (cron / job) - Purge maîtrisée (cron / job)
- Index DB adaptés - Index DB adaptés
- Tests sur cas supprimé / restauré - Tests sur cas supprimé / restauré
- Audit des `include` sur les relations soft-deletables
--- ---
@@ -256,3 +280,387 @@ return raw.filter(c => c.isVisible).map(toPublicDto);
// Admin : même repo, filtre différent dans le service admin // Admin : même repo, filtre différent dans le service admin
return raw.map(toAdminDto); // retourne tout, visible ou non return raw.map(toAdminDto); // retourne tout, visible ou non
``` ```
---
<a id="pattern-audit-transactionnel-atomique"></a>
## Pattern : Audit transactionnel — mutation et log dans la même `$transaction`
- Objectif : garantir l'invariant `mutation persistée ⇔ audit log existe` quand l'audit est un livrable métier (pas un simple effet de bord informatif).
- Contexte : opérations sensibles (correction par un délégué hors périmètre habituel, opérations admin, opérations soumises à conformité).
- Quand l'utiliser : tout flux où une mutation sans trace serait inacceptable.
- Quand l'éviter : audits purement informatifs (statistiques d'usage, debug) — fire-and-forget acceptable.
- Avantage :
- rollback automatique si l'audit échoue → pas de mutation orpheline
- aucune divergence possible entre l'état persisté et la trace
- Limites / vigilance :
- une mutation peut désormais échouer pour cause "audit indisponible" → 5xx renvoyé au client (cohérent : on préfère refuser la mutation que la passer sans trace)
- Validé le : 27-04-2026
- Contexte technique : Prisma / NestJS — RL799_V2
### Implémentation
```typescript
type AuditClient = Prisma.TransactionClient | typeof prisma;
export const logActionSync = async (
client: AuditClient,
userId: string,
action: string,
targetType?: string,
targetId?: string,
metadata?: Record<string, unknown>,
) => {
await client.auditLog.create({ data: { userId, action, targetType, targetId, metadata } });
};
await prisma.$transaction(async (tx) => {
await tx.<entity>.update({ where: { id }, data: { ... } });
await logActionSync(tx, userId, '<entity>.<action>', '<entity>', id, { ... });
});
```
### Anti-patterns
- `logAction(...)` (fire-and-forget) après le persist quand l'audit est requis métier
- `logActionSync(prisma, ...)` (hors transaction) après le persist : synchrone mais pas atomique avec la mutation
- `.catch(() => {})` autour de l'audit "pour ne pas casser la mutation"
### Checklist
- [ ] Le helper d'audit accepte un `client: AuditClient` (transaction ou prisma)
- [ ] Mutation et audit dans la même `$transaction`
- [ ] Test d'atomicité : mock `createAuditLog` qui throw → assert rollback (cf. `knowledge/backend/patterns/tests.md`)
---
<a id="pattern-index-unique-partiel-actif"></a>
## Pattern : Index unique partiel Postgres pour invariant "≤ 1 active par X"
- Objectif : enforcer l'invariant "au plus une row active par scope" au niveau base de données plutôt que via un check applicatif vulnérable aux races.
- Contexte : ressources avec un cycle de vie `active → revoked/closed` où l'invariant métier impose une seule active par user/contexte (invitation, mandat d'officier, lock éditeur).
- Quand l'utiliser : dès qu'un check applicatif "≤ 1 active" est nécessaire et que la concurrence est possible.
- Quand l'éviter : si la table n'a pas de colonne `status` discriminante ou si plusieurs rows actives sont métier-acceptables.
- Avantage :
- 2e INSERT concurrente échoue avec contrainte unique violée (P2002) plutôt que de créer un doublon
- défense en profondeur : le check applicatif reste, mais la DB est la dernière ligne
- Limites / vigilance :
- Prisma ne supporte pas les unique partials en `schema.prisma` → ajouter dans la migration SQL brute
- documenter dans la migration : un `prisma format` accidentel pourrait droper l'index
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```sql
-- prisma/migrations/<TS>_xxx/migration.sql
CREATE UNIQUE INDEX invitations_one_active_per_user
ON invitations(user_id) WHERE status = 'active';
```
```typescript
export const revokeAndIssueInvitation = async (input) => {
try {
return await prisma.$transaction(async (tx) => {
await tx.invitation.updateMany({
where: { userId: input.userId, status: 'active' },
data: { status: 'revoked', revokedAt: new Date() },
});
return tx.invitation.create({
data: { userId: input.userId, ..., status: 'active' },
});
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
return { ok: false, reason: 'RACE_CONFLICT' };
}
throw err;
}
};
```
### Checklist
- [ ] Index partiel ajouté dans le SQL brut de la migration
- [ ] Handler `P2002` traduit en code métier (`RACE_CONFLICT`, 409)
- [ ] Test de race : `Promise.all([resend(), resend()])` puis `count({ status: 'active' }) === 1`
- [ ] Commentaire dans la migration : "Prisma ne supporte pas les unique partials en schema, ne pas droper sur `prisma format`"
---
<a id="pattern-uuid-v5-deterministe-seed"></a>
## Pattern : UUID v5 déterministe pour ids de seed
- Objectif : permettre d'écrire `seedUserId('venerable')` côté tests/code tout en garantissant que `User.id` reste un UUID RFC 4122 en base — débloque la rigidification Zod `.uuid()` en aval.
- Contexte : seed Prisma qui crée des entités référencées par slug lisible dans les tests, et qui doivent rester typées strictement côté API.
- Quand l'utiliser : nouveaux seeds OU migration d'un seed historique avec slugs littéraux comme PK.
- Quand l'éviter : si le seed est purement aléatoire (`@default(uuid())`) et qu'aucun test ne référence un user particulier par identifiant.
- Avantage :
- déterminisme : `seedUserId('venerable')` donne toujours le même UUID v5
- type uniforme : tous les `User.id` sont des UUID RFC 4122 → `.uuid()` activable
- lisibilité préservée : le code de tests reste sémantique
- Limites / vigilance :
- le slug ne doit JAMAIS être persisté en clair (mapping explicite `{ id: seedUserId(slug), ...rest }`)
- migration depuis un seed slug existant = chantier en 2 commits (cf. pattern rigidification Zod 2 phases dans `contracts.md`)
- Validé le : 24-04-2026
- Contexte technique : Prisma / uuid v5 — RL799_V2
### Implémentation
```typescript
// packages/shared/src/utils/seedIdentity.ts
import { v5 as uuidv5 } from 'uuid';
// Namespace stable du projet (généré une fois, committé ensuite)
export const SEED_USER_NAMESPACE = '2cd71e75-dd5e-42cc-b9fa-52888c42cc3d';
export const seedUserId = (slug: string): string =>
uuidv5(slug, SEED_USER_NAMESPACE);
```
Côté tests :
- helpers (`TEST_SECRETARY`, `TEST_VENERABLE`) exposent l'UUID résolu : les tests écrivent `TEST_SECRETARY.id`, pas `'secretaire'`
- les users ad-hoc éphémères (créés/supprimés dans le scope d'un test) utilisent `randomUUID()`, pas `seedUserId()` — réservé aux entités seed durables
### Pourquoi pas UUID v4 aléatoire
Le déterminisme est essentiel : il permet aux fixtures E2E de pointer un user précis (`const TRESORIER_ID = seedUserId('tresorier')`) sans lire la base, et garantit la reproductibilité du seed en CI.
---
<a id="pattern-test-invariant-post-seed"></a>
## Pattern : Test d'invariant post-seed
- Objectif : transformer la liste canonique des entités seed en contrat exécutable, détecter immédiatement un drift (slug ajouté hors helper, user oublié, format incohérent).
- Contexte : projet avec un seed structurant (users, configurations système) référencé par les fixtures de tests et les flux E2E.
- Quand l'utiliser : à chaque migration qui modifie la forme d'une entité seed (UUID, format d'id, contraintes).
- Quand l'éviter : seed purement aléatoire et jetable (pas de référence stable depuis les tests).
- Avantage :
- le test devient un contrat lisible du seed, pas une abstraction
- détecte un futur dev qui ajouterait un user via un slug littéral sans `seedUserId()`
- détecte un user oublié ou dupliqué
- Limites / vigilance :
- **nombre exact**, pas "au moins N" : si le seed tronque à 29 au lieu de 30, le test doit échouer
- filtrer explicitement par la liste des slugs connus — ne pas valider "tous les users en base" (résidus possibles)
- Validé le : 24-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation
```typescript
// __tests__/seedInvariants.test.ts
const SEED_SLUGS: readonly string[] = [
'venerable', 'secretaire', /* … 30 slugs … */
];
const EXPECTED_SEED_USER_COUNT = 31;
test('seed invariant: users seed possèdent un UUID déterministe', async () => {
assert.equal(SEED_SLUGS.length, EXPECTED_SEED_USER_COUNT, 'liste figée');
const expectedIds = SEED_SLUGS.map(seedUserId);
const users = await prisma.user.findMany({
where: { id: { in: expectedIds } },
select: { id: true },
});
assert.equal(users.length, EXPECTED_SEED_USER_COUNT);
assert.ok(users.every((u) => isValidUuid(u.id)));
});
```
### Checklist
- [ ] Liste figée des slugs en constante
- [ ] Compte exact (`.equal`, pas `.gte`)
- [ ] Filtrage explicite par la liste (pas de `findMany()` global)
- [ ] Vérification du format de l'id
---
<a id="pattern-check-fail-loud-conditionnee"></a>
## Pattern : Check `RAISE EXCEPTION` conditionnée à la présence de données
- Objectif : préserver la rejouabilité de la migration sur une DB vide (dev `prisma migrate reset`) tout en gardant le fail-loud sur DB peuplée.
- Contexte : migration qui fait un backfill de données existantes et veut échouer si l'admin/owner cible est absent.
- Quand l'utiliser : toute check "fail if missing X" qui protège un backfill, jamais le schéma lui-même.
- Quand l'éviter : check de schéma purement structurel (`NOT NULL`, FK) — ces contraintes appartiennent au DDL, pas à un `RAISE`.
- Avantage :
- DB vide (dev reset) : 0 row à backfiller → check skip propre, migration passe
- DB prod/staging avec données : check conservée, fail-loud comme prévu
- Limites / vigilance :
- une migration doit rester rejouable sur une DB vide ET une DB peuplée — c'est le contrat de `prisma migrate reset`
- Validé le : 21-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Anti-pattern
```sql
-- ❌ Bloque tout migrate reset sur dev (DB vide)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true) THEN
RAISE EXCEPTION 'Migration X requires an admin user.';
END IF;
END $$;
```
### Pattern correct
```sql
-- ✅ Exige admin uniquement s'il y a des données à backfiller
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM cotisation_entries WHERE status = 'paid')
AND NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true)
THEN
RAISE EXCEPTION 'Migration X requires an admin user to backfill existing paid entries.';
END IF;
END $$;
```
---
<a id="pattern-revocation-atomique-etat-transversal"></a>
## Pattern : Révocation atomique d'un état transversal lors d'une transition de cycle
- Objectif : éteindre les champs d'état transversaux (délégation, lock, ownership) dans la **même transaction** que la transition de cycle de vie de l'entité parente.
- Contexte : transitions `close / archive / cancel / soft-delete / lock définitif` d'une entité qui porte un ou plusieurs champs transversaux n'ayant plus de sens dans le nouveau cycle.
- Quand l'utiliser : à chaque transition de cycle où un état transversal devient un "zombie" potentiel (délégation qui survit à la clôture, lock qui survit à l'archivage).
- Quand l'éviter : transitions sans état transversal pertinent (archivage simple).
- Avantage :
- aucun état zombie possible
- la valeur précédente est capturée sous lock → audit et notif fiables (pas de race entre lecture et écriture)
- Limites / vigilance :
- les effets de bord (audit, notif) DOIVENT sortir de la transaction (best-effort, fire-and-forget)
- l'idempotence est gérée par le `WHERE` du `updateMany` (`closedAt: null`) — la 2e tentative ne re-déclenche pas les effets de bord
- Validé le : 27-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```typescript
let previousDelegateeId: string | null = null;
let updateCount = 0;
await prisma.$transaction(async (tx) => {
const lockResult = await tx.$queryRaw<Array<{ delegatee_id: string | null }>>`
SELECT delegatee_id FROM "entities"
WHERE id = ${id} AND closed_at IS NULL
FOR UPDATE
`;
if (lockResult.length === 0) return; // idempotence
previousDelegateeId = lockResult[0].delegatee_id;
const updated = await tx.entity.updateMany({
where: { id, closedAt: null },
data: {
closedAt: now,
closedBy: userId,
delegateeId: null, // ← révocation atomique dans la même tx
},
});
updateCount = updated.count;
});
// Effets de bord HORS de la transaction
if (updateCount > 0 && previousDelegateeId !== null) {
logAction(userId, 'entity:delegation_revoked_on_close', ...);
void notifyDelegatee(previousDelegateeId, ...);
}
```
### Les 4 invariants
1. La révocation vit dans le **même `updateMany`** que la transition principale.
2. La capture de la valeur précédente est sous **`SELECT FOR UPDATE`** dans la transaction.
3. Les effets de bord (audit, notif) **sortent de la transaction**.
4. L'idempotence est gérée par le `WHERE` (`closedAt: null`) — la 2e tentative est un no-op observable.
### Tests minimaux
- Happy path : transition avec valeur transversale présente → champ nullé + audit + notif au bon target
- Sans valeur transversale : pas d'effet de bord (pas d'audit révocation, pas de notif)
- Idempotence : 2e transition retombe en already_closed sans double effet
---
<a id="pattern-migration-destructive-4-phases"></a>
## Pattern : Migration destructive en 4 phases avec sentinelle d'archive
- Objectif : refondre une table avec PK changée ou colonnes incompatibles sans perdre l'audit historique des rows métier importantes.
- Contexte : table dont la PK ou la forme évolue de façon non-rétrocompatible (ex : token en clair → hash SHA-256 stocké, slug → UUID).
- Quand l'utiliser : refonte structurelle où un `ALTER TABLE` patchwork serait fragile (FK multiples, index, contraintes).
- Quand l'éviter : ajout simple de colonne nullable, refactor cosmétique d'index.
- Avantage :
- DROP + CREATE plus sûr qu'un patchwork ALTER quand la PK change
- les rows historiques (`status = 'consumed'`) sont conservées pour audit
- sentinelle d'archive non-collisionnable garantit qu'aucun login ne peut matcher une row archivée
- Limites / vigilance :
- Phase 1 (DELETE des rows non-migrables) impose une communication aux admins pré-deploy
- inspection manuelle obligatoire du SQL généré par `prisma migrate dev --create-only`
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Recette
```sql
-- Phase 1 : invalidation propre des données non-migrables
-- Les tokens en clair ne peuvent pas être convertis en SHA-256 (one-way).
-- Les rows 'consumed' sont conservées pour audit historique.
DELETE FROM invitations WHERE status != 'consumed';
-- Phase 2 : refonte de la table
CREATE TEMP TABLE invitations_archive AS
SELECT email, status, consumed_at FROM invitations WHERE status = 'consumed';
DROP TABLE invitations CASCADE;
CREATE TABLE invitations (
id TEXT NOT NULL DEFAULT gen_random_uuid()::text PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
...
);
CREATE UNIQUE INDEX invitations_one_active_per_user
ON invitations(user_id) WHERE status = 'active';
-- Phase 3 : restauration des rows consommées avec sentinelle non-collisionnable
-- '_legacy_' n'est jamais produit par crypto.randomBytes(32).toString('hex')
INSERT INTO invitations (id, user_id, token_hash, email, status, consumed_at)
SELECT
gen_random_uuid()::text,
u.id,
'_legacy_' || gen_random_uuid()::text,
a.email,
'consumed',
a.consumed_at
FROM invitations_archive a
JOIN users u ON u.email = a.email;
DROP TABLE invitations_archive;
-- Phase 4 : drop des colonnes obsolètes sur d'autres tables
ALTER TABLE users DROP COLUMN IF EXISTS must_change_password;
```
Côté repository, filtrer la sentinelle :
```typescript
const LEGACY_TOKEN_HASH_PREFIX = '_legacy_';
export const findInvitationByTokenHash = async (tokenHash: string) => {
if (tokenHash.startsWith(LEGACY_TOKEN_HASH_PREFIX)) return null;
// … lookup normal
};
```
### Checklist
- [ ] Phase 1 communiquée aux admins pré-deploy si tokens actifs en cours
- [ ] Phase 2 préfère `DROP + CREATE` quand la PK change
- [ ] Phase 3 utilise un préfixe **garanti non-collisionnable** par construction cryptographique
- [ ] Idempotence (`IF EXISTS` / `IF NOT EXISTS`) sur les changements réversibles
- [ ] Procédure rollback documentée (`pg_dump` avant migration)
- [ ] Smoke test post-deploy (login, création, magic link)

View File

@@ -0,0 +1,378 @@
---
title: Backend — Patterns : Tests
domain: backend
bucket: patterns
tags: [tests, vitest, prisma, integration, isolation]
applies_to: [implementation, review, debug]
severity: high
validated_on: 2026-05-02
source_projects: [RL799_V2]
---
# Backend — Patterns : Tests
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.
---
<a id="pattern-cleanup-track-tests-integration-db"></a>
## Pattern : `cleanup.track()` LIFO pour tests d'intégration DB
- Objectif : nettoyer proprement les artefacts créés par chaque test sans recourir à un `TRUNCATE CASCADE` qui casserait les transactions du SUT.
- Contexte : tests qui écrivent dans une vraie base (pas mockée), en cohabitation avec un seed durable et des tables FK-liées.
- Quand l'utiliser : dès qu'un test crée des rows à la volée et qu'on veut éviter la pollution inter-tests.
- Quand l'éviter : si le projet utilise `transactions + rollback par test` (déjà isolé), ou si le SUT n'écrit jamais en DB.
- Avantage :
- ordre LIFO = ordre FK-safe (les enfants tombent avant les parents)
- résistant aux throws intermédiaires (le `afterEach` tourne quand même)
- pas de `TRUNCATE` qui casserait les transactions ouvertes par le SUT
- Limites / vigilance :
- convention "100 % via `cleanup.track`" : ne pas mélanger avec des `prisma.*.deleteMany` directs
- la queue est pour le teardown, pas pour le setup : ne jamais tracker une création
- Validé le : 24-04-2026
- Contexte technique : Vitest / Prisma / Postgres — RL799_V2
### Implémentation (helper minimal)
```typescript
// __tests__/helpers/db.ts
type CleanupFn = () => Promise<void> | void;
export function createCleanup() {
const queue: CleanupFn[] = [];
return {
track(fn: CleanupFn) { queue.push(fn); },
async run() {
while (queue.length > 0) {
const fn = queue.pop()!;
try { await fn(); }
catch (err) { console.warn('cleanup.run error:', err); }
}
},
};
}
```
### Usage typique
```typescript
const cleanup = createCleanup();
afterEach(async () => { await cleanup.run(); });
test('crée une tenue', async () => {
const tenue = await prisma.tenue.create({ data: { /* … */ } });
cleanup.track(async () => {
await prisma.tenue.deleteMany({ where: { id: tenue.id } });
});
// … assertions
});
```
### Checklist
- [ ] Ordre LIFO respecté (FK-safe)
- [ ] Aucun `prisma.*.deleteMany` direct hors `cleanup.track` dans les fichiers concernés
- [ ] Cleanup défensif (`try/catch + warn`) — pas de partial leak
- [ ] Aucune création (seulement des suppressions/restores) dans la queue
---
<a id="pattern-globalsetup-vitest-purge-residus"></a>
## Pattern : `globalSetup` vitest pour purger les résidus DB inter-runs
- Objectif : repartir d'une DB propre en début de suite quand des cleanups intra-test sont incomplets et que les artefacts s'accumulent entre sessions.
- Contexte : projet vitest avec `maxWorkers: 1` (DB partagée), tables 100 % alimentées par les tests (notifications, audit logs, payments éphémères).
- Quand l'utiliser : symptômes de "tests verts en isolation, rouges en suite complète après plusieurs jours" liés à des `findFirst({ type })` qui tombent sur des résidus.
- Quand l'éviter : si la suite tourne déjà avec une stratégie `transactions + rollback` ou DB-per-worker.
- Avantage :
- hook standard vitest, pas de cron externe ni de `prisma:reset` manuel entre sessions
- 1 seule passe au démarrage de la suite, coût négligeable
- Limites / vigilance :
- **NE PAS purger les tables seed** (users, soirees seed, profiles, etc.) — réservé aux tables 100 % "test-only"
- ne corrige pas la flakiness intra-run entre fichiers consécutifs
- Validé le : 25-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation
```typescript
// vitest.config.ts
test: {
maxWorkers: 1,
globalSetup: ['./src/__tests__/globalSetup.ts'],
}
// src/__tests__/globalSetup.ts
export default async function globalSetup() {
await prisma.notification.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.cotisationPayment.deleteMany();
await prisma.$disconnect();
}
```
### Vérification avant d'ajouter une table
```bash
# Tables référencées par un snapshot/seed → NE PAS purger
grep -rln "prisma.<entity>.findMany\|seedSnapshot" src/__tests__/
```
### Checklist
- [ ] Aucune table seed dans la liste des `deleteMany`
- [ ] Hook référencé dans `vitest.config.ts`
- [ ] Justifier en commentaire pourquoi chaque table listée est test-only
---
<a id="pattern-template-database-isolation-fichiers"></a>
## Pattern : Template database Postgres pour isoler les fichiers de tests
- Objectif : éliminer la flakiness inter-fichiers en garantissant que chaque fichier de tests démarre avec une DB seedée pristine.
- Contexte : suite vitest qui partage une DB Postgres unique (cas typique : `maxWorkers: 1` ou DB unique côté CI).
- Quand l'utiliser : projet avec ≥50 fichiers de tests partageant une DB, présence de tests qui mutent les données seed sans restaurer systématiquement.
- Quand l'éviter : si les tests utilisent transactions+rollback (déjà isolés), DB-per-worker, ou si le rôle Postgres n'a pas le droit `CREATEDB`.
- Avantage :
- flakiness inter-fichiers éliminée par construction
- les tests ne polluent plus la DB de dev partagée
- réutilisation entre sessions : ~27 s au 1er run, < 1 s aux suivants
- Limites / vigilance :
- surcoût ~50 s sur 134 fichiers (cycle `drop + create FROM TEMPLATE` ~380 ms par fichier)
- les sub-processes (`prisma db seed`) doivent recevoir les envs critiques explicitement (`APP_BASE_URL`, `JWT_SECRET`, `ENCRYPTION_KEY`)
- Validé le : 01-05-2026
- Contexte technique : Prisma 7 / pg 8 / Postgres 17 / vitest 4 — RL799_V2
### Mécanique en 3 lots
**Lot 1 — DSN + primitives admin SQL** (`dbUrls.ts`, `dbAdmin.ts`) : `getAdminDsn()`, `getTemplateDsn()`, `getTestDsn()`, `databaseExists`, `dropDatabase` (`pg_terminate_backend` + `DROP IF EXISTS`), `createDatabase(name, { template })`. Whitelist stricte des noms de DB (les SQL admin ne supportent pas `$1`).
**Lot 2 — Bootstrap idempotent du template** (`bootstrapTemplate.ts`) :
```typescript
export async function ensureTemplateReady() {
if (await databaseExists(TEMPLATE)) return; // no-op si déjà là
await createDatabase(TEMPLATE);
spawnSync('pnpm', ['-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy'], {
env: { ...process.env, DB_URL: getTemplateDsn() },
});
spawnSync('pnpm', ['-C', 'apps/api', 'exec', 'prisma', 'db', 'seed'], {
env: { ...process.env, DB_URL: getTemplateDsn() },
});
}
```
**Lot 3 — Câblage vitest** :
```typescript
// globalSetup.ts
export default async function globalSetup() {
await ensureTemplateReady();
if (await databaseExists(TEST)) await dropDatabase(TEST);
return async () => {
if (await databaseExists(TEST)) await dropDatabase(TEST);
};
}
// setupFile.ts (avant chaque fichier)
if (await databaseExists(TEST)) await dropDatabase(TEST);
await createDatabase(TEST, { template: TEMPLATE });
```
### Pièges anticipés
- **`resolveDbUrl()` qui force le pathname `/test`** : doit préserver le DSN s'il pointe déjà sur la template (sinon le seed sub-process écrit dans la mauvaise DB).
- **`prisma db seed` sub-process** : les envs critiques chargées par `lib/prisma.ts` côté app doivent être propagées au sub-process (`loadEnv.ts` importé en tête de `globalSetup.ts`).
- **`globalForPrisma` cache un PrismaClient** : c'est le **contenu** de la DB qui change (drop+create), pas le DSN — pas de `$disconnect()` nécessaire grâce à `pg_terminate_backend`.
### Checklist
- [ ] Whitelist stricte des noms de DB autorisés (anti-injection)
- [ ] Bootstrap idempotent (réutilise la template entre sessions de dev)
- [ ] Sub-processes du bootstrap reçoivent les envs critiques
- [ ] Drop + create FROM TEMPLATE au début de chaque fichier
- [ ] Test de flakiness avant/après pour valider le gain
---
<a id="pattern-helper-waitfor-fire-and-forget"></a>
## Pattern : Helper `waitForX()` polling-borné pour les attentes fire-and-forget
- Objectif : remplacer les `setTimeout(50) + findFirst` dispersés par un helper centralisé robuste à la charge CPU et auto-documenté.
- Contexte : tests qui valident un side-effect async (audit log, notification, mail log) déclenché par `setImmediate` / `Promise.resolve().then(...)` côté SUT.
- Quand l'utiliser : tout test qui attend qu'un side-effect non-awaited apparaisse en DB.
- Quand l'éviter : pour vérifier l'**absence** d'un event — là il faut un délai fixe puis assertion d'absence (le polling jusqu'au timeout ne prouve rien).
- Avantage :
- robuste aux variations de charge (pas de durée arbitraire)
- fail rapide si l'event n'arrive pas (timeout 1500 ms par défaut)
- lisibilité : intention claire (`waitForX` vs `setTimeout` + `findFirst`)
- Limites / vigilance :
- le polling consomme des requêtes DB (1 toutes les 50 ms) — négligeable en `maxWorkers: 1`
- **NE PAS** utiliser pour vérifier l'absence d'un event
- Validé le : 25-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation
```typescript
// __tests__/helpers/asyncWait.ts
export const waitForAudit = async (
query: { action: string; targetId?: string; userId?: string },
options: { timeoutMs?: number; intervalMs?: number } = {},
) => {
const timeout = options.timeoutMs ?? 1500;
const interval = options.intervalMs ?? 50;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const audit = await prisma.auditLog.findFirst({ where: query });
if (audit) return audit;
await new Promise((r) => setTimeout(r, interval));
}
return null;
};
```
### Anti-pattern
```typescript
// ❌ délai arbitraire et fragile
await new Promise((r) => setTimeout(r, 50));
const notif = await prisma.notification.findFirst({ where: { ... } });
// ✅ polling borné, intention claire
const notif = await waitForNotification({ type: 'X', recipientId: userId });
```
### Checklist
- [ ] Filtre exhaustif (au minimum `recipientId` ou `targetId` pour éviter de matcher les artefacts d'un autre test)
- [ ] Timeout par défaut court (1500 ms)
- [ ] Migration progressive — pas tous les tests d'un coup
---
<a id="pattern-test-atomicite-transaction"></a>
## Pattern : Tester l'atomicité d'une transaction (audit + mutation)
- Objectif : prouver par un test que la promesse "X est garanti par une transaction" tient — sans ce test, un refactor "fire-and-forget" passe en review sans alarme.
- Contexte : code de service qui revendique `mutation persistée ⇔ audit log existe` via `prisma.$transaction(async tx => { await tx.X.update(...); await logActionSync(tx, ...); })`.
- Quand l'utiliser : sur tout chemin critique où l'audit ou un side-effect transactionnel est un livrable métier.
- Quand l'éviter : pour les audits purement informatifs (statistiques d'usage) où le fire-and-forget est acceptable.
- Avantage :
- le test fait office de contrat exécutable du pattern transactionnel
- une régression silencieuse (passer `prisma` au lieu de `tx`) casse le test immédiatement
- Limites / vigilance :
- le mock doit cibler le seul appel ciblé (pas un mock global qui fait tout throw)
- Validé le : 27-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation (recipe vitest + spyOn)
```typescript
import * as auditRepository from '../repositories/admin/auditRepository';
test('mutation rollback si audit throw', async () => {
vi.spyOn(auditRepository, 'createAuditLog').mockImplementation(async (data) => {
if (data.action === 'X.cross_soiree_update') {
throw new Error('audit-failure-simulee');
}
return null as never;
});
const res = await POST_HANDLER(...);
assert.ok(res.status >= 500);
assert.equal(await prisma.X.findFirst(...), null); // rollback
assert.equal(await prisma.auditLog.count(...), 0);
});
```
### Checklist
- [ ] Mock chirurgical sur l'action ciblée, pas un mock global
- [ ] Assertions sur **trois** invariants : status HTTP 5xx, donnée non persistée, audit non écrit
- [ ] Restaurer le mock après le test (`vi.restoreAllMocks()` en `afterEach`)
---
<a id="pattern-describe-convention-tests-integration"></a>
## Pattern : Convention `describe()` minimale pour tests d'intégration
- Objectif : rendre les fichiers de tests > 150 LOC navigables, regroupables par cause d'échec et lisibles dans le reporter.
- Contexte : tests d'intégration API où les assertions s'enchaînent sans regroupement, ou fichiers historiquement plats.
- Quand l'utiliser : tout fichier > 150 LOC ou > 5 tests.
- Quand l'éviter : fichier court (< 50 LOC, < 3 tests) où le `describe` ajoute du bruit.
- Avantage :
- le reporter vitest groupe les échecs logiquement (toutes les erreurs d'auth ensemble)
- navigation facilitée par recherche (`describe('Auth'`)
- un groupe devient candidat naturel à l'extraction en fichier dédié
- Limites / vigilance :
- 2 niveaux maximum — au-delà ça devient illisible
- Validé le : 24-04-2026
- Contexte technique : Vitest — RL799_V2
### Convention recommandée (2 niveaux)
```typescript
describe('POST /api/tenues', () => {
describe('Auth', () => {
test('refuse un user non-admin → 403', async () => { /* … */ });
test('refuse un token invalide → 401', async () => { /* … */ });
});
describe('Validation', () => {
test('refuse un body sans date → 400', async () => { /* … */ });
});
describe('Happy path', () => {
test('crée une tenue valide → 201', async () => { /* … */ });
});
});
```
### Seuils
- < 50 LOC et < 3 tests : optionnel
- 50150 LOC : 1 niveau de contexte
- \> 150 LOC ou > 5 tests : 2 niveaux obligatoires
### Niveau 2 — vocabulaire stable
`Auth`, `Validation`, `Happy path`, `Edge cases`, `Error handling`, `Integration`. Ne pas inventer un nom local — la cohérence inter-fichiers vaut plus que l'expressivité ponctuelle.
---
<a id="pattern-refactor-iteratif-tests-monolithe"></a>
## Pattern : Refactor itératif d'un fichier de tests monolithe
- Objectif : découper un fichier > 1000 LOC mêlant plusieurs domaines métier en fichiers thématiques sans introduire de régression.
- Contexte : fichier de tests historique qui partage un `beforeEach` lourd, n'a pas de `describe`, et accueille tous les nouveaux tests par défaut.
- Quand l'utiliser : > 1000 LOC, plusieurs domaines mélangés, navigation pénible.
- Quand l'éviter : fichier ciblé, déjà cohérent même volumineux.
- Avantage :
- 1 commit par domaine extrait → review possible commit par commit
- helpers réutilisables émergent naturellement
- reporter vitest groupe les échecs par domaine
- Limites / vigilance :
- flakiness inter-fichiers possible pendant la transition (cleanups incomplets temporaires)
- tolérer 1-2 fails au 1er run pendant le chantier, investiguer après
- Validé le : 25-04-2026
- Contexte technique : Vitest — RL799_V2
### Étapes (ordre obligatoire)
1. **Analyse** : `grep -nE "^test|^// ---" fichier.test.ts` → carte des sections + repérage des dépendances closure.
2. **Extraction des helpers d'abord** : `helpers/<domaine>.ts` avec `setup<Domaine>Fixtures(cleanup)` qui retourne explicitement les IDs (pas de closure module-level).
3. **POC sur le domaine le plus simple** (4-7 tests, peu de dépendances). Commit + push dès que vert.
4. **Itération domaine par domaine** par ordre croissant de complexité. Une seule règle : ne jamais commencer un nouveau domaine sans avoir commit le précédent.
5. **Suppression finale du fichier monolithe** quand le dernier domaine est extrait.
### Pièges à éviter
- **Ne jamais dupliquer le fichier** pour le scinder : toujours **déplacer** (extraction → suppression dans l'original → commit).
- **Closure variables partagées** : si les tests utilisent `let X = ''` au niveau module, les ids doivent passer par le retour de `setup<Domaine>`.
- **Snapshot/restore seed** : si le fichier original prenait un snapshot d'entries seed, **chaque** nouveau fichier doit le faire (sinon le 1er fichier qui tourne snapshot un état déjà pollué).
### Checklist
- [ ] Helpers extraits **avant** le 1er domaine
- [ ] 1 commit par domaine
- [ ] `wc -l` du fichier original baisse à chaque commit (preuve de progrès)
- [ ] Suite verte à chaque commit

View File

@@ -14,5 +14,6 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
| `stripe.md` | Stripe, paiements, webhooks, subscriptions | billing_cycle_anchor vs current_period_end, list() sans has_more, concurrence trial→payant, non-idempotence, 200 pendant processing | | `stripe.md` | Stripe, paiements, webhooks, subscriptions | billing_cycle_anchor vs current_period_end, list() sans has_more, concurrence trial→payant, non-idempotence, 200 pendant processing |
| `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète | | `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète |
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h | | `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h |
| `nextjs.md` | Next.js, build, routing | Prisma init au chargement module, server-only dans repositories, redirect boucle infinie feature flags | | `nextjs.md` | Next.js, build, routing | Prisma init au chargement module, server-only dans repositories, redirect boucle infinie feature flags, dossiers `_*` exclus du routing App Router |
| `general.md` | Observabilité, migrations, performance, architecture | Observabilité insuffisante, migrations non reproductibles, upsert N+1, authorize-after-fetch, valeur sentinelle DTO, idempotence endpoint, fichier orphelin, mélange Date UTC/locale, champ fantôme Zod, catch vide, params non validés, cast TS brut, chevauchement temporel, TOCTOU, biais agrégation, couplage types erreur, service HTTP-aware, count sans filtre, env top-level | | `general.md` | Observabilité, migrations, performance, architecture | Observabilité insuffisante, migrations non reproductibles, upsert N+1, authorize-after-fetch, valeur sentinelle DTO, idempotence endpoint, fichier orphelin, mélange Date UTC/locale, champ fantôme Zod, catch vide, params non validés, cast TS brut, chevauchement temporel, TOCTOU, biais agrégation, couplage types erreur, service HTTP-aware, count sans filtre, env top-level, dérive DTO liste vs détail, notification linkUrl rôle-aware, matrice documentée vs code, format `User.id` mixte, Web Push topic > 32 chars, lib npm types non embarqués, form HTML POST dans un mail, env vars frontend-facing fail-fast |
| `tests.md` | Isolation des tests d'intégration | `vi.stubEnv` sans restauration, `maxWorkers: 1` masque l'isolation, flakiness inter-fichiers DB partagée |

View File

@@ -366,3 +366,96 @@ it('retourne 403 si subscription inactive', async () => {
- Contexte technique : auth / cycle de vie compte — RL799_V2 17-04-2026 - Contexte technique : auth / cycle de vie compte — RL799_V2 17-04-2026
--- ---
<a id="risque-helpers-x-actif-derivants"></a>
## Helpers "X actif" qui dérivent silencieusement
### Risques
- Plusieurs helpers répondent à la même question — *"l'entité X est-elle active / opérante ?"* — avec des filtres légèrement différents
- Un user passe la guard A mais pas la guard B sur la même ressource (ou inversement). Bugs silencieux, pas d'erreur, juste une asymétrie de comportement
### Symptômes
- Délégation `secretaireDeSeance` "active" filtrée sur `status: 'published', closedAt: null, cancelledAt: null` dans un helper, juste `cancelledAt: null` dans l'autre
- Un ex-délégué d'une soirée clôturée garde l'autorité cross-soirée indéfiniment
### Bonnes pratiques / mitigations
1. **Un seul helper canonique** par notion d'activité (ex : `isDelegationActive`, `isSoireeOpenForRappel`). Les autres l'appellent
2. Si la centralisation n'est pas faisable immédiatement (ex : helper appelé en N+1 query, perf), au moins un test qui compare leur output sur des fixtures partagées et casse à la moindre divergence
3. Au minimum : un commentaire en tête du helper "secondaire" qui pointe vers le canonique et liste explicitement les filtres à maintenir synchronisés
- Contexte technique : auth / RBAC — RL799_V2 27-04-2026
---
<a id="risque-guard-charge-objets-riches"></a>
## Guard d'autorisation qui charge des objets riches
### Risques
- Une guard d'autorisation s'exécute à CHAQUE requête sur une route protégée
- Si la guard a besoin de "trouver une candidate" (ex : "cette tenue est-elle dans les 'dernières rappelables' du grade pour une de mes délégations ?"), le repo helper utilisé doit avoir un select **minimal**, PAS le select complet utilisé par les services métier
- Pour un user avec N délégations actives, on charge N agrégats volumineux à chaque requête
### Symptômes
- Même fonction repo appelée par (1) un service qui a besoin de toutes les relations (rendu UI) et (2) une guard qui n'a besoin que de l'id
- La guard paie le coût du fetch riche inutilement
- Latence guard qui croît avec le nombre de relations chargées
### Bonnes pratiques / mitigations
Exposer **deux variantes** du repo helper :
- `findX(...)` — select riche, utilisé par les services métier
- `findXIdOnly(...)` — select `{ id: true }`, utilisé par les guards
```typescript
// Guard
export const requireXAccess = async (request, id, { roleSet }) => {
// utilise findXIdOnly (select minimal) — pas findX
const candidate = await repo.findXIdOnly({ ... });
if (!candidate || candidate.id !== id) return forbidden();
};
// Service métier
export const getXFullDetails = async (id) => {
return repo.findX({ ... }); // include riche
};
```
Coût : duplication de la clause `where` (acceptable, factorisable en constante). Bénéfice : la guard reste O(1) en payload même quand les relations grossissent.
- Contexte technique : auth / performance — RL799_V2 27-04-2026
---
<a id="risque-suppression-flag-auth-global"></a>
## Suppression d'un flag auth global (DB + DTO + tests) — cleanup atomique obligatoire
### Risques
- Un flag profondément câblé dans Prisma (ex : `mustChangePassword`, `isVerified`) ne peut pas être supprimé incrémentalement : chaque cleanup partiel produit un état non-compilable
- Les fixtures de tests qui posent `mustChangePassword: false` cassent à la compilation TS au moment du drop — bloque tout commit séparé
- Les helpers `helpers/db.ts` et les DTO partagés (`packages/shared`) sont prioritaires, sinon les imports cross-package échouent en cascade
### Symptômes
- `Property 'mustChangePassword' does not exist on type 'User'` après un drop partiel
- Tentative de découpage en sous-lots qui échoue au typecheck
### Bonnes pratiques / mitigations
Quand on prévoit de supprimer un flag auth profondément câblé :
1. **Le cleanup ne peut pas être incrémental** — soit on supprime tout dans un chantier, soit on garde le flag avec un nullable de transition
2. **Les fixtures de tests doivent être nettoyées dans le même PR** — grep systématique avant de démarrer (`grep -rn "mustChangePassword" apps/`) pour estimer l'ampleur
3. **Les helpers `helpers/db.ts`** sont prioritaires — un seul fichier touché casse tous les tests qui l'importent
4. **Les DTO partagés (`packages/shared`)** doivent être alignés en premier
5. Considérer un sous-lot dédié au cleanup si le flag est transverse — éviter de l'inclure dans un sous-lot fonctionnel
**Anti-pattern** : déprécier en douceur en gardant le flag avec un commentaire `// @deprecated` sans supprimer les usages. Le code mort s'accumule, les futurs devs hésitent à le nettoyer ("pourquoi c'est encore là ?"), la dépréciation ne se finit jamais.
- Contexte technique : auth / refactor schema — RL799_V2 28-04-2026

View File

@@ -222,3 +222,40 @@ Quand un repository ou service crée une nouvelle valeur pour un champ enum-like
3. Vérifier que les endpoints de lecture qui parsent ces données acceptent la nouvelle valeur 3. Vérifier que les endpoints de lecture qui parsent ces données acceptent la nouvelle valeur
- Contexte technique : Zod / contrats partagés — RL799_V2 03-04-2026 - Contexte technique : Zod / contrats partagés — RL799_V2 03-04-2026
---
<a id="risque-zod-email-tolowercase-trim"></a>
## Bug Zod 4 — `z.string().email().toLowerCase().trim()` rejette les emails à trim
### Risques
- Le pattern `z.string().email().toLowerCase().trim()` ne fait **pas** ce qu'il prétend en Zod 4 : `.email()` est une assertion qui valide le format **brut**, **avant** que les transforms `.toLowerCase()` / `.trim()` s'appliquent
- Un email avec espace trailing (`"BOB@X.FR "`) est rejeté `Invalid email` au lieu d'être trim+lower
### Symptômes
- Test fixture `BOB@X.FR ` (trailing space) → 400 alors que l'intention est `bob@x.fr`
- Pattern présent dans plusieurs schémas du projet (`visitorProfileLookupSchema`, `tenueVisitorCreateSchema`, etc.)
### Bonnes pratiques / mitigations
```typescript
// ❌ Pattern legacy faux (Zod 4) — assertion AVANT transforms
const emailSchema = z.string().email().max(254).toLowerCase().trim();
// ✅ Pattern correct : trim/lower AVANT email assertion via pipe
const emailSchema = z
.string()
.trim()
.toLowerCase()
.pipe(z.string().email().max(254));
```
`.pipe()` chaîne deux schémas — le premier transforme (trim+lower), le second valide (email+max). L'ordre devient explicite et l'assertion est appliquée après normalisation.
**Tests à ajouter** : `BOB@X.FR ` (trailing space) → `bob@x.fr`, ` ALICE@TEST.FR` (leading + casse) → `alice@test.fr`. Si le schéma rejette `Invalid email`, le bug est présent.
**À auditer projet-wide** : grep tous les schémas avec ce pattern (`.email().toLowerCase().trim()`) et migrer en `.pipe()`.
- Contexte technique : Zod 4 — RL799_V2 01-05-2026

View File

@@ -868,3 +868,280 @@ try {
- Définir une timezone métier unique pour les communications utilisateur. - Définir une timezone métier unique pour les communications utilisateur.
- Contexte technique : dates / formatage serveur — RL799_V2 15-04-2026 - Contexte technique : dates / formatage serveur — RL799_V2 15-04-2026
---
<a id="risque-derive-dto-liste-vs-detail"></a>
## Dérive silencieuse DTO liste vs DTO détail
### Risques
- Un DTO "détail" expose un ensemble complet de champs métier, pendant qu'un DTO "liste" ne propage qu'un sous-ensemble jugé "suffisant" au moment où il est créé
- Au fil du temps, le front a besoin de plus de champs et découvre que les DTOs de liste sont **amputés** — workarounds ad-hoc, champs morts produits jamais consommés, helpers partagés impossibles à appeler sur les listes sans cast
### Symptômes
- Un consommateur front appelle l'endpoint détail juste pour obtenir un champ qui existe côté détail mais pas liste (N+1 réseau déguisé)
- Workarounds ad-hoc (`soireeClosedAt: Date | null` dans un mapper TenueSummary, copie partielle de champs) parce que le champ racine manque
- Helper partagé (`getSoireeLifecycle(input)`) qui accepte un `SoireeLifecycleInput` qu'**aucun** DTO de liste n'implémente réellement
- Type "sous-ensemble" (`SoireeCalendarStatus = 'draft' | 'pending_vm_approval' | 'published'`) aligné sur un filtre SQL transitoire plutôt que sur la sémantique du domaine
### Bonnes pratiques / mitigations
- **Règle par défaut** : DTO liste = sous-ensemble de DTO détail, pas un type parallèle. Extraire une base commune si besoin (`SoireeCore`).
- Pour chaque champ scalaire ajouté au DTO détail, se poser la question : doit-il aussi être dans les DTOs de liste ? Si oui, le propager sur-le-champ
- **Typage fort sur les sous-ensembles** : `SoireeCalendarStatus = SoireeStatus` (alias) plutôt qu'une union locale qui reflète un filtre SQL
- Test de coverage statique qui vérifie, pour chaque DTO ciblé, que tous ses mappers exposent les champs requis
- Audit périodique après une livraison qui ajoute des champs (ex : `openedAt` persistant) : lister les DTOs de liste et vérifier qu'aucun n'est amputé
- Contexte technique : DTO / contrats partagés — RL799_V2 23-04-2026
---
<a id="risque-notif-linkurl-non-role-aware"></a>
## Notification `linkUrl` non rôle-aware → page vide / 403 silencieux
### Risques
- Une notification envoyée à N destinataires multi-rôles avec un `linkUrl` constant route certains utilisateurs vers une page à laquelle ils n'ont pas accès
- Symptôme côté membre : "la notif m'envoie sur une page vide" — UX cassée sans message d'erreur explicite
### Symptômes
- Code de création de notif qui fait `recipients.map((r) => ({ linkUrl: 'constant' }))` sans lire `r.role`
- Notif qui cible plusieurs rôles (ex : "tous les membres") mais utilise un linkUrl pointant vers un module à accès restreint
### Bonnes pratiques / mitigations
```typescript
// Toujours sélectionner role dans le select des recipients
const recipients = await prisma.user.findMany({
where: { isActive: true, role: { in: [...ROLES_ALL_ACTIVE] } },
select: { id: true, role: true },
});
// Brancher le linkUrl par rôle
const secretariatRoles = new Set(['secretaire', 'venerable', 'admin']);
linkUrl: secretariatRoles.has(recipient.role)
? `/secretariat?soireeId=${id}`
: `/tenues?tab=calendrier`;
```
**Règle d'or** : le `linkUrl` d'une notif doit ouvrir une page **que l'utilisateur a le droit de voir ET où le contexte de la notif est visible**. Un membre qui reçoit "Soirée annulée" doit atterrir sur le calendrier (carte rouge), pas sur un module secrétariat qu'il ne peut pas consulter.
**Test E2E suggéré** : publier une notif multi-rôles, se connecter avec chaque rôle, cliquer, vérifier que chacun arrive sur une page accessible et pertinente.
- Contexte technique : notifications / RBAC — RL799_V2 23-04-2026
---
<a id="risque-matrice-documentee-vs-code"></a>
## Matrice documentée ≠ code — dérive silencieuse
### Risques
- Une matrice de permissions / contrats publiée dans une story (markdown) diverge discrètement de l'implémentation
- La doc dit "X peut Y", le code refuse Y à X (ou inversement). Aucun test ne couvre la combinaison rare
- La divergence se paye au prochain audit RBAC ou au touchement suivant du module — souvent par surprise
### Symptômes
- Story d'origine qui annonce une perm que le code ne grant pas (ou inversement)
- Un nouvel agent lit la story et la matrice, pense que la perm est active, et écrit du code qui repose dessus → faux positif aval
- Bug détecté plusieurs cycles après publication, par hasard
### Bonnes pratiques / mitigations
1. **Audit pré-flight systématique** avant tout PATCH d'un module RBAC : `grep -rn '<helper-perm>' apps/` pour confirmer les call sites, comparer avec la matrice de la story d'origine
2. **Réconciliation atomique** : si on touche un helper de permission, mettre à jour les **deux couches** (granulaire `permissions.ts` + fonctionnelle `documentPermissions.ts`) dans la même PR
3. **Test de matrice dédié** : un test unitaire qui itère la matrice de la story et vérifie chaque cellule. Casse à la première dérive
4. Préférer **un seul source of truth** (le code) et générer la doc automatiquement (markdown depuis tests, ou inverse)
- Contexte technique : RBAC / documentation — RL799_V2 20-04-2026
---
<a id="risque-format-user-id-mixte"></a>
## Format `User.id` : UUID OU slug, jamais les deux
### Risques
- Un schéma où `User.id` est un `String` libre finit par mélanger deux formats : IDs lisibles du seed (`admin`, `membre-m05`) et vrais UUIDs générés à l'invitation
- Conséquence : impossible de mettre `z.string().uuid()` dans les DTOs qui prennent un `userId` sans casser la prod
- Surface d'injection grande (payloads de 100 caractères acceptés au lieu de UUID stricts)
### Symptômes
- Schéma Zod avec `z.string().min(1).max(128)` là où on voudrait `z.string().uuid()`
- Commentaire "l'ID n'est pas forcément un UUID, on accepte toute chaîne"
- Deux populations d'ids coexistantes en base (seed slug + invitations UUID)
### Bonnes pratiques / mitigations
- Décider tôt : soit `@default(uuid())` côté Prisma partout, soit IDs structurés documentés avec une regex stricte (`^[a-z]+-[a-z0-9]+$`) publiée dans un helper shared (`isValidUserId`)
- **Ne jamais mélanger**
- Ajouter un test d'invariant : à la fin du seed, assert que tous les `users.id` matchent le format choisi
- Si migration vers UUID en cours de route : prévoir un script qui propage sur **toutes** les FKs (`audit_logs.user_id`, `notifications.recipient_id`, `refresh_tokens.user_id`, et tout `@relation` vers `User`)
- Pattern de migration : UUID v5 déterministe via `seedUserId(slug)` (cf. `pattern-uuid-v5-deterministe-seed` dans `patterns/prisma.md`)
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026
---
<a id="risque-web-push-topic-32-chars"></a>
## Web Push `topic` header > 32 chars rejeté/tronqué (RFC 8030)
### Risques
- La [RFC 8030 §5.4](https://datatracker.ietf.org/doc/html/rfc8030#section-5.4) limite le header `Topic` à 32 caractères URL-safe
- FCM tronque silencieusement (topics distincts pour deux notifs censées dédupliquer), Apple Push rejette la requête, Mozilla autopush comportement variable
- Symptôme : déduplication absente → avalanche de notifs au reconnect d'un device offline
### Symptômes
- Push provider qui retourne 4xx sur des `topic` longs
- Plusieurs notifs reçues là où une seule devrait l'être
### Bonnes pratiques / mitigations
```typescript
import crypto from 'node:crypto';
const hashTopic = (seed: string): string =>
crypto.createHash('sha256').update(seed).digest('base64url').slice(0, 32);
await webpush.sendNotification(sub, body, {
TTL: 86_400,
urgency: 'high',
topic: hashTopic(`${type}-${contextId}`), // toujours ≤ 32 chars URL-safe
});
```
**Notes** :
- `base64url` (Node `crypto` natif depuis 16.x) produit un encoding URL-safe (`A-Za-z0-9_-`)
- Tronquer à 32 chars **après** encoding base64url, pas avant le hash
- Test unitaire : assert `topic.length <= 32` ET `topic.match(/^[A-Za-z0-9_-]+$/)` pour toutes les seeds réalistes
- Contexte technique : Web Push / RFC 8030 — RL799_V2 28-04-2026
---
<a id="risque-lib-npm-types-non-embarques"></a>
## Lib npm avec types annoncés mais non embarqués
### Risques
- Certaines libs Node prétendent embarquer leurs types TS depuis une version donnée mais le package npm publié ne les contient pas
- `@types/<lib>` DefinitelyTyped existe mais peut être legacy, non maintenu, ou en conflit avec les exports réels du package
### Symptômes
- `Could not find a declaration file for module '<lib>'. … Try \`npm i --save-dev @types/<lib>\``
- TS7016 après `pnpm add <lib>` alors que la doc annonce que les types sont embarqués
### Bonnes pratiques / mitigations
Créer une déclaration TS locale minimaliste qui couvre uniquement la surface consommée par le projet :
```typescript
// apps/api/src/types/web-push.d.ts (exemple)
declare module 'web-push' {
export interface PushSubscriptionLike {
endpoint: string;
keys: { p256dh: string; auth: string };
}
export interface RequestOptions {
TTL?: number;
urgency?: 'very-low' | 'low' | 'normal' | 'high';
topic?: string;
}
export function setVapidDetails(subject: string, publicKey: string, privateKey: string): void;
export function sendNotification(
sub: PushSubscriptionLike,
payload?: string | Buffer | null,
options?: RequestOptions,
): Promise<{ statusCode: number; body: string; headers: Record<string, string> }>;
const _default: { setVapidDetails: typeof setVapidDetails; sendNotification: typeof sendNotification };
export default _default;
}
```
**Bénéfices** :
- on est maître du contrat utilisé (si la lib évolue, on étend volontairement)
- pas de dépendance `@types/*` legacy
- documentable : commentaire JSDoc en tête `Pourquoi pas @types/<lib>`
**Préventif** :
- `tsconfig.json` doit `include` le dossier `src/types/**/*.d.ts`
- documenter en commentaire en tête du `.d.ts` POURQUOI on a écrit ça soi-même
- Contexte technique : TypeScript / npm — RL799_V2 28-04-2026
---
<a id="risque-form-html-post-mail"></a>
## Form HTML POST dans un mail = neutralisé par tous les clients
### Risques
- Un `<form method="POST" action="...">` placé dans le corps HTML d'un mail transactionnel est **neutralisé par tous les clients mail majeurs** — c'est une mesure anti-phishing universelle, pas un bug
- Toute donnée structurée doit transiter par **l'URL** d'un GET (query string ou path), donc visible côté visiteur
### Symptômes
| Client | Comportement réel sur `<form method="POST">` |
| --- | --- |
| Gmail web | Rewrite l'action en GET, body en query string |
| Gmail iOS/Android | Bouton inactif ou ouvre en GET |
| Outlook web | Strip le `<form>` complètement |
| Apple Mail (macOS/iOS) | Désactive le submit, bouton no-op |
| Thunderbird | Bloqué par sécurité |
### Bonnes pratiques / mitigations
Mitigations pour ne pas exposer la donnée dans l'URL navigable :
1. **Pattern token signé court** (HMAC ou JWT) : encode la donnée dans un token opaque dans la query string, échangé immédiatement côté client contre un état serveur, puis `history.replaceState()` pour nettoyer l'URL (cf. `pattern-magic-link-url-clean` dans `patterns/auth.md`)
2. **Token one-shot DB** : génère un token aléatoire stocké en DB, consommé à la 1ʳᵉ requête, expire ensuite
3. **Cookie de session courte** : le 1ᵉʳ hit set un cookie httpOnly puis redirige vers une URL clean
À documenter dans toute spec de magic link / RSVP / one-shot URL pour éviter qu'un dev parte sur un POST mail.
- Contexte technique : email transactionnel — RL799_V2 30-04-2026
---
<a id="risque-env-vars-frontend-facing-fail-fast"></a>
## env vars frontend-facing — fail-fast strict hors dev (pas de fallback `localhost`)
### Risques
- Un mail prod qui contient un lien `http://localhost:3000/foo` parce que `APP_URL` n'a pas été défini sur l'instance prod
- Aucun signal serveur, aucune erreur au déploiement, aucune trace en logs. L'utilisateur final clique → page introuvable
- Le fallback dev-friendly (`process.env.APP_URL ?? 'http://localhost:3000'`) cache l'erreur de config en non-dev
### Symptômes
- URL `localhost` dans des emails reçus par des utilisateurs réels
- Détection uniquement par un humain qui reçoit le mail, pas par le serveur
### Bonnes pratiques / mitigations
```typescript
export const getBaseUrl = (): string => {
const raw = process.env.APP_URL;
if (raw !== undefined && raw !== '') return raw.replace(/\/+$/, '');
if (process.env.NODE_ENV === 'development') return 'http://localhost:3000';
throw new Error('APP_URL non configuré (requis hors dev). Le bouton ... pointerait vers un host invalide.');
};
```
- Dev local : fallback silencieux (workflow attendu)
- Prod / staging / test : throw au premier appel → erreur visible dans les logs du dispatch
- Le throw au boot du dispatch est préférable à un mail dégradé silencieux
**Variantes à étendre** : tout helper qui construit une URL frontend depuis le backend (reset password, invitation, convocation, notification mail) doit utiliser le même helper centralisé. Une seule source de vérité par projet — éviter le doublon `APP_URL` + `APP_BASE_URL`.
**Test** : couvrir les 4 cas (env défini avec slash, env défini sans slash, env undefined NODE_ENV=dev → fallback, env undefined NODE_ENV=prod → throw).
- Contexte technique : config / mails transactionnels — RL799_V2 29-04-2026

View File

@@ -164,3 +164,28 @@ return buildLocalizedPath(locale, "home");
- **Signal review** : logique dupliquée dans `middleware.ts` avec un commentaire "Edge incompatible" - **Signal review** : logique dupliquée dans `middleware.ts` avec un commentaire "Edge incompatible"
- Contexte technique : Next.js / middleware — RL799_V2 08-04-2026 - Contexte technique : Next.js / middleware — RL799_V2 08-04-2026
---
<a id="risque-app-router-private-folders"></a>
## App Router — dossiers `_*` exclus silencieusement du routing
### Risques
- Tout segment d'URL préfixé par `_` (ex : `_e2e`, `_helpers`, `__internal`) est traité par Next.js App Router comme un *private folder* et **exclu silencieusement du routing**
- Le `route.ts` ou `page.tsx` existe sur le filesystem, le typecheck passe, mais l'URL retourne 404
- Aucune erreur au boot, aucun warning
### Symptômes
- "Ma route existe pourtant je vois le fichier" — `apps/api/src/app/api/__e2e/visitor-token/route.ts` qui retourne 404
- Temps de debug perdu à chercher une cause obscure
### Bonnes pratiques / mitigations
- Ne **jamais** préfixer un segment de route par `_` ou `__` même pour signaler une intention "interne / e2e / debug"
- Utiliser des noms explicites : `/api/e2e/`, `/api/internal/`, `/api/dev/` (sans underscore initial)
- Pour gater l'accès en prod : check `process.env` au début du handler (`if (process.env.E2E !== '1') return 404`)
- Référence : Next.js docs — Project Structure → Private folders. Convention héritée de l'écosystème React/Webpack pour exclure les dossiers de la résolution
- Contexte technique : Next.js App Router — RL799_V2 30-04-2026

View File

@@ -441,3 +441,181 @@ Checklist minimale après `prisma migrate resolve --applied` :
- Ajouter des tests ciblés sur payload partiel et concurrence logique. - Ajouter des tests ciblés sur payload partiel et concurrence logique.
- Contexte technique : Prisma / partition logique — RL799_V2 09-04-2026 - Contexte technique : Prisma / partition logique — RL799_V2 09-04-2026
---
<a id="risque-capture-pre-updatemany-race-window"></a>
## Capture pré-`updateMany` sans transaction — race window silencieuse
### Risques
- `findUnique` + `updateMany` non atomiques : entre les deux, un autre process peut modifier le champ capturé. L'audit log ment (enregistre une `previousValue` qui n'était plus la valeur courante au moment de l'écriture). Notif envoyée au mauvais target.
- Si l'`updateMany` ne filtre que sur `status` sans inclure la valeur attendue, il peut écraser une nouvelle valeur sans erreur
### Symptômes
```typescript
// ❌ Race window entre les deux requêtes
const before = await prisma.entity.findUnique({ where: { id }, select: { x: true } });
// … un autre process modifie entity.x ici …
const after = await prisma.entity.updateMany({
where: { id, status: 'X' },
data: { status: 'Y', x: null },
});
audit.log({ previousX: before.x }); // ← MENT
notify(before.x); // ← mauvais target
```
### Bonnes pratiques / mitigations
**Solution 1 — `SELECT ... FOR UPDATE` dans une transaction** (cf. `pattern-revocation-atomique-etat-transversal` dans `patterns/prisma.md`) :
```typescript
let previousValue: T | null = null;
await prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw<Array<{ x: T }>>`
SELECT x FROM "entities" WHERE id = ${id} FOR UPDATE
`;
if (locked.length === 0) return;
previousValue = locked[0].x;
await tx.entity.updateMany({ where: { id, ... }, data: { ... } });
});
```
**Solution 2 — `WHERE` qui inclut la valeur attendue** (CAS-light, sans transaction) :
```typescript
const updated = await prisma.entity.updateMany({
where: { id, status: 'X', x: expectedX }, // ← guard sur la valeur
data: { ... },
});
if (updated.count === 0) {
// soit déjà transitionné, soit x a changé — relire et décider
}
```
### Détecteur mental
Si tu écris :
```typescript
const before = await prisma.X.findUnique(...);
await prisma.X.updateMany(...);
// … tu utilises before.<champ> dans l'audit ou la notif
```
**Stop**. Tu as une race. Soit `before.<champ>` n'a pas changé entre les deux (et alors pourquoi le capturer ?), soit il a pu changer (et tu mens).
- Contexte technique : Prisma / concurrence — RL799_V2 27-04-2026
---
<a id="risque-slugs-metier-user-id"></a>
## Slugs métier comme `User.id` — schémas Zod laxistes obligés
### Risques
- Un `User.id` en `String` libre (slug lisible côté seed, UUID `@default(uuid())` côté invitations) empêche toute rigidification Zod `.uuid()` sur les champs `userId` côté API
- Couplage tests/seed invisible : des dizaines de tests hardcodent `'membre-m05'` côté input, sans contrat explicite. Tout renommage du seed casse la suite en cascade sans warning compilateur
- Drift silencieux : deux populations d'ids coexistent en base, validation impossible à uniformiser
### Symptômes
- `prisma.user.create({ data: { id: '<texte-lisible>', ... } })` dans un fichier de seed
- Schéma Zod avec `z.string().min(1).max(128)` là où on voudrait `z.string().uuid()`
- Test qui référence `userId: 'membre-m05'` en argument d'une requête API
- Commentaire "l'ID n'est pas forcément un UUID, on accepte toute chaîne" → dette déguisée
### Bonnes pratiques / mitigations
- Décider tôt : soit `@default(uuid())` côté Prisma partout, soit IDs structurés documentés avec une regex stricte (`^[a-z]+-[a-z0-9]+$`) publiée dans un helper shared (`isValidUserId`)
- **Ne jamais mélanger** : si le seed utilise des slugs et les comptes produits utilisent des UUIDs, les schémas Zod sont condamnés à être laxistes
- Migration : utiliser un UUID v5 déterministe (`seedUserId(slug)`) — cf. `pattern-uuid-v5-deterministe-seed` dans `patterns/prisma.md`
- Test d'invariant post-seed obligatoire (cf. pattern dédié)
- Si migration en cours de route : prévoir un script qui propage sur **toutes** les FKs (`audit_logs.user_id`, `notifications.recipient_id`, `refresh_tokens.user_id`, etc.)
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026
---
<a id="risque-prisma-where-relation-every-vide"></a>
## `where: { relation: { every: ... } }` trivialement vrai sur relation vide
### Risques
- La clause `every` sur une relation est **trivialement vraie** quand la relation est vide. Sans coupler avec `some: {}`, on capture aussi les rows qui n'ont aucune entrée liée — risque de purge à tort sur les nouvelles entités
### Symptômes
```typescript
// ❌ Faux : capture aussi les profiles SANS aucune VR liée
const orphans = await prisma.visitorProfile.findMany({
where: {
lastSeenAt: { lt: cutoff },
registrations: { every: { status: 'rejected' } }, // vacuously true si pas de VR
},
});
```
- Test "purge orphelins après 30 j" qui supprime un profile fraîchement créé
- Tests qui passent sur des fixtures avec relations existantes mais cassent dès qu'une entité sans relation est créée
### Bonnes pratiques / mitigations
```typescript
// ✅ Correct : exige au moins une VR liée ET toutes rejected
where: {
lastSeenAt: { lt: cutoff },
registrations: {
some: {}, // au moins une VR existe
every: { status: 'rejected' }, // toutes rejected
},
},
```
**Règle générale** : à chaque fois qu'on cherche « toutes les X de Y sont Z », vérifier si Y peut avoir 0 X. Si oui, ajouter `some: {}` pour exclure le cas vide.
- Contexte technique : Prisma — RL799_V2 01-05-2026
---
<a id="risque-resolvedburl-template-based-testing"></a>
## `resolveDbUrl()` testing template-based — préserver le DSN explicite vers la template
### Risques
- Un helper `resolveDbUrl()` qui force `pathname='/<projet>_test'` quand `NODE_ENV=test` écrase un DSN appelant qui pointe explicitement vers `<projet>_test_template`
- Le bootstrap template (`runPrisma(['db', 'seed'])` en sub-process avec `DB_URL=...test_template + NODE_ENV=test`) écrit dans la mauvaise DB ou échoue avec "database does not exist"
### Symptômes
- `bootstrapTemplate échec "pnpm prisma db seed" (exit 1)`
- Tests vitest échouent ensuite avec `Database <projet>_test does not exist on the database server`
- Sub-process de seed qui logge un DSN différent de celui passé en `env`
### Bonnes pratiques / mitigations
```typescript
const resolveDbUrl = (): string | undefined => {
const url = process.env.DB_URL;
if (!url) return url;
if (process.env.NODE_ENV !== 'test') return url;
try {
const parsed = new URL(url);
// Exception : préserver le DSN si déjà sur la template
// (cas bootstrap migrate/seed, sinon le seed pointe sur <projet>_test inexistante)
if (parsed.pathname === '/<projet>_test_template') {
return url;
}
parsed.pathname = '/<projet>_test';
return parsed.toString();
} catch {
return url;
}
};
```
**Règle générale** : toute stratégie template-based doit auditer le chemin du `DB_URL` à travers les sub-processes de bootstrap. Le bootstrap ouvre une connexion sur la template, mais le seed transitif exécuté via un sub-process peut être sujet à des transformations agressives du DSN qui le redirigent ailleurs.
- Contexte technique : Prisma / template database / Vitest — RL799_V2 01-05-2026

View File

@@ -0,0 +1,123 @@
---
title: Backend — Risques & vigilance : Tests
domain: backend
bucket: risques
tags: [tests, vitest, isolation, env-vars, flakiness]
applies_to: [analysis, implementation, review, debug]
severity: high
validated_on: 2026-05-02
source_projects: [RL799_V2]
---
# Backend — Risques & vigilance : Tests
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/risques/README.md` pour l'index complet.
---
<a id="risque-vi-stubenv-sans-restauration"></a>
## `vi.stubEnv` sans restauration — fuite env vars inter-fichiers
### Risques
- Un test qui stub une env var dans `beforeAll` sans `vi.unstubAllEnvs()` en `afterAll` affecte silencieusement tous les fichiers exécutés après lui dans le même process
- En séquentiel (`maxWorkers: 1`), l'ordre est déterministe et la fuite est invisible — la suite passe au vert
- En passant à `maxWorkers > 1`, les env vars stubbées sont partagées entre workers → tests imprévisibles
### Symptômes
- Tests qui passent en isolation mais échouent dans la suite complète, ou inversement
- Comportement d'un endpoint qui dépend d'une env définie dans un fichier qui n'a rien à voir
- Migration de `maxWorkers: 1` vers `maxWorkers: 4` qui rouge la suite d'un coup
### Bonnes pratiques / mitigations
```typescript
beforeAll(() => {
vi.stubEnv('RESEND_API_KEY', 'test-key');
});
afterAll(() => {
vi.unstubAllEnvs();
});
// Variante encore plus robuste (isolation parfaite par test) :
beforeEach(() => { vi.stubEnv('X', 'y'); });
afterEach(() => { vi.unstubAllEnvs(); });
```
- Détection : `rg "vi\.stubEnv\(" __tests__ | wc -l` doit être ≤ `rg "vi\.unstubAllEnvs\(\)" __tests__ | wc -l` regroupé par fichier
- Avant toute migration vers `maxWorkers > 1` : sweep complet `stubEnv` / `unstubAllEnvs`
- Contexte technique : Vitest — RL799_V2 24-04-2026
---
<a id="risque-maxworkers-1-masque-isolation"></a>
## `maxWorkers: 1` masque les problèmes d'isolation
### Risques
- L'exécution séquentielle cache systématiquement tous les bugs d'isolation : `vi.stubEnv` non restaurée, mutations de seed non restaurées, `deleteMany` direct, compteurs globaux non resets
- La CI actuelle ne peut pas détecter ces problèmes → faux sentiment de sécurité
- Le jour où on veut paralléliser pour gagner du temps, on découvre une dizaine de bugs d'isolation simultanément
### Symptômes
- CI verte en `maxWorkers: 1`, rouge dès `maxWorkers > 1`
- Tests "verts depuis 6 mois" qui rougent soudainement après un changement de config
- Pas de détection possible avant le passage en parallèle
### Bonnes pratiques / mitigations
- Tester la parallélisation tôt, même si la suite est petite — passer `maxWorkers: 2` force l'équipe à écrire des tests isolés
- Si on hérite d'un projet en `maxWorkers: 1`, ne pas migrer d'un coup. Audit ciblé d'abord :
- `grep "vi\.stubEnv\(" / "vi\.unstubAllEnvs\(" / "deleteMany\(" / "TEST_USER\."` pour repérer les patterns suspects
- Ajouter un audit "hidden_by_serial_execution" en review de tests : lister les patterns qui marcheraient aujourd'hui mais casseraient en parallèle
- Heuristique : projet > 200 tests → impératif de passer en parallèle (sinon CI > 15 min = friction dev majeure)
- Contexte technique : Vitest — RL799_V2 24-04-2026
---
<a id="risque-flakiness-inter-fichiers-db-partagee"></a>
## Flakiness inter-fichiers vitest avec DB partagée
### Risques
- Un fichier de tests laisse des artefacts résiduels en DB que le fichier suivant ne s'attend pas à voir : audits orphelins, notifications, entries seed mutées, rate-limiters non resets
- Le pattern se "résout" au 2e run par chance (le `beforeEach` finit par nettoyer par effet de bord), donnant une fausse confiance
- En CI, un retry automatique masque la vraie cause
### Symptômes
- 2-4 tests rouges au 1er run, vert au 2e run sans aucune modification
- `vitest run <fichier-isolé>` vert, suite complète rouge
- Compteurs `count({ type: 'X' })` qui tombent sur des résidus d'anciens tests
### Bonnes pratiques / mitigations
**Diagnostic** :
```bash
# 1. Run isolé sur le fichier suspect
pnpm -C apps/api test mon-fichier
# 2. 2 runs consécutifs de la suite complète
pnpm -C apps/api test && pnpm -C apps/api test
# Si 1er rouge / 2e vert → flakiness inter-fichiers
```
**Stratégies par horizon** :
- **Court terme** : accepter comme dette connue si le 2e run est stable, documenter dans le commit message
- **Moyen terme** : identifier le fichier qui pollue, ajouter le cleanup manquant dans son `afterEach`
- **Long terme** : DB-per-worker ou `transactions + rollback` (chantier d'infra dédié, voir `knowledge/backend/patterns/tests.md` pattern template database)
**À ne PAS faire** :
- Ajouter des `setTimeout` pour "attendre que ça se stabilise"
- Wrapper les assertions dans des try/catch silencieux
- Marquer les tests `.skip`
**Heuristique gravité** :
- 1 fail intermittent toutes les 5 runs : acceptable temporairement
- 1+ fail systématique au 1er run, vert au 2e : à diagnostiquer mais pas urgent
- Fails aléatoires différents à chaque run : urgent (state corruption)
- Contexte technique : Vitest / Prisma — RL799_V2 25-04-2026

View File

@@ -8,9 +8,10 @@ Avant toute proposition frontend, identifie le fichier dont le nom et la descrip
| Fichier | Domaine | Entrées clés | | Fichier | Domaine | Entrées clés |
|---------|---------|--------------| |---------|---------|--------------|
| `state.md` | State management, UI states, Zustand, listes paginées | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère | | `state.md` | State management, UI states, Zustand, listes paginées, refactor monolithe Vue | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère, refactor monolithe Vue sous-lots Go/No-Go, convention `pages/<module>/`, `styles.css` partagé non-scoped, annuaire client-side TTL |
| `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité | | `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité, AppInput Outlined Material thème dark, fusion DRY composants jumeaux par prop discriminante |
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique | | `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique, factorisation page mode dynamique via `meta.mode` typé |
| `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes | | `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes |
| `nextjs.md` | Next.js App Router, embeds, ESLint | Click-to-load embeds tiers, ESLint flat config Next.js | | `nextjs.md` | Next.js App Router, embeds, ESLint | Click-to-load embeds tiers, ESLint flat config Next.js |
| `tests.md` | Tests styles React Native, Jest node env | Tests de styles sans renderer JSX | | `tests.md` | Tests styles React Native, smoke checks, mount + mock composable | Tests de styles sans renderer JSX, smoke checks `readFileSync`, classe CSS modifier vs texte, cleanup E2E best-effort, helpers SW purs, mount + mock composable, assertions React Email |
| `general.md` | Focus visible, inputs date HTML5, journaux/audit logs, pages admin | Focus visible interne pour overflow clip, restyle global `<input type="date">`, UI patterns journaux d'audit, structuration pages admin (eyebrows + grille filtres + variante danger) |

View File

@@ -128,3 +128,166 @@ setItems((prev) => [...prev, created]); // pas de router.refresh()
``` ```
**Pour les entités avec relations :** utiliser un helper `findItemById(tenantId, id)` appelé après la mutation pour retourner la forme complète avec les relations résolues. **Pour les entités avec relations :** utiliser un helper `findItemById(tenantId, id)` appelé après la mutation pour retourner la forme complète avec les relations résolues.
---
<a id="pattern-app-input-outlined-material-dark"></a>
## Pattern : AppInput Outlined Material adapté thème dark
### Synthèse
- **Objectif** : homogénéiser tous les inputs de l'app avec un design "outlined Material" adapté à un thème dark custom (label flottant, encoche opaque calée sur la card parente).
- **Contexte** : projet Vue/React avec un thème dark où les inputs natifs cassent le design system (couleur d'encoche, débordement Safari iOS, fond input transparent).
- **Quand l'utiliser** : design system app-wide où tous les inputs doivent suivre la même grammaire visuelle.
- **Quand l'éviter** : design strictement neutre (inputs natifs OS-style) ou framework UI déjà opinioné (Vuetify, Material UI).
### Analyse
- **Avantages** :
- design cohérent sur tous les formulaires (login, profil, modales)
- encoche calée sur la **card parente**, pas sur le bg global → fusion visuelle propre
- `appearance: none` + `min-width: 0` + `min-height: 48px` corrigent les inputs date Safari iOS
- **Limites / vigilance** :
- les pièges (couleur d'encoche, débordement, fond transparent) sont non-évidents et coûtent du temps à chaque itération si non documentés
- `inheritAttrs: false` + séparation manuelle class/style obligatoires pour permettre le layout grid externe sans fuite des attrs HTML
### Validation
- Validé le : 01-05-2026
- Contexte technique : Vue 3 Composition API — RL799_V2
### Composant central
```vue
<script setup lang="ts">
defineOptions({ inheritAttrs: false });
const props = defineProps<{
modelValue?: string | number;
label: string; // requis — pas d'input sans label
type?: string;
staticLabel?: boolean; // label toujours haut (utile pour selects)
}>();
const attrs = useAttrs();
// Séparation manuelle class/style — permet layout grid externe (--col-2)
// sans que les attrs HTML fuitent sur le wrapper
const wrapperClass = computed(() => attrs.class);
const wrapperStyle = computed(() => attrs.style);
const inputAttrs = computed(() => {
const { class: _c, style: _s, ...rest } = attrs;
return rest;
});
</script>
<template>
<label :class="['app-input', wrapperClass]" :style="wrapperStyle">
<input
:value="modelValue"
placeholder=" "
v-bind="inputAttrs"
class="app-input__control"
@input="$emit('update:modelValue', $event.target.value)"
/>
<span class="app-input__label">{{ label }}</span>
</label>
</template>
<style scoped>
.app-input__control {
width: 100%;
min-width: 0; /* CRITIQUE Safari iOS — sinon débordement */
min-height: 48px; /* homogénéise date/datetime-local */
padding: 12px;
background: transparent; /* fusion totale avec la card parente */
border: 1px solid var(--color-border-base);
border-radius: 6px;
appearance: none; /* CRITIQUE iOS pour datetime-local */
-webkit-appearance: none;
}
/* Label flottant quand input rempli OU focus */
.app-input__control:focus + .app-input__label,
.app-input__control:not(:placeholder-shown) + .app-input__label {
top: 0;
font-size: 0.75rem;
/* Encoche opaque calée sur la CARD parente, pas le bg global */
background: var(--app-input-notch-bg, var(--color-surface-raised));
}
</style>
```
### Pièges documentés
1. **Encoche du mauvais bg** : utiliser `--color-bg-elevated` (canvas global) au lieu de `--color-surface-raised` (card) → patch coloré visible. Toujours caler sur la couleur de la card parente. Si l'input vit dans un contexte différent (modale, header), exposer `--app-input-notch-bg` en custom property pour override.
2. **Bordure traverse le label** : si le label flottant n'a pas de `background` opaque, la bordure passe derrière le texte. L'encoche n'est pas optionnelle.
3. **Fond transparent obligatoire** : si le fond de l'input est différent de la card, l'encoche révèle un patch. Solution : `background: transparent` → fusion totale.
4. **`placeholder=" "` imposé** : le sélecteur `:placeholder-shown` ne marche que si un placeholder existe. Un espace suffit, n'apparaît pas visuellement.
5. **`inheritAttrs: false` + séparation class/style** : sans ça, un parent qui pose `class="--col-2"` voit cette classe ET tous les attrs HTML atterrir sur le wrapper.
### Variantes
- **`AppSelect`** : même base, label toujours flottant haut. Chevron SVG `stroke="currentColor"` 1.5px.
- **`AppTextarea`** : label toujours flottant haut, pas de slot trailing.
---
<a id="pattern-fusion-dry-composants-jumeaux"></a>
## Pattern : Fusion DRY de composants jumeaux par prop discriminante
### Synthèse
- **Objectif** : factoriser deux composants partageant la même UI à 80 %+ avec des contextes d'appel différents, sans extraire un 3ᵉ composant `Body` qui multiplie les fichiers et les indirections.
- **Contexte** : ex `ConvocationResponseCard` (autonome, charge ses données) + `ConvocationResponseForm` (reçoit la convocation du parent) — même UI, deux modes de consommation.
- **Quand l'utiliser** : diff entre les deux composants tient en une dizaine de `computed`/`v-if` discriminés sur **un seul flag de mode**.
- **Quand l'éviter** :
- cycles de vie ou stores différents (un consomme un store Pinia, l'autre est purement contrôlé) — le `computed` discriminant pollue tout le composant
- UI diverge à > 30 % (sections présentes dans l'un, absentes dans l'autre)
### Analyse
- **Avantages** :
- une seule source de vérité pour markup et styles
- tests structurels consolidés sur un seul fichier
- évolution UX synchronisée par construction
- **Limites / vigilance** :
- **anti-pattern à refuser** : extraire un 3ᵉ composant `Body` partagé entre les deux composants originaux. Multiplie les fichiers, ajoute une couche d'indirection (props drilling, events bubbling) sans réduire la complexité réelle
### Validation
- Validé le : 01-05-2026
- Contexte technique : Vue 3 — RL799_V2 (530 lignes dupliquées → 310 lignes uniques)
### Implémentation
```vue
<script setup lang="ts">
const props = defineProps<{
// Mode A : Card autonome (consomme un dataset complet)
data?: ProchaineTenueData;
// Mode B : Form simple (reçoit juste l'objet à éditer)
convocation?: ProchaineTenueConvocation;
}>();
const isCardMode = computed(() => props.data !== undefined);
// Convocation dérivée selon le mode → le reste du composant manipule
// uniquement `convocation.value`, sans se soucier du mode
const convocation = computed(() => {
if (isCardMode.value) {
const primary = props.data!.primaryGrade;
return props.data!.gradeInfos[primary]?.convocation;
}
return props.convocation;
});
// Émission typée en union — chaque mode émet son type approprié
const emit = defineEmits<{
updated: [ProchaineTenueData | ConvocationResponseData];
}>();
</script>
```
### Critère de décision
Si le diff entre les deux composants tient en une dizaine de `computed`/`v-if` discriminés sur **un seul flag de mode**, fusionner. Si ça déborde, garder distincts.

View File

@@ -0,0 +1,377 @@
# Frontend — Patterns : Général
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/patterns/README.md` pour l'index complet.
---
<a id="pattern-focus-visible-interne-overflow-clip"></a>
## Pattern : Focus visible interne pour champs sous overflow clip
### Synthèse
- **Objectif** : préserver le focus visible des champs `<input>` / `<select>` quand l'app applique `overflow-x: clip` sur ses conteneurs (PageShell, panel, layout) — le focus outline natif est dessiné **hors** de la box du champ et se fait clipper.
- **Contexte** : app mobile-first où `overflow-x: clip` (ou `hidden`) est fréquent sur les conteneurs pour empêcher le débordement horizontal.
- **Quand l'utiliser** : tout projet avec une chaîne de parents en `overflow: hidden|clip` autour des champs de saisie.
- **Quand l'éviter** : si la chaîne de parents n'a pas d'`overflow: hidden|clip` — l'outline natif suffit.
### Analyse
- **Avantages** :
- aucun pixel ne sort de la box, donc aucun clip possible
- `box-shadow: inset` + `border-color` est portable Chrome/Firefox/Safari
- **Limites / vigilance** :
- `outline-offset: -2px` marche sur Chrome mais le rendu varie : Firefox/Safari peuvent ignorer selon la combinaison `outline-style`
### Validation
- Validé le : 27-04-2026
- Contexte technique : CSS / mobile-first — RL799_V2
### Pattern correctif (par composant)
```css
.my-input:focus-visible,
.my-select:focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
```
### Application globale au thème (recommandée pour mobile-first)
Plutôt que de répéter le pattern dans chaque composant, le pousser dans le fichier de thème global. Tous les inputs/selects/textareas en bénéficient automatiquement.
```css
/* Dans theme/<theme>.css ou globals.css, hors :root */
input[type='text']:focus-visible,
input[type='search']:focus-visible,
input[type='email']:focus-visible,
input[type='tel']:focus-visible,
input[type='url']:focus-visible,
input[type='number']:focus-visible,
input[type='password']:focus-visible,
input[type='date']:focus-visible,
input[type='datetime-local']:focus-visible,
input[type='time']:focus-visible,
input[type='month']:focus-visible,
input[type='week']:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
```
### Pourquoi sélecteurs d'attribut explicites et pas `input:focus-visible`
`input` couvre aussi `type='checkbox'`, `type='radio'`, `type='file'`, `type='range'`, `type='color'` qui ont leur propre design natif. La règle pourrait casser leur rendu (carrés or autour des cases à cocher, etc.). On liste explicitement les types texte/saisie.
### Conflits avec composants ayant leur propre `:focus`
Un composant qui a déjà `.my-input:focus { ... }` (sans `-visible`) gardera la priorité par spécificité (classe > tag). La règle globale ne joue que pour les composants qui n'ont rien défini → sûr à introduire en mid-projet.
---
<a id="pattern-restyle-input-date-sans-wrapper-js"></a>
## Pattern : Restyle global de `<input type="date">` 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
---
<a id="pattern-ui-journaux-audit-logs"></a>
## 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 `<pre>` 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. `<optgroup>` 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<string, Entry[]>();
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
<select>
<option value="">Toutes les actions</option>
<optgroup v-for="[cat, opts] in groupsFromLabels" :label="cat" :key="cat">
<option v-for="o in opts" :value="o.value" :key="o.value">{{ o.label }}</option>
</optgroup>
</select>
```
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 `<time>` pour les forensics.
```typescript
const formatRelative = (iso: string) => {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return 'à l\'instant';
if (diff < 3600) return `il y a ${Math.round(diff / 60)} min`;
if (diff < 86400) return `il y a ${Math.round(diff / 3600)} h`;
if (diff < 172800) return 'hier';
if (diff < 604800) return `il y a ${Math.round(diff / 86400)} j`;
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric', month: 'short', year: 'numeric',
});
};
```
### 5. Détails techniques repliables
La metadata détaillée (clés/valeurs verboses, IDs internes, raisons null) sert à l'investigation, pas à la lecture courante. La masquer derrière un `Voir les détails / Masquer les détails`, et reset l'état d'expansion à chaque rechargement.
```typescript
const expanded = ref<Set<string>>(new Set());
const toggle = (id: string) => {
const next = new Set(expanded.value);
next.has(id) ? next.delete(id) : next.add(id);
expanded.value = next;
};
// Reset après chaque load
const loadList = async () => {
/* … */
expanded.value = new Set();
};
```
Le bouton "voir détails" n'apparaît **que si** la card a effectivement de la metadata. Un toggle vide pollue la grille visuelle.
### Anti-patterns à éviter
- Cards uniformes en couleur/border quel que soit le type d'événement → tue la scanabilité
- Date absolue toujours visible (`27 avril 2026 à 08:37`) sur des cards serrées → bruit cognitif inutile
- UUIDs en plein texte sans rétrogradation visuelle
- Cascade 2 selects quand un `<optgroup>` natif suffit
- Bouton "voir détails" affiché même sans détails à voir
- Persister l'expansion entre navigations / paginations → état orphelin
---
<a id="pattern-structuration-pages-admin"></a>
## Pattern : Structuration de pages admin (eyebrows + grille filtres + variante danger)
### Synthèse
- **Objectif** : poser une grammaire visuelle commune sur les écrans admin (filtres + liste/form + sections multiples) sans framework lourd.
- **Contexte** : module Admin avec plusieurs panels (Audit, Utilisateurs, Corbeille, etc.) qui partagent la même structure.
- **Quand l'utiliser** : ≥ 3 panels admin avec structure similaire.
- **Quand l'éviter** : page admin unique sans cohérence inter-panels à maintenir.
### Validation
- Validé le : 27-04-2026
- Contexte technique : Vue 3 / CSS — RL799_V2 (5 panels admin)
### 1. Eyebrows de section
Au lieu de titres H2/H3 trop forts, utiliser des "eyebrows" : mini-labels uppercase, letter-spacing élargi, taille `caption`, couleur d'accent.
```css
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: var(--font-size-caption);
color: var(--color-accent);
margin: 0;
}
```
```html
<p class="eyebrow">Filtres</p>
<div class="my-filters"><!----></div>
<p class="eyebrow">Résultats <span class="eyebrow-count">· {{ total }} entrées</span></p>
<div class="my-list"><!----></div>
```
Le compteur (`· N entrées`) est en `font-weight: normal`, sans uppercase, en `color-text-secondary` — typographie en cascade.
Bénéfice : structure visuelle sans concurrencer un éventuel titre de page. Cohérence inter-écrans dans tout un module si l'eyebrow est utilisé partout.
Quand ne pas afficher l'eyebrow `Résultats` : sur loading / error / empty state. Le `StateBlock` (ou équivalent) prend le relais.
### 2. Grille de filtres hiérarchique
Quand 3+ filtres dont l'un est dominant (typiquement une recherche), au lieu d'un flex-wrap chaotique, utiliser une grille avec le filtre primaire en pleine largeur :
```html
<div class="filters">
<label class="filters__label filters__label--full">Recherche
<input type="search">
</label>
<label class="filters__label">Statut <select><!----></select></label>
<label class="filters__label">Rôle <select><!----></select></label>
</div>
```
```css
.filters {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: end;
gap: var(--space-2);
}
.filters__label--full {
grid-column: 1 / -1;
}
```
### 3. Variante `danger` pour actions destructives
Sur un écran qui mélange actions constructives et destructives (ex : saisie initiale + bypass admin) :
```css
.btn--danger {
border-color: color-mix(in srgb, var(--color-danger) 48%, transparent);
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
}
.card--danger {
border-left: 3px solid var(--color-danger);
}
```
`color-mix` produit un rouge **soft** (12-20 % saturation), pas un flash rouge violent.
**Anti-pattern** : `background: red` / `color: red` directs ou hex hardcodés (`#ef4444`). Toujours via tokens du thème.
### Anti-patterns à éviter
- Filtres en `flex-wrap` qui produisent 3 lignes asymétriques selon le viewport
- Hint général qui répète ce que l'empty state dit déjà → 1 message, 1 niveau d'info
- Confondre "titre de page" (l'onglet actif suffit souvent) et "structure de section" (eyebrows)
- Action destructive en variant primary or → danger explicite

View File

@@ -198,3 +198,82 @@ Sans focus trap, le clavier peut sortir de la sheet/panel et casser l'ordre de n
- Restaurer le focus au trigger à la fermeture. - Restaurer le focus au trigger à la fermeture.
--- ---
<a id="pattern-factorisation-page-meta-mode"></a>
## Pattern : Factorisation page mode dynamique via `route.meta.mode` typé
### Synthèse
- **Objectif** : factoriser un composant Vue qui partage 95 % de sa logique entre plusieurs routes ne différant que par le wording, sans recourir à `route.name` (fragile) ou query string (manipulable).
- **Contexte** : projet Vue avec deux routes (ex : `/invitation` + `/reset-password`) qui partagent la mécanique technique stricte (validation token + saisie mot de passe + consume) et ne diffèrent que par le wording.
- **Quand l'utiliser** : factorisation justifiée par un partage > 90 % de logique entre routes.
- **Quand l'éviter** : routes qui diffèrent fonctionnellement au-delà du wording — préférer deux composants distincts.
### Analyse
- **Avantages** :
- explicite, déclaratif, type-checkable
- augmenter `RouteMeta` dans `vue-router` pour qu'un mode manquant produise une erreur build
- **Limites / vigilance** :
- wording centralisé en un seul `computed` — pas de `v-if` éparpillés dans le template
- endpoint dynamique en un seul `if/else` dans le handler
### Validation
- Validé le : 28-04-2026
- Contexte technique : Vue 3 / vue-router — RL799_V2
### Implémentation
```typescript
// router/index.ts
{
path: '/invitation',
name: 'invitation',
component: ResetPasswordPage,
meta: { requiresGuest: true, mode: 'invitation' as const },
},
{
path: '/reset-password',
name: 'reset-password',
component: ResetPasswordPage,
meta: { requiresGuest: true, mode: 'reset' as const },
},
```
```typescript
// ResetPasswordPage.vue
const pageMode = computed<PageMode>(() => {
const meta = route.meta as { mode?: PageMode } | undefined;
return meta?.mode === 'invitation' ? 'invitation' : 'reset';
});
const wording = computed(() => {
if (pageMode.value === 'invitation') {
return { eyebrow: 'Bienvenue', title: 'Définissez votre mot de passe', /* … */ };
}
return { eyebrow: 'Réinitialisation', title: '...', /* … */ };
});
const handleSubmit = async () => {
if (pageMode.value === 'invitation') {
await consumeInvitationToken(token.value, newPassword.value);
} else {
await consumeResetToken(token.value, newPassword.value);
}
// Post-consume uniforme : redirige /login pour les deux modes
};
```
### Règles d'or
- `meta.mode` typé en literal union (`'invitation' | 'reset'`)
- A11y obligatoire : autofocus sur le champ password au mount post-validation, `<label for>` associés, `<AppMessage variant="error">` avec `role="alert"`, `<AppMessage variant="success">` avec `aria-live="polite"`
- Test obligatoire : monter la page avec chaque `meta.mode` et vérifier que le wording correspond
### Anti-patterns
- Détecter le mode via `route.name` (fragile, couplage implicite, signal d'intention faible)
- Détecter via query string (pollue URL, manipulable)
---

View File

@@ -208,3 +208,270 @@ Sans état transitoire formel, les guards lisent des valeurs incomplètes et dé
- Store : `hydrateStatus` + promesse partagée en cours pour les appels concurrents. - Store : `hydrateStatus` + promesse partagée en cours pour les appels concurrents.
- Guards : `await hydrate()` avant toute décision d'accès. - Guards : `await hydrate()` avant toute décision d'accès.
- UI : fallback de rendu tant que l'hydratation n'est pas `ready`. - UI : fallback de rendu tant que l'hydratation n'est pas `ready`.
---
<a id="pattern-refactor-monolithe-vue-sous-lots"></a>
## Pattern : Refactor monolithe Vue — sous-lots Go/No-Go + ordre topologique
### Synthèse
- **Objectif** : découper un composant Vue monolithique (> 1500 lignes script ou > 2000 lignes total) en composables + sous-composants livrés en commits successifs validés un à un.
- **Contexte** : page Vue avec plusieurs responsabilités métier mêlées, peu ou pas de `describe`, sans sous-découpage interne.
- **Quand l'utiliser** : fichier > 1500 lignes script, fenêtre de calme sans PR concurrente prévue, tests E2E robustes en place.
- **Quand l'éviter** : page < 1000 lignes, pas de tests E2E pour servir de filet, ou planning serré sans Go/No-Go possible entre commits.
### Analyse
- **Avantages** :
- aucune régression à chaque sous-lot validé indépendamment
- helpers réutilisables émergent naturellement
- reporter vitest / Playwright groupe les échecs par responsabilité
- **Limites / vigilance** :
- les `data-testid` E2E doivent être **copiés-collés exactement** dans les composants enfants (sinon les E2E rotent silencieusement)
- les bindings template doivent rester alignés avec les noms destructurés du composable
- le typecheck `tsc --noEmit` ne suffit pas — utiliser `vue-tsc` (cf. `frontend/risques/state.md` risque-templates-vue-references-orphelines)
### Validation
- Validé le : 29-04-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2 (4 pages refactorées, 17 commits, 644/644 tests verts)
### Stratégie en 3 étapes
1. **Audit complet préalable** : sections du template avec plages de lignes + rôle métier + `v-if` clé, blocs script regroupés par responsabilité avec évaluation `autonome` vs `couplé`, imports + composables + DTOs consommés, tests existants, couplages externes (deep-links, sélecteurs CSS).
2. **Plan en sous-lots ordonnés par risque croissant** :
- L1 : composables purement autonomes (zéro dépendance interne)
- L2 : composants enfants auto-contenus (modales)
- L3 : composables avec couplage modéré (cache + watchers)
- L4 : composables avec couplage métier subtil (cascade, propagation)
- L5 : composant enfant complexe (D&D, drag-handle conditionnel)
3. **Go/No-Go explicite entre chaque lot** : 1 commit thématique par lot avec validation typecheck + tests + diff montré au pair avant push.
### Ordre topologique des dépendances dans le script post-refactor
Quand plusieurs composables se consomment mutuellement, respecter strictement l'ordre topologique (la déclaration de la donnée doit précéder son usage, sous peine de TDZ) :
```typescript
// 1. Data centrale de la page
const currentSoiree = ref<SoireeData | null>(null);
const error = ref('');
// 2. Composables qui ne consomment que la data centrale
const { lifecycle, isLiveView } = useSoireeLifecycle(currentSoiree, allTenuesCancelled);
// 3. Composables qui produisent des refs consommées par d'autres
const { responsesData } = useResponseTracking(currentSoiree, error);
// 4. Composables qui consomment les refs produites
const { pastActiveGrade } = useGradeSelection(soireeTenues, responsesData);
```
### Anti-patterns
- ❌ Grouper plusieurs préoccupations dans un même composable juste parce qu'elles sont voisines dans le script
- ❌ Sortir un composable qui consomme directement `process.cwd()` ou un store global sans le passer en argument (couplage caché)
- ❌ Extraire le CSS scoped vers le composant enfant **avant** de vérifier que toutes les classes y sont effectivement utilisées (certaines classes vivent en CSS global)
- ❌ Sauter le grep des références orphelines avant de supprimer un bloc
### Checklist
- [ ] `data-testid` copiés-collés exactement dans les composants enfants
- [ ] Bindings template alignés avec les noms destructurés
- [ ] Props/events des composants enfants alignés avec les usages
- [ ] `vue-tsc` (pas `tsc`) en vérification typecheck
- [ ] QA visuel obligatoire post-refactor (mount réel en browser)
---
<a id="pattern-convention-pages-module-scope"></a>
## Pattern : Convention `pages/<module>/{composables,components,utils,__tests__}/`
### Synthèse
- **Objectif** : structurer une app Vue qui dépasse 20 pages avec plusieurs domaines métier, en regroupant la logique extraite par module.
- **Contexte** : app Vue avec routing par page et logique extraite (composables, sous-composants, tests).
- **Quand l'utiliser** : page > 1000 lignes envisage le scope, > 1500 lignes le crée systématiquement.
- **Quand l'éviter** : page < 500 lignes sans logique extraite — laisser au niveau racine.
### Analyse
- **Avantages** :
- un module métier = un sous-dossier, navigation simplifiée
- l'alias `@/pages/<module>/<X>` rend les fichiers résilients aux déplacements
- les tests d'un module vivent avec lui (pas dans un dossier global qui mélange tout)
- **Limites / vigilance** :
- les tests scopés calculent leur `root` avec **4 niveaux** de remontée (`'../../../..'`), pas 3 — source d'erreur fréquente lors d'un déplacement
- le dossier `pages/__tests__/` global reste réservé aux tests transverses + tests des pages legacy
### Validation
- Validé le : 29-04-2026
- Contexte technique : Vue 3 / Vite / Vitest — RL799_V2 (5 modules scopés, 35 fichiers extraits)
### Structure type
```
pages/<module>/
├── <Module>Page.vue # page principale (carcasse + template)
├── <Autre>Page.vue # autres pages du même module si existent
├── composables/ # logique métier extraite
│ ├── use<X>.ts
│ └── use<Y>.ts
├── components/ # sous-composants .vue scopés au module
├── utils/ # helpers purs (formatters, defensive wrappers)
├── styles.css # CSS partagé non-scoped (cf. pattern dédié)
└── __tests__/ # tests scopés au module
```
### Ce qui reste dans `pages/__tests__/` global
Trois cas légitimes uniquement :
1. **Tests transverses** qui couvrent plusieurs modules (`lifecycleUnification.test.mjs`)
2. **Tests d'infrastructure** non rattachés à un module métier (`OfflineIntegration.test.mjs` pour le SW PWA)
3. **Tests des pages encore à plat** (legacy non-encore scopées) — `LoginPage.test.mjs` reste à `pages/__tests__/` tant que `LoginPage.vue` est à `pages/`
### Calcul de `root` dans les tests scopés
```typescript
const here = dirname(fileURLToPath(import.meta.url));
// 4 niveaux : __tests__ → <module> → pages → src → frontend
const root = resolve(here, '../../../..');
```
Si le test crashe avec `ENOENT: no such file … '<frontend>/src/src/pages/...'`, c'est que le `root` n'a pas été ajusté.
### Imports : alias `@/` plutôt que relatif
Toujours utiliser l'alias `@/pages/<module>/<X>` plutôt que `./X` ou `../X`. Bénéfice : déplacer un fichier ne casse pas ses imports internes (juste les imports depuis l'extérieur, qu'on met à jour via sed bulk).
### Critère extraction composable vs composant
| Cas | Préférer composable | Préférer composant |
|-----|---------------------|---------------------|
| Logique pure (state + actions, pas de markup) | ✓ | |
| Modale auto-contenue, > 30 lignes template | | ✓ |
| Form > 50 lignes avec validation | | ✓ |
| Plusieurs refs/computeds entrelacés | ✓ | |
| CSS spécifique > 50 lignes | | ✓ (avec styles.css si partagé) |
Préférence générale : composables script-only quand possible (risque CSS nul, plus simple à tester).
---
<a id="pattern-styles-css-module-non-scoped"></a>
## Pattern : `styles.css` partagé non-scoped pour modules avec composants extraits
### Synthèse
- **Objectif** : partager des classes CSS entre la page parente et ses sous-composants extraits sans dupliquer le CSS dans chaque `<style scoped>` enfant.
- **Contexte** : refactor d'une page Vue monolithique en N composants enfants (modales, forms) qui partagent des classes communes.
- **Quand l'utiliser** : ≥ 2 composants enfants partagent des classes (modales, forms, badges du module).
- **Quand l'éviter** : composants enfants strictement indépendants, ou règle CSS utilisée nulle part ailleurs.
### Analyse
- **Avantages** :
- une seule définition par classe partagée
- dérive impossible (un changement profite à tous les composants du module)
- **Limites / vigilance** :
- tentation de tout sortir en non-scoped "au cas où" → refusé, pollue le namespace global
### Validation
- Validé le : 29-04-2026
- Contexte technique : Vue 3 / scoped CSS — RL799_V2 (152 lignes CSS migrées dans `pages/venerable/styles.css`)
### Pattern
```vue
<!-- pages/<module>/<Module>Page.vue -->
<template>
<!-- -->
</template>
<!-- Styles partagés du module : importés en non-scoped pour atteindre
les composants enfants extraits (modales/forms) qui ne peuvent
pas hériter d'un `<style scoped>` parent -->
<style src="@/pages/<module>/styles.css"></style>
<style scoped>
/* Classes spécifiques au layout de la page parente uniquement */
</style>
```
Les composants enfants utilisent les classes sans rien importer :
```vue
<template>
<div class="vm-modal"> <!-- vient de styles.css -->
<h2 class="vm-modal__title"></h2>
<button class="primary"></button> <!-- vient du CSS global -->
</div>
</template>
```
### Quoi mettre dans `styles.css`
- Classes utilisées par > 1 composant enfant du module
- Classes utilisées par la page ET par un composant enfant
- Conventions/tokens visuels propres au module
### Quoi NE PAS y mettre
- Classes utilisées uniquement dans la page parente → `<style scoped>` de la page
- Classes utilisées uniquement dans un seul composant enfant → `<style scoped>` du composant
- Classes globales (`primary`, `ghost`, etc.) qui vivent déjà dans `style.css` global
---
<a id="pattern-annuaire-client-side-ttl-refresh"></a>
## Pattern : Annuaire client-side avec TTL + refresh + `lastFetchedAt`
### Synthèse
- **Objectif** : permettre un load complet en mémoire au mount avec filtre client (annuaire de N membres, catalog de produits) tout en évitant le refetch inutile entre ouvertures.
- **Contexte** : composable Vue qui charge un dataset moyen (< quelques milliers d'items) consommé par filtres client.
- **Quand l'utiliser** : modale ou page consultée fréquemment qui n'a pas besoin de pagination serveur.
- **Quand l'éviter** : dataset > 10k items (utiliser pagination/keyset), ou besoin de temps réel cross-clients (basculer SSE/WS).
### Analyse
- **Avantages** :
- cache TTL configurable (par défaut 5 min) → pas de refetch entre ouvertures
- `refresh()` méthode publique pour forcer après création/update
- `lastFetchedAt` exposé pour debug / UI ("annuaire mis à jour il y a X")
- **Limites / vigilance** :
- si user B crée une entrée pendant que user A a la modale ouverte, A ne voit pas l'entrée tant qu'il ne ferme/rouvre pas ou clique refresh
- TTL atténue mais ne résout pas — pour temps réel, basculer SSE/WS
### Validation
- Validé le : 01-05-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2
### Implémentation
```typescript
const useEntityDirectory = (options: { ttlMs?: number } = {}) => {
const ttlMs = options.ttlMs ?? 5 * 60 * 1000;
const directory = ref<Entry[]>([]);
const lastFetchedAt = ref<Date | null>(null);
const isCacheStale = (): boolean => {
if (!lastFetchedAt.value) return true;
return Date.now() - lastFetchedAt.value.getTime() > ttlMs;
};
const loadDirectory = async (params: { force?: boolean } = {}) => {
if (!params.force && !isCacheStale() && directory.value.length > 0) return;
const data = await api.fetchDirectory();
directory.value = data;
lastFetchedAt.value = new Date();
};
const refresh = () => loadDirectory({ force: true });
return { directory, lastFetchedAt, loadDirectory, refresh };
};
```

View File

@@ -153,3 +153,368 @@ Les classes et la structure DOM changent fréquemment sans régression fonctionn
- Préférer `data-testid` paramétré par identifiant métier stable. - Préférer `data-testid` paramétré par identifiant métier stable.
- Éviter `locator.first()` si l'ordre peut muter. - Éviter `locator.first()` si l'ordre peut muter.
- Isoler les tests mutateurs avec stratégie de remise à l'état (snapshot/restore). - Isoler les tests mutateurs avec stratégie de remise à l'état (snapshot/restore).
---
<a id="pattern-tests-statiques-vitest-smoke-checks"></a>
## Pattern : Tests statiques `readFileSync` annotés comme smoke checks
### Synthèse
- **Objectif** : utiliser les tests `readFileSync + includes()` pour leur force réelle (présence de signaux structurels) tout en évitant la fausse confiance qu'ils donnent sur le comportement runtime.
- **Contexte** : projets Vue où le pattern `readFileSync` est largement utilisé pour vérifier la présence de testid, rôle ARIA, appel de service.
- **Quand l'utiliser** : assertions sur **présence** d'un pattern (testid, import, regex interdite). À doubler par un mount pour les comportements interactifs.
- **Quand l'éviter** : composants interactifs (focus trap, submit delegation, navigation clavier) — utiliser un test mount.
### Analyse
- **Avantages** :
- rapide à écrire et lire, résistant aux refactors de structure
- bon pour vérifier la présence de comportements clés sans monter
- **Limites / vigilance** :
- **ne valide PAS** que le focus trap cycle correctement, que le composant émet les bons événements, que la navigation clavier Escape ferme la modal
- **ne distingue pas** si une référence est dans le `<script>` ou le `<template>` (variable supprimée du script mais référencée dans le template → string-match passe, crash runtime)
### Validation
- Validé le : 21-04-2026
- Contexte technique : Vue 3 / Vitest — RL799_V2
### Annotation honnête recommandée
```typescript
// Note honnête : ces assertions sont des smoke checks structurels, pas une
// vérification runtime du focus trap. Le comportement réel (Tab cycle, Escape,
// focus restauré) se valide manuellement en QA ou via un test happy-dom dédié.
test('AppDialog : pattern previousActiveElement présent', () => {
expect(content.includes('previousActiveElement')).toBeTruthy();
});
```
### Règle
Si un comportement runtime est critique (focus trap, submit delegation, validation conditionnelle), écrire un test happy-dom séparé avec `@vue/test-utils`. Le smoke check ne dispense pas du test comportemental — il documente juste la présence d'un signal.
---
<a id="pattern-asserter-classe-css-modifier-vs-texte"></a>
## Pattern : Asserter classe CSS modifier vs texte (E2E robuste aux refactors visuels)
### Synthèse
- **Objectif** : rendre les tests E2E robustes aux refactors visuels (texte → icône SVG, label v1 → label v2, i18n future).
- **Contexte** : tests E2E qui valident un état rendu d'un composant.
- **Quand l'utiliser** : composant utilisant une convention BEM stricte avec modifier sémantique (`.grade-badge--apprenti`, `.status-badge--published`).
- **Quand l'éviter** : framework CSS-in-JS qui génère des classes hashées (`_grade_x4f3z`) — la classe modifier n'est pas accessible.
### Analyse
- **Avantages** :
- la classe modifier porte la **sémantique** et change beaucoup moins souvent que le texte
- typiquement liée à la prop ou au state, pas au rendu
- **Limites / vigilance** :
- ne remplace pas la validation visuelle (snapshot, screenshot) si le besoin est de protéger le rendu pixel-perfect
- reste pertinent : validation de contenu utilisateur (notes, prix) où le rendu textuel **est** la spec
### Validation
- Validé le : 25-04-2026
- Contexte technique : Playwright / Vue 3 — RL799_V2
### Exemple
```typescript
// ❌ Fragile : casse au refactor texte → SVG ou label v1 → v2
await expect(badge).toHaveText('A∴');
// ✅ Robuste : casse uniquement si le grade change ou si BEM est rompu
await expect(badge).toHaveClass(/grade-badge--apprenti/);
```
### Cas combinable
- Regex tolérante sur le texte (`/convoqu/i`) pour les cas où le texte est la seule prise (badges sans classe modifier)
- Validation visuelle (snapshot) en complément quand le rendu pixel-perfect est protégé
---
<a id="pattern-cleanup-e2e-best-effort"></a>
## Pattern : Cleanup E2E best-effort (try/catch + timeout court)
### Synthèse
- **Objectif** : empêcher qu'un cleanup post-test (réinitialisation d'un statut, suppression d'une fixture) fasse échouer un scénario métier qui a déjà passé.
- **Contexte** : tests Playwright avec `finally { await cleanup() }` qui font des PATCH/DELETE/POST sur l'API.
- **Quand l'utiliser** : tout cleanup post-test non critique pour les tests suivants (parce qu'on a un seed ou un autre cleanup global).
- **Quand l'éviter** : si le cleanup est critique pour l'isolation (UNIQUE constraint au prochain test) — monter le timeout à 60 s plutôt que silencer.
### Analyse
- **Avantages** :
- une lenteur transitoire de l'API de cleanup (audit log, notif fanout, lock DB) ne fait plus passer le test du vert au rouge
- la dette d'état après échec de cleanup est mineure (le test suivant restaure souvent ou un seed le fera)
- **Limites / vigilance** :
- setup pré-test (`beforeEach`) : un setup qui échoue **doit** faire échouer le test, sinon on teste un état inconnu
### Validation
- Validé le : 25-04-2026
- Contexte technique : Playwright — RL799_V2
### Pattern
```typescript
async function restoreEntry(page: Page, snap: Snapshot): Promise<void> {
try {
await page.request.patch(`/api/.../entries/${snap.entryId}/status`, {
data: { status: snap.status },
headers: { 'Content-Type': 'application/json' },
timeout: 10_000,
});
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[spec-name] restoreEntry échoué (cleanup best-effort):', err);
}
}
test('mon scénario métier', async ({ page }) => {
const snap = await snapshotEntry(page, USER_ID);
try {
// … scénario métier …
} finally {
await restoreEntry(page, snap); // best-effort
}
});
```
---
<a id="pattern-helpers-sw-purs-extraits"></a>
## Pattern : Helpers Service Worker purs extraits pour tests Vitest
### Synthèse
- **Objectif** : permettre des tests unitaires Vitest fiables sur la logique du Service Worker, sans monter de stubs élaborés pour `self` / `registration` / `caches` / `clients`.
- **Contexte** : SW custom (mode `injectManifest` ou similaire) qui contient de la logique non triviale (parsing payload push, validation linkUrl anti open-redirect, sélection client focused).
- **Quand l'utiliser** : dès que le SW dépasse 50 lignes ou contient une regex / une condition métier.
- **Quand l'éviter** : SW trivial (juste un `precacheAndRoute(self.__WB_MANIFEST)`).
### Analyse
- **Avantages** :
- tests Vitest standards, pas de mock `self`/`registration`
- logique partageable avec d'autres parties du frontend (regex linkUrl partagée serveur ↔ SW ↔ store)
- le `sw.ts` final reste lisible : précache + routes + thin event handlers
- **Limites / vigilance** :
- les listeners `addEventListener('push'|...)` restent non testés unitairement → couverture par E2E + checklist DevTools manuelle
- bien isoler les imports : pas de dépendance Vue/Pinia dans `sw-helpers.ts` (le SW n'a pas d'accès au DOM)
- regex dupliquée serveur ↔ SW : extraire dans `@<scope>/shared` quand possible (cf. `pattern-regex-critique-partagee-anti-divergence` dans `backend/patterns/contracts.md`)
### Validation
- Validé le : 28-04-2026
- Contexte technique : Vite + vite-plugin-pwa `injectManifest` / Vitest — RL799_V2
### Implémentation
```typescript
// apps/frontend/src/sw-helpers.ts (testable pure)
const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
export const isInternalPath = (url: string): boolean =>
INTERNAL_PATH_REGEX.test(url);
export const parsePushPayload = (
eventData: { json: () => unknown } | null | undefined,
): SwPushPayload => {
try {
const raw = eventData?.json() as Record<string, unknown> | null;
if (!raw || typeof raw.title !== 'string') throw new Error('invalid');
return {
title: raw.title.slice(0, 80),
body: typeof raw.body === 'string' ? raw.body.slice(0, 160) : undefined,
linkUrl: typeof raw.linkUrl === 'string' && isInternalPath(raw.linkUrl) ? raw.linkUrl : '/',
};
} catch {
return { title: 'AppDefault', body: 'Notification', linkUrl: '/' };
}
};
```
```typescript
// apps/frontend/src/sw.ts (thin wrappers)
/// <reference lib="webworker" />
import { isInternalPath, parsePushPayload, selectFocusedClient } from './sw-helpers';
self.addEventListener('push', (event) => {
event.waitUntil((async () => {
const payload = parsePushPayload(event.data ?? undefined);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const focused = selectFocusedClient(clients);
if (focused) {
focused.postMessage({ type: 'push:received', payload });
return;
}
await self.registration.showNotification(payload.title, {
body: payload.body,
data: { url: payload.linkUrl },
});
})());
});
```
### Checklist
- [ ] Toute logique conditionnelle / regex / parsing extraite dans `sw-helpers.ts`
- [ ] `sw.ts` ne contient que precache, routes runtime, thin `addEventListener` qui appellent les helpers
- [ ] Helpers testés avec Vitest standard (pas de mock `self`)
- [ ] Listeners SW couverts par E2E + checklist manuelle DevTools post-build
---
<a id="pattern-test-mount-mock-composable-controllable"></a>
## Pattern : Test mount + mock composable contrôlable
### Synthèse
- **Objectif** : tester un composant Vue qui consomme un composable réactif en contrôlant l'état du composable depuis le test, sans monter de fixtures lourdes.
- **Contexte** : composant `<script setup>` qui appelle `const x = useFooBar()` et utilise `x.isReady.value` / `x.action()` dans le template.
- **Quand l'utiliser** : composant interactif avec branches conditionnelles dépendant du composable (5 états du footer push, modes admin vs user).
- **Quand l'éviter** : composant trivial sans logique conditionnelle (wrapper de markup).
### Analyse
- **Avantages** :
- couvre la cohérence script ↔ template (un attribut renommé dans le composable casse le test)
- test indépendant de la VRAIE implémentation (pas de duplication des stubs DOM `Notification`, `serviceWorker`, etc.)
- chaque scénario UI testable isolément en mutant l'état du mock entre tests
- **Limites / vigilance** :
- le mock doit retourner exactement la même SHAPE que le composable réel — un test peut passer alors que le composable a évolué et casse en runtime
- mitigation : déclarer un type `ReturnType<typeof useFooBar>` ou `export type UseFooBar` exporté par le composable, et typer le mock dessus
- `vi.mock` est hoisté → définir l'état du mock dans `beforeEach`, pas en module-level
### Validation
- Validé le : 28-04-2026
- Contexte technique : Vitest + @vue/test-utils + Vue 3 — RL799_V2
### Implémentation
```typescript
// composable
export type UsePushNotifications = {
isSupported: ComputedRef<boolean>;
isSubscribed: Ref<boolean>;
subscribe: () => Promise<void>;
};
export const usePushNotifications = (): UsePushNotifications => { /* … */ };
```
```typescript
// Test du composant
import { computed, ref } from 'vue';
import type { UsePushNotifications } from '@/composables/usePushNotifications';
let mockState: { isSupported: boolean; isSubscribed: boolean };
const subscribeSpy = vi.fn();
vi.mock('@/composables/usePushNotifications', () => ({
usePushNotifications: (): UsePushNotifications => ({
isSupported: computed(() => mockState.isSupported),
isSubscribed: ref(mockState.isSubscribed),
subscribe: subscribeSpy,
}),
}));
import MyComponent from '@/components/MyComponent.vue';
beforeEach(() => {
subscribeSpy.mockReset();
mockState = { isSupported: true, isSubscribed: false };
});
test('CTA visible quand !isSubscribed', () => {
mount(MyComponent);
expect(document.querySelector('[data-testid="cta"]')).not.toBeNull();
});
```
### Checklist
- [ ] Composable expose un type `Use<Name>` réutilisable dans les tests
- [ ] Mock retourne une shape conforme à ce type
- [ ] État du mock défini dans `beforeEach` (réinitialisé entre tests)
- [ ] Tests utilisent `data-testid` (pas de couplage CSS class)
- [ ] Couvre TOUS les états observables (pas que le happy path)
---
<a id="pattern-assertions-html-react-email"></a>
## Pattern : Assertions sur le HTML rendu par React Email — pièges à éviter
### Synthèse
- **Objectif** : écrire des tests Vitest fiables sur le HTML produit par un template React Email sans buter sur les commentaires JSX, les glyphes Unicode bruts, et les `<link rel="preload">` injectés automatiquement.
- **Contexte** : tests qui vérifient la présence ou l'absence de patterns dans le HTML rendu (côté mail Resend ou côté PDF Puppeteer en `previewOnly`).
- **Quand l'utiliser** : assertions sur le HTML rendu par `@react-email/components`.
- **Quand l'éviter** : tests qui valident uniquement la présence d'un composant React (snapshot par exemple).
### Validation
- Validé le : 29-04-2026
- Contexte technique : Vitest / React Email — RL799_V2
### Piège 1 — Commentaires React entre fragments JSX
React Email insère `<!-- -->` entre 2 fragments `{var}` adjacents :
```html
<p>V∴M∴ <!-- -->Pierre Vénérable</p>
```
Une regex `/V∴M∴ Pierre/` échoue. Helper utile :
```typescript
const matchAroundComments = (_source: string, ...parts: string[]): RegExp => {
const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(escaped.join('\\s*(?:<!--[^>]*-->\\s*)*'), 'i');
};
assert.match(html, matchAroundComments(html, 'V∴M∴ ', 'Pierre Vénérable'));
```
### Piège 2 — Caractères Unicode bruts dans les constantes shared
Une constante avec `` (U+2019) brut est rendue **telle quelle** dans le HTML par React Email (pas d'encodage). Une regex avec `'` ASCII ou `&apos;` échoue.
```typescript
assert.match(html, /Dans l(?:'||&#x27;|&apos;)attente/);
```
### Piège 3 — `<link rel="preload">` injecté automatiquement par `<Img>`
Le composant `<Img>` de `@react-email/components` injecte `<link rel="preload" as="image" href="…">` dans le `<head>` en mode mail (pas en mode `previewOnly`). Une assertion `expect(html).not.toMatch(/<link\s+rel/i)` échoue sur le mail.
Solution : autoriser les `<link rel>` qui pointent vers `appBaseUrl` (URL maîtrisée), bloquer les autres :
```typescript
const linkMatches = html.match(/<link\s+[^>]*href=["']([^"']+)["']/gi) ?? [];
for (const linkTag of linkMatches) {
const href = /href=["']([^"']+)["']/i.exec(linkTag)?.[1];
assert.ok(href.startsWith(appBaseUrl) || href.startsWith('data:'));
}
```
### Piège 4 — Assertions trop génériques sur des termes ambigus
Une regex `/V∴M∴/` pour vérifier "ligne signature V∴M∴ vacante absente" matche aussi le footer "Le V∴M∴ et les officiers..." qui contient le même token. Solution : cibler un pattern plus précis du contexte (la classe CSS du style `vmSignature` : `font-weight:600`).
### Garde-fou Puppeteer mode PDF
En mode `previewOnly`, le HTML doit être strictement offline-safe :
```typescript
assert.doesNotMatch(html, /<link\s+rel/i); // pas de preload (≠ mode mail)
assert.doesNotMatch(html, /<script\s+src/i);
assert.doesNotMatch(html, /<img[^>]+src=["'](?!data:)/i); // pas de http(s)
```

View File

@@ -319,3 +319,474 @@ Poser `role="menu"` / `role="menuitem"` implique obligatoirement :
- Contexte technique : PWA / install prompt — RL799_V2 18-04-2026 - Contexte technique : PWA / install prompt — RL799_V2 18-04-2026
--- ---
<a id="risque-cache-pwa-soft-delete-fuite"></a>
## Cache offline PWA + soft-delete — invalidation diff-based scopée
### Risques
- Une PWA qui cache des blobs en IndexedDB pour offline reading **ne reçoit aucun signal automatique** quand un document est soft-deleted côté serveur
- Le contenu reste lisible hors-ligne indéfiniment. Pour des données réglementaires ou sensibles, c'est un gap de sécurité non négligeable
### Symptômes
- Document soft-deleted en base, encore consultable offline par les utilisateurs qui l'ont mis en cache
- Aucun mécanisme automatique de purge
### Bonnes pratiques / mitigations
**Mitigation V1 (best-effort, diff-based)** au prochain `loadEntries` online :
1. Lire la liste serveur courante (filtrée `deletedAt IS NULL`)
2. Lire les IDs cachés localement **scopés au même périmètre** (type+grade) — sinon on supprime à tort un doc d'un autre onglet
3. Diff : `cached - server = soft-deleted``removeCachedDocument(id)` pour chaque
```typescript
const bustCachedIfMissing = async (candidateIds: string[], serverIds: Set<string>) => {
for (const id of candidateIds) {
if (!serverIds.has(id)) await removeCachedDocument(id);
}
};
```
**Mitigation V2 (push server-initiated)** : Service Worker abonné à un canal (postMessage / WebSocket / SSE), serveur publie `{ type: 'document-soft-deleted', id }` sur soft-delete, SW intercepte et fait `caches.delete()` immédiat. Coût : infra push + gestion connectivité partielle. À garder en backlog si le risque devient critique (audit GDPR).
**Tests recommandés** :
- doc caché + recharge avec serveur qui omet ce doc → assert `removeCachedDocument` appelé
- doc caché + serveur qui retourne le doc → assert pas d'effet (non-régression)
- doc caché pour scope `rituels` + recharge sur scope `mementos` qui omet ce doc → assert pas d'effet (scope isolation)
- Contexte technique : PWA / IndexedDB — RL799_V2 20-04-2026
---
<a id="risque-duplication-focus-parent-modal"></a>
## Duplication parent ↔ modal de la capture de focus
### Risques
- Quand un composant modal implémente correctement le pattern a11y `previousActiveElement` (capture à `onMounted`, restitution à `close`/`submit`), le composant parent **ne doit PAS** stocker un `lastTrigger` en parallèle
- Code mort avec commentaire trompeur : un lecteur qui cherche à comprendre le flux focus va s'y perdre
### Symptômes
- Le parent a une ref `lastFabTrigger` / `lastTrigger` écrite au clic du trigger
- Elle n'est **jamais lue** — le commentaire prétend "pour restitution du focus par la modal", mais la modal a son propre mécanisme
### Bonnes pratiques / mitigations
- **Une seule responsabilité** pour la restitution du focus : la modal
- Le parent se contente d'ouvrir la modal (`uploadModalOpen.value = true`)
- Le parent ne capture le focus QUE si la modal ne le fait pas (cas d'un overlay maison sans pattern `previousActiveElement`)
- **Repérage en code review** : `grep -n "lastTrigger\|previousActive" components/ pages/` → s'il y a des occurrences dans BOTH un parent et une modal du même flux, c'est le signal
- Contexte technique : Vue 3 / a11y modales — RL799_V2 20-04-2026
---
<a id="risque-touch-action-none-card-mobile"></a>
## `touch-action: none` sur card mobile bloque scroll vertical
### Risques
- Une card mobile gérant un swipe horizontal avec `touch-action: none` capture **tout** le toucher, laissant le JS gérer le scroll vertical
- Le JS détecte scroll vs swipe via un seuil mais doit **libérer** l'événement après l'avoir analysé → un délai imperceptible s'installe, le scroll vertical devient saccadé et souvent ignoré
- L'utilisateur trouve la liste "inscrollable" quand son pouce touche directement les cards
### Symptômes
- Poser le pouce sur une card puis scroller ne marche qu'une fois sur 20
- Scroll OK dans les marges vides à côté
- Aucune erreur console
### Bonnes pratiques / mitigations
```css
/* AVANT — bug : scroll vertical capturé */
.member-card {
touch-action: none;
}
/* APRÈS — scroll natif OK, swipe horizontal toujours fonctionnel */
.member-card {
touch-action: pan-y;
}
```
Combiné avec un handler JS qui `preventDefault` uniquement sur mouvement horizontal significatif (> 10 px) :
```typescript
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY)) {
event.preventDefault();
}
```
**Règle** :
- Card qui gère swipe horizontal ET scroll vertical : `touch-action: pan-y` (le navigateur gère nativement le scroll vertical, seul l'axe horizontal est laissé au JS)
- Card qui ne gère QUE du pan/zoom custom (rare) : `touch-action: none` peut se justifier
- Tous les autres cas : laisser la valeur par défaut (`auto`)
**Repérage en code review** : `grep -rn "touch-action: none" components/` → chaque occurrence est suspecte.
- Contexte technique : CSS / mobile — RL799_V2 21-04-2026
---
<a id="risque-button-wrapper-card-color-inherit"></a>
## `<button>` wrapper card — toujours `color: inherit` (reset user-agent)
### Risques
- Quand on utilise un `<button>` comme wrapper cliquable d'une card (pattern idiomatique pour l'a11y clavier), il hérite du `color` user-agent par défaut
- Sur certains setups (Safari/dark mode notamment), ce `color` peut être bleu — le texte enfant hérite et contamine titres et paragraphes qui devraient prendre la couleur du thème
### Symptômes
- Titre de card en bleu sur fond sombre alors que le thème prévoit de l'or
- Bug non visible en dev light mode sur Chrome — apparaît uniquement sur certains setups → difficile à reproduire
### Bonnes pratiques / mitigations
```css
.my-card-button {
/* reset user-agent */
color: inherit;
font-family: inherit;
border: 0;
background: transparent;
/* … */
}
```
**Règle** : tout `<button>` qui wrappe du contenu stylé par le thème (card, liste, ligne de tableau) doit reset `color`, `font-family`, `font-size`, `border`, `background`. C'est un bootstrap user-agent minimal à prévoir dans tout design system.
**Alternative** : `<div role="button" tabindex="0">` avec `@keydown.enter/space`. Plus verbeux, mais évite les resets. Pattern valide si l'équipe est à l'aise avec les implications a11y.
- Contexte technique : CSS / a11y — RL799_V2 21-04-2026
---
<a id="risque-erreur-silencieuse-4-etats"></a>
## Erreur silencieuse = blanc indistinguable de "aucune donnée" — 4 états distincts (loading / empty / error / forbidden)
### Risques
- Un composant qui affiche le résultat d'un fetch sans distinguer ses 4 états produit du **blanc** dans 3 cas sur 4 — l'utilisateur ne peut pas savoir si la donnée est en chargement, légitimement vide, en erreur réseau, ou refusée par RBAC
- Sur les flows critiques (rituel, opérations sensibles), un blanc silencieux est inacceptable : l'utilisateur prend des décisions sur la base de l'affichage
### Symptômes
- Plusieurs cards d'une vue qui auto-fetchent et tombent toutes en `[]` côté front quand l'API renvoie 403 — affichage indistinguable de "vide légitime"
- Toast générique "Une erreur est survenue" sans corrélation avec un retry actionnable
### Bonnes pratiques / mitigations
Tout composant qui fetch une ressource doit avoir **4 états distincts** dans son rendu :
1. **Loading** : skeleton, spinner, ou texte explicite (« Chargement… »)
2. **Empty (donnée légitimement vide)** : message explicite *« Aucune donnée enregistrée pour … »*
3. **Error (réseau / serveur)** : message + bouton retry. Ne jamais se contenter d'un blanc
4. **Forbidden (403)** : message explicite *« Vous n'avez pas accès à cette donnée »* + suggestion d'action (recharger / contacter admin)
Le frontend doit savoir **distinguer 403** des autres erreurs au niveau de son service HTTP, et propager l'info au composant. Ne pas traiter `!response.ok` en bloc avec un message générique.
```typescript
export const getXxx = async (id: string) => {
const response = await apiFetch(`/api/xxx/${id}`);
if (response.status === 403) throw new ForbiddenError(...);
if (response.status === 404) throw new NotFoundError(...);
if (!response.ok) throw new Error(await parseError(response));
return (await response.json()).data;
};
```
Le composant catche les types d'erreur et choisit le rendu approprié.
- Contexte technique : Vue 3 / fetch — RL799_V2 27-04-2026
---
<a id="risque-service-worker-non-secure-context"></a>
## Service Worker invisible en accès non-secure (HTTP via IP réseau)
### Risques
- Les Service Workers exigent un [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) — HTTPS strict, OU URL `http://localhost:*` ou `http://127.0.0.1:*`
- Un accès via IP réseau en HTTP (Tailscale `100.x.x.x`, LAN `192.168.x.x`) est non-secure → `navigator.serviceWorker` est `undefined` → la PWA fonctionne en mode "navigateur classique" mais sans cache offline, sans push, sans badge, sans installation
- Les tests E2E en Tailscale loupent silencieusement les régressions SW
### Symptômes
- `navigator.serviceWorker.register('/sw.js')` lève `Cannot read properties of undefined (reading 'register')`
- DevTools > Application > Service Workers ne montre rien
- Tests "ça marche en LAN" qui ne reflètent pas la prod HTTPS
### Bonnes pratiques / mitigations
```javascript
// Détection
console.log(window.isSecureContext, location.protocol, location.hostname);
// true "https:" "..." → OK
// true "http:" "localhost" → OK
// false "http:" "192.168.1.42" → SW désactivé
```
Stratégies par contexte :
1. **Test local** : utiliser `http://localhost:<port>` strict (jamais l'IP, même en LAN)
2. **Test réseau / mobile** : reverse proxy HTTPS (Caddy/Traefik avec Let's Encrypt, ou `tailscale cert` pour le magicDNS Tailscale)
3. **Préview Vite** : `vite preview --https` avec un certificat auto-signé (acceptable en dev test)
**Préventif** :
- documenter dans le README projet que le SW exige HTTPS/localhost
- en CI E2E, toujours utiliser `localhost` (le webServer Playwright tourne sur `localhost` par défaut)
- ne PAS supposer qu'un test "ça marche en LAN" reflète la prod HTTPS
- Contexte technique : PWA / Service Worker — RL799_V2 28-04-2026
---
<a id="risque-vite-pwa-bascule-strategies-runtime-caching"></a>
## Vite-plugin-pwa : bascule `generateSW` → `injectManifest` rend `runtimeCaching` inerte
### Risques
- En mode `injectManifest`, Vite PWA n'injecte PAS de runtime workbox — il bundle le `src/sw.ts` fourni tel quel
- Toute la config `workbox.*` du `vite.config.ts` (sauf `globPatterns`/`globIgnores` déplacés sous `injectManifest.*`) est ignorée silencieusement, sans warning
- Régression directe : leak de cookies API en cache, contenu sensible en cache, 404 transformés en `index.html`
### Symptômes
- Après bascule, les routes runtime configurées dans `workbox.runtimeCaching` n'ont plus aucun effet
- Le build passe, aucune erreur visible, mais en DevTools > Application > Service Worker on ne voit pas les routes attendues
### Bonnes pratiques / mitigations
Détection : examiner `dist/sw.js` après build — chercher des strings clés (`/api/`, `uploads`, `manifest.webmanifest`, `addEventListener`). Si elles sont absentes du SW custom, c'est qu'on a perdu la protection.
**Mitigation** : RÉIMPLÉMENTER À LA MAIN dans `src/sw.ts` toutes les routes runtime, denylist, et cleanup :
```typescript
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkOnly, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST);
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly());
registerRoute(({ url }) => url.pathname.startsWith('/uploads/'), new NetworkOnly());
registerRoute(
({ url }) => url.pathname === '/manifest.webmanifest',
new NetworkFirst({
cacheName: 'manifest-cache',
plugins: [new ExpirationPlugin({ maxAgeSeconds: 300, maxEntries: 1 })],
}),
);
registerRoute(
new NavigationRoute(async () => { /* fallback handler */ }, {
denylist: [/^\/api\//, /^\/uploads\//, /^\/manifest\.webmanifest$/],
}),
);
```
**Vérifications obligatoires post-bascule** (DevTools sur build/preview) :
1. Application > Service Worker : `sw.js` activé
2. Network : `POST /api/auth/login` → pas de "(from ServiceWorker)", pas de cache
3. Network : GET `/uploads/foo.pdf` → réseau direct
4. Network mode Offline : navigation `/page` → fallback `index.html` ; `/api/foo` → erreur réseau (PAS index.html)
5. Déploiement v2 : ancien cache purgé après activation
`setCatchHandler` ne suffit PAS à remplacer `navigateFallback` — il ne se déclenche que si une route enregistrée throw. Pour le navigation fallback, utiliser `NavigationRoute` explicitement.
- Contexte technique : Vite / vite-plugin-pwa / Workbox — RL799_V2 28-04-2026
---
<a id="risque-ts-strict-uint8array-buffersource"></a>
## TS strict — `Uint8Array<ArrayBufferLike>` non assignable à `BufferSource`
### Risques
- TS 5.7+ avec lib DOM récente paramètre `Uint8Array` par défaut sur `ArrayBufferLike` (qui inclut `SharedArrayBuffer`)
- Beaucoup d'APIs DOM (Push API, WebCrypto certaines surfaces) attendent un `BufferSource` strict avec `buffer: ArrayBuffer` — d'où une erreur TS au build
### Symptômes
```
Type 'Uint8Array<ArrayBufferLike>' is not assignable to type 'BufferSource'.
Types of property 'buffer' are incompatible.
Type 'ArrayBufferLike' is not assignable to type 'ArrayBuffer'.
Type 'SharedArrayBuffer' is missing the following properties from type 'ArrayBuffer'…
```
L'erreur est au build, pas au runtime (le code marche en JS).
### Bonnes pratiques / mitigations
Créer explicitement un `ArrayBuffer` strict, puis remplir via une vue `Uint8Array` :
```typescript
// ❌ Ne compile pas en TS strict
const urlBase64ToUint8Array = (s: string): Uint8Array => {
const raw = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
};
// ✅ Compile et fonctionne identiquement
const urlBase64ToArrayBuffer = (s: string): ArrayBuffer => {
const raw = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
const buf = new ArrayBuffer(raw.length);
const view = new Uint8Array(buf);
for (let i = 0; i < raw.length; i++) view[i] = raw.charCodeAt(i);
return buf;
};
```
**Alternative déconseillée** : `as ArrayBuffer` cast — masque le problème, peut rater une vraie incompatibilité si la lib DOM évolue.
- Contexte technique : TypeScript 5.x / lib DOM — RL799_V2 28-04-2026
---
<a id="risque-safe-areas-ios-viewport-fit-cover"></a>
## Safe-areas iOS — `viewport-fit=cover` indispensable
### Risques
- Sur iPhone Pro/Max (notch + home indicator + Dynamic Island), `padding-top: env(safe-area-inset-top)` ou `padding-bottom: env(safe-area-inset-bottom)` retourne **0** sans `<meta viewport content="… viewport-fit=cover">`
- Le développeur conclut à tort que le pattern ne fonctionne pas, alors qu'il manque juste l'opt-in
### Symptômes
- Header fixed rogné par le notch
- Nav bottom rognée par le home indicator
- `env(safe-area-inset-*)` retourne 0 sur iPhone Pro+
### Bonnes pratiques / mitigations
**Pattern complet (3 endroits) à appliquer ensemble** :
```html
<!-- 1) index.html — opt-in safe-areas -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
```
```css
/* 2) Header sticky : top + safe-area-inset-top */
.app-header {
position: fixed;
top: 0;
padding-top: env(safe-area-inset-top);
height: calc(var(--size-header-height) + env(safe-area-inset-top));
}
/* 3) Nav bottom : bottom + safe-area-inset-bottom + safe-area-inset-left/right */
.app-nav {
position: fixed;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
height: calc(var(--size-nav-height) + env(safe-area-inset-bottom));
}
/* FAB / menu flottant : bottom au-dessus de la nav + safe-area + offset */
.fab {
bottom: calc(var(--size-nav-height) + env(safe-area-inset-bottom) + 16px);
/* `max()` empêche les boutons d'être collés au bord rond du device */
right: max(16px, env(safe-area-inset-right));
}
```
**Règle de débogage** : si `env(safe-area-inset-bottom)` semble retourner 0 sur iPhone Pro+, **vérifier `<meta viewport>` AVANT de chercher ailleurs**. C'est presque toujours la cause.
`safe-area-inset-left` et `safe-area-inset-right` ne sont non-nuls qu'en mode paysage (notch latéral). Garder le `padding-left/right` quand même → no-op en portrait, fix en paysage.
- Contexte technique : CSS / iOS — RL799_V2 02-05-2026
---
<a id="risque-input-date-safari-ios-min-width"></a>
## `<input type="date">` Safari iOS — `appearance: none` + `min-width: 0` + `min-height` obligatoires
### Risques
- Sur Safari iOS (et Chrome iOS car webkit sous-jacent), un `<input type="date">` ou `datetime-local` :
- déborde de son conteneur sur la droite (largeur intrinsèque > 100 %)
- apparaît plus mince que les autres inputs (hauteur intrinsèque différente)
- affiche un styling natif iOS qui casse le design system
### Symptômes
- `width: 100%` ne suffit pas — la largeur intrinsèque écrase la contrainte
- Bug non reproductible sur Chrome desktop, visible uniquement sur iPhone réel ou Safari Responsive Design Mode
### Bonnes pratiques / mitigations
```css
input[type="date"],
input[type="datetime-local"],
input[type="time"] {
appearance: none; /* neutralise le styling natif */
-webkit-appearance: none; /* Safari iOS — ne PAS oublier */
min-width: 0; /* permet à width: 100% de gagner */
min-height: 48px; /* aligne avec les autres inputs */
}
```
**Les 4 propriétés sont nécessaires** :
- `appearance: none` seul : le styling natif disparaît mais la largeur intrinsèque reste → débordement
- `min-width: 0` seul : le styling natif reste, on a juste cassé sa hauteur
- `min-height: 48px` : nécessaire pour homogénéiser avec les inputs text classiques
- `-webkit-appearance: none` : redondant en théorie avec `appearance: none` mais nécessaire en pratique sur certaines versions Safari iOS
- Contexte technique : CSS / Safari iOS — RL799_V2 01-05-2026
---
<a id="risque-fieldset-legend-flex-grid"></a>
## `<fieldset>` / `<legend>` cassent un layout flex inline
### Risques
- Pour un champ "label + valeur inline" (ex : `Grade [GradeBadge]` sur la même ligne), le réflexe sémantique est `<fieldset>` + `<legend>`
- `<legend>` a un comportement natif particulier : interrompt visuellement le `border` du fieldset, son `display` est traité spécialement, il ne se comporte pas comme un enfant flex/grid normal
- Le legend prend toute la largeur, l'input passe en dessous, impossible de les aligner sans hacks `position: absolute`
### Symptômes
- `<fieldset style="display: flex">` avec `<legend>` + `<input>` qui ne s'alignent pas sur la même ligne
### Bonnes pratiques / mitigations
Pour un groupe de champs **avec layout custom** (flex/grid inline), utiliser `<div>` + `<span class="label">` + le champ :
```html
<!-- ❌ Layout cassé : legend ne se comporte pas comme un enfant flex -->
<fieldset class="field-inline">
<legend>Grade</legend>
<GradeBadge :grade="grade" />
</fieldset>
<!-- ✅ Layout custom OK : div + span — perte sémantique mineure
compensée par aria-labelledby si besoin -->
<div class="field-inline">
<span class="field-inline__label" id="grade-label">Grade</span>
<GradeBadge :grade="grade" aria-labelledby="grade-label" />
</div>
```
Garder `<fieldset>/<legend>` uniquement quand on accepte le rendu natif (groupe vertical avec border + legend qui chevauche le border supérieur — pattern formulaire admin classique).
**Trade-off à assumer** : `<fieldset>` apporte une sémantique a11y (groupe de champs liés). En remplaçant par `<div>`, on peut compenser via `role="group" aria-labelledby="..."` si le besoin d'a11y est fort. Pour un simple label+badge inline, c'est rarement nécessaire.
- Contexte technique : HTML / a11y / CSS — RL799_V2 02-05-2026
---

View File

@@ -382,3 +382,176 @@ followingsError: string | null; // erreur de fetchFollowings
- Règle : dans un store qui gère à la fois des mutations et des listes paginées, chaque opération doit avoir sa propre clé d'erreur - Règle : dans un store qui gère à la fois des mutations et des listes paginées, chaque opération doit avoir sa propre clé d'erreur
- Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026 - Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026
---
<a id="risque-emit-vue-mutation-serveur-sans-listener"></a>
## `emit` Vue annonçant une mutation serveur sans listener parent → caches stale
### Risques
- Un composant enfant émet un événement (`emit('approved')`) après une mutation côté serveur, mais aucun parent n'écoute
- L'enfant met bien à jour son état local, mais les caches parents qui dérivent du même statut (badges accordion, verrous cascade, prop `previousAggregate` consommée par d'autres enfants) restent stale **indéfiniment**, jusqu'au prochain changement d'écran/route
- Bug invisible dans les tests structurels (`content.includes`) et passe inaperçu en revue parce que le code enfant est "correct"
### Symptômes
- Tout autre affichage du même statut dans le parent ou dans des sous-composants frères reste "Publiée" alors que la planche est "Approuvée" en DB
- L'UI ne se rafraîchit qu'au prochain reload manuel ou navigation
### Bonnes pratiques / mitigations
```bash
# Repérer les emits qui annoncent une mutation serveur
grep -rn "defineEmits" apps/frontend/src/components
# Pour chaque emit trouvé, chercher s'il a au moins un listener parent
grep -rn "@<eventName>=" apps/frontend/src/pages apps/frontend/src/components
```
Zéro listener = bug latent (sauf si l'emit est purement informatif — analytics, debug).
**Règle** : tout `emit` qui annonce une **mutation persistée serveur** (création, suppression, changement de statut, validation) doit avoir au moins un listener parent qui :
1. Invalide les caches locaux dérivés de la même donnée (Map de statuts, computed, props transitives)
2. Recharge la slice agrégée si le parent passe une prop construite à partir d'un autre fetch (`previousAggregate`, dashboard data)
3. **Ne se contente PAS de l'optimistic update** de l'enfant — la source de vérité reste serveur, le cache parent doit refléter l'état serveur post-mutation
```vue
<!-- Enfant émet, parent ignore. Le badge de l'accordion reste stale -->
<PlancheTraceeCard :tenue-id="planche.tenueId" mode="previous" />
<!-- Listener explicite qui invalide les caches dérivés -->
<PlancheTraceeCard
:tenue-id="planche.tenueId"
mode="previous"
@approved="onPlancheApproved(planche.tenueId)"
/>
```
**Couverture** : test de mount complet via `@vue/test-utils` qui simule l'événement et vérifie que le rendu parent change. Les tests `readFileSync + content.includes('emit')` valident que le code enfant émet, pas que le parent écoute.
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
---
<a id="risque-templates-vue-references-orphelines"></a>
## Templates Vue — références orphelines invisibles à `tsc --noEmit`
### Risques
- Une variable supprimée du `<script setup>` mais encore référencée dans le `<template>` ne génère pas d'erreur compile avec `tsc --noEmit` seul (Volar moins strict que `tsc` pur sur les expressions template)
- Le composant crashe **uniquement au runtime** : `Cannot read properties of undefined` ou `[Vue warn] Property "X" was accessed during render but is not defined`
### Symptômes
- Refactor où on extrait un composable et oublie de destructurer une variable, ou on retire un import devenu (apparemment) inutilisé
- Typecheck passe, tests structurels passent, page charge mais une section ne s'affiche pas / un bouton ne fait rien
- `[Vue warn]` dans la console au mount
### Bonnes pratiques / mitigations
**Recommandation outillage** : migrer le `typecheck` du projet de `tsc --noEmit` vers `vue-tsc -p tsconfig.typecheck.json --noEmit`. Avec `vue-tsc`, les expressions template sont strictement typées contre le `<script setup>` exposé. Une ref orpheline → erreur de compile, pas warning runtime.
**Checklist étendue avant de marquer une extraction "done"** :
```bash
# Pour chaque symbole supprimé/non destructuré, grep dans le template
for symbol in normalizedQuery directoryOfficeLabel; do
echo "=== $symbol ==="
# Patterns template critiques
grep -nE "v-(if|else-if|show)=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
grep -nE "\\{\\{[^}]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
grep -nE "(:|@)[a-z-]+=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
done
```
**QA visuel obligatoire post-refactor** : pour tout refactor qui touche une page importante, ouvrir la page en browser dev avant de pousser :
- naviguer aux états critiques (search, modals, toggle accordion)
- vérifier la console : zéro `[Vue warn]` ou `ReferenceError`
- tester au moins un workflow complet par section refactorée
- Contexte technique : Vue 3 / Volar / `vue-tsc` — RL799_V2 29-04-2026
---
<a id="risque-symboles-orphelins-suppression-bloc"></a>
## Symboles orphelins après suppression d'un bloc — checklist grep
### Risques
- Refactor où on supprime un bloc cohérent (state + computeds + handlers) en utilisant un Edit ciblé. Tout compile, tous les tests passent, mais au runtime : `ReferenceError: <symbole> is not defined` au mount
- Le symbole supprimé était encore référencé dans un **lifecycle hook** (`onMounted`, `onUnmounted`), un **watcher**, ou un **handler asynchrone** non inclus dans le bloc supprimé
### Symptômes
- Page qui ne mount plus après refactor, alors que typecheck OK + tests verts
- Erreur visible uniquement à `cmd+R` sur la page
### Bonnes pratiques / mitigations
```bash
# Pour chaque symbole déclaré dans le bloc à supprimer
for symbol in updatePointerType pointerMql closeInsertMenuOnOutsideClick; do
echo "=== $symbol ==="
grep -n "\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
done
```
Doit retourner **uniquement les lignes du bloc à supprimer**. Si une référence apparaît hors du bloc → ne pas supprimer sans traiter explicitement (déplacer, adapter, ou laisser).
**Lieux à vérifier en priorité** :
| Lieu | Fréquence du piège |
|------|--------------------|
| `onMounted(() => { ... })` | ⚠️⚠️⚠️ très fréquent |
| `onUnmounted(() => { ... })` | ⚠️⚠️⚠️ très fréquent (cleanup) |
| `watch(() => x, () => { fn() })` | ⚠️⚠️ fréquent |
| Handler async (`.then(() => fn())`) | ⚠️ rare mais existe |
| Computed dans une autre section du fichier | ⚠️ rare |
| Template (`@click`, `:disabled`) | typecheck attrape via SFC plugin |
**Garde-fou complémentaire** : QA visuel obligatoire post-refactor (cf. `risque-templates-vue-references-orphelines`).
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
---
<a id="risque-extraction-vue-ts-bug-typage-latent"></a>
## Extraction `.vue` → composable `.ts` révèle des bugs de typage latents
### Risques
- Vue 3 + Volar compile les blocs `<script setup>` avec une stratégie d'inférence moins agressive que `tsc` strict pur. Un handler peut compiler dans le `.vue` si le runtime n'utilise pas le champ manquant
- Le composable extrait est compilé par `tsc -p tsconfig.typecheck.json` **hors contexte Vue** → toutes les règles strictes s'appliquent → la divergence de type devient une erreur bloquante
- C'est un effet de bord **positif** mais qui peut bloquer l'extraction tant qu'on n'a pas diagnostiqué la divergence
### Symptômes
```
Type 'X' is not assignable to type 'Y'.
The types of '<champ>.<sous-champ>' are incompatible…
Type 'A' is missing the following properties from type 'B': …
```
Cas typique : un emit Vue annonçait `Detail` (type pour la liste) alors que la fonction service renvoyait `Data` (type pour la mutation). Silencieux dans le `.vue` d'origine, devenu visible dans le `.ts` extrait.
### Bonnes pratiques / mitigations
Trois cas typiques quand l'erreur apparaît après extraction :
1. **Le type annoncé ne matche pas le type réellement émis** : aligner sur le type réellement émis plutôt que sur le type annoncé dans `defineEmits`. Si possible, corriger aussi la signature de l'émetteur — mais c'est un autre scope
2. **Le `.vue` exploitait un cast implicite** : `v-if="x.foo"` réduit le union type. Dans un `.ts` extrait, narrow explicitement avec un `if` ou un type guard
3. **Volar n'analysait pas un chemin de type complexe** : type récursif, génériques imbriqués, `Pick<...>` dans une union → extraire un alias intermédiaire propre dans `@<module>/types.ts`
**Quoi faire face à l'erreur** :
1. **NE PAS** mettre `as Foo` pour faire taire le compilateur — c'est probablement masquer le même bug sous un autre nom
2. Identifier lequel des deux types est correct (généralement celui que la fonction service / l'API renvoie réellement)
3. Aligner la signature du handler/composable sur ce type-là
4. Documenter dans un commentaire au-dessus du handler que le type émis diverge du type annoncé dans `defineEmits` (si on ne corrige pas l'émetteur dans le même refactor)
5. Ouvrir un TODO si la correction de l'émetteur est hors scope
**Recommandation outillage** : `vue-tsc` plutôt que `tsc` pur en typecheck (cf. `risque-templates-vue-references-orphelines`). Ce genre de divergence aurait été détecté **avant** le refactor.
- Contexte technique : Vue 3 / Volar / `vue-tsc` — RL799_V2 29-04-2026

View File

@@ -149,6 +149,22 @@ source_projects: [app-alexandrie, app-template-resto, RL799_V2]
- Contexte technique : Vue 3 / node:test — RL799_V2 02-04-2026 - Contexte technique : Vue 3 / node:test — RL799_V2 02-04-2026
### Cas additionnel : obsolescence silencieuse après refacto structurel
Au-delà du faux garde-fou de non-régression, un test en `readFileSync(path) + content.includes(...)` devient obsolète sans alarme dès qu'une réorganisation structurelle déplace le code visé. Trois variantes vécues :
1. **Fichier déplacé par scoping** (ex: `pages/X.vue``pages/<module>/X.vue`) → `ENOENT` au runtime, le test crashe au lieu de signaler une régression métier
2. **Logique extraite dans un composable / sous-composant** → la chaîne attendue ne vit plus dans le `.vue` mais dans `composables/use<X>.ts` ; le `.vue` existe encore mais ne contient plus le pattern, donc le test échoue sur une assertion sans rapport avec la vraie cause
3. **Variable supprimée du `<script setup>` mais conservée dans le template** → string-match passe (le template contient toujours la string), crash JS au mount du composant
**Mitigations spécifiques** :
- Centraliser le `path` du fichier visé dans une constante en tête de fichier de test (pas `resolve(...)` inline) — facilite le rerouting en cas de refacto
- Lors d'une extraction de logique dans un composable / sous-composant, grep les tests structurels qui pointaient le fichier d'origine et les rediriger vers le nouveau chemin
- Pour les composants interactifs (formulaires, modales, listes avec actions), compléter le string-match par au moins un test de mount via `@vue/test-utils` qui vérifie le render sans crash — c'est le seul moyen de valider la cohérence script ↔ template
- Contexte technique : Vue 3 / vitest — RL799_V2 30-04-2026 (3 cas observés sur la même session)
--- ---
<a id="risque-catch-false-test-skip-e2e"></a> <a id="risque-catch-false-test-skip-e2e"></a>
@@ -171,3 +187,129 @@ source_projects: [app-alexandrie, app-template-resto, RL799_V2]
- **Signal review** : `.catch(() => false)` suivi de `test.skip` dans un test E2E - **Signal review** : `.catch(() => false)` suivi de `test.skip` dans un test E2E
- Contexte technique : Playwright / E2E — RL799_V2 08-04-2026 - Contexte technique : Playwright / E2E — RL799_V2 08-04-2026
---
<a id="risque-tests-e2e-6-causes-racines"></a>
## Tests E2E qui rotent — 6 causes-racines récurrentes
### Risques
- Sur une suite E2E mature, les fails ne viennent presque jamais d'un bug applicatif : ils viennent d'un désalignement test ↔ code de prod
- Conclure à une régression métier alors que c'est du test obsolète fait perdre du temps et masque les vraies régressions
### Symptômes
Les 6 patterns observés sur RL799_V2 (Playwright + Vue 3 + refactors UI fréquents) :
1. **Testid changé sans MAJ tests** : `getByTestId('library-entries')` timeout, mais le composant expose `data-testid="document-list"`. Cause : refactor d'un composant qui fusionne plusieurs vues en un composant générique avec un testid neutre.
2. **Labels métier qui changent** : `await expect(badge).toHaveText('Publiée')` échoue, le badge affiche désormais 'Convoquée'. Cause : refactor lifecycle qui renomme les labels affichés sans toucher aux testids structurels.
3. **Menus / dropdowns conditionnels** : `getByTestId('odj-insert-menu').click()` timeout aléatoire — parfois le menu s'ouvre, parfois pas. Cause : UX qui adapte le flow selon l'état (1 seul type → bouton direct, plusieurs → menu).
4. **Features supprimées** : `await page.goto('/secretaire?soireeId=xxx')` charge la page mais ne sélectionne plus la soirée. Cause : query param retiré au profit d'une navigation par onglets + click sur card.
5. **Refactor visuel** : `await expect(badge).toHaveText('A∴')` échoue, le badge affiche désormais une icône SVG. Cause : refactor de représentation (texte → icône) sans toucher au testid.
6. **Cleanup post-test** : test métier passe en 2 s, mais le `finally { await restoreEntry() }` timeout à 30 s. Cause : le PATCH de cleanup tape sur une route lente (audit log, notif, validation).
### Bonnes pratiques / mitigations
À chaque diagnostic E2E, vérifier d'abord ces 6 hypothèses avant de conclure à une régression métier :
- **Cause 1** : grep `data-testid` dans le composant cible avant de modifier le test. Ne jamais "deviner" le testid à partir du nom de la page.
- **Cause 2** : préférer asserter sur des classes CSS modifier (`.badge--published`) ou des testids d'état (`data-testid="status-published"`) plutôt que sur du texte humain (cf. `pattern-asserter-classe-css-modifier-vs-texte` dans `frontend/patterns/tests.md`).
- **Cause 3** : guard conditionnel via `isVisible({ timeout: 1_000 }).catch(() => false)` pour gérer les deux branches.
- **Cause 4** : quand un test commence par une URL avec query param, vérifier en premier que ce param est encore consommé par la page (grep `useRoute` / `route.query` dans le composant).
- **Cause 5** : asserter la classe CSS modifier (plus stable que innerHTML qui contiendrait le SVG).
- **Cause 6** : cleanup best-effort avec timeout court (cf. `pattern-cleanup-e2e-best-effort` dans `frontend/patterns/tests.md`).
### Méta-leçon
Quand on découvre N fails E2E après une période de refactor intense :
1. Lancer la suite complète une fois pour avoir la liste exhaustive
2. Trier par cause-racine plutôt que par fichier
3. Fixer en lots cohérents (1 commit par cause-racine) plutôt qu'1 commit par fail
4. Capitaliser les patterns dès qu'ils se répètent (> 2 occurrences)
- Contexte technique : Playwright / Vue 3 — RL799_V2 25-04-2026
---
<a id="risque-tests-string-match-repointer-composant"></a>
## Tests `string-match .vue` — limites et compléments après extraction
### Risques
- Quand on extrait une section/onglet vers un sous-composant, les assertions `readFileSync + content.includes('Ordre du jour')` échouent — la string est maintenant dans le sous-composant, pas dans la page
- Mauvaises réactions : supprimer le test (perd la garantie), `.skip()` (dette accumulée), inverser en `toBeFalsy()` (régression masquée), repointer aveuglément (peut camoufler un problème)
### Symptômes
```
AssertionError: expected false to be truthy
expect(tenuesPage.includes('Ordre du jour')).toBeTruthy();
^
```
### Bonnes pratiques / mitigations
**Diagnostic** : lire l'assertion et identifier ce qu'elle garantit (présence d'un comportement métier, d'un data-testid critique, d'un ordre visuel).
**Repointer correctement** :
```typescript
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '../../../..');
// Page coquille (ce qui reste : layout, tabs, rendu conditionnel)
const tenuesPage = readFileSync(
resolve(root, 'src/pages/tenues/TenuesPage.vue'),
'utf-8',
);
// Sous-composant qui incarne désormais le markup d'un onglet
const prochaineView = readFileSync(
resolve(root, 'src/pages/tenues/components/ProchaineTenueView.vue'),
'utf-8',
);
// Composable qui incarne désormais la logique d'un onglet
const useProchaine = readFileSync(
resolve(root, 'src/pages/tenues/composables/useProchaineTenue.ts'),
'utf-8',
);
test('TenuesPage utilise le modèle de vue testable pour tab/titre', () => {
expect(tenuesPage.includes('resolveTenuesTab')).toBeTruthy();
expect(useProchaine.includes('getProchaineTenueTitle')).toBeTruthy();
});
```
**Renommer aussi le test si pertinent** :
```typescript
// Avant
test('TenuesPage redirige vers une page dédiée en cas de 403...', () => { /* … */ });
// Après — le nom devient un index sémantique
test('usePastTenues redirige vers une page dédiée en cas de 403...', () => { /* … */ });
```
### Anti-pattern : tests structurels qui bougent en cascade
Si tes tests doivent être systématiquement mis à jour à chaque refactor, c'est que beaucoup de garanties sont vérifiées par string-match plutôt que par comportement. Pour les composants interactifs critiques (formulaires, listes avec actions, modales), **doubler** avec un test de mount `@vue/test-utils` qui survit aux refactors.
### Trois variantes vécues
1. **Fichier déplacé par scoping** (`pages/X.vue``pages/<module>/X.vue`) → `ENOENT` au runtime, le test crashe au lieu de signaler une régression métier
2. **Logique extraite dans un composable / sous-composant** → la chaîne attendue ne vit plus dans le `.vue` ; le test échoue sur une assertion sans rapport avec la vraie cause
3. **Variable supprimée du `<script setup>` mais conservée dans le template** → string-match passe (le template contient toujours la string), crash JS au mount du composant
**Mitigations spécifiques** :
- Centraliser le `path` du fichier visé dans une constante en tête de fichier de test — facilite le rerouting en cas de refacto
- Lors d'une extraction, grep les tests structurels qui pointaient le fichier d'origine et les rediriger vers le nouveau chemin
- Pour les composants interactifs, compléter par au moins un test de mount via `@vue/test-utils` qui vérifie le render sans crash
- Contexte technique : Vue 3 / Vitest — RL799_V2 29-04-2026

View File

@@ -0,0 +1,11 @@
# Workflow — Patterns validés — Index
Patterns liés au process de développement, aux agents BMAD et au pilotage des chantiers.
Avant toute proposition workflow, identifie le fichier dont le nom et la description matchent le contexte traité, puis lis-le.
---
| Fichier | Domaine | Entrées clés |
|---------|---------|--------------|
| `general.md` | Review adversarial, isolation de hunks, méthode de chantier | Review adversarial obligatoire sur chemin critique, isolation chirurgicale d'un hunk via `git apply --cached` |

View File

@@ -0,0 +1,157 @@
---
title: Workflow — Patterns : Général
domain: workflow
bucket: patterns
tags: [review, adversarial, git, chantier, agent]
applies_to: [analysis, implementation, review]
severity: high
validated_on: 2026-04-27
source_projects: [RL799_V2]
---
# Workflow — Patterns : Général
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/workflow/patterns/README.md` pour l'index complet.
---
<a id="pattern-review-adversarial-chemin-critique"></a>
## Pattern : Review adversarial obligatoire sur chemin critique
- Objectif : détecter avant commit les race conditions, assertions trop molles et dépendances cachées qui ne sont pas visibles à la lecture du spec mais le sont à la lecture du code écrit.
- Contexte : chantier qui touche un chemin critique (atomicité, audit, sécurité, intégrité financière, lifecycle d'entité métier) — même petit (< 100 lignes).
- Quand l'utiliser : systématique sur les chemins critiques, indépendamment de la taille du chantier.
- Quand l'éviter : refactor cosmétique, polish UX, documentation, tests d'un chemin déjà couvert.
- Avantage :
- capture les défauts post-spec (l'intention est cadrée, l'implémentation peut diverger)
- moins coûteuse à corriger avant commit qu'en post-prod (incident, debug, rollback)
- Limites / vigilance :
- pas un substitut à la code review classique (posture différente : "qu'est-ce qui casse" vs "est-ce conforme au spec")
- pas un substitut aux tests qui passent (les tests valident ce qu'on a pensé tester)
- Validé le : 27-04-2026
- Contexte technique : workflow agent / BMAD — RL799_V2
### Identifier un chemin critique
Au moins **une** des conditions :
- mutation atomique d'une entité métier (close, cancel, lock, archive)
- émission d'audit log (la traçabilité est non-négociable)
- émission de notification (mauvais target = bug visible utilisateur)
- logique d'autorisation, RBAC, gates de permission
- calculs financiers (paiements, soldes, totaux)
- intégrité référentielle (FK, soft-delete)
- concurrence : plusieurs process peuvent toucher la même entité
### Format de review efficace
Posture : œil fâché, on cherche à casser ce qu'on vient de livrer.
Pour chaque finding :
- **Sévérité** : HIGH / MEDIUM / LOW / NIT
- **Localisation** : `file:line`
- **Problème** : ce qui peut casser, dans quel scénario
- **Mitigation possible** : 1-3 options
- **Verdict** : fix dans le scope, dette explicite, ignorer
Sortie : tableau synthétique. Si > 0 HIGH ou > 2 MEDIUM, on fixe avant commit.
### Anti-règle
- Confondre review adversarial et code review classique
- Confondre "tests verts" et "implémentation correcte"
- Lire son propre code en attendant de le valider (biais cognitif fort)
---
<a id="pattern-isolation-hunk-git-apply-cached"></a>
## Pattern : Isolation chirurgicale d'un hunk via `git apply --cached <patch>`
- Objectif : commiter uniquement les hunks d'un chantier A dans un fichier également modifié par un chantier B, sans interaction `git add -p`.
- Contexte : plusieurs chantiers parallèles touchent le même fichier central (catalog audit, schema partagé, fichier d'enums).
- Quand l'utiliser : agent IA en environnement non-interactif qui prépare un commit avec sélection de hunks.
- Quand l'éviter : si on commit la totalité du fichier (`git add <file>` direct suffit), ou si les hunks sont logiquement indissociables.
- Avantage :
- non-interactif, scriptable
- le worktree garde tous les changements (mes hunks A commités + hunks B unstaged pour le chantier B)
- Limites / vigilance :
- en interactif humain, `git add -p` reste plus rapide
- le patch filtré doit rester valide (en-têtes `diff --git`, `index`, `@@` cohérents)
- Validé le : 27-04-2026
- Contexte technique : git / chantiers parallèles — RL799_V2
### Recette
```bash
# 1. Capturer le diff du fichier mixte
git diff <file> > /tmp/mixed.patch
# 2. Éditer le patch pour ne garder que les hunks à commiter
# (un agent peut le faire via Read + Write — c'est un fichier texte)
# 3. Appliquer le patch filtré uniquement à l'index (pas au worktree)
git apply --cached /tmp/mine_only.patch
# 4. Vérifier le staged
git diff --cached <file>
# 5. Commit
git commit ...
# 6. Le worktree garde TOUS les changements
git diff <file> # affiche les hunks de l'autre chantier
```
### Format minimal d'un hunk
```diff
diff --git a/<file> b/<file>
index <hash_old>..<hash_new> 100644
--- a/<file>
+++ b/<file>
@@ -<line>,<count> +<line>,<count> @@ <context>
<ligne contexte avant>
+<ligne ajoutée>
<ligne contexte après>
```
Les `index` et `@@` viennent du `git diff` original — pas besoin de recalculer les hashes.
---
<a id="pattern-sub-agents-trianguler-localisations"></a>
## Pattern : Trianguler par grep les localisations rapportées par un sub-agent
- Objectif : ne jamais agir directement sur les localisations précises rapportées par un sub-agent d'analyse — toujours vérifier avant de patcher.
- Contexte : workflow multi-agent où un sub-agent (Explore, audit BMAD, lecture de code) retourne un rapport avec findings.
- Quand l'utiliser : à la réception de tout rapport de sub-agent qui cite des fichiers, lignes, ou snippets.
- Quand l'éviter : pour les comptages globaux et tendances — fiables même sans triangulation.
- Avantage :
- évite de "corriger" du code déjà correct (pollution de commit, perte de confiance)
- distingue les findings actionables des extrapolations
- Limites / vigilance :
- ajoute ~15-20 % de temps à la phase d'action — largement compensé par les commits évités
- Validé le : 24-04-2026
- Contexte technique : workflow multi-agent / BMAD — RL799_V2
### Règle
> Ne jamais agir sur les **localisations précises** d'un sub-agent sans vérification.
> - Sub-agent dit "fichier X ligne Y a pattern Z" → `grep Z fichier X` doit confirmer
> - Sub-agent dit "fichiers A, B, C, D ont pattern Z" → grep chaque fichier
> - Si un finding s'effondre à la vérif, relire critiquement les autres findings du même sub-agent
### Ce qui reste fiable chez un sub-agent
- Comptages globaux (X fichiers sur Y ont le pattern Z — l'ordre de grandeur est généralement juste)
- Tendances (ex: "le projet a un problème de cleanup" — juste, même si les fichiers cités sont faux)
- Patterns de risque identifiés (`vi.stubEnv` sans restauration est un vrai pattern à risque)
### Ce qui est peu fiable
- Numéros de ligne précis (peuvent être hallucinés)
- Listes de fichiers exhaustives pour un pattern rare
- Snippets de code cités (peuvent être reconstruits de mémoire plutôt que lus)
### Communication au user
> *"Sub-agent X signale 4 fichiers, j'ai validé 1/4 et invalidé 3/4. Voici le plan d'action corrigé."*

View File

@@ -6,4 +6,4 @@ Risques liés au process de développement, aux agents BMAD, et au tracking des
| Fichier | Domaine | Entrées clés | | Fichier | Domaine | Entrées clés |
|---------|---------|--------------| |---------|---------|--------------|
| `story-tracking.md` | BMAD, agents, story completion | Story "completed" avec tâches ❌, story "done" sans fichiers source dans File List | | `story-tracking.md` | BMAD, agents, story completion | Story "completed" avec tâches ❌, story "done" sans fichiers source dans File List, stratégie de fix d'une suite E2E qui rote en masse |

View File

@@ -270,3 +270,47 @@ source_projects: [app-alexandrie, app-template-resto, RL799_V2]
- Contexte technique : BMAD / traçabilité adaptation story — RL799_V2 15-04-2026 - Contexte technique : BMAD / traçabilité adaptation story — RL799_V2 15-04-2026
--- ---
<a id="risque-strategie-fix-suite-e2e-en-masse"></a>
## Stratégie de fix d'une suite E2E qui rote en masse (post-refactor)
### Risques
- Quand un projet enchaîne plusieurs refactors UI/lifecycle sans run E2E entre chaque, on découvre souvent N fails (10-20) d'un coup
- Tentation de fixer fail-by-fail dans l'ordre où ils apparaissent — erreur : on se disperse sur N causes-racines mélangées et chaque commit n'est pas reviewable
- Sans capitalisation immédiate des patterns, on perd le bénéfice de la session pour les futurs refactors
### Symptômes
- 13 fails d'un coup après une période de refactor intense
- Tentative de fix fail-par-fail qui produit un commit géant difficile à reviewer
- Capitalisation reportée → patterns oubliés à la session suivante
### Bonnes pratiques / mitigations
**Process recommandé** :
1. **Run complet en mode list** d'abord : `playwright test --reporter=list`. On veut **la liste exhaustive** des fails, pas un comptage one-shot par fichier (qui peut masquer des fails qui apparaissent uniquement quand on lance la suite entière à cause d'effets de bord seed).
2. **Catégoriser par cause-racine** plutôt que par fichier (cf. `knowledge/frontend/risques/tests.md` — Tests E2E qui rotent — 6 causes-racines récurrentes) :
- testid changé sans MAJ tests
- label métier changé (lifecycle, statut)
- menu/dropdown conditionnel
- feature supprimée (query param, route)
- refactor visuel (texte → icône)
- cleanup post-test à rendre best-effort
3. **1 commit = 1 cause-racine**. Pas "fix 19 fails" en un commit géant. Permet :
- Review plus simple (chaque commit a un thème clair)
- Revert chirurgical si une cause-racine s'avère mal diagnostiquée
- Capitalisation : chaque commit message documente le pattern
4. **Validation entre commits** : run la suite complète après chaque commit pour savoir avant le push que le commit n'a pas régressé d'autres tests.
5. **Capitaliser les patterns à > 2 occurrences** : si on voit le même type de fail 3 fois sur 3 fichiers différents, c'est un pattern. Le poser dans `knowledge/frontend/risques/tests.md` immédiatement, pas plus tard.
**Anti-pattern à éviter** : "je fixe les 5 fails P1 d'abord, je verrai les P2 après". Si la cause-racine est la même (ex : labels lifecycle v3), on perd 30 min à re-comprendre quand on retourne sur les P2 plus tard. Mieux : grouper par cause-racine, pas par priorité.
**Métrique de référence** : 19 fails fixés en 4 commits / ~3 h dont 1 h de capitalisation. Effort par fail : ~6 min de fix + 4 min de validation.
- Contexte technique : Playwright / refactor UI — RL799_V2 25-04-2026