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 &&