mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-04-06 21:41:42 +02:00
Capitalisation complète — app-alexandrie & app-template-resto (23-03-2026)
Intègre ~50 entrées depuis 95_a_capitaliser.md vers les fichiers validés :
- backend risques : +15 (GET sans authz, TOCTOU tenantId, TTL UTC, AdminRoleGuard, P3014...)
- backend patterns : P2002 amendé (create+update) + 10 nouveaux (Decimal, URL safe, EN enforcement...)
- frontend risques : +21 (defaultValue/key, useTransition global, consent state, Tailwind invalide...)
- frontend patterns : +6 (click-to-load, toggle optimiste, Server Action retourne entité...)
- debug/postmortem : export{fn} ne crée pas de binding local
95_a_capitaliser.md remis à l'état initial vide.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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)
|
- [RedisHealthService avec cache interne court](#pattern-redis-health-cache-court)
|
||||||
- [Sémantique explicite `Trial` vs `Paid` dans Subscription](#pattern-subscription-trial-vs-paid)
|
- [Sémantique explicite `Trial` vs `Paid` dans Subscription](#pattern-subscription-trial-vs-paid)
|
||||||
- [Restauration d’achats Stripe en 3 étapes](#pattern-restauration-achats-stripe)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
- [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
|
|||||||
|
|
||||||
<a id="pattern-prisma-p2002-update-unique"></a>
|
<a id="pattern-prisma-p2002-update-unique"></a>
|
||||||
|
|
||||||
## 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.
|
- 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.
|
- 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 être mis à jour après création.
|
- 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.
|
- Quand l’éviter : jamais si le champ peut réellement entrer en collision.
|
||||||
- Avantage :
|
- Avantage :
|
||||||
- réponse client stable
|
- réponse client stable
|
||||||
@@ -708,10 +717,28 @@ handlePackWebhookEvent(event): PackWebhookResult | null
|
|||||||
- Conserver requestId et format d’erreur standardisé
|
- 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
|
### Checklist
|
||||||
|
|
||||||
- `P2002` intercepté sur les updates sensibles
|
- `P2002` intercepté sur les creates ET les updates sensibles
|
||||||
- Code d’erreur métier stable
|
- Code d’erreur métier stable (409 Conflict)
|
||||||
- Pas de 500 générique sur conflit prévisible
|
- 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)
|
- [ ] Mode dégradé permissif si `count === null` (Redis down)
|
||||||
- [ ] Clé nommée `{app}:quota:{action}:{userId}:{yyyy-mm-dd}` (date UTC)
|
- [ ] 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)
|
- [ ] Anti-pattern évité : `incrBy` + `setEx` séparés (race condition si count === 1 concurrent)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<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-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-helper-tenant-module-partage"></a>
|
||||||
|
## 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";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-helper-feature-flag-tenant"></a>
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-reutiliser-champ-existant-v1"></a>
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-validation-url-externe"></a>
|
||||||
|
## 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 `<a href>` ou `<img src>` 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-utilitaires-purs-module-partage"></a>
|
||||||
|
## 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-en-enforcement-tenant"></a>
|
||||||
|
## 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`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<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.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Ce fichier recense des risques back-end susceptibles de provoquer :
|
|||||||
- régressions coûteuses,
|
- régressions coûteuses,
|
||||||
- incohérences de données.
|
- 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)
|
- [NestJS 11 — `TooManyRequestsException` inexistante](#risque-nestjs-toomanyrequest)
|
||||||
- [`ForbiddenException` utilisé pour des erreurs de validation](#risque-forbidden-pour-validation)
|
- [`ForbiddenException` utilisé pour des erreurs de validation](#risque-forbidden-pour-validation)
|
||||||
- [PrismaService — getter explicite manquant sur nouveau modèle](#risque-prismaservice-getter-manquant)
|
- [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.
|
- **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
|
- Contexte technique : NestJS / PrismaService encapsulé — app-alexandrie 20-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-get-sans-controle-acces"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-schema-divergence-spec-story"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-prisma-init-module-build"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-server-only-repositories-tests"></a>
|
||||||
|
## `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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-controller-corrompu-insertions"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-ttl-redis-heure-locale"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-story-completed-taches-echec"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-story-done-file-list-vide"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-prisma-transaction-toctou-tenantid"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-prisma-or-tenantid-null"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-nextorder-hors-transaction"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-redirect-boucle-infinie"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-tenantid-sans-fk-relation"></a>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-adminroleguard-sans-decorateur"></a>
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -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 redélibérer éternellement sur des sujets déjà tranchés,
|
||||||
- de propager des “bonnes pratiques” théoriques non éprouvées.
|
- 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)
|
- [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)
|
- [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)
|
- [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 met à jour la date
|
||||||
- on précise le nouveau contexte
|
- on précise le nouveau contexte
|
||||||
- En cas de doute → le pattern n’entre pas encore ici
|
- En cas de doute → le pattern n’entre pas encore ici
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-click-to-load-embeds-tiers"></a>
|
||||||
|
## 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 <a href={url}>Ouvrir {label}</a>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!loaded && <button onClick={() => setLoaded(true)}>Charger {label}</button>}
|
||||||
|
{loaded && <iframe src={url} onError={() => setErrored(true)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-toggle-optimiste-rollback"></a>
|
||||||
|
## Pattern : Toggle optimiste avec rollback (React Server Action)
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : masquer la latence serveur sur un toggle boolean en mettant à jour l’UI immédiatement, avec rollback en cas d’erreur.
|
||||||
|
- **Contexte** : toggles boolean (visibilité, disponibilité, settings) où la latence doit être masquée.
|
||||||
|
- **Quand l’utiliser** : toggles sans besoin de re-fetcher l’entité entière après mutation.
|
||||||
|
- **Quand l’éviter** : mutations qui retournent des données complexes → préférer le pattern "Server Action retournant l’entité".
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 21-03-2026
|
||||||
|
- Contexte technique : React / Next.js App Router — app-template-resto
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [optimistic, setOptimistic] = useState(initialValue);
|
||||||
|
|
||||||
|
async function handleToggle() {
|
||||||
|
const prev = optimistic;
|
||||||
|
setOptimistic(!prev); // update immédiat
|
||||||
|
try {
|
||||||
|
await toggleAction(!prev);
|
||||||
|
router.refresh(); // synchronise le Server Component parent
|
||||||
|
} catch {
|
||||||
|
setOptimistic(prev); // rollback si erreur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-server-action-retourne-entite"></a>
|
||||||
|
## Pattern : Server Action retournant l’entité — élimination de `router.refresh()` sur create/edit
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : mettre à jour l’état local directement avec les données réelles retournées par le serveur, sans round-trip SSR supplémentaire.
|
||||||
|
- **Contexte** : liste d’items managée côté client (`useState`) avec création et modification via Server Actions.
|
||||||
|
- **Quand l’utiliser** : create et edit d’entités dans une liste. Plus performant que toggle optimiste + `router.refresh()`.
|
||||||
|
- **Quand l’éviter** : simples toggles boolean → le pattern optimiste avec rollback suffit.
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
- **Avantages vs toggle optimiste + `router.refresh()` :**
|
||||||
|
- Zéro aller-retour SSR supplémentaire (~500ms–2s économisés sur mobile)
|
||||||
|
- État local garanti cohérent avec la DB (données réelles, pas calculées localement)
|
||||||
|
- Pas de flash de rechargement
|
||||||
|
- **Limites / vigilance** :
|
||||||
|
- `revalidatePath` reste nécessaire pour invalider le cache des pages publiques
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 22-03-2026
|
||||||
|
- Contexte technique : React / Next.js App Router — app-template-resto story 3.8
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Repository — retourne l’entité complète
|
||||||
|
export async function createItem(tenantId: string, data: Input): Promise<ItemRow> {
|
||||||
|
return prisma.item.create({ data: { tenantId, ...data }, select: { ...fullSelect } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action — retourne la donnée au client
|
||||||
|
export async function createItemAction(formData: FormData): Promise<ItemRow> {
|
||||||
|
const actor = await requireOwner();
|
||||||
|
const item = await createItem(actor.tenantId, input);
|
||||||
|
revalidatePath("/dashboard/..."); // invalider cache pages publiques
|
||||||
|
return item; // ← clé : retourner l’entité
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client — mise à jour locale sans round-trip SSR
|
||||||
|
const created = await createItemAction(formData);
|
||||||
|
setItems((prev) => [...prev, created]); // pas de router.refresh()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pour les entités avec relations :** utiliser un helper `findItemById(tenantId, id)` appelé après la mutation pour retourner la forme complète avec les relations résolues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-eslint-flat-config-nextjs"></a>
|
||||||
|
## Pattern : ESLint flat config avec presets Next.js (`eslint.config.mjs`)
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : éviter les bugs de compatibilité de l’ancien `.eslintrc` avec Next.js récent.
|
||||||
|
- **Contexte** : projet Next.js récent utilisant déjà le flat config ESLint.
|
||||||
|
- **Quand l’utiliser** : nouveau projet Next.js ou migration ESLint.
|
||||||
|
- **Quand l’éviter** : si le projet doit rester compatible avec des outils legacy ESLint.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 16-03-2026
|
||||||
|
- Contexte technique : Next.js 16+ / ESLint flat config — app-template-resto
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// eslint.config.mjs
|
||||||
|
import nextPlugin from "@next/eslint-plugin-next";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nextPlugin.configs["core-web-vitals"],
|
||||||
|
...nextPlugin.configs["typescript"],
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// overrides ciblés ici
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="pattern-grilles-2-colonnes-mobile-first"></a>
|
||||||
|
## Pattern : Grilles 2 colonnes FR/EN — mobile-first
|
||||||
|
|
||||||
|
### Synthèse
|
||||||
|
|
||||||
|
- **Objectif** : afficher les champs FR + EN côte à côte sur desktop, en colonne unique sur mobile.
|
||||||
|
- **Contexte** : formulaires dashboard avec champs bilingues FR/EN côte à côte.
|
||||||
|
- **Quand l’utiliser** : tout formulaire avec colonnes parallèles sur un projet mobile-first.
|
||||||
|
- **Quand l’éviter** : si les champs sont indépendants et n’ont pas de relation visuelle FR/EN.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Validé le : 22-03-2026
|
||||||
|
- Contexte technique : Tailwind CSS / React — app-template-resto
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ✅ Mobile-first — colonne unique sur < 640px, 2 colonnes sur ≥ 640px -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<input placeholder="Nom (FR)" />
|
||||||
|
<input placeholder="Name (EN)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ❌ À éviter — 2 colonnes trop étroites sur mobile -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">...</div>
|
||||||
|
```
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Ce fichier recense des risques front-end susceptibles de provoquer :
|
|||||||
- dette technique rapide,
|
- dette technique rapide,
|
||||||
- régressions UX/perf/a11y.
|
- régressions UX/perf/a11y.
|
||||||
|
|
||||||
Dernière mise à jour : 20-03-2026
|
Dernière mise à jour : 23-03-2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,6 +39,26 @@ Dernière mise à jour : 20-03-2026
|
|||||||
- [Dimensions d'image via tokens `spacing` (React Native)](#risque-dimensions-image-via-spacing)
|
- [Dimensions d'image via tokens `spacing` (React Native)](#risque-dimensions-image-via-spacing)
|
||||||
- [Écran détail Expo Router — store vide en deep link / reload](#risque-store-vide-deep-link)
|
- [Écran détail Expo Router — store vide en deep link / reload](#risque-store-vide-deep-link)
|
||||||
- [`useEffect` fetch — guard incomplet sur les états terminaux](#risque-useeffect-guard-incomplet)
|
- [`useEffect` fetch — guard incomplet sur les états terminaux](#risque-useeffect-guard-incomplet)
|
||||||
|
- [Store Zustand : collections sans clé de contexte (navigation inter-contexte)](#risque-zustand-collection-sans-cle-contexte)
|
||||||
|
- [`useSearchParams()` sans `Suspense` casse le build Next.js App Router](#risque-usesearchparams-sans-suspense)
|
||||||
|
- [Type `ViewData` dupliqué entre couche serveur et composant UI (Next.js)](#risque-type-viewdata-duplique)
|
||||||
|
- [Composant React dans un fichier `.ts` — `React.createElement` workaround](#risque-composant-react-fichier-ts)
|
||||||
|
- [Double validation de segment dynamique App Router (layout + page)](#risque-double-validation-segment-app-router)
|
||||||
|
- [Faux test négatif — tester le helper au lieu de tester l'exclusion](#risque-faux-test-negatif)
|
||||||
|
- [État booléen UI dérivé hardcodé au lieu d'être calculé depuis le store](#risque-boolean-ui-hardcode-store)
|
||||||
|
- [Flag `isLoading` unique pour des opérations de natures différentes](#risque-flag-isloading-unique-nature-differente)
|
||||||
|
- [Consent state : `false` ambigu entre "pas de décision" et "refus explicite"](#risque-consent-state-false-ambigu)
|
||||||
|
- [Script inline : interpolation directe au lieu de `JSON.stringify`](#risque-script-inline-interpolation-directe)
|
||||||
|
- [Next.js App Router : `window.location.reload()` au lieu de `router.refresh()`](#risque-window-location-reload-nextjs)
|
||||||
|
- [`useTransition` + optimistic update : snapshot capturé après `setState`](#risque-usetransition-snapshot-apres-setstate)
|
||||||
|
- [`window.confirm()` dans une app React/Next.js](#risque-window-confirm-react)
|
||||||
|
- [`import type` depuis `src/server/**` dans un composant client](#risque-import-type-server-composant-client)
|
||||||
|
- [Inline styles dans les composants dashboard](#risque-inline-styles-dashboard)
|
||||||
|
- [Classes Tailwind invalides courantes (bugs silencieux)](#risque-tailwind-classes-invalides)
|
||||||
|
- [Next.js : `<img>` natif interdit dans les composants](#risque-img-natif-nextjs)
|
||||||
|
- [`useTransition` global pour des listes d'items interactifs](#risque-usetransition-global-liste-items)
|
||||||
|
- [`useCallback` inutile quand le callback est wrappé en inline au render](#risque-usecallback-inutile-inline)
|
||||||
|
- [Formulaire React avec `defaultValue` sans `key` prop](#risque-formulaire-defaultvalue-sans-key)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -440,3 +460,553 @@ if (forums.length > 0 || isLoading || paywallRequired) return;
|
|||||||
**Règle** : les états "zéro résultat intentionnel" (liste vide + flag métier) doivent être traités comme "données présentes" dans le guard de fetch.
|
**Règle** : les états "zéro résultat intentionnel" (liste vide + flag métier) doivent être traités comme "données présentes" dans le guard de fetch.
|
||||||
|
|
||||||
- Contexte technique : React Native / Zustand / Expo Router — app-alexandrie story 4.1, 20-03-2026
|
- Contexte technique : React Native / Zustand / Expo Router — app-alexandrie story 4.1, 20-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-zustand-collection-sans-cle-contexte"></a>
|
||||||
|
## Store Zustand : collections sans clé de contexte (navigation inter-contexte)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un store qui stocke des collections dépendant d'un paramètre de navigation (forumSlug, threadId...) sans stocker ce paramètre affiche des données périmées lors d'une navigation inter-contexte
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Naviguer du forum A vers le forum B affiche encore les catégories/threads du forum A
|
||||||
|
- Guard `if (items.length > 0) return` empêche le rechargement lors d'un changement de contexte
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Stocker la clé de contexte avec les données : `categoriesForumSlug: string | null`
|
||||||
|
- Invalider si `categoriesForumSlug !== currentForumSlug` avant de retourner depuis le cache
|
||||||
|
- Ou supprimer le guard et dépendre uniquement du changement de paramètre dans le `useEffect`
|
||||||
|
|
||||||
|
- Contexte technique : React Native / Zustand / Expo Router — app-alexandrie 23-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-usesearchparams-sans-suspense"></a>
|
||||||
|
## `useSearchParams()` sans `Suspense` casse le build Next.js App Router
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un composant client utilisant `useSearchParams()` peut provoquer un échec de prerender/build s'il est rendu sans boundary `Suspense` depuis la page/layout serveur
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `Error: useSearchParams() should be wrapped in a suspense boundary` au `next build`
|
||||||
|
- Fonctionne en dev mais échoue à la CI/CD
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Isoler le composant client qui utilise `useSearchParams()` et le rendre sous `<Suspense fallback={...}>` au niveau de la page
|
||||||
|
- Ne jamais appeler `useSearchParams()` directement dans un composant rendu sans `Suspense` depuis un Server Component
|
||||||
|
|
||||||
|
- Contexte technique : Next.js App Router récent / Turbopack — app-template-resto 16-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-type-viewdata-duplique"></a>
|
||||||
|
## Type `ViewData` dupliqué entre couche serveur et composant UI (Next.js)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- TypeScript accepte deux structures identiques par structural typing — si le type source évolue, la couche UI reste désynchronisée sans erreur de compilation tant que les formes correspondent
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Deux définitions du même type dans `src/server/` et `src/app/`
|
||||||
|
- Champ ajouté côté serveur mais absent dans le composant UI sans warning
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ La couche UI importe et re-exporte
|
||||||
|
export type { PublicHomeViewData } from "@/server/public/getPublicHomeData";
|
||||||
|
|
||||||
|
// ❌ À éviter — redéfinition locale
|
||||||
|
export type PublicHomeViewData = { tenantName: string; ... };
|
||||||
|
```
|
||||||
|
|
||||||
|
- Règle : le type appartient à la couche qui le produit. La couche UI importe uniquement.
|
||||||
|
|
||||||
|
- Contexte technique : Next.js App Router / TypeScript — app-template-resto 16-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-composant-react-fichier-ts"></a>
|
||||||
|
## Composant React dans un fichier `.ts` — `React.createElement` workaround
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Code illisible vs JSX natif
|
||||||
|
- Fausse impression que le fichier est "sans JSX" — peut tromper les outils de linting et les reviewers
|
||||||
|
- Empêche l'utilisation de la syntaxe JSX si on doit ajouter des enfants complexes
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `React.createElement(...)` dans un fichier `.ts`
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Tout fichier exportant une fonction retournant un `ReactElement` ou utilisant React doit avoir l'extension `.tsx`
|
||||||
|
- Sans exception
|
||||||
|
|
||||||
|
- Contexte technique : TypeScript / React — app-template-resto 16-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-double-validation-segment-app-router"></a>
|
||||||
|
## Double validation de segment dynamique App Router (layout + page)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Si le layout fait `notFound()` sur un segment invalide ET que la page répète la même condition, les deux deviennent désynchronisés silencieusement lors d'une modification
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Même condition de validation dans `layout.tsx` et `page.tsx` d'un même segment
|
||||||
|
- Modification du layout n'est pas reportée dans la page (comportement divergent)
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Si le layout garde, la page consomme — une seule responsabilité par couche
|
||||||
|
- La page doit faire confiance à son layout parent
|
||||||
|
- **Règle** : un seul composant est responsable de la garde sur un segment dynamique
|
||||||
|
|
||||||
|
- Contexte technique : Next.js App Router — app-template-resto 17-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-faux-test-negatif"></a>
|
||||||
|
## Faux test négatif — tester le helper au lieu de tester l'exclusion
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un test nommé "X n'utilise pas Y" qui appelle Y en interne est un test normal mal documenté, pas un test d'exclusion
|
||||||
|
- Donne une fausse confiance sur le comportement par défaut du helper
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Test intitulé "sans fallback, la valeur EN vide n'est pas remplacée" mais qui appelle le helper avec fallback activé
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Un vrai test négatif vérifie que X n'importe pas Y, ou que le comportement par défaut empêche l'effet indésirable
|
||||||
|
- Pour un helper à fallback optionnel : tester explicitement le cas `fallbackToFr=false` (défaut) avec une valeur vide
|
||||||
|
|
||||||
|
- Contexte technique : TypeScript / Jest — app-template-resto 17-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-boolean-ui-hardcode-store"></a>
|
||||||
|
## État booléen UI dérivé hardcodé au lieu d'être calculé depuis le store
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un état toggle (`isBookmarked`, `isLiked`, `isFollowed`) initialisé à `false` en dur ne reflète jamais l'état réel
|
||||||
|
- Le bouton est toujours en mode "ajouter" sans jamais passer en mode "supprimer"
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `const isBookmarked = false; // état local géré ci-dessous via state`
|
||||||
|
- Bouton bookmark/like toujours dans le même état visuel peu importe l'état réel
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Anti-pattern — état hardcodé
|
||||||
|
const isBookmarked = false;
|
||||||
|
|
||||||
|
// ✅ Pattern correct — dérivé du store au rendu
|
||||||
|
const { bookmarks } = useCommunityStore();
|
||||||
|
const isBookmarked = bookmarks.some((b) => b.thread.id === threadId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Règle : si le store contient la liste (bookmarks, likes, follows), l'état booléen se dérive avec `.some()` ou `.has()`
|
||||||
|
|
||||||
|
- Contexte technique : React Native / Zustand — app-alexandrie story 4.4, 20-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-flag-isloading-unique-nature-differente"></a>
|
||||||
|
## Flag `isLoading` unique pour des opérations de natures différentes
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Un même flag (ex: `isBookmarking`) utilisé à la fois pour les mutations (add/remove) et le chargement de la liste provoque des bugs visuels — spinner manquant au premier chargement si une mutation est en cours en parallèle
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Spinner absent au premier chargement de la liste bookmarks
|
||||||
|
- Bouton "ajouter" désactivé alors qu'aucune mutation n'est en cours
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Anti-pattern — un seul flag pour tout
|
||||||
|
isBookmarking: boolean;
|
||||||
|
|
||||||
|
// ✅ Pattern correct — séparation claire
|
||||||
|
isBookmarking: boolean; // mutations add/remove
|
||||||
|
isLoadingBookmarks: boolean; // chargement de la liste (GET)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Contexte technique : React Native / Zustand — app-alexandrie story 4.4, 20-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-consent-state-false-ambigu"></a>
|
||||||
|
## Consent state : `false` ambigu entre "pas de décision" et "refus explicite"
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Sans champ `decided`, `analytics: false` peut signifier "première visite" ou "refus explicite" — indistinguables
|
||||||
|
- Le banner de consentement réapparaît à chaque visite après un refus, violant l'AC de persistance du choix
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Banner qui réapparaît après rechargement malgré un refus explicite
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ConsentState = {
|
||||||
|
analytics: boolean;
|
||||||
|
decided: boolean; // true = l'utilisateur a fait un choix (cookie présent)
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT: ConsentState = { analytics: false, decided: false };
|
||||||
|
|
||||||
|
// À la lecture du cookie :
|
||||||
|
if (!cookieValue) return DEFAULT; // decided=false (première visite)
|
||||||
|
return { analytics: parsed.analytics, decided: true };
|
||||||
|
```
|
||||||
|
|
||||||
|
- L'état initial du banner doit être `!decided`, pas `!analytics`
|
||||||
|
|
||||||
|
- Contexte technique : Next.js / cookies — app-template-resto 21-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-script-inline-interpolation-directe"></a>
|
||||||
|
## Script inline : interpolation directe au lieu de `JSON.stringify`
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Injection XSS potentielle via une valeur de configuration interpolée directement dans un `<Script>` inline
|
||||||
|
- La regex de validation en amont peut évoluer et laisser passer des valeurs dangereuses
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `` {`gtag('config', '${measurementId}');`} `` — interpolation directe sans échappement
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Anti-pattern — interpolation directe
|
||||||
|
{`gtag('config', '${measurementId}');`}
|
||||||
|
|
||||||
|
// ✅ Pattern correct — JSON.stringify garantit l'échappement
|
||||||
|
{`gtag('config', ${JSON.stringify(measurementId)});`}
|
||||||
|
```
|
||||||
|
|
||||||
|
- S'applique aussi aux `dangerouslySetInnerHTML` et aux attributs `data-*` injectés en JS
|
||||||
|
|
||||||
|
- Contexte technique : Next.js / `<Script>` — app-template-resto 21-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-window-location-reload-nextjs"></a>
|
||||||
|
## Next.js App Router : `window.location.reload()` au lieu de `router.refresh()`
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Full reload = perd l'état React, navigation complète, plus lent
|
||||||
|
- `router.refresh()` est l'outil idoine : retrigger le fetch des Server Components sans détruire l'état client
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `window.location.reload()` après un Server Action dans un Client Component
|
||||||
|
- Flash de rechargement visible, perte de l'état local (scroll, focus, état de formulaire)
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Anti-pattern — full reload
|
||||||
|
await createCategoryAction(formData);
|
||||||
|
window.location.reload();
|
||||||
|
|
||||||
|
// ✅ Pattern correct — RSC diff, préserve l'état client
|
||||||
|
const router = useRouter();
|
||||||
|
await createCategoryAction(formData);
|
||||||
|
router.refresh();
|
||||||
|
```
|
||||||
|
|
||||||
|
- `router.refresh()` refetch uniquement les Server Components affectés (via `revalidatePath`) et applique un diff. L'état des Client Components est préservé.
|
||||||
|
|
||||||
|
- Contexte technique : Next.js App Router — app-template-resto 21-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-usetransition-snapshot-apres-setstate"></a>
|
||||||
|
## `useTransition` + optimistic update : snapshot capturé après `setState`
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Stale closure classique : le snapshot est capturé après `setState`, donc `categories` peut déjà référencer la nouvelle liste au moment du rollback
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Rollback optimiste qui ne restaure pas l'ancienne valeur
|
||||||
|
- Après une erreur serveur, l'état reste sur la nouvelle liste au lieu de revenir à l'état précédent
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Anti-pattern — snapshot capturé après setState
|
||||||
|
const newList = [...categories];
|
||||||
|
setCategories(newList);
|
||||||
|
startTransition(async () => {
|
||||||
|
try { await action(); }
|
||||||
|
catch { setCategories(categories); } // peut être newList
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Pattern correct — snapshot AVANT toute mutation d'état
|
||||||
|
const snapshot = categories; // capturer AVANT setCategories
|
||||||
|
setCategories(newList);
|
||||||
|
startTransition(async () => {
|
||||||
|
try { await action(); }
|
||||||
|
catch { setCategories(snapshot); } // rollback garanti
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Règle** : toujours assigner le snapshot dans un `const` **avant** le premier `setState`
|
||||||
|
|
||||||
|
- Contexte technique : React / Next.js App Router — app-template-resto 21-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-window-confirm-react"></a>
|
||||||
|
## `window.confirm()` dans une app React/Next.js
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Bloque le thread principal
|
||||||
|
- Ne fonctionne pas en SSR
|
||||||
|
- Non stylable, UX mobile mauvaise
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `if (!confirm("Supprimer ?")) return;` dans un Client Component
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Anti-pattern
|
||||||
|
if (!confirm("Supprimer ?")) return;
|
||||||
|
|
||||||
|
// ✅ Pattern correct — confirmation inline via état React
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
{deletingId === item.id && (
|
||||||
|
<div>
|
||||||
|
<span>Supprimer « {item.label} » ?</span>
|
||||||
|
<button onClick={() => { setDeletingId(null); doDelete(item.id); }}>Confirmer</button>
|
||||||
|
<button onClick={() => setDeletingId(null)}>Annuler</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- S'applique aussi à `window.alert()` et `window.prompt()`
|
||||||
|
|
||||||
|
- Contexte technique : React / Next.js — app-template-resto 21-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-import-type-server-composant-client"></a>
|
||||||
|
## `import type` depuis `src/server/**` dans un composant client
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Violation de boundary même si l'import est type-only (effacé à la compilation)
|
||||||
|
- Ouvre la porte à des imports runtime si le code est refactoré rapidement
|
||||||
|
- La règle ESLint `no-restricted-imports` doit couvrir les `import type` aussi
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `import type { Foo } from "@/server/..."` dans un fichier `"use client"`
|
||||||
|
- Passe en review car le compilateur ne bloque pas les type-only imports
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Types partagés entre server et client doivent vivre dans `src/types/` ou `src/lib/`
|
||||||
|
- Configurer `no-restricted-imports` avec `allowTypeImports: false` pour les paths serveur
|
||||||
|
|
||||||
|
- Contexte technique : Next.js App Router / TypeScript — app-template-resto 22-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-inline-styles-dashboard"></a>
|
||||||
|
## Inline styles dans les composants dashboard
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Contourne le système Tailwind + tokens CSS
|
||||||
|
- Crée des incohérences visuelles non détectées par le linter
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `style={{ color: '#123456', marginTop: 8 }}` dans un composant dashboard
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Bloquer en code review systématiquement tout `style={{...}}` dans les composants dashboard
|
||||||
|
- Exception acceptable uniquement : animations CSS dynamiques (valeurs calculées au runtime)
|
||||||
|
|
||||||
|
- Contexte technique : React / Tailwind — app-template-resto 22-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-tailwind-classes-invalides"></a>
|
||||||
|
## Classes Tailwind invalides courantes (bugs silencieux)
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Classes Tailwind invalides sont silencieusement ignorées — aucun warning, comportement visuellement cassé
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Item masqué affiché à pleine opacité (`opacity-55` → invalide)
|
||||||
|
- Largeur incorrecte (`w-35` → invalide)
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
Erreurs courantes :
|
||||||
|
- `opacity-55` → invalide. Scale : 0/5/10/20/25/30/40/50/60/70/75/80/90/95/100 → utiliser `opacity-50` ou `opacity-60`
|
||||||
|
- `w-35` → invalide. Scale saute de `w-32` à `w-36` → utiliser `w-36`
|
||||||
|
- `box-border` → redondant. Tailwind Preflight applique déjà `box-sizing: border-box` globalement
|
||||||
|
|
||||||
|
- Toujours vérifier les classes custom/non-standard avec l'extension Tailwind IntelliSense
|
||||||
|
|
||||||
|
- Contexte technique : Tailwind CSS — app-template-resto 22-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-img-natif-nextjs"></a>
|
||||||
|
## Next.js : `<img>` natif interdit dans les composants
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Warning ESLint `@next/next/no-img-element` → avec `--max-warnings=0` : erreur CI
|
||||||
|
- Pas de lazy loading, pas d'optimisation WebP, risque de layout shift (CLS)
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `<img src="..." />` dans un composant Next.js
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- Toujours utiliser `<Image>` de `next/image` à la place
|
||||||
|
- Exception acceptable : composants de test ou storybook uniquement
|
||||||
|
|
||||||
|
- Contexte technique : Next.js / ESLint — app-template-resto 22-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-usetransition-global-liste-items"></a>
|
||||||
|
## `useTransition` global pour des listes d'items interactifs
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- `isPending` global désactive **tous** les boutons de tous les items pendant qu'une opération est en cours sur un seul item
|
||||||
|
- Sur mobile : UX bloquée, impossible d'agir pendant qu'une autre opération tourne
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Clic sur "Masquer" pour l'item A → boutons des items B et C grisés
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Avant — bloque tout
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
// render : disabled={isPending}
|
||||||
|
|
||||||
|
// ✅ Après — per-item
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function handleToggle(id: string) {
|
||||||
|
setPendingId(id);
|
||||||
|
(async () => {
|
||||||
|
try { await toggleAction(id); }
|
||||||
|
catch (err) { handleError(err); }
|
||||||
|
finally { setPendingId(null); }
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
// render : disabled={pendingId === item.id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règles :**
|
||||||
|
- `pendingId === item.id` pour les boutons d'item (désactive uniquement l'item en cours)
|
||||||
|
- `pendingId !== null` pour les boutons globaux (ex: "Ajouter")
|
||||||
|
- `finally` garantit la réinitialisation même en cas d'erreur
|
||||||
|
|
||||||
|
- Contexte technique : React / Next.js — app-template-resto 22-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-usecallback-inutile-inline"></a>
|
||||||
|
## `useCallback` inutile quand le callback est wrappé en inline au render
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- Le handler stable est re-wrappé dans une arrow inline lors du passage en prop → nouvelle référence à chaque render → `React.memo` ne peut pas éviter le re-render
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleToggle = useCallback((id: string) => { ... }, []); // stable ✓
|
||||||
|
|
||||||
|
// Mais au render :
|
||||||
|
<ItemCard onToggle={() => handleToggle(item.id)} />
|
||||||
|
// ↑ nouvelle closure à chaque render → memo inutile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
- `useCallback` n'a de valeur que si le callback est passé **directement** en prop, sans re-wrapping
|
||||||
|
- Si la signature doit capturer des variables de boucle, deux options :
|
||||||
|
1. Passer les données en props et laisser l'enfant appeler le handler avec ses propres props
|
||||||
|
2. Accepter que `memo` ne soit pas protégé et supprimer le `useCallback` inutile
|
||||||
|
- Ne pas laisser un `useCallback` "pour faire bien" si son effet réel est nul
|
||||||
|
|
||||||
|
- Contexte technique : React — app-template-resto 22-03-2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="risque-formulaire-defaultvalue-sans-key"></a>
|
||||||
|
## Formulaire React avec `defaultValue` sans `key` prop
|
||||||
|
|
||||||
|
### Risques
|
||||||
|
|
||||||
|
- `defaultValue`, `defaultChecked`, `defaultSelected` ne s'appliquent qu'au montage
|
||||||
|
- Si le composant est réutilisé (même nœud DOM, nouvelle prop) sans être démonté, les valeurs ne se mettent pas à jour
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- L'utilisateur édite l'entité A, clique sur "Modifier" pour l'entité B → le formulaire affiche encore les données de A
|
||||||
|
|
||||||
|
### Bonnes pratiques / mitigations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Fix obligatoire : key unique basée sur l'ID de l'entité éditée
|
||||||
|
<EntityForm
|
||||||
|
key={formState.mode === "edit" ? formState.entity.id : `create-${formState.contextId}`}
|
||||||
|
...
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Règle** : tout formulaire d'édition réutilisé pour plusieurs entités doit avoir une `key` distincte par entité
|
||||||
|
|
||||||
|
- Contexte technique : React / Next.js — app-template-resto 21-03-2026
|
||||||
|
|||||||
@@ -138,3 +138,39 @@ constructor() {
|
|||||||
### Alternative écartée
|
### Alternative écartée
|
||||||
|
|
||||||
`nest start --watch` a été testé mais a introduit des conflits ESM/CJS dans ce contexte (`exports is not defined`).
|
`nest start --watch` a été testé mais a introduit des conflits ESM/CJS dans ce contexte (`exports is not defined`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `export { fn }` ne constitue pas un import local — détecté uniquement au build
|
||||||
|
|
||||||
|
### Contexte
|
||||||
|
|
||||||
|
Projet `app-template-resto`, story 2-4, le 17-03-2026.
|
||||||
|
Dans `getPublicHomeData.ts`, la fonction `resolvePublicTenantSelection` avait été déplacée dans `src/server/tenant/resolvePublicTenant.ts` et re-exportée depuis l'ancien emplacement.
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- `Cannot find name 'resolvePublicTenantSelection'` au `next build` uniquement
|
||||||
|
- ESLint et `tsc` (hors build) ne signalaient rien
|
||||||
|
- La fonction était utilisée localement dans le même fichier qui la re-exportait
|
||||||
|
|
||||||
|
### Cause
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// getPublicHomeData.ts
|
||||||
|
export { resolvePublicTenantSelection } from "@/server/tenant/resolvePublicTenant";
|
||||||
|
|
||||||
|
// puis, plus bas dans le même fichier :
|
||||||
|
const result = resolvePublicTenantSelection(env); // ← NameError au build
|
||||||
|
```
|
||||||
|
|
||||||
|
Un re-export (`export { fn } from "..."`) ne crée pas de binding local dans le fichier. La fonction est exportée vers l'extérieur mais n'est pas disponible comme variable locale.
|
||||||
|
|
||||||
|
### Correctif / règle à retenir
|
||||||
|
|
||||||
|
Si une fonction est utilisée dans le même fichier qui la re-exporte, ajouter un `import` séparé en plus du `export` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { resolvePublicTenantSelection } from "@/server/tenant/resolvePublicTenant";
|
||||||
|
export { resolvePublicTenantSelection }; // pour les appelants externes
|
||||||
|
```
|
||||||
|
|||||||
1280
95_a_capitaliser.md
1280
95_a_capitaliser.md
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user