Files
_Assistant_Lead_Tech/knowledge/backend/patterns/nextjs.md
MaksTinyWorkshop b3417ad77b capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:12:44 +02:00

13 KiB

Backend — Patterns : Next.js

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


Pattern : Next.js runtime-only — orchestration en bord et logique pure testable

  • Objectif : préserver la testabilité unitaire et la lisibilité du code serveur Next.js en limitant les dépendances runtime-only aux couches d'orchestration.
  • Contexte : applications Next.js avec Server Actions, route handlers, modules email/auth et logique métier testée côté Node.
  • Quand l'utiliser : dès qu'un flux serveur mélange APIs Next.js runtime-only (cookies(), headers(), redirect(), server-only) et logique métier réutilisable.
  • Quand l'éviter : petits modules purement runtime sans logique métier notable, ou fonctions triviales sans intérêt à être testées séparément.
  • Avantage :
    • garde la logique métier importable dans un runner Node standard
    • évite que server-only contamine des modules purs
    • facilite les tests unitaires sans mocks lourds du runtime Next.js
    • clarifie la responsabilité des Server Actions et handlers serveur
  • Limites / vigilance :
    • demande une discipline de découpage
    • peut introduire une indirection inutile si la logique extraite est réellement triviale
    • les frontières d'injection doivent rester simples pour éviter un excès d'abstraction
  • Validé le : 19-03-2026
  • Contexte technique : Next.js / Server Actions / Node test runner / modules backend injectables

Implémentation (exemple minimal)

- réserver `import "server-only"` aux fichiers qui utilisent réellement des APIs runtime Next.js
- garder la Server Action, route handler ou module email comme couche d'orchestration fine
- extraire la logique métier pure dans une fonction ou un service sans dépendance à `cookies()`, `headers()`, `redirect()` ou `server-only`
- injecter explicitement les dépendances utiles (client DB, token, callback de redirect, logger, etc.)
- tester unitairement le module pur dans le runner Node ; tester l'orchestrateur plus légèrement

Checklist

  • server-only absent des modules de logique pure
  • APIs Next.js runtime-only limitées aux couches d'entrée
  • Logique métier principale testable sans runtime Next.js
  • Dépendances injectées explicitement quand utile
  • Server Action ou handler fin et lisible

Pattern : Next.js server-only & Server Actions — règles d'isolation

  • Objectif : permettre les tests unitaires Node tout en gardant les contraintes runtime Next.js là où elles sont nécessaires.
  • Contexte : monorepo Next.js App Router avec logique métier testée en Node runner natif.
  • Quand l'utiliser : dès qu'un module mixe logique pure et dépendances runtime Next.js.
  • Quand l'éviter : modules purement UI côté client.
  • Avantage :
    • logique pure testable sans friction (runner Node natif)
    • Server Action fine et lisible — orchestration uniquement
    • server-only explicite et intentionnel, pas par habitude
  • Limites / vigilance :
    • ne pas mettre server-only dans les repositories purs — casse les tests Node hors Next.js
  • Validé le : 16-03-2026
  • Contexte technique : Next.js App Router / Node.js test runner

Règles

- `server-only` uniquement sur les modules qui appellent des APIs Next.js runtime
  (cookies(), headers(), redirect()) — pas sur les repositories ni la logique pure
- Logique pure extraite dans un module injectable sans `server-only` :
  deleteSession({ prismaClient, sessionToken })
  → testable avec le runner Node sans friction
- Server Action = orchestration mince, elle appelle les modules purs injectés
  et gère les dépendances Next.js runtime uniquement
- Logique de validation / sanitisation (safeHttpUrl, etc.) → module utilitaire séparé,
  sans import nodemailer / server-only

Checklist

  • server-only absent des repositories et modules de logique pure
  • Server Action ≤ 10 lignes, délègue au module pur injectable
  • Modules purs couverts par des tests .spec.ts Node sans config spéciale
  • La logique pure ne dépend pas du runtime pour être exécutée

Pattern : Utilitaires purs — extraire dans un module sans server-only

  • Objectif : permettre aux repositories et aux tests d'importer la même implémentation des utilitaires purs sans friction.
  • Contexte : fonctions pures (slugify, formatters, validators) utilisées par des repositories qui ont server-only.
  • Quand l'utiliser : dès qu'une fonction pure est utilisée dans un repository ET dans des tests.
  • Risque si ignoré : logique dupliquée dans les tests qui diverge silencieusement de l'implémentation réelle.
  • Validé le : 21-03-2026
  • Contexte technique : Node.js / Next.js — app-template-resto

Implémentation

src/server/menuAdmin/
  allergensRepository.ts   ← import { slugify } from "./slugify"
  slugify.ts               ← export function slugify() {} // pas de "server-only"

tests/
  allergens-admin.test.ts  ← import { slugify } from "../src/server/menuAdmin/slugify.ts"

Pattern : 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

- Avant de créer un modèle ReservationConfig, vérifier si PublicHomeProfile.reservationUrl suffit
- Un champ optionnel dans le modèle le plus proche est suffisant en V1
- Ne créer un modèle dédié que si : cycle de vie distinct, relations, ou cardinalités multiples

Pattern : Valider le protocole d'une URL externe avant de la passer à un lien public

  • Objectif : prévenir les injections javascript: et URLs malformées dans les <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

function isSafeUrl(url: string): boolean {
  try {
    const { protocol } = new URL(url);
    return protocol === "https:" || protocol === "http:";
  } catch {
    return false;
  }
}

// Validation complète en service/repository
if (mediaUrl) {
  try { new URL(mediaUrl); } catch { throw new HttpError("URL invalide.", { status: 400 }); }
  if (!mediaUrl.startsWith("https://") && !mediaUrl.startsWith("http://"))
    throw new HttpError("URL doit commencer par https://.", { status: 400 });
  if (mediaUrl.length > 500)
    throw new HttpError("URL trop longue.", { status: 400 });
}
// Retourner null si invalide — le composant gère l'absence d'URL

Checklist

  • Validation format (new URL()) + protocole + longueur max
  • Retourner null si invalide, jamais passer la string brute
  • Composant UI reçoit string | null, jamais une string non vérifiée

Pattern : Next.js after() pour fanout post-réponse

  • Objectif : exécuter du travail accessoire (notif push, audit, webhook) APRÈS avoir renvoyé la réponse HTTP, sans bloquer la latence métier ni risquer la fermeture lambda du fire-and-forget naïf.
  • Contexte : route Next.js 15+ (App Router) qui termine une mutation métier (ex : prisma.$transaction) et veut déclencher un effet de bord non critique.
  • Quand l'utiliser : effet de bord best-effort qui ne doit JAMAIS bloquer la réponse principale (push, log audit asynchrone, webhook sortant).
  • Quand l'éviter : effet de bord qui DOIT réussir avant de répondre 200 (validation transactionnelle, écriture critique).
  • Avantage :
    • réponse HTTP immédiate (latence métier préservée)
    • le runtime Node attend la fin du callback avant de fermer le process — pas de fire-and-forget orphelin
    • les exceptions du callback sont attrapables localement, ne propagent pas au caller
  • Limites / vigilance :
    • JAMAIS placer after() à l'intérieur d'une prisma.$transaction(async tx => { ... after(...) }) : si la transaction rollback, le callback after() reste planifié et s'exécute sur des données qui n'existent pas en DB
    • construire le payload SYNCHRONE avant after() et attraper les erreurs là, sinon une exception dans le callback async devient silencieuse
    • comportement en serverless (Vercel, Lambda) vs Node standalone peut différer — tester en cible
  • Validé le : 28-04-2026
  • Contexte technique : Next.js 15+ App Router — RL799_V2

Implémentation

import { after } from 'next/server';

export const notifyConvocationPublished = async (
  tenueId: string,
  recipientIds: string[],
): Promise<void> => {
  // 1. Mutation métier (transactionnelle)
  await prisma.$transaction(async (tx) => {
    await tx.notification.createMany({ data: ... });
  });

  // 2. Construction payload SYNCHRONE — toute exception attrapée ici
  let payload: PushPayload;
  try {
    payload = buildPushPayload(tenueId);
  } catch (err) {
    log.warn('payload build failed', { err });
    return;
  }

  // 3. Hook après la transaction — JAMAIS dedans
  after(async () => {
    try {
      await pushService.sendPushToUsers(recipientIds, payload);
    } catch (err) {
      log.warn('fanout failed', { err }); // jamais throw vers le caller
    }
  });
};

Anti-patterns

  • await pushService.sendPushToUsers(...) dans le service métier (bloque la latence + propage les erreurs)
  • after(() => { ... }) à l'intérieur d'une prisma.$transaction(async tx => { ... after(...); })
  • Construire le payload DANS le callback after() async — une exception y devient silencieuse

Checklist

  • after() appelé APRÈS le await prisma.$transaction(...), jamais à l'intérieur
  • Payload construit synchrone avant after(), exceptions attrapées localement
  • Le callback after() n'a pas le droit de throw (best-effort wrapping)
  • La route métier renvoie 2xx même si le fanout échoue

Pattern : Gate "agir au nom de X" (3 étages : rôle → type/scope → cible actif)

  • Objectif : valider correctement une autorisation d'override d'attribution (uploadedByOverride, createdByOverride) pour qu'un rôle élevé puisse agir "au nom de" un autre user, sans laisser de faille.
  • Contexte : endpoint API qui accepte un override d'attribution (archiviste upload pour un Frère, admin crée une entité pour X).
  • Quand l'utiliser : tout endpoint avec un override d'attribution sensible.
  • Quand l'éviter : si la délégation est implicite et déjà couverte par un guard centralisé.
  • Avantage :
    • validation strictement séquentielle et défensive — chaque étage a son code HTTP propre
    • le check "actif" combine toutes les dimensions disponibles (pas un seul flag)
  • Limites / vigilance :
    • ordre impératif : rôle EN PREMIER (plus sensible). Un membre lambda envoyant un payload avec override doit recevoir 403 même si la cible est valide
  • Validé le : 20-04-2026
  • Contexte technique : Next.js / API HTTP — RL799_V2

Les 3 étages

  1. Rôle : le user courant a-t-il la capacité ? Sinon 403 FORBIDDEN.
  2. Type/scope : l'override est-il pertinent pour ce type d'entité ? Sinon 400 VALIDATION_ERROR.
  3. Cible : la cible existe-t-elle ET est-elle active ? Sinon 400 VALIDATION_ERROR si introuvable OU inactive OU démissionnée OU décédée.

Implémentation

let effectiveUploadedBy = currentUser.id;
if (metadata.uploadedByOverride) {
  // Étage 1 : rôle
  if (userRole !== 'archiviste' && userRole !== 'admin') {
    return errorResponse(403, 'FORBIDDEN', '...');
  }
  // Étage 2 : type/scope
  if (metadata.type !== 'planche') {
    return errorResponse(400, 'VALIDATION_ERROR', '...');
  }
  // Étage 3 : cible actif (toutes les dimensions disponibles)
  const targetUser = await getUserById(metadata.uploadedByOverride);
  const isInactive =
    !targetUser
    || !targetUser.isActive
    || !!targetUser.profile?.resignedAt
    || !!targetUser.profile?.deceasedAt;
  if (isInactive) {
    return errorResponse(400, 'VALIDATION_ERROR', '...');
  }
  effectiveUploadedBy = metadata.uploadedByOverride;
}

Pourquoi vérifier toutes les dimensions

Un user peut être isActive: true dans le système mais avoir une resignedAt antérieure (désactivation non-synchrone). Le check "actif" doit combiner toutes les dimensions disponibles du modèle, pas un seul flag.