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

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

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

View File

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