mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-06-28 01:53:40 +02:00
docs(knowledge): capitalisation backend — intégration du triage local (mai-juin 2026)
Triage et intégration des propositions backend du buffer 95_a_capitaliser.md (lot local RL799_V2 + app-alexandrie, mai-juin 2026), distinct de la capitalisation remote antérieure (triage 2026-05-02). ~73 entrées intégrées sur knowledge/backend/, dont : - patterns/auth.md : série "membrane d'auth fédérée BFF/OIDC" (9 patterns) + jose algo whitelist - patterns/prisma.md : recette fusionnée "Migration String/Int → enum" (backfill + Cas A/B/C), row réactivable, endpoint replace atomique, updateMany conditionnel, etc. - risques/general.md : 19 risques (epoch s vs ms, keepAliveTimeout=0, upsert+filtre liste, fail-safe catch-all, retrait asymétrique front/back, anti-énumération rate-limit, etc.) - patterns/general, async, nestjs, contracts, tests + risques/auth, contracts, prisma, redis, stripe, tests - compléments d'entrées existantes (authorize-after-fetch, P3014, cursor opaque, DI swc, Stripe v20...) - README patterns/risques mis à jour Doublons internes corrigés en relecture (suppression-champ .map() → general seul ; e2e DB-based → tests.md seul). Doublons hors backend / entrées projet / rejets non intégrés. Source 95_a_capitaliser.md non purgée à ce stade (purge en fin de capitalisation complète). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,14 +8,14 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
|
||||
|
||||
| Fichier | Domaine | Entrées clés |
|
||||
|---------|---------|--------------|
|
||||
| `auth.md` | Auth, sessions, tokens, erreurs API, corrélation | Format erreur standardisé, middleware requestId, anti-énumération, token usage unique, autorisation interne, opérations atomiques |
|
||||
| `contracts.md` | Contrats API, Zod, error codes, HTTP sémantique | Contracts-First/Zod-Infer/No-DTO, error codes comme contrat, HTTP 200 payload métier, idempotence POST = retour ressource existante |
|
||||
| `prisma.md` | Prisma, DB, migrations, pagination | Soft delete, pagination cursor, idempotency key, P2002 unique, Decimal sérialisation, migration manuelle P3014, filtrage métier dans service |
|
||||
| `stripe.md` | Stripe, paiements, webhooks entrants, subscriptions | Provider-Strategy, metadata subscription_data, parsing webhook unique, restauration achats, Trial vs Paid |
|
||||
| `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique |
|
||||
| `auth.md` | Auth, sessions, tokens, erreurs API, corrélation | Format erreur standardisé, middleware requestId, anti-énumération, token usage unique, autorisation interne, opérations atomiques, jose whitelist algo, membrane BFF fédérée (jonction unique, session stateless JWE, pont d'identité idpSub, refresh 100% serveur), cutover irréversible, token opaque quick-link, RBAC dérivé JWT enrichi, 403 vs 404 oracle d'existence |
|
||||
| `contracts.md` | Contrats API, Zod, error codes, HTTP sémantique | Contracts-First/Zod-Infer/No-DTO, error codes comme contrat, HTTP 200 payload métier, idempotence POST = retour ressource existante, typer strict à la source, prop audience templates mail/PDF, verbe HTTP marker idempotent, endpoint distinct vs paramètre force, Zod .optional() honnête |
|
||||
| `prisma.md` | Prisma, DB, migrations, pagination | Soft delete, pagination cursor, idempotency key, P2002 unique, Decimal sérialisation, migration manuelle P3014 (+ réalignement DB dev), filtrage métier dans service, colonnes plates vs table dédiée, étape dérivée source unique, row réactivable, endpoint replace atomique, bascule idempotente updateMany, pagination relation N-N (some), $queryRawUnsafe en façade, token usage-unique condition WHERE, FK + snapshot label dérivé, migration String/Int → enum (backfill + sans downtime), util crypto transverse |
|
||||
| `stripe.md` | Stripe, paiements, webhooks entrants, subscriptions | Provider-Strategy, metadata subscription_data, parsing webhook unique, restauration achats, Trial vs Paid, prix en base → price_data inline |
|
||||
| `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique, ressource paire non ordonnée (clé canonique), batched eligibility (anti N+1 .map async), providers externes via injection par token, e2e DB-based NestJS + Prisma v7 |
|
||||
| `multi-tenant.md` | Multi-tenant, isolation, feature flags | 403 vs 404, repository tenant-aware, tenantId dans updates, helper tenant partagé, feature flag tenant, EN enforcement |
|
||||
| `nextjs.md` | Next.js App Router, Server Actions, isolation | Runtime-only logique pure, server-only isolation, utilitaires purs sans server-only, réutiliser champ V1, validation URL externe |
|
||||
| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents, hooks fire-and-forget après création DB, fanout notification avec filtre grade, auto-purge fenêtre temporelle SQL, test de rollback pipeline multi-fichiers atomique |
|
||||
| `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 |
|
||||
| `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, test de rollback pipeline multi-fichiers atomique, Promise.all queries DB indépendantes, cron multi-instance lock Redis SET NX EX, verrou une-action-par-entité updateMany, persist-then-send (effet de bord à statut persistant) |
|
||||
| `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, contrôle d'accès conditionnel pure logic shared, safe path resolution anti-traversal, alias d'import @/* migration en masse, test d'invariant de structure (scan statique), script ops batch fail-closed, helper transverse paramétrable (sendMailWithRetry) |
|
||||
| `tests.md` | Tests d'intégration DB, isolation, atomicité | `cleanup.track()` LIFO, `globalSetup` purge, template database Postgres, helper `waitForX()` polling-borné (+ vérif absence side-effect), test d'atomicité transaction, convention `describe()` 2 niveaux, refactor itératif d'un fichier monolithe, DI testable via hooks module-level `__setXForTests`, e2e DB-based NestJS + Prisma v7 (Jest/swc) |
|
||||
| `llm-providers.md` | Fournisseurs LLM, auth, coûts | OAuth consumer ≠ API key, prompt caching system prompt stable, budget cap fournisseur |
|
||||
|
||||
@@ -278,3 +278,168 @@ Tout pipeline qui écrit N fichiers avec rollback (suppression des déjà-écrit
|
||||
- que l'erreur est bien propagée à l'appelant.
|
||||
|
||||
Quand le nombre d'artefacts change (ajout d'un variant), mettre à jour ce test pour couvrir le nouveau N.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-promise-all-queries-db-independantes"></a>
|
||||
## Pattern : Promise.all des queries DB indépendantes dans un service handler
|
||||
|
||||
- Objectif : réduire la latence d'un service handler dont les queries DB sont posées en série alors qu'elles n'ont aucune dépendance de données.
|
||||
- Contexte : service handler API avec plusieurs `await` chaînés (lectures DB indépendantes).
|
||||
- Quand l'utiliser : >2 `await` dans le corps du handler, dont les arguments ne dérivent pas du résultat précédent.
|
||||
- Quand l'éviter :
|
||||
- contrôles d'autorisation (auth → user lookup → permission) : garder en série pour fail-fast et ne pas gaspiller de queries sur une requête non autorisée
|
||||
- boucles `for (...) await` à pagination progressive / arrêt anticipé : volontairement séquentielles
|
||||
- Avantage :
|
||||
- latence totale `~max()` au lieu de `~sum()`
|
||||
- gain proportionnellement **plus grand** en prod réseau (chaque round-trip cumule en série, s'absorbe en parallèle)
|
||||
- Limites / vigilance :
|
||||
- les queries conditionnelles doivent être emballées en `Promise.resolve(default)` pour garder le type `Promise<T>` dans le tuple
|
||||
- la transformation des résultats reste APRÈS le `Promise.all` ; les dérivations synchrones des seuls paramètres d'entrée peuvent passer AVANT
|
||||
- Validé le : 11-05-2026
|
||||
- Contexte technique : Next.js 16 API + Prisma 7 — RL799_V2
|
||||
|
||||
### Anti-pattern (séquentiel)
|
||||
|
||||
```ts
|
||||
const odjItems = soireeId ? await findOdjItemsBySoiree(soireeId) : [];
|
||||
const issues = await findLatestIssuesByTenueIds(tenueIds);
|
||||
const soireeContext = soireeId ? await getSoireeContext(soireeId) : null;
|
||||
const convocations = await findConvocationsByTenuesAndGrade(tenueIds, userId);
|
||||
// total : ~sum(latence_de_chaque_query)
|
||||
```
|
||||
|
||||
Symptômes : endpoint dans le top des plus lents, `curl -w "%{time_total}"` corrélé linéairement au nombre d'`await`, chaque query individuelle rapide (~50-70 ms) sans N+1 — c'est l'orchestration qui est en cause.
|
||||
|
||||
### Pattern (parallèle)
|
||||
|
||||
```ts
|
||||
const [odjItems, issues, soireeContext, convocations] = await Promise.all([
|
||||
soireeId ? findOdjItemsBySoiree(soireeId) : Promise.resolve([]),
|
||||
findLatestIssuesByTenueIds(tenueIds),
|
||||
soireeId ? getSoireeContext(soireeId) : Promise.resolve(null),
|
||||
findConvocationsByTenuesAndGrade(tenueIds, userId),
|
||||
]);
|
||||
// total : ~max(latence_de_chaque_query)
|
||||
```
|
||||
|
||||
### Détection
|
||||
|
||||
1. Mesurer (`curl -w "%{time_total}"` / `console.time()`), cibler les endpoints > 150 ms en local DB chaude.
|
||||
2. Compter les `await` du handler ; >2 hors contrôles d'autorisation = suspect.
|
||||
3. Pour chaque `await N+1`, vérifier si l'argument dérive du résultat de `await N`. Si NON → parallélisable.
|
||||
|
||||
### Tests
|
||||
|
||||
Le contrat de l'endpoint ne change pas. Les tests qui mockent les repositories passent sans modification. Si un test casse, c'est qu'il sur-spécifiait l'ordre d'exécution (anti-pattern de test).
|
||||
|
||||
Cas vécu : `dashboardService.handleProchaineTenue` — 4 queries séquentielles → `Promise.all`. 240 ms → 170 ms (-29% local, -40 à -50% projeté prod réseau), 1526/1526 tests verts sans modification.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-cron-multi-instance-lock-redis"></a>
|
||||
## Pattern : Cron multi-instance idempotent via lock Redis `SET NX EX`
|
||||
|
||||
- Objectif : garantir qu'un `@Cron` ne s'exécute qu'**une seule fois** en environnement multi-pods, sans introduire de scheduler distribué (Bull, etc.).
|
||||
- Contexte : `@Cron` (`@nestjs/schedule`) déployé sur N instances avec effets de bord (notifications, mails de campagne).
|
||||
- Quand l'utiliser : tout cron avec effet de bord non idempotent en déploiement multi-instance.
|
||||
- Quand l'éviter : instance unique garantie, ou job purement idempotent.
|
||||
- Avantage :
|
||||
- aucune dépendance supplémentaire
|
||||
- auto-libération du lock si le pod crashe (TTL)
|
||||
- Limites / vigilance :
|
||||
- TTL > durée du job mais < intervalle entre deux exécutions
|
||||
- Redis down → renvoyer `false` (s'abstenir : ne pas risquer une double exécution d'effets de bord)
|
||||
- expliciter l'expression cron ET la timezone — les raccourcis `CronExpression.*` partent en UTC serveur
|
||||
- Validé le : 04-06-2026
|
||||
- Contexte technique : NestJS / `@nestjs/schedule` / Redis — app-alexandrie
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// Lock distribué : n'exécuter le corps QUE si SET NX renvoie 'OK'
|
||||
async acquireLock(key: string, ttlSec: number): Promise<boolean> {
|
||||
const res = await client.set(key, '1', { NX: true, EX: ttlSec });
|
||||
return res === 'OK';
|
||||
}
|
||||
|
||||
@Cron('0 8 * * 1', { timeZone: 'Europe/Paris' }) // expression + TZ explicites
|
||||
async runWeeklyJob() {
|
||||
if (!(await this.acquireLock('cron:weekly-job', 600))) return; // un autre pod a gagné, ou Redis down
|
||||
// ... corps du job ...
|
||||
}
|
||||
```
|
||||
|
||||
### Règle
|
||||
|
||||
Expliciter l'expression cron ET la timezone (`{ timeZone: 'Europe/Paris' }`) : les raccourcis `CronExpression.EVERY_WEEK` / `EVERY_DAY_AT_9AM` s'exécutent en UTC (heure serveur) → décalage vs l'intention métier.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-verrou-une-action-par-entite-updatemany"></a>
|
||||
## Pattern : Verrou atomique « une action par entité » via `updateMany` conditionnel
|
||||
|
||||
- Objectif : garantir qu'une action ne peut être déclenchée qu'**au plus une fois** par entité (relance, remboursement, envoi de mail de campagne, confirmation), résistant aux requêtes concurrentes.
|
||||
- Contexte : endpoint qui déclenche un effet de bord one-shot marqué par un champ (`sentAt`, `refundedAt`...).
|
||||
- Quand l'utiliser : tout flow « au plus une fois par entité » exposé à des appels concurrents.
|
||||
- Quand l'éviter : action naturellement idempotente côté effet de bord.
|
||||
- Distinction avec le lock Redis cron : ici le verrou est porté par la **DB** (champ d'état de l'entité, atomicité de l'`UPDATE ... WHERE`), pas par un lock Redis externe ; il protège une action par entité, pas une exécution unique de job multi-pods.
|
||||
- Avantage :
|
||||
- guard précoce (fast-path) qui évite le coût des requêtes intermédiaires pour les appels normaux
|
||||
- `updateMany` conditionnel atomique qui tranche la race condition
|
||||
- Limites / vigilance :
|
||||
- le `if (alreadySent) return 409` initial seul **ne suffit pas** : deux requêtes concurrentes le passent toutes les deux
|
||||
- `lock.count === 0` = une autre requête a gagné la course → 409
|
||||
- Validé le : 20-06-2026
|
||||
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// Guard précoce (optimistic fast-path, non atomique — suffit pour 99% des cas)
|
||||
if (entity.sentAt) return errorResponse(409, 'ALREADY_SENT', '...');
|
||||
|
||||
// ... traitement coûteux ...
|
||||
|
||||
// Verrou atomique : UPDATE conditionnel WHERE sentAt IS NULL
|
||||
const lock = await prisma.entity.updateMany({
|
||||
where: { id: entityId, sentAt: null },
|
||||
data: { sentAt: new Date() },
|
||||
});
|
||||
if (lock.count === 0) {
|
||||
return errorResponse(409, 'ALREADY_SENT', '...');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-persist-then-send-statut-apres-effet"></a>
|
||||
## Pattern : Effet de bord externe avec statut persistant — « persist-then-send »
|
||||
|
||||
- Objectif : ne pas faire mentir un statut métier qui reflète l'état d'un effet de bord externe, en cas d'échec partiel.
|
||||
- Contexte : opération qui (1) persiste une donnée nécessaire à une vérification ultérieure (token hash, idempotency key), (2) déclenche un effet externe best-effort (mail, webhook), (3) expose un statut (`pending|emailed|active`).
|
||||
- Quand l'utiliser : dès que le statut exposé doit refléter l'état RÉEL de l'effet externe et que ce dernier peut échouer.
|
||||
- Quand l'éviter : pas d'effet externe, ou statut sans valeur métier (rejouabilité non requise).
|
||||
- Avantage :
|
||||
- tout artefact émis reste traçable côté serveur (donnée vérifiable posée d'abord)
|
||||
- le statut reste `pending` (rejouable) si l'effet externe échoue
|
||||
- Limites / vigilance :
|
||||
- exige DEUX écritures (poser la donnée vérifiable, puis marquer le statut) — pas un seul `update` atomique
|
||||
- Validé le : 16-06-2026
|
||||
- Contexte technique : Prisma / Resend / Keycloak — RL799_V2
|
||||
|
||||
### Règle
|
||||
|
||||
Séparer en deux écritures : d'abord poser la donnée vérifiable SANS toucher au statut, PUIS — seulement si l'effet externe réussit — faire passer le statut. Ordre `persist token → send → mark status`.
|
||||
|
||||
```txt
|
||||
// ✅ token toujours vérifiable même si le mail rate ; statut = état réel (reste pending → rejouable)
|
||||
setOnboardingToken(...) // hash + expiresAt, statut INCHANGÉ
|
||||
await executeActionsEmail() // effet externe (attendre le 204)
|
||||
markOnboardingEmailed(...) // statut passé à 'emailed' APRÈS confirmation
|
||||
|
||||
// ❌ un seul update atomique avant l'envoi : le statut ment si Resend/Keycloak est down
|
||||
update({ status: 'emailed', tokenHash, expiresAt })
|
||||
```
|
||||
|
||||
Cas vécu : RL799 Lot B onboarding Keycloak — `setOnboardingToken` (statut inchangé) puis `markOnboardingEmailed` (statut après 204 confirmé de `executeActionsEmail`).
|
||||
|
||||
@@ -297,6 +297,15 @@ Le helper `requireRoleAccess` doit retourner :
|
||||
- [ ] 403 uniquement quand le token est valide mais le rôle insuffisant
|
||||
- [ ] Frontend redirige vers `/login` sur 401, affiche "accès refusé" sur 403
|
||||
|
||||
### Complément — 403 vs 404 sur ressource existante non autorisée (oracle d'existence)
|
||||
|
||||
Sur une ressource cloisonnée par appartenance (colonne, tenant, propriétaire), le choix 403 vs 404 est lui-même un risque d'énumération. Si l'ordre des contrôles est « ressource introuvable → 404 » PUIS « non autorisé → 403 », un attaquant distingue « la ressource existe mais pas pour moi » (403) de « n'existe pas » (404) → énumération.
|
||||
|
||||
- Pour les ressources cloisonnées par appartenance, renvoyer **404 uniforme** dans le chemin non-privilégié (introuvable ET hors-périmètre confondus).
|
||||
- Réserver le **403** aux cas où l'existence de la ressource est déjà publique.
|
||||
- Trade-off : erreurs client moins précises. Pertinence proportionnelle à la prévisibilité des IDs (négligeable sur UUID v4, réelle sur IDs séquentiels).
|
||||
- Cas vécu : consultation d'instruction hors-colonne RL799 → bascule 403 → 404.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-auth-dernier-admin-actif"></a>
|
||||
@@ -677,3 +686,282 @@ test('rejette un token signé avec un autre purpose', async () => {
|
||||
- Tokens d'accès aux ressources publiques limitées dans le temps
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-jose-whitelist-algorithme"></a>
|
||||
## Pattern : jose — whitelister explicitement l'algorithme (signature ET chiffrement)
|
||||
|
||||
- Objectif : empêcher les attaques de confusion d'algorithme en figeant l'algo accepté à la vérification/déchiffrement, et non en se fiant à ce que la clé permet.
|
||||
- Contexte : tout module qui vérifie (`jwtVerify`) ou déchiffre (`jwtDecrypt`) des tokens/sessions avec `jose` — OIDC, JWE de session BFF, tokens internes.
|
||||
- Quand l'utiliser : à chaque appel `jose` qui consomme un token signé ou chiffré.
|
||||
- Quand l'éviter : jamais sur une surface qui consomme des tokens externes ou attaquables.
|
||||
- Avantage :
|
||||
- bloque la confusion d'algorithme (un attaquant ne peut pas forcer un algo plus faible présent dans le JWKS ou supporté par la clé)
|
||||
- rend explicite le contrat cryptographique attendu de l'émetteur
|
||||
- Limites / vigilance :
|
||||
- l'`alg` est un contrôle à part entière — `iss`/`aud`/`exp` ne le couvrent pas
|
||||
- garder la whitelist synchronisée avec ce que l'IdP/l'émetteur produit réellement
|
||||
- Validé le : 13-06-2026
|
||||
- Contexte technique : jose / OIDC / JWE — RL799_V2
|
||||
|
||||
### Règle
|
||||
|
||||
`jwtVerify(token, jwks, { issuer, audience })` SANS `algorithms: [...]` accepte tout algo présent dans le JWKS → confusion d'algorithme. Idem `jwtDecrypt(jwe, key)` sans `keyManagementAlgorithms`/`contentEncryptionAlgorithms` accepte tout algo supporté par jose avec la clé fournie.
|
||||
|
||||
- À chaque `jwtVerify` : passer `algorithms: [<algo émis par l'IdP>]` (Keycloak = `['RS256']`).
|
||||
- À chaque `jwtDecrypt` : passer `keyManagementAlgorithms` + `contentEncryptionAlgorithms` figés sur ce que l'émetteur produit (ex. `['dir']` + `['A256GCM']`).
|
||||
- L'`alg` est le 4ᵉ contrôle obligatoire après `iss`/`aud`/`exp`. S'applique à tout (dé)chiffrement de tokens/sessions, pas seulement OIDC.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `algorithms: [...]` explicite sur chaque `jwtVerify`
|
||||
- [ ] `keyManagementAlgorithms` + `contentEncryptionAlgorithms` explicites sur chaque `jwtDecrypt`
|
||||
- [ ] Whitelist alignée sur l'émetteur réel
|
||||
- [ ] Test : un token signé/chiffré avec un autre algo est rejeté
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-membrane-auth-federee"></a>
|
||||
## Pattern : Membrane d'auth fédérée en coexistence — point de jonction unique
|
||||
|
||||
- Objectif : greffer un 2ᵉ système d'authentification (ex. Keycloak OIDC) à côté de l'auth maison sans la modifier, de façon rollback-safe et sans disperser l'AuthN/AuthZ.
|
||||
- Contexte : intégration d'un IdP fédéré en coexistence avec une auth existante, livrée par lots.
|
||||
- Quand l'utiliser : toute introduction d'un second mécanisme d'auth amené à coexister.
|
||||
- Quand l'éviter : remplacement immédiat sans phase de coexistence (cutover direct).
|
||||
- Avantage :
|
||||
- tout le code en aval ignore l'existence du 2ᵉ système (NFR « AuthN/AuthZ non dispersée »)
|
||||
- rollback par simple flag (zéro delta comportemental flag off)
|
||||
- invariant d'identité fédérée protège contre l'escalade de privilèges par claim forgé
|
||||
- Limites / vigilance :
|
||||
- la jonction devient async (chemin fédéré = déchiffrement + JWKS + DB) → propager `await` à tous les call sites
|
||||
- flag d'activation à lire à chaque requête, jamais memoizé
|
||||
- Validé le : 14-06-2026
|
||||
- Contexte technique : auth fédérée / OIDC / Keycloak BFF — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Un seul point de jonction** : `resolveAuthPayload(request)` traduit token → contexte métier `{ userId, email, role, offices }`. Tout le reste du code ignore le 2ᵉ système.
|
||||
2. **Discrimination par SOURCE, jamais par inspection du contenu** : cookie/header A → chemin maison ; cookie B → chemin fédéré. Si les deux coexistent, le plus conservateur (maison) GAGNE.
|
||||
3. **Flag d'activation fail-closed STRICT** : ON ssi `process.env.FLAG === 'true'` (toute autre valeur = OFF), lu à CHAQUE requête (jamais memoizé — sinon les tests qui togglent via `vi.stubEnv` deviennent ordre-dépendants ; seule la CONFIG est lazy-memoizée).
|
||||
4. **Invariant sacré de l'identité fédérée** : le validateur de token ne sort QUE l'identité (`sub` + `email` informatif), JAMAIS un claim d'autorisation (`realm_access.roles`, grade, office). `role`/`offices` dérivés EXCLUSIVEMENT de la DB locale. Test d'invariant obligatoire : forger un token avec `realm_access.roles: ['admin']` et vérifier qu'il n'accorde RIEN (403 sur route admin).
|
||||
5. **Migration des call sites async** : si la jonction rend les helpers d'auth async, propager `await` à TOUS les call sites par codemod mécanique guidé par `tsc`, et prouver le zéro-delta par la suite complète flag-off verte.
|
||||
|
||||
- Cas vécu : RL799 K1.1 (`authHelpers.ts::resolveAuthPayload`, 140 call sites migrés, test:api flag off vert).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-frontiere-session-bff-stateless"></a>
|
||||
## Pattern : Frontière de session BFF stateless — JWE `dir`+`A256GCM`, fail-closed
|
||||
|
||||
- Objectif : porter une session contenant des tokens (OIDC ou autres) dans un cookie httpOnly sans store serveur (pas de Redis/table).
|
||||
- Contexte : archi BFF où les tokens vivent côté serveur, chiffrés dans un cookie, jamais exposés au JS.
|
||||
- Quand l'utiliser : session stateless chiffrée portée par cookie.
|
||||
- Quand l'éviter : sessions à store serveur (Redis/table) déjà en place, ou besoin de révocation immédiate côté serveur.
|
||||
- Avantage :
|
||||
- équivalent iron-session sans dépendance supplémentaire si `jose` est déjà là
|
||||
- cookie altéré → échec de déchiffrement (AEAD), expiration vérifiée gratuitement par `jwtDecrypt`
|
||||
- frontière isolée : un seul module manipule les tokens bruts
|
||||
- Limites / vigilance :
|
||||
- taille du cookie à surveiller (< 4096 octets)
|
||||
- secret dédié durci, validé strictement
|
||||
- Validé le : 14-06-2026
|
||||
- Contexte technique : jose / cookie httpOnly / BFF — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Un SEUL module** (dé)chiffre et manipule les tokens bruts — vérifiable par `grep "from 'jose'"` limité à ce module + le validateur. Tout le reste reçoit du déjà-déchiffré.
|
||||
2. **JWE `alg: 'dir'` + `enc: 'A256GCM'`** (AEAD) avec `setIssuedAt`/`setExpirationTime` → l'`exp` du JWE donne l'expiration de session.
|
||||
3. **Whitelist explicite des algos au déchiffrement** (`keyManagementAlgorithms: ['dir']`, `contentEncryptionAlgorithms: ['A256GCM']`) — cf. [pattern jose — whitelist d'algorithme](#pattern-jose-whitelist-algorithme).
|
||||
4. **Secret DÉDIÉ par usage** (jamais le secret de chiffrement-au-repos), validé `/^[0-9a-f]{64}$/i` — PAS seulement `length === 64` (64 chars non-hex → Buffer < 32 bytes → erreur cryptique ; `A256GCM dir` exige exactement 32 bytes). Fail-fast avec hint de génération, lu lazy NON memoizé (testabilité).
|
||||
5. **Fail-closed asymétrique** : la clé est obtenue AVANT le try/catch (secret absent/malformé = erreur OPS → doit TRAVERSER → 500), MAIS toute erreur de déchiffrement/format/exp dans le try → `null` (cookie invalide, pas une panne) + log du TYPE d'échec (`decrypt_failed|expired|malformed`) SANS le contenu.
|
||||
6. **La frontière est une LIB** : elle ne fabrique jamais de `Response` — la couche jonction traduit `null` en 401.
|
||||
7. Logger via le logger structuré du projet (pino), pas `console.*`.
|
||||
|
||||
- Cas vécu : RL799 K1.2 `lib/keycloak/session.ts` (cookie ~3 ko < 4096, TTL 8 h aligné Max-Age + exp JWE).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-pont-identite-federee"></a>
|
||||
## Pattern : Pont d'identité fédérée — colonne `idpSub` nullable + finder fail-safe
|
||||
|
||||
- Objectif : relier un identifiant opaque d'IdP (le `sub` OIDC) à un `User` local, en posant d'abord l'interface (stub) puis la résolution réelle quand le découpage en lots l'impose.
|
||||
- Contexte : série « membrane d'auth fédérée » — liaison `sub IdP → User` local sans toucher la couche consommatrice.
|
||||
- Quand l'utiliser : besoin d'un mapping identité externe → user local, livré par lots.
|
||||
- Quand l'éviter : pas de fédération d'identité, ou liaison déjà existante.
|
||||
- Avantage :
|
||||
- migration purement additive, back-fill incrémental possible
|
||||
- shape figé par la jonction → toute divergence casse la jonction au typecheck
|
||||
- aucune fuite de l'`idpSub` (clé de liaison interne)
|
||||
- Limites / vigilance :
|
||||
- colonne nullable tant que le cutover n'est pas complet (NOT NULL seulement après)
|
||||
- le finder ne décide pas de l'accès — il remonte l'état brut
|
||||
- Validé le : 14-06-2026
|
||||
- Contexte technique : Prisma / OIDC / repository — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Migration purement additive** : `ADD COLUMN <sub> TEXT` + `CREATE UNIQUE INDEX`, **nullable, pas de NOT NULL, pas de DEFAULT, pas de back-fill**. Les comptes pré-fédération restent NULL (Postgres tolère plusieurs NULL sous un index `@unique` → back-fill incrémental). Le NOT NULL viendrait seulement après cutover complet.
|
||||
2. **Le finder vit au repository** (`route → service → repository`), retourne EXACTEMENT le shape figé par la couche jonction (ex. `{ id, email, role, isActive }`), via `select` minimal — jamais le mapper complet du record.
|
||||
3. **Fail-safe** : try/catch → `null` (une panne DB ne doit pas 500 le chemin authentifié).
|
||||
4. **Ne filtre PAS sur l'état actif** : retourne `isActive` tel quel — la décision d'accepter/rejeter appartient à la jonction (source unique).
|
||||
5. **Sens d'import** : la lib (subResolver) importe la VALEUR du finder ; le repository importe le TYPE du contrat en `import type` (effacé à la compilation → pas de cycle runtime).
|
||||
6. **No-leak** : l'`idpSub` est une clé de liaison interne — jamais en réponse API, jamais loggé. Le `userId` exposé en aval est l'`id` LOCAL, jamais le `sub`.
|
||||
7. **Prouver la levée du stub par un test E2E qui n'injecte PAS** le resolver de test (laisse le resolver de prod réel) : poser un `sub` sur un user de seed, forger un token, flag on → résolution DB réelle. Sans ce test, on prouve seulement le finder, pas le câblage.
|
||||
|
||||
- Cas vécu : RL799 K1.3 `findUserByKeycloakSub` + `User.keycloakSub`.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-greffe-effet-de-bord-regalien"></a>
|
||||
## Pattern : Greffe d'effet de bord externe best-effort sur une opération régalienne
|
||||
|
||||
- Objectif : déclencher un effet de bord EXTERNE non-critique (provisionner une identité IdP, notifier un tiers) depuis une mutation locale CRITIQUE sans jamais coupler leur succès.
|
||||
- Contexte : mutation régalienne (créer un membre) qui doit déclencher un appel externe non-bloquant.
|
||||
- Quand l'utiliser : tout effet de bord externe non-critique greffé sur une opération locale qui ne doit jamais échouer.
|
||||
- Quand l'éviter : effet de bord faisant partie de la transaction logique (audit régalien — cf. [pattern audit best-effort vs régalien](#pattern-auth-audit-best-effort-vs-regalien)).
|
||||
- Avantage :
|
||||
- l'opération locale réussit même si l'effet de bord échoue
|
||||
- reprovisionnable au rejeu (idempotent), zéro doublon
|
||||
- rollback par flag (zéro appel réseau flag off)
|
||||
- Limites / vigilance :
|
||||
- timeout interne obligatoire pour ne pas retarder la réponse régalienne
|
||||
- service account dédié à périmètre minimal, secrets jamais loggés
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : auth fédérée / provisioning IdP / fetch — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Architecture en couches stricte** : `clientBas-niveau` (HTTP/fetch isolé, zéro logique métier) ← `serviceOrchestration` (chercher-avant-créer, fail-safe, écriture DB) ← greffe dans le service régalien. Jamais d'appel HTTP direct depuis le service métier.
|
||||
2. **Fail-safe TOTAL au service** : try/catch englobant → retourne `null` (jamais de throw vers l'appelant régalien), log + audit du statut. L'entité « non-provisionnée » est reprovisionnable au rejeu.
|
||||
3. **Idempotent par chercher-avant-créer** : interroger l'externe par clé naturelle (email `exact=true`) avant de créer ; réutiliser l'existant.
|
||||
4. **Timeout interne obligatoire** (`AbortController`, ex. 5 s) : un externe qui pend ne doit pas bloquer la réponse régalienne. Placer la greffe APRÈS les autres effets de bord prioritaires (ex. envoi de mail).
|
||||
5. **Token machine-to-machine memoizé avec marge d'expiration** (client credentials), pas un fetch par appel. Service account DÉDIÉ à périmètre minimal (`manage-users`), distinct du client d'auth utilisateur.
|
||||
6. **Minimisation des données** vers l'externe : body strictement limité à l'identité d'auth, zéro donnée métier sensible (invariant RGPD). Tester par assertion sur les clés exactes du payload.
|
||||
7. **Flag-gated `=== 'true'` lu à chaque requête** : flag off = zéro appel réseau, zéro branche, zéro delta.
|
||||
8. **Secrets jamais loggés** (token, client_secret) ; ne logger que des identifiants non-sensibles (email, userId, statut).
|
||||
|
||||
- Cas vécu : RL799 K1.4 `provisionMemberIdentity` greffé sur `handleCreateMember`, fetch + token injectables pour tests (aucun IdP vivant en CI).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-refresh-session-bff-serveur"></a>
|
||||
## Pattern : Refresh de session BFF 100 % serveur — signal `Set-Cookie` remonté à la frontière
|
||||
|
||||
- Objectif : rafraîchir l'access token expiré côté serveur, dans le point de jonction d'auth, sans que la couche LIB (sans accès à la `Response`) ne fabrique elle-même le cookie.
|
||||
- Contexte : archi BFF où les tokens OIDC sont chiffrés dans un cookie httpOnly, jamais exposés au JS.
|
||||
- Quand l'utiliser : refresh d'une session BFF stateless portée par cookie.
|
||||
- Quand l'éviter : session à store serveur où la rotation se fait côté store.
|
||||
- Avantage :
|
||||
- la jonction reste une LIB (pas de fabrication de `Response`)
|
||||
- re-login transparent côté front quand le SSO IdP est encore actif
|
||||
- aucun token exposé au JS
|
||||
- Limites / vigilance :
|
||||
- la réécriture du cookie doit être généralisée (cf. risque rotation refresh token IdP)
|
||||
- convention d'unité epoch en SECONDES (OIDC) au recalcul de `expiresAt`
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : openid-client / cookie httpOnly / BFF — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **La jonction remonte un champ `sessionCookieToApply?: string`** dans son contexte de retour — le `Set-Cookie` du nouveau cookie chiffré, calculé après refresh réussi. La LIB ne fabrique pas de cookie, elle REMONTE un signal.
|
||||
2. **Un handler de frontière l'attache** (`headers.append('Set-Cookie', ...)`). Point d'application : le handler d'HYDRATATION systématique post-login (`/me` / `/session`) appelé à chaque boot par les router guards.
|
||||
3. **Marge de refresh anticipé** (~30 s) : rafraîchir un token qui expire dans < marge, pour couvrir le temps de traitement + dérive d'horloge.
|
||||
4. **Fail-closed** : le helper de refresh retourne `null` sur tout échec (refresh token mort/révoqué, panne IdP), jamais de throw. La jonction mappe `null` → 401 code distinct (`SESSION_EXPIRED`) + purge du cookie zombie (clear immédiat, sinon le cookie mort reboucle).
|
||||
5. **Convention d'unité epoch en SECONDES** (OIDC) au recalcul de `expiresAt` post-refresh — un mélange s/ms est un bug classique coûteux.
|
||||
6. **Invariant minimisation** : après refresh, repasser par le validateur de token qui n'extrait QUE l'identité (sub/email). `role`/`permissions` viennent TOUJOURS de la DB.
|
||||
7. **Distinguer deux codes 401** : « access token expiré mais rattrapable par refresh » (transparent, pas remonté au front) vs « session non rafraîchissable » (`SESSION_EXPIRED` → re-login transparent côté front).
|
||||
8. **Hook de test injectable du grant** qui ENCAPSULE la résolution de config (discovery réseau) côté prod : un grant de test ne touche jamais le réseau. Tester sur le CODE DE PRODUCTION réel (jonction + handler `/me` + tokens RS256 forgés), pas sur des mocks.
|
||||
|
||||
- Cas vécu : RL799 K1.5 `refreshKeycloakSession` + `resolveAuthPayload` + `sessionCookieToApply` consommé par `handleGetSession`, openid-client v6 `refreshTokenGrant`.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-cutover-auth-irreversible"></a>
|
||||
## Pattern : Cutover d'auth irréversible — isoler le point de non-retour hors du chemin auto
|
||||
|
||||
- Objectif : livrer une bascule destructive (retrait d'un système d'auth legacy au profit d'un nouveau) en gardant tout réversible sauf les 2-3 actions vraiment irréversibles, isolées derrière une gate et un prérequis ops.
|
||||
- Contexte : migration destructive — retrait d'un système legacy, `ALTER COLUMN SET NOT NULL` après backfill, `DROP TABLE`.
|
||||
- Quand l'utiliser : toute migration où le rollback cesse d'être possible une fois exécutée.
|
||||
- Quand l'éviter : changements purement additifs et réversibles.
|
||||
- Avantage :
|
||||
- le code livré reste réversible via git tant que l'irréversible n'est pas exécuté
|
||||
- aucun `migrate deploy` ne franchit le point de non-retour par accident
|
||||
- gate GO/NO-GO + runbook documenté
|
||||
- Limites / vigilance :
|
||||
- prérequis ops hors-code = bloquant explicite, story marquée NON-MERGEABLE si non vérifiable par l'agent
|
||||
- vérifier en review les 3 risques d'un retrait d'auth
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : migration destructive / Prisma / cutover — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Séparer le réversible de l'irréversible.** Tout le code (retrait des branches legacy, nouveaux chemins) est livré et vert — réversible via git. Les 2-3 actions VRAIMENT irréversibles (migration `NOT NULL`, `DROP TABLE`) sont placées en **migration DRAFT HORS du dossier que l'outil applique automatiquement** (ex. `docs/infra/cutover-migration-draft/migration.sql`, PAS `prisma/migrations/`). Le schéma déclaré reste réversible (`String?`).
|
||||
2. **Gate bloquante AVANT l'irréversible** : un script lecture-seule (`cutover-check`) qui vérifie les pré-conditions (0 orphelin cassé par la contrainte) et émet GO/NO-GO. Ordre IMPOSÉ et documenté en runbook : prérequis ops → gate verte → migration → retrait final. Jamais inverser.
|
||||
3. **Prérequis ops hors-code = bloquant documenté** : si la bascule dépend d'une capacité externe (config realm IdP, SMTP, service account) que l'agent NE PEUT PAS vérifier, marquer la story explicitement NON-MERGEABLE et le tracer. Ne pas présumer « code vert = prêt à merger ».
|
||||
4. **Vérifier en review les 3 risques d'un retrait d'auth** : (a) tout hook/scaffolding de test sur le chemin d'auth est STRUCTURELLEMENT inerte en prod (call-site unique dans le setup de test, `null` au module-level, chargé seulement par le runner — grep exhaustif des call-sites) ; (b) retrait NET prouvé par grep 0-hit des primitives legacy (`createToken|verifyToken|verifyPassword`) en code de prod ; (c) le point de non-retour est bien hors du chemin auto.
|
||||
|
||||
- Cas vécu : RL799 K1.8 cutover Keycloak — migration NOT NULL+DROP en draft hors-Prisma, gate `runCutoverGate`, prérequis FR10 realm bloquant, hook resolver de test inerte vérifié.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-surface-publique-token-opaque"></a>
|
||||
## Pattern : Surface publique authentifiée par token opaque (quick-link mail, sans login)
|
||||
|
||||
- Objectif : exposer une surface publique non authentifiée par un token opaque (quick-link reçu par mail) en durcissant systématiquement contre l'énumération, le prefetch et la fuite de données.
|
||||
- Contexte : route accessible sans login via un token de mail (présence-sans-friction, instruction sans-friction, RSVP).
|
||||
- Quand l'utiliser : toute surface publique à token opaque.
|
||||
- Quand l'éviter : authentification membre classique — utiliser refresh + access httpOnly.
|
||||
- Avantage :
|
||||
- identité prouvée par possession du token, éligibilité re-vérifiée à chaque requête
|
||||
- pas d'oracle d'énumération (réponse neutre indistinguable)
|
||||
- GET read-only protège contre le prefetch (SafeLinks Outlook)
|
||||
- Limites / vigilance :
|
||||
- mapper de sortie dédié obligatoire (le mapper interne fuit des champs)
|
||||
- rate-limiter dédié à enregistrer dans le registre central de reset
|
||||
- Validé le : 23-06-2026
|
||||
- Contexte technique : surface publique / token opaque / mail — RL799_V2
|
||||
|
||||
### Checklist de durcissement (validée 2× sur RL799)
|
||||
|
||||
1. **Token opaque = identité, JAMAIS éligibilité.** Stocker `sha256(token)` en DB (jamais le clair), lookup par hash sur index unique. Le clair ne vit qu'en mémoire au dispatch.
|
||||
2. **Garde partagée GET+POST** (`resolveDeliveryAndGuards`) en union discriminée `{ ok: true, ... } | { ok: false, code }` — un seul point de revalidation appelé par les deux verbes (DRY + cohérence).
|
||||
3. **Re-valider l'éligibilité à CHAQUE requête** (membre actif + grade/colonne), pas seulement à l'émission du token — bloque la pollution après changement d'état.
|
||||
4. **401 NEUTRE indistinguable** : token forgé ET inéligibilité membre → MÊME code + MÊME message (constante `NEUTRAL_TOKEN_MESSAGE`). Pas d'oracle d'énumération RGPD. La distinction est tracée UNIQUEMENT côté serveur (`logSecurityEvent`). À TESTER : `expect(messageMismatch).toBe(messageForged)`.
|
||||
5. **Mapper de sortie DÉDIÉ minimal** — ne JAMAIS réutiliser le mapper interne (qui fuite `organizerId`/agrégat/id). À TESTER par clés EXACTES : `expect(Object.keys(data).sort()).toEqual([...])` + boucle sur les champs interdits.
|
||||
6. **GET strictement read-only** (anti-prefetch SafeLinks Outlook) ; mutation seulement au POST (clic explicite). À TESTER : un GET n'écrit rien en DB.
|
||||
7. **Rate-limiter IP dédié**, AJOUTÉ au registre central de reset (sinon flakiness inter-fichiers).
|
||||
8. **Domaine d'erreur dédié** (pas de réutilisation d'un code d'un autre domaine public).
|
||||
9. **Route fine** (export GET/POST → handler thin → service). Aucune logique dans la route.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-rbac-derive-source-unique"></a>
|
||||
## Pattern : RBAC dérivé d'une source de vérité — JWT enrichi + union au guard
|
||||
|
||||
- Objectif : dériver une autorité (rôle/capacité) d'une source unique (mandat, relation) sans la dupliquer ni introduire de fenêtre d'incohérence.
|
||||
- Contexte : un rôle/accès doit dériver d'une entité (ex. `OfficerMandate`) sans devenir une source concurrente.
|
||||
- Quand l'utiliser : autorité dérivable d'une relation, mutation de la source rare.
|
||||
- Quand l'éviter : besoin de fraîcheur immédiate (la source doit se propager en < TTL du token).
|
||||
- Avantage :
|
||||
- NFR « pas de fenêtre d'incohérence » satisfaite par construction (source lue, jamais dupliquée comme rôle séparé)
|
||||
- cumul des autorités préservé
|
||||
- reste zéro-DB au guard
|
||||
- Limites / vigilance :
|
||||
- un changement de la source met jusqu'à la durée de vie du token (ex. 15 min) à se propager — acceptable si la mutation est rare
|
||||
- Validé le : 12-06-2026
|
||||
- Contexte technique : auth / JWT / RBAC dérivé — RL799_V2
|
||||
|
||||
### Règle
|
||||
|
||||
NE PAS écrire le rôle dérivé dans la table user (duplication + fenêtre d'incohérence à gérer). À la place :
|
||||
1. calculer les attributs dérivés au login/refresh via un helper unique, les injecter dans le JWT à côté du rôle de base ;
|
||||
2. au guard, évaluer l'union `{rôle de base} ∪ {attributs dérivés}` — reste zéro-DB.
|
||||
|
||||
Trade-off assumé : un changement de la source met jusqu'à la durée de vie du token à se propager.
|
||||
|
||||
### Stratégie de migration sans coupure (app live)
|
||||
|
||||
Basculer par ADDITION : backfill source → enrichir le token en coexistence → guards en union sur-ensemble → réduire l'enum en DERNIER (le typecheck servant de filet d'exhaustivité).
|
||||
|
||||
- Cas vécu : refonte RBAC RL799 (offices dérivés des mandats).
|
||||
|
||||
---
|
||||
|
||||
@@ -75,6 +75,7 @@ packages/contracts/src/
|
||||
- Types inférés (`z.infer<>`)
|
||||
- Codes d'erreur applicatifs stables
|
||||
- Enums et constantes partagées (ex : liste officielle de sujets/topics)
|
||||
- **Tout texte business affichable dont backend ET client/mail/PDF partagent le contrôle** : motif d'erreur, label de statut, message de refus métier doit transiter par le contrat, sous forme de constante `as const` (ex : `DM_ELIGIBILITY_MESSAGES`). Le backend l'utilise dans `HttpException(...)`, le client dans sa fonction `getXxxCopy`. Anti-pattern : deux dictionnaires de strings parallèles (un dans le service Nest, un dans le module mobile) qui divergent au premier changement de wording. Ne s'applique PAS aux textes purement UI (titres de boutons, libellés de formulaire) qui appartiennent au client seul.
|
||||
|
||||
### Ce qui n'appartient PAS à contracts
|
||||
|
||||
@@ -298,6 +299,22 @@ test('PATCH .strict() rejette les champs hors-whitelist', async () => {
|
||||
- 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
|
||||
|
||||
### Sous-règle — `.optional()` uniquement si le producteur peut réellement omettre le champ
|
||||
|
||||
`.optional()` sur un schéma Zod doit **refléter la réalité du producteur**, jamais servir de filet pour la rétrocompatibilité des fixtures de tests. Si le serveur projette toujours le champ (valeur par défaut comprise), le schéma ne doit PAS le marquer `.optional()`.
|
||||
|
||||
```ts
|
||||
// ❌ Trompeur : le serveur projette toujours ces champs
|
||||
visibilityStatus: VisibilityStatusSchema.optional(),
|
||||
placeholderLabel: z.string().nullable().optional(),
|
||||
|
||||
// ✅ Honnête : reflète ce que le serveur retourne
|
||||
visibilityStatus: VisibilityStatusSchema,
|
||||
placeholderLabel: z.string().nullable(),
|
||||
```
|
||||
|
||||
Pourquoi : un `.optional()` permissif infère `T | undefined` → on est forcé d'écrire `x ?? 'DEFAULT'` partout côté client inutilement ; et il masque les drifts de fixtures (un objet construit en omettant le champ, ou avec un nom de champ **différent** comme `isAutoHidden: false` au lieu de `visibilityStatus: 'VISIBLE'`, passe le type-check). Méthode : pour chaque champ, demander « le producteur peut-il LÉGITIMEMENT omettre ce champ ? » — si non, pas de `.optional()` ; si oui (rétrocompat lecture seule), documenter la raison en commentaire. Sur app-alexandrie story 8.2, durcir 3 schémas a révélé un champ fantôme `isAutoHidden` côté store mobile (14 erreurs TS en cascade).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-enum-canonique-sous-ensembles-nommes"></a>
|
||||
@@ -480,3 +497,109 @@ const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
|
||||
- [ ] 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)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-typer-strict-a-la-source"></a>
|
||||
## Pattern : Typer strict à la source, pas au call-site
|
||||
|
||||
- Objectif : faire propager automatiquement le typage strict d'un symbole contraint à tous ses consommateurs, et attraper les drifts au compilateur là où ils naissent.
|
||||
- Contexte : symbole sémantiquement contraint (enum fermé, union de littéraux, identifiant typé : `UserRole`, `NotificationType`, `SoireeStatus`) déclaré comme `string` à la source.
|
||||
- Quand l'utiliser : dès qu'une constante, un retour de helper ou un champ DTO porte une valeur d'un type contraint.
|
||||
- Quand l'éviter : à la frontière externe (entrée HTTP, payload JSON brut, requête SQL raw) où la valeur est forcément `unknown`/`string` — on cast explicitement après validation ; ou quand le type strict crée une dépendance circulaire (rare).
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : TypeScript — RL799_V2 (chantier durcissement UserRole)
|
||||
|
||||
### Règle
|
||||
|
||||
Tout symbole sémantiquement contraint doit être déclaré avec son **type le plus strict à la source de définition**, pas au call-site qui en a besoin. Sinon le caller doit caster (`as UserRole`) et le bug ne sort qu'au moment où un code aval impose le typage strict — pas à la source du drift.
|
||||
|
||||
Exemples : `Set<UserRole>` plutôt que `Set<string>` pour les constantes RBAC ; retour `role: UserRole` plutôt que `role: string` pour les helpers d'auth ; `status: SoireeStatus` plutôt que `status: string` dans les DTOs.
|
||||
|
||||
### Anti-pattern vs pattern
|
||||
|
||||
```ts
|
||||
// ❌ Drift silencieux + cast à chaque call-site
|
||||
export const ROLES_ADMIN: ReadonlySet<string> = new Set(['admin']);
|
||||
if (ROLES_ADMIN.has(auth.role as UserRole)) { /* cast nécessaire ailleurs */ }
|
||||
|
||||
// ✅ Type fort propagé partout
|
||||
export const ROLES_ADMIN: ReadonlySet<UserRole> = new Set<UserRole>(['admin']);
|
||||
if (ROLES_ADMIN.has(auth.role)) { /* OK si auth.role: UserRole */ }
|
||||
```
|
||||
|
||||
### Bénéfice mesurable
|
||||
|
||||
Une seule modification à la source propage le typage strict à tous les call-sites. Sur RL799_V2, typer 15 constantes `ROLES_*` en `Set<UserRole>` a fait gagner le typage strict à 9 fichiers consommateurs et révélé un bug latent (`canPublishCommunicationType` qui recevait `auth.role: string`), résolu structurellement.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-audience-prop-templates"></a>
|
||||
## Pattern : Prop `audience` pour templates mail/PDF multi-cibles
|
||||
|
||||
- Objectif : servir plusieurs audiences (membre, visiteur…) depuis une seule source de vérité de template, sans dupliquer le fichier et donc sans drift entre versions.
|
||||
- Contexte : template de rendu (mail HTML, PDF) qu'on étend pour une 2ᵉ audience alors que < 40 % du contenu diverge.
|
||||
- Quand l'utiliser : tant que la divergence de contenu reste faible (< ~40 %) — un changement d'identité, signature, format de date propage alors automatiquement à toutes les audiences.
|
||||
- Quand l'éviter : au-dessus de ~40 % de divergence — dupliquer le template et extraire un partial commun devient plus maintenable que des conditions dispersées.
|
||||
- Validé le : 13-05-2026
|
||||
- Contexte technique : React Email / PDF templates / NestJS — RL799_V2 (chantier convocation visiteurs)
|
||||
|
||||
### Règle
|
||||
|
||||
1. Ajouter une prop `audience: 'audienceA' | 'audienceB'` à la source du template. Défaut = audience historique (rétrocompat : appelants existants sans la prop rendent la version originale).
|
||||
2. Ajouter les URLs/booleans spécifiques à chaque audience en props **optionnelles** (`visitorRegistrationUrl?`, `unsubscribeUrl?`) pour ne pas casser l'audience historique.
|
||||
3. Conditionner via un `const isVisitor = audience === 'visitor'` en tête de composant : heading, CTA principal, sections opt-out/désabonnement.
|
||||
4. Sortir des **chemins de stockage distincts** pour les artefacts persistants (`{tenueDir}/{grade}.pdf` membre vs `{tenueDir}/visitor-{grade}.pdf` visiteur) — évite l'écrasement quand les deux audiences sont servies pour la même entité.
|
||||
5. Factoriser la construction des props dans un builder commun (`buildConvocationProps({ audience, ... })`) qui applique les règles spécifiques.
|
||||
6. Tests : assertions ciblées par audience dans le même fichier, sections délimitées, avec assertions positives (présence) ET négatives (absence des éléments réservés à l'autre audience).
|
||||
|
||||
### Trade-off
|
||||
|
||||
Compter la ligne de divergence vs la ligne de partage avant d'appliquer. Sous ~40 %, la prop `audience` tient ; au-dessus, dupliquer + extraire un partial.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-verbe-http-marker-idempotent"></a>
|
||||
## Pattern : Verbe HTTP pour endpoint « marker » idempotent (set boolean sur ressource existante)
|
||||
|
||||
- Objectif : trancher une fois pour toutes `POST` vs `PATCH` sur un endpoint qui set un champ booléen sur une ressource déjà créée (ex : `POST/PATCH /users/me/onboarding/complete`).
|
||||
- Contexte : endpoint « marker » qui passe un champ existant `false → true` (`User.onboardingCompleted`).
|
||||
- Quand l'utiliser : dès que l'action met à jour un champ d'une row déjà existante.
|
||||
- Quand l'éviter : action qui crée une nouvelle ressource, déclenche un side-effect métier complexe (envoi email, paiement) ou n'a pas de mapping naturel sur une ressource → `POST`.
|
||||
- Validé le : 28-05-2026
|
||||
- Contexte technique : NestJS / contrat API — app-alexandrie (review IA-v2.7)
|
||||
|
||||
### Règle
|
||||
|
||||
Critère de décision : « est-ce que j'update un champ d'une row existante ? » → **PATCH** (mise à jour partielle, sémantique REST). **POST** réservé à la création / au side-effect métier.
|
||||
|
||||
Bonus cohérence : si le controller a déjà `PATCH /me/handle`, `PATCH /me/topics`, aligner `PATCH /me/onboarding/complete` plutôt qu'introduire un `POST` exotique au milieu.
|
||||
|
||||
Réponse : `204 No Content` quand il n'y a pas de payload de retour utile.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-endpoint-distinct-vs-force"></a>
|
||||
## Pattern : Endpoint distinct pour intention divergente (vs paramètre `force`)
|
||||
|
||||
- Objectif : exposer une opération qui viole un invariant protecteur d'un endpoint existant sans polluer cet endpoint d'un paramètre `force/bypass`.
|
||||
- Contexte : endpoint REST avec un invariant protégeant l'utilisateur d'une opération accidentelle (rétrogradation de statut, suppression silencieuse), et besoin légitime d'exposer l'opération inverse.
|
||||
- Quand l'utiliser : dès qu'on est tenté d'ajouter un param `force/skipChecks/admin/bypass` à un endpoint pour contourner son propre invariant.
|
||||
- Quand l'éviter : si l'invariant n'existe pas (l'endpoint accepte déjà nativement les deux intentions).
|
||||
- Validé le : 29-05-2026
|
||||
- Contexte technique : NestJS / contracts Zod — app-alexandrie (ux-cleanup-7)
|
||||
|
||||
### Règle
|
||||
|
||||
Un paramètre `force` oblige le client à comprendre qu'il bypass un invariant interne, la doc à expliquer « pourquoi force ? », le service à porter un `if (payload.force)`, et casse la lisibilité des audit logs. Préférer un **endpoint dédié** dont le verbe HTTP porte la sémantique.
|
||||
|
||||
```typescript
|
||||
// ❌ Anti-pattern : param `force` qui pollue la sémantique
|
||||
POST /content/:id/consumption { state: 'NOT_STARTED', force: true }
|
||||
|
||||
// ✅ Pattern : endpoint dédié à l'intention « reset »
|
||||
POST /content/:id/consumption { state: 'COMPLETED' } // markConsumed — idempotent, jamais dégrade
|
||||
DELETE /content/:id/consumption // resetConsumption — volontaire, dégrade explicitement
|
||||
```
|
||||
|
||||
Bénéfices : le verbe HTTP porte la sémantique destructive ; le service expose 2 méthodes distinctes (`markConsumed`, `resetConsumption`) avec invariants préservés ; les audit logs distinguent immédiatement les 2 opérations ; les contracts Zod restent propres (pas de discriminated union artificielle). L'effort marginal (5-10 lignes) est compensé par une clarté permanente.
|
||||
|
||||
@@ -597,3 +597,278 @@ Trigger : `workflow_run` du workflow `Tests` quand vert sur main, OU `workflow_d
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-controle-acces-conditionnel-contexte-metier"></a>
|
||||
## Pattern : Contrôle d'accès conditionnel par contexte métier (pure logic shared)
|
||||
|
||||
- Objectif : décider l'accès à une ressource selon une **capacité dérivée** du user (pas seulement son rôle/grade) + un **contexte d'usage** porté par la ressource, avec une logique unique et testable, partagée front et back.
|
||||
- Contexte : ressource (typiquement un Document) avec accès inconditionnel pour certains rôles de curation (archiviste/admin) ET accès conditionnel pour les autres, basé sur une capacité du user (`canInvestigate`, `canTreasure`, …) et un contexte porté par la ressource (`'enquete:template'`, `'officier:mdc:memento'`).
|
||||
- Quand l'utiliser : l'accès dépend d'un contexte métier extensible et doit être calculable côté frontend (afficher/masquer un bouton sans round-trip) ET côté backend (gate d'autorisation).
|
||||
- Quand l'éviter : accès purement rôle/grade → un helper RBAC standard suffit.
|
||||
- Avantage :
|
||||
- une seule source de vérité, pas de divergence front/back possible
|
||||
- extensible sans cascade : un nouveau contexte = un `case` dans le `switch` shared
|
||||
- testable en pure logic (matrice de tests sans Prisma ni mocks)
|
||||
- allow-list strict : contexte non implémenté = `false` par défaut (pas de fuite d'autorisation)
|
||||
- Limites / vigilance :
|
||||
- défense en profondeur obligatoire : gater AUSSI le listing (`GET /documents?type=...`), pas seulement le `view`, sinon la ressource leak via la liste
|
||||
- `usageContext String?` simple en V1 ; migrer vers `String[]` seulement si un match multiple devient nécessaire (anticiper en `String[]` est de la sur-ingénierie)
|
||||
- n'envisager un service générique paramétré (`canUserViewContextualResource`) qu'à partir du 3ᵉ usage
|
||||
- Validé le : 06-05-2026
|
||||
- Contexte technique : Prisma / packages shared / Next.js + Vue — RL799_V2
|
||||
|
||||
### Forme du pattern
|
||||
|
||||
**1. Sur la ressource : champ `usageContext`** (NULL = pas de gate contextuelle, la ressource utilise les gates standard de son type).
|
||||
|
||||
```prisma
|
||||
model X {
|
||||
/// Contexte métier qui conditionne l'accès. NULL = pas de gate contextuelle.
|
||||
usageContext String? @map("usage_context")
|
||||
}
|
||||
```
|
||||
|
||||
**2. Service shared `canUserViewX(ctx, resource)` — pure logic**, exporté depuis `packages/shared` pour être consommé par l'API et le frontend.
|
||||
|
||||
```typescript
|
||||
export type XViewerContext = {
|
||||
role: UserRole;
|
||||
capabilities: { canInvestigate: boolean /* … */ };
|
||||
};
|
||||
|
||||
export function canUserViewX(
|
||||
ctx: XViewerContext,
|
||||
resource: { type: string; usageContext: string | null },
|
||||
): boolean {
|
||||
if (resource.type !== 'X_TYPE_GATED') return true; // hors scope gate
|
||||
if (ctx.role === 'archiviste' || ctx.role === 'admin') return true; // curation
|
||||
if (!resource.usageContext) return false; // orpheline → curation-only
|
||||
switch (resource.usageContext) {
|
||||
case 'enquete:template':
|
||||
return ctx.capabilities.canInvestigate;
|
||||
default:
|
||||
return false; // allow-list strict
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Côté API** : la gate appelle `canUserViewX` dans le handler de view → `403 FORBIDDEN` si refusé.
|
||||
**4. Côté frontend** : la même fonction calcule le rendu conditionnel (`v-if="canSeeHelper"`), zéro round-trip.
|
||||
|
||||
### Anti-patterns à éviter
|
||||
|
||||
- Logique backend-only : force le frontend à un round-trip pour savoir s'il affiche un bouton.
|
||||
- Champ `accessRoles: string[]` sur la ressource : ne capture pas une capacité dérivée (`canInvestigate` n'est pas un rôle).
|
||||
- `switch` éparpillé dans plusieurs handlers : centraliser dans un seul service shared.
|
||||
- Gate uniquement sur `view` sans gater le listing : fuite par la liste.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-safe-path-resolution-streaming-db-driven"></a>
|
||||
## Pattern : Safe path resolution pour streaming de fichiers DB-driven (anti path traversal)
|
||||
|
||||
- Objectif : empêcher un path traversal (`../../../etc/passwd`, chemin absolu hors racine) lors du streaming d'un fichier dont le chemin est stocké en DB, même si la requête vient d'un user authentifié et autorisé.
|
||||
- Contexte : endpoint qui streame un fichier disque dont le path vient de la DB (`Document.filePath`, `ConvocationIssue.pdfPathsByGrade`, …), en particulier quand le champ est un JSON libre côté Prisma que Zod ne peut pas valider strictement à l'écriture.
|
||||
- Quand l'utiliser : tout streaming de fichier dont le chemin n'est pas une constante du code.
|
||||
- Quand l'éviter : fichier servi depuis un chemin entièrement contrôlé par le code (constante, pas d'entrée DB).
|
||||
- Avantage :
|
||||
- défense en profondeur : protège même si la DB est compromise (row corrompue, migration foireuse, accès SQL direct d'un attaquant)
|
||||
- re-validation à la LECTURE qui couvre les rows insérées avant l'existence d'un schéma Zod
|
||||
- Limites / vigilance :
|
||||
- retourner `500` (+ `console.error`) et non `404` : un path hors racine est un bug applicatif/compromission, pas une erreur user — à surveiller via les logs prod
|
||||
- utiliser `path.relative`, **jamais** `String.startsWith(ROOT)` (casse avec les liens symboliques)
|
||||
- un blocage Zod en amont est insuffisant seul : Zod valide à l'écriture, pas à la lecture
|
||||
- Validé le : 06-05-2026
|
||||
- Contexte technique : Node.js / Prisma — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
import { isAbsolute, join, normalize, relative } from 'node:path';
|
||||
|
||||
const SAFE_ROOT = '/srv/uploads/x';
|
||||
|
||||
const safeResolvePath = (storedPath: string): string | null => {
|
||||
const absolute = isAbsolute(storedPath) ? storedPath : join(SAFE_ROOT, storedPath);
|
||||
const normalized = normalize(absolute); // résout les `..`
|
||||
const rel = relative(SAFE_ROOT, normalized);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) return null; // s'évade de SAFE_ROOT
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// Dans le handler :
|
||||
const absolutePath = safeResolvePath(storedPath);
|
||||
if (!absolutePath) {
|
||||
console.error('[handler] path stocké hors SAFE_ROOT:', storedPath);
|
||||
return errorResponse(500, 'FILE_NOT_FOUND', 'Fichier indisponible');
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- `path.join(ROOT, userInput)` puis `fs.readFile` sans normalisation : `'../../../etc/passwd'` passe.
|
||||
- `resolved.startsWith(ROOT)` : casse avec les symlinks — `path.relative` est la bonne API.
|
||||
- "Trust DB" ("c'est moi qui écris") : ignore le bug de migration et l'attaquant à accès SQL direct.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-alias-import-convention-migration"></a>
|
||||
## Pattern : Alias d'import `@/*` — convention et migration en masse
|
||||
|
||||
- Objectif : remplacer les imports relatifs profonds (`../../X`) par un alias `@/X` résolu depuis la racine `src/`, et migrer un projet établi sans casser les exceptions.
|
||||
- Contexte : projet (monorepo ou non) à 200+ fichiers où la profondeur des remontées relatives varie au fil du temps.
|
||||
- Quand l'utiliser : convention à graver tôt ; migration en masse d'un existant.
|
||||
- Quand l'éviter : voisins directs (un seul `../`) — les forcer en `@/` n'apporte que du bruit dans le diff.
|
||||
- Avantage :
|
||||
- refactor-friendly : déplacer un fichier dans `src/` ne casse aucun import en aval
|
||||
- lisibilité (`@/services/X` dit où on va, `../../../services/X` dit combien on remonte)
|
||||
- linter-friendly (`import/no-relative-parent-imports`)
|
||||
- Limites / vigilance :
|
||||
- `tsconfig.json` ET la config bundler doivent toutes deux connaître l'alias, sinon erreurs runtime obscures
|
||||
- typecheck + tests OBLIGATOIRES immédiatement après un sed de masse (faux positifs, fichiers hors `src/`)
|
||||
- Validé le : 11-05-2026
|
||||
- Contexte technique : monorepo pnpm (Next.js 16 + Vue 3.5 + Vite 7) — RL799_V2
|
||||
|
||||
### Setup
|
||||
|
||||
```json
|
||||
// tsconfig.json
|
||||
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }
|
||||
```
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }
|
||||
```
|
||||
|
||||
Next.js : alias TS reconnu automatiquement. Vitest : reconnaît les paths TS automatiquement.
|
||||
|
||||
### Convention de migration
|
||||
|
||||
**À migrer** : tout import qui traverse ≥ 2 niveaux (`../../X` ou plus) ET pointe vers un fichier sous `src/`.
|
||||
|
||||
**À laisser en relatif** (exceptions) :
|
||||
1. Voisins directs (`./sibling`, `../sibling` un seul niveau).
|
||||
2. Fichiers hors `src/` (ex. `next.config.ts` à la racine de l'app) — laisser en relatif + commentaire JSDoc justifiant l'exception.
|
||||
3. `__dirname`-relatif dans les `.mjs` (`resolve(here, '../../../src/...')`) : c'est un chemin filesystem, pas un import.
|
||||
4. Imports vers un sous-fichier précis pour éviter un cycle réintroduit par le barrel `@/X/index.ts`.
|
||||
|
||||
### Migration en masse via sed (avec garde-fous)
|
||||
|
||||
```bash
|
||||
# Repérer les candidats
|
||||
grep -rE "from '\.\./\.\./" src/ --include='*.ts' --include='*.tsx' -l
|
||||
|
||||
# DRY-RUN d'abord (sans -i) pour relire le diff
|
||||
sed -E "s|from '(\.\./){2,}([^']+)'|from '@/\2'|g" src/services/foo.ts
|
||||
|
||||
# Appliquer après validation du dry-run
|
||||
find src -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.vue' \) \
|
||||
-exec sed -i.bak -E "s|from '(\.\./){2,}([^']+)'|from '@/\2'|g" {} \;
|
||||
find src -name '*.bak' -delete
|
||||
```
|
||||
|
||||
Récupération après sed : `pnpm typecheck` → lire chaque import cassé → identifier les exceptions (fichiers hors `src/`) → `git checkout -p` ou correction manuelle.
|
||||
|
||||
### Anti-patterns à éviter
|
||||
|
||||
- Migrer les voisins directs "pour la cohérence" → bruit sans gain.
|
||||
- Migrer sans typecheck immédiat → l'exception se découvre en CI 3 commits plus tard.
|
||||
- Regex sed trop permissive (`s|\.\./|@/|g`) qui matche aussi `import.meta.url` ou des strings non-imports.
|
||||
|
||||
### Cas vécu
|
||||
|
||||
RL799_V2 (2026-05-11) : 246 imports migrés sur 124 fichiers côté `apps/api/`. Sed + typecheck → 1 seule erreur (`@/next.config`, fichier hors `src/`), corrigée à la main avec JSDoc. ~5 min de migration + 2 min de fix.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-test-invariant-structure-scan-statique"></a>
|
||||
## Pattern : Test d'invariant de structure — verrouiller par scan statique une propriété d'architecture
|
||||
|
||||
- Objectif : transformer en BUILD ROUGE une régression silencieuse sur une propriété architecturale correcte-par-construction (ex. "auth opt-in par route : une route est publique parce que son handler n'appelle aucun helper d'auth", "les handlers Z loggent toujours W").
|
||||
- Contexte : propriété de sécurité/correction vraie aujourd'hui par convention, mais vulnérable à la dérive (un dev colle `requireRoleAccess` dans un handler public par copier-coller → la route marche pour un membre loggé, casse pour un guest → régression silencieuse).
|
||||
- Quand l'utiliser : tout invariant architectural reposant sur une convention plutôt que sur un mécanisme dur (pas de middleware global, pas de contrainte DB).
|
||||
- Quand l'éviter : si l'invariant est déjà garanti par construction dure (middleware central, contrainte SQL) — le test devient redondant.
|
||||
- Avantage :
|
||||
- une régression de convention casse le build au lieu de passer en review
|
||||
- le scan statique n'a aucune dépendance runtime vivante
|
||||
- Limites / vigilance :
|
||||
- **prouver la mordacité** : un test d'invariant qui ne peut jamais échouer est un placebo
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : Vitest / scan statique `readFileSync` — RL799_V2 (K1.6 `keycloakGuestInvariant.test.ts`)
|
||||
|
||||
### Règles
|
||||
|
||||
1. **Scan statique récursif** (`readFileSync`, pas de runtime) sur la SURFACE pertinente. Pour l'auth : `route.ts` + imports DIRECTS de 1ᵉʳ niveau, **PAS** la fermeture transitive complète (un handler public importe légitimement des services partagés dont d'autres fonctions sont protégées → faux positifs). Documenter la granularité choisie et sa limite.
|
||||
2. **Garde anti-angle-mort DYNAMIQUE** : énumérer l'arborescence (`app/api/public/**`) plutôt qu'une liste figée → une nouvelle route entre AUTO dans le périmètre. Exceptions hors-arborescence : liste explicite versionnée + note assumant l'angle mort résiduel.
|
||||
3. **Checkpoint conscient** : une liste `KNOWN_*` comparée par égalité casse le build quand le périmètre change, forçant une DÉCISION humaine ("cette nouvelle route est-elle bien guest ?"). Ne pas le confondre avec la couverture de scan (c'est l'énumération dynamique qui couvre).
|
||||
4. **Invariant comportemental par SPIES non-appelés**, pas par code de réponse : injecter des hooks espions (`vi.fn` + `not.toHaveBeenCalled()`). NE PAS asserter sur un `error.code` qui peut être un homonyme métier (ex. `INVALID_TOKEN` domaine présence ≠ `INVALID_TOKEN` Keycloak). NE PAS `throw` dans le spy (un `catch` du handler pourrait l'avaler et masquer la violation).
|
||||
5. **CRITÈRE DE RÉUSSITE — prouver la mordacité** : en dev comme en revue, injecter temporairement une violation, vérifier que le test devient ROUGE avec un message pointant le fichier exact, puis retirer la probe.
|
||||
6. **Auto-documentation** : commentaire en tête expliquant POURQUOI (la propriété protégée) et COMMENT l'étendre sans casser l'esprit.
|
||||
|
||||
Modèle de référence : scan de couverture de catalogue (`auditCatalogCoverage` — toute action loggée doit figurer au catalogue).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-script-ops-batch-fail-closed"></a>
|
||||
## Pattern : Script ops batch fail-closed — rapport actionnable, dry-run honnête, revue par exécution réelle
|
||||
|
||||
- Objectif : un script ops one-shot qui mute des données en masse via un service externe (migration d'identités, back-fill, réconciliation) doit produire un rapport exploitable, ne jamais sur-promettre en dry-run, et être revu en l'EXÉCUTANT réellement (pas seulement via ses tests).
|
||||
- Contexte : script batch qui appelle un service de prod best-effort retournant souvent `null`/booléen opaque (qui mélange "externe down transitoire" et "entrée refusée définitivement").
|
||||
- Quand l'utiliser : tout script de migration/back-fill batch qui dépend d'un externe.
|
||||
- Quand l'éviter : mutation transactionnelle simple en un seul appel (pas de classification par item nécessaire).
|
||||
- Avantage :
|
||||
- le rapport devient l'outil de décision de l'ops (et d'une gate en aval)
|
||||
- l'exécution réelle révèle des findings que les tests (dépendance mockée) masquent
|
||||
- Limites / vigilance :
|
||||
- au-delà des invariants classiques (idempotent : `where: null` + chercher-avant-créer ; PII masquée ; secrets jamais loggés ; exit codes ; runner projet), 4 exigences spécifiques au BATCH sont souvent ratées (voir règles)
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : script ops Node + service externe — RL799_V2 (K1.7 `keycloak-migrate`)
|
||||
|
||||
### Règles
|
||||
|
||||
1. **FAIL-CLOSED ≠ FAIL-SAFE** : un batch doit CLASSER chaque item (`migrated`/`reused`/`blocked`/`failed`/`skipped`). Implémenter un orchestrateur DÉDIÉ qui appelle les mêmes briques basses et OBSERVE le résultat, SANS toucher le service de prod (qui reste fail-safe pour son usage).
|
||||
2. **RAPPORT EXPLOITABLE** : la raison d'un `failed` ne doit JAMAIS être `err.name` seul (sur une panne `fetch`, ça donne "Error", inactionnable). Remonter `name: message` nettoyé/borné (sans secret) + le statut HTTP des erreurs typées.
|
||||
3. **DRY-RUN HONNÊTE** : décider et DOCUMENTER ce que le dry-run touche. S'il interroge l'externe en LECTURE, le dire en tête ("interroge X en lecture seule"). Un dry-run NE DOIT PAS écrire d'artefact sur disque (stdout suffit). Documenter qu'il sur-estime les succès s'il ne tente pas l'écriture (ne détecte pas les refus de création).
|
||||
4. **ACTION SENSIBLE = AUDIT** : toute action destructive/sensible (révocation de token, ré-émission d'invitation) trace un audit persistant (`logAction`), pas un `console.log` volatil — même sans userId admin (tracer sur la cible). Ajouter l'action au catalogue d'audit si un test de couverture le vérifie.
|
||||
|
||||
### Méthode de review (l'apprentissage clé)
|
||||
|
||||
Pour un script ops, NE PAS se contenter des tests verts (la dépendance externe y est mockée). **EXÉCUTER le script en réel** (dry-run contre la vraie DB locale, externe absent). Cas vécu RL799 K1.7 : cette exécution a sorti M1 (raison `failed: Error` opaque) + M2 (dry-run qui écrit un fichier), tous deux invisibles en test. Les tests prouvent la logique ; l'exécution prouve l'expérience ops.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-helper-transverse-send-mail-with-retry"></a>
|
||||
## Pattern : Helper transverse paramétrable + wrapper local par caller (ex. `sendMailWithRetry`)
|
||||
|
||||
- Objectif : partager un helper technique entre deux domaines tout en préservant une configuration spécifique à un caller, sans réintroduire de duplication ni de couplage inter-domaines.
|
||||
- Contexte : une mécanique technique (boucle de retry mail, backoff) utilisée par plusieurs domaines, dont l'un a des paramètres distincts.
|
||||
- Quand l'utiliser : ≥ 2 domaines partagent la même logique technique mais avec des réglages différents.
|
||||
- Quand l'éviter : un seul caller → pas besoin de paramétrer ni de wrapper.
|
||||
- Avantage :
|
||||
- zéro duplication de la logique de retry (mono-source)
|
||||
- zéro couplage inter-domaines : le helper vit dans `lib/`, neutre
|
||||
- chaque domaine garde sa config co-localisée avec son usage
|
||||
- Limites / vigilance :
|
||||
- le helper neutre ne doit connaître aucun domaine (pas d'import repository métier)
|
||||
- Validé le : 23-06-2026
|
||||
- Contexte technique : Node.js / lib transverse — RL799_V2 (v2-6-2)
|
||||
|
||||
### Forme
|
||||
|
||||
```typescript
|
||||
// lib/mailRetry.ts — neutre, config en 2e param avec défaut
|
||||
export const sendMailWithRetry = (args: MailArgs, retry = MAIL_RETRY) => { /* … */ };
|
||||
|
||||
// Caller standard : appel direct (config par défaut)
|
||||
await sendMailWithRetry(args);
|
||||
|
||||
// Caller spécifique (flux visiteur : attempts:2, backoffMs:[1000]) :
|
||||
// const de config dédiée + wrapper local nommé qui fige la config
|
||||
const VISITOR_MAIL_RETRY = { attempts: 2, backoffMs: [1000] };
|
||||
const sendVisitorMailWithRetry = (args: MailArgs) => sendMailWithRetry(args, VISITOR_MAIL_RETRY);
|
||||
```
|
||||
|
||||
Le wrapper garde un nom métier lisible aux sites d'appel, la config custom reste près de son domaine, la logique de retry reste mono-source.
|
||||
|
||||
@@ -176,3 +176,167 @@ private async reconcileSessionStatus(session, now = new Date()) {
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-ressource-paire-non-ordonnee"></a>
|
||||
## Pattern : Ressource « paire non ordonnée » — adresser par les participants, pas par l'id
|
||||
|
||||
- Objectif : éviter un endpoint « créer la ressource » séparé avant le premier écrit, et la race condition associée, pour une ressource dont l'identité est dérivable d'une paire d'acteurs.
|
||||
- Contexte : DM 1:1, match, follow réciproque, partage 1:1 — toute ressource « paire non ordonnée » où l'id est déductible des deux participants.
|
||||
- Quand l'utiliser : dès que le premier écrit doit pouvoir créer la ressource implicitement (premier message à un utilisateur, première interaction).
|
||||
- Quand l'éviter : ressource avec identité propre (commande, document) qui existe indépendamment des participants.
|
||||
- Avantage :
|
||||
- 1 seul appel API au lieu de 2 (UX + perf)
|
||||
- pas de race condition : contrainte unique Prisma sur la paire ordonnée applicative (`userA < userB`)
|
||||
- l'endpoint GET par id reste légitime pour la pagination des fils existants
|
||||
- Limites / vigilance :
|
||||
- appliquer les vérifications d'éligibilité/quota AVANT le `findUnique` pour ne pas leak l'existence de la ressource
|
||||
- l'upsert doit se faire en transaction (création conversation + création message)
|
||||
- Validé le : 13-05-2026
|
||||
- Contexte technique : NestJS / Prisma — app-alexandrie story 10.2
|
||||
|
||||
### Implémentation (exemple minimal)
|
||||
|
||||
```txt
|
||||
POST /messaging/conversations/with/:targetUserId/messages
|
||||
```
|
||||
|
||||
Le service, après contrôles d'éligibilité/quota :
|
||||
|
||||
1. `findUnique({ where: { userAId_userBId: orderPair(currentUser, target) } })`
|
||||
2. Si absent → `create` la conversation
|
||||
3. `create` le message dans la même `$transaction`
|
||||
|
||||
Adresser par l'identité des deux participants (ou le seul participant cible, l'autre étant `req.user`), jamais par l'id de la ressource. Faire un upsert idempotent côté serveur en transaction.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Endpoint d'écriture adressé par les participants, pas par l'id de la ressource
|
||||
- [ ] Contrainte unique Prisma sur la paire ordonnée applicative (`userA < userB`)
|
||||
- [ ] Vérifications d'éligibilité/quota AVANT le `findUnique` (anti-leak d'existence)
|
||||
- [ ] Upsert conversation + message dans une même `$transaction`
|
||||
- [ ] Endpoint GET par id conservé pour la pagination
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-batched-eligibility-decoration"></a>
|
||||
## Pattern : Batched eligibility / decoration — ne jamais calculer dans `.map(async)`
|
||||
|
||||
- Objectif : éviter le N+1 caché quand chaque row d'une liste doit être décorée d'un flag calculé par une logique partagée (éligibilité, ACL, entitlements).
|
||||
- Contexte : `listX` qui ajoute un flag dérivé (`isReadOnly`, `canEdit`...) calculé par row à partir de queries internes.
|
||||
- Quand l'utiliser : tout listing qui décore N rows d'un flag dépendant de queries (DB ou cache) par row.
|
||||
- Quand l'éviter : flag purement synchrone dérivable de la row elle-même (pas de query).
|
||||
- Avantage :
|
||||
- passe de N×K requêtes à un nombre constant de queries bulk
|
||||
- lookup O(1) via `Set` / `Map` au moment de la décoration
|
||||
- Limites / vigilance :
|
||||
- le cache (ex : entitlements 60 s) n'amortit pas la première fenêtre froide
|
||||
- itérer la page **synchroniquement** après le chargement bulk — pas de `await` dans la boucle de décoration
|
||||
- Validé le : 13-05-2026
|
||||
- Contexte technique : NestJS / Prisma — app-alexandrie story 10.2 DM messaging
|
||||
|
||||
### Anti-pattern (N+1)
|
||||
|
||||
```typescript
|
||||
// ❌ N appels indépendants → N×K requêtes (K = queries internes de getEligibility)
|
||||
const items = await Promise.all(
|
||||
page.map(async (row) => {
|
||||
const eligibility = await this.getEligibilityForExistingConversation(
|
||||
currentUserId, peerIdFromRow(row),
|
||||
);
|
||||
return { ...row, isReadOnly: eligibility.isReadOnly };
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern (batched)
|
||||
|
||||
1. Extraire les IDs cibles : `const peerIds = page.map(peerIdFromRow)`.
|
||||
2. Charger TOUTES les dépendances en bulk :
|
||||
- `await entitlements.getEntitlementsForUser(currentUserId)` (1 fois)
|
||||
- `await Promise.all(peerIds.map(id => entitlements.getEntitlementsForUser(id)))` (parallèle, cache amortit)
|
||||
- `await prisma.follow.findMany({ where: { OR: [{ followerId: currentUserId, followingId: { in: peerIds } }, { followerId: { in: peerIds }, followingId: currentUserId }] } })` (1 seule requête)
|
||||
3. Construire des `Set` / `Map` pour lookup O(1).
|
||||
4. Itérer la page **synchroniquement** et décorer chaque row.
|
||||
|
||||
### Règle
|
||||
|
||||
Tout flag décoratif calculé par row qui dépend d'une logique partagée DOIT être factorisé en une méthode `compute<Flag>ForBatch(peerIds[])` retournant `Map<peerId, value>`.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-providers-externes-injection-token"></a>
|
||||
## Pattern : Providers externes via injection par token (jamais `new XxxProvider()`)
|
||||
|
||||
- Objectif : rendre les providers externes mockables, swappables par env, et conformes au contrat DI Nest.
|
||||
- Contexte : service Nest qui dépend d'un système externe (email, billing, push, OAuth IdP, storage, geocoding...).
|
||||
- Quand l'utiliser : tout provider qui touche un système externe.
|
||||
- Quand l'éviter : services purement applicatifs (`UsersService`, `CommunityService`) — injectés par classe directement.
|
||||
- Avantage :
|
||||
- mock propre en test (`useValue`)
|
||||
- rotation d'implémentation par env sans rebuild
|
||||
- arbre DI complet : le module déclare la dépendance
|
||||
- Limites / vigilance :
|
||||
- un `new XxxProvider()` au constructeur s'exécute même quand un test injecte un mock du service → tests cassés
|
||||
- Validé le : 20-05-2026
|
||||
- Contexte technique : NestJS — app-alexandrie story infra-5
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```typescript
|
||||
// ❌ instancié au constructeur : non mockable, non swappable, contrat DI incomplet
|
||||
export class AuthService {
|
||||
private readonly emailProvider = new NoopEmailProvider();
|
||||
private readonly authProvider = new GoogleAuthProvider();
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
}
|
||||
// Conséquences observées (avant fix) : AuthService 5 fails, billing 26 fails, notifications 2 fails.
|
||||
```
|
||||
|
||||
### Pattern correct
|
||||
|
||||
```typescript
|
||||
// 1. Token + interface
|
||||
export const EMAIL_PROVIDER = Symbol('EMAIL_PROVIDER');
|
||||
export interface EmailProvider {
|
||||
sendVerificationEmail(email: string, token: string): Promise<void>;
|
||||
}
|
||||
|
||||
// 2. Implémentation @Injectable()
|
||||
@Injectable()
|
||||
export class NoopEmailProvider implements EmailProvider { /* ... */ }
|
||||
|
||||
// 3. Binding dans le module
|
||||
@Module({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: EMAIL_PROVIDER, useClass: NoopEmailProvider },
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
// 4. Injection par token
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(EMAIL_PROVIDER) private readonly emailProvider: EmailProvider,
|
||||
) {}
|
||||
}
|
||||
|
||||
// 5. Mock en test
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: EMAIL_PROVIDER, useValue: { sendVerificationEmail: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
```
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Token (`Symbol`) + interface dans un fichier dédié
|
||||
- [ ] Implémentation `@Injectable()` qui implémente l'interface
|
||||
- [ ] Binding `{ provide: TOKEN, useClass: Impl }` dans le module
|
||||
- [ ] Injection via `@Inject(TOKEN)` au constructeur
|
||||
- [ ] Aucun `new XxxProvider()` dans un service métier
|
||||
|
||||
@@ -249,6 +249,26 @@ npx prisma migrate resolve --applied <timestamp>_<nom>
|
||||
|
||||
**Ne pas utiliser `prisma db push` en production** — il ne versionne pas les migrations.
|
||||
|
||||
### Variante : réaligner une DB dev sur un schéma amendé (migration WIP non encore mergée)
|
||||
|
||||
Quand `migrate dev` est bloqué (P3014, user applicatif sans droit `CREATE DATABASE`) et qu'une migration **non encore mergée** doit être corrigée : amender directement le `migration.sql` existant (la DB dev est jetable), puis réaligner la base **sans nouvelle migration** :
|
||||
|
||||
```bash
|
||||
# 1. Générer le SQL d'écart DB → schéma cible
|
||||
npx prisma migrate diff \
|
||||
--from-config-datasource --to-schema prisma/schema.prisma \
|
||||
--config prisma.config.ts --script > diff.sql
|
||||
|
||||
# 2. Appliquer (v7 : PAS de --schema ici, datasource lue depuis prisma.config.ts ;
|
||||
# --file OU --stdin, une seule des deux)
|
||||
npx prisma db execute --file diff.sql --config prisma.config.ts
|
||||
|
||||
# 3. Vérifier
|
||||
npx prisma migrate diff ... --exit-code # doit afficher « No difference detected »
|
||||
```
|
||||
|
||||
⚠️ Valable **uniquement** pour une migration WIP non poussée. Une fois la migration mergée, créer une migration corrective **additive** (ne jamais amender une migration déjà partagée).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-filtrage-metier-service"></a>
|
||||
@@ -664,3 +684,510 @@ export const findInvitationByTokenHash = async (tokenHash: string) => {
|
||||
- [ ] Procédure rollback documentée (`pg_dump` avant migration)
|
||||
- [ ] Smoke test post-deploy (login, création, magic link)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-colonnes-plates-vs-table-duree-de-vie"></a>
|
||||
## Pattern : Colonnes plates vs table dédiée — choix par durée de vie de la donnée
|
||||
|
||||
- Objectif : choisir la bonne forme de stockage pour les données d'étapes/cycle de vie d'un agrégat selon que celles-ci survivent ou non à la fin du parcours.
|
||||
- Contexte : agrégat avec un parcours en N étapes (timeline d'un candidat, lifecycle d'un dossier, états d'un workflow).
|
||||
- Quand l'utiliser :
|
||||
- **Colonnes plates** sur la row principale → si les données sont purgées en même temps que la row à la fin du parcours (admission/clôture). Pas de table satellite : moins de JOINs, projection DTO plate, transactions plus simples.
|
||||
- **Table dédiée** (one-to-many ou one-to-one séparé) → si les données survivent à la fin du parcours (audit trail, archivage légal, historique multi-candidatures).
|
||||
- Quand l'éviter : si la cardinalité du détail est variable (préférer alors une table), ou si l'on est tenté de stocker N champs hétérogènes dans un seul blob JSON.
|
||||
- Avantage colonnes plates :
|
||||
- 0 JOIN sur le détail courant
|
||||
- DTO plat, sérialisation directe
|
||||
- transactions atomiques plus simples (1 seule row à locker)
|
||||
- Avantage table dédiée :
|
||||
- indépendance du cycle de vie (la donnée historique ne contraint pas la suppression du parent)
|
||||
- index dédiés possibles
|
||||
- cardinalité variable (vs N colonnes fixes)
|
||||
- Limites / vigilance :
|
||||
- colonnes plates : N colonnes nullable **bien nommées** (pas un JSON blob), DELETE de la row = perte définitive (acceptable seulement si la purge est prévue)
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||
|
||||
### Heuristique de décision
|
||||
|
||||
Question simple : « cette donnée doit-elle survivre à la suppression de la row parent ? » → si **non** → colonnes plates ; si **oui** → table dédiée.
|
||||
|
||||
### Exemple
|
||||
|
||||
RL799 — module Enquête profane : `Profane.letterReadAt`, `Profane.letterVoteOutcome`, `Profane.enquetesMarkedDoneAt`, `Profane.reportReadingAt`, etc. (9 colonnes timeline plates) plutôt qu'une table `ProfaneTimelineEvent`. Justifié : la row `Profane` est DELETE à l'admission (purge totale), donc l'historique de parcours n'a pas à survivre à la row.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-etape-courante-derivee-source-unique"></a>
|
||||
## Pattern : Étape courante dérivée de colonnes booléennes/timestamps (source unique de vérité)
|
||||
|
||||
- Objectif : éviter la duplication entre une colonne `status` explicite et l'état réel dérivé des timestamps/outcomes.
|
||||
- Contexte : agrégat avec parcours en N étapes où chaque étape laisse une trace (date, outcome). On veut connaître l'étape courante à un instant T.
|
||||
- Quand l'utiliser : dès qu'une colonne `currentStep`/`status` redondante risquerait de diverger de l'état réel dérivable des autres colonnes.
|
||||
- Quand l'éviter : si l'étape courante a une sémantique métier autre que ses propres timestamps (ex. dépend d'un autre agrégat).
|
||||
- Solution : un helper **pur** (côté package shared) qui prend en input les colonnes brutes (pas un objet ORM) et retourne un type union discriminant. Ordre de priorité explicite documenté en tête du helper (et qui matche l'ordre des `if`).
|
||||
- Avantage :
|
||||
- source unique de vérité — frontend et backend partagent le même calcul
|
||||
- testable en isolation (helper pur, pas de DB)
|
||||
- aucun drift possible entre `status` stocké et état réel
|
||||
- Limites / vigilance :
|
||||
- toute évolution du parcours nécessite de mettre à jour le helper + ses tests
|
||||
- **ne pas** exposer en parallèle une colonne `currentStep` stockée en DB : le helper EST la source, le frontend reçoit `currentStep` calculé dans le DTO mapper, pas lu d'une colonne
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : monorepo TS partagé front/back — RL799_V2
|
||||
|
||||
### Implémentation (exemple)
|
||||
|
||||
```typescript
|
||||
// packages/shared/src/dto/soirees.ts
|
||||
export const getSoireeLifecycle = (input, now?) => { /* … */ };
|
||||
// Priorité documentée : cancelledAt > closedAt > status('draft'|'pending')
|
||||
// > openedAt > status('published')
|
||||
|
||||
// packages/shared/src/utils/profaneTimeline.ts
|
||||
export const deriveCurrentStep = (input) => { /* … */ };
|
||||
// Priorité : status !== pending → 'closed' ; sinon
|
||||
// bandeauVoteOutcome === 'passed' → 'initiation' ;
|
||||
// reportReadingVoteOutcome === 'passed' → 'bandeau' ; etc.
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
Matrice paramétrée (~15-30 cas) couvrant toutes les transitions pertinentes (cf. `soireeLifecycle.test.ts`).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-row-reactivable-reset-cycle"></a>
|
||||
## Pattern : Row réactivable (reset des colonnes de cycle, identité préservée)
|
||||
|
||||
- Objectif : permettre à une même entité métier de traverser plusieurs cycles (candidatures, abonnements, mandats) en gardant la même identité technique.
|
||||
- Contexte : entité dont l'identité (nom/prénom/email humain) doit rester reconnaissable d'un cycle au suivant, mais dont l'état fonctionnel doit repartir de zéro.
|
||||
- Quand l'utiliser : entité multi-cycles dont l'identité technique doit rester stable (URLs persistantes, audit trail continu, comptage natif des cycles).
|
||||
- Quand préférer DELETE+INSERT à la place :
|
||||
- si l'entité doit garder un historique riche par cycle (rapports, pièces jointes spécifiques) → table satellite `<Entity>Cycle` avec FK vers la row principale
|
||||
- si l'identité change vraiment d'un cycle au suivant (changement légal, fusion d'entités)
|
||||
- Solution : une fonction `reactivate<Entity>()` qui reset **toutes** les colonnes "de cycle" + status à `pending` initial, sans toucher à l'identité (id, créateur, coordonnées). Compteur `attemptCount` incrémenté, gate métier sur la valeur (ex. max 3 cycles).
|
||||
- Avantage :
|
||||
- identité technique stable → URLs persistantes, audit trail continu
|
||||
- comptage natif des cycles (`attemptCount`)
|
||||
- pas de "fantôme" historique à filtrer en table
|
||||
- Limites / vigilance :
|
||||
- le reset doit être **exhaustif** — chaque nouvelle colonne de cycle doit être ajoutée à la fonction reset (à enforcer par revue ou test)
|
||||
- l'audit log doit conserver l'événement `<entity>:reactivated` (le reset efface tout sauf l'audit séparé)
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||
|
||||
### Implémentation (exemple)
|
||||
|
||||
```typescript
|
||||
export const reactivateProfane = async (profaneId, client) => {
|
||||
await client.profane.update({
|
||||
where: { id: profaneId },
|
||||
data: {
|
||||
status: 'pending',
|
||||
refusedAt: null,
|
||||
rejectionReason: null,
|
||||
attemptCount: { increment: 1 },
|
||||
letterReadAt: null,
|
||||
letterVoteOutcome: null,
|
||||
// … toutes les colonnes timeline reset à null
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Service : gate `attemptCount >= 3 → 400 MAX_ATTEMPTS_REACHED` **avant** le reset. Audit `enquete:profane_reactivated` posé pour l'historique.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Fonction `reactivate` exhaustive (toutes les colonnes de cycle)
|
||||
- [ ] Compteur `attemptCount` incrémenté
|
||||
- [ ] Gate métier sur le compteur (limite max)
|
||||
- [ ] Audit log de la réactivation
|
||||
- [ ] Test d'intégration : 1 cycle complet → réactivation → état initial
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-endpoint-replace-atomique"></a>
|
||||
## Pattern : Endpoint replace atomique (remplacement à un slot)
|
||||
|
||||
- Objectif : remplacer un membre d'une collection de slots (3 enquêteurs, 5 officiers, etc.) en une seule transaction atomique, sans passer par "DELETE puis POST".
|
||||
- Contexte : agrégat avec une collection de slots où l'on veut remplacer un membre par un autre. Le chaînage `DELETE` puis `POST` ouvre une fenêtre où la collection est dans un état intermédiaire invalide (cardinalité < attendue) et double les notifications.
|
||||
- Quand l'utiliser : dès qu'un remplacement à un slot doit être atomique et que les notifications doivent être chirurgicales (1 sortie, 1 entrée).
|
||||
- Quand l'éviter : ajout/retrait simple sans sémantique de remplacement → POST/DELETE suffisent.
|
||||
- Solution : `PUT /resource/:id/members/:oldId` avec body `{ newMemberId }`. Le service exécute revoke + assign + side-effects (anonymisation, audit) dans la **même transaction Prisma**.
|
||||
- Avantage :
|
||||
- aucune fenêtre d'incohérence visible par un lecteur concurrent
|
||||
- une seule notification post-commit (`notifyAssigned(newId)` + `notifyRevoked(oldId)`) au lieu d'un mailing dupliqué aux membres inchangés
|
||||
- permet de gérer les invariants intermédiaires (ex. anonymisation du rapport déposé par le remplacé) en cohérence avec la modification
|
||||
- Limites / vigilance :
|
||||
- plus complexe qu'un POST (2 IDs au lieu d'1)
|
||||
- le frontend doit comprendre la sémantique "remplacement" et ne pas chaîner DELETE+POST
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : Next.js App Router + transaction Prisma — RL799_V2
|
||||
|
||||
### Implémentation (exemple)
|
||||
|
||||
```typescript
|
||||
// PUT /api/venerable/profanes/:profaneId/enqueteurs/:oldEnqueteurId
|
||||
// Body: { newEnqueteurId }
|
||||
export const handleReplaceEnqueteur = async (req, profaneId, oldId) => {
|
||||
const { newEnqueteurId } = await validate(req);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Anonymiser l'éventuel rapport de l'ancien
|
||||
const rapport = await findRapportByEnqueteur(oldId, tx);
|
||||
if (rapport) await anonymizeRapport(rapport.id, tx);
|
||||
// 2. Revoke + assign
|
||||
await revokeEnqueteur(enqueteId, oldId, tx);
|
||||
await assignEnqueteurs(enqueteId, [newEnqueteurId], { /* … */ }, tx);
|
||||
// 3. Audit composite (1 seul log au lieu de 2)
|
||||
await logAction(tx, 'enquete:investigator_replaced', { oldId, newId: newEnqueteurId });
|
||||
});
|
||||
// Post-commit : notifications ciblées (diff connu : 1 sortie, 1 entrée)
|
||||
void notifyAssigned([newEnqueteurId]);
|
||||
void notifyRevoked(oldId);
|
||||
// Les autres membres de la collection ne reçoivent RIEN (cloisonnement)
|
||||
};
|
||||
```
|
||||
|
||||
### Cloisonnement des notifications
|
||||
|
||||
Avec un endpoint replace atomique, le service connaît exactement le diff (1 sortie, 1 entrée) → mailing chirurgical. Avec 2 appels DELETE+POST, le 2e appel voit la collection déjà réduite et re-mailerait les membres inchangés par défaut sans diff intelligent.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-bascule-etat-idempotente-updatemany"></a>
|
||||
## Pattern : Bascule d'état idempotente avec `updateMany` conditionnel (anti-race)
|
||||
|
||||
- Objectif : basculer une row d'un état A vers un état B au franchissement d'un seuil (compteur de reports, quota, vote) sans race ni double effet.
|
||||
- Contexte : transition pilotée par un seuil où le pattern "lire l'état puis updater" est vulnérable aux courses. Deux requêtes concurrentes voient l'état initial simultanément et écrasent toutes deux la transition.
|
||||
- Quand l'utiliser : transition dont la condition de garde peut s'exprimer entièrement dans un `WHERE` (état lu en base).
|
||||
- Quand l'éviter : si la condition de garde est calculée hors DB, ou si l'on doit retourner la row mise à jour (utiliser `update` + gestion `P2025`, mais l'idempotence est alors perdue).
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : Prisma / Postgres — app-alexandrie
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```ts
|
||||
// ❌ DANGEREUX : race entre findUnique et update
|
||||
const thread = await prisma.thread.findUnique({ where: { id }, select: { visibilityStatus: true } });
|
||||
if (thread?.visibilityStatus !== 'VISIBLE') return;
|
||||
await prisma.thread.update({
|
||||
where: { id },
|
||||
data: { visibilityStatus: 'AUTO_HIDDEN', autoHiddenAt: new Date() },
|
||||
});
|
||||
// Deux requêtes concurrentes voient 'VISIBLE' et écrasent toutes deux autoHiddenAt.
|
||||
```
|
||||
|
||||
### Pattern correct
|
||||
|
||||
```ts
|
||||
// ✅ updateMany filtre côté SQL → idempotence garantie par le SGBD
|
||||
const result = await prisma.thread.updateMany({
|
||||
where: { id, visibilityStatus: 'VISIBLE' },
|
||||
data: { visibilityStatus: 'AUTO_HIDDEN', autoHiddenAt: new Date() },
|
||||
});
|
||||
if (result.count > 0) {
|
||||
logger.log(`Thread ${id} basculé (count=${result.count})`);
|
||||
}
|
||||
```
|
||||
|
||||
L'`UPDATE ... WHERE` est atomique au niveau row : pas de transaction explicite ni de `SELECT ... FOR UPDATE`. `result.count === 0` = no-op idempotent (le perdant de la course).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-pagination-relation-n-n-some"></a>
|
||||
## Pattern : Récupération paginée via relation N-N — `some` plutôt que double findMany
|
||||
|
||||
- Objectif : paginer une liste d'entités qui satisfont une relation N-N sans charger en mémoire un set intermédiaire non borné (risque DoS).
|
||||
- Contexte : "récupérer une liste paginée d'entités E qui satisfont une relation N-N (`UserPack`, `Member`, `Tag`)". La version naïve fait deux `findMany` séquentiels — le premier sans `take`, donc non borné si la relation explose (1000+ rows).
|
||||
- Quand l'utiliser : tout listing paginé dont le filtre passe par une relation N-N.
|
||||
- Quand l'éviter : si le set intermédiaire est borné par construction et petit (quelques rows).
|
||||
- Validé le : 27-05-2026
|
||||
- Contexte technique : Prisma — app-alexandrie
|
||||
|
||||
### Anti-pattern (DoS-able si la relation explose)
|
||||
|
||||
```ts
|
||||
// ❌ Charge potentiellement N×1000 lignes avant pagination
|
||||
const sharingUserPacks = await prisma.userPack.findMany({
|
||||
where: { packId: { in: myPacks.map(p => p.packId) } },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
});
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: sharingUserPacks.map(p => p.userId) } },
|
||||
take: limit + 1,
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern recommandé
|
||||
|
||||
```ts
|
||||
// ✅ Pagination bornée au niveau User, pas de chargement intermédiaire
|
||||
const myPacks = await prisma.userPack.findMany({
|
||||
where: { userId: currentUserId, revokedAt: null },
|
||||
select: { packId: true },
|
||||
});
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { not: currentUserId },
|
||||
deletedAt: null,
|
||||
userPacks: { some: { packId: { in: myPacks.map(p => p.packId) }, revokedAt: null } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1,
|
||||
});
|
||||
```
|
||||
|
||||
Prisma génère un sous-select `EXISTS` borné par l'`orderBy` + `take` du niveau supérieur. L'index utilisé est celui de la jointure (`UserPack (userId, packId)`).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-raw-sql-queryrawunsafe-facade"></a>
|
||||
## Pattern : Raw SQL via `$queryRawUnsafe` quand Prisma est encapsulé dans une façade
|
||||
|
||||
- Objectif : écrire une requête raw SQL paramétrée quand l'accès DB passe par un service-façade qui ne réexporte pas le tag template `$queryRaw`.
|
||||
- Contexte : Prisma encapsulé dans un `PrismaService` (façade NestJS qui ne réexpose que les modèles + quelques méthodes). Le tag template `$queryRaw\`...\`` n'est PAS disponible (`Property '$queryRaw' does not exist`), mais `$queryRawUnsafe(query, ...values)` l'est.
|
||||
- Quand l'utiliser : raw SQL nécessaire (agrégations, requêtes non exprimables via le query builder) à travers une façade Prisma.
|
||||
- Quand l'éviter : si la requête s'exprime via le query builder Prisma (préférer le typage natif).
|
||||
- Validé le : 08-06-2026
|
||||
- Contexte technique : NestJS / Prisma façade — app-alexandrie
|
||||
|
||||
### Règle
|
||||
|
||||
```typescript
|
||||
// Paramètres positionnels $1, $2… → toujours paramétrés, jamais d'interpolation
|
||||
const rows = await this.prisma.$queryRawUnsafe<Row[]>(
|
||||
'SELECT COUNT(*) AS cnt FROM members WHERE tenant_id = $1',
|
||||
tenantId,
|
||||
);
|
||||
const total = Number(rows[0].cnt); // bigint → Number
|
||||
```
|
||||
|
||||
- `$queryRawUnsafe` n'est "unsafe" que par son **nom** : `Unsafe` désigne le fait que le SQL est une string libre (non validée par Prisma), PAS l'absence de paramétrage. Avec des `$n` paramétrés il est aussi sûr que le tag template — jamais d'interpolation de chaîne dans le SQL.
|
||||
- ⚠️ Noms de tables/colonnes = noms DB **réels** (`@map`/`@@map`, souvent snake_case), pas les noms du client Prisma (camelCase). Une requête raw contourne le mapping → vérifier le schéma avant d'écrire le SQL.
|
||||
- Caster les agrégats : selon le driver, `COUNT(...)` revient en `bigint` → `Number(row.cnt)` côté TS.
|
||||
- Avant de suivre une tech-spec qui écrit du raw, vérifier ce que la façade expose réellement (grep des usages raw existants) plutôt que supposer l'API Prisma standard.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-token-usage-unique-updatemany-where"></a>
|
||||
## Pattern : Consommation concurrente d'un token usage-unique — condition dans le `WHERE` de l'UPDATE
|
||||
|
||||
- Objectif : rendre un token (ou flag) usage-unique sous concurrence (2 POST simultanés avec le même token) sans double consommation.
|
||||
- Contexte : sous `READ COMMITTED` (défaut Postgres/Prisma), un `findFirst(tokenHash)` + `update(WHERE id)` séparés laissent les deux lectures voir le token vivant → les deux updates réussissent (double consommation).
|
||||
- Quand l'utiliser : consommation atomique d'une ressource usage-unique (token, ticket, slot) sous concurrence possible (double-clic, retry, multi-onglets).
|
||||
- Quand l'éviter : ressource sans contrainte d'usage unique.
|
||||
- Validé le : 16-06-2026
|
||||
- Contexte technique : Prisma / Postgres — RL799_V2 (Lot C Keycloak onboarding)
|
||||
|
||||
### Règle
|
||||
|
||||
Faire un `updateMany` dont le `WHERE` porte la **condition de consommation** (le hash encore présent), pas seulement un SELECT préalable. Le verrou de ligne sérialise les transactions concurrentes : le 2e voit `count: 0` → traiter comme `token_not_found`.
|
||||
|
||||
```typescript
|
||||
const { count } = await tx.delivery.updateMany({
|
||||
where: { id, onboardingTokenHash: hash },
|
||||
data: { keycloakSub, onboardingTokenHash: null },
|
||||
});
|
||||
if (count === 0) return { ok: false, reason: 'token_not_found' };
|
||||
```
|
||||
|
||||
`update` (par `@id`) ne permet pas un `WHERE` composite → `updateMany` est l'outil, en lisant `result.count`.
|
||||
|
||||
Garde-fou complémentaire : un re-pointage de colonne `@unique` peut lever `P2002` sous race (un concurrent prend la valeur entre check et update) → catcher `P2002` et le mapper en « collision » plutôt que 500.
|
||||
|
||||
Test obligatoire : `Promise.all([POST, POST])` même token → attendu `[200, 400]`.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-fk-snapshot-label-vs-texte-libre"></a>
|
||||
## Pattern : « FK + snapshot label dérivé serveur » ≠ « FK + texte libre saisi client »
|
||||
|
||||
- Objectif : distinguer deux conceptions « FK + texte » visuellement identiques mais sémantiquement opposées, pour ne pas produire un label falsifiable ou une donnée perdue.
|
||||
- Contexte : un enregistrement référence une autre entité ET veut afficher son libellé même après disparition de la cible.
|
||||
- Quand l'utiliser : tout « sujet / cible » d'une entité pointant une autre entité supprimable.
|
||||
- Validé le : 18-06-2026
|
||||
- Contexte technique : Prisma / Next.js App Router — RL799_V2 (chantier ODJ)
|
||||
|
||||
### Les deux patterns
|
||||
|
||||
- **(a) Texte libre client** (ex. `plancheAuthorId` FK ⊕ `plancheAuthorName` saisi) : deux modes **exclusifs**, tous deux fournis par le client. Le handler prend le nom tel quel. Convient quand la cible peut ne pas exister en base (auteur non-membre).
|
||||
- **(b) Snapshot dérivé serveur** (ex. `subjectProfaneId`/`subjectUserId` FK + `subjectLabel` calculé) : le client envoie **seulement l'id**, le backend résout `firstName/lastName` de la cible et fige le label. Anti-falsification + survie à la suppression de la cible.
|
||||
|
||||
### Piège
|
||||
|
||||
Croire que (b) « imite » (a). NON — (a) ne fait aucune résolution serveur. Implémenter (b) en copiant (a) produit un label client falsifiable et désynchronisé.
|
||||
|
||||
### Règle
|
||||
|
||||
- Sujet/cible **référençant une entité connue** → pattern (b) : résolution + snapshot côté serveur à l'écriture, FK `onDelete: SetNull`.
|
||||
- Cible **hors-base** → pattern (a).
|
||||
|
||||
### Corollaire sur `onDelete: SetNull`
|
||||
|
||||
Sa justification dépend du cycle de vie réel de la cible :
|
||||
- réellement déclenché si la cible est **hard-deleted** (ex. `Profane` DELETE à l'admission → le snapshot est indispensable) ;
|
||||
- purement garde-fou FK si la cible est **soft-deleted/anonymisée** (ex. `User` jamais hard-deleted → le snapshot survit trivialement).
|
||||
|
||||
Ne pas copier le rationale d'un cas à l'autre.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-migration-string-int-enum-sans-downtime"></a>
|
||||
## Pattern : Migration Postgres String/Int → enum (backfill défensif + cast sans downtime)
|
||||
|
||||
- Objectif : durcir une colonne `String` libre ou `Int` en `enum` Postgres sans déploiement raté ni état hybride, en préservant l'audit et sans downtime.
|
||||
- Contexte : opération d'hygiène DB la plus fréquente et la plus piégeuse — la migration plante au premier `INSERT`/valeur qui ne matche pas l'enum, et `Int → enum` n'accepte pas le cast direct.
|
||||
- Quand l'utiliser : conversion d'une colonne à valeurs finies (`status`, `type`, `grade`) vers un `enum`.
|
||||
- Quand l'éviter : champ réellement libre (texte saisi), ou snapshot historique volontairement laissé en `String?`/`Int?` (cf. cascade ci-dessous).
|
||||
- Validé le : 11-05-2026
|
||||
- Contexte technique : Prisma 7 + PostgreSQL 16 — RL799_V2
|
||||
|
||||
### Étape 0 — Backfill défensif (pré-scan AVANT toute migration)
|
||||
|
||||
Avant TOUTE conversion, exécuter un script de pré-scan qui :
|
||||
|
||||
1. liste les **valeurs distinctes en DB** : `SELECT DISTINCT col FROM table` (avec cardinalités) ;
|
||||
2. compare aux valeurs **attendues par l'enum** (issues du DTO `as const` / du schéma Zod) ;
|
||||
3. identifie les **orphelins** (présents en DB mais pas dans l'enum) ;
|
||||
4. pour chaque orphelin, **décision explicite avant** la migration : mapper (`UPDATE col = 'new' WHERE col = 'orphan'`), NULLifier (si nullable), ou rejeter la migration si l'orphelin révèle un bug applicatif.
|
||||
|
||||
Anti-pattern : lancer `prisma migrate deploy` en pensant « la DB est cohérente parce que l'app valide via Zod » — la valeur peut venir d'un ancien feature flag, d'un import historique, d'une console SQL admin. (Cas RL799 V1.1 : pré-scan exécuté, 0 orphelin sur 7 colonnes → migration appliquée en confiance.)
|
||||
|
||||
### Cas A — `String → enum` (cast direct natif, pas de colonne tampon)
|
||||
|
||||
```sql
|
||||
-- 1. Créer l'enum
|
||||
CREATE TYPE "Grade" AS ENUM ('Apprenti', 'Compagnon', 'Maitre');
|
||||
|
||||
-- 2. Si la colonne a un DEFAULT, le drop avant ALTER TYPE
|
||||
ALTER TABLE "Document" ALTER COLUMN "grade" DROP DEFAULT;
|
||||
|
||||
-- 3. Cast direct (Postgres accepte String → enum via USING)
|
||||
ALTER TABLE "Document"
|
||||
ALTER COLUMN "grade" TYPE "Grade" USING "grade"::"Grade";
|
||||
|
||||
-- 4. Re-poser le DEFAULT typé enum
|
||||
ALTER TABLE "Document" ALTER COLUMN "grade" SET DEFAULT 'Apprenti'::"Grade";
|
||||
```
|
||||
|
||||
Les contraintes `UNIQUE` sur la colonne sont préservées automatiquement par Postgres tant que le nouveau type accepte les mêmes valeurs — pas de drop+recreate.
|
||||
|
||||
### Cas B — `String? → enum NOT NULL` (backfill des NULL AVANT le SET NOT NULL)
|
||||
|
||||
Le cast direct fonctionne sans colonne tampon, mais il faut backfiller les NULL **avant** `SET NOT NULL`, sinon il lève à la fin.
|
||||
|
||||
```sql
|
||||
-- 1. Backfill des NULL historiques vers la valeur par défaut métier
|
||||
UPDATE "table" SET "col" = 'DefaultValue' WHERE "col" IS NULL;
|
||||
|
||||
-- 2. Cast direct text → enum
|
||||
ALTER TABLE "table" ALTER COLUMN "col" TYPE "MyEnum" USING "col"::"MyEnum";
|
||||
|
||||
-- 3. SET NOT NULL après le backfill
|
||||
ALTER TABLE "table" ALTER COLUMN "col" SET NOT NULL;
|
||||
|
||||
-- 4. Garde-fou anti-NULL résiduel (la NOT NULL bloquerait déjà, mais log explicite pour le debug)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM "table" WHERE "col" IS NULL) THEN
|
||||
RAISE EXCEPTION 'table.col contient des NULL après backfill — anomalie';
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
### Cas C — `Int → enum` (cast direct REFUSÉ → colonne tampon obligatoire)
|
||||
|
||||
Postgres refuse `ALTER COLUMN x TYPE myEnum USING x::myEnum` quand `x` est `INTEGER`, même avec un `USING` explicite. Passer par une colonne tampon + `UPDATE CASE WHEN`, sans downtime (expand/contract) :
|
||||
|
||||
```sql
|
||||
-- 1. Enum cible
|
||||
CREATE TYPE "Grade" AS ENUM ('Apprenti', 'Compagnon', 'Maitre');
|
||||
|
||||
-- 2. Colonne tampon du type cible (expand)
|
||||
ALTER TABLE "OdjItem" ADD COLUMN "grade_new" "Grade";
|
||||
|
||||
-- 3. Remplir via UPDATE CASE/WHEN
|
||||
UPDATE "OdjItem"
|
||||
SET "grade_new" = CASE "grade"
|
||||
WHEN 1 THEN 'Apprenti'::"Grade"
|
||||
WHEN 2 THEN 'Compagnon'::"Grade"
|
||||
WHEN 3 THEN 'Maitre'::"Grade"
|
||||
END;
|
||||
|
||||
-- 4. Garde-fou : aucune ligne ne doit rester NULL après l'UPDATE
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM "OdjItem" WHERE "grade_new" IS NULL AND "grade" IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'Migration grade : valeur Int hors mapping détectée';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 5. Drop l'index éventuel sur l'ancienne colonne
|
||||
DROP INDEX IF EXISTS "OdjItem_grade_idx";
|
||||
|
||||
-- 6. Swap : drop old, rename new, SET NOT NULL si besoin (contract)
|
||||
ALTER TABLE "OdjItem" DROP COLUMN "grade";
|
||||
ALTER TABLE "OdjItem" RENAME COLUMN "grade_new" TO "grade";
|
||||
ALTER TABLE "OdjItem" ALTER COLUMN "grade" SET NOT NULL;
|
||||
|
||||
-- 7. Recréer l'index
|
||||
CREATE INDEX "OdjItem_grade_idx" ON "OdjItem"("grade");
|
||||
```
|
||||
|
||||
Pour une colonne `Int?` nullable : **omettre** le `SET NOT NULL` (étape 6) et adapter le garde-fou (`WHERE grade_new IS NULL AND grade IS NOT NULL`) pour ne pas crier sur les NULL légitimes.
|
||||
|
||||
### Récapitulatif des deux casts
|
||||
|
||||
- `String → enum` : USING natif **accepté** → pas de colonne tampon.
|
||||
- `Int → enum` : USING direct **refusé** → colonne tampon + `UPDATE CASE/WHEN` obligatoire.
|
||||
- Dans tous les cas : backfill défensif préalable + garde-fou `DO $$` + drop/recreate du DEFAULT typé.
|
||||
|
||||
### Cascade côté code (post-migration)
|
||||
|
||||
Après `prisma generate`, TypeScript révèle **toutes** les coercions implicites précédentes (`x as Grade`, comparaisons numériques) — effet iceberg : un fix SQL unique peut révéler 30-50 erreurs TS dormantes.
|
||||
|
||||
- **Helpers de conversion aux frontières** : `gradeToRank(g): 1|2|3` / `rankToGrade(r): Grade` exportés depuis `@app/shared/utils` (UI qui pivote par rang sans toucher au domain).
|
||||
- **Snapshots historiques** : laisser volontairement `String?`/`Int?` les colonnes de snapshot d'état (ex. `Attendance.gradeAtTime`). Le domain strict ne s'applique qu'aux entités vivantes.
|
||||
- **Validation API** : durcir les query params (`?grade=`) avec un type guard `isGrade(s): s is Grade` qui rejette aussi lowercase/abréviations.
|
||||
|
||||
Bug latent typique capté : un `=== 'apprenti'` (lowercase) qui ne matche jamais `'Apprenti'` (TitleCase) — invisible en `string`, signalé immédiatement par TS après la bascule en enum. Le typage strict révèle, ne crée pas, ces bugs.
|
||||
|
||||
### Vigilance
|
||||
|
||||
⚠️ Le pré-scan ne détecte PAS les **index partiels avec littéraux text** qui bloquent l'`ALTER` — cf. `risque-index-partiel-text-alter-enum` dans `risques/prisma.md`.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Pré-scan des valeurs distinctes vs enum attendu, orphelins décidés explicitement
|
||||
- [ ] DEFAULT droppé avant `ALTER TYPE`, re-posé typé enum après
|
||||
- [ ] `Int → enum` : colonne tampon + garde-fou `DO $$`
|
||||
- [ ] `String? → enum NOT NULL` : backfill des NULL avant `SET NOT NULL`
|
||||
- [ ] Grep préalable des index partiels littéraux (cf. risque compagnon)
|
||||
- [ ] Cascade TS gérée (helpers de frontière, snapshots laissés souples)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-util-crypto-transverse-neutre"></a>
|
||||
## Pattern : Extraire un util crypto/transverse neutre partagé entre deux domaines
|
||||
|
||||
- Objectif : factoriser une mécanique technique partagée entre deux domaines métier (ici : tokens de réponse « quick-link » hashés sha256) **sans coupler les domaines**.
|
||||
- Contexte : deux domaines (convocations + instructions) partagent la même primitive crypto. Le piège est de réutiliser un repository du domaine A dans le domaine B. La distinction : on factorise un util **transverse neutre** (crypto), jamais du métier.
|
||||
- Quand l'utiliser : deux domaines partagent une mécanique purement technique (hash, génération de token, encodage).
|
||||
- Quand l'éviter : si le code partagé porte de la logique métier → préférer la duplication au couplage inter-domaines.
|
||||
- Validé le : 23-06-2026
|
||||
- Contexte technique : Prisma / monorepo — RL799_V2
|
||||
|
||||
### Règles
|
||||
|
||||
1. L'util (`lib/responseToken.ts`) ne connaît **aucun domaine** : pas d'import Prisma, pas d'import repository, JSDoc sans référence métier. Il produit/hashe, c'est tout.
|
||||
2. Chaque domaine pose **son propre** champ `responseToken` sur **son** modèle de delivery et gère **son** lookup.
|
||||
3. Pour ne pas casser les call-sites historiques pendant la migration, ré-exporter depuis l'ancien emplacement : `export { hashResponseToken } from '@/lib/responseToken'` — zéro modification chez le call-site legacy, zéro duplication.
|
||||
4. Vérifier l'absence de duplication résiduelle par grep ciblé sur la primitive (`randomBytes(32)`, `createHash('sha256')`) — tolérer les redéfinitions locales en zone test pure.
|
||||
|
||||
### Modèle de delivery « autonome »
|
||||
|
||||
Calquer la mécanique token d'un modèle existant (`ConvocationDelivery`) mais retirer **toute** la chaîne FK du domaine d'origine (issue/grade/mailLog/status) — ne garder que : id applicatif + 2 FK (parent métier + recipient) + `responseToken @unique` + timestamps. Migration : table créée **vide**, en-tête documentant explicitement « pas de backfill » + l'invariant d'isolation (zéro FK croisée).
|
||||
|
||||
|
||||
@@ -158,3 +158,43 @@ handlePackWebhookEvent(event): PackWebhookResult | null
|
||||
- Règle métier explicitée
|
||||
- Guards alignés sur la sémantique choisie
|
||||
- Fixtures et seeds cohérents
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-price-data-inline-checkout-dynamique"></a>
|
||||
|
||||
## Pattern : Prix défini en base → `price_data` inline pour un checkout dynamique
|
||||
|
||||
- Objectif : faire d'un checkout Stripe one-shot la source de vérité d'un montant qui vit dans NOTRE base (saisi par un admin métier), sans Price pré-créé dans le dashboard Stripe.
|
||||
- Contexte : produit one-shot dont le prix est défini en back-office et non figé dans Stripe.
|
||||
- Quand l'utiliser : checkout `mode: 'payment'` dont le montant est piloté par la base.
|
||||
- Quand l'éviter : récurrent/abonnement (`mode: 'subscription'`) — garder un `Price` recurring pré-créé.
|
||||
- Avantage :
|
||||
- l'admin ne saisit qu'un montant (+ devise) ; aucun objet `Price` à gérer dans Stripe
|
||||
- un changement de prix prend effet immédiatement au prochain checkout
|
||||
- Limites / vigilance :
|
||||
- `unit_amount` est en **centimes** = stockage entier (jamais de flottant)
|
||||
- le montant venant de notre base, ajouter une garde anti-fraude best-effort (voir ci-dessous)
|
||||
- Validé le : 05-06-2026
|
||||
- Contexte technique : Stripe Checkout / NestJS — app-alexandrie (ux-parcours-3/7 + bo-6)
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
stripe.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency, // ISO 4217 minuscules, normalisée à l'écriture
|
||||
unit_amount: amountInCents, // entier, centimes
|
||||
product_data: { name },
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
### Garde-fous
|
||||
|
||||
- **Anti-fraude (best-effort, non bloquant)** : au webhook, comparer `amount_total`/`currency` au montant attendu (retrouvé via metadata) et logguer tout écart (`stripe_amount_suspicious`). Stripe a déjà encaissé → log, pas de blocage.
|
||||
- **Validation devise stricte côté écriture** (ISO 4217 minuscules) sur TOUS les modèles qui pilotent un checkout : un schéma lâche sur l'un et strict sur l'autre = devise invalide passée à Stripe. Défense serveur : défauter la devise quand un montant est posé sans elle (un montant sans devise = produit non achetable).
|
||||
|
||||
@@ -249,6 +249,160 @@ const notif = await waitForNotification({ type: 'X', recipientId: userId });
|
||||
- [ ] Timeout par défaut court (1500 ms)
|
||||
- [ ] Migration progressive — pas tous les tests d'un coup
|
||||
|
||||
### Pattern symétrique pour vérifier l'ABSENCE d'un side-effect
|
||||
|
||||
`waitForX` ne convient pas pour prouver qu'**aucun** event n'apparaît : le polling jusqu'au timeout ne distingue pas "aucun event" de "event arrivé après le timeout". Le remplacer par `setTimeout(100) + count()` souffre d'une double fragilité (trop court en CI lent → faux négatif si l'event fuyant arrive après ; trop long → temps gaspillé).
|
||||
|
||||
Le helper symétrique correct est un polling-borné **fail-fast** (`assertCountStable`) : il poll-vérifie que le compteur reste à la valeur attendue sur une fenêtre courte, et abandonne **dès** qu'un compteur surnuméraire est observé.
|
||||
|
||||
```typescript
|
||||
// __tests__/helpers/asyncWait.ts
|
||||
export const assertNotificationCountStable = async (
|
||||
query: { type: NotificationType; recipientId?: string },
|
||||
expected: number,
|
||||
options: { windowMs?: number; intervalMs?: number } = {},
|
||||
): Promise<number> => {
|
||||
const window = options.windowMs ?? 200;
|
||||
const interval = options.intervalMs ?? 50;
|
||||
const deadline = Date.now() + window;
|
||||
|
||||
let count = await prisma.notification.count({ where: query });
|
||||
if (count > expected) return count; // fail-fast immédiat
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
count = await prisma.notification.count({ where: query });
|
||||
if (count > expected) return count; // fail-fast
|
||||
}
|
||||
return count;
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ délai arbitraire — trop court en CI lent (faux négatif), trop long = temps perdu
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(await prisma.notification.count({ where: { /* … */ } })).toBe(1);
|
||||
|
||||
// ✅ polling borné, fail-fast si une notif fuyante apparaît
|
||||
const count = await assertNotificationCountStable({ type: 'X', recipientId: userId }, 1);
|
||||
expect(count).toBe(1);
|
||||
```
|
||||
|
||||
Checklist (absence) :
|
||||
|
||||
- [ ] Fenêtre courte (200 ms par défaut — durée typique d'un fire-and-forget non-désiré, pas un timeout)
|
||||
- [ ] Filtre exhaustif — `recipientId` ou `targetId` au minimum
|
||||
- [ ] Le test affirme `count === expected` APRÈS le helper, pas pendant
|
||||
|
||||
Cas vécu : `PATCH/DELETE payments → aucune nouvelle notif` (`cotisationsPayments.test.ts`), 05-05-2026.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-injection-dependance-hooks-module-level"></a>
|
||||
## Pattern : Injection de dépendance testable via hooks module-level `__setXForTests` / `__resetXForTests`
|
||||
|
||||
- Objectif : surcharger en test une dépendance d'un module de prod (getter de clés JWKS, sub-resolver DB, …) SANS polluer la signature publique (param de DI) ni recourir à `vi.mock` (fragile sur les imports transitifs, hoisting capricieux).
|
||||
- Contexte : module dont une dépendance interne doit varier en test mais dont la signature publique est un invariant ("signatures intouchables").
|
||||
- Quand l'utiliser : la dépendance est interne et `vi.mock` se révèle fragile (chaîne d'imports transitifs).
|
||||
- Quand l'éviter : la dépendance est déjà un paramètre explicite de la fonction (param-threading suffit) ; ou un `vi.mock` simple et stable fait l'affaire.
|
||||
- Avantage :
|
||||
- pas de param de DI dans le contrat public, pas de `vi.mock` fragile
|
||||
- bonus diagnostic : injecter un fake qui **throw si appelé** prouve qu'un chemin n'est PAS pris (ex. "le sub-resolver Keycloak ne doit pas être appelé quand le JWT-maison gagne")
|
||||
- Limites / vigilance :
|
||||
- **reset systématique dans `setupFile.ts` avant chaque fichier** (même garde-fou que les rate-limiters in-memory : un fichier qui injecte ne doit pas polluer le suivant → flakiness inter-fichiers évitée)
|
||||
- le défaut de PRODUCTION reste le comportement réel (ex. stub fail-closed `() => null` tant que la vraie impl n'existe pas)
|
||||
- Validé le : 14-06-2026
|
||||
- Contexte technique : Vitest — RL799_V2 (rate-limiters, puis Keycloak K1.1)
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// module de prod
|
||||
let active = productionDefault;
|
||||
|
||||
export function __setXForTests(v: typeof productionDefault): void { active = v; }
|
||||
export function __resetXForTests(): void { active = productionDefault; }
|
||||
```
|
||||
|
||||
```typescript
|
||||
// setupFile.ts — reset AVANT chaque fichier
|
||||
beforeEach(() => {
|
||||
__resetXForTests();
|
||||
__resetAllRateLimitersForTests();
|
||||
});
|
||||
```
|
||||
|
||||
Cas vécus : `__resetAllRateLimitersForTests` (`rateLimiter.ts`), puis `__setKeycloakKeyGetterForTests` / `__setSubResolverForTests` (K1.1).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-e2e-db-based-nestjs-prisma-v7"></a>
|
||||
## Pattern : e2e DB-based NestJS + Prisma v7 (Jest + @swc/jest)
|
||||
|
||||
- Objectif : exécuter des e2e Jest contre une vraie base Postgres (sans mocker `PrismaClient`) dans un projet NestJS utilisant Prisma v7.
|
||||
- Contexte : Prisma v7 charge dynamiquement un runtime WASM (`await import('....mjs')`, `import.meta.url`) → incompatible avec la config Jest historique `ts-jest` + `moduleNameMapper` qui mock le client.
|
||||
- Quand l'utiliser : projet NestJS + Prisma v7 voulant des e2e avec persistance réelle (validation de contracts Zod réels, fixtures partagées avec le seed, intégrité cross-modules).
|
||||
- Quand l'éviter : suites qui n'ont pas besoin d'une vraie DB → garder le pipeline mocké (plus rapide, plus isolé).
|
||||
- Avantage :
|
||||
- exploite des builders typés contre une vraie base
|
||||
- pattern inter-projet : tout NestJS + Prisma v7 rencontre le même problème
|
||||
- Limites / vigilance :
|
||||
- garde-fou anti-truncate destructeur obligatoire (refuser si `DATABASE_URL` ne contient pas "test")
|
||||
- **PAS** de mock global `argon2` (sinon le hash est stub et les tests d'auth ne valident rien)
|
||||
- Validé le : 27-05-2026
|
||||
- Contexte technique : NestJS / Jest / @swc/jest / Prisma v7 / Postgres — app-alexandrie
|
||||
|
||||
### Config Jest e2e DB-based minimale
|
||||
|
||||
```json
|
||||
{
|
||||
"moduleFileExtensions": ["js", "mjs", "json", "ts"],
|
||||
"testRegex": ".e2e-db-spec.ts$",
|
||||
"setupFiles": ["<rootDir>/test-env-db.ts"],
|
||||
"transformIgnorePatterns": ["/node_modules/(?!(@prisma)/)"],
|
||||
"transform": {
|
||||
"^.+\\.(t|j|mj)s$": ["@swc/jest", {
|
||||
"jsc": {
|
||||
"target": "es2023",
|
||||
"parser": { "syntax": "typescript", "decorators": true, "dynamicImport": true },
|
||||
"transform": { "legacyDecorator": true, "decoratorMetadata": true },
|
||||
"keepClassNames": true
|
||||
},
|
||||
"module": { "type": "commonjs" }
|
||||
}]
|
||||
},
|
||||
"moduleNameMapper": { "^(\\.{1,2}/.*)\\.js$": "$1" }
|
||||
}
|
||||
```
|
||||
|
||||
Points-clés :
|
||||
|
||||
- **`@swc/jest`** au lieu de `ts-jest` : gère `import.meta.url`, `await import()` dynamique, et respecte `decoratorMetadata` (DI Nest).
|
||||
- **`transformIgnorePatterns`** ouvert pour `/node_modules/@prisma/` : les `.mjs` du runtime WASM doivent passer dans le transformer.
|
||||
- **`moduleNameMapper`** strip les extensions `.js` : Prisma v7 écrit ses imports en ESM strict (`./internal/class.js`), Jest CJS résout `.ts`.
|
||||
- **PAS** de `moduleNameMapper` mappant `PrismaClient` vers un mock (sinon on tape le mock, pas la vraie DB).
|
||||
|
||||
### Helper e2e avec garde-fou anti-truncate
|
||||
|
||||
```typescript
|
||||
// _helpers/e2e-db.ts
|
||||
export async function truncateE2EDb(prisma: PrismaClient): Promise<void> {
|
||||
const url = process.env.DATABASE_URL ?? '';
|
||||
if (!url.includes('test')) {
|
||||
throw new Error('[e2e-db] truncate refusé : DATABASE_URL ne contient pas "test"');
|
||||
}
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} RESTART IDENTITY CASCADE;`);
|
||||
}
|
||||
```
|
||||
|
||||
Infra (à provisionner une fois) : DB dédiée avec "test" dans le nom + `DATABASE_URL=...test pnpm exec prisma migrate deploy`.
|
||||
|
||||
### Cohabitation avec les e2e mockés historiques
|
||||
|
||||
- Garder le pipeline mocké pour les suites sans besoin de vraie DB.
|
||||
- Migrer progressivement vers le DB-based les e2e qui bénéficient d'une vraie persistance.
|
||||
- Convention de nommage : `*.e2e-spec.ts` (mockés) vs `*.e2e-db-spec.ts` (DB-based) — extension distinctive captée par les `testRegex` respectifs.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-test-atomicite-transaction"></a>
|
||||
|
||||
Reference in New Issue
Block a user