---
title: Backend — Patterns : Prisma
domain: backend
bucket: patterns
tags: [prisma, postgres, migration, pagination, idempotency, decimal]
applies_to: [analysis, implementation, review, debug]
severity: medium
validated_on: 2026-03-23
source_projects: [app-template-resto, app-alexandrie]
---

# Backend — Patterns : Prisma

> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.

---

<a id="pattern-soft-delete-archivage-explicite"></a>

## Pattern : Soft delete et archivage explicite

- Objectif : permettre la suppression logique sans perte immédiate de données.
- Contexte : données métier critiques, besoins d'audit, restauration ou conformité.
- Quand l'utiliser : dès qu'une suppression peut avoir des impacts métier ou légaux.
- Quand l'éviter : données purement techniques ou réellement éphémères.
- Avantage :
  - Restauration possible
  - Audit et traçabilité
  - Réduction des suppressions irréversibles
- Limites / vigilance :
  - Complexité accrue sur les requêtes
  - Nécessite une discipline stricte (filtres par défaut)
- Validé le : 25-01-2026
- Contexte technique : API + DB relationnelle

### Implémentation (exemple minimal)

```txt
- Champ deletedAt (nullable) ou status
- Les requêtes standards filtrent deletedAt IS NULL
- Endpoints dédiés pour restauration / purge
- Index DB tenant compte du soft delete
```

### Piège — `include` ne filtre pas `deletedAt` automatiquement

`include: { related: true }` n'applique pas le filtre soft delete sur la relation. Si la relation pointe vers une entité elle-même soft-deletable, le doc caché reste exposé via la relation → fuite systématique.

Mitigations :

- relations to-many : `include: { related: { where: { deletedAt: null } } }`
- relations to-one (Prisma ne supporte pas `where` dans un `include` to-one) : `include: { related: { select: { deletedAt: true, ... } } }` puis filtrer post-query côté repo (`if (entity.related?.deletedAt) entity.related = null`)

Toujours `grep -rn "include.*<relationName>"` après l'ajout d'un soft delete pour identifier les sites à fixer.

### Pattern atomique anti-race delete/restore

```typescript
const result = await prisma.<model>.updateMany({
  where: { id, deletedAt: null },          // ou { not: null } pour restore
  data: { deletedAt: new Date(), deletedById: actorId },
});
if (result.count === 0) return notFound(); // idempotent, pas de double-audit
```

`updateMany` + `where: { id, deletedAt: null }` permet de transformer un check-then-update non atomique en un update atomique conditionnel — le `count === 0` distingue "déjà supprimé" de "introuvable" sans risque de double effet de bord.

### Checklist

- Filtrage soft delete par défaut
- Restauration explicite possible
- Purge maîtrisée (cron / job)
- Index DB adaptés
- Tests sur cas supprimé / restauré
- Audit des `include` sur les relations soft-deletables

---

<a id="pattern-pagination-robuste-cursor-based"></a>

## Pattern : Pagination robuste (cursor-based) pour les listings

- Objectif : fournir des listings stables et performants sans incohérences entre pages.
- Contexte : endpoints de liste (ex. /users, /orders) avec volume potentiellement important.
- Quand l'utiliser : dès qu'un listing peut dépasser quelques dizaines/centaines d'items ou subir des écritures concurrentes.
- Quand l'éviter : listes strictement petites et statiques.
- Avantage :
  - Résultats stables malgré insertions/suppressions
  - Meilleure performance que l'offset sur gros volumes
  - Expérience client plus fiable
- Limites / vigilance :
  - Nécessite un tri déterministe (champ + tie-breaker)
  - Complexité légèrement supérieure à offset/limit
- Validé le : 25-01-2026
- Contexte technique : API HTTP + DB (Postgres/MySQL), agnostique framework

### Implémentation (exemple minimal)

```txt
- Trier par (createdAt DESC, id DESC) (exemple)
- Le client envoie cursor = dernier (createdAt,id) reçu
- Le backend renvoie nextCursor si plus de résultats
- Ne jamais exposer de cursor implicite ou non documenté
```

### Checklist

- Tri déterministe (avec tie-breaker)
- nextCursor renvoyé et documenté
- Limite max de page (protection)
- Index DB aligné avec le tri

---

<a id="pattern-idempotency-key-operations-sensibles"></a>

## Pattern : Idempotency key pour opérations sensibles

- Objectif : empêcher les doublons lors de retries ou timeouts.
- Contexte : création de ressources, paiements, webhooks.
- Quand l'utiliser : toute opération non strictement en lecture.
- Quand l'éviter : endpoints purement GET.
- Avantage :
  - Protection contre doublons
  - Robustesse face aux retries
- Limites / vigilance :
  - Stockage et expiration des clés à gérer
- Validé le : 25-01-2026
- Contexte technique : API HTTP + DB transactionnelle

### Implémentation (exemple minimal)

```txt
- Client fournit Idempotency-Key
- Backend stocke la clé + résultat
- Retry retourne le résultat initial
```

### Checklist

- Clé obligatoire sur endpoints sensibles
- Contrainte d'unicité côté DB
- Comportement documenté

---

<a id="pattern-prisma-p2002-update-unique"></a>

## Pattern : mapping explicite de `P2002` Prisma sur create/update de champ unique

- Objectif : transformer un conflit d'unicité prévisible en erreur métier exploitable plutôt qu'en 500 opaque.
- Contexte : `create`, `update` ou `upsert` Prisma sur un champ `@unique` alimenté par une source externe, concurrente, ou après un pre-check.
- Quand l'utiliser : dès qu'un champ unique peut entrer en collision — à la création ET à la modification.
- Quand l'éviter : jamais si le champ peut réellement entrer en collision.
- Avantage :
  - réponse client stable
  - diagnostic métier plus rapide
- Limites / vigilance :
  - le mapping doit rester cohérent avec le format d'erreur API standardisé
- Validé le : 10-03-2026
- Contexte technique : Prisma / PostgreSQL / NestJS

### Implémentation (exemple minimal)

```txt
- Catch explicite de PrismaClientKnownRequestError code P2002
- Mapping vers une erreur métier stable
- Conserver requestId et format d'erreur standardisé
```

### Implémentation (exemple complet)

```typescript
import { Prisma } from "@prisma/client";

try {
  await prisma.item.create({ data: { ... } });
  // ou: await prisma.item.update({ where: { id }, data: { ... } });
} catch (err) {
  if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
    throw new HttpError("Un élément avec ce nom existe déjà.", { status: 409 });
  }
  throw err;
}
```

**Important :** un pre-check applicatif (`findUnique` avant `create`) ne suffit pas contre les race conditions. Le `try/catch P2002` est le seul garde-fou fiable. S'applique à `create`, `update`, `updateMany`, `upsert`.

### Checklist

- `P2002` intercepté sur les creates ET les updates sensibles
- Code d'erreur métier stable (409 Conflict)
- Pas de 500 générique sur conflit prévisible

---

<a id="pattern-decimal-prisma-serialisation"></a>
## Pattern : Sérialiser les champs `Decimal` Prisma en string au niveau du repository

- Objectif : éviter que les objets `Decimal` Prisma traversent les couches et causent des erreurs de sérialisation JSON silencieuses.
- Contexte : tout champ `Decimal` en Prisma (ex: `price`) retourné via API ou Server Action.
- Quand l'utiliser : systématiquement sur tout champ `Decimal` dans les repositories.
- Risque si ignoré : `Decimal` n'est pas JSON-sérialisable nativement — comportement varie selon Node vs browser vs test runner.
- Validé le : 17-03-2026
- Contexte technique : Prisma / Node.js — app-template-resto

### Implémentation

```typescript
// Repository — convertir avant de retourner
return {
  ...dish,
  price: dish.price?.toString() ?? null, // Decimal → string
};

// DTO public
type DishDto = {
  price: string | null; // pas Decimal
};
```

---

<a id="pattern-prisma-migration-manuelle-p3014"></a>
## Pattern : Prisma — Migration manuelle sans shadow DB (P3014)

- Objectif : créer et appliquer une migration Prisma quand la shadow database est interdite (DB managée, permissions restreintes).
- Contexte : DB managées — Supabase, PlanetScale, Railway avec rôle limité, RDS sans superuser.
- Quand l'utiliser : quand `prisma migrate dev` échoue avec `P3014 Prisma Migrate could not create the shadow database`.
- Risque si ignoré : blocage complet de la migration sur env managé.
- Validé le : 23-03-2026
- Contexte technique : Prisma v7+ — app-alexandrie / Supabase

### Implémentation

```bash
# 1. Écrire le SQL manuellement
mkdir -p prisma/migrations/<timestamp>_<nom>
# Créer migration.sql à la main

# 2. Appliquer le SQL directement en DB
npx prisma db execute --file prisma/migrations/<timestamp>_<nom>/migration.sql

# 3. Marquer la migration comme appliquée dans _prisma_migrations
npx prisma migrate resolve --applied <timestamp>_<nom>

# Note Prisma v7 : ne pas utiliser --schema= (option supprimée), utiliser prisma.config.ts
```

**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>
## Pattern : Filtrage des règles métier dans le service, pas dans le repository

- Objectif : séparer la couche d'accès aux données (repository) des règles de visibilité métier (service).
- Contexte : entités publiques avec règles de filtrage (`isVisible`, `isActive`), qui varient selon le contexte appelant (public vs admin).
- Quand l'utiliser : dès qu'une règle de visibilité dépend du contexte d'appel.
- Quand l'éviter : filtres de performance (pagination, tenant scoping) — ceux-là restent dans le `where`.
- Avantage :
  - la règle est testable unitairement sans Prisma (mock de données brutes)
  - la requête DB reste simple et stable entre contextes
  - les cas futurs (ex: admin voit les invisibles) ne nécessitent pas de modifier la requête
- Validé le : 17-03-2026
- Contexte technique : Prisma / Node.js / Next.js — app-template-resto

### Implémentation (exemple minimal)

```typescript
// Repository — charge tout ce qui est candidat
async findCategories(tenantId: string) {
  return prisma.category.findMany({ where: { tenantId } }); // pas de filtre isVisible
}

// Service — applique la règle métier et mappe vers DTO
const raw = await repo.findCategories(tenantId);
return raw.filter(c => c.isVisible).map(toPublicDto);

// Admin : même repo, filtre différent dans le service admin
return raw.map(toAdminDto); // retourne tout, visible ou non
```

---

<a id="pattern-audit-transactionnel-atomique"></a>
## Pattern : Audit transactionnel — mutation et log dans la même `$transaction`

- Objectif : garantir l'invariant `mutation persistée ⇔ audit log existe` quand l'audit est un livrable métier (pas un simple effet de bord informatif).
- Contexte : opérations sensibles (correction par un délégué hors périmètre habituel, opérations admin, opérations soumises à conformité).
- Quand l'utiliser : tout flux où une mutation sans trace serait inacceptable.
- Quand l'éviter : audits purement informatifs (statistiques d'usage, debug) — fire-and-forget acceptable.
- Avantage :
  - rollback automatique si l'audit échoue → pas de mutation orpheline
  - aucune divergence possible entre l'état persisté et la trace
- Limites / vigilance :
  - une mutation peut désormais échouer pour cause "audit indisponible" → 5xx renvoyé au client (cohérent : on préfère refuser la mutation que la passer sans trace)
- Validé le : 27-04-2026
- Contexte technique : Prisma / NestJS — RL799_V2

### Implémentation

```typescript
type AuditClient = Prisma.TransactionClient | typeof prisma;

export const logActionSync = async (
  client: AuditClient,
  userId: string,
  action: string,
  targetType?: string,
  targetId?: string,
  metadata?: Record<string, unknown>,
) => {
  await client.auditLog.create({ data: { userId, action, targetType, targetId, metadata } });
};

await prisma.$transaction(async (tx) => {
  await tx.<entity>.update({ where: { id }, data: { ... } });
  await logActionSync(tx, userId, '<entity>.<action>', '<entity>', id, { ... });
});
```

### Anti-patterns

- `logAction(...)` (fire-and-forget) après le persist quand l'audit est requis métier
- `logActionSync(prisma, ...)` (hors transaction) après le persist : synchrone mais pas atomique avec la mutation
- `.catch(() => {})` autour de l'audit "pour ne pas casser la mutation"

### Checklist

- [ ] Le helper d'audit accepte un `client: AuditClient` (transaction ou prisma)
- [ ] Mutation et audit dans la même `$transaction`
- [ ] Test d'atomicité : mock `createAuditLog` qui throw → assert rollback (cf. `knowledge/backend/patterns/tests.md`)

---

<a id="pattern-index-unique-partiel-actif"></a>
## Pattern : Index unique partiel Postgres pour invariant "≤ 1 active par X"

- Objectif : enforcer l'invariant "au plus une row active par scope" au niveau base de données plutôt que via un check applicatif vulnérable aux races.
- Contexte : ressources avec un cycle de vie `active → revoked/closed` où l'invariant métier impose une seule active par user/contexte (invitation, mandat d'officier, lock éditeur).
- Quand l'utiliser : dès qu'un check applicatif "≤ 1 active" est nécessaire et que la concurrence est possible.
- Quand l'éviter : si la table n'a pas de colonne `status` discriminante ou si plusieurs rows actives sont métier-acceptables.
- Avantage :
  - 2e INSERT concurrente échoue avec contrainte unique violée (P2002) plutôt que de créer un doublon
  - défense en profondeur : le check applicatif reste, mais la DB est la dernière ligne
- Limites / vigilance :
  - Prisma ne supporte pas les unique partials en `schema.prisma` → ajouter dans la migration SQL brute
  - documenter dans la migration : un `prisma format` accidentel pourrait droper l'index
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2

### Implémentation

```sql
-- prisma/migrations/<TS>_xxx/migration.sql
CREATE UNIQUE INDEX invitations_one_active_per_user
  ON invitations(user_id) WHERE status = 'active';
```

```typescript
export const revokeAndIssueInvitation = async (input) => {
  try {
    return await prisma.$transaction(async (tx) => {
      await tx.invitation.updateMany({
        where: { userId: input.userId, status: 'active' },
        data: { status: 'revoked', revokedAt: new Date() },
      });
      return tx.invitation.create({
        data: { userId: input.userId, ..., status: 'active' },
      });
    });
  } catch (err) {
    if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
      return { ok: false, reason: 'RACE_CONFLICT' };
    }
    throw err;
  }
};
```

### Checklist

- [ ] Index partiel ajouté dans le SQL brut de la migration
- [ ] Handler `P2002` traduit en code métier (`RACE_CONFLICT`, 409)
- [ ] Test de race : `Promise.all([resend(), resend()])` puis `count({ status: 'active' }) === 1`
- [ ] Commentaire dans la migration : "Prisma ne supporte pas les unique partials en schema, ne pas droper sur `prisma format`"

---

<a id="pattern-uuid-v5-deterministe-seed"></a>
## Pattern : UUID v5 déterministe pour ids de seed

- Objectif : permettre d'écrire `seedUserId('venerable')` côté tests/code tout en garantissant que `User.id` reste un UUID RFC 4122 en base — débloque la rigidification Zod `.uuid()` en aval.
- Contexte : seed Prisma qui crée des entités référencées par slug lisible dans les tests, et qui doivent rester typées strictement côté API.
- Quand l'utiliser : nouveaux seeds OU migration d'un seed historique avec slugs littéraux comme PK.
- Quand l'éviter : si le seed est purement aléatoire (`@default(uuid())`) et qu'aucun test ne référence un user particulier par identifiant.
- Avantage :
  - déterminisme : `seedUserId('venerable')` donne toujours le même UUID v5
  - type uniforme : tous les `User.id` sont des UUID RFC 4122 → `.uuid()` activable
  - lisibilité préservée : le code de tests reste sémantique
- Limites / vigilance :
  - le slug ne doit JAMAIS être persisté en clair (mapping explicite `{ id: seedUserId(slug), ...rest }`)
  - migration depuis un seed slug existant = chantier en 2 commits (cf. pattern rigidification Zod 2 phases dans `contracts.md`)
- Validé le : 24-04-2026
- Contexte technique : Prisma / uuid v5 — RL799_V2

### Implémentation

```typescript
// packages/shared/src/utils/seedIdentity.ts
import { v5 as uuidv5 } from 'uuid';

// Namespace stable du projet (généré une fois, committé ensuite)
export const SEED_USER_NAMESPACE = '2cd71e75-dd5e-42cc-b9fa-52888c42cc3d';

export const seedUserId = (slug: string): string =>
  uuidv5(slug, SEED_USER_NAMESPACE);
```

Côté tests :

- helpers (`TEST_SECRETARY`, `TEST_VENERABLE`) exposent l'UUID résolu : les tests écrivent `TEST_SECRETARY.id`, pas `'secretaire'`
- les users ad-hoc éphémères (créés/supprimés dans le scope d'un test) utilisent `randomUUID()`, pas `seedUserId()` — réservé aux entités seed durables

### Pourquoi pas UUID v4 aléatoire

Le déterminisme est essentiel : il permet aux fixtures E2E de pointer un user précis (`const TRESORIER_ID = seedUserId('tresorier')`) sans lire la base, et garantit la reproductibilité du seed en CI.

---

<a id="pattern-test-invariant-post-seed"></a>
## Pattern : Test d'invariant post-seed

- Objectif : transformer la liste canonique des entités seed en contrat exécutable, détecter immédiatement un drift (slug ajouté hors helper, user oublié, format incohérent).
- Contexte : projet avec un seed structurant (users, configurations système) référencé par les fixtures de tests et les flux E2E.
- Quand l'utiliser : à chaque migration qui modifie la forme d'une entité seed (UUID, format d'id, contraintes).
- Quand l'éviter : seed purement aléatoire et jetable (pas de référence stable depuis les tests).
- Avantage :
  - le test devient un contrat lisible du seed, pas une abstraction
  - détecte un futur dev qui ajouterait un user via un slug littéral sans `seedUserId()`
  - détecte un user oublié ou dupliqué
- Limites / vigilance :
  - **nombre exact**, pas "au moins N" : si le seed tronque à 29 au lieu de 30, le test doit échouer
  - filtrer explicitement par la liste des slugs connus — ne pas valider "tous les users en base" (résidus possibles)
- Validé le : 24-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2

### Implémentation

```typescript
// __tests__/seedInvariants.test.ts
const SEED_SLUGS: readonly string[] = [
  'venerable', 'secretaire', /* … 30 slugs … */
];
const EXPECTED_SEED_USER_COUNT = 31;

test('seed invariant: users seed possèdent un UUID déterministe', async () => {
  assert.equal(SEED_SLUGS.length, EXPECTED_SEED_USER_COUNT, 'liste figée');

  const expectedIds = SEED_SLUGS.map(seedUserId);
  const users = await prisma.user.findMany({
    where: { id: { in: expectedIds } },
    select: { id: true },
  });

  assert.equal(users.length, EXPECTED_SEED_USER_COUNT);
  assert.ok(users.every((u) => isValidUuid(u.id)));
});
```

### Checklist

- [ ] Liste figée des slugs en constante
- [ ] Compte exact (`.equal`, pas `.gte`)
- [ ] Filtrage explicite par la liste (pas de `findMany()` global)
- [ ] Vérification du format de l'id

---

<a id="pattern-check-fail-loud-conditionnee"></a>
## Pattern : Check `RAISE EXCEPTION` conditionnée à la présence de données

- Objectif : préserver la rejouabilité de la migration sur une DB vide (dev `prisma migrate reset`) tout en gardant le fail-loud sur DB peuplée.
- Contexte : migration qui fait un backfill de données existantes et veut échouer si l'admin/owner cible est absent.
- Quand l'utiliser : toute check "fail if missing X" qui protège un backfill, jamais le schéma lui-même.
- Quand l'éviter : check de schéma purement structurel (`NOT NULL`, FK) — ces contraintes appartiennent au DDL, pas à un `RAISE`.
- Avantage :
  - DB vide (dev reset) : 0 row à backfiller → check skip propre, migration passe
  - DB prod/staging avec données : check conservée, fail-loud comme prévu
- Limites / vigilance :
  - une migration doit rester rejouable sur une DB vide ET une DB peuplée — c'est le contrat de `prisma migrate reset`
- Validé le : 21-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2

### Anti-pattern

```sql
-- ❌ Bloque tout migrate reset sur dev (DB vide)
DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true) THEN
    RAISE EXCEPTION 'Migration X requires an admin user.';
  END IF;
END $$;
```

### Pattern correct

```sql
-- ✅ Exige admin uniquement s'il y a des données à backfiller
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM cotisation_entries WHERE status = 'paid')
     AND NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true)
  THEN
    RAISE EXCEPTION 'Migration X requires an admin user to backfill existing paid entries.';
  END IF;
END $$;
```

---

<a id="pattern-revocation-atomique-etat-transversal"></a>
## Pattern : Révocation atomique d'un état transversal lors d'une transition de cycle

- Objectif : éteindre les champs d'état transversaux (délégation, lock, ownership) dans la **même transaction** que la transition de cycle de vie de l'entité parente.
- Contexte : transitions `close / archive / cancel / soft-delete / lock définitif` d'une entité qui porte un ou plusieurs champs transversaux n'ayant plus de sens dans le nouveau cycle.
- Quand l'utiliser : à chaque transition de cycle où un état transversal devient un "zombie" potentiel (délégation qui survit à la clôture, lock qui survit à l'archivage).
- Quand l'éviter : transitions sans état transversal pertinent (archivage simple).
- Avantage :
  - aucun état zombie possible
  - la valeur précédente est capturée sous lock → audit et notif fiables (pas de race entre lecture et écriture)
- Limites / vigilance :
  - les effets de bord (audit, notif) DOIVENT sortir de la transaction (best-effort, fire-and-forget)
  - l'idempotence est gérée par le `WHERE` du `updateMany` (`closedAt: null`) — la 2e tentative ne re-déclenche pas les effets de bord
- Validé le : 27-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2

### Implémentation

```typescript
let previousDelegateeId: string | null = null;
let updateCount = 0;

await prisma.$transaction(async (tx) => {
  const lockResult = await tx.$queryRaw<Array<{ delegatee_id: string | null }>>`
    SELECT delegatee_id FROM "entities"
    WHERE id = ${id} AND closed_at IS NULL
    FOR UPDATE
  `;
  if (lockResult.length === 0) return; // idempotence
  previousDelegateeId = lockResult[0].delegatee_id;

  const updated = await tx.entity.updateMany({
    where: { id, closedAt: null },
    data: {
      closedAt: now,
      closedBy: userId,
      delegateeId: null,    // ← révocation atomique dans la même tx
    },
  });
  updateCount = updated.count;
});

// Effets de bord HORS de la transaction
if (updateCount > 0 && previousDelegateeId !== null) {
  logAction(userId, 'entity:delegation_revoked_on_close', ...);
  void notifyDelegatee(previousDelegateeId, ...);
}
```

### Les 4 invariants

1. La révocation vit dans le **même `updateMany`** que la transition principale.
2. La capture de la valeur précédente est sous **`SELECT FOR UPDATE`** dans la transaction.
3. Les effets de bord (audit, notif) **sortent de la transaction**.
4. L'idempotence est gérée par le `WHERE` (`closedAt: null`) — la 2e tentative est un no-op observable.

### Tests minimaux

- Happy path : transition avec valeur transversale présente → champ nullé + audit + notif au bon target
- Sans valeur transversale : pas d'effet de bord (pas d'audit révocation, pas de notif)
- Idempotence : 2e transition retombe en already_closed sans double effet

---

<a id="pattern-migration-destructive-4-phases"></a>
## Pattern : Migration destructive en 4 phases avec sentinelle d'archive

- Objectif : refondre une table avec PK changée ou colonnes incompatibles sans perdre l'audit historique des rows métier importantes.
- Contexte : table dont la PK ou la forme évolue de façon non-rétrocompatible (ex : token en clair → hash SHA-256 stocké, slug → UUID).
- Quand l'utiliser : refonte structurelle où un `ALTER TABLE` patchwork serait fragile (FK multiples, index, contraintes).
- Quand l'éviter : ajout simple de colonne nullable, refactor cosmétique d'index.
- Avantage :
  - DROP + CREATE plus sûr qu'un patchwork ALTER quand la PK change
  - les rows historiques (`status = 'consumed'`) sont conservées pour audit
  - sentinelle d'archive non-collisionnable garantit qu'aucun login ne peut matcher une row archivée
- Limites / vigilance :
  - Phase 1 (DELETE des rows non-migrables) impose une communication aux admins pré-deploy
  - inspection manuelle obligatoire du SQL généré par `prisma migrate dev --create-only`
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2

### Recette

```sql
-- Phase 1 : invalidation propre des données non-migrables
-- Les tokens en clair ne peuvent pas être convertis en SHA-256 (one-way).
-- Les rows 'consumed' sont conservées pour audit historique.
DELETE FROM invitations WHERE status != 'consumed';

-- Phase 2 : refonte de la table
CREATE TEMP TABLE invitations_archive AS
  SELECT email, status, consumed_at FROM invitations WHERE status = 'consumed';

DROP TABLE invitations CASCADE;

CREATE TABLE invitations (
  id            TEXT NOT NULL DEFAULT gen_random_uuid()::text PRIMARY KEY,
  user_id       TEXT NOT NULL,
  token_hash    TEXT NOT NULL UNIQUE,
  ...
);
CREATE UNIQUE INDEX invitations_one_active_per_user
  ON invitations(user_id) WHERE status = 'active';

-- Phase 3 : restauration des rows consommées avec sentinelle non-collisionnable
-- '_legacy_' n'est jamais produit par crypto.randomBytes(32).toString('hex')
INSERT INTO invitations (id, user_id, token_hash, email, status, consumed_at)
SELECT
  gen_random_uuid()::text,
  u.id,
  '_legacy_' || gen_random_uuid()::text,
  a.email,
  'consumed',
  a.consumed_at
FROM invitations_archive a
JOIN users u ON u.email = a.email;

DROP TABLE invitations_archive;

-- Phase 4 : drop des colonnes obsolètes sur d'autres tables
ALTER TABLE users DROP COLUMN IF EXISTS must_change_password;
```

Côté repository, filtrer la sentinelle :

```typescript
const LEGACY_TOKEN_HASH_PREFIX = '_legacy_';

export const findInvitationByTokenHash = async (tokenHash: string) => {
  if (tokenHash.startsWith(LEGACY_TOKEN_HASH_PREFIX)) return null;
  // … lookup normal
};
```

### Checklist

- [ ] Phase 1 communiquée aux admins pré-deploy si tokens actifs en cours
- [ ] Phase 2 préfère `DROP + CREATE` quand la PK change
- [ ] Phase 3 utilise un préfixe **garanti non-collisionnable** par construction cryptographique
- [ ] Idempotence (`IF EXISTS` / `IF NOT EXISTS`) sur les changements réversibles
- [ ] 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).

