mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-06-28 10:03:40 +02:00
f1b783407a
Triage et intégration des propositions backend du buffer 95_a_capitaliser.md (lot local RL799_V2 + app-alexandrie, mai-juin 2026), distinct de la capitalisation remote antérieure (triage 2026-05-02). ~73 entrées intégrées sur knowledge/backend/, dont : - patterns/auth.md : série "membrane d'auth fédérée BFF/OIDC" (9 patterns) + jose algo whitelist - patterns/prisma.md : recette fusionnée "Migration String/Int → enum" (backfill + Cas A/B/C), row réactivable, endpoint replace atomique, updateMany conditionnel, etc. - risques/general.md : 19 risques (epoch s vs ms, keepAliveTimeout=0, upsert+filtre liste, fail-safe catch-all, retrait asymétrique front/back, anti-énumération rate-limit, etc.) - patterns/general, async, nestjs, contracts, tests + risques/auth, contracts, prisma, redis, stripe, tests - compléments d'entrées existantes (authorize-after-fetch, P3014, cursor opaque, DI swc, Stripe v20...) - README patterns/risques mis à jour Doublons internes corrigés en relecture (suppression-champ .map() → general seul ; e2e DB-based → tests.md seul). Doublons hors backend / entrées projet / rejets non intégrés. Source 95_a_capitaliser.md non purgée à ce stade (purge en fin de capitalisation complète). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
343 lines
14 KiB
Markdown
343 lines
14 KiB
Markdown
---
|
||
title: Backend — Patterns : NestJS
|
||
domain: backend
|
||
bucket: patterns
|
||
tags: [nestjs, guards, auth, redis, quota]
|
||
applies_to: [analysis, implementation, review, debug]
|
||
severity: medium
|
||
validated_on: 2026-03-07
|
||
source_projects: [app-alexandrie]
|
||
---
|
||
|
||
# Backend — Patterns : NestJS
|
||
|
||
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.
|
||
|
||
---
|
||
|
||
<a id="pattern-guard-global-nestjs"></a>
|
||
|
||
## Pattern : Guard global NestJS — ordre d'enregistrement et décorateurs de bypass
|
||
|
||
- Objectif : protéger tous les endpoints par défaut, avec un mécanisme explicite pour les exceptions.
|
||
- Contexte : API NestJS avec plusieurs guards globaux (authn, authz, feature flags...).
|
||
- Quand l'utiliser : dès qu'on a 2+ guards globaux dont l'un dépend du résultat de l'autre.
|
||
- Quand l'éviter : si un seul guard suffit.
|
||
- Avantage :
|
||
- Sécurité par défaut (opt-out, pas opt-in)
|
||
- Ordre d'exécution garanti et explicite
|
||
- Bypass documenté et traçable via décorateurs
|
||
- Limites / vigilance :
|
||
- L'ordre des `APP_GUARD` dans `providers[]` est l'ordre d'exécution — ne pas inverser
|
||
- Exporter le service depuis son module si injecté dans un guard global d'un autre module
|
||
- Validé le : 07-03-2026
|
||
- Contexte technique : NestJS v10+
|
||
|
||
### Implémentation (exemple minimal)
|
||
|
||
```typescript
|
||
// app.module.ts
|
||
providers: [
|
||
{ provide: APP_GUARD, useClass: AuthGuard }, // 1er : peuple request.user
|
||
{ provide: APP_GUARD, useClass: EmailVerifiedGuard }, // 2ème : lit request.user
|
||
{ provide: APP_GUARD, useClass: EntitlementsGuard }, // 3ème : lit request.user + entitlements
|
||
]
|
||
|
||
// skip-auth.decorator.ts
|
||
export const SKIP_AUTH = 'skipAuth';
|
||
export const SkipAuth = () => SetMetadata(SKIP_AUTH, true);
|
||
|
||
// auth.guard.ts
|
||
const skip = this.reflector.getAllAndOverride<boolean>(SKIP_AUTH, [
|
||
context.getHandler(),
|
||
context.getClass(), // permet @SkipAuth() au niveau classe
|
||
]);
|
||
if (skip) return true;
|
||
```
|
||
|
||
### Checklist
|
||
|
||
- [ ] AuthGuard enregistré en premier dans `providers[]`
|
||
- [ ] AuthModule exporte AuthService si AuthGuard est dans AppModule
|
||
- [ ] Décorateur `@SkipAuth()` sur tous les endpoints publics (auth, health, docs)
|
||
- [ ] Tests unitaires sur le guard avec reflector mocké
|
||
|
||
---
|
||
|
||
<a id="pattern-redis-health-cache-court"></a>
|
||
|
||
## Pattern : RedisHealthService avec cache interne court
|
||
|
||
- Objectif : exposer un état Redis exploitable par les guards globaux sans ping Redis à chaque requête.
|
||
- Contexte : backend Node/NestJS avec Redis consulté dans le chemin de décision d'écriture.
|
||
- Quand l'utiliser : quand plusieurs requêtes concurrentes doivent consulter l'état Redis.
|
||
- Quand l'éviter : si Redis n'est pas consulté dans le chemin request/response.
|
||
- Avantage :
|
||
- réduit fortement le flood de `PING`
|
||
- garde un signal d'état suffisamment frais
|
||
- Limites / vigilance :
|
||
- la fenêtre de cache doit rester courte
|
||
- l'état initial doit être explicite et assumé
|
||
- Validé le : 10-03-2026
|
||
- Contexte technique : NestJS / Redis
|
||
|
||
### Implémentation (exemple minimal)
|
||
|
||
```txt
|
||
- Mémoriser lastStatus et lastCheck
|
||
- Si le dernier check a moins de 5s, retourner l'état en cache
|
||
- Sinon exécuter un vrai PING et mettre le cache à jour
|
||
- Utiliser un état initial optimiste (`up`) si le produit ne doit pas bloquer les écritures au boot
|
||
```
|
||
|
||
### Checklist
|
||
|
||
- Cache court documenté
|
||
- Pas de ping Redis à chaque requête
|
||
- Comportement initial explicite
|
||
|
||
---
|
||
|
||
<a id="pattern-quota-redis-atomique"></a>
|
||
## Pattern : Quota journalier Redis atomique (INCR + EXPIREAT pipeline)
|
||
|
||
- Objectif : implémenter un quota d'action journalier sans race condition ni clé TTL orpheline.
|
||
- Contexte : quota par utilisateur sur une fenêtre calendaire UTC (posts, requêtes, actions sensibles).
|
||
- Quand l'utiliser : toute limite d'action journalière avec Redis disponible.
|
||
- Quand l'éviter : si Redis est down — prévoir un mode dégradé permissif (voir implémentation).
|
||
- Avantage :
|
||
- atomicité garantie : `INCR + EXPIREAT` dans un pipeline `MULTI/EXEC`
|
||
- pas de clé sans TTL même en cas de deux requêtes simultanées (`count === 1` concurrent)
|
||
- mode dégradé explicite si Redis down (`count === null` → permissif)
|
||
- Limites / vigilance :
|
||
- compensation `incrBy(-1)` en cas de dépassement — ne couvre pas les crashes entre INCR et la vérification
|
||
- la fenêtre expire à minuit UTC, pas à minuit local
|
||
- Validé le : 20-03-2026
|
||
- Contexte technique : Redis / NestJS / app-alexandrie story 4.2
|
||
|
||
### Implémentation (exemple minimal)
|
||
|
||
```typescript
|
||
// RedisService — méthode dédiée
|
||
async incrWithExpireAt(key: string, expireAtMs: number): Promise<number | null> {
|
||
const pipeline = this.client.multi();
|
||
pipeline.incr(key);
|
||
pipeline.expireAt(key, Math.floor(expireAtMs / 1000));
|
||
const results = await pipeline.exec();
|
||
return results[0] as number; // valeur post-INCR
|
||
}
|
||
|
||
// Service métier
|
||
const today = new Date().toISOString().split('T')[0]; // yyyy-mm-dd UTC
|
||
const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
||
const quotaKey = `app:quota:post:${userId}:${today}`;
|
||
const count = await redis.incrWithExpireAt(quotaKey, midnight.getTime());
|
||
|
||
if (count !== null && count > QUOTA_MAX) {
|
||
await redis.incrBy(quotaKey, -1); // compensation
|
||
throw new HttpException({ error: { code: 'QUOTA_EXCEEDED' } }, HttpStatus.TOO_MANY_REQUESTS);
|
||
}
|
||
// count === null → Redis down → mode dégradé permissif
|
||
```
|
||
|
||
### Checklist
|
||
|
||
- [ ] Vérifier le quota AVANT la création en DB
|
||
- [ ] `INCR + EXPIREAT` dans un pipeline atomique
|
||
- [ ] 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)
|
||
|
||
---
|
||
|
||
<a id="pattern-fusionner-lastseenat-reconciliation"></a>
|
||
## Pattern : Fusionner `lastSeenAt` dans l'update de réconciliation — évite N requêtes DB par requête
|
||
|
||
- Objectif : éviter deux appels Prisma distincts (réconciliation + lastSeenAt) sur chaque requête authentifiée.
|
||
- Contexte : service de réconciliation d'état de session appelé à chaque request via guard ou middleware.
|
||
- Quand l'utiliser : dès qu'un `lastSeenAt` est mis à jour systématiquement et qu'un update conditionnel coexiste.
|
||
- Avantage : 1 requête DB par requête authentifiée au lieu de 2.
|
||
- Validé le : 30-03-2026
|
||
- Contexte technique : NestJS / Prisma — app-alexandrie
|
||
|
||
### Implémentation (exemple minimal)
|
||
|
||
```typescript
|
||
// ❌ 2 requêtes par requête authentifiée
|
||
private async reconcileSessionStatus(session) {
|
||
if (statusChanged) await prisma.session.update({ data: { status, graceEndsAt } });
|
||
}
|
||
await prisma.session.update({ data: { lastSeenAt: now } }); // 2ème update systématique
|
||
|
||
// ✅ 1 requête — lastSeenAt toujours inclus dans le même appel
|
||
private async reconcileSessionStatus(session, now = new Date()) {
|
||
await prisma.session.update({
|
||
data: { lastSeenAt: now, ...(statusChanged && { status, graceEndsAt }) }
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
<a id="pattern-ressource-paire-non-ordonnee"></a>
|
||
## Pattern : Ressource « paire non ordonnée » — adresser par les participants, pas par l'id
|
||
|
||
- Objectif : éviter un endpoint « créer la ressource » séparé avant le premier écrit, et la race condition associée, pour une ressource dont l'identité est dérivable d'une paire d'acteurs.
|
||
- Contexte : DM 1:1, match, follow réciproque, partage 1:1 — toute ressource « paire non ordonnée » où l'id est déductible des deux participants.
|
||
- Quand l'utiliser : dès que le premier écrit doit pouvoir créer la ressource implicitement (premier message à un utilisateur, première interaction).
|
||
- Quand l'éviter : ressource avec identité propre (commande, document) qui existe indépendamment des participants.
|
||
- Avantage :
|
||
- 1 seul appel API au lieu de 2 (UX + perf)
|
||
- pas de race condition : contrainte unique Prisma sur la paire ordonnée applicative (`userA < userB`)
|
||
- l'endpoint GET par id reste légitime pour la pagination des fils existants
|
||
- Limites / vigilance :
|
||
- appliquer les vérifications d'éligibilité/quota AVANT le `findUnique` pour ne pas leak l'existence de la ressource
|
||
- l'upsert doit se faire en transaction (création conversation + création message)
|
||
- Validé le : 13-05-2026
|
||
- Contexte technique : NestJS / Prisma — app-alexandrie story 10.2
|
||
|
||
### Implémentation (exemple minimal)
|
||
|
||
```txt
|
||
POST /messaging/conversations/with/:targetUserId/messages
|
||
```
|
||
|
||
Le service, après contrôles d'éligibilité/quota :
|
||
|
||
1. `findUnique({ where: { userAId_userBId: orderPair(currentUser, target) } })`
|
||
2. Si absent → `create` la conversation
|
||
3. `create` le message dans la même `$transaction`
|
||
|
||
Adresser par l'identité des deux participants (ou le seul participant cible, l'autre étant `req.user`), jamais par l'id de la ressource. Faire un upsert idempotent côté serveur en transaction.
|
||
|
||
### Checklist
|
||
|
||
- [ ] Endpoint d'écriture adressé par les participants, pas par l'id de la ressource
|
||
- [ ] Contrainte unique Prisma sur la paire ordonnée applicative (`userA < userB`)
|
||
- [ ] Vérifications d'éligibilité/quota AVANT le `findUnique` (anti-leak d'existence)
|
||
- [ ] Upsert conversation + message dans une même `$transaction`
|
||
- [ ] Endpoint GET par id conservé pour la pagination
|
||
|
||
---
|
||
|
||
<a id="pattern-batched-eligibility-decoration"></a>
|
||
## Pattern : Batched eligibility / decoration — ne jamais calculer dans `.map(async)`
|
||
|
||
- Objectif : éviter le N+1 caché quand chaque row d'une liste doit être décorée d'un flag calculé par une logique partagée (éligibilité, ACL, entitlements).
|
||
- Contexte : `listX` qui ajoute un flag dérivé (`isReadOnly`, `canEdit`...) calculé par row à partir de queries internes.
|
||
- Quand l'utiliser : tout listing qui décore N rows d'un flag dépendant de queries (DB ou cache) par row.
|
||
- Quand l'éviter : flag purement synchrone dérivable de la row elle-même (pas de query).
|
||
- Avantage :
|
||
- passe de N×K requêtes à un nombre constant de queries bulk
|
||
- lookup O(1) via `Set` / `Map` au moment de la décoration
|
||
- Limites / vigilance :
|
||
- le cache (ex : entitlements 60 s) n'amortit pas la première fenêtre froide
|
||
- itérer la page **synchroniquement** après le chargement bulk — pas de `await` dans la boucle de décoration
|
||
- Validé le : 13-05-2026
|
||
- Contexte technique : NestJS / Prisma — app-alexandrie story 10.2 DM messaging
|
||
|
||
### Anti-pattern (N+1)
|
||
|
||
```typescript
|
||
// ❌ N appels indépendants → N×K requêtes (K = queries internes de getEligibility)
|
||
const items = await Promise.all(
|
||
page.map(async (row) => {
|
||
const eligibility = await this.getEligibilityForExistingConversation(
|
||
currentUserId, peerIdFromRow(row),
|
||
);
|
||
return { ...row, isReadOnly: eligibility.isReadOnly };
|
||
}),
|
||
);
|
||
```
|
||
|
||
### Pattern (batched)
|
||
|
||
1. Extraire les IDs cibles : `const peerIds = page.map(peerIdFromRow)`.
|
||
2. Charger TOUTES les dépendances en bulk :
|
||
- `await entitlements.getEntitlementsForUser(currentUserId)` (1 fois)
|
||
- `await Promise.all(peerIds.map(id => entitlements.getEntitlementsForUser(id)))` (parallèle, cache amortit)
|
||
- `await prisma.follow.findMany({ where: { OR: [{ followerId: currentUserId, followingId: { in: peerIds } }, { followerId: { in: peerIds }, followingId: currentUserId }] } })` (1 seule requête)
|
||
3. Construire des `Set` / `Map` pour lookup O(1).
|
||
4. Itérer la page **synchroniquement** et décorer chaque row.
|
||
|
||
### Règle
|
||
|
||
Tout flag décoratif calculé par row qui dépend d'une logique partagée DOIT être factorisé en une méthode `compute<Flag>ForBatch(peerIds[])` retournant `Map<peerId, value>`.
|
||
|
||
---
|
||
|
||
<a id="pattern-providers-externes-injection-token"></a>
|
||
## Pattern : Providers externes via injection par token (jamais `new XxxProvider()`)
|
||
|
||
- Objectif : rendre les providers externes mockables, swappables par env, et conformes au contrat DI Nest.
|
||
- Contexte : service Nest qui dépend d'un système externe (email, billing, push, OAuth IdP, storage, geocoding...).
|
||
- Quand l'utiliser : tout provider qui touche un système externe.
|
||
- Quand l'éviter : services purement applicatifs (`UsersService`, `CommunityService`) — injectés par classe directement.
|
||
- Avantage :
|
||
- mock propre en test (`useValue`)
|
||
- rotation d'implémentation par env sans rebuild
|
||
- arbre DI complet : le module déclare la dépendance
|
||
- Limites / vigilance :
|
||
- un `new XxxProvider()` au constructeur s'exécute même quand un test injecte un mock du service → tests cassés
|
||
- Validé le : 20-05-2026
|
||
- Contexte technique : NestJS — app-alexandrie story infra-5
|
||
|
||
### Anti-pattern
|
||
|
||
```typescript
|
||
// ❌ instancié au constructeur : non mockable, non swappable, contrat DI incomplet
|
||
export class AuthService {
|
||
private readonly emailProvider = new NoopEmailProvider();
|
||
private readonly authProvider = new GoogleAuthProvider();
|
||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||
}
|
||
// Conséquences observées (avant fix) : AuthService 5 fails, billing 26 fails, notifications 2 fails.
|
||
```
|
||
|
||
### Pattern correct
|
||
|
||
```typescript
|
||
// 1. Token + interface
|
||
export const EMAIL_PROVIDER = Symbol('EMAIL_PROVIDER');
|
||
export interface EmailProvider {
|
||
sendVerificationEmail(email: string, token: string): Promise<void>;
|
||
}
|
||
|
||
// 2. Implémentation @Injectable()
|
||
@Injectable()
|
||
export class NoopEmailProvider implements EmailProvider { /* ... */ }
|
||
|
||
// 3. Binding dans le module
|
||
@Module({
|
||
providers: [
|
||
AuthService,
|
||
{ provide: EMAIL_PROVIDER, useClass: NoopEmailProvider },
|
||
],
|
||
})
|
||
export class AuthModule {}
|
||
|
||
// 4. Injection par token
|
||
@Injectable()
|
||
export class AuthService {
|
||
constructor(
|
||
@Inject(EMAIL_PROVIDER) private readonly emailProvider: EmailProvider,
|
||
) {}
|
||
}
|
||
|
||
// 5. Mock en test
|
||
const module = await Test.createTestingModule({
|
||
providers: [
|
||
AuthService,
|
||
{ provide: EMAIL_PROVIDER, useValue: { sendVerificationEmail: jest.fn() } },
|
||
],
|
||
}).compile();
|
||
```
|
||
|
||
### Checklist
|
||
|
||
- [ ] Token (`Symbol`) + interface dans un fichier dédié
|
||
- [ ] Implémentation `@Injectable()` qui implémente l'interface
|
||
- [ ] Binding `{ provide: TOKEN, useClass: Impl }` dans le module
|
||
- [ ] Injection via `@Inject(TOKEN)` au constructeur
|
||
- [ ] Aucun `new XxxProvider()` dans un service métier
|