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
+527
View File
@@ -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).