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:
MaksTinyWorkshop
2026-06-25 11:25:02 +02:00
parent ef24d85d57
commit f1b783407a
18 changed files with 2896 additions and 24 deletions
+123
View File
@@ -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.