diff --git a/10_backend_patterns_valides.md b/10_backend_patterns_valides.md
index 085f1f1..568f750 100644
--- a/10_backend_patterns_valides.md
+++ b/10_backend_patterns_valides.md
@@ -8,7 +8,7 @@ Ce fichier contient **uniquement** des patterns back-end :
Objectif : éviter de réinventer la roue et réduire le temps de debug.
-Dernière mise à jour : 20-03-2026
+Dernière mise à jour : 23-03-2026
---
@@ -30,7 +30,7 @@ Dernière mise à jour : 20-03-2026
- [RedisHealthService avec cache interne court](#pattern-redis-health-cache-court)
- [Sémantique explicite `Trial` vs `Paid` dans Subscription](#pattern-subscription-trial-vs-paid)
- [Restauration d’achats Stripe en 3 étapes](#pattern-restauration-achats-stripe)
-- [Mapping explicite de `P2002` Prisma sur update de champ unique](#pattern-prisma-p2002-update-unique)
+- [Mapping explicite de `P2002` Prisma sur create/update de champ unique](#pattern-prisma-p2002-update-unique)
- [Autorisation interne minimale sans RBAC complet](#pattern-autorisation-interne-minimale)
- [Anti-énumération sur endpoints auth liés à un email](#pattern-anti-enumeration-auth-email)
- [Token à usage unique — génération, hash et invalidation atomique](#pattern-token-usage-unique)
@@ -42,6 +42,15 @@ Dernière mise à jour : 20-03-2026
- [Opérations auth sensibles — atomiques, idempotentes et cohérentes](#pattern-auth-operations-atomiques)
- [Réponse HTTP 200 avec payload métier pour les états d'accès](#pattern-http-200-payload-metier)
- [Quota journalier Redis atomique (INCR + EXPIREAT pipeline)](#pattern-quota-redis-atomique)
+- [Filtrage des règles métier dans le service, pas dans le repository](#pattern-filtrage-metier-service)
+- [Sérialiser les champs `Decimal` Prisma en string au niveau du repository](#pattern-decimal-prisma-serialisation)
+- [Extraire les helpers de résolution tenant dans un module partagé dédié](#pattern-helper-tenant-module-partage)
+- [Helper centralisé d'activation de features tenant-scoped](#pattern-helper-feature-flag-tenant)
+- [Réutiliser un champ existant plutôt que créer un modèle dédié en V1](#pattern-reutiliser-champ-existant-v1)
+- [Valider le protocole d'une URL externe avant de la passer à un lien public](#pattern-validation-url-externe)
+- [Utilitaires purs : extraire dans un module sans `server-only`](#pattern-utilitaires-purs-module-partage)
+- [EN enforcement optionnel par tenant (toggle + publish gate)](#pattern-en-enforcement-tenant)
+- [Prisma — Migration manuelle sans shadow DB (P3014)](#pattern-prisma-migration-manuelle-p3014)
---
@@ -686,11 +695,11 @@ handlePackWebhookEvent(event): PackWebhookResult | null
-## Pattern : mapping explicite de `P2002` Prisma sur update de champ unique
+## 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 : `update` Prisma sur un champ `@unique` alimenté par une source externe ou concurrente.
-- Quand l’utiliser : dès qu’un champ unique peut être mis à jour après création.
+- 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
@@ -708,10 +717,28 @@ handlePackWebhookEvent(event): PackWebhookResult | null
- 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 updates sensibles
-- Code d’erreur métier stable
+- `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
---
@@ -1149,3 +1176,260 @@ if (count !== null && count > QUOTA_MAX) {
- [ ] Mode dégradé permissif si `count === null` (Redis down)
- [ ] Clé nommée `{app}:quota:{action}:{userId}:{yyyy-mm-dd}` (date UTC)
- [ ] Anti-pattern évité : `incrBy` + `setEx` séparés (race condition si count === 1 concurrent)
+
+---
+
+
+## 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
+```
+
+---
+
+
+## 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
+};
+```
+
+---
+
+
+## Pattern : Extraire les helpers de résolution tenant dans un module partagé dédié
+
+- Objectif : éviter les couplages sémantiques incorrects entre domaines en centralisant les utilitaires transverses tenant.
+- Contexte : toute fonction de résolution de tenant utilisée par plusieurs domaines métier.
+- Quand l'utiliser : dès qu'un helper est importé par plus d'un module métier.
+- Risque si ignoré : un module métier devient dépendance implicite d'un autre domaine distinct.
+- Validé le : 17-03-2026
+- Contexte technique : Next.js / TypeScript — app-template-resto
+
+### Implémentation
+
+```typescript
+// ✅ src/server/tenant/resolvePublicTenant.ts
+export function resolvePublicTenantSelection(env: NodeJS.ProcessEnv) { ... }
+
+// ✅ Rétrocompatibilité depuis l'ancien emplacement si nécessaire
+export { resolvePublicTenantSelection } from "@/server/tenant/resolvePublicTenant";
+```
+
+---
+
+
+## Pattern : Helper centralisé d'activation de features tenant-scoped
+
+- Objectif : centraliser la logique d'activation/désactivation de pages ou modules par tenant dans un helper pur.
+- Contexte : app multi-tenant avec features activables (pages publiques, modules optionnels, intégrations).
+- Quand l'utiliser : dès qu'une feature peut être activée/désactivée par tenant.
+- Avantage :
+ - helper pur et testable sans I/O
+ - comportement par défaut sain (`null`/`undefined` → tout activé)
+ - composants de navigation et pages importent ce helper, jamais Prisma directement
+- Validé le : 17-03-2026
+- Contexte technique : Next.js App Router / TypeScript — app-template-resto
+
+### Implémentation
+
+```typescript
+// src/server/public/publicPagesConfig.ts
+export function isPublicPageEnabled(
+ config: PublicPagesConfigRecord | null | undefined,
+ pageKey: PublicPageKey
+): boolean {
+ if (!config) return true; // config absente = tout activé par défaut
+ return config[PAGE_KEY_TO_CONFIG_FIELD[pageKey]];
+}
+```
+
+**Règle :** `null`/`undefined` → tout activé. Évite les régressions si la config n'a pas été provisionnée.
+
+---
+
+
+## Pattern : Réutiliser un champ existant plutôt que créer un modèle dédié en V1
+
+- Objectif : éviter la sur-ingénierie en V1 en réutilisant un champ existant quand le besoin est simple.
+- Contexte : early-stage, besoin de stocker une configuration simple (URL, flag, valeur unique).
+- Quand l'utiliser : quand la donnée a le même cycle de vie qu'un modèle existant et ne nécessite pas de relations.
+- Quand l'éviter : si la configuration a son propre cycle de vie, des cardinalités multiples, ou des relations distinctes.
+- Avantage : zéro migration supplémentaire, zéro scope creep
+- Validé le : 17-03-2026
+- Contexte technique : Prisma / Node.js — app-template-resto
+
+### Règle
+
+```txt
+- Avant de créer un modèle ReservationConfig, vérifier si PublicHomeProfile.reservationUrl suffit
+- Un champ optionnel dans le modèle le plus proche est suffisant en V1
+- Ne créer un modèle dédié que si : cycle de vie distinct, relations, ou cardinalités multiples
+```
+
+---
+
+
+## Pattern : Valider le protocole d'une URL externe avant de la passer à un lien public
+
+- Objectif : prévenir les injections `javascript:` et URLs malformées dans les `` ou `` publics.
+- Contexte : toute URL venant d'une config tenant, DB ou saisie utilisateur, rendue dans le HTML.
+- Quand l'utiliser : systématiquement sur tout champ URL libre stocké en DB et rendu côté HTML.
+- Risque si ignoré : injection `javascript:`, URL malformée, potentiel XSS.
+- Validé le : 17-03-2026
+- Contexte technique : Node.js / Next.js — app-template-resto
+
+### Implémentation
+
+```typescript
+function isSafeUrl(url: string): boolean {
+ try {
+ const { protocol } = new URL(url);
+ return protocol === "https:" || protocol === "http:";
+ } catch {
+ return false;
+ }
+}
+
+// Validation complète en service/repository
+if (mediaUrl) {
+ try { new URL(mediaUrl); } catch { throw new HttpError("URL invalide.", { status: 400 }); }
+ if (!mediaUrl.startsWith("https://") && !mediaUrl.startsWith("http://"))
+ throw new HttpError("URL doit commencer par https://.", { status: 400 });
+ if (mediaUrl.length > 500)
+ throw new HttpError("URL trop longue.", { status: 400 });
+}
+// Retourner null si invalide — le composant gère l'absence d'URL
+```
+
+### Checklist
+
+- [ ] Validation format (`new URL()`) + protocole + longueur max
+- [ ] Retourner `null` si invalide, jamais passer la string brute
+- [ ] Composant UI reçoit `string | null`, jamais une string non vérifiée
+
+---
+
+
+## Pattern : Utilitaires purs — extraire dans un module sans `server-only`
+
+- Objectif : permettre aux repositories et aux tests d'importer la même implémentation des utilitaires purs sans friction.
+- Contexte : fonctions pures (slugify, formatters, validators) utilisées par des repositories qui ont `server-only`.
+- Quand l'utiliser : dès qu'une fonction pure est utilisée dans un repository ET dans des tests.
+- Risque si ignoré : logique dupliquée dans les tests qui diverge silencieusement de l'implémentation réelle.
+- Validé le : 21-03-2026
+- Contexte technique : Node.js / Next.js — app-template-resto
+
+### Implémentation
+
+```
+src/server/menuAdmin/
+ allergensRepository.ts ← import { slugify } from "./slugify"
+ slugify.ts ← export function slugify() {} // pas de "server-only"
+
+tests/
+ allergens-admin.test.ts ← import { slugify } from "../src/server/menuAdmin/slugify.ts"
+```
+
+---
+
+
+## Pattern : EN enforcement optionnel par tenant (toggle + publish gate)
+
+- Objectif : permettre à un tenant d'activer l'obligation de remplir les champs traduits EN, avec une gate à la publication.
+- Contexte : app multi-tenant avec internationalisation optionnelle.
+- Quand l'utiliser : dès qu'un tenant peut choisir d'activer/désactiver une exigence de contenu i18n.
+- Validé le : 21-03-2026
+- Contexte technique : Prisma / Next.js App Router — app-template-resto
+
+### Implémentation
+
+```typescript
+// 1. Modèle Tenant
+enableEn Boolean @default(false)
+
+// 2. Vérification dans chaque action mutante (create/update)
+const { enableEn } = await getEnConfig(tenantId);
+if (enableEn && !labelEn) throw new HttpError("Traduction EN requise.", { status: 400 });
+
+// 3. Gate publish — vérification de complétude
+const result = await checkEnCompleteness(tenantId); // 4 requêtes en Promise.all
+// Exclut : isSystem:true, tenantId:null, isVisible:false
+if (!result.complete) throw new HttpError("Contenu EN incomplet.", { status: 422 });
+```
+
+**Règles :**
+- `isVisible: false` n'est pas inclus dans le check (une entité masquée ne bloque pas la publication)
+- `revalidatePath` sur **toutes** les pages menu après toggle du flag (pas seulement `/settings`)
+
+---
+
+
+## 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/_
+# Créer migration.sql à la main
+
+# 2. Appliquer le SQL directement en DB
+npx prisma db execute --file prisma/migrations/_/migration.sql
+
+# 3. Marquer la migration comme appliquée dans _prisma_migrations
+npx prisma migrate resolve --applied _
+
+# 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.
diff --git a/10_backend_risques_et_vigilance.md b/10_backend_risques_et_vigilance.md
index c29f58e..e9b13db 100644
--- a/10_backend_risques_et_vigilance.md
+++ b/10_backend_risques_et_vigilance.md
@@ -8,7 +8,7 @@ Ce fichier recense des risques back-end susceptibles de provoquer :
- régressions coûteuses,
- incohérences de données.
-Dernière mise à jour : 20-03-2026
+Dernière mise à jour : 23-03-2026
---
@@ -49,6 +49,20 @@ Dernière mise à jour : 20-03-2026
- [NestJS 11 — `TooManyRequestsException` inexistante](#risque-nestjs-toomanyrequest)
- [`ForbiddenException` utilisé pour des erreurs de validation](#risque-forbidden-pour-validation)
- [PrismaService — getter explicite manquant sur nouveau modèle](#risque-prismaservice-getter-manquant)
+- [Endpoints GET sans contrôle d'accès sur ressource protégée](#risque-get-sans-controle-acces)
+- [Divergence schéma Prisma / spec story (champ déclaré ✅ mais absent)](#risque-schema-divergence-spec-story)
+- [Prisma initialisé au chargement de module — casse le build Next.js](#risque-prisma-init-module-build)
+- [`server-only` dans les repositories — bloque les tests unitaires](#risque-server-only-repositories-tests)
+- [Controller NestJS corrompu par insertions multiples](#risque-controller-corrompu-insertions)
+- [TTL Redis quota calculé en heure locale (dérive jusqu'à ±12h)](#risque-ttl-redis-heure-locale)
+- [Story "completed" avec tâches ❌ auto-déclarées](#risque-story-completed-taches-echec)
+- [Story "done" sans aucun fichier source dans la File List](#risque-story-done-file-list-vide)
+- [Prisma `$transaction` multi-tenant : écriture sans `tenantId` dans le WHERE (TOCTOU)](#risque-prisma-transaction-toctou-tenantid)
+- [Prisma OR multi-tenant : `tenantId: null` manquant sur la branche système](#risque-prisma-or-tenantid-null)
+- [Calcul de `nextOrder` hors transaction (race condition `sortOrder`)](#risque-nextorder-hors-transaction)
+- [Redirect vers la page désactivée elle-même (boucle infinie feature flags)](#risque-redirect-boucle-infinie)
+- [Champ `tenantId` sans FK ni relation Prisma vers `Tenant`](#risque-tenantid-sans-fk-relation)
+- [NestJS `@UseGuards(AdminRoleGuard)` sans `@RequireAdminRole()` — silencieusement ouvert](#risque-adminroleguard-sans-decorateur)
---
@@ -643,3 +657,378 @@ get forum() {
- **Checklist review** : à chaque nouvelle migration Prisma, vérifier que `prisma.service.ts` est mis à jour.
- Contexte technique : NestJS / PrismaService encapsulé — app-alexandrie 20-03-2026
+
+---
+
+
+## Endpoints GET sans contrôle d'accès sur ressource protégée
+
+### Risques
+
+- Un endpoint de lecture expose des données premium/protégées à tout utilisateur authentifié
+- La règle "seuls les writes vérifient les droits" est un anti-pattern qui cause des fuites silencieuses
+
+### Symptômes
+
+- `getCategories`, `getThreads` ou équivalent accessible sans vérification d'entitlements
+- Endpoint write protégé par `assertForumAccess` mais GET correspondant non protégé
+
+### Bonnes pratiques / mitigations
+
+- Tout endpoint retournant des données liées à une ressource protégée (forum pack, contenu premium) doit appeler `assertForumAccess` ou équivalent, même pour les GET
+- **Checklist review** : pour chaque nouveau GET, vérifier qu'il passe par le guard/helper d'accès si la ressource appartient à un scope protégé
+
+- Contexte technique : NestJS / app-alexandrie — 23-03-2026
+
+---
+
+
+## Divergence schéma Prisma / spec story (champ déclaré ✅ mais absent)
+
+### Risques
+
+- Une tâche de story cochée ✅ implique un champ (ex: `consumedAt`, `tokenHash`) qui n'existe pas dans `schema.prisma`
+- Le code compile ou passe en review sans que le champ soit réellement présent en DB
+
+### Symptômes
+
+- Erreur à l'exécution sur un champ inexistant malgré une story marquée "done"
+- `schema.prisma` ne contient pas le champ mentionné dans les tâches
+
+### Bonnes pratiques / mitigations
+
+- Avant de marquer une tâche ✅, croiser avec `schema.prisma` pour confirmer que le champ existe réellement
+- Une story peut décrire un champ comme stratégie de conception sans l'avoir intégré — toujours vérifier
+
+- Contexte technique : Prisma / app-template-resto — 16-03-2026
+
+---
+
+
+## Prisma initialisé au chargement de module — casse le build Next.js
+
+### Risques
+
+- Un import global qui initialise Prisma immédiatement peut faire échouer la collecte de pages/routes au build si `DATABASE_URL` n'est pas disponible dans l'environnement de build
+
+### Symptômes
+
+- `PrismaClientInitializationError` ou `Error: Environment variable not found: DATABASE_URL` au `next build`
+- L'app tourne en dev mais le build CI échoue
+
+### Bonnes pratiques / mitigations
+
+- Préférer une initialisation lazy-safe : retarder l'accès DB au moment de l'appel métier
+- Retourner un proxy qui lève une erreur claire uniquement lors du premier accès réel à la DB
+- Ne jamais instancier `new PrismaClient()` au top-level d'un module importé par Next.js
+
+- Contexte technique : Next.js App Router / Prisma — app-template-resto 16-03-2026
+
+---
+
+
+## `server-only` dans les repositories — bloque les tests unitaires
+
+### Risques
+
+- `import "server-only"` empêche l'exécution des fichiers hors runtime Next.js
+- Les tests Node.js échouent avec `Error: This module cannot be imported from a Client Component module`
+
+### Symptômes
+
+- Tests qui passent via le dev server mais échouent via `jest` en mode node
+- Erreur au `require()` d'un repository depuis un test unitaire
+
+### Bonnes pratiques / mitigations
+
+- Ne mettre `server-only` que dans les fichiers qui utilisent des APIs Next.js runtime (`cookies()`, `headers()`, `redirect()`)
+- **Ne pas** mettre `server-only` dans les repositories purs (qui n'appellent que Prisma)
+- Alternative de secours : créer un stub `node_modules/server-only/index.js` no-op pour les tests
+
+- Contexte technique : Next.js App Router / Jest — app-template-resto 16-03-2026
+
+---
+
+
+## Controller NestJS corrompu par insertions multiples
+
+### Risques
+
+- Des méthodes imbriquées, décorateurs orphelins ou routes dupliquées cassent la syntaxe TypeScript sans que le compilateur ne l'attrape toujours
+- La story est marquée "completed" alors que le code ne compile pas
+
+### Symptômes
+
+- `@Get('/route')` apparaît dans le corps d'une autre méthode
+- La même route est déclarée 2-3 fois dans le même controller
+- Erreur NestJS au runtime mais pas à la compilation
+
+### Bonnes pratiques / mitigations
+
+- Quand on ajoute >3 endpoints à un controller existant, réécrire le fichier entier en partant du fichier original
+- Ne jamais insérer par blocs séparés — la concaténation casse la structure AST
+- **Checklist review** : grep `@Get\|@Post\|@Patch\|@Delete` dans le controller et vérifier qu'aucune route n'est dupliquée
+
+- Contexte technique : NestJS / TypeScript — app-alexandrie 20-03-2026
+
+---
+
+
+## TTL Redis quota calculé en heure locale (dérive jusqu'à ±12h)
+
+### Risques
+
+- Le reset du quota journalier dérive selon le timezone du serveur, pouvant aller jusqu'à ±12h d'écart par rapport à minuit UTC
+
+### Symptômes
+
+- Quota qui se remet à zéro à des heures inattendues selon l'environnement de déploiement
+- Comportement différent en dev local (TZ machine) et en prod (TZ container)
+
+### Bonnes pratiques / mitigations
+
+```typescript
+// ✅ CORRECT — UTC midnight garanti
+const midnight = new Date(
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1),
+);
+const ttlMs = midnight.getTime() - now.getTime();
+
+// ❌ RISQUÉ — heure locale du serveur
+const endOfDay = new Date();
+endOfDay.setHours(23, 59, 59, 999); // dérive selon TZ serveur
+```
+
+- Règle : tout `expireAt` ou `TTL` de quota journalier doit utiliser `Date.UTC()` — vérifier systématiquement en review
+
+- Contexte technique : Redis / NestJS — app-alexandrie 20-03-2026
+
+---
+
+
+## Story "completed" avec tâches ❌ auto-déclarées
+
+### Risques
+
+- Un agent sette `Status: completed` alors que son propre Dev Agent Record liste des items ❌ non implémentés
+- Le store mobile, service ou tests peuvent être déclarés manquants par l'agent lui-même mais la story semble terminée
+
+### Symptômes
+
+- Dev Agent Record contient `❌ store mobile non implémenté` mais `Status: completed`
+- Code review découvre des ACs non satisfaits
+
+### Bonnes pratiques / mitigations
+
+- Avant de setter `Status: completed`, vérifier que le Dev Agent Record ne contient aucun ❌
+- En cas de doute ou d'item manquant, setter `Status: review` pour déclencher la code review
+- **Règle** : `Status: completed` = zéro ❌ auto-déclaré dans le Dev Agent Record
+
+- Contexte technique : BMAD / workflow agent — app-alexandrie 20-03-2026
+
+---
+
+
+## Story "done" sans aucun fichier source dans la File List
+
+### Risques
+
+- Un agent peut halluciner la completion d'une story en produisant une note générique sans écrire de code
+- La File List ne contient que des fichiers `_bmad-output/` mais aucun `src/`, `prisma/`, `tests/`
+
+### Symptômes
+
+- Completion note générique du type "Ultimate context engine analysis completed"
+- File List réduite à 2 fichiers meta (story file, sprint-status)
+- `git log --follow src/` ne montre aucun commit lié à la story
+
+### Bonnes pratiques / mitigations
+
+- Lors d'une code review, si la File List ne contient aucun fichier source : traiter comme non implémentée
+- Vérifier avec `git log --follow src/` avant d'accepter le `Status: done`
+- Ne pas faire confiance au status `done` sans preuve dans le code
+
+- Contexte technique : BMAD / agent Codex — app-template-resto 21-03-2026
+
+---
+
+
+## Prisma `$transaction` multi-tenant : écriture sans `tenantId` dans le WHERE (TOCTOU)
+
+### Risques
+
+- Un pre-check d'appartenance tenant + une `$transaction` avec `update({ where: { id } })` sans `tenantId` crée une fenêtre TOCTOU
+- Un bug upstream qui laisse passer un id cross-tenant peut contourner l'isolation
+
+### Symptômes
+
+- Vérification préalable OK mais écriture sur une ressource d'un autre tenant possible en race condition
+- Le guard applicatif est passé mais la DB n'enforce pas au niveau de l'écriture
+
+### Bonnes pratiques / mitigations
+
+```typescript
+// ❌ Anti-pattern — check OK mais écriture sans tenantId
+const existing = await prisma.item.findMany({ where: { id: { in: ids }, tenantId } });
+await prisma.$transaction(
+ ids.map((id, idx) => prisma.item.update({ where: { id }, data: { sortOrder: idx + 1 } }))
+);
+
+// ✅ Défense en profondeur — tenantId dans chaque écriture
+await prisma.$transaction(
+ ids.map((id, idx) => prisma.item.updateMany({ where: { id, tenantId }, data: { sortOrder: idx + 1 } }))
+);
+```
+
+- Règle : toute écriture Prisma sur une ressource tenant-aware doit inclure `tenantId` dans le WHERE, même dans une transaction précédée d'un check
+- Utiliser `updateMany`/`deleteMany` (pas `update`/`delete`) pour inclure `tenantId` sans exception si 0 lignes
+
+- Contexte technique : Prisma / multi-tenant — app-template-resto 21-03-2026
+
+---
+
+
+## Prisma OR multi-tenant : `tenantId: null` manquant sur la branche système
+
+### Risques
+
+- Sur un modèle à `tenantId` nullable distinguant ressources "système" et "tenant", un filtre `{ isSystem: true }` sans `tenantId: null` expose des ressources corrompues à tous les tenants
+
+### Symptômes
+
+- Un tag `isSystem: true` avec `tenantId` non-null est exposé à tous les tenants
+- Bug de sécurité difficile à détecter car le comportement nominal semble correct
+
+### Bonnes pratiques / mitigations
+
+```typescript
+// ❌ Trop permissif
+OR: [{ isSystem: true }, { tenantId, isSystem: false }]
+
+// ✅ Défense en profondeur — double condition sur la branche système
+OR: [{ isSystem: true, tenantId: null }, { tenantId, isSystem: false }]
+```
+
+- Règle : sur tout modèle `tenantId?` (nullable) + flag `isSystem`/`isGlobal`/`isPublic`, la branche "ressource publique" du filtre OR doit toujours inclure `tenantId: null`
+
+- Contexte technique : Prisma / multi-tenant — app-template-resto 21-03-2026
+
+---
+
+
+## Calcul de `nextOrder` hors transaction (race condition `sortOrder`)
+
+### Risques
+
+- Deux requêtes concurrentes obtiennent le même `MAX(sortOrder)` et créent deux entités avec le même `sortOrder`
+
+### Symptômes
+
+- Deux items avec le même `sortOrder` dans la même catégorie/scope
+- Bug aléatoire selon la charge — invisible en dev, présent en prod
+
+### Bonnes pratiques / mitigations
+
+```typescript
+// ✅ Calcul dans la transaction interactive
+return prisma.$transaction(async (tx) => {
+ const maxOrder = await tx.entity.aggregate({
+ where: { tenantId, scopeId },
+ _max: { sortOrder: true },
+ });
+ const nextOrder = (maxOrder._max.sortOrder ?? 0) + 1;
+ return tx.entity.create({ data: { ..., sortOrder: nextOrder } });
+});
+```
+
+- Règle : ne jamais calculer `maxOrder` hors de la transaction qui crée l'entité
+
+- Contexte technique : Prisma / transactions — app-template-resto 21-03-2026
+
+---
+
+
+## Redirect vers la page désactivée elle-même (boucle infinie feature flags)
+
+### Risques
+
+- Une page désactivée redirige vers elle-même via le fallback — boucle infinie silencieuse absorbée par Next.js mais UX cassée
+
+### Symptômes
+
+- Page `/` désactivée → redirect vers `buildLocalizedPath("home")` = `/` → boucle
+- Next.js absorbe la boucle mais l'utilisateur voit un écran bloqué ou vide
+
+### Bonnes pratiques / mitigations
+
+```typescript
+// Si la page est sa propre destination de fallback, ne pas rediriger
+if (pageKey === "home") return null; // évite redirect home → home
+return buildLocalizedPath(locale, "home");
+```
+
+- Règle : dans tout mécanisme de redirection sur page désactivée, toujours vérifier que `pageKey !== fallbackKey`
+- Retourner `null` (accès non bloqué) plutôt que de boucler
+
+- Contexte technique : Next.js App Router / feature flags tenant — app-template-resto 17-03-2026
+
+---
+
+
+## Champ `tenantId` sans FK ni relation Prisma vers `Tenant`
+
+### Risques
+
+- Un `tenantId TEXT NOT NULL` sans relation Prisma ne génère aucune FK en DB
+- L'isolation multi-tenant n'est pas enforced au niveau base de données
+
+### Symptômes
+
+- Migration SQL sans `ALTER TABLE ... ADD CONSTRAINT ... REFERENCES "tenants"`
+- Prisma ne génère pas de FK automatiquement sans `@relation` déclarée
+
+### Bonnes pratiques / mitigations
+
+Tout modèle tenant-scoped doit avoir les trois :
+1. `tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)` dans le modèle Prisma
+2. La relation inverse dans `Tenant` (ex: `menuCategories MenuCategory[]`)
+3. La FK correspondante dans la migration SQL
+
+- **Checklist review** : vérifier systématiquement que les nouveaux modèles respectent ce guardrail
+
+- Contexte technique : Prisma / multi-tenant — app-template-resto 17-03-2026
+
+---
+
+
+## NestJS `@UseGuards(AdminRoleGuard)` sans `@RequireAdminRole()` — silencieusement ouvert
+
+### Risques
+
+- `AdminRoleGuard.canActivate()` lit la metadata `REQUIRE_ADMIN_ROLE_KEY` posée par `@RequireAdminRole()`
+- Si le décorateur est absent, `requiresAdmin = false/undefined` → le guard retourne `true` et laisse passer sans vérification
+
+### Symptômes
+
+- Endpoint admin accessible à tout utilisateur authentifié
+- Zéro erreur de compilation ou de démarrage — le bug est silencieux
+
+### Bonnes pratiques / mitigations
+
+```typescript
+// ✅ Correct — les deux décorateurs ensemble
+@Post('admin/ressource')
+@UseGuards(AdminRoleGuard)
+@RequireAdminRole()
+async createRessource(...) {}
+
+// ❌ Silencieusement non protégé — @RequireAdminRole() manquant
+@Post('admin/ressource')
+@UseGuards(AdminRoleGuard)
+async createRessource(...) {}
+```
+
+- Règle : s'applique à tout guard NestJS qui délègue la décision à une metadata de décorateur
+- **Checklist review** : vérifier systématiquement les endpoints admin que `@RequireAdminRole()` est présent
+
+- Contexte technique : NestJS / guards metadata — app-alexandrie 23-03-2026
diff --git a/10_frontend_patterns_valides.md b/10_frontend_patterns_valides.md
index df7823b..c6cda56 100644
--- a/10_frontend_patterns_valides.md
+++ b/10_frontend_patterns_valides.md
@@ -12,7 +12,7 @@ Il sert de **mémoire durable** pour éviter :
- de redélibérer éternellement sur des sujets déjà tranchés,
- de propager des “bonnes pratiques” théoriques non éprouvées.
-Dernière mise à jour : 20-03-2026
+Dernière mise à jour : 23-03-2026
---
@@ -29,6 +29,11 @@ Dernière mise à jour : 20-03-2026
- [Tests de styles React Native sans renderer JSX](#pattern-tests-styles-sans-renderer)
- [Export des styles de composant pour réutilisation partielle](#pattern-export-styles-composant)
- [Token typography par usage sémantique (React Native)](#pattern-token-typography-semantique)
+- [Click-to-load strict pour les embeds tiers (iframe/widget)](#pattern-click-to-load-embeds-tiers)
+- [Toggle optimiste avec rollback (React Server Action)](#pattern-toggle-optimiste-rollback)
+- [Server Action retournant l'entité — élimination de `router.refresh()` sur create/edit](#pattern-server-action-retourne-entite)
+- [ESLint flat config avec presets Next.js (`eslint.config.mjs`)](#pattern-eslint-flat-config-nextjs)
+- [Grilles 2 colonnes FR/EN — mobile-first](#pattern-grilles-2-colonnes-mobile-first)
---
@@ -634,3 +639,192 @@ mediumText12: { fontSize: 12, fontWeight: ‘500’ }, // ambigu, réutilisé
- on met à jour la date
- on précise le nouveau contexte
- En cas de doute → le pattern n’entre pas encore ici
+
+---
+
+
+## Pattern : Click-to-load strict pour les embeds tiers (iframe/widget)
+
+### Synthèse
+
+- **Objectif** : ne charger aucun service tiers sans action explicite de l’utilisateur (performance + consentement implicite).
+- **Contexte** : site/webapp avec modules de réservation, map, chat ou tout embed iframe à la demande.
+- **Quand l’utiliser** : dès qu’un embed tiers est chargé à la demande (pas au premier rendu).
+- **Quand l’éviter** : si l’embed est central à la page et doit être visible immédiatement.
+
+### Analyse
+
+- **Avantages** :
+ - LCP non pollué par des tiers (performance-first)
+ - Aucun tiers ne reçoit de données utilisateur sans action volontaire (consentement implicite)
+ - Fallback toujours disponible en cas d’erreur iframe
+- **Limites / vigilance** :
+ - Le fallback (lien externe + `tel:`) doit être actionnable même si l’embed échoue
+
+### Validation
+
+- Validé le : 21-03-2026
+- Contexte technique : React / Next.js — app-template-resto
+
+### Implémentation
+
+```tsx
+const [loaded, setLoaded] = useState(false);
+const [errored, setErrored] = useState(false);
+
+if (errored) return Ouvrir {label};
+
+return (
+ <>
+ {!loaded && }
+ {loaded &&