mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-05-18 08:18:15 +02:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user