Home›Documentation

Documentation

The complete guide to using Stravoda day to day.

On this page

  • Module — Carnet de bord AAC (`lib/services/aac-trip/`)
  • Module admin — Analytics BI (14r-5)
  • Module — Admin Stravoda interne (Étape 12)
  • Module — Audit diff champ par champ (14h-2)
  • Module — Authentification (Étape 3a + 14r)
  • Module Facturation (Étape 8)
  • Module CPF (14z-2)
  • Module — Guide interactif post-onboarding (Étape 14k + 14r-10 Phase 1)
  • Module — Sécurité données (RLS + chiffrement PII + masquage)
  • Module — Espace élève (`lib/services/eleve/`)
  • Module Growth — Revenus automatiques (14z-3)
  • Module — Moniteurs (Étapes 9a + 9b + 9c + 9e)
  • Module — Internationalisation (Étape 14m / 14m-2)
  • Module — Conformité travail & jours fériés (14r-6)
  • Module Pages légales (Étape 13.5)
  • Module — Preuves de leçon & signatures (14r-6)
  • Module — Messagerie interne (14r-7)
  • Module — Espace moniteur (Étape 10)
  • Module Nautique (14y)
  • Module — Onboarding (Étape 3b · 14r-10 Phase 2 : 5 étapes)
  • Module — Suivi parent AAC (`lib/services/parent/`)
  • Module — Permis (Étape 14s)
  • Module — Planning (Étape 5)
  • Module — Quotas & Coûts (anti-abus par école)
  • Module Rapports mensuels (14h)
  • Module Avis Google IA (14z)
  • Module admin — RGPD Master (14r-5)
  • Module RGPD & Preuves légales (ÉTAPE 14z-4)
  • Module — Sécurité (anti-abus essai + dashboard sécurité)
  • Module Paramètres école (Étape 13)
  • Module — SMS (Étape 7)
  • Module — Statistiques comparatives (14r-7)
  • Module — Élèves (Étapes 6a + 6b)
  • Module — Support IA (Étape 12z-1 + auto-apprentissage 14k)
  • Module Transport (14y)
  • Module — Véhicules (Étape 9d)

Module — Carnet de bord AAC (`lib/services/aac-trip/`)

> Trajets de conduite accompagnée déclarés par l'élève ou le parent, validés par gérant ou moniteur référent.

Modèle Prisma

`AacTrip` — migration `20260630154353_aac_trip_logbook` :

  • Scopé `{ schoolId, studentId }`, enums `AacTripType` (URBAIN, ROUTE, AUTOROUTE, NUIT, AUTRE),

`AacTripDeclaredBy` (STUDENT, PARENT), `AacTripStatus` (PENDING, VALIDATED, REJECTED).

  • Totaux dénormalisés sur `Student` : `aacTotalValidatedHours`, `aacTotalValidatedKm` (recalculés à chaque validation).

Déclaration (élève / parent)

RouteAuthRate limit
`POST/GET /api/portal/[token]/aac-trips``authStudent` (PORTAL/ONBOARDING)`STUDENT_LINK_USE` / token
`POST/GET /api/parent/[token]/aac-trips``verifyStudentLink` type PARENT`PARENT_AAC_TRIP_DECLARE` 5/jour/token
  • Schémas : `DeclareAacTripSchema`, `DeclareParentAacTripSchema` (`shared/schemas/aac-trip.ts`).
  • Parent : `declaredByName` obligatoire (pas de compte).

Validation (dashboard)

  • `GET /api/students/[id]/aac-trips` — liste + pending (GERANT ou moniteur référent).
  • `PATCH /api/aac-trips/[id]` — `{ action: VALIDATE \| REJECT, rejectionReason? }`.
  • Autorisation : `assertCanReviewAacTrips` (GERANT ou MONITEUR + `primaryInstructorId`).
  • Validation → `recalcAacValidatedTotals` + audit `VALIDATE_AAC_TRIP` / `REJECT_AAC_TRIP`.

UI

  • Élève : section carnet dans onglet « Mon suivi » (`EleveProgressTab` + `AacLogbookSection`).
  • Parent : `AacLogbookSection` dans `ParentSpace` (seule interaction hors lecture seule).
  • Dashboard : `StudentAacTripsPanel` dans `StudentAacSection` ; moniteur référent sur `/moniteur/eleves/[id]`.

Tests

  • `src/test/aac-trip-service.test.ts` (6)
  • `src/test/parent-aac-trip-route.test.ts` (1)

Module admin — Analytics BI (14r-5)

Description

Dashboard `/stravoda-admin/analytics` (ADMIN) : MRR, ARR, croissance MoM, churn, courbe 12 mois, usage produit, alertes business, cohortes rétention.

Routes API

  • `GET /api/admin/analytics/overview` — ADMIN
  • `GET /api/admin/analytics/cohorts` — ADMIN
  • `GET /api/admin/analytics/usage` — usage + alertes — ADMIN

Services

  • `lib/services/admin/analytics/overview.ts` — agrégats purs
  • `lib/services/admin/analytics/cohorts.ts` — tableau cohortes M+1/3/6/12
  • `lib/services/admin/analytics/queries.ts` — requêtes Prisma

Tests

  • `src/test/admin-analytics.test.ts`

Module — Admin Stravoda interne (Étape 12)

> Back-office interne de l'équipe Stravoda. Exception assumée à l'isolation multi-tenant :

> l'admin observe toutes les écoles. Spec complète : `specs/admin.md`. Découpage 12a…12h.

Principe d'accès (double garde)

1. Middleware (`src/middleware.ts`) : `/stravoda-admin/*` filtré par `STRAVODA_ADMIN_EMAILS`

(email Supabase autorisé). Les routes `/api/admin/*` ne sont pas couvertes par le middleware.

2. RBAC `requireStravodaAdmin(minRole)` (`src/lib/auth/stravoda-admin.ts`) : charge la ligne

`StravodaAdmin` active par `supabaseId`, compare le rang. Unique défense des routes API.

Hiérarchie : `STRAVODA_ADMIN_ROLE_RANK = { ANALYST: 0, SUPPORT: 1, ADMIN: 2 }` (`shared/constants.ts`).

  • ANALYST : lecture.
  • SUPPORT : lecture + impersonation (12c).
  • ADMIN : tout (export, équipe, templates, cookies, désactivation user).

Contrats (12a — livré)

`requireStravodaAdmin(minRole = 'ANALYST'): Promise<StravodaAdminSession>`

`StravodaAdminSession = { adminId, supabaseId, email, role }`.

  • `UNAUTHORIZED` si pas de session Supabase.
  • `FORBIDDEN` si aucune ligne `StravodaAdmin` (même email whitelisté), compte désactivé, ou rôle < `minRole`.
  • `hasAdminRole(role, minRole): boolean` — helper pur de comparaison de rang.

Service `lib/services/admin/`

  • `mrr.ts` (pur, testable, zéro I/O) :

- `monthlyEquivalent(school): number` — prix mensuel équivalent (mensualise l'annuel : facteur

`1 - ANNUAL_DISCOUNT` = 0,8). Source prix = `SUBSCRIPTION_TIERS` + `STRIPE_MODULE_PRICE_EUR`

Module — Audit diff champ par champ (14h-2)

Journal des modifications avec diff `{ field, before, after }[]` stocké dans `AuditLog.payload` existant — zéro nouvelle table.

Écriture (`lib/audit/`)

  • `write.ts` — `writeAuditLog({ …, diff?, reason? })` fusionne `diff` / `reason` dans `payload` + hash SHA-256.
  • `diff.ts` (PUR) — `computeAuditDiff(before, after, fields)`, `serializeAuditValue`, `AUDIT_SENSITIVE_FIELDS` (`password`, `tokenHash`, `supabaseId` → `[MODIFIÉ]`).
  • `shared/audit.ts` — types client-safe, `parseAuditDiff`, `maskDisplayValue` (masquage téléphone en UI).

Mutations enrichies

Chaque UPDATE lit l'état avant, calcule le diff après merge, appelle `writeAuditLog` :

ActionService
`UPDATE_STUDENT``student/mutations/update.ts`
`UPDATE_INSTRUCTOR``instructor/mutations/update.ts`
`UPDATE_VEHICLE``vehicle/mutations.ts`
`UPDATE_PACKAGE``billing/packages.ts`
`RESCHEDULE_LESSON``lesson/mutations/reschedule.ts`
`EXAM_RESULT_*``student/exam-result.ts`
`UPDATE_SCHOOL_SETTINGS``school/audit-update.ts` (+ mutations profile/rates/planning/cancellation/sms)

Module — Authentification (Étape 3a + 14r)

> Statut : ✅ Fait (3a + 14r). Onboarding (3b) séparé. Stack : Next.js 16.2.9, @supabase/ssr, Prisma 7, Zod 4, next-intl 4.

Rôle

Inscription gérant, connexion/déconnexion (email + Google OAuth), réinitialisation de mot de passe, enrôlement MFA TOTP, gestion de session (remember-me, timeout inactivité, déconnexion globale, journal connexions).

Les élèves n'ont pas de compte (lien JWT signé — voir `lib/auth/student-link.ts`). Moniteurs : invitation (Étape 9).

Flux de création de compte (décision : après confirmation email)

1. `POST /api/auth/signup` → rate limit `REGISTER` → Zod → `AuthService.signUp(input)`. Le service applique `assertPasswordLength` + `assertNotPwned` puis `supabase.auth.signUp` (metadata: firstName/lastName/schoolName/role) + `emailRedirectTo=/api/auth/callback`. Aucune écriture DB.

2. Email confirmé → `GET /api/auth/callback` → `exchangeCodeForSession` → `createSchoolAndOwner` (transaction idempotente) → redirige `/{locale}/onboarding/etape-1`.

3. Tant que non confirmé : pas de session → `requireAuth` échoue → pas d'accès `/dashboard`.

Routes API (`src/app/api/auth/`)

Les routes sont fines : `rate limit → Zod → AuthService`. Toute la logique Supabase Auth + le mapping

d'erreur vivent dans `services/auth.service.ts` (zéro logique métier dans `app/`).

RouteMéthodeRate limitEffet (délègue au service)
`signup`POST`REGISTER` (3/1h)

Module Facturation (Étape 8)

Spec : `specs/facturation.md`. Découpage 8a / 8b / 8c tous livrés (8c-0→8c-5).

8a — Forfaits & paiements (LIVRÉ)

Données

  • `Package` (existant) : forfait élève (nom, permis, heures, prix TTC, `includesExamFees`).
  • `Payment` (existant) : paiement (`amount`, `mode`, `reference?`, `invoiceNumber` unique, `receiptUrl?`).
  • `Avoir` (existant) : lecture seule en 8a (création 8c).
  • `InvoiceCounter` (nouveau) : `(schoolId, year, lastNumber)`, `@@unique([schoolId, year])` — compteur séquentiel.
  • `School` (+ champs) : `invoicePrefix?`, `vatExempt Boolean @default(true)`, `vatRate Float?`.

Numérotation facture (sans trou)

`[PREFIX]-[ANNÉE]-[SEQ4]` (ex. `TOULON-2026-0042`). Préfixe = `School.invoicePrefix` ; si absent, dérivé

du nom (`derivePrefix`) puis figé. L'incrément (`InvoiceCounter` via `upsert` + `increment`) ET la création

du `Payment` se font dans une transaction `Serializable` (`recordPayment`) → rollback = pas de trou ;

unicité `Payment.invoiceNumber` = pas de doublon. Séquence par école et par année civile.

TVA (reçu)

`vatExempt=true` (défaut) → « TVA non applicable, art. 293B du CGI ». Sinon « TVA {vatRate} % incluse (TTC) ».

Service — `lib/services/billing/` (façade `billing.service.ts`)

FonctionSignatureNotes
`createPackage``(schoolId, CreatePackageInput, actorUserId) → {packageId}`GERANT. Vérifie élève ∈ école. Audit `CREATE_PACKAGE`.

Module CPF (14z-2)

Scope

  • Certification Qualiopi sur `School` (toggle, n°, expiry, `mcfReference`)
  • Forfait : `cpfFinanced`, `cpfAmount`, `cpfDossierNumber`, `cpfStatus`
  • Page `/dashboard/cpf` (si `qualiopiCertified`)
  • Convention PDF `CONVENTION_CPF` (CERFA 10819*01)

Routes

MéthodeRouteRôle
GET`/api/cpf/guide`GERANT — assistant IA
GET`/api/cpf/dossiers`GERANT — suivi dossiers
POST`/api/packages/[id]/cpf`GERANT — activer CPF
GET`/api/packages/[id]/cpf-convention`GERANT — PDF convention

Relance post-échec

  • Cron `sms-reminders` → `remindExamFailureFollowup()`
  • Paramètres : onglet Annulations (`examFailureFollowup*`)
  • Idempotence : `ExamDate.failureFollowupSent` + dédup SmsLog 30j

Module — Guide interactif post-onboarding (Étape 14k + 14r-10 Phase 1)

> Barre/checklist non bloquante sur le dashboard gérant, après clôture du wizard 4 étapes (Étape 3b).

> Distinct du composant `OnboardingProgress` (wizard `/onboarding/*`) — ne pas confondre avec le modèle Prisma `OnboardingProgress`.

Vue d'ensemble

Après `School.onboardingCompletedAt` défini, le gérant voit :

1. Overlay bienvenue (1 fois, `welcomeSeenAt`) — « Commencer le guide » / « Passer »

2. Checklist flottante (coin bas-droit, réductible) — 5 étapes avec barre de progression

3. Tooltips contextuels — 1er visit planning / élèves (localStorage)

Confettis + email `ONBOARDING_COMPLETE` à la complétion.

Modèle Prisma (migrations `support_knowledge_dashboard_guide`, `dashboard_guide_welcome_14r10`)

model OnboardingProgress {
  id             String    @id @default(uuid())
  schoolId       String    @unique
  userId         String
  completedSteps String[]  // SCHOOL_INFO_CONFIGURED | INSTRUCTOR_INVITED | STUDENT_INVITED | LESSON_CREATED | SMS_CONFIGURED
  dismissedAt    DateTime?
  welcomeSeenAt  DateTime? // overlay bienvenue (14r-10)
  completedAt    DateTime?
  ...
}

Legacy 14k (`RATES_CONFIGURED`, `SMS_SENT`) normalisés via `DASHBOARD_GUIDE_LEGACY_STEP_MAP`.

Étapes et déclencheurs (14r-10)

StepLabel UILienHook métier
`SCHOOL_INFO_CONFIGURED`Infos école

Module — Sécurité données (RLS + chiffrement PII + masquage)

Deuxième couche de défense, au-delà de l'isolation applicative `schoolId` : RLS au niveau base + chiffrement

applicatif des PII + masquage des PII dans les journaux. Aucun changement de schéma Prisma.

1. Row Level Security (RLS)

`src/lib/db/rls-setup.sql` active le RLS et une politique « No direct access » (`USING (false)` pour

`anon` + `authenticated`) sur : Student, Lesson, Instructor, Vehicle, Package, Payment (le brief citait

« Invoice », inexistant), Document, AuditLog. Notre API se connecte avec le rôle service/propriétaire

(BYPASSRLS) → jamais bloquée. Application : `npx tsx scripts/apply-rls.ts` (idempotent, via `DIRECT_URL`).

2. Chiffrement des champs PII (AES-256-GCM déterministe)

Champs chiffrés au repos : `Student.phone/email/neph`, `Instructor.phone`.

  • `src/lib/crypto/field-encrypt.ts` — `encryptField`/`decryptField`, `encryptIfNeeded`/`decryptIfNeeded`

(idempotent, préfixe `enc:`), `isEncrypted`. IV déterministe = `HMAC(key, value)` → un même clair

donne un même chiffré : la recherche par égalité et l'unicité (téléphone/NEPH) restent possibles

(impossible avec un IV aléatoire). Clé lue depuis `process.env.ENCRYPTION_KEY` (64 hex).

  • `src/lib/db/encryption.ts` — extension Prisma transparente (`applyEncryption`) appliquée au

singleton (`lib/db/prisma.ts`) : chiffre `data`/`create`/`update`, transforme les `where` d'égalité

(`equals`/`in`/`not`, + `AND/OR/NOT`), et déchiffre récursivement les résultats (relations imbriquées

incluses, ex. `lesson.student.phone`). Couvre donc TOUTES les lectures/écritures sans toucher les ~40

services consommateurs (`assertPhoneUnique`, `checkNephDuplicate`, `requestPortalAccess`,

Module — Espace élève (`lib/services/eleve/`)

> Étape 11. Spec : `specs/espace-eleve.md`. État : 11a livré (accès + lecture seule) ·

> 11b-1 livré (réservation + annulation in-space) · 11b-2 livré (validation moniteur des

> demandes — voir `docs/modules/moniteur.md`) · 11c livré (documents : upload requis + contrat

> signé) · 11d livré (lien parent AAC, lecture seule — voir `docs/modules/parent.md`).

Vue d'ensemble

L'élève n'a pas de compte. Il accède à son espace via un magic-link : il saisit son

téléphone sur une page publique, reçoit par SMS un lien signé (`StudentLink` type `PORTAL`, TTL 24h

plafonné — cf. `.cursorrules §18`). L'identité (`studentId` + `schoolId`) vient **du JWT vérifié en

base à chaque requête** (défense en profondeur) — jamais d'un body/query. Branding école, jamais Stravoda.

Accès (anti-énumération)

  • Page publique `GET /[locale]/e/acces` → `EleveAccessForm` → `POST /api/portal/access`.
  • `requestPortalAccess(phone, locale)` :

- `phone` normalisé en amont par `PortalAccessSchema` (même transform qu'à la création élève).

- Cherche un élève `{ phone, deletedAt: null, isActive: true, smsConsent: true }`.

- Si trouvé : `createStudentLink({ type: 'PORTAL' })` + `enqueueStudentSms(STUDENT_PORTAL_ACCESS, url)` + audit `SEND_PORTAL_LINK`.

- Si non trouvé : ne fait rien (pas d'erreur, pas de SMS).

  • La route répond toujours `{ data: { ok: true } }` — aucune information sur l'existence du numéro.
  • Rate-limit : `RATE_LIMITS.STUDENT_LINK_REQUEST` (3 / 1h, par IP).

Espace (lecture seule)

Module Growth — Revenus automatiques (14z-3)

Créneaux flash

  • Déclencheur : annulation de leçon → `notifyWaitingListOnSlotFreed` (13c, inchangé côté `cancelLesson`).
  • Condition flash : créneau libéré dans les `school.flashAnticipationHours` (défaut 48h) et `flashSlotsEnabled`.
  • SMS : template `waitingList.flashOffer` si flash, sinon `waitingList.slotOffer`.
  • Acceptation OUI : leçon + `Payment` mode `FLASH_LESSON` au tarif réduit (`offerFlashPrice`).
  • Paramètres : onglet Annulations → toggle, % réduction (5–40), délai anticipation.
  • Stats dashboard : `getFlashSlotStats(schoolId)`.

Upsell espace élève

  • Seuil : `ELEVE_UPSELL_THRESHOLD` (2h restantes).
  • Checkout : package 10h (`ELEVE_UPSELL_PACKAGE_HOURS`) + Stripe Connect via `getEleveUpsellInfo`.
  • Webhook : `recordCheckoutPayment` + SMS gérant si `metadata.source=eleve_upsell`.

Widget leads

  • Modèle : `WidgetLead` (NEW → CONTACTED → CONVERTED | IGNORED).
  • Capture : `POST /api/public/leads` → `capturePublicLead` (plus de création Student directe).
  • Cron : `remindWidgetLeadJ1`, `remindWidgetLeadJ7` dans `enqueueDueReminders`.
  • Conversion : `convertWidgetLeadByPhone` à la création d'un `Student` (même téléphone).
  • UI : `/dashboard/prospects` (GERANT).

Rentabilité moniteur

  • Service : `computeInstructorProfitability(schoolId, instructorId)`.
  • UI : section « Rentabilité » fiche moniteur (GERANT uniquement, `showComparative`).
  • PDF : alimente le rapport unifié existant (14v).

Tests

`src/test/growth-14z3.test.ts`

Module — Moniteurs (Étapes 9a + 9b + 9c + 9e)

> Rôle : gérer les moniteurs d'une école — liste de pilotage avec statistiques de performance + fiche

> éditable (profil, couleur planning, permis enseignés, horaires, carte professionnelle, élèves assignés,

> indisponibilités, invitation à un compte, export iCal). Isolation `schoolId` stricte, verrou

> optimiste à l'édition, audit des écritures. Spec source : `specs/moniteurs-vehicules.md` §9a/§9b/§9c/§9e.

> Véhicules (9d) = module dédié `docs/modules/vehicles.md`.

Périmètre livré (9a)

  • Liste `/dashboard/moniteurs` (GERANT) : une ligne/carte par moniteur avec stats :

opérationnel (leçons réalisées, élèves assignés, prochaine indispo) + pilotage (taux présence, taux

réussite examens, revenus estimés, moyenne leçons/examen) + alerte carte pro expirante.

  • Fiche `/dashboard/moniteurs/[id]` (GERANT) : onglets Informations (identité, qualifications, contrat, accès Stravoda — édition inline par section + audit `UPDATE_INSTRUCTOR`), Planning (leçons du jour + indispos), Statistiques (`computeInstructorPeriodStats` + rentabilité), Documents (upload Supabase privé, URL signée 120 s), Congés (`InstructorLeavesPanel`). Header : avatar, badges, désactivation, lien planning.
  • CRUD : création (GERANT), édition (GERANT, verrou optimiste), soft-delete (GERANT, Ghost Data).

Indisponibilités (9b)

  • Modèle `Unavailability` (existant) : `startAt`/`endAt`, `reason?`, `isRecurring`, `recurrenceRule`.
  • Ponctuelle : créneau absolu unique `[startAt, endAt]`.
  • Récurrente hebdomadaire : `isRecurring=true` ; `recurrenceRule='WEEKLY:<JOUR>'` dérivé du jour de

semaine UTC de `startAt` ; bloque chaque semaine ce jour, sur la plage horaire de `[startAt,endAt]`

(≤ 24 h), à partir de la 1ʳᵉ occurrence.

Module — Internationalisation (Étape 14m / 14m-2)

Rôle

Permettre aux écoles hors France métropolitaine d'utiliser Stravoda avec fuseau horaire, devise et langue UI adaptés.

Données (Prisma)

ChampModèleDéfautUsage
`timezone``School``Europe/Paris`Bornes « aujourd'hui », créneaux élève, SMS, crons, PDF
`currencySymbol``School``€`Affichage montants UI
`preferredEmailHour``School``8`Rapport mensuel (1er du mois, heure locale)
`country``School`—Overlay légal CGV/CGU + suggestion timezone/devise

Migration : `prisma/migrations/20260626220000_school_internationalization/`

Config

  • `src/config/timezones.ts` — `COUNTRY_DEFAULT_TIMEZONE`, liste UI, `inferTimezoneFromCountry()`
  • `src/config/currencies.ts` — devises supportées, `getCurrencyConfig()`, `inferCurrencyFromCountry()`

Module — Conformité travail & jours fériés (14r-6)

Jours fériés

  • Config : `src/config/public-holidays.ts` (FR/BE/ES/MA, algorithme Butcher)
  • Sync école : `src/lib/services/school/public-holidays-sync.ts` → `School.publicHolidays`
  • Récurrence : sauts automatiques (`reason: HOLIDAY`) dans `recurrence.ts`
  • API pré-check : `GET /api/planning/holiday-check?date=`

Droit du travail

  • Config : `src/config/labor-rules.ts`
  • Vérification création leçon : `src/lib/services/labor/compliance.ts`
  • Dashboard : `loadLaborComplianceOverview` + widget `DashboardLaborCompliance`

Congés moniteurs

  • Modèle : `InstructorLeave` (Prisma)
  • Services : `src/lib/services/instructor-leave/`
  • API gérant : `/api/instructors/[id]/leaves`
  • API moniteur : `/api/moniteur/leaves`

Signatures internes

  • Canvas : `src/components/documents/SignatureCanvas.tsx`
  • Service : `src/lib/services/document/internal-signature.ts`
  • Page : `/dashboard/documents` (onglet signatures)
  • YouSign inchangé pour contrat, devis, convention CPF, DPA

Module Pages légales (Étape 13.5)

Pages publiques sous `/[locale]/legal/*`. Contenu i18n (`locales/fr/legal.json`) — brouillon juridique, relecture avocat obligatoire avant prod.

Routes

RouteContenu
`/legal/mentions-legales`Éditeur, SIRET, hébergeurs Vercel + Supabase
`/legal/confidentialite`RGPD, mineurs AAC, sous-traitants, droits
`/legal/cgv`Abonnement SaaS, essai 14j, résiliation
`/legal/cgu`Règles d'usage
`/legal/cookies`Types de cookies, lien bandeau

Redirections legacy : `/cgv` → `/legal/cgv`, `/confidentialite` → `/legal/confidentialite`.

Brouillon

`LEGAL_DRAFT_MODE=true` (env serveur) → bannière rouge `LegalDraftBanner` sur toutes les pages `/legal/*`.

Constantes

`LEGAL_ENTITY` dans `shared/constants.ts` — raison sociale, SIRET, contacts (placeholders à compléter avant mise en ligne).

Footer

Landing page + `LegalNav` interne — liens vers les 5 pages.

Module — Preuves de leçon & signatures (14r-6)

Signature interne (non eIDAS)

Types concernés (`INTERNAL_SIGNATURE_DOC_TYPES`) :

  • `FICHE_PRESENCE`
  • `ATTESTATION_RDV_AAC`
  • `RAPPORT_PROGRESSION`
  • `RECU_ESPECES`

Flow :

1. Document créé avec statut `EN_ATTENTE_SIGNATURE`

2. `SignatureCanvas` capture PNG

3. `POST /api/documents/[id]/internal-sign` → SHA-256 → statut `SIGNE`

YouSign (eIDAS — inchangé)

  • `CONTRAT_FORMATION`, `DEVIS`, `CONVENTION_CPF`, DPA

Dashboard signatures

  • `GET /api/documents/signatures`
  • Badge rouge si `EN_ATTENTE_SIGNATURE` > 7 jours (`SIGNATURE_OVERDUE_DAYS`)

Module — Messagerie interne (14r-7)

> Chat gérant ↔ moniteurs (pas d'élèves). Push notification via infra 14i.

Modèle

`InternalMessage` : `schoolId`, `fromUserId`, `toUserId?` (null = broadcast équipe), `content`, `readAt?`.

Routes

  • `GET /api/messages?peerUserId=` — liste (auth, scopée session)
  • `POST /api/messages` — envoi (GERANT, `SendMessageSchema`)
  • `POST /api/messages/[id]/read` — marquer lu

UI

  • Gérant : `/dashboard/messages` (sidebar + badge non-lus)
  • Moniteur : `/moniteur/messages` (bottom nav + badge)

Services

`lib/services/messaging/` — queries, mutations (push), peers.

Tests

`src/test/features-14r7.test.ts` (push destinataire / broadcast).

Module — Espace moniteur (Étape 10)

> Rôle : espace mobile-first dédié au rôle `MONITEUR`, sous `/moniteur`. Le moniteur consulte sa

> journée, clôture ses leçons (effectuée / élève absent), voit son planning en lecture seule, gère ses

> élèves référents (fiche + progression REMC éditable) et son profil (infos en lecture, indisponibilités

> ponctuelles, iCal, stats du mois). Isolation stricte : un moniteur ne voit/ne touche QUE ses propres

> leçons et ses élèves référents. Zéro accès facturation. Spec source : `specs/espace-moniteur.md`.

Décisions de cadrage (validées 2026-06-22)

  • `Package.usedHours` à la complétion : différé à l'Étape 13a. `Lesson` n'a pas de champ `packageId` ;

brancher l'incrément sans le recrédit symétrique (annulation/no-show) créerait une comptabilité asymétrique.

Le lien `Lesson↔Package` atterrira en un bloc cohérent.

  • `/moniteur/demandes` : livrée à l'Étape 11b-2. La demande concrète est portée par `BookingRequest`

(créneau + permis + statut), écrite par l'espace élève (11b-1). Le moniteur accepte (création de leçon,

re-check conflit) ou refuse (SMS élève). Le badge accueil affiche le vrai nombre de demandes PENDING.

  • Session : `requireAuth`/`AuthSession` étendus avec `instructorId: string | null` (depuis `User.instructorId`).
  • 10d progression REMC : éditable par le moniteur sur ses référents (cohérent 6c-3).
  • 10e profil : lecture seule côté moniteur (édition = GÉRANT, fiche 9a).
  • 10e indispo a posteriori : autorisée + avertissement des leçons en conflit (aucune leçon modifiée).

Périmètre livré

Module Nautique (14y)

Périmètre

Clubs nautiques et écoles de voile : permis côtier/hauturier existants + VHF, CRR, permis fluvial pro.

Schéma

  • `School.market` : `NAUTIQUE`
  • `LessonType.PRATIQUE_EAU`, `THEORIE` — leçons salle / eau
  • `LessonStatus.CANCELLED_WEATHER` — annulation météo sans pénalité (`lessonConsumedHours` = 0)
  • Champs leçon existants : `weatherConditions`, `meetingPoint`, `navigationZone`

Documents requis (catégorie BATEAU)

`CERTIFICAT_MEDICAL_NAUTIQUE`, `ATTESTATION_NAGE` — en plus des permis VHF/CRR si sélectionnés.

Alertes dashboard

  • Leçons annulées météo (semaine en cours) — `getTransportDashboardAlerts()` si `market === NAUTIQUE`
  • Permis VHF non planifié pour élèves `BATEAU_COTIER` sans leçon `VHF` planifiée

Routes API

MéthodeRouteRôle
POST`/api/lessons/[id]/weather-cancel`GERANT — annulation météo sans pénalité + audit `CANCEL_LESSON_WEATHER`

UI

  • Bouton « Annuler (météo) » dans `LessonDetailPanel` pour leçons catégorie BATEAU (GERANT)

Isolation

Même règle multi-tenant : `schoolId` depuis session sur toutes les requêtes.

Module — Onboarding (Étape 3b · 14r-10 Phase 2 : 5 étapes)

> Rôle : guider un gérant fraîchement confirmé (Étape 3a) à travers 5 étapes jusqu'à un abonnement

> Stripe en essai (14 j, zéro débit immédiat), puis l'accès au dashboard.

> Spec source : `specs/onboarding.md` + `specs/14r-10-phase2-wizard.md`. Décisions : `docs/DEVELOPER_WAR_BOOK.md`.

Parcours (5 étapes — 14r-10 Phase 2)

1. Étape 1 — Métier : AUTO / TRANSPORT / NAUTIQUE / SOLO. Pose les indices de tier (SOLO→SOLO,

TRANSPORT→TRANSPORT) et ré-amorce `permitTypes` avec `MARKET_DEFAULT_PERMITS` (si métier changé / vide). → `saveMarket`.

2. Étape 2 — Permis : cards par catégorie filtrées par le métier (`PermitCardSelector`). → `savePermits`.

3. Étape 3 — Infos école : nom, contact, pays, adresse, SIRET/SIREN, Qualiopi ; tarifs suggérés

calculés (permis + pays). → `saveSchoolInfo`.

4. Étape 4 — Formule : tier (Starter/Pro/École ou Solo) + période mensuel/annuel (annuel = −20 %)

+ modules auto-déduits des permis + prix temps réel. → `selectPlan`.

5. Étape 5 — Carte : SetupIntent (Payment Element) → aucun débit → abonnement `trial_period_days: 14`,

puis clôture immédiate (`finalizeOnboarding`) → dashboard.

> Le setup initial moniteur/véhicule ne fait plus partie du wizard (Phase 1 14r-10) : il est couvert

> par le guide post-login (`INSTRUCTOR_INVITED`, `LESSON_CREATED`…). Pour Solo, `finalizeOnboarding`

> crée automatiquement le profil moniteur (`ensureSoloInstructor`).

Barre de progression (`components/onboarding/OnboardingProgress`, 5 étapes), étape active en emerald, `aria-current="step"`.

Module — Suivi parent AAC (`lib/services/parent/`)

> Étape 11d. Spec : `specs/espace-eleve.md` (§11d). État : livré. Portail public lecture seule

> pour l'accompagnateur d'un élève en conduite accompagnée (AAC). Vue de suivi + carnet de bord

> (déclaration de trajets, seule interaction parent hors lecture seule). Self-service : le parent

> peut renouveler son lien expiré via `/parent/acces` sans solliciter le gérant.

Vue d'ensemble

Le parent n'a pas de compte. Le gérant lui envoie un lien signé depuis la fiche élève

(`StudentLink` type `PARENT`, TTL 24h plafonné). L'identité (`studentId` + `schoolId`) vient du

JWT vérifié en base à chaque requête. Branding école, jamais « Stravoda ». `type` de `StudentLink`

est un `String` libre → la valeur `PARENT` n'a demandé aucune migration.

Génération du lien (gérant)

`createParentLink(schoolId, studentId, actorUserId, locale)` (`parent/link.ts`) :

  • Élève scopé `{ id, schoolId, deletedAt: null }` (sinon `NOT_FOUND`).
  • Éligibilité : `isAAC` et `aacParentPhone` renseigné, sinon `CONFLICT`

(`students.errors.parentLinkUnavailable`). L'AAC peut être majeur — la minorité n'est pas requise.

  • `createStudentLink({ type: 'PARENT' })` → URL `…/{locale}/parent/{token}`.
  • SMS au `aacParentPhone` : `enqueueStudentSms` avec `studentId: null` + content pré-rendu

(`renderSms(PARENT_ACCESS, { firstName, link })`). `studentId: null` ⇒ `resolveRecipients` envoie au

`recipientPhone` sans passer par le consentement/routage SMS de l'élève (le destinataire est le parent).

Module — Permis (Étape 14s)

> Source unique client-safe : `src/shared/permits.ts`. Config serveur dérivée : `lib/config/permit-types.ts`.

Types de permis (enum Prisma `PermitType`)

PermisCatégorieModule Stripe addon
B, B96, BE, AMVOITURE (AM = cyclo, tier base)Non (inclus tier voiture)
A1, A2, AMOTOOui si ≥1 permis moto (hors AM)
BATEAU_COTIER, BATEAU_HAUTURIERBATEAUOui si ≥1 permis bateau

> Note : le plan maître mentionne COTE/FLUVIAL/BATEAU legacy — le code conserve `BATEAU_COTIER` / `BATEAU_HAUTURIER` (rétrocompatibilité données existantes).

API publique (`shared/permits.ts`)

  • `PERMIT_TYPES` — liste exhaustive (Zod + UI)
  • `getPermitCategory(permit)` → `'VOITURE' | 'MOTO' | 'BATEAU'`
  • `VOITURE_PERMITS`, `MOTO_PERMITS`, `BATEAU_PERMITS`
  • `DEFAULT_HOURLY_RATES` — tarifs horaires suggérés (€/h)
  • `ratesForPermitTypes(permitTypes, existing?)` — fusion école / défauts
  • `PERMIT_META` — labelKey, minAge, durationHours, competencyKeys REMC par catégorie
  • `competencyLabelKey(permitType, competency)` — clé i18n `pedagogy.competenciesByCategory.*`

Couleurs planning (`module-colors.ts`)

  • `CATEGORY_COLORS` : VOITURE `#10b981`, MOTO `#f59e0b`, BATEAU `#3b82f6`

Module — Planning (Étape 5)

> Rôle : afficher et gérer les leçons d'une auto-école dans un calendrier.

> 5a (livrée) : lecture/affichage (FullCalendar + vue semaine). 5b (livrée) : création +

> détection de conflits + drag & drop + annulation. 5c (livrée) : récurrence + remplacement

> moniteur + fermeture exceptionnelle + politique crédit/pénalité à l'annulation.

> Export imprimable (8c-4) : `PlanningExportButton` → `GET /api/planning/export` (PDF A4 paysage

> d'une plage, filtre moniteur, flux à la volée) ; service `planning-export.service.ts`. Voir

> `docs/modules/billing.md` §8c-4.

Périmètre 14u (planning enrichi — popup + couleurs + UX)

  • Popup leçon : `LessonDetailPanel` — `GET /api/lessons/[id]` (`getLessonDetail`) charge élève (tel/sms, heures restantes, prochain examen, badge dossier), moniteur (tel, leçons du jour), véhicule + lieu. Actions gérant : valider (`POST …/complete`), absent (`POST …/no-show`), déplacer (hint drag), remplacer moniteur, annuler. Moniteur : effectuée+note, absent, incident.
  • Couleurs : `lessonEventStyle(permitType, status)` — fond catégorie (`CATEGORY_COLORS`) + overlay statut (opacité COMPLETED, rayures CANCELLED, rouge NO_SHOW/INCIDENT). Surcharge journalière moniteur → `fc-event-overloaded`.
  • Calendrier : tooltip natif (élève · moniteur · heure), légende catégories+statuts (`PlanningLegend`), chips moniteurs cliquables, onglets 🚗/🏍️/⛵.
  • Fiche élève : `StudentMiniPlanning` (onglet Leçons, 30 jours) + `LessonModal` pré-rempli.

Périmètre 5a (lecture)

  • Page `/dashboard/planning` (sous le layout dashboard → RBAC `GERANT`/`COMPTABLE` + gating déjà assurés).
  • Calendrier FullCalendar chargé en lazy (`next/dynamic`, `ssr:false`) — composant lourd qui touche le DOM.
  • Vues : mois / semaine (défaut, `timeGridWeek`) / jour / liste (barre d'outils native FullCalendar).
  • Onglets par catégorie de permis présente sur l'école (Voiture · Moto · Bateau), accent couleur depuis `CATEGORY_ACCENT` (`module-colors.ts`).

Module — Quotas & Coûts (anti-abus par école)

Protection contre les abus de coût des fournisseurs externes (IA, SMS, stockage, email), par école, via

des compteurs Redis (Upstash) — zéro nouvelle table Prisma. Les alertes et dépassements sont tracés

en `AuditLog` (zéro PII). Une vue coûts back-office agrège l'usage et estime le coût total plateforme.

Constantes (`src/shared/constants.ts`)

ConstanteValeur
`AI_MONTHLY_TOKENS_PER_SCHOOL`100 000
`AI_DAILY_TOKENS_PER_SCHOOL`10 000
`AI_MAX_TOKENS_PER_REQUEST`1 000 (borne dure des tokens de sortie/appel)
`SMS_MONTHLY_LIMIT_PER_SCHOOL` / `SMS_DAILY_LIMIT_PER_SCHOOL`500 / 100
`STORAGE_LIMIT_PER_SCHOOL_MB`500 (alerte à 90 %)
`EMAIL_MONTHLY_LIMIT_PER_SCHOOL`1 000
`QUOTA_MONTHLY_TTL_S` / `QUOTA_DAILY_TTL_S`35 j / 2 j
`SMS_UNIT_COST_EUR` / `AI_COST_EUR_PER_1K_TOKENS` / `EMAIL_UNIT_COST_EUR`0,06 / 0,009 / 0,001

Clés Redis

Module Rapports mensuels (14h)

Rapport PDF mensuel automatique pour chaque école ACTIVE/TRIALING.

Modèle

`SchoolMonthlyReport` — une ligne par `(schoolId, period)` avec `dataJson`, `pdfUrl`, `pdfHash`, `sentAt`.

Services

  • `lib/services/reports/monthly/aggregate.ts` — agrégation des métriques (4 pages PDF)
  • `lib/services/reports/monthly/generate.ts` — rendu PDF, upload bucket privé, upsert DB
  • `lib/services/reports/monthly/email.ts` — email gérant via `renderEmailTemplate('MONTHLY_SCHOOL_REPORT')`
  • `lib/services/reports/monthly/queries.ts` — liste et téléchargement
  • `lib/services/reports/monthly/cron.ts` — batch 1er du mois

Routes

RouteAuthDescription
`GET /api/reports/monthly/[period]`GERANT, PDF_EXPORTTélécharge le PDF archivé
`POST /api/reports/monthly/generate`GERANT, PDF_EXPORTGénération manuelle mois en cours
`GET /api/cron/school-monthly-report`CRON_SECRETCron `0 * 1 * *` (filtre heure locale école)

UI

  • `/dashboard/rapports` — 12 derniers rapports + archive + génération manuelle
  • Lien depuis Paramètres → onglet DREAL

PDF

`lib/pdf/monthly-school-report/` — 4 pages : synthèse, moniteurs, finances, alertes.

Module Avis Google IA (14z)

Périmètre

Sync Google Business Profile, réponses Claude, délai humain, validation gérant pour avis 1–3 étoiles.

Schéma

  • `GoogleReview` — statuts `PENDING | APPROVED | POSTED | IGNORED`
  • `School.googleBusinessAccountId`, `googleBusinessTokensEncrypted` (AES-256-GCM)

Services

FonctionFichier
`listReviews``lib/services/reviews/queries.ts`
`generateReviewResponse``lib/services/reviews/generate.ts`
`computeScheduledPostAt``lib/services/reviews/schedule.ts`
`approveReview` / `ignoreReview` / `regenerateReview``lib/services/reviews/mutations.ts`
`syncGoogleReviews``lib/services/reviews/sync.ts`
`postDueReviewResponses``lib/services/reviews/post.ts`
`storeGoogleTokens``lib/services/reviews/tokens.ts`

Routes API

MéthodeRoute

Module admin — RGPD Master (14r-5)

Description

Vue consolidée `/stravoda-admin/rgpd/master` (ADMIN) : registre Art. 30, DPA par école, effacements, violations CNIL, export pack compliance ZIP.

Modèle Prisma

  • `DataBreach` — violations déclarées (severity, status, reportedAt)

Routes API

  • `GET /api/admin/rgpd/master` — overview + DPA + breaches
  • `POST /api/admin/rgpd/breaches` — déclarer une violation
  • `POST /api/admin/rgpd/breaches/[id]/report` — marquer déclaration CNIL
  • `GET /api/admin/rgpd/compliance-pack` — ZIP (registre PDF/CSV, audit, violations)

Services

  • `lib/services/admin/rgpd-master/overview.ts`
  • `lib/services/admin/rgpd-master/breaches.ts`
  • `lib/services/admin/rgpd-master/compliance-pack.ts`

Tests

  • `src/test/admin-rgpd-master.test.ts`

Alertes

  • DPA manquant école active
  • Effacement > 25 j (`COMPLIANCE_RGPD_PENDING_DAYS`)
  • Violation non déclarée > 72 h (`DATA_BREACH_CNIL_HOURS`) — KPI master + badge UI + bouton déclaration CNIL

UI (2026-06-30 overhaul)

  • `/stravoda-admin/rgpd/master` : `DataBreachPanel` (déclarer violation, marquer CNIL, badge délai dépassé), KPI `breachOpen`/`breachUnreported`
  • `/stravoda-admin/rgpd` : pagination cursor, modal refus, lien école si nom résolu

Module RGPD & Preuves légales (ÉTAPE 14z-4)

Complète la conformité RGPD et construit le système de preuves légales. Étend l'existant

(anonymisation, AuditLog, YouSign, PDF) — ne duplique rien.

Existant réutilisé (ne pas dupliquer)

BriqueEmplacement
Soft-delete + cron anonymisation (5 ans)`src/lib/services/student/{mutations/delete,anonymize}.ts`
Effacement public (Art. 17)`src/lib/services/erasure/*`, `DataErasureRequest`
AuditLog immuable + `writeAuditLog``src/lib/audit/write.ts`, model `AuditLog`
Signature électronique eIDAS`src/lib/yousign/*`, `src/lib/services/student/signature.ts`
PDF (`@react-pdf/renderer`)`src/lib/pdf/*` (render.tsx, types.ts, styles.ts)
Stockage privé + URL signée`src/lib/storage/documents.ts`
Résumé RGPD école`src/lib/services/school/queries/rgpd.ts`
Garde admin Stravoda`src/lib/auth/stravoda-admin.ts` (`ADMINSUPPORTANALYST`)

Décisions d'architecture (validées)

Module — Sécurité (anti-abus essai + dashboard sécurité)

Anti-abus de l'essai gratuit (inscription gérant) + blocages (IP / email / pattern / compte) + journal

d'événements de sécurité observés sur toutes les écoles, exposés dans un back-office dédié.

> Principe d'isolation inversé (comme tout `/stravoda-admin`) : l'équipe Stravoda observe l'ensemble

> des écoles — pas d'isolation `schoolId`, remplacée par le RBAC `StravodaAdmin` (ANALYST < SUPPORT < ADMIN).

Modèles Prisma

  • `TrialUsage` — une ligne par démarrage d'essai (signup). `emailNormalized` + `emailHash` (SHA-256)

+ `ip` + `userAgent`. `flagged`/`flagReason` (signal faible non bloquant). `schoolId` rempli a posteriori.

  • `BlockedEntity` — `type` ∈ `IP | EMAIL | EMAIL_PATTERN | USER`, `value`, `reason`, `expiresAt?`

(null = permanent), `isActive` (false = débloqué, jamais supprimé), `evidence?`/`notes?`. `@@unique([type,value])`.

  • `SecurityEvent` — `type` (9 types) + `severity` (`LOW|MEDIUM|HIGH|CRITICAL`) + `ip/userId/email/metadata`

+ `resolved`/`resolvedAt`/`resolvedBy`.

Migration : `prisma/migrations/20260626153000_security_anti_abuse` (à appliquer via `prisma migrate dev`).

Normalisation email (`lib/security/email-normalize.ts`, PUR)

  • `normalizeEmail` : lowercase → alias domaine (`googlemail→gmail`, `hotmail.fr→hotmail.com`) →

suppression sous-adressage `+tag` → suppression des points pour domaines « dotless » (Gmail).

  • `emailSimilarityScore(a,b)` : Levenshtein normalisé sur les parties locales (`1 − dist/(len_a+len_b)`),

`"john"/"j0hn" ≈ 0.875`. Seuil suspect `EMAIL_SIMILARITY_SUSPECT_THRESHOLD = 0.85`.

Module Paramètres école (Étape 13)

Hub `/dashboard/parametres` — configuration multi-onglets scopée `schoolId` (session serveur).

Onglets

OngletChamps PrismaRoute API
ÉcolelogoUrl, primaryColor, siret, phone, email, website, address, city, postalCode, agrementNumber, agrementExpiry`PATCH /api/school/profile`, `POST /api/school/logo`
TarifsdefaultHourlyRates (Json)`PATCH /api/school/rates`
Abonnement(Stripe)existant `SubscriptionCard` + portal
PlanningopeningHours, defaultLessonDuration, instructorBuffer, maxDailyInstructorHours`PATCH /api/school/planning`
AnnulationscancellationDeadlineHours, cancellationPenaltyHours, smsAacRouting`PATCH /api/school/cancellation`
SMSsmsSenderName, smsReminderHoursBefore, googleReviewUrl`PATCH /api/school/sms-settings`
ÉquipeUser + Instructor

Module — SMS (Étape 7)

> Rôle : envoyer les SMS transactionnels et marketing d'une auto-école de façon fiable et conforme

> (consentement, routage AAC, RGPD). 7a (livrée) : dispatch (file de fiabilité OVH), rappels

> automatiques, lien d'annulation public, webhook delivery report. 7b (livrée) : campagnes par

> segment, SMS avis Google post-permis, relance document manquant.

>

> Provider OVH (`lib/sms/`, signature OVH) inchangé. Ce module = orchestration métier (`lib/services/sms/`).

Architecture

src/lib/services/sms/
  types.ts        RenderContext, RecipientResolution, DispatchSummary, ReminderSummary, SmsBlockReason
  consent.ts      resolveRecipients() — consentement (BLOQUANT) + routage AAC
  render.ts       registre templateKey → sms.json, interpolate {{var}}, composeMessage, format date/heure
  dispatch.ts     sendSms(log) + dispatchQueued() — pick, backoff, MAJ statut
  reminders/      shared.ts · lessons.ts · exams.ts · packages.ts · reputation.ts · documents.ts · index.ts
  segments.ts     loadSegmentCandidates() + matchSegment()/filterSegment() (ciblage campagnes 7b)
  campaigns.ts    previewCampaign() + sendCampaign() (campagnes par segment 7b)
  cancel.ts       getCancelDetails() + performCancellation() (annulation publique)
  webhook.ts      applyDeliveryReport() (DLR OVH → DELIVERED|FAILED)
  index.ts        façade SmsService
src/lib/auth/cancel-link.ts   token CANCEL dédié (JWT 26h, haché en DB)
src/lib/auth/cron.ts          assertCronAuthorized() (CRON_SECRET timing-safe, partagé)
src/app/api/cron/sms-dispatch/route.ts     GET (5 min)
src/app/api/cron/sms-reminders/route.ts    GET (1 h)
src/app/api/cancel/[token]/route.ts        GET (détails) + POST (annulation)
src/app/api/webhooks/sms/route.ts          POST (DLR OVH)
src/app/api/sms/campaigns/route.ts         GET (aperçu) + POST (envoi) — GERANT (7b)
src/app/api/sms/settings/route.ts          PATCH (lien avis Google) — GERANT (7b)
src/app/[locale]/cancel/[token]/page.tsx   page publique + components/sms/CancelLessonView.tsx
src/app/[locale]/dashboard/sms/page.tsx    campagnes + paramètre avis Google (7b)
  components/sms/SmsCampaignForm.tsx · SmsReviewSettingsForm.tsx

File de fiabilité (queue `SmsLog`)

`QUEUED → SENT → DELIVERED | FAILED`. Producteurs (planning 5c, élèves 6b, rappels 7a) créent des

Module — Statistiques comparatives (14r-7)

> Page gérant `/dashboard/statistiques` : taux réussite code/conduite, leçons moyennes avant permis, graphique 12 mois, comparaison moniteurs, occupation planning (Pro+).

Benchmarks

Constantes statiques `NATIONAL_BENCHMARKS` dans `shared/constants.ts` (mise à jour manuelle 1×/an).

Services

ServiceFichierRôle
`getSchoolStatistics``overview.ts`Stats comparatives nationales
`getOccupancyStats``occupancy.ts`Taux d'occupation planning (gate `advancedStats`)

Occupation : `WEEKLY_HOURS_PER_INSTRUCTOR = 40` ; leçons `PENDING/CONFIRMED/COMPLETED/NO_SHOW` ; alertes >90 % / <30 % / 60–80 % optimal.

Routes API

RouteRBACGate
`GET /api/stats/occupancy?period=month\week\3m\custom&from=&to=`GERANT`advancedStats` Pro+

Module — Élèves (Étapes 6a + 6b)

> Rôle : gérer les élèves d'une école — liste filtrable/paginée + fiche 5 onglets (DOSSIER éditable,

> autres onglets en lecture seule) + self-onboarding, export RGPD, parrainage et anonymisation.

> Isolation `schoolId` stricte, audit des accès et des écritures.

> Spec source : `specs/students.md`. 6c (documents/PDF/import/pédagogie) reste non construit.

Périmètre livré (6a)

  • Liste `/dashboard/eleves` : colonnes nom / téléphone / heures restantes (barre) / documents / prochain

examen ; recherche nom+NEPH (debounce) ; filtres serveur (statut actif/inactif, moniteur référent,

documents manquants) ; pagination cursor (`Charger plus`) ; mises en évidence client « forfait

< 3h » (ambre) et « impayés » (rouge) — voir note agrégats.

  • Fiche `/dashboard/eleves/[id]` : entête (mineur/statut) + onglets `Dossier | Leçons | Forfait &

paiements | Examens | Documents`. DOSSIER éditable (drawer) ; les autres onglets affichent l'existant

en lecture seule (édition complète = 6b/6c/Étape 8).

  • CRUD : création (GERANT), édition (GERANT, verrou optimiste), soft-delete RGPD (GERANT, Ghost Data).

Architecture

shared/schemas/student.ts        Zod : Create / Update (+ version) / ListQuery ; isMinor() pur ;
                                 refine « mineur ⇒ contact d'urgence obligatoire »
lib/services/student/
  types.ts        formes publiques (liste, dossier, détail onglets)
  utils.ts        cache tags (school:${id}:students[:${sid}]), revalidateStudents, clés d'erreur i18n
  validation.ts   assertCanDeleteStudent (Ghost Data), assertInstructorInSchool, assertPhoneUnique
  queries.ts      listStudents (cursor + dérivés), listActiveInstructors
  detail.ts       getStudentDetail, getStudentOwner
  audit.ts        recordStudentView (VIEW_STUDENT — moniteur NON référent uniquement)
  mutations/      create.ts · update.ts (verrou optimiste) · delete.ts (soft-delete) · index.ts
  index.ts        façade + StudentService
lib/services/student.service.ts  shim de compat (export * from './student')
app/api/students/route.ts        GET (liste, GERANT/COMPTABLE/MONITEUR) · POST (GERANT)
app/api/students/[id]/route.ts   GET (+ VIEW audit) · PATCH (GERANT) · DELETE (GERANT)
app/[locale]/dashboard/eleves/   page.tsx (liste RSC) · [id]/page.tsx (fiche RSC) · loading/error
components/students/             StudentsClient · StudentsTable · StudentFormFields · StudentFormSheet
                                 · StudentDetailView · StudentReadTabs
shared/sanitize.ts               cleanText() — nettoyage HTML des champs texte libre (notes, adresse…)

Module — Support IA (Étape 12z-1 + auto-apprentissage 14k)

Assistant IA intégré (widget) pour les gérants/moniteurs + back-office de supervision (équipe Stravoda).

Spec : `specs/support-ia-emailing.md`. Provider : Anthropic (Claude Sonnet). Retrieval : full-text Postgres.

Vue d'ensemble

  • Widget (`/dashboard`, `/moniteur`) : bouton flottant « ? » → chat. L'utilisateur authentifié pose une

question ; l'IA répond à partir de la base de connaissances (Q&A curées + docs/modules). Si l'IA n'est

pas confiante → ticket `PENDING_HUMAN` + notification interne. Une réponse humaine ultérieure apparaît

dans le widget.

  • Admin (`/stravoda-admin/support`) : 4 onglets — Conversations (`source=DASHBOARD`), Chat Commercial

(14r-8), Base Q&A (CRUD + import CSV, hors catégorie `SALES`), Documentation (sync depuis `docs/modules/*.md`

+ stats auto-apprentissage 14k).

  • Pages publiques (`/aide`, `/documentation`) : contenu servi depuis `SupportQA` (`isPublic=true`, hors

`SALES`) et `SupportDocument` (`isPublic=true`). Éditable depuis l'admin (toggle `isPublic`). Seed idempotent

des Q/R historiques de `help.json` : `POST /api/admin/seed-help-qa` (ADMIN) ou bouton « Charger les Q&A /aide ».

Auto-apprentissage Q&A (14k)

  • Sur conversation `HUMAN_REPLIED` non encore curée : bouton Ajouter à la base Q&A → drawer pré-rempli

(1ʳᵉ question user + réponse admin + catégorie suggérée par Claude).

  • Validation SUPPORT → `createQa` + `SupportConversation.addedToKnowledge = true` + badge « ✓ Dans la base ».
  • Lors de la réponse à un ticket `PENDING_HUMAN` : bannière si similarité textuelle ≥ `SUPPORT_QA_SIMILARITY_THRESHOLD` (0,8) avec une Q&A existante.

Module Transport (14y)

Périmètre

Centres de formation poids lourd et CPC : permis C/CE/C1/C1E/D/DE/D1/D1E, suivi CPC élève, documents réglementaires, tier `TRANSPORT`.

Schéma

  • `School.market` : `TRANSPORT`
  • `CpcRecord` : formations CPC_INITIAL (280 h) et CPC_PERIODIQUE (35 h)
  • `Student.medicalVisitDate` : visite médicale obligatoire (5 ans)
  • `SubscriptionTier.TRANSPORT` : 189 €/mois — inclut module CPC + export CERFA

Services

FonctionFichier
`listCpcRecords``lib/services/cpc/mutations.ts`
`getStudentCpcSummary``lib/services/cpc/mutations.ts`
`createCpcRecord``lib/services/cpc/mutations.ts`
`completeCpcRecord``lib/services/cpc/complete.ts`
`updateStudentMedicalVisit``lib/services/cpc/medical-visit.ts`
`getTransportDashboardAlerts``lib/services/cpc/alerts.ts`
`exportCerfaPdf``lib/services/cpc/cerfa.ts`

Routes API

Méthode

Module — Véhicules (Étape 9d)

> Rôle : gérer le parc d'une école — liste avec statut contrôle technique / assurance + CRUD (création,

> édition, archivage soft). Isolation `schoolId` stricte, verrou optimiste à l'édition, Ghost Data avant

> archivage, audit des écritures. Alertes CT/assurance branchées sur l'accueil gérant.

> Spec source : `specs/moniteurs-vehicules.md` §9d.

Périmètre livré

  • Liste `/dashboard/vehicules` (GERANT) : photo + marque/modèle/année, lien fiche, badges CT/assurance/km.
  • Fiche `/dashboard/vehicules/[id]` (GERANT) : 4 onglets — Informations (photo), Documents, Alertes & révisions (carnet intégré), Planning.
  • CRUD : création / édition (verrou optimiste) / archivage soft (Ghost Data) — tiroir depuis liste ou fiche.
  • Dashboard : alertes `VEHICLE_CT`, `VEHICLE_INSURANCE` et `VEHICLE_MAINTENANCE_KM` (km > prochaine révision).
  • Notifications proactives : cron horaire (`sms-reminders`) à 9h locale → push gérant (+ email `VEHICLE_EXPIRY_ALERT` si pas de push) pour CT/assurance ≤ 30 j et non expirés ; anti-spam Redis `vehicle:*-alert:{vehicleId}:{month}` (fail-open).
  • Kilométrage leçon : champs `kmStart`/`kmEnd` à la clôture moniteur → mise à jour `Vehicle.currentKm`.
  • Carnet de route : `/dashboard/vehicules/[id]/carnet` + export PDF mensuel (`GET /api/vehicles/[id]/logbook`).

Architecture

shared/schemas/vehicle.ts        Zod : Create / Update (+ version) / ListQuery ; VEHICLE_TYPES
                                 (MANUEL|AUTO|MOTO|BATEAU) ; permitType ; km bornés (VEHICLE_MAX_KM)
lib/services/vehicle/
  types.ts        formes publiques (VehicleListItem, ExpiryStatus)
  utils.ts        cache tags (school:${id}:vehicles[:${vid}]), revalidate, expiryStatus +
                  ctStatus/insuranceStatus (seuils), clés d'erreur i18n, toIso
  validation.ts   assertCanDeleteVehicle (Ghost Data : leçons futures PENDING/CONFIRMED)
  queries.ts      listVehicles (avec statuts CT/assurance dérivés)
  mutations.ts    createVehicle · updateVehicle (verrou optimiste) · softDeleteVehicle (soft-delete)
  alerts-dedup.ts isNearExpiry · clés Redis anti-spam · tryAcquireVehicleAlertSlot
  alerts.ts       getVehiclesNearExpiry · notifyVehicleExpiry (push + email fallback)
  alerts-cron.ts  runVehicleExpiryAlertCron (9h locale, écoles actives)
  detail.ts       getVehicleDetail (fiche complète + leçons)
  documents.ts    uploadVehicleDocument · deleteVehicleDocument · getVehicleDocumentUrl
  photo.ts        uploadVehiclePhoto (bucket public vehicle-photos)
  mutations-build.ts buildCreateData / buildVehicleData
  index.ts        façade + VehicleService
lib/services/vehicle.service.ts  shim de compat (export * from './vehicle')
app/api/vehicles/route.ts        GET (liste, GERANT) · POST (GERANT)
app/api/vehicles/[id]/route.ts   PATCH (GERANT, verrou optimiste) · DELETE (GERANT, soft-delete)
app/api/vehicles/[id]/documents/     POST upload · GET/[docId] URL signée · DELETE
app/api/vehicles/[id]/photo/         PATCH upload photo
app/[locale]/dashboard/vehicules/[id]/  page.tsx fiche détail (4 onglets)
components/vehicles/             VehiclesClient · VehiclesTable · VehicleFormSheet/Fields · MaintenanceBadge
locales/fr/vehicles.json         namespace `vehicles`

+ `resolveModules(permitTypes)` (`shared/pricing.ts`). Zéro chiffre en dur.

- `computeMrr(schools[]): MrrResult` — total + `byTier` (base) + `byModule` (modules), reconciliés

(`Σ byTier + Σ byModule = totalMrr`). Seules les écoles `ACTIVE`/`TRIALING` (`BILLING_STATUSES`) comptent.

- `round2`, `BILLING_STATUSES` exportés.

  • `dashboard.ts` : `getAdminDashboard(): Promise<AdminDashboard>` — lecture `school.findMany`

(champs scopés, zéro PII), compteurs par statut, `newThisMonth` (`createdAt ≥ début mois`),

`churnThisMonth` (`CANCELLED` ∧ `updatedAt ≥ début mois`). `trend` = 30 derniers `MrrSnapshot`

(vrai historique daté, centimes → euros, ordre chronologique) + `trendAvailable` (false si aucun

snapshot → fallback UI « Données disponibles dès demain »).

  • `snapshot.ts` (server-only, dette MRR résolue) :

- `captureMrrSnapshot(at?)` — cron quotidien : 1 `MrrSnapshot` du MRR réel du jour (toutes écoles non

supprimées), montants en centimes.

- `seedMrrSnapshots(days = MRR_SNAPSHOT_TREND_DAYS)` — seed unique au déploiement : reconstruit les

`days` derniers jours depuis les écoles existantes (proxy `createdAt`). Idempotent :

`{ created: 0, skipped: true }` si la table n'est pas vide (jamais d'écrasement).

- Calcul PUR délégué à `mrr.ts` `buildSnapshotPayload(schools)` (euros → centimes via `eurToCents`,

breakdowns reconciliés, compteurs par statut) — aucune dérive de logique de prix.

  • `monthly-report.ts` : `buildMonthlyReportCsv(): Promise<{filename, csv}>` — une ligne par école

facturable, colonnes `schoolId;Nom;Formule;Modules;Montant mensuel;Date début;Statut`, `;` + BOM UTF-8.

Routes

  • `GET /api/admin/reports/monthly` — ADMIN seul, `RATE_LIMITS.ADMIN_API`, runtime nodejs, `text/csv`.
  • `POST /api/admin/mrr-snapshot/seed` — ADMIN seul, `RATE_LIMITS.ADMIN_API` — seed initial (idempotent).
  • `GET /api/cron/mrr-snapshot` — cron (`CRON_SECRET` timing-safe, hors RBAC admin), schedule `0 2 * * *`

(`vercel.json`) → `captureMrrSnapshot`.

UI

  • `app/[locale]/stravoda-admin/layout.tsx` (RSC) : `requireStravodaAdmin('ANALYST')` → redirige `/` si

pas admin (jamais d'écran d'erreur) → `AdminShell`.

  • `app/[locale]/stravoda-admin/page.tsx` (RSC) : tableau de bord (MRR, breakdowns, compteurs, courbe,

bouton export rendu si `role === 'ADMIN'`).

  • `components/admin/AdminShell.tsx` (client) : sidebar 8 sections (gating par rang ; items 12b…12h

désactivés « bientôt » jusqu'à leur livraison), badge « ADMIN STRAVODA », rôle + email, logout.

  • `components/admin/MonthlyReportButton.tsx` (client) : `window.open` (évite `no-html-link-for-pages`).

i18n

  • Namespace `admin` (`locales/fr/admin.json`), chargé dans `i18n/request.ts`.

Contrats (12b — livré : Écoles)

Service

  • `schools.ts` : `listSchools(query): Promise<SchoolListPage>` — pagination cursor (jamais offset,

`take + 1` → `nextCursor`), recherche `nom OU email` (insensitive). Nb de moniteurs actifs et dernière

activité (`max(AuditLog.createdAt)`) résolus par un seul `groupBy` sur la page (zéro N+1 ;

aucun groupBy si page vide). `ADMIN_SCHOOLS_PAGE_SIZE = 20` (`shared/constants.ts`).

  • `school-detail.ts` : `getSchoolDetail(schoolId): Promise<SchoolDetail>` — infos + métriques en

`Promise.all` (élèves actifs, leçons du mois, revenus = Σ paiements du mois civil, moniteurs actifs)

+ 50 derniers `AuditLog` (`ADMIN_SCHOOL_AUDIT_LIMIT`, IDs/actions seulement, zéro PII) + branding

(logo/couleur) en lecture seule. `NOT_FOUND` si l'école n'existe pas.

  • Zod : `AdminSchoolsQuerySchema` (`shared/schemas/admin.ts`) — `cursor?` (uuid), `search?` (1..120).

Routes

  • `GET /api/admin/schools` — ANALYST+, `RATE_LIMITS.ADMIN_API`, Zod query (cursor + search).

UI

  • `app/[locale]/stravoda-admin/ecoles/page.tsx` (RSC) : 1ʳᵉ page chargée serveur → `SchoolsList`.
  • `app/[locale]/stravoda-admin/ecoles/[id]/page.tsx` (RSC) : fiche (infos, 4 métriques, apparence

read-only, activité). Bouton « Voir en tant que gérant » rendu désactivé pour SUPPORT/ADMIN

(impersonation = 12c). `NOT_FOUND` → carte « introuvable » (pas d'écran d'erreur).

  • `components/admin/SchoolsList.tsx` (client) : recherche debounce + load-more cursor via l'API,

lignes cliquables → fiche.

  • Item « Écoles » de `AdminShell` activé (actif aussi sur les sous-routes `/ecoles/*`).

i18n (12b)

  • `admin.schools.*`, `admin.subStatus.*` ajoutés ; labels permis via namespace `permitTypes`.

Contrats (12c — livré : Impersonation)

> Correction du mécanisme validé (2026-06-23). Le brief plaçait le déchiffrement du cookie dans le

> middleware + injection d'un header `x-impersonated-school-id` lu par `requireAuth`. Cassé : (1) le

> matcher du middleware exclut `/api` → le header n'atteint jamais les mutations ; (2) `decrypt()` =

> `node:crypto`, indisponible sur le runtime Edge du middleware. Design retenu : `requireAuth`

> lit+déchiffre le cookie directement (runtime Node, RSC et `/api`). Le middleware ne fait qu'un test

> de présence (pas de déchiffrement Edge) pour rediriger un admin sans impersonation hors du dashboard.

> Détails : `DEVELOPER_WAR_BOOK.md`.

`lib/auth/impersonation.ts` (server-only)

  • Cookie httpOnly chiffré `IMPERSONATION_COOKIE = 'stravoda_impersonation'` =

`encrypt(JSON.stringify({ schoolId, adminId, exp }))` (AES-256-GCM, `ENCRYPTION_KEY`), TTL

`IMPERSONATION_TTL_MS = 1h`.

  • `encodeImpersonation({schoolId, adminId, exp}): string` — chiffre la charge utile.
  • `decodeImpersonation(token, now): { schoolId, adminId } | null` — pur : déchiffre + valide la forme

+ vérifie `exp > now`. `null` sur échec déchiffrement / forme invalide / expiré (jamais d'exception).

  • `readImpersonationContext(supabaseId): Promise<{ schoolId, adminId } | null>` — lit le cookie, décode,

puis re-vérifie en base : `StravodaAdmin` actif, `admin.id === payload.adminId` (cookie lié à

l'admin connecté), `School` existe. `null` sinon. Aucune session gérant sans cookie valide.

`requireAuth` (helpers) — session synthétique

  • `AuthSession.impersonatedBy?: string | null` ajouté.
  • Si aucune ligne `User` pour le user Supabase → tente `readImpersonationContext(user.id)`. Si valide →

renvoie une `AuthSession` synthétique `{ userId: adminId, schoolId: <cible>, role: 'GERANT', email,

supabaseId, instructorId: null, impersonatedBy: adminId }`. Mutations réelles autorisées. Sinon

`UNAUTHORIZED` (comportement inchangé pour les vrais utilisateurs). `userId = adminId` → **toute écriture

est déjà attribuée à l'admin dans `AuditLog`** (traçabilité sans hook invasif).

Middleware (présence seule)

  • Email Supabase ∈ `STRAVODA_ADMIN_EMAILS` naviguant une route protégée (`/dashboard`, `/moniteur`,

`/onboarding`) sans cookie d'impersonation présent → redirigé vers `/stravoda-admin/ecoles`

(test de présence uniquement, aucun déchiffrement Edge). Avec cookie → laissé passer (déchiffrement

côté serveur dans `requireAuth`).

Routes (SUPPORT+)

  • `POST /api/admin/impersonation/start` — `requireStravodaAdmin('SUPPORT')`, `ADMIN_API`, Zod

`{ schoolId: uuid }`. Vérifie l'existence de l'école → pose le cookie chiffré (TTL 1h) → audit

`START_IMPERSONATION` (`userId = adminId`, `schoolId = cible`).

  • `POST /api/admin/impersonation/end` — `requireStravodaAdmin('SUPPORT')`. Supprime le cookie + audit

`END_IMPERSONATION` (si contexte présent).

UI

  • `components/admin/ImpersonateButton.tsx` (client) : remplace le bouton désactivé de la fiche école

(SUPPORT/ADMIN) → POST `start` → `router.push('/dashboard')`.

  • `components/admin/ImpersonationBanner.tsx` (client) : bannière rouge persistante rendue par le

layout dashboard quand `session.impersonatedBy` ≠ null (« MODE IMPERSONATION — [école] » + « Quitter »

→ POST `end` → `router.push('/stravoda-admin/ecoles')`).

  • i18n `admin.impersonation.*` (banner, quit).

Contrats (12d — livré : Abonnements + Utilisateurs)

Service `lib/services/admin/subscriptions.ts` (server-only)

  • `subscriptionWindows(now = new Date())` — pur, testable : bornes de date des alertes.

`trialMax = now + TRIAL_EXPIRING_SOON_DAYS j` · `pastDueBefore = now − PAST_DUE_OVERDUE_DAYS j` ·

`monthStart = 1ᵉʳ du mois courant`.

  • `getSubscriptionsOverview(now?): Promise<SubscriptionsOverview>` — lecture (toutes écoles, `deletedAt:null`) :

- `suspended` : `SUSPENDED` (alerte critique rouge foncé, tri `updatedAt` asc — plus anciens en premier).

- `pastDueOverdue` : `PAST_DUE` ∧ `updatedAt < pastDueBefore` (alerte rouge, tri `updatedAt` asc).

- `trialsExpiring` : `TRIALING` ∧ `trialEndsAt ∈ [now, trialMax]` (alerte orange, tri `trialEndsAt` asc).

- `cancelledThisMonth` : `CANCELLED` ∧ `updatedAt ≥ monthStart` (tri `updatedAt` desc).

- Chaque section = `{ items, hasMore }` (`take = ADMIN_SUBSCRIPTION_ALERT_LIMIT + 1` → UI affiche `N+` si tronqué).

- `SubscriptionAlert` inclut `schoolStatus` (badge « Archivée ») + `subscriptionStatus` (libellé ligne).

- `tierChanges` : `AuditLog action='UPDATE_SUBSCRIPTION'` (N derniers, `ADMIN_TIER_CHANGE_LIMIT`),

`tier`/`period` extraits du `payload` JSON (lecture défensive).

Service `lib/services/admin/users.ts` (server-only)

  • `listAdminUsers(query): Promise<AdminUserListPage>` — pagination cursor (`take+1`), recherche par

email (insensitive), rôle ∈ {GERANT, COMPTABLE} (les MONITEUR sont gérés par école, hors scope),

`deletedAt:null`. Renvoie `schoolName`, `isActive`, `lastLoginAt`.

  • `setUserActive(userId, isActive, adminId)` — ADMIN seul (garde route). `NOT_FOUND` si absent ;

idempotent (no-op si déjà dans l'état) ; sinon `User.isActive = isActive` + audit

`DEACTIVATE_USER` / `ACTIVATE_USER` (`userId = adminId`). `requireAuth` bloque déjà `isActive=false`

(pas de suppression du compte Supabase). TOGGLE (activer/désactiver) plutôt que désactivation seule

→ évite de verrouiller une école sans recours.

Zod (`shared/schemas/admin.ts`)

  • `AdminUsersQuerySchema` (`cursor?` uuid, `search?` 1..120) ; `SetUserActiveSchema` (`isActive: boolean`).

Routes

  • `GET /api/admin/users` — ANALYST+, `ADMIN_API`, Zod query.
  • `PATCH /api/admin/users/[id]` — ADMIN seul, `ADMIN_API`, body `{ isActive }`.

UI

  • `app/[locale]/stravoda-admin/abonnements/page.tsx` (RSC) : 3 listes d'alertes (essais J-7, impayés > 7 j,

résiliations du mois) + historique des changements de formule. Lecture seule (aucune action).

  • `app/[locale]/stravoda-admin/utilisateurs/page.tsx` (RSC) → `UsersList`. `canManage = ADMIN`.
  • `components/admin/UsersList.tsx` (client) : recherche debounce + cursor + toggle activer/désactiver

(ADMIN, `confirm` avant désactivation) → `PATCH`, met à jour l'état local.

  • `AdminShell` : items « Abonnements » + « Utilisateurs » activés.
  • i18n `admin.subscriptions.*`, `admin.users.*`.

Contrats (12e — livré : Équipe)

> Décision (2026-06-23) — table `StravodaAdmin` = source de vérité (gate relâché). L'accès

> `/stravoda-admin/*` n'est plus filtré par `STRAVODA_ADMIN_EMAILS` au middleware : tout utilisateur

> authentifié atteint le préfixe, et `requireStravodaAdmin` (layout RSC) redirige les non-admins vers `/`.

> Les `/api/admin/*` étaient déjà DB-gardés → aucune régression. Sans ça, l'invitation « par email » serait

> cassée (l'invité resterait bloqué au Edge sans ajout manuel à l'env). `STRAVODA_ADMIN_EMAILS` reste utilisé

> uniquement pour la redirection UX d'impersonation (12c). Détail : `DEVELOPER_WAR_BOOK.md`.

Migration

  • `StravodaAdminInvitation` (`email`, `role`, `tokenHash` @unique SHA-256, `expiresAt`, `usedAt?`,

`createdAt`, `@@index([email])`). Pas de `firstName/lastName` : fournis par l'invité à l'activation.

Service `lib/services/admin/team.ts` (server-only, ADMIN seul via garde route)

  • `listTeam(): Promise<TeamOverview>` — `members` (StravodaAdmin : id/email/role/firstName/lastName/isActive/

createdAt) + `pendingInvitations` (invitations non utilisées non expirées : id/email/role/expiresAt).

  • `inviteAdmin(email, role, actorAdminId, locale)` — `CONFLICT` si un StravodaAdmin actif a déjà cet email ;

`CONFLICT` si une invitation pending non expirée existe déjà pour cet email (`admin.team.errors.invitationPending`) ;

sinon token brut (SHA-256 haché en base, `INVITATION_TOKEN_EXPIRY_HOURS = 48 h`), email Resend

(`lib/email/admin-invite.ts`), audit `INVITE_ADMIN` (schoolId `null`, payload `{ invitationId, role }`

— zéro PII).

  • `cancelAdminInvitation(invitationId, actorAdminId)` — supprime l'invitation pending ; audit `CANCEL_ADMIN_INVITATION`.
  • `resendAdminInvitation(invitationId, actorAdminId, locale)` — nouveau token + expiration 48 h, invalide l'ancien

hash, renvoie l'email ; audit `RESEND_ADMIN_INVITATION`.

  • `setAdminActive(adminId, isActive, actorAdminId)` — toggle. Gardes : pas d'auto-désactivation

(`CONFLICT`), pas de désactivation du dernier ADMIN actif (`CONFLICT`). Audit `ACTIVATE_ADMIN`/

`DEACTIVATE_ADMIN`.

Service `lib/services/admin/team-activation.ts` (server-only, public)

  • `getValidAdminInvitation(raw): { id, email, role }` — `UNAUTHORIZED` si introuvable/utilisée/expirée.
  • `activateAdminInvitation(raw, firstName, lastName, password)` — `assertPasswordLength` + `assertNotPwned` ;

crée le compte Supabase (`email_confirm`) ; transaction : marque l'invitation utilisée (garde `usedAt:null`)

+ crée `StravodaAdmin` (rôle de l'invitation, `isActive:true`) ; rollback Supabase si l'écriture échoue.

Audit `ACTIVATE_ADMIN`.

Zod (`shared/schemas/admin.ts`)

  • `InviteAdminSchema` (`email`, `role ∈ {ADMIN,SUPPORT,ANALYST}`) · `ActivateAdminSchema`

(`firstName`, `lastName`, `password ≥ PASSWORD_MIN_LENGTH`) · `SetAdminActiveSchema` (`isActive`).

Routes

  • `POST /api/admin/team` — ADMIN seul, `ADMIN_API` — invite.
  • `DELETE /api/admin/team/invitations/[id]` — ADMIN seul — annule une invitation pending.
  • `POST /api/admin/team/invitations/[id]/resend` — ADMIN seul — renvoie l'invitation (nouveau token 48 h).
  • `PATCH /api/admin/team/[id]` — ADMIN seul, `ADMIN_API` — toggle isActive.
  • `GET|POST /api/equipe/activation/[token]` — public (l'invité n'a pas encore de compte), `runtime nodejs`,

rate-limit par token (`STUDENT_LINK_USE`). GET → `{ email, role }` ; POST → active.

UI

  • `app/[locale]/stravoda-admin/equipe/page.tsx` (RSC, ADMIN) → `TeamManager` (orchestrateur) composant

`TeamInviteForm`, `TeamMembersTable`, `TeamPendingInvitations` (`src/components/admin/team/`).

  • `app/[locale]/equipe/activation/[token]/page.tsx` (public) → `AdminActivateForm` (prénom/nom/mot de passe).
  • `AdminShell` : item « Équipe » activé (ADMIN).
  • i18n `admin.team.*` (+ email transactionnel via namespace `admin`).

Contrats (12f — livré : Emails)

> Périmètre du « rendu effectif » (décision 2026-06-23). `EMAIL_TEMPLATES_CONFIG` est le registre des

> types d'emails (défauts éditables + variables). Le résolveur `renderEmailTemplate(type, vars)` applique

> override DB → défaut → interpolation `{{var}}`. On câble **uniquement les 2 emails réellement envoyés

> par notre code ; les autres types sont des défauts éditables** câblés quand leur flux d'envoi existera.

> Depuis emails-resend-hook : `SIGNUP_CONFIRMATION`/`PASSWORD_RESET` (+ nouveaux `MAGIC_LINK`/`EMAIL_CHANGE`)

> ne sont plus `managedBy:'SUPABASE'` → ils sont `wired:true`, envoyés par NOTRE pipeline via le hook Supabase

> (`/api/auth/email-hook`). Le modèle `EmailTemplate` n'a pas de `locale` (mono-langue FR). Détail : `DEVELOPER_WAR_BOOK.md`.

Migration

  • `EmailTemplate` (`type` @unique ∈ `EMAIL_TEMPLATES_CONFIG`, `subject`, `htmlBody` @db.Text, `updatedAt`).

Une ligne uniquement quand un type est surchargé ; absence de ligne = défaut du registre.

Config `lib/config/email-templates/` (client-safe — partagé éditeur + aperçu) — 14r-4b

  • Éclaté par domaine (plafond 150 lignes) ; chemin d'import `@/lib/config/email-templates` inchangé via `index.ts` :

`types.ts` (`EmailTemplateDefinition` + `tone?` + `EMAIL_TEMPLATE_TYPES` (26) + `isEmailTemplateType`),

`builders.ts` (helpers premium `P/LEAD/CTA/HINT/NOTE/SECONDARY_LINK/INFO_BLOCK/STEP_CARD/HELP_BLOCK/ALERT`),

`auth.ts` · `billing.ts` · `dunning.ts` · `school.ts` (`satisfies Record<string, EmailTemplateDefinition>`).

  • Types : auth (`SIGNUP_CONFIRMATION`/`PASSWORD_RESET`/`MAGIC_LINK`/`EMAIL_CHANGE` `wired:true` via hook Supabase — `EMAIL_CHANGE` `tone:'warning'`, `INSTRUCTOR_INVITATION`/`ADMIN_INVITATION`/`PARENT_ACCESS` `wired:true`),

cycle de vie (`WELCOME`, `TRIAL_ENDING`, `TRIAL_REMINDER_7`, `TRIAL_WILL_END`, `TRIAL_REMINDER_1`, `SUBSCRIPTION_ACTIVE`),

recouvrement (`PAYMENT_FAILED`, `DUNNING_3`, `DUNNING_6`, `SUSPENDED`, `PAYMENT_RECOVERED`, `CARD_EXPIRING` — `tone:'urgent'` sur impayé/suspension),

école (`LESSON_CANCELLED`, `DOCUMENT_MISSING`, `MONTHLY_SCHOOL_REPORT`, `STAGE_CERTIFICATE`, `ERASURE_CONFIRMED`).

Les nouveaux emails du cycle de vie facturation sont `wired:false` (défauts éditables, flux d'envoi à câbler).

  • Par type : `defaultSubject`, `defaultHtmlBody` (contenu interne avec `{{var}}`), `variables: string[]`,

`sampleVars` (aperçu), `wired`, `managedBy?`, `tone?` (`'urgent'` → header rouge).

Shared `shared/email-template.ts` (client-safe) — gabarit premium 14r-4b

  • `escapeHtml` · `interpolate(tpl, vars)` (remplace `{{key}}`, laisse les inconnus tels quels — l'envoi

n'échoue jamais) · `EmailTone` (`'default' | 'urgent' | 'warning'`) · `wrapEmailHtml(inner, tone)` (gabarit branding

premium « MyOrigines » : fond crème `#F5F5F0`, header coloré vert `#0D6B4F` / rouge `#DC2626` urgent / orange `#EA580C` warning,

pastille logo, carte arrondie, footer ; styles 100 % inline) · `renderEmailContent(htmlBody, vars, {tone?})`

= `wrapEmailHtml(interpolate(htmlBody, valeurs HTML-échappées), tone)`. Utilisé serveur et aperçu client.

Résolveur `lib/email/render.ts` (server-only)

  • `renderEmailTemplate(type, vars): { subject, html, text }` — `subject`=interpolate(override?.subject ??

défaut) ; `html`=renderEmailContent(override?.htmlBody ?? défaut) ; `text`=htmlToText(html). Câblé dans

`instructor-invite.ts` (vars `{nom, ecole, lien}`) et `admin-invite.ts` (vars `{role, lien}`).

Service `lib/services/admin/emails.ts` (server-only)

  • `listEmailTemplates()` — fusionne défauts + overrides → `{type, subject, htmlBody, variables, sampleVars,

isOverridden, wired, managedBy, tone}[]`.

  • `upsertEmailTemplate(type, {subject, htmlBody}, adminId)` — garde `type ∈ config`, upsert, audit

`UPDATE_EMAIL_TEMPLATE` (schoolId null, payload `{type}`).

  • `resetEmailTemplate(type, adminId)` — supprime l'override (retour au défaut), audit `RESET_EMAIL_TEMPLATE`.

Routes (ADMIN seul, `ADMIN_API`)

  • `PUT /api/admin/emails/[type]` — upsert (`UpsertEmailTemplateSchema`). `DELETE` — reset.
  • Lecture : RSC (liste passée à l'éditeur, ~9 entrées, pas de GET dédié).

UI

  • `app/[locale]/stravoda-admin/emails/page.tsx` (RSC, SUPPORT+ lecture) → `EmailTemplateEditor`.
  • `components/admin/EmailTemplateEditor.tsx` (client) : sélecteur de type + champ sujet + textarea HTML +

sidebar variables (insertion au curseur) + aperçu `<iframe sandbox>` débounce 300 ms (srcDoc =

`renderEmailContent` avec `sampleVars` et le `tone` du template → header vert/rouge) + badges

(surchargé / défaut / Supabase / non câblé). Édition +

Réinitialiser ADMIN seul (`canEdit`).

  • `AdminShell` : item « Emails » activé (SUPPORT+).
  • i18n `admin.emails.*` (+ libellés `admin.emails.types.*`).

Contrats (12g — livré : Cookies)

> Décision (2026-06-23). Bandeau cookies réel (pas seulement un éditeur). Config singleton

> surchargeable (défauts `COOKIE_CONSENT_DEFAULTS`), endpoint public caché 1 h, et un composant

> `CookieBanner` réutilisé pour l'aperçu de l'éditeur et l'affichage live. Le consentement est

> persisté côté client (cookie 1er-party `stravoda_cookie_consent`, 1 an) ; Analytics + Support chat

> off par défaut. Le gating effectif des scripts Analytics/Support viendra avec ces features (12z) —

> 12g pose la fondation (config + consentement stocké). Texte mono-FR (comme `EmailTemplate`).

Migration

  • `CookieConsentConfig` singleton (`id @default("singleton")`) : `mainText` @db.Text, `acceptLabel`,

`refuseLabel`, `customizeLabel`, `analyticsDefaultEnabled` (false), `supportChatDefaultEnabled` (false),

`updatedAt`. Une seule ligne ; absence de ligne = défauts du registre.

Config `lib/config/cookie-consent.ts` (client-safe)

  • `COOKIE_CONSENT_DEFAULTS` (texte FR + labels + 2 booléens off) · `COOKIE_CONSENT_COOKIE`

(`stravoda_cookie_consent`). Constantes durées/longueurs/cache → `shared/constants.ts`.

Service `lib/services/admin/cookie-consent.ts` (server-only)

  • `getCookieConsentConfig()` → singleton ou défauts (forme `PublicCookieConfig`).
  • `upsertCookieConsentConfig(input, adminId)` → upsert `id:'singleton'`, audit `UPDATE_COOKIE_CONFIG`

(schoolId null, sans PII).

Routes

  • `GET /api/public/cookie-config` — public, sans auth, `Cache-Control: public, max-age=3600` → renvoie

la config (ou défauts). Rate limit `API_PUBLIC`.

  • `PUT /api/admin/cookies` — ADMIN seul (`UpsertCookieConfigSchema`, `ADMIN_API`).

UI

  • `components/cookies/CookieBanner.tsx` (client, présentationnel) : texte + 3 boutons (accepter/refuser/

personnaliser) + panneau « personnaliser » (toggles Analytics + Support). `preview` → rendu inline (éditeur).

  • `components/cookies/CookieBannerLive.tsx` (client, monté dans `[locale]/layout.tsx`) : si cookie de

consentement absent → fetch endpoint public → affiche le bandeau ; au choix → écrit le cookie 1 an + masque.

  • `app/[locale]/stravoda-admin/cookies/page.tsx` (RSC, ADMIN seul) → `CookieConsentEditor` (champs +

toggles + aperçu bas de page via `CookieBanner preview`).

  • `AdminShell` : item « Cookies » activé (ADMIN). i18n `admin.cookies.*` (éditeur) + `cookies.*` (public).

Contrats (12h — livré : Logs globaux)

> Aucune migration : réutilise `AuditLog` (déjà toutes-écoles, `schoolId` nullable). Lecture ANALYST+,

> export CSV ADMIN seul. Zéro PII : on n'expose jamais `payload` ni le hash — uniquement

> `id/schoolId/userId/action/entityType/entityId/createdAt` (cohérent avec la fiche école 12b).

Filtres + pagination

  • `AdminLogsQuerySchema` (Zod) : `schoolId?` (uuid), `action?` (string ≤64), `userId?` (string ≤64,

= `actorUserId`), `from?`/`to?` (datetime ISO, borne `createdAt`), `cursor?` (uuid). Tous optionnels.

  • Where construit dynamiquement (filtres combinables) ; `orderBy [{createdAt:'desc'},{id:'desc'}]` ;

cursor sur `id` (`take = PAGE+1`, `skip:1` si cursor). `ADMIN_LOGS_PAGE_SIZE = 50`.

Service `lib/services/admin/logs.ts` (server-only)

  • `buildAuditLogWhere(query)` (pur, partagé liste + export).
  • `listAuditLogs(query)` → `{ entries: AuditLogEntry[], nextCursor }`.
  • `buildAuditLogCsv(query)` → `{ filename, csv }` (`;` + BOM, en-têtes FR figés, **plafond

`ADMIN_LOGS_EXPORT_MAX = 5000`** lignes pour borner l'export).

Routes

  • `GET /api/admin/logs` — ANALYST+, filtres en query, `ADMIN_API`.
  • `GET /api/admin/logs/export` — ADMIN seul, mêmes filtres, réponse `text/csv` en pièce jointe.

UI

  • `app/[locale]/stravoda-admin/logs/page.tsx` (RSC, ANALYST+) → `LogsExplorer` (charge la 1ʳᵉ page,

passe `canExport = ADMIN`).

  • `components/admin/LogsExplorer.tsx` (client) : formulaire de filtres (schoolId/action/userId/from/to) +

table + « Charger plus » (cursor) + bouton Exporter CSV (ADMIN, `window.open` route export avec filtres).

  • `AdminShell` : item « Logs » activé (ANALYST). i18n `admin.logs.*`.

Contrats (12z-2 — livré : Emailing campagnes broadcast)

> Distinct de 12f (templates transactionnels surchargeables). 12z-2 = campagnes broadcast

> vers les gérants/moniteurs. Sous-route `/stravoda-admin/emails/campaigns`. Réutilise `lib/ai/anthropic.ts`

> (12z-1) pour l'amélioration de sujet. Résolution des destinataires 100 % serveur (jamais depuis le body).

Migration `admin_email_campaigns`

  • `AdminEmailCampaign` : `subject`, `htmlBody`, `segment` (JSON sérialisé validé Zod), `recipientCount`,

`status` (`DRAFT|SCHEDULED|SENT|FAILED`), `scheduledAt?`, `sentAt?`, `sentBy` (adminId), `resendBatchId?`.

`@@index([status, scheduledAt])`.

  • `AdminEmailRecipient` : `campaignId` (FK cascade), `email`, `name`, `status` (`QUEUED|SENT|DELIVERED|BOUNCED`),

`sentAt?`. `@@index([campaignId, status])`.

Zod (`shared/schemas/admin-campaigns.ts`)

  • `ADMIN_EMAIL_SEGMENTS` (8) : `ALL_SCHOOLS | BY_TIER | TRIAL | PAST_DUE | BY_PERMIT | SPECIFIC_GERANT |

ALL_INSTRUCTORS | INSTRUCTORS_OF_SCHOOL`. `CampaignSegmentSchema` (refine : `BY_TIER`→`tier`,

`BY_PERMIT`→`permitType`, `SPECIFIC_GERANT`/`INSTRUCTORS_OF_SCHOOL`→`schoolId`).

  • `PreviewCountSchema` (`{ segment }`), `CreateCampaignSchema` (`{ subject, htmlBody, segment,

mode: 'SEND_NOW'|'SCHEDULE', scheduledAt? }` — refine : `scheduledAt` futur si `SCHEDULE`),

`ImproveSubjectSchema` (`{ subject }`), `CampaignsQuerySchema` (`{ cursor? }`).

Email `lib/email/`

  • `batch.ts` (server-only) : `sendEmailBatch(messages)` → découpe en lots `EMAIL_BATCH_MAX = 100` et appelle

`resend.batch.send`. Renvoie les index envoyés/échoués (best-effort, pas de double-envoi).

  • `campaign-render.ts` (client-safe) : `sanitizeCampaignHtml` (allowlist stricte) + `renderCampaignBody`

(interpolation `{{prenom}} {{ecole}} {{tier}} {{trialEnd}} {{dashboardUrl}}` + enveloppe email-safe).

Service `lib/services/admin/campaigns/` (server-only, chacun ≤ 150 l.)

  • `segments.ts` : `resolveRecipients(segment)` (écoles→GERANT actifs / moniteurs→MONITEUR actifs, vars par

destinataire), `countRecipients(segment)`. Source serveur uniquement (filtres `School`/`User`).

  • `mutations.ts` : `createCampaign(input, adminId)` (résout serveur → 0 destinataire ⇒ `VALIDATION_ERROR` ;

persiste campagne + recipients QUEUED ; `SEND_NOW`→dispatch immédiat, `SCHEDULE`→`scheduledAt`),

`resendBounces(id, adminId)` (re-QUEUE les `BOUNCED`).

  • `dispatch.ts` : `dispatchCampaign(id)` (envoie les `QUEUED` par lots, marque `SENT`, campagne `SENT`/`sentAt`),

`drainScheduledCampaigns(now)` (cron : passe les `SCHEDULED` échues en envoi + draine les `QUEUED` restants).

  • `queries.ts` : `listCampaigns({cursor})` (cursor `ADMIN_CAMPAIGN_PAGE_SIZE`), `getCampaignDetail(id)`.
  • `audit` : `SEND_ADMIN_EMAIL` (global `schoolId:null`, count + campaignId uniquement, jamais la liste d'emails).

Routes

  • `POST /api/admin/emails/campaigns` — ADMIN, create + send/schedule. `GET` — SUPPORT+, liste cursor.
  • `GET /api/admin/emails/campaigns/[id]` — SUPPORT+, détail + destinataires/statuts.
  • `POST /api/admin/emails/campaigns/[id]/resend-bounces` — ADMIN.
  • `POST /api/admin/emails/campaigns/preview-count` — ADMIN, aperçu nb destinataires (temps réel).
  • `POST /api/admin/emails/campaigns/improve-subject` — ADMIN, IA (réutilise `completeText`).
  • `GET /api/cron/admin-email-dispatch` — `assertCronAuthorized` (`CRON_SECRET`), draine SCHEDULED + QUEUED.

UI

  • `app/[locale]/stravoda-admin/emails/campaigns/page.tsx` (RSC, SUPPORT+) → `CampaignsView`

(`canSend = ADMIN`). `EmailsSubNav` (Transactionnels / Campagnes) ajouté aux deux pages emails.

  • `components/admin/emails/` : `CampaignsView` (onglets), `NewCampaignForm` (ADMIN : segment + aperçu count +

sujet `[✨ Améliorer]` + éditeur HTML + variables + aperçu iframe sandbox + envoyer/planifier),

`SegmentPicker`, `CampaignHistory`, `CampaignDetailDrawer`.

  • `AdminShell` : item « Emails » (déjà SUPPORT+). i18n `admin.campaigns.*`.

Cron / Constantes

  • `vercel.json` : `/api/cron/admin-email-dispatch` toutes les 5 min.
  • `EMAIL_BATCH_MAX = 100`, `EMAIL_CAMPAIGN_SUBJECT_MAX`, `EMAIL_CAMPAIGN_HTML_MAX`, `ADMIN_CAMPAIGN_PAGE_SIZE`.

Tests

  • `src/test/admin-service.test.ts` (12) : `hasAdminRole`, `requireStravodaAdmin` (5 cas RBAC),

`computeMrr` (réconciliation, TRIALING inclus, mensualisation annuelle, tier null), `getAdminDashboard`,

`buildMonthlyReportCsv`.

  • `src/test/admin-routes.test.ts` (5) : export ADMIN→200 / SUPPORT→403 / ANALYST→403 ; écoles

ANALYST→200 (recherche transmise) / cursor non-uuid→422 (Zod, service non appelé).

  • `src/test/admin-schools.test.ts` (7) : `listSchools` (mapping groupBy sans N+1, `nextCursor` via

`take+1`, recherche `OR`, page vide → aucun groupBy) ; `getSchoolDetail` (métriques, revenus 0, NOT_FOUND).

  • `src/test/admin-mrr-snapshot.test.ts` (4) : `buildSnapshotPayload` (centimes, réconciliation, compteurs),

`captureMrrSnapshot` (1 insert), `seedMrrSnapshots` (proxy `createdAt` sur N jours + idempotence).

  • `admin-routes.test.ts` couvre aussi : seed ADMIN→200 / SUPPORT→403 ; cron autorisé→200 / non autorisé→401.
  • `admin-service.test.ts` : courbe = snapshots (centimes→euros, ordre chrono) + fallback `trendAvailable=false`.
  • `src/test/admin-impersonation.test.ts` (13) — 12c : `decodeImpersonation` (round-trip, expiré,

corrompu, forme invalide → null) ; `readImpersonationContext` (aucun cookie / admin inactif / cookie

d'un autre admin / école inexistante → null ; cas valide → contexte) ; routes `start` (ANALYST→403,

SUPPORT→200 + cookie + audit, école introuvable→404) et `end` (cookie supprimé + audit).

  • `src/test/admin-subscriptions.test.ts` (2) — 12d : `subscriptionWindows` (bornes ±7 j + monthStart) ;

`getSubscriptionsOverview` (mapping des 3 alertes + dates + historique formules, lecture défensive payload).

  • `src/test/admin-users.test.ts` (6) — 12d : `listAdminUsers` (cursor `take+1`, mapping, filtre rôle) ;

`setUserActive` (NOT_FOUND, idempotent, audit DEACTIVATE/ACTIVATE_USER).

  • `admin-routes.test.ts` couvre aussi : users ANALYST→200 ; PATCH user ADMIN→200 / SUPPORT+ANALYST→403.

Migrations

  • `MrrSnapshot` (`20260623183346_add_mrr_snapshot`) : `snapshottedAt`, `totalMrr`/`byTier`/`byModule`

(centimes), 4 compteurs de statut, `@@index([snapshottedAt])`.

Dette / hors-périmètre

  • ~~Courbe MRR = proxy `createdAt`~~ RÉSOLU : `MrrSnapshot` + cron quotidien + seed initial.

(Le seed reste un proxy `createdAt` ponctuel pour amorcer l'historique ; les snapshots quotidiens

suivants sont des points réels datés.)

  • Apparence éditable : reportée à l'endpoint partagé gérant (Étape 13) ; 12b = lecture seule.
  • `School.createdAt` non indexé (orderBy liste) — acceptable au volume actuel, à indexer si > ~10k écoles.
  • Impersonation (12c) : pas de hook `IMPERSONATION_ACTION` par écriture (couverture partielle vs

`tx.auditLog.create` direct) — `userId = adminId` suffit à la traçabilité ; START/END logués. Cookie

non purgé côté serveur si invalide (RSC ne peut pas écrire de cookie) → simplement ignoré (fail-closed) ;

le `/end` ou un nouveau `/start` nettoie.

  • Utilisateurs (12d) : MONITEUR exclu de la liste (géré par école) ; pas de garde « dernier GERANT

actif » sur la désactivation (responsabilité ADMIN) — à ajouter si besoin de filet anti-lock-out.

  • Équipe / Emails / Cookies / Logs = 12e…12h.

Lecture (`lib/services/audit/`)

  • `listEntityAuditTimeline(schoolId, entityType, entityId)` — timeline entité (max `AUDIT_ENTITY_LIMIT`).
  • `listSchoolAuditLogs(schoolId, query)` — journal école paginé cursor (`AUDIT_SCHOOL_PAGE_SIZE`).
  • `buildSchoolAuditCsv(schoolId, query)` — export CSV (max `AUDIT_SCHOOL_EXPORT_MAX`).

Schéma query : `shared/schemas/audit.ts` (`entityType`, `from`, `to`, `userId`, `cursor`).

Routes API (GERANT, `requireAuth` + `schoolId` session)

RouteRôle
`GET /api/audit/entity`Timeline d'une entité
`GET /api/audit`Journal école filtrable
`GET /api/audit/export`CSV période (rate limit `PDF_EXPORT`)

UI

  • `EntityAuditTimeline` — timeline expandable sur fiche élève (onglet Historique), moniteur, véhicule (édition). Données pré-chargées côté serveur (élève/moniteur) ou au clic « Modifier » (véhicule).
  • `SchoolAuditExplorer` + `/dashboard/historique` — filtres entité / dates + export CSV (nav GERANT).

i18n : `locales/fr/audit.json`.

Tests

  • `src/test/audit-diff.test.ts` — diff, champs sensibles, parse.
  • Mutations : `student-service.test.ts` (UPDATE_STUDENT + diff), `exam-result.test.ts`, `school-settings.test.ts`.
signUp + email confirmation. Erreur → `CONFLICT` (key `auth.errors.emailInUse`)
`login`POST`LOGIN` (5/15min)signInWithPassword + Turnstile si ≥3 échecs IP + remember-me + audit LOGIN_SUCCESS
`login/status`GET—`captchaRequired` + clé Turnstile publique
`google`GET—redirect OAuth Supabase (`provider: google`, `flow=google` au callback)
`sessions`GET—10 dernières connexions (LOGIN_SUCCESS)
`sessions/signout-all`POST—`admin.signOut(userId, 'others')` + audit SIGNOUT_ALL_SESSIONS
`sessions/report`POST—email support + audit SUSPICIOUS_LOGIN_REPORTED
`logout`POST—signOut
`reset-request`POST`PASSWORD_RESET` (3/1h)resetPasswordForEmail. Réponse générique (anti-énumération)
`reset-confirm`POST—updateUser(password) + signOut({scope:'others'}). Règles 12+HIBP
`callback`GET—exchangeCodeForSession ; flux reset (`?next`) · OAuth Google (`?flow=google`) · création School+User
`email-hook`POST—Send Email Hook Supabase → Resend. Garde `Authorization: Bearer ${SUPABASE_HOOK_SECRET}` (temps constant, 401 fail-closed). Délègue à `dispatchSupabaseAuthEmail`

Route école : `GET/PATCH /api/school/security` — `sessionTimeoutMinutes` (PATCH GERANT).

Emails auth via Resend (emails-resend-hook)

Supabase n'envoie plus ses emails : le « Send Email Hook » POST sur `/api/auth/email-hook`. La route (fine)

vérifie le Bearer puis appelle `lib/email/auth-hook.ts dispatchSupabaseAuthEmail` (server-only) :

Zod sur le payload Supabase → mapping `email_data.email_action_type` → template du registre → lien de

vérification `${NEXT_PUBLIC_SUPABASE_URL}/auth/v1/verify?token={token_hash}&type={action}&redirect_to=...`

→ `renderEmailTemplate` (gabarit premium) → `sendEmail` (Resend).

`email_action_type`TemplateTonDestinataire
`signup``SIGNUP_CONFIRMATION`default`user.email`
`recovery``PASSWORD_RESET`urgent`user.email`
`magiclink``MAGIC_LINK`default`user.email`
`email_change``EMAIL_CHANGE`warning`user.new_email`

Action non gérée (ex. `reauthentication`) → 200 sans envoi (no-op). `SUPABASE_HOOK_SECRET` optionnel : sans

secret, le hook répond 401 sans casser le reste de l'app. Config manuelle : voir `TODO_DEPLOIEMENT.md`

(URL du hook + désactiver « Enable email confirmations » natif Supabase après bascule).

Schémas Zod (`shared/schemas/auth.ts`)

`SignupSchema` · `LoginSchema` (+ `turnstileToken?`) · `ResetRequestSchema` · `ResetConfirmSchema`.

`shared/schemas/school-security.ts` — `UpdateSchoolSecuritySchema`.

Logique (`src/lib/`)

  • `auth/sign-in.ts` : brute-force + Turnstile + remember-me cookie + LOGIN_SUCCESS.
  • `auth/oauth-callback.ts` : routage post-Google (dashboard / onboarding / no_account).
  • `auth/session-timeout.ts` : `assertSessionNotExpired` via dernier AuditLog (appelé dans `requireAuth`).
  • `auth/bruteforce.ts` : seuils CAPTCHA (3) / bloc IP (10) depuis `LOGIN_FAILED`.
  • `auth/turnstile.ts` : vérification Cloudflare Turnstile (optionnel si clés env absentes).
  • `auth/login-success.ts` : audit LOGIN_SUCCESS (IP, UA, browser, OS, method, rememberMe).
  • `services/auth/sessions.ts` : historique connexions, signout-all, report suspicious.
  • `services/school/mutations/security.ts` : timeout école.

UI (`src/app/[locale]/(auth)/` + `components/auth/`)

`login` (Google + remember-me + Turnstile), `signup` + `PasswordStrengthBar`, `reset-password`, `layout`.

`SessionWatcher` + `SessionGuard` (dashboard + moniteur). `LoginConnectionsPanel` sur `/dashboard/securite` et Paramètres.

Onglet Sécurité dans `/dashboard/parametres` (`SettingsSecurityTab`). `MfaEnrollment` sur `/dashboard/securite`.

Sécurité

  • Cookies session httpOnly (@supabase/ssr) — remember-me = maxAge 30 j sur cookies auth Supabase.
  • Cookie préférence `stravoda_remember` (non httpOnly, 1 an) pour pré-cocher la case login.
  • `School.sessionTimeoutMinutes` : timeout inactivité client (`SessionWatcher`) + serveur (`assertSessionNotExpired`).
  • Turnstile après 3 `LOGIN_FAILED` / IP / 10 min ; bloc IP 1 h après 10 échecs.
  • `schoolId` toujours serveur (session/DB), jamais body.
  • Rate limits depuis `RATE_LIMITS` (constants.ts).
  • Mot de passe ≥12 + HIBP fail-closed. Indicateur force client (`shared/password-strength.ts`).

Pré-requis Supabase (manuel)

Confirm email = ON · Redirect URL `…/api/auth/callback` · Provider Google activé (Client ID + Secret).

Variables d'environnement (optionnelles)

  • `NEXT_PUBLIC_TURNSTILE_SITE_KEY` + `TURNSTILE_SECRET_KEY` — CAPTCHA anti-brute-force login.
  • `SUPABASE_HOOK_SECRET` — secret du Send Email Hook (≥16). Sans lui, `/api/auth/email-hook` répond 401.

Tests (`src/test/`)

  • `auth-password.test.ts`, `auth-service.test.ts`, `auth-advanced.test.ts` (14r : remember-me, CAPTCHA, timeout, journal, signout-all, force MDP).
`updatePackage``(schoolId, packageId, UpdatePackageInput, actorUserId) → void`GERANT. Audit `UPDATE_PACKAGE`.
`getStudentBilling``(schoolId, studentId) → StudentBilling`forfaits + paiements + `defaultRate` (€/h du permis élève).
`recordPayment``(schoolId, packageId, RecordPaymentInput, actorUserId) → {paymentId, invoiceNumber}`GERANT/COMPTABLE. Transaction Serializable + n° facture. Audit `RECORD_PAYMENT`.
`getReceiptUrl``(schoolId, paymentId) → string`URL signée ; génère le PDF paresseusement s'il manque.
`exportMonthlyPayments``(schoolId, "YYYY-MM") → {filename, csv}`CSV `;` + BOM, groupé par mode + totaux.
`listActivePackages` / `listPayments` (cursor) / `listUnpaid` / `listAvoirs``(schoolId, …)`données des 4 onglets.
`school.service.getBillingSettings` / `updateBillingSettings``(schoolId[, BillingSettingsInput])`préfixe + TVA. Exonéré → `vatRate=null`.

Impayé = `priceTotal − Σ paiements > 0.01` (`BILLING_BALANCE_EPSILON`). En retard si forfait créé il y a > 30 j.

Routes API

  • `POST /api/packages` (GERANT) · `GET /api/packages?studentId=` (GERANT/COMPTABLE)
  • `PATCH /api/packages/[id]` (GERANT)
  • `POST /api/packages/[id]/payments` (GERANT/COMPTABLE)
  • `GET /api/payments/[id]/receipt` (GERANT/COMPTABLE, runtime nodejs, rate `PDF_EXPORT`) → `{ url }`
  • `GET /api/billing/payments?cursor=` (GERANT/COMPTABLE) — pagination onglet Paiements
  • `GET /api/billing/export?month=YYYY-MM` (GERANT/COMPTABLE, rate `PDF_EXPORT`) → CSV
  • `PATCH /api/school/billing-settings` (GERANT)

UI

  • `/dashboard/facturation` (RSC, GERANT/COMPTABLE) : `BillingTabs` (Forfaits actifs / Paiements / Impayés /

Avoirs) + `ExportMonthForm` + `BillingSettingsForm` (GERANT). Reçu via `ReceiptButton` (URL signée → onglet).

- Onglet Impayés : `UnpaidActions` par ligne (« Encaisser » → `PaymentDialog` ; « Lien de paiement en

ligne » → `POST checkout` si `onlinePaymentEnabled`), visible si `canRecordPayment` (GERANT∨COMPTABLE),

`router.refresh()` après succès. C'est la surface d'encaissement du COMPTABLE (seule page qu'il atteint).

  • Fiche élève → onglet Forfaits : `StudentBillingPanel` (créer forfait, encaisser, reçus) quand `canManage`

(sinon `PackagesTab` lecture seule). Données initiales fournies par la RSC (pas de fetch en effet).

  • Isolation COMPTABLE (défense en profondeur, 3 couches) :

1. nav filtrée (`DashboardShell` par rôle) ;

2. redirection serveur de toute page dashboard ≠ facturation (`dashboard/layout.tsx`, chemin lu via

header `x-pathname` posé en middleware) + fiche élève `canManage` = GERANT seul ;

3. garde API : `COMPTABLE` n'est présent que dans les `requireRole()` des routes facturation listées

ci-dessus. Toutes les autres routes (élèves, documents, planning) le rejettent en 403.

Tests — `src/test/billing.test.ts` (11) + `src/test/comptable-isolation.test.ts` (14)

Isolation (forfait/paiement scopés schoolId), numérotation sans trou (format + dérivation + incrément

atomique), n° attribué dans la transaction, solde/impayés, helpers prix/préfixe, export CSV par mode.

8b-1 — Paiement en ligne (Stripe Connect)

Flux de fonds : un compte connecté Express par école (`School.stripeConnectAccountId`). L'argent des élèves arrive sur le compte de l'école via destination charge (`payment_intent_data.transfer_data.destination`) ; la plateforme ne prélève aucune commission à ce stade. `School.stripeConnectChargesEnabled` reflète `charges_enabled` (KYC terminé) — aucun lien de paiement tant que `false`.

Stripe (`lib/stripe/connect.ts`) : `createConnectAccount` (Express, `card_payments`+`transfers`), `createConnectOnboardingLink` (AccountLink), `getConnectChargesEnabled`, `createPackageCheckout` (destination charge, idempotencyKey = sha256(school+package+montant+'checkout') déterministe). Helpers d'abonnement extraits → `lib/stripe/subscription.ts`.

Service : `billing/connect.ts` (`getConnectStatus` resync+persist `charges_enabled`, `startConnectOnboarding`, `isOnlinePaymentEnabled` = lecture DB pure) ; `billing/online-payment.ts` (`createPackageCheckoutLink` : gate Connect + solde restant > 0 ; `recordCheckoutPayment(session)` : transaction Serializable, dédup `stripePaymentId`, n° facture, mode `CB` + `stripePaymentId`, `schoolId` du metadata serveur).

Routes : `POST/GET /api/billing/connect` (GERANT) ; `POST /api/packages/[id]/checkout` (GERANT/COMPTABLE) ; webhook `POST /api/webhooks/stripe` case `checkout.session.completed` (dédup `StripeEvent` + unicité `Payment.stripePaymentId`).

UI : `StripeConnectCard` (bloc paramètres facturation, GERANT) ; bouton « Lien de paiement en ligne » dans `StudentBillingPanel` (GERANT, masqué si Connect non activé ou solde nul).

Tests : `src/test/billing-online.test.ts` (12 : statut Connect, gating, destination charge, webhook isolation/dédup/n° facture).

Remboursements Stripe (billing-stripe-refund)

Schéma : `Payment.refundedAt DateTime?`, `Payment.refundAmount Float?`.

Service (`billing/refund.ts`) : `createStripeRefund(schoolId, paymentId, { amount?, reason? }, actorUserId) → { refundId, amount, status }`.

  • Charge le `Payment` scopé `schoolId` ; exige `stripePaymentId` (sinon `CONFLICT notStripePayment`) et `refundedAt null` (sinon `CONFLICT alreadyRefunded`).
  • Appelle `stripe.refunds.create({ payment_intent, amount? en centimes, reason })` — remboursement partiel si `amount` fourni, total sinon (sans `amount` côté Stripe).
  • Met à jour `refundedAt` + `refundAmount`, audit `PAYMENT_REFUNDED`, email élève `PAYMENT_REFUNDED` (`refund-email.ts`).

Route : `POST /api/payments/[id]/refund` (GERANT/COMPTABLE, body `RefundPaymentSchema`).

UI : `RefundButton` + `RefundDialog` (montant total/partiel, motif, avertissement délai 5–10 j) sur paiements Stripe non remboursés dans `BillingTables`, `StudentBillingPanel` ; badge `RefundBadge` si remboursé ; `ManualRefundHint` pour paiements manuels. Espace élève : badge « Remboursé » (`ElevePaymentsSection`).

Tests : `src/test/billing-refund.test.ts` (6 : Stripe params, partiel, déjà remboursé, non-Stripe, email).

Les avoirs internes (8c-1) restent en parallèle pour les remboursements comptables sans Stripe.

8b-2 — Devis PDF + signature eIDAS

Modèle : `Document.packageId String?` (relation `Package.documents`, `onDelete: SetNull`, `@@index`) — un devis (`Document` type `DEVIS`) par forfait.

Génération (`billing/quote.ts`) : `generatePackageQuote(schoolId, packageId, actorUserId) → {documentId}`. Rend le PDF (`lib/pdf/quote.tsx` → `renderQuotePdf`), upload bucket privé, upsert `Document` `DEVIS` `EN_ATTENTE` par `(schoolId, packageId)` (régénération = réinitialise la signature), audit `GENERATE_DOCUMENT`, revalidate. Appelée best-effort dans `createPackage` (un échec PDF ne fait pas échouer la création — `/api/packages` POST passe en runtime nodejs). `getStudentBilling` attache `quote: {documentId, sent, signed}|null` à chaque `PackageView`. Mention TVA mutualisée (`billing/utils.vatNotice`, partagée avec le reçu) ; validité = `QUOTE_VALIDITY_DAYS` j.

Signature : `sendContractForSignature` généralisée → `sendDocumentForSignature` (types signables `CONTRAT_FORMATION` | `DEVIS`, nom YouSign selon le type). Mineur → signataire = parent (contacts AAC requis). Webhook `signature_request.done` (`handleSignedContract`, déjà générique sur `youSignDocId`) marque le devis `VALIDE` + `signatureHash` (idempotent). Paiement jamais bloqué par l'état du devis.

Routes : `POST /api/packages/[id]/quote` (GERANT, runtime nodejs) régénère le devis ; `POST /api/documents/[id]/sign` généralisée (GERANT) ; lecture du PDF via `GET /api/documents/[id]/download` (URL signée).

UI : `QuoteActions` par forfait dans `StudentBillingPanel` (GERANT) — statut (généré / envoyé / signé), « Voir le devis », « Envoyer en signature », « Régénérer ».

Tests : `src/test/billing-quote.test.ts` (5 : génération + upsert `packageId` create/update, isolation `schoolId`, NOT_FOUND, reset signature) ; `src/test/student-signature.test.ts` (+ cas DEVIS).

8b-2b — Facture proforma (avant paiement)

Modèle : `DocumentType.PROFORMA_INVOICE` — un document proforma par forfait (`packageId`), indépendant de l'encaissement.

Génération (`billing/proforma.ts`) : `generateProformaInvoice(schoolId, packageId, actorUserId) → {documentId, proformaNumber, url}`. PDF (`lib/pdf/proforma-invoice.tsx` → `renderProformaInvoicePdf`) avec en-tête école (+ logo si présent), mention « FACTURE PROFORMA — Document non fiscal », n° `PRO-{year}-{schoolId[:4]}-{seq}`, détail HT/TVA/TTC (taux école ou art. 293B), mentions légales. Stockage bucket privé, upsert `Document` `PROFORMA_INVOICE`, audit `PROFORMA_GENERATED`, URL signée retournée. Numéro séquentiel par école/année (régénération conserve le n° existant).

Route : `POST /api/packages/[id]/proforma` (GERANT/COMPTABLE, runtime nodejs).

UI : `ProformaActions` dans `StudentBillingPanel` (à côté du devis) — générer / télécharger / régénérer.

Tests : `src/test/billing-proforma.test.ts` (6) ; `src/test/billing-proforma-route.test.ts` (2).

8c-1 — Avoirs (création + PDF)

Schéma : modèle `Avoir` (existant) désormais alimenté — `amount`, `hoursRefunded`, `reason`, `pdfUrl?`, `processedAt`, `processedBy`, relations `package` + `student`, scopé `schoolId`.

Service (`billing/avoir.ts`) :

  • `createAvoir(schoolId, packageId, CreateAvoirInput, actorUserId) → {avoirId}` — transaction `Serializable`. Garde-fou anti-avoir fantôme : `amount ≤ net encaissé` où net = `Σ paiements − Σ avoirs existants` (+ `BILLING_BALANCE_EPSILON`), sinon `CONFLICT` (`avoirExceedsPaid`). `reason` sanitizé (`cleanText`). Audit `CREATE_AVOIR`, revalidate. Montant/heures saisis (pas de calcul auto ni de consommation planning en 8c).
  • `getAvoirUrl(schoolId, avoirId) → string` — URL signée ; génère le PDF paresseusement (`lib/pdf/avoir.tsx` → `renderAvoirPdf`, réf. `AVOIR-{8 chars id}`) puis le stocke (`Avoir.pdfUrl`), exactement comme le reçu. Mention TVA mutualisée (`vatNotice`).
  • `listAvoirs` enrichi : `studentName` + `packageName` (join `package.student`).

Routes : `POST /api/packages/[id]/avoirs` (GERANT seul) ; `GET /api/avoirs/[id]/pdf` (GERANT/COMPTABLE, runtime nodejs, rate `PDF_EXPORT`) → `{ url }`.

UI : bouton « Établir un avoir » par forfait dans `StudentBillingPanel` (GERANT, visible si `paidAmount > 0`) → `AvoirDialog` (montant plafonné au net encaissé côté UI, re-vérifié serveur). Onglet Avoirs (`AvoirsTable`) : élève + forfait + motif + montant + heures + `AvoirPdfButton`.

Tests : `src/test/billing-avoir.test.ts` (7 : NOT_FOUND, garde-fou montant/net, avoirs existants réduisent le net, création scopée + audit, PDF paresseux/réutilisé, isolation) ; `src/test/pdf-render.test.ts` (+ smoke avoir `%PDF`).

8c-2 — Crédit forfait (clôture + avoir)

Schéma : `Package.closedAt DateTime?` (migration `billing_package_closed_at`). Un forfait clôturé sort des forfaits actifs et des impayés (`listActivePackages`/`listUnpaid` filtrent `closedAt: null`) ; il reste visible dans la fiche élève avec un badge « Clôturé ».

Service (`billing/package-credit.ts`) : `closePackageWithCredit(schoolId, packageId, CreateAvoirInput, actorUserId) → {avoirId}`. Action métier « clôturer & créditer » = avoir + clôture atomiques dans une transaction `Serializable` : vérifie le forfait ouvert (sinon `NOT_FOUND` / `CONFLICT packageClosed`), crée l'avoir via `createAvoirInTx` (helper extrait de 8c-1, garde-fou `montant ≤ net encaissé` hérité), pose `closedAt`. Audits `CREATE_AVOIR` + `CLOSE_PACKAGE`, revalidate scopé tenant. Montant/heures saisis (pas de suivi `Lesson↔Package` en 8c).

Route : `POST /api/packages/[id]/credit` (GERANT seul, schéma `CreateAvoirSchema`).

UI : bouton « Clôturer & créditer » par forfait ouvert (`StudentBillingPanel`, GERANT, si `paidAmount>0`) → `AvoirDialog` en `mode="credit"` (avertissement de clôture définitive). Forfait clôturé = badge « Clôturé », actions (encaisser / lien en ligne / avoir / clôture / éditer / devis) masquées.

Tests : `src/test/billing-credit.test.ts` (4 : NOT_FOUND, déjà clôturé, garde-fou montant, succès avoir+closedAt+double audit).

---

8c-3 — Abonnement Stravoda (gestion in-app + Customer Portal)

L'abonnement SaaS de l'école (≠ facturation élève). Création de l'essai = onboarding (`subscribeWithTrial`) ; ce bloc ajoute la gestion in-app et le Stripe Customer Portal.

Stripe (`lib/stripe/subscription-manage.ts`, extrait de `subscription.ts` en 14r-9) :

  • `createBillingPortalSession(customerId, returnUrl)` → session Customer Portal (CB / factures / résiliation gérés par Stripe).
  • `updateSubscriptionItems({ subscriptionId, tierPriceId, modulePriceId, moduleQuantity, allModulePriceIds, prorationBehavior })` → `subscriptions.update`. L'item module est identifié par appartenance à `allModulePriceIds` (≤ 2 items : tier + module ; ajout/màj/suppression selon `moduleQuantity`). Opération convergente (état d'items cible) → pas de clé d'idempotence. `prorationBehavior` = `always_invoice` (upgrade) / `none` (downgrade), cf. 14r-9.
  • `getImmediateInvoiceAmountEur(sub)` → montant € de la facture immédiate d'un upgrade `always_invoice` (email de confirmation).

Service (`subscription.service.ts`) :

  • `getSubscriptionInfo(schoolId)` — lecture DB. Modules dérivés des permis (`resolveModules(permitTypes)`), prix `computeDisplayPrice`. `CANCELLED` → `hasSubscription:false`. 14r-9 : `pendingTier`/`pendingTierEffectiveAt`, `nextChargeEur` (formule effective = pending ?? courante), `activeInstructorCount`.
  • `changeSubscription(schoolId, { tier, period }, actorUserId)` — garde « abonnement actif » (`CONFLICT noActiveSubscription`), délègue à `applyPlanChange` (14r-9).
  • `startBillingPortal(schoolId)` — garde `stripeCustomerId` (`CONFLICT noCustomer`), renvoie l'URL du portail (retour → `/dashboard/parametres`).

Routes : `GET /api/subscription` (GERANT, lecture), `PATCH /api/subscription` (GERANT + MFA AAL2), `POST /api/subscription/portal` (GERANT + MFA AAL2). Schéma partagé `shared/schemas/subscription.ts` (`ChangeSubscriptionSchema`).

UI : section « Abonnement Stravoda » dans `/dashboard/parametres` (GERANT) + `SubscriptionCard` (formule/période/statut/échéance, sélecteur tier+période avec estimation, mise à jour, bouton Portal ; erreur `auth.mfa.required` → invite à activer la 2FA ; 14r-9 bannière downgrade programmé, prochain débit, avertissement capacité moniteurs). i18n `settings.subscription`.

Tests : `src/test/subscription-service.test.ts` (lecture, downgrade programmé, CANCELLED, garde abonnement actif, délégation `applyPlanChange`, portal).

---

14r-9 — Changement de plan + prorata (Phase 1, LIVRÉ)

> Multi-sites complet en Phase 2 (cf. section dédiée ci-dessous). Spec : `specs/14r-9-plans-prorata.md`.

Direction (`subscription-plan-change.planDirection`) : rang de tier (`SOLO<STARTER<PRO=TRANSPORT<ECOLE`, `SUBSCRIPTION_TIER_RANK`) ; à rang égal, `MONTHLY→YEARLY` = upgrade, `YEARLY→MONTHLY` = downgrade, sinon upgrade. `tier+period` identiques → `NONE` (aucun appel Stripe).

Garde anti-downgrade (`assertTierCapacity`) : si `maxInstructors != null` et `count(User MONITEUR actifs) > max` → `CONFLICT` (`settings.subscription.errors.tooManyInstructors`), zéro appel Stripe.

UPGRADE (`applyUpgrade`) : `updateSubscriptionItems(prorationBehavior:'always_invoice')` → débit prorata immédiat ; `tier`/`billingPeriod` mis à jour immédiatement + reset `pending*` ; audit `UPDATE_SUBSCRIPTION {direction:'UPGRADE'}` ; email `PLAN_UPGRADED` (montant prorata via `getImmediateInvoiceAmountEur`).

DOWNGRADE (`applyDowngrade`) : `updateSubscriptionItems(prorationBehavior:'none')` (prochaine facture au nouveau prix, pas de débit immédiat) ; `tier` inchangé, on stocke `pendingTier`/`pendingBillingPeriod`/`pendingTierEffectiveAt = currentPeriodEnd ?? trialEndsAt` (sinon application immédiate) ; audit `{direction:'DOWNGRADE'}` ; email `PLAN_DOWNGRADE_SCHEDULED`.

Application à l'échéance (`subscription.ts` `pendingDowngradeData` dans `syncSubscriptionToSchool`) : quand `currentPeriodEnd > pendingTierEffectiveAt`, bascule `tier = pendingTier` + reset `pending*` (idempotent).

Premier paiement : à la conversion `TRIALING → ACTIVE`, email best-effort `PAYMENT_FIRST` (`notifyFirstPayment`, CTA dashboard, 3 étapes d'onboarding).

Données (migration `plans_pending_downgrade_14r9`) : `School.pendingTier`, `School.pendingBillingPeriod`, `School.pendingTierEffectiveAt` (nullable).

Emails (`email-templates/billing.ts`, `wired:true`) : `PAYMENT_FIRST`, `PLAN_UPGRADED`, `PLAN_DOWNGRADE_SCHEDULED`, `MODULE_REMOVED_SCHEDULED`, `CANCELLATION_CONFIRMED`.

Résiliation in-app (`subscription/cancel.ts`, migration `subscription_cancellation_reason`) : page `/dashboard/parametres/abonnement` ; bouton visible si `ACTIVE|TRIALING` et pas déjà programmé ; dialog 2 étapes (motif `CancellationReasonCode` + commentaire optionnel → confirmation avec date de fin) ; `POST /api/subscription/cancel` (GERANT + MFA AAL2) → `stripe.subscriptions.update({ cancel_at_period_end: true })` + `CancellationReason` + `School.subscriptionCancelAtPeriodEnd` + audit `SUBSCRIPTION_CANCELLED_BY_GERANT` + email `CANCELLATION_CONFIRMED`. Webhook `syncSubscriptionToSchool` resynchronise `cancel_at_period_end`. Stripe Customer Portal inchangé pour réactivation / CB / factures.

Changement de modules post-inscription (`subscription-module-change.ts` + `subscription-module-change-apply.ts`) : `PATCH /api/subscription/modules` (MFA AAL2). Ajout → `always_invoice` + `billingModules` immédiat + `PLAN_UPGRADED` ; retrait → `none` + `pendingBillingModules`/`pendingBillingModulesEffectiveAt` (appliqué par `pendingBillingModulesData` dans `syncSubscriptionToSchool`) + email `MODULE_REMOVED_SCHEDULED`. UI : `SubscriptionModulesSection` (3 checkboxes Moto/Bateau/Transport +49 €).

Tests : `src/test/subscription-plan-change.test.ts` (direction, garde capacité, upgrade always_invoice, downgrade pending/sans ancre, no-op) + `subscription-service.test.ts` + `subscription-module-change*.test.ts`.

---

14r-9 Phase 2 — Multi-sites (LIVRÉ)

> Spec : `specs/14r-9-phase2-multisite.md`.

Modèle : un établissement = une `School` enfant (`parentSchoolId`, relation `SchoolHierarchy`). L'école d'inscription d'origine est la racine (`parentSchoolId = null`). Migration `multisite_phase2_14r9p2` : `School.stripeSiteItemId` (item d'abonnement Stripe facturant un site enfant).

Site actif vs racine (`AuthSession`, `lib/auth/helpers.ts` + `lib/auth/active-site.ts`) :

  • `session.schoolId` = site actif → scope TOUTES les requêtes métier (inchangées).
  • `session.rootSchoolId` = `parentSchoolId ?? ancre` → abonnement Stripe / tier / subscriptionStatus / gating d'accès. Repointé racine : `dashboard/layout` (`getOnboardingState`), `getDashboardHeader(active, root, user)`, `subscription.service` + routes `/api/subscription*`, `dashboard/parametres`, comptage moniteurs de la garde downgrade (= groupe entier).
  • Switcher GÉRANT only : cookie httpOnly `stravoda_active_site` surchargeant `session.schoolId`, validé en base dans `requireAuth` (appartenance au groupe via `resolveActiveSchoolId`) → zéro escalade ; fallback ancre si invalide. Route `POST /api/school/active-site` (audit `SWITCH_SITE`).

CRUD établissements (`school/mutations/sites.ts`, tier `ECOLE` + MFA AAL2) :

  • `addSite(rootSchoolId, input, actor)` : garde tier ECOLE + abonnement actif ; crée la `School` enfant (hérite pays/devise/timezone/marché/permis/branding) ; facturation consolidée = `addSiteSubscriptionItem` (Price ad-hoc `price_data` au prix `getMultiSitePrice(base, index)`, `proration_behavior:'always_invoice'`) ; `stripeSiteItemId` posé ; rollback du site si Stripe échoue ; audit `ADD_SITE`. Route `POST /api/school/sites`.
  • `removeSite(rootSchoolId, siteId, actor)` : garde appartenance groupe (jamais la racine) + ghost-data (refus si élèves actifs ou leçons à venir) ; `removeSiteSubscriptionItem` (`proration_behavior:'none'`) ; soft-delete (`deletedAt`, `stripeSiteItemId=null`) ; audit `REMOVE_SITE`. Route `DELETE /api/school/sites/[id]`.

Stripe (`lib/stripe/sites.ts`) : `addSiteSubscriptionItem` (crée une Price ad-hoc puis ajoute l'item, prorata immédiat — neutralisé pendant l'essai) / `removeSiteSubscriptionItem` (item `deleted:true`, pas de remboursement immédiat).

Vue (`school/queries/multi-site.ts` → `MultiSiteView`) : liste du groupe avec `isRoot`/`index`/`priceEur` (remise dégressive sur base ECOLE de la période), `total`, `canManage` (tier ECOLE). UI : onglet « Multi-sites » (`SettingsMultiSiteTab`, CRUD + upsell ECOLE) + `SiteSwitcher` actif (header).

Limites tier : inchangées (site actif) — un groupe multi-sites est nécessairement ECOLE (moniteurs/élèves illimités).

Affichage prorata multi-sites (`subscription/multi-site-billing.ts` → `loadMultiSiteBilling`) : si tier effectif = `ECOLE` et ≥2 sites → lignes par établissement (`getMultiSitePrice`) + modules + `grandTotalEur` ; exposé dans `SubscriptionInfo.multiSiteBilling` ; `nextChargeEur` = ce total. UI : `SubscriptionCard` (bloc détail par site).

Tests : `src/test/multisite-14r9p2.test.ts` (résolution site actif, garde tier/abonnement, facturation + rollback, ghost-data, vue groupe) + `multisite-billing.test.ts` (4).

---

14r-4c — Dunning, rappels d'essai, carte expirante & anti-abus (LIVRÉ)

Cycle de vie complet de l'abonnement SaaS de l'école après la création de l'essai (8c-3).

Données (migration `dunning_card_expiry_antiabuse_14r4c`)

  • `SubscriptionStatus.SUSPENDED` — nouvelle valeur, distincte de `PAUSED` (Stripe `unpaid`). Posée par notre cron dunning à J+7, jamais par le mapping Stripe.
  • `School` : `trialReminder7Sent`, `trialReminder1Sent`, `pastDueAt`, `dunningReminder3Sent`, `dunningReminder6Sent`, `cardExpiryWarningSent` (flags d'idempotence + horloge dunning).
  • `TrialUsage.cardFingerprint` (+ `@@index`) — empreinte Stripe de la carte capturée à la conversion.

Notifications — `lib/stripe/`

  • `owner.ts` : `getOwnerByCustomer` / `getOwnerBySchoolId` (résolution du gérant, `OwnerContact`).
  • `notify.ts` : `notifyOwner({ owner, template, vars, sms?, push? })` — email (`renderEmailTemplate` + `sendEmail`) + SMS best-effort (objet email + lien) + push `pushToGerants`. `billingLink()`.
  • `format.ts` (`formatMoney`/`formatDay`/`formatHour`) + `price-label.ts` (`subscriptionPriceLabel`, dérivé tier/période/modules).
  • Templates 14r-4b câblés (`wired:true`) : `PAYMENT_FAILED`, `DUNNING_3`, `DUNNING_6`, `SUSPENDED`, `PAYMENT_RECOVERED`, `CARD_EXPIRING`, `TRIAL_REMINDER_7`, `TRIAL_REMINDER_1`.

Webhooks — `lib/stripe/dunning.ts` (case dans `app/api/webhooks/stripe/route.ts`)

  • `invoice.payment_failed → notifyPaymentFailed` : `PAST_DUE` + `pastDueAt` posé à l'entrée seulement (horloge dunning stable) + email rouge + push + audit `PAYMENT_FAILED`. No-op si déjà `SUSPENDED`.
  • `invoice.payment_succeeded → notifyPaymentRecovered` : depuis PAST_DUE/SUSPENDED → `ACTIVE` + `pastDueAt=null` + reset flags + email `PAYMENT_RECOVERED`. No-op hors impayé.
  • `customer.source.updated → handleCardUpdated` : si impayé, `stripe.invoices.pay` de la facture ouverte (le succès rebascule via `payment_succeeded`).
  • `syncSubscriptionToSchool` durci : Stripe `past_due` n'écrase pas `SUSPENDED` ; retour `ACTIVE` depuis impayé → reset `pastDueAt`/flags.

Crons

  • `runTrialReminderCron` (dans le cron horaire `sms-reminders`) : J-7 (±1 j, email+SMS), J-1 (±4 h, email+push), idempotent par flags.
  • `/api/cron/dunning` (`0 10 * * *`, `runDunningCron`) : J+3 `DUNNING_3`+SMS, J+6 `DUNNING_6`+SMS+push, J+7 `SUSPENDED`+email+audit `SUBSCRIPTION_SUSPENDED`.
  • `/api/cron/card-expiry` (`0 9 1 * *`, `runCardExpiryCron`) : carte par défaut Stripe expirant ≤ 60 j → `CARD_EXPIRING` + audit `CARD_EXPIRY_WARNING` + flag.

Suspension — `/suspended`

  • Garde dans `app/[locale]/dashboard/layout.tsx` : `SUSPENDED → /suspended` (RSC, hors `/dashboard`, pas de boucle).
  • Page `app/[locale]/suspended/page.tsx` + `components/billing/SuspendedPortalButton.tsx` (CTA Stripe Portal via `/api/subscription/portal`). i18n `billing.suspended.*`.

Anti-abus — `lib/security/`

  • `card-fingerprint.ts` `recordTrialCardFingerprint` (appelé dans `subscribeWithTrial`, best-effort) : empreinte réutilisée par une autre école → `TrialUsage.flagged='card_fingerprint_reuse'` + `SecurityEvent` (jamais bloquant — décision).
  • `signup-guard.ts` `enforceIpTrialWindow` : ≤ 3 essais / IP / 30 j (`TrialUsage.count`), complète le rate-limit `SIGNUP_DAILY` 3/24h.
  • Helper Stripe `getPaymentMethodCardFingerprint(pmId)`.

Constantes

`DUNNING_REMINDER_3_DAYS`, `DUNNING_REMINDER_6_DAYS`, `DUNNING_SUSPEND_DAYS`, `TRIAL_REMINDER_7_DAYS`, `TRIAL_REMINDER_1_DAYS`, `CARD_EXPIRY_WARNING_DAYS`, `TRIAL_IP_WINDOW_DAYS`, `TRIAL_IP_30D_MAX`.

Tests — `src/test/dunning-14r4c.test.ts` (11)

payment_failed (entrée/déjà PAST_DUE/SUSPENDED), payment_recovered (reset/no-op), dunning cron (J+3/J+7/idempotence), trial reminders (J-7/J-1), card fingerprint (flag/inédit/null). + `security-signup-guard` (IP 30 j) + maj `webhook-stripe`/`onboarding-service`.

Décisions / dette assumée

  • Pas d'email « nouvelle carte reçue » dédié (`customer.source.updated`) : la confirmation passe par `PAYMENT_RECOVERED` au succès du débit (aucun template intermédiaire créé).
  • Portail réutilisé `/api/subscription/portal` (GERANT + AAL2) ; pas de `/api/billing/portal`.
  • Carte expirante : N+1 d'appels Stripe (1 `customers.retrieve` par école éligible) — acceptable au volume du cron mensuel.

---

8c-4 — Export planning imprimable (PDF)

Libellés mutualisés : `lib/pdf/labels.ts` (`PERMIT_LABELS`, `LESSON_STATUS_LABELS`, `LESSON_TYPE_LABELS`, `EXAM_TYPE_LABELS`) — libellés FR figés (les PDF sont des documents français), extraits de `generate-doc.ts` (DRY), réutilisés par 8c-4 et 8c-5.

PDF (`lib/pdf/planning.tsx`) : A4 paysage, leçons groupées par jour, colonnes horaire/élève/moniteur/véhicule/permis/type/lieu/statut, en-tête école + période + filtre moniteur + total.

Service (`planning-export.service.ts`) : `getPlanningExportPdf(schoolId, { from, to, instructorId? }) → Buffer`. Lecture seule scopée `schoolId`, regroupement par jour (clé `fr-CA` YYYY-MM-DD), rendu `renderPlanningExportPdf`.

Route : `GET /api/planning/export?from&to&instructorId` (GERANT + MONITEUR, `PlanningExportQuerySchema`, `PDF_EXPORT`, runtime nodejs). Flux PDF à la volée (non stocké) en pièce jointe.

UI : `PlanningExportButton` (popover, plage par défaut = semaine courante, applique le filtre moniteur actif du board) dans la barre d'outils `PlanningBoard`. i18n `planning.export`.

Tests : `src/test/planning-export.test.ts` (4 : NOT_FOUND, where scopé schoolId+plage, groupement par jour, filtre moniteur) + smoke `pdf-render`.

---

8c-5 — Rapport DREAL annuel (PDF)

Contenu (par année civile UTC) : synthèse (élèves inscrits / leçons effectuées / heures), tableau par catégorie de permis, tableau examens (présentés = reçus+échecs, reçus, échecs, taux de réussite par `CODE`/`CONDUITE`).

Service (`dreal-report.service.ts`) : `getDrealReportPdf(schoolId, year) → Buffer`. Bornes `Date.UTC(year,0,1)..Date.UTC(year+1,0,1)`. Agrégats `groupBy` (zéro N+1) : `student.groupBy` (inscrits/an, `createdAt` dans l'année), `lesson.groupBy` (status `COMPLETED`, `_count` + `_sum.durationMinutes`), `examDate.groupBy` (type×résultat). Taux = reçus/présentés (`—` si 0). Rendu `renderDrealReportPdf`.

Route : `GET /api/reports/dreal?year=YYYY` (GERANT seul — réglementaire, `DrealReportQuerySchema` : `DREAL_REPORT_MIN_YEAR`(2024)..année courante, `PDF_EXPORT`, runtime nodejs). Flux PDF en pièce jointe.

UI : section « Rapport annuel (DREAL) » dans `/dashboard/parametres` (GERANT) + `DrealReportCard` (sélecteur année + téléchargement). i18n `settings.dreal`.

Tests : `src/test/dreal-report.test.ts` (4 : NOT_FOUND, bornes UTC scopées, agrégation par permis + taux + totaux, taux « — » si 0 examen) + smoke `pdf-render`.

Dette 8c-4/8c-5

  • Export CSV DREAL non livré (PDF seul) — ajout trivial si demandé par l'administration.
  • Fuseau horaire des bornes DREAL = UTC (serveur prod UTC) ; affinage fuseau école si besoin.

Hors 8c (dette assumée)

  • Crédit forfait : `Lesson↔Package` + ajustement `usedHours` non traité (sous-étape dédiée).
  • UI paiement COMPTABLE : le COMPTABLE garde l'accès API à `POST /api/packages/[id]/payments` mais

l'unique UI d'encaissement (panneau fiche élève) lui est fermée et `/dashboard/facturation` est en lecture

seule → action « enregistrer un paiement » à ajouter sur les onglets facturation (8b ou sous-étape).

  • Abonnement 8c-3 : ajout/retrait manuel de module + multi-site dégressif (`getMultiSitePrice`, lié infra Étape 13) reportés ; affichage « se termine le … » (`cancel_at_period_end` non tracké, résiliation = Portal).

14x — Plan Solo (moniteurs indépendants)

  • Tier `SOLO` : 79 €/mois, 756 €/an (−20 %), max 30 élèves, max 1 moniteur (lui-même).
  • Stripe : `STRIPE_PRICE_SOLO_MONTHLY` / `STRIPE_PRICE_SOLO_ANNUAL` → `getTierPriceId('SOLO', period)`.
  • Limites : `assertStudentLimit` (31ᵉ élève → `CONFLICT billing.studentLimitReached`) ; `assertCanAddInstructor` (Solo → `FORBIDDEN`).
  • Onboarding : étape 1 `structureType=SOLO` fige `tier=SOLO` ; étape 4 auto-crée `Instructor` + `User.instructorId`.
  • UI : sidebar dashboard simplifiée, lien « Vue moniteur », badge élèves orange > 25, bandeau upgrade > 28.
`/dashboard/parametres`
`updateSchoolProfile` (name+address+city)
`INSTRUCTOR_INVITED`Inviter moniteur`/dashboard/moniteurs``inviteInstructor`
`STUDENT_INVITED`Ajouter élèves`/dashboard/eleves``createStudent`
`LESSON_CREATED`Créer une leçon`/dashboard/planning``createLesson`
`SMS_CONFIGURED`Configurer SMS`/dashboard/parametres?tab=sms``updateSchoolSmsSettings`

Routes API

MéthodeRouteRôleEffet
GET`/api/dashboard/guide`GERANTétat courant (+ `welcomePending`)
POST`/api/dashboard/guide/dismiss`GERANTmasquer la checklist
POST`/api/dashboard/guide/reopen`GERANTréafficher
POST`/api/dashboard/guide/welcome-seen`GERANTmarquer overlay vu

UI

  • `DashboardWelcomeOverlay` — modal 1 fois
  • `DashboardGuideFloat` — checklist flottante (remplace `DashboardGuideBar` 14k)
  • `GuidePageTooltip` — tooltips dismissibles (localStorage `GUIDE_TOOLTIP_STORAGE`)
  • `GuideConfetti` — animation canvas (`prefers-reduced-motion`)
  • Montés dans `DashboardShell` (GERANT, props `guideState` depuis `dashboard/layout.tsx`)

Email

`ONBOARDING_COMPLETE` — best-effort via `notifyOnboardingGuideComplete` à la 5ᵉ étape.

Tests

`src/test/dashboard-guide.test.ts` — normalisation legacy, markStep, completion email, welcomePending, dismiss/reopen, stats admin.

`isKnownInboundSender`, import dedup, SMS dispatch, exports, PDF…).

  • `src/lib/services/student/crypto.ts` — `encryptStudentPii`/`decryptStudentPii` (source unique des

champs via `ENCRYPTED_FIELDS.Student`), pour les usages hors-Prisma (script de migration).

Migration : `npx tsx scripts/encrypt-existing-pii.ts` (client Prisma dédié sans extension, idempotent).

3. Masquage dans les logs (`src/lib/crypto/mask.ts`)

`maskPhone` (`*1234`), `maskEmail` (`j*@gmail.com`), `maskNeph` (`****56789`) + `maskPiiDeep`

(récursif, gère les entrées de diff `{field, before, after}`). Appliqué dans `writeAuditLog` → aucun

téléphone/email/NEPH en clair dans un AuditLog (l'export consentements ne logue qu'un `count`).

Env & déploiement

`ENCRYPTION_KEY: z.string().length(64)` (déjà dans `src/env.ts`). Voir `TODO_DEPLOIEMENT.md` :

générer la clé, l'ajouter sur Vercel, exécuter `apply-rls.ts` puis `encrypt-existing-pii.ts`.

⚠️ Ne jamais changer `ENCRYPTION_KEY` après chiffrement (déterministe → données illisibles).

Décisions clés

  • Déterministe vs IV aléatoire : le brief demandait un IV aléatoire ; choix validé d'un IV

déterministe car phone/neph/email sont recherchés par égalité (login magic-link, matching SMS entrant,

unicité NEPH, dedup import) et aucun ajout de colonne « blind index » n'était permis. Compromis assumé :

l'égalité de deux valeurs est observable.

  • Extension Prisma + cast `PrismaClient` : couverture complète et DRY sans cascade de types ; le

chiffrement s'applique aussi dans les transactions interactives.

  • Lecture tolérante : `decryptIfNeeded` renvoie la valeur telle quelle hors préfixe `enc:` → données

pré-migration lisibles, déploiement progressif sûr.

Tests — `src/test/field-encryption.test.ts`

Round-trip encrypt/decrypt, déterminisme, idempotence, null/clair pré-migration, encrypt/decrypt élève,

extension (where chiffré + résultat + relation imbriquée déchiffrés), masquage (clé + diff), artefact RLS.

`getEleveSpace(token): EleveSpace` — type de lien autorisé : `PORTAL` ou `ONBOARDING` (sinon `UNAUTHORIZED`).

Charge l'élève scopé `{ id, schoolId, deletedAt: null }` (sinon `NOT_FOUND`) puis compose :

  • branding : `school.{name, primaryColor, logoUrl}`.
  • contact : téléphone école + nom du moniteur référent (`primaryInstructor`).
  • lessons (`getEleveLessons`) : `upcoming` (PENDING/CONFIRMED, futures, ≤ `ELEVE_UPCOMING_LESSONS_LIMIT`)

+ `history` (10 dernières `COMPLETED`, avec `instructorNote`).

  • progress (`getEleveProgress`) : heures (`Package.aggregate` `totalHours/usedHours`, forfaits

non supprimés/non clôturés ; `remaining = max(0, total − used)`), 4 compétences REMC complétées

(`NON_ABORDE` par défaut), prochain examen (`ExamDate` futur le plus proche).

`EleveSpace` ajoute aussi (11b-1) booking : `requiresValidation` (`School.requireInstructorValidation`)

+ `instructors` (moniteurs `isActive` éligibles au permis de l'élève). Chaque leçon `upcoming` porte

`cancellable` (calculé serveur : créneau au-delà du délai de pénalité — pas de `Date.now()` au rendu client).

Page `/[locale]/e/[token]` : dossier non complété → formulaire self-onboarding (6b) ;

dossier complété → `EleveSpace`. Lien invalide → message + bouton « Demander un nouvel accès ».

Réservation + annulation (11b-1)

Mutations via le token PORTAL (mêmes garanties d'identité que la lecture). Garde commune

`authStudent(token)` dans `eleve/shared.ts` (type `PORTAL`/`ONBOARDING` sinon `UNAUTHORIZED`).

  • Créneaux `getAvailableSlots(token, { instructorId, fromISO }, now?)` : pour le jour de `fromISO`,

intersection `School.openingHours[jour] ∩ Instructor.workingHours[jour]`, pas de

`BOOKING_SLOT_GRANULARITY_MIN` (30), durée `defaultLessonDuration`. Exclut : passé/lead

(`BOOKING_MIN_LEAD_HOURS`=24), hors fenêtre (`BOOKING_HORIZON_DAYS`=14), `findConflict`, indispo

(`hasUnavailabilityConflict`). Moniteur non éligible → `CONFLICT`. TZ : jour + heures en UTC

(dette commune planning). Perf : 2 lectures indexées/créneau (≤~20/jour) — acceptable MVP.

  • Demande `requestBooking(token, { instructorId, scheduledAt }, now?)` : garde-fous `smsConsent`,

moniteur éligible, lead time, horizon, max 1 `PENDING`/élève. Puis selon

`School.requireInstructorValidation` :

- `true` → transaction `Serializable` : `assertAssrForMinor` + `assertNoConflict` + `BookingRequest`

`PENDING` + SMS moniteur (`instructor.new_request_notify`, pré-rendu, `studentId: null` →

`resolveRecipients` envoie au `recipientPhone` sans consentement élève) + audit `CREATE_BOOKING_REQUEST`.

- `false` → `createLesson` (réutilise TOUS les garde-fous + audit + `revalidatePlanning`) + SMS

confirmation élève (`student.bookingConfirmed`). Retour `{ mode, id }`.

  • Annulation in-space `cancelEleveLesson(token, lessonId)` (`eleve/cancel.ts`) : leçon **scopée

`{ id, schoolId, studentId }`** (l'ajout du `studentId` empêche d'annuler la leçon d'autrui),

statut annulable sinon `CONFLICT`. `cancelLesson(..., { cancelledBy: 'STUDENT' })` (pénalité par

`resolveCancellationPolicy` même hors délai = défense en profondeur ; l'UI masque le bouton dans le

délai via `cancellable`) + confirmation SMS mutualisée (`enqueueCancellationConfirmation`, extraite

de `sms/cancel.ts` et réutilisée par le flux lien CANCEL).

Divergence assumée vs spec : la confirmation directe utilise une nouvelle clé

`student.bookingConfirmed` (sans lien) plutôt que `booking.confirmed` (qui impose un `{{cancelLink}}`

inutile/expiré pour une réservation lointaine) — l'annulation in-space remplace le lien SMS.

Documents (11c)

Réutilise le modèle unique `Document` et les services gérant (zéro duplication). Lecture composée

dans `getEleveSpace` (RSC), mutations via le token PORTAL. Aucune migration.

  • Lecture : `getEleveSpace` ajoute `documents: { items, contract }` via `toEleveDocuments(checklist)`

(`eleve/documents.ts`, pur) sur la sortie de `getStudentDocumentsChecklist`. `items` = documents

requis (statut + nom de fichier) ; `contract` = `CONTRAT_FORMATION` `VALIDE` (sinon `null`).

  • Upload `uploadEleveDocument(token, type, file)` : garde `type ∈ ELEVE_UPLOADABLE`

(`REQUIRED_DOCUMENT_TYPES` + `AUTORISATION_PARENTALE` ; contrat/plan/devis/avoir → `FORBIDDEN`),

`validateUpload` (taille+MIME+magic bytes), puis `uploadStudentDocument(..., STUDENT_ACTOR)`

(upsert `EN_ATTENTE` + audit `UPLOAD_DOCUMENT` + `revalidateStudents`). Pas de SMS gérant (décision).

  • Download `getEleveDocumentDownloadUrl(token, documentId)` : `Document` re-scopé

`{ id, studentId, schoolId }` → garde `type=CONTRAT_FORMATION` et `status=VALIDE` et `fileUrl`

(sinon `FORBIDDEN`/`NOT_FOUND`) → `createSignedDocumentUrl` (TTL 120 s). La facturation (devis/avoir)

n'est jamais exposée.

  • UI : onglet Documents (`EleveDocumentsTab`, nav `grid-cols-5`) — upload par item, téléchargement du

contrat signé ; client `portalUpload(path, formData)` (multipart, sans `Content-Type` forcé).

Contrats

FonctionEntréeSortieNotes
`requestPortalAccess``phone: string`, `locale?``void`anti-énumération, side-effect conditionnel
`getEleveSpace``token: string``EleveSpace``UNAUTHORIZED` (type) / `NOT_FOUND`
`getEleveLessons``studentId, schoolId, cancellationDeadlineHours, now?``EleveLessons`scopé tenant ; `cancellable` calculé
`getEleveProgress``studentId, schoolId, now?``EleveProgress`scopé tenant
`getAvailableSlots``token, { instructorId, fromISO }, now?``string[]` (ISO)`UNAUTHORIZED`/`CONFLICT`
`requestBooking``token, { instructorId, scheduledAt }, now?``{ mode, id }`validation on/off
`cancelEleveLesson``token, lessonId``{ penaltyApplied, penaltyHours }`scope `studentId`
`getEleveDocuments``token``EleveDocuments`items requis + contrat VALIDE
`uploadEleveDocument``token, type, file``{ documentId, status }`garde `ELEVE_UPLOADABLE` → `FORBIDDEN`
`getEleveDocumentDownloadUrl``token, documentId``{ url }`contrat `VALIDE` scopé `studentId`

Isolation & sécurité

  • Toutes les lectures scopées `{ studentId, schoolId }` issus du JWT.
  • Un token élève A ne renvoie jamais les données d'un élève B (test `eleve-service.test.ts`).
  • Aucune donnée sensible exposée : ni NEPH, ni notes internes, ni détail facturation.
  • Aucune PII dans les logs (audit = IDs/codes uniquement).

Fichiers

  • Service : `lib/services/eleve/{types,shared,access,lessons,progress,space,slots,booking,cancel,documents,index}.ts` (tous ≤150 l.).
  • Routes : `app/api/portal/access/route.ts` · `app/api/portal/[token]/booking/route.ts` (GET slots + POST)

· `app/api/portal/[token]/lessons/[lessonId]/cancel/route.ts`

· `app/api/portal/[token]/documents/route.ts` (POST upload multipart, 11c)

· `app/api/portal/[token]/documents/[documentId]/download/route.ts` (GET URL signée, 11c)

(publiques, nodejs, rate `STUDENT_LINK_USE` par token haché).

  • UI : `app/[locale]/e/acces/page.tsx`, `app/[locale]/e/[token]/page.tsx`,

`components/eleve/{EleveAccessForm,EleveSpace,EleveLessonsTab,EleveBookingTab,EleveCancelButton,EleveProgressTab,EleveDocumentsTab,EleveContactTab,api,format,constants}`.

  • i18n : `locales/fr/eleve.json` (namespaces `eleve.booking`, `eleve.documents`, `eleve.errors`, onglets

`booking`/`documents`) ; labels types via `documentType.*` (common) ; clés SMS `student.bookingConfirmed`,

`student.bookingRefused` (11b-2), `instructor.new_request_notify`.

  • 11c (réutilisé du module élèves) : `Document` + `getStudentDocumentsChecklist`/`uploadStudentDocument`/

`createSignedDocumentUrl`/`validateUpload` — aucune migration. Constantes `REQUIRED_DOCUMENT_TYPES`/

`MINOR_REQUIRED_DOCUMENT_TYPE`/`UPLOAD_*`/`DOCUMENT_SIGNED_URL_TTL_SEC`.

  • 11b-2 (côté moniteur) : les `BookingRequest` PENDING sont tranchées dans `/moniteur/demandes`

(`lib/services/moniteur/booking-requests.ts` — accept réutilise `createLessonInTx`, refuse → SMS

`student.bookingRefused`). Détails dans `docs/modules/moniteur.md`.

  • Constantes : `BOOKING_SLOT_GRANULARITY_MIN`, `BOOKING_HORIZON_DAYS`, `BOOKING_MIN_LEAD_HOURS`.
  • Migration `booking_requests` : `BookingRequest` + enum `BookingRequestStatus` + `School.requireInstructorValidation`.

Tests

  • `src/test/eleve-service.test.ts` (7) : isolation du scope, garde de type de lien, `NOT_FOUND`,

agrégat heures (+ clamp), anti-énumération.

  • `src/test/eleve-booking.test.ts` (12) : validation on/off, max 1 `PENDING`, consentement, éligibilité

moniteur, lead time, filtrage des créneaux (conflit/indispo/horaires), annulation scopée `studentId`

(isolation), `NOT_FOUND`/`CONFLICT`.

  • `src/test/eleve-documents.test.ts` (9) : vue items requis + contrat `VALIDE`, garde type téléversable

(contrat/devis → `FORBIDDEN`), download contrat `VALIDE` scopé `{id,studentId,schoolId}` (isolation),

refus non-contrat/non-`VALIDE`, `NOT_FOUND`, garde type de lien.

  • Blocage planning — helper unique `lib/services/lesson/unavailability.ts` `hasUnavailabilityConflict`

branché dans `assertNoConflict` (create + reschedule), `createRecurringLessons`, `replaceInstructor`,

`getReplacementOptions`. Erreur `planning.errors.instructorUnavailable`.

  • Service `lib/services/instructor/unavailability.ts` :

- `createUnavailability(schoolId, instructorId, CreateUnavailabilityInput, actorUserId) : { id }` —

vérifie l'appartenance école, dérive `recurrenceRule`, sanitize le motif, audit `CREATE_UNAVAILABILITY`,

revalidate `instructors` + `planning`.

- `deleteUnavailability(schoolId, instructorId, id, actorUserId) : { id }` — `deleteMany` scopé

`id+instructorId+schoolId` (`NOT_FOUND` si count 0), audit `DELETE_UNAVAILABILITY`, revalidate.

  • Routes : `POST /api/instructors/[id]/unavailability` + `DELETE …/[unavailId]` (GERANT).
  • Schéma `shared/schemas/unavailability.ts` : `startAt`/`endAt`/`isRecurring`/`reason?` ; refine fin>début

+ plage récurrente ≤ 24 h.

  • UI : `components/instructors/UnavailabilityManager.tsx` (liste + suppression + formulaire).
  • Édition d'une indispo non gérée (suppression + recréation). Récurrences non hebdomadaires non gérées.

Architecture

shared/schemas/instructor.ts     Zod : Create / Update (+ version) / ListQuery ; WorkingHours (par jour,
                                 1 créneau {start,end}, start<end) ; couleur hex ; permitTypes min 1
lib/services/instructor/
  types.ts        formes publiques (InstructorListItem + stats, InstructorDetail, AssignedStudent)
  utils.ts        cache tags (school:${id}:instructors[:${iid}]), revalidate, clés d'erreur i18n,
                  authExpiryStatus (ok|soon|expired|unknown), toIso
  validation.ts   assertCanDeleteInstructor (Ghost Data : leçons futures), normalisation workingHours
  stats.ts        agrégats SANS N+1 (groupBy leçons + findMany scopés schoolId, mapping en mémoire)
  queries.ts      listInstructors (avec stats), getInstructorDetail
  mutations/      create.ts · update.ts (verrou optimiste) · delete.ts (soft-delete) · index.ts
  ical.ts         getInstructorCalendar (9e) — leçons → buildICalendar (lib/ical/build.ts)
  index.ts        façade + InstructorService
lib/services/instructor.service.ts  shim de compat (export * from './instructor')
app/api/instructors/route.ts        GET (liste, GERANT) · POST (GERANT)
app/api/instructors/[id]/route.ts   GET (GERANT) · PATCH (GERANT) · DELETE (GERANT)
app/[locale]/dashboard/moniteurs/   page.tsx (liste RSC) · [id]/page.tsx (fiche RSC) · loading/error
components/instructors/             InstructorsClient · InstructorStatsBadges · InstructorFormSheet
                                    · InstructorDetailView
locales/fr/instructors.json         namespace `instructors`

Contrats de service

  • `listInstructors(schoolId, { isActive? }) : InstructorListItem[]`

Moniteurs de l'école (non supprimés) + stats agrégées. Nombre de requêtes constant (zéro N+1).

  • `getInstructorDetail(id, schoolId) : InstructorDetail` — `NOT_FOUND` si hors école.
  • `createInstructor(schoolId, input, actorUserId) : { id }` — audit `CREATE_INSTRUCTOR`.
  • `updateInstructor(schoolId, id, input, actorUserId) : { id }` — verrou optimiste `version` →

`CONFLICT` (`instructors.errors.versionConflict`) si périmée ; audit `UPDATE_INSTRUCTOR`.

  • `softDeleteInstructor(schoolId, id, actorUserId) : { id }` — Ghost Data : `CONFLICT`

(`instructors.errors.hasFutureLessons`) si leçons futures PENDING/CONFIRMED ; audit `DELETE_INSTRUCTOR`.

Règles de calcul des statistiques

StatFormule`null` (affiché `—`) si
Taux présence`COMPLETED / (COMPLETED + NO_SHOW)`dénominateur 0
Taux réussite examens`RECU / (RECU + ECHOUE)` des élèves référents0 examen jugé
Moyenne leçons/examen`leçons COMPLETED des référents / examens jugés`0 examen jugé
Revenus (estimation)`Σ paiements` des forfaits des élèves référentsjamais (0 € possible)

Revenus = approximation : pas de lien `Leçon↔Forfait` (dette transverse). Attribution via élève

référent (`primaryInstructorId`). Libellé UI « estimation ».

Décisions / dette

  • Buffer par moniteur : non implémenté. La fiche affiche `school.instructorBuffer` en lecture seule ;

un override par moniteur nécessiterait un champ + câblage dans la détection de conflits du planning.

  • Pagination : la liste moniteurs n'est PAS paginée (ensemble borné par le tier : 2/6/illimité ;

réaliste < 50). `findMany` ordonné sans offset. Dénormaliser/paginer si une école dépasse l'échelle MVP.

  • Stats : agrégats chargés par quelques `findMany`/`groupBy` scopés `schoolId` puis mappés en mémoire

(constant en nb de requêtes). Acceptable à l'échelle MVP ; dénormaliser si volume important.

  • Indisponibilités (9b) : fuseau UTC pour le jour de semaine + heures (cohérent avec le stockage des

leçons ; affinage fuseau école = dette commune planning). Pas d'édition (delete + recreate), récurrences

hebdomadaires seulement.

Invitation moniteur (9c)

  • Infra email `lib/email/` (Resend) : `client.ts` (singleton) · `send.ts` (`sendEmail`, `from`=`EMAIL_FROM`,

zéro PII loggée) · `templates.ts` (`instructorInvitationEmail` pur, HTML email-safe + texte) ·

`instructor-invite.ts` (compose le contenu via `getTranslations({locale,'instructors'})` puis envoie).

  • Env : `EMAIL_FROM` (défaut `Stravoda <noreply@stravoda.com>`, dans `.env.example`).
  • Modèle : `InvitationToken` (`tokenHash` SHA-256, `email`, `role`, `expiresAt`, `usedAt`, `instructorId`).
  • Service `instructor/invitation.ts` :

- `inviteInstructor(schoolId, instructorId, actorUserId, locale='fr') : { id }` — gardes : email requis

(`VALIDATION_ERROR inviteNoEmail`), pas de compte existant (`CONFLICT alreadyHasAccount`). Token brut

`randomBytes(32)` → stocké haché, jamais en clair ; expiry `INVITATION_TOKEN_EXPIRY_HOURS` (48 h) ;

email d'activation `…/{locale}/activate/{raw}` ; audit `INVITE_INSTRUCTOR`.

- `getValidInvitation(raw) : { id, email, schoolId, instructorId }` — existe + non utilisé + non expiré +

lié à un moniteur, sinon `UNAUTHORIZED invitationInvalid`.

  • Service `instructor/activation.ts` : `activateInvitation(raw, password) : { ok }` — `assertPasswordLength`

+ `assertNotPwned` (12 + HIBP, comme l'inscription gérant) → `admin.createUser` (Supabase, `email_confirm`)

→ transaction Prisma { `invitationToken.usedAt` (one-shot via `updateMany count`) + `User` MONITEUR lié

`instructorId`/`supabaseId` } ; rollback `admin.deleteUser` si l'écriture Prisma échoue ; audit

`ACTIVATE_INSTRUCTOR`.

  • Routes : `POST /api/instructors/[id]/invite` (GERANT, rate `INVITATION` par école) ;

`GET/POST /api/activate/[token]` (public, rate `STUDENT_LINK_USE` par token haché, runtime nodejs).

  • UI : `InviteInstructorButton` (fiche, caché si `hasAccount`, désactivé sans email) ; page publique

`/[locale]/activate/[token]` + `ActivateForm` (email pré-rempli + mot de passe → succès + lien connexion).

  • Dette : aucun espace moniteur (Étape 10) → après connexion un MONITEUR sur `/dashboard` reçoit

`FORBIDDEN` (la page d'activation renvoie vers `/login`, pas d'auto-redirection). Pas de « renvoyer/révoquer »

d'invitation. Resend/Supabase admin non exercés en live (mockés en test).

Export iCal du planning (9e)

  • Générateur pur `lib/ical/build.ts` `buildICalendar({ calendarName, events, now? })` : enveloppe

`VCALENDAR` (VERSION 2.0, PRODID, `X-WR-CALNAME`) + un `VEVENT` par leçon (`UID`/`DTSTAMP`/`DTSTART`/`DTEND`

en UTC `…Z`, `SUMMARY`, `STATUS`, `LOCATION?`). Conforme RFC 5545 : échappement TEXT (`\` `;` `,` `\n`),

pliage des lignes à 75 octets (continuation préfixée d'une espace), terminaisons CRLF. Aucune I/O.

  • Service `instructor/ical.ts` `getInstructorCalendar(schoolId, instructorId) : { filename, content }` —

re-scope le moniteur (`NOT_FOUND` hors école) + ses leçons (`scheduledAt ≥ now − ICAL_PAST_WINDOW_DAYS`,

futures incluses). Statut leçon → iCal : `PENDING`/`TO_RESCHEDULE`→`TENTATIVE`, `CONFIRMED`/`COMPLETED`/

`NO_SHOW`→`CONFIRMED`, `CANCELLED_*`→`CANCELLED`. `SUMMARY` = élève (i18n `instructors.ical.eventSummary`),

`LOCATION` = `meetingPoint ?? nom école`, `filename` slugifié.

  • Route `GET /api/instructors/[id]/ical` (GERANT, rate `API_AUTHENTICATED`, runtime nodejs) → réponse

`text/calendar; charset=utf-8` en pièce jointe (`Content-Disposition: attachment`).

  • UI : lien « Export iCal » (`<a download>`) sur la fiche moniteur (`InstructorDetailView`).
  • Dette : téléchargement authentifié (snapshot à l'instant T) — pas de feed d'abonnement tokenisé

(URL signée + token haché) qui se mettrait à jour dans le calendrier. Événements en UTC.

Utilitaires purs (`shared/`)

  • `shared/utils/timezone.ts` — `schoolDayBounds`, `startOfDayInTimezone`, `startOfDayFromDateKey`, `weekdayKeyInTimezone`, `isSchoolLocalHour`, `toSchoolTime`, …
  • `shared/utils/currency.ts` — `formatCurrency`, `formatCurrencyUnits`
  • `shared/locale/suggest.ts` — suggestion timezone/devise depuis pays (client-safe)

Services serveur

  • `lib/services/school/locale-context.ts` — `getSchoolLocaleContext(schoolId)` (cache unstable_cache)
  • `lib/services/school/mutations/locale.ts` — mise à jour paramètres locale
  • `app/api/school/locale/route.ts` — GET/PATCH (GERANT, Zod, schoolId session)

i18n UI (14m-2)

  • Source de vérité : `src/locales/fr/*.json` (42 namespaces + `sms.json`)
  • Overlays : `src/locales/en/*.json`, `src/locales/es/*.json` — même structure par namespace (plus de `messages.json` monolithique)
  • Manifest : `src/i18n/locale-config.ts` (`WRAPPED_NAMESPACE_KEYS`, `listFrNamespaceFiles()`)
  • Assemblage : `src/i18n/assemble-messages.ts` + imports `src/i18n/locale-parts.ts` (généré)
  • Chargement : `src/i18n/load-messages.ts` — `deepMerge(FR, EN|ES)` par namespace puis assemble
  • `src/i18n/request.ts` — délègue à `loadMessages(locale)`
  • Split one-shot : `npx tsx scripts/split-locales.ts` (découpe monolithique → namespaces, régénère `locale-parts.ts`)
  • Génération semi-auto : `npm run i18n:generate` (`scripts/i18n-generate.ts`, clé `ANTHROPIC_API_KEY`, écrit un fichier par namespace)
  • Stub FR → EN/ES : `npx tsx scripts/i18n-stub.ts`

Points d'intégration timezone

ZoneFichierPattern
Dashboard accueil`dashboard/data.ts``schoolDayBounds(timezone, now)`
Équipe du jour`dashboard/team.ts`reçoit `timezone` depuis data
Moniteur today`moniteur/today.ts``getSchoolLocaleContext`
Créneaux élève`eleve/slots.ts``startOfDayFromDateKey` + `weekdayKeyInTimezone`
Réservation élève`eleve/booking.ts``formatDate/Time(..., tz)`
SMS render`sms/render.ts`défaut `DEFAULT_SCHOOL_TIMEZONE`
SMS batch (docs/inactifs)`sms/reminders/local-gate.ts`9h locale
Rapport mensuel cron`reports/monthly/cron.ts`1er du mois + `preferredEmailHour` locale
Waiting list`waiting-list/match.ts`, `notify.ts`param `timezone` depuis `getSchoolLocaleContext`
Compliance admin`admin/compliance/periods.ts``DEFAULT_SCHOOL_TIMEZONE` (plateforme)
Yousign`yousign/signature.ts`timezone école passée à l'API
PDF incidents`incident/format-pdf.ts`, `pdf.ts``toSchoolTime(..., timezone)`
Effacement RGPD`erasure/create.ts``toSchoolTime(..., DEFAULT_SCHOOL_TIMEZONE)`
Remplacement moniteur`lesson/queries.ts``startOfDayFromDateKey`
Palette commandes`search/queries.ts``schoolDayBounds`
Détail moniteur enrichi`instructor/enriched-detail.ts``schoolDayBounds`

Crons multi-fuseaux

  • Rapport mensuel : cron Vercel **`0 * 1 * *`** (toutes les heures le 1er du mois) ; filtrer `isFirstDayOfMonthInTimezone` + `isSchoolLocalHour(preferredEmailHour)`.
  • SMS batch documents/inactifs : filtrer `isSchoolBatchSmsWindow` (9h locale).

UI paramètres

  • `components/settings/SettingsLocaleTab.tsx` — timezone, symbole devise, heure email, suggestion depuis pays
  • Intégré dans `SettingsView.tsx` (onglet « Locale »)

Légal

  • `locales/fr/legal.json` — clés `countryOverlays.*`
  • `lib/legal/country-overlay.ts` — résolution overlay par `School.country`
  • `LegalDocumentPage.tsx` — affichage overlay

Tests

  • `src/test/internationalization.test.ts` — utils timezone/currency + comptage namespaces EN/ES + `loadNamespace('en', 'billing.json')`
  • Mocks `getSchoolLocaleContext` dans dashboard, SMS, monthly-report, command-palette, lesson-service

Dette résiduelle connue

  • Planning interne (`lesson/utils.startOfDay`, récurrence, fermetures) : encore en heure serveur locale Node — acceptable tant que écoles FR ; migration progressive si besoin.
  • `lesson/unavailability.ts` récurrence hebdo : `getUTCDay` — à migrer si indispos récurrentes hors FR.
  • UI élève `EleveBookingTab` : jours générés côté client en locale navigateur ; le serveur ancre via `dateKey` (YYYY-MM-DD) en fuseau école.
  • Traductions EN/ES : générées semi-automatiquement — relecture humaine avant mise en prod commerciale hors FR.
Sous-blocContenu
10a`AuthSession.instructorId` ; layout `/moniteur` (rôle MONITEUR + garde instructorId, sinon état « non rattaché ») ; redirection post-login par rôle (MONITEUR `/dashboard` → `/moniteur`) ; coque mobile `MoniteurShell` (barre d'onglets bas) ; `loading`/`error` ; namespace i18n `moniteur`
10bAccueil `/moniteur` : leçons du jour + compléter (+ note ≤500) / no-show + « mes prochains jours » + badge demandes (réel, 11b-2) + bouton incident (placeholder)
11b-2Demandes `/moniteur/demandes` : liste des `BookingRequest` PENDING + accepter (crée la leçon, re-check conflit) / refuser (SMS élève) + badge accueil réel
10cPlanning `/moniteur/planning` : FullCalendar lecture seule (ses leçons), filtre permis, export iCal
10dÉlèves `/moniteur/eleves` : référents (liste) + fiche lecture seule + progression REMC éditable
10eProfil `/moniteur/profil` : infos (lecture) + indispos ponctuelles (vue/ajout/suppr, warn conflit) + iCal + stats du mois

Sécurité (toutes routes `/api/moniteur/*`)

  • `requireAuth` → `checkRateLimit('API_AUTHENTICATED')` → `requireInstructor(session)` (rôle MONITEUR et

`instructorId` non nul, sinon `FORBIDDEN`, renvoie l'`instructorId`).

  • `schoolId` et `instructorId` toujours depuis la session, jamais du body/query. La route planning

ignore tout `instructorId` passé en query (forcé serveur).

  • Leçons : `WHERE instructorId = session.instructorId` ; élèves : `WHERE primaryInstructorId = session.instructorId`.
  • Zéro route de facturation exposée au MONITEUR.

Architecture

shared/schemas/moniteur.ts        Zod CompleteLessonSchema (note ≤ MONITEUR_LESSON_NOTE_MAX_LEN)
lib/auth/helpers.ts               AuthSession.instructorId + requireInstructor(session): string
lib/services/moniteur/
  index.ts        façade d'exports
  header.ts       getMoniteurHeader(userId) → { userName } (coque)
  today.ts        getMoniteurDay(schoolId, instructorId, now?) → { today, upcoming(3j), pendingRequests }
                  (pendingRequests = countPendingBookingRequests, demandes PENDING futures du moniteur)
  booking-requests.ts  list/countPendingBookingRequests + acceptBookingRequest / refuseBookingRequest (11b-2) :
                  accept = $transaction Serializable { findFirst PENDING scopé → createLessonInTx (re-check
                  conflit/ASSR → CONFLICT) → BookingRequest ACCEPTED + lessonId } + audit + SMS bookingConfirmed ;
                  refuse = updateMany gardé PENDING (CONFLICT si count 0) + audit + SMS bookingRefused
  lessons.ts      completeLesson / markNoShow — garde état (CLOSABLE) + verrou optimiste (version) +
                  audit COMPLETE_LESSON / NO_SHOW_LESSON ; no-show → pénalité via resolveCancellationPolicy
                  (usedHours NON touché — différé 13a)
  profile.ts      getMoniteurProfile(schoolId, instructorId) — lecture seule (nom/tel/email/couleur/permis)
  students.ts     listMoniteurStudents / getMoniteurStudentDetail / upsertMoniteurProgress —
                  garde référent (primaryInstructorId) ; aucun champ facturation ni internalNotes
  unavailability.ts  list / create (ponctuelle, récurrente → FORBIDDEN, renvoie conflits) / delete (ponctuelle)
  stats.ts        getMoniteurMonthStats — leçons COMPLETED du mois + taux présence (groupBy, zéro N+1)
lib/services/instructor/ical.ts   getInstructorCalendar réutilisé pour l'iCal moniteur (id = session)

Routes API

POST   /api/moniteur/lessons/[id]/complete     note? ≤500 → COMPLETED + instructorNote + audit
POST   /api/moniteur/lessons/[id]/no-show      → NO_SHOW + pénalité + audit
GET    /api/moniteur/lessons                   liste (planning) — instructorId FORCÉ session
GET    /api/moniteur/ical                       export .ics du planning (session, runtime nodejs)
PUT    /api/moniteur/students/[id]/progress    progression REMC (garde référent)
POST   /api/moniteur/unavailability            ajout ponctuelle (récurrente → 403) + conflits (warn)
DELETE /api/moniteur/unavailability/[id]       suppression ponctuelle scopée
GET    /api/moniteur/booking-requests          liste des demandes PENDING (instructorId FORCÉ session)
POST   /api/moniteur/booking-requests/[id]/accept   crée la leçon (re-check conflit) + ACCEPTED + SMS élève
POST   /api/moniteur/booking-requests/[id]/refuse   passage REFUSED (guard PENDING) + SMS élève

UI

app/[locale]/moniteur/
  layout.tsx            garde rôle/instructorId + MoniteurShell
  loading.tsx error.tsx
  page.tsx              accueil (leçons du jour + prochains jours + badge demandes réel)
  planning/page.tsx     MoniteurPlanning (lecture seule)
  demandes/page.tsx     MoniteurBookingRequests (liste PENDING + accepter/refuser)
  eleves/page.tsx       liste référents
  eleves/[id]/page.tsx  fiche lecture seule + StudentProgressTab (endpoint moniteur)
  profil/page.tsx       infos + stats + iCal + MoniteurUnavailabilities
  incident/[lessonId]/page.tsx   placeholder (UI réelle = Étape 14f)
components/moniteur/
  MoniteurShell.tsx · TodayLessonCard.tsx · MoniteurPlanning.tsx · MoniteurCalendar.tsx (lazy) ·
  MoniteurUnavailabilities.tsx · MoniteurBookingRequests.tsx (11b-2)
components/students/StudentProgressTab.tsx   prop `endpoint` ajoutée (réutilisé côté moniteur)

Concurrence / cohérence

  • `completeLesson`/`markNoShow` : `findFirst` scopé (school+instructor) → `NOT_FOUND` si pas sa leçon ;

garde `status ∈ {PENDING, CONFIRMED}` (idempotence : 2ᵉ tap / annulation concurrente → `CONFLICT`) ;

`updateMany` gardé par `version` (verrou optimiste) → `version++`. `revalidateTag(school:${id}:planning)`.

  • Indispo : création réutilise `instructor/unavailability.createUnavailability` (forcée ponctuelle) ; les

conflits sont calculés et renvoyés pour avertissement (aucune leçon modifiée).

i18n

`locales/fr/moniteur.json` (namespace `moniteur`, chargé dans `i18n/request.ts`). Réutilise les namespaces

existants `lessonType`/`lessonStatus`/`permitTypes` (common) et `pedagogy` (progression). EN/ES → fallback FR.

Hors périmètre (dette / étapes suivantes)

  • `Package.usedHours` : Étape 13a (lien `Lesson↔Package` + recrédit symétrique).
  • Incident : UI réelle Étape 14f (bouton + page placeholder seulement).
  • Fuseau horaire : « aujourd'hui » / mois calculés en heure locale serveur (dette commune planning UTC).
  • Auto-édition profil : non (couleur/téléphone restent GÉRANT, fiche 9a).

Guide interactif post-onboarding (Étape 14k)

Après clôture du wizard, le gérant voit une barre « Premiers pas » sur le dashboard (5 étapes, non bloquante).

Détails complets : `docs/modules/dashboard-guide.md`.

  • Modèle `OnboardingProgress` (1 ligne / école, distinct du composant wizard homonyme).
  • Initialisation à `completeOnboarding` via `ensureDashboardGuide`.
  • Cocher auto : leçon créée, élève ajouté, moniteur invité, tarifs renseignés, 1er SMS envoyé.
  • Dismiss / réouverture header ; confettis à 5/5.

Flux Stripe (zéro débit immédiat)

getOrCreateStripeCustomer → setupIntents.create({usage:'off_session'})  // carte, AUCUN débit
  → Payment Element confirme le SetupIntent
  → customers.update({invoice_settings.default_payment_method})
  → subscriptions.create({ items:[tier, module×qty], trial_period_days:14,
                           default_payment_method, trial_settings.end_behavior.missing_payment_method:'cancel' },
                         { idempotencyKey: School.subscriptionIdempotencyKey })
  → School { stripeSubscriptionId, subscriptionStatus:TRIALING, trialEndsAt, onboardingStep:4 }
  • Jamais de `checkout.sessions` `mode:'payment'` ni `PaymentIntent` à l'inscription.
  • Idempotence : `School.subscriptionIdempotencyKey` (UUID figé) → double-clic / retry = 1 abonnement.
  • 3DS : `confirmSetup({ redirect:'if_required' })` + détection du retour (`redirect_status`/`setup_intent` dans l'URL).

Mapping permis → module (`MODULE_PERMIT_MAP`)

PermisModule facturé
`B`, `AM`base voiture (0 €)
`A`, `A1`, `A2`Module Moto (+29 €)
`BATEAU_COTIER`, `BATEAU_HAUTURIER`Module Bateau (+29 €)

Fichiers

  • Schéma : `School` + `tier`, `billingPeriod`, `onboardingStep`, `onboardingCompletedAt`, `subscriptionIdempotencyKey` ; enums `SubscriptionTier`, `BillingPeriod`.
  • Env : `STRIPE_PRICE_{STARTER,PRO,ECOLE}_{MONTHLY,YEARLY}` + `STRIPE_PRICE_MODULE_{MONTHLY,YEARLY}` (validés `startsWith('price_')`).
  • Zod : `src/shared/schemas/onboarding.ts` (`MarketSchema`, `PermitsSchema`, `SchoolInfoSchema`, `PlanSchema`, `SubscribeSchema`).
  • Prix purs (client-safe) : `src/shared/pricing.ts` (`resolveModules`, `computeDisplayPrice`).
  • Service : `src/lib/services/onboarding.service.ts` (`saveMarket`, `savePermits`, `saveSchoolInfo`, `selectPlan`, `startCardSetup`, `subscribeWithTrial`, `finalizeOnboarding`, `getOnboardingState`).

- `getOnboardingState` retourne `{ step, completedAt, permitTypes, tier, signupPreferredTier, skipTrial, billingModules, hasSubscription, subscriptionStatus }`.

- `saveMarket` : lit `market`/`permitTypes` actuels ; ré-amorce les permis par défaut uniquement si le métier change ou si la sélection est vide (préserve un retour arrière).

- `saveSchoolInfo` : lit `market` en base pour décider du mode Solo (jamais depuis le client) ; rejette `VALIDATION_ERROR` (`onboarding.errors.schoolNameMissing`) si école non-Solo sans nom.

- `subscribeWithTrial` / `finalizeOnboarding` : garde d'ordre — rejettent `CONFLICT` (`onboarding.errors.permitTypesMissing`) si `permitTypes` est vide (étape 2 sautée).

- `finalizeOnboarding` : anti-bypass — rejette `CONFLICT` (`onboarding.errors.subscriptionRequired`) si `stripeSubscriptionId` absent ; idempotent (retour anticipé si `onboardingCompletedAt` défini) ; crée le moniteur Solo (`ensureSoloInstructor`), appelle `fireEnsureGuide` (14k) + automation `ONBOARDING_COMPLETED`. Orchestré par la route carte après `subscribeWithTrial`.

  • Stripe lib : `createSetupIntent`, `createTrialSubscription`, `getSetupIntentPaymentMethod`.
  • Routes : `/api/onboarding/{market,permits,school-info,plan,setup-intent,subscribe}` (RBAC : rate limit → auth → GERANT → Zod → service ; `subscribe` + `requireAAL2`, puis `subscribeWithTrial` → `finalizeOnboarding`).
  • Webhook : `/api/webhooks/stripe` — `customer.subscription.{created,updated,deleted,trial_will_end}` + `invoice.payment_failed`. Dedup `StripeEvent` créé après traitement réussi ; échec de traitement → 5xx (Stripe retente, zéro event perdu).
  • UI : `app/[locale]/onboarding/{layout,etape-1..5}` + `components/onboarding/{OnboardingProgress,MarketStep,PermitsStep,PermitCardSelector,SchoolInfoForm,PlanSelector,SoloPlanSelector,CardSetup}` ; 14k `components/dashboard/{DashboardGuideFloat,GuideConfetti}` + routes `/api/dashboard/guide/*`.
  • Réabonnement : `app/[locale]/reabonnement/page.tsx` (accès suspendu si `CANCELLED`) + `components/auth/LogoutButton.tsx`.
  • i18n : `src/locales/fr/onboarding.json`, `src/locales/fr/billing.json` (`reactivation.*`).

Gating (Server Components, PAS le proxy)

  • `dashboard/layout.tsx` : `onboardingCompletedAt` nul → `redirect('/onboarding/etape-{step}')` (clamp 1..5) ; sinon si `subscriptionStatus === 'CANCELLED'` → `redirect('/reabonnement')` (page hors segment `/dashboard` pour éviter la boucle). `PAST_DUE` reste accessible (bandeau d'avertissement = Étape Facturation).
  • `onboarding/layout.tsx` : terminé → `redirect('/dashboard')`.
  • Raison : doc Next 16 → proxy = optimistic checks (cookie), zéro requête DB (exécuté sur chaque route + prefetch).
  • Réactivation : la page `/reabonnement` informe + déconnecte uniquement pour l'instant ; le flux complet (relance SetupIntent + nouvel abonnement) est reporté à l'Étape Facturation.

Sécurité

  • `schoolId` toujours depuis la session ; modules/prix calculés serveur (le client n'envoie que `tier`/`period`/`setupIntentId`).
  • Montant facturé = Price ID Stripe (jamais un montant client).
  • `server-only` sur service + stripe lib ; publishable key seule côté client.
  • `revalidatePath` après chaque mutation.

Emails

Confirmation / reset partent via Resend (SMTP custom Supabase) — aucun changement de code, redirect URLs (`…/api/auth/callback`) inchangées.

Tests

`src/test/onboarding-pricing.test.ts` (resolveModules, computeDisplayPrice) ;

`src/test/onboarding-service.test.ts` (saveMarket : tier SOLO/TRANSPORT + permis par défaut + préservation au revisit ; savePermits ; saveSchoolInfo : lecture métier/permis en base + garde nom école ; idempotence subscribe, isolation schoolId serveur, quantité modules, getOnboardingState ; finalizeOnboarding : clôture, anti-bypass abonnement, garde d'ordre permitTypes, idempotence) ;

`src/test/solo-tier.test.ts` (`finalizeOnboarding` Solo : moniteur auto-créé + lien `User.instructorId`) ;

`src/test/security-aal2.test.ts` (AAL2 avant `subscribeWithTrial`/`finalizeOnboarding`) ;

`src/test/dashboard-guide.test.ts` (14k : markStep, dismiss/reopen, stats admin, isolation schoolId).

Prérequis / limites connues

  • Remplacer les placeholders `STRIPE_PRICE_*` par les vrais Price IDs créés dans Stripe pour tester de bout en bout.
  • Le setup moniteur/véhicule est sorti du wizard (Phase 2) → géré par le guide post-login + modules dédiés.
  • Solo : le profil moniteur est créé automatiquement à la clôture (`ensureSoloInstructor`).
  • Email si `aacParentEmail` renseigné : `sendParentAccessEmail` (`lib/email/parent-link.ts`, template

`PARENT_ACCESS` dans `EMAIL_TEMPLATES_CONFIG`). Échec email non bloquant (`.catch`).

  • Audit `SEND_PARENT_LINK`.
  • Route gérant : `POST /api/students/[id]/parent-link` (auth → rate `API_AUTHENTICATED` → `GERANT`).
  • UI : bouton « Lien parent AAC » dans `StudentShareActions` (visible si `isAAC`).

Self-service renouvellement (parent)

`requestParentPortalAccess(phone, locale)` (`parent/access.ts`) :

  • Recherche `Student` actif (`deletedAt: null`, `isActive: true`) avec `isAAC` et `aacParentPhone` =

numéro normalisé (`PortalAccessSchema`). Aucune correspondance → silence (anti-énumération).

  • Si trouvé → `createParentLink(schoolId, studentId, null, locale)` (audit `userId: null`).
  • Route publique : `POST /api/portal/parent-access` — rate limit IP `STUDENT_LINK_REQUEST` +

plafond téléphone Redis `portal-parent-access:phone` (`tryConsumeParentPortalAccessPhoneLimit`).

  • UI : `/parent/acces` (`ParentAccessForm`) ; lien expiré `/parent/[token]` → CTA « Redemander un accès ».
  • Carnet de bord : `POST/GET /api/parent/[token]/aac-trips` — déclaration trajet (`declaredByName` requis). Voir `docs/modules/aac-logbook.md`.

Vue parent (lecture seule + carnet)

`getParentSpace(token): ParentSpace` (`parent/space.ts`) :

  • `verifyStudentLink` → garde `type === 'PARENT'` (sinon `UNAUTHORIZED` ; jamais via `SPACE_LINK_TYPES`

donc aucune mutation possible).

  • Élève scopé `{ id, schoolId, deletedAt: null }` (sinon `NOT_FOUND`).
  • Réutilise `getEleveLessons` + `getEleveProgress` (zéro duplication) :

- heures (achetées/effectuées/restantes) + REMC (C1–C4),

- prochaine leçon = `upcoming[0]`,

- prochain RDV pédagogique AAC = première `upcoming` de type `RDV_PEDAGOGIQUE` (statut affiché),

- prochain examen = `nextExam` depuis `getEleveProgress`,

- 5 dernières leçons = `history.slice(0, PARENT_HISTORY_LIMIT)` (date, durée, note moniteur).

  • Prénom élève uniquement (`studentFirstName`) — pas de nom de famille ni données sensibles.
  • Page RSC `app/[locale]/parent/[token]/page.tsx` → `ParentSpace` ; lien invalide → `parent.invalid`.

Périmètre strict : jamais de facturation, NEPH, note interne, document ni action.

Contrats

FonctionEntréeSortieNotes
`getParentSpace``token: string``ParentSpace`garde `type==='PARENT'` ; `UNAUTHORIZED`/`NOT_FOUND`
`createParentLink``schoolId, studentId, actorUserId, locale?``{ url, expiresAt }`éligibilité `isAAC`+`aacParentPhone` ; SMS parent ; audit
`requestParentPortalAccess``phone, locale?``void`self-service ; silence si inconnu ; délègue `createParentLink`

Fichiers

  • Service : `lib/services/parent/{types,space,link,access,portal-access-rate-limit,index}.ts` (tous ≤150 l.).
  • Auth : `lib/auth/student-link.ts` (`StudentLinkType` += `'PARENT'`).
  • Route : `app/api/students/[id]/parent-link/route.ts` ; self-service `app/api/portal/parent-access/route.ts`.
  • UI : `app/[locale]/parent/[token]/page.tsx`, `app/[locale]/parent/acces/page.tsx`,

`components/parent/{ParentSpace,ParentAccessForm}.tsx` (RSC/form, ≤250 l.),

bouton dans `components/students/StudentShareActions.tsx`.

  • i18n : namespace `parent` (`locales/fr/parent.json`, chargé dans `i18n/request.ts`) ;

`students.share.parentLink*` + `students.errors.parentLinkUnavailable` ; REMC via `pedagogy.*`,

types de leçon via `lessonType.*`. SMS `student.parentAccess` (`SMS_TEMPLATE_KEYS.PARENT_ACCESS`).

  • Constantes : `PARENT_HISTORY_LIMIT` (5).

Isolation & sécurité

  • Lectures scopées `{ studentId, schoolId }` du JWT — aucune donnée d'un autre établissement.
  • Un token PORTAL/ONBOARDING ne donne pas accès à `/parent` (garde `type==='PARENT'`).
  • Aucune PII en logs (audit = IDs/codes uniquement).

Tests

  • `src/test/parent-service.test.ts` (11) : composition (prochaine leçon, prochain RDV `RDV_PEDAGOGIQUE`,

prochain examen, historique borné à 5), périmètre whitelist (pas facturation/NEPH), token expiré,

isolation `schoolId`, refus lien non-`PARENT`, `createParentLink` (SMS `studentId: null`, email si

`aacParentEmail`, audit `SEND_PARENT_LINK`, gardes `isAAC`/`aacParentPhone` → `CONFLICT`, `NOT_FOUND`).

  • `src/test/parent-access-{route,service,phone-rate-limit}.test.ts` (10) : self-service (numéro valide/inconnu,

rate limit IP/téléphone), CTA lien expiré → `/parent/acces`.

  • `lessonCategoryColor(permitType)` — couleur événement FullCalendar
  • REMC adapté

    Libellés C1–C4 par catégorie dans `locales/fr/pedagogy.json` → `competenciesByCategory.{VOITURE|MOTO|BATEAU}`. Composants : `StudentProgressTab`, `EleveProgressTab`, `ParentSpace` (prop `permitType`).

    Tarifs école

    • Onboarding étape 1 : `saveSchoolInfo` initialise `defaultHourlyRates` via `ratesForPermitTypes`
    • Paramètres → onglet Tarifs : affiche les tarifs fusionnés (défaut + overrides gérant)

    Tests

    `src/test/permits.test.ts` — catégories, couleurs, tarifs par défaut.

    Migration

    npx prisma migrate dev --name add_permit_types_b96_be
  • Sélecteurs moniteur et véhicule (filtrés par l'onglet actif).
  • Couleurs d'événement par catégorie de permis (`lessonCategoryColor`) : emerald voiture, amber moto, blue bateau. Légende statuts conservée via `LESSON_STATUS_COLOR` (référence).
  • Périmètre 5b (création / déplacement / annulation)

    • Création : bouton « Nouvelle leçon » + clic/sélection d'un créneau → `LessonModal` (élève recherché,

    moniteur/véhicule filtrés par type de permis, type de leçon, date/heure pré-remplie, durée = `school.defaultLessonDuration`, lieu optionnel).

    • Drag & drop / resize : `editable`/`selectable=true` ; `eventDrop`/`eventResize` → `PATCH /api/lessons/[id]`

    avec verrou optimiste (`version`). Version périmée → toast « modifié entre-temps » + rechargement (revert de l'event).

    • Annulation : clic sur une leçon → détail → `DELETE /api/lessons/[id]` (soft cancel `CANCELLED_SCHOOL`, motif sanitizé).
    • Buffer : bloc gris `display:'background'` non-cliquable de `school.instructorBuffer` minutes après chaque leçon active.
    • Détection de conflits (dans une transaction Serializable, avant écriture) :

    - moniteur déjà occupé (chevauchement étendu du buffer) → `CONFLICT planning.errors.instructorBusy` ;

    - véhicule déjà occupé (chevauchement strict) → `CONFLICT planning.errors.vehicleBusy`.

    - Fenêtre de candidats bornée par `LESSON_MAX_DURATION_MIN` (aucune leçon plus longue ne peut chevaucher).

    • Limite journalière : `dailyLimitExceeded` renvoyé par le service → alerte (toast, non bloquant) si la charge du moniteur dépasse `school.maxDailyInstructorHours`.
    • Isolation : `schoolId` session + ownership (`instructor`/`student`/`vehicle` re-vérifiés `findFirst {id, schoolId}`) ; chaque `updateMany` re-scope `schoolId`.

    Périmètre 5c (récurrence / remplacement / fermeture / politique d'annulation)

    • Récurrence : toggle « Répéter » dans `LessonModal` → fréquence (hebdo/bi-hebdo) + fin par **nombre

    d'occurrences** *ou* date (XOR). `POST /api/lessons/recurring` → `createRecurringLessons` génère chaque

    occurrence dans une transaction Serializable, partage un `seriesId`, détecte les conflits par occurrence

    (les conflictuelles ne sont pas créées et remontées dans `conflicts`), et saute les jours fermés

    (`ClosureDate`) et fériés (`school.publicHolidays`) → `skipped`. Plafond dur `RECURRENCE_MAX_OCCURRENCES`.

    • Remplacement moniteur : `GET /api/instructors/[id]/replace?date=` → `getReplacementOptions` (leçons du jour du

    moniteur + remplaçants éligibles (permis), libres (`findConflict`) et disponibles (`Unavailability`)).

    `POST` → `replaceInstructor` réassigne par créneau (verrou optimiste `version`, conflit re-vérifié) et **enfile

    un SMS** par élève. Succès partiel possible (`reassigned` / `failed`).

    • Fermeture exceptionnelle : `POST /api/closures` → `createClosure` crée la `ClosureDate` et bascule toutes les

    leçons actives du jour en `TO_RESCHEDULE` (initié par l'école → aucune pénalité), + SMS par élève.

    • Politique crédit/pénalité (`resolveCancellationPolicy`, source unique dans `lesson.service`) :

    - `SCHOOL` → heure recréditée, aucune pénalité ;

    - `STUDENT` hors délai (`< cancellationDeadlineHours`) → pénalité = `cancellationPenaltyHours` ;

    - `STUDENT` dans les délais → aucune pénalité.

    La politique fixe `status`/`penaltyApplied`/`penaltyHours` sur la leçon. **Le décompte réel de

    `Package.usedHours` est livré en 13a** (cf. section suivante).

    • SMS : en 5c on enfile seulement (`SmsLog` `status=QUEUED`, `templateKey` + `lessonId`, `content` vide).

    L'envoi réel (file + retry + rendu du template) est le module SMS (Étape 7) ; son dispatcher rendra le

    contenu depuis `templateKey` + `lessonId`.

    Suivi des heures consommées — pont Lesson ↔ Package (13a)

    > Dette critique résolue : sans ce lien, les heures restantes affichées (fiche élève, espace élève,

    > stats moniteur) étaient fausses. `Lesson.packageId` (`String?`, `onDelete: SetNull`, `@@index`) rattache

    > chaque leçon à un forfait. Source unique de vérité : `src/lib/services/lesson/package-hours.ts`.

    • Auto-association à la création : `createLesson` + `createRecurringLessons` résolvent `findActivePackageId`

    (forfait le plus récent non clôturé / non supprimé de l'élève pour ce type de permis) et posent

    `packageId` à l'insertion. Aucun forfait → `packageId` reste `null` (aucune erreur). **Les heures ne sont

    jamais consommées à la création**, uniquement au passage à un état terminal consommateur.

    • Consommation `usedHours` (toujours dans la même transaction que l'écriture de la leçon, via

    `adjustPackageHours`, clamp ≥ 0, warn si dépassement de `totalHours`) :

    - leçon → COMPLETED (moniteur) : `usedHours += durationMinutes / 60` ;

    - NO_SHOW avec pénalité : `usedHours += penaltyHours` ;

    - annulation élève hors délai (`penaltyApplied`) : `usedHours += penaltyHours` ;

    - annulation école : forfait inchangé.

    • Réassignation manuelle (gérant) : `setLessonPackage(schoolId, lessonId, packageId|null)` (tx Serializable) —

    le nouveau forfait doit appartenir à la même école + même élève (isolation). Les heures déjà consommées

    par la leçon (`lessonConsumedHours` : durée si COMPLETED, sinon heures de pénalité) sont transférées de

    l'ancien vers le nouveau forfait (`-consumed` / `+consumed`, jamais sous 0). Route `PATCH /api/lessons/[id]/package`

    (GERANT) ; UI = sélecteur de forfait dans le tiroir `LessonDetail` (forfaits de l'élève chargés à l'ouverture).

    • Invalidation cache : tout flux modifiant `usedHours` appelle `revalidateBilling(schoolId, studentId)` en plus

    de `revalidatePlanning` → heures restantes à jour sur la fiche élève / espace élève.

    Données & pagination

    • `GET /api/lessons` — lecture des leçons d'une fenêtre `[from, to[`.

    - RBAC : `rate limit (API_AUTHENTICATED) → requireAuth → requireRole(['GERANT','COMPTABLE']) → Zod → service`.

    - `schoolId` depuis la session (jamais des query params).

    - Filtres optionnels : `permitType` (répétable), `instructorId`, `vehicleId`.

    - Pagination cursor (jamais offset) : `orderBy [scheduledAt asc, id asc]`, `cursor={id}` + `skip:1`, lit `take+1` pour déterminer `nextCursor`.

    - Réponse : `{ data: { lessons: PlanningLesson[], nextCursor: string|null } }`.

    • Le client (`CalendarView`) appelle l'API à chaque changement de fenêtre visible et suit le cursor en boucle jusqu'à épuisement avant de rendre les événements.

    Fichiers

    • Service : `src/lib/services/lesson.service.ts`

    - `listLessons(schoolId, query)` → `{ lessons, nextCursor }` (scopé `schoolId`).

    - `getPlanningContext(schoolId)` → `{ permitTypes, instructors[], vehicles[], students[], defaultDuration, bufferMin }` (actifs / non supprimés) pour onglets + sélecteurs + modal.

    - `createLesson(schoolId, input)` / `rescheduleLesson(schoolId, id, input)` → `{ lesson, dailyLimitExceeded }` (transaction Serializable + conflits).

    - `cancelLesson(schoolId, id, { cancelledBy?, reason? })` → soft cancel (transaction : lit la leçon scopée + applique `resolveCancellationPolicy`).

    - 5c : `createRecurringLessons` → `{ seriesId, created[], conflicts[], skipped[] }` ; `getReplacementOptions` → `ReplacementSlot[]` ; `replaceInstructor` → `{ reassigned[], failed[] }` ; `createClosure` → `{ affected }` ; `resolveCancellationPolicy` (exportée, pure, testée).

    - 13a : `setLessonPackage(schoolId, id, packageId|null)` (réassignation forfait + transfert d'heures) ; helper `lesson/package-hours.ts` (`findActivePackageId`, `adjustPackageHours`, `lessonConsumedHours`, `hoursFromMinutes`).

    - Helpers internes : `findConflict` (moniteur+buffer / véhicule, lecture seule) → `assertNoConflict` (lève) ; `isDailyLimitExceeded` ; `enqueueSms` (`SmsLog` QUEUED). `revalidatePlanning` + `revalidateBilling` (13a) après mutation.

    • Zod : `src/shared/schemas/lesson.ts` — `LessonsQuerySchema`, `CreateLessonSchema`, `RescheduleLessonSchema`, `LESSON_TYPES`, 5c `RecurrenceSchema` (count XOR until), `CreateRecurringLessonsSchema`, `ReplaceOptionsQuerySchema`, `ReplaceInstructorSchema`, `CreateClosureSchema`, 13a `SetLessonPackageSchema` (`packageId` uuid nullable).
    • Routes : `src/app/api/lessons/route.ts` (GET GERANT/COMPTABLE, POST GERANT) + `src/app/api/lessons/[id]/route.ts` (PATCH / DELETE — GERANT) + 13a `src/app/api/lessons/[id]/package/route.ts` (PATCH GERANT) + 5c `src/app/api/lessons/recurring/route.ts` (POST), `src/app/api/instructors/[id]/replace/route.ts` (GET/POST), `src/app/api/closures/route.ts` (POST) — tous GERANT.
    • Couleurs (client-safe, pas `server-only`) : `src/lib/config/module-colors.ts`

    - `PERMIT_CATEGORY`, `CATEGORY_ACCENT`, `LESSON_STATUS_COLOR` (bg + texte contrasté), helpers `categoriesFromPermitTypes` / `permitTypesInCategory`.

    - Les hex des statuts dupliquent volontairement les tokens `@theme` de `globals.css` (FullCalendar exige des hex JS, pas une variable CSS).

    • UI : `PlanningBoard.tsx` (onglets/filtres/légende, drag-reschedule, toast, boutons remplacement/fermeture) + `CalendarView.tsx` (wrapper FullCalendar, lazy) + `LessonModal.tsx` (création + toggle récurrence — remonté via `key`) + 13a `LessonDetail.tsx` (tiroir détail extrait : annulation + sélecteur de forfait) + 5c `ReplaceInstructorModal.tsx` + `ClosureModal.tsx`.
    • Page/segment : `src/app/[locale]/dashboard/planning/{page,loading,error}.tsx`.
    • i18n : `src/locales/fr/planning.json` (namespace `planning` : `modal`/`detail`/`actions`/`errors`/`warnings`/`success`/`validation` + 5c `recurrence`/`replace`/`closure`) ; statuts via `lessonStatus` (dont `TO_RESCHEDULE`), types via `lessonType` (`common.json`).
    • Constantes : `PLANNING_PAGE_SIZE`, `PLANNING_MAX_PAGE_SIZE`, `PLANNING_DAY_START_TIME`, `PLANNING_DAY_END_TIME`, `LESSON_MIN_DURATION_MIN`, `LESSON_MAX_DURATION_MIN`, 5c `RECURRENCE_MAX_OCCURRENCES`, `SMS_TEMPLATE_KEYS`, 13a `MINUTES_PER_HOUR` (`shared/constants.ts`).
    • Schéma : migration `…_planning_recurrence_closure` — `LessonStatus.TO_RESCHEDULE` + `Lesson.seriesId` (+ index `[schoolId, seriesId]`) ; 13a migration `…_lesson_package_hours` — `Lesson.packageId` (`onDelete: SetNull`) + `@@index([packageId])` + relation `Package.lessons`.

    Catégories ↔ permis (onglets / accents)

    CatégoriePermisAccent
    Voiture`B`emerald `#0F6E56`
    Moto`A1`, `A2`, `A`, `AM`amber `#D97706`
    Bateau`BATEAU_COTIER`, `BATEAU_HAUTURIER`sky `#0284C7`

    > Note : `AM` est facturé dans la base voiture (`MODULE_PERMIT_MAP`) mais regroupé visuellement sous Moto dans le planning (deux-roues). Le mapping *facturation* (`shared/constants.ts`) et le mapping *visuel* (`module-colors.ts`) sont deux préoccupations distinctes.

    Couleurs de statut (légende)

    StatutFondTexte
    `PENDING``#93C5FD``#1E3A8A`
    `CONFIRMED``#6EE7B7``#065F46`
    `COMPLETED``#059669``#FFFFFF`
    `CANCELLED_STUDENT``#FCD34D``#78350F`
    `CANCELLED_SCHOOL``#D1D5DB``#374151`
    `NO_SHOW``#F87171``#7F1D1D`
    `TO_RESCHEDULE``#C4B5FD``#4C1D95`

    Tests

    `src/test/lesson-service.test.ts` (27) : isolation `schoolId` (lecture + mutations + ownership), pagination cursor

    (`take+1`, `nextCursor`, `cursor`+`skip:1`, jamais d'offset), `version` exposée, conflit moniteur, buffer

    (leçon finissant juste avant → bloque), conflit véhicule, ownership cross-school (NOT_FOUND), alerte limite

    journalière, verrou optimiste (`updateMany count===0 → CONFLICT`), soft cancel (statut/`cancelledBy`/sanitize motif).

    5c : `resolveCancellationPolicy` (3 branches SCHOOL / STUDENT hors-délai / STUDENT dans-les-délais), récurrence

    (série + `seriesId` commun, conflit par occurrence créées partiellement, saut fermeture+férié), remplacement

    (réassignation + SMS enfilé + scope `schoolId`, échec si remplaçant occupé → aucun SMS), fermeture (`TO_RESCHEDULE`

    sans pénalité + SMS par élève), options de remplacement (éligibilité permis + disponibilité + isolation).

    13a : auto-association à la création (forfait actif scopé / `null` si aucun), annulation élève hors délai

    → `+penaltyHours`, annulation école → forfait inchangé. `src/test/lesson-package-hours.test.ts` (16) :

    `hoursFromMinutes`/`lessonConsumedHours` (purs), `findActivePackageId` (scope+ordre+null), `adjustPackageHours`

    (delta exact, clamp ≥ 0, warn dépassement, no-op delta 0 / forfait absent), `setLessonPackage` (transfert

    ancien→nouveau, détachement clampé, isolation élève NOT_FOUND, no-op inchangé). `src/test/moniteur-service.test.ts` :

    COMPLETED → `usedHours` incrémenté de la durée exacte, leçon sans forfait → aucun ajustement, no-show → pénalité décomptée.

    Dette technique connue

    • Audit SHA-256 (`.cursorrules §8`) : non posé sur les écritures de leçon. Aucun pattern d'audit n'existe

    encore dans le codebase (auth/abonnement n'auditent pas non plus) → à traiter comme module transverse uniforme,

    pas un one-off planning.

    • Liste d'élèves chargée entièrement dans `getPlanningContext` pour la recherche du modal (acceptable MVP).

    À remplacer par un endpoint de recherche dédié au module Élèves (Étape 6).

    • SMS différé (Étape 7) : 5c enfile dans `SmsLog` (QUEUED) sans envoyer. Le dispatcher (file + retry +

    rendu template) reste à construire ; il rendra `content` depuis `templateKey` + `lessonId`.

    • ~~Crédit/débit forfait différé~~ : RÉSOLU en 13a — `Lesson.packageId` + `package-hours.ts` (auto-association

    + consommation `usedHours` à COMPLETED/pénalité + réassignation manuelle). Reste mineur : `adjustPackageHours` fait un

    read-modify-write de `usedHours` dans la transaction (les flux moniteur ne sont pas Serializable) — deux complétions

    simultanées sur le même forfait pourraient se télescoper (cas extrême : un seul élève, complétions séquentielles).

    • `getReplacementOptions` : vérifie conflit + indisponibilité par candidat × créneau (N×M requêtes en lecture).

    Acceptable à l'échelle d'une journée ; à optimiser si besoin.

    À venir

    • Étape 6 — Élèves : fiches, NEPH, documents, recherche dédiée.
  • IA : `ai:tokens:{schoolId}:{YYYY-MM}` (+ `ai:tokens:day:{schoolId}:{YYYY-MM-DD}`)
  • SMS : `sms:count:{schoolId}:{YYYY-MM-DD}` (+ `sms:count:{schoolId}:{YYYY-MM}`)
  • Email : `email:count:{schoolId}:{YYYY-MM}`
  • Storage : pas de compteur — mesure directe via `supabaseAdmin.storage.list()` (parcours borné).
  • Services

    FichierFonctionsPoint d'application
    `src/lib/redis.ts``getRedis()` (singleton Upstash partagé)rate-limit + quotas
    `src/lib/ai/quota.ts``checkAndIncrementAiQuota`, `getAiQuotaUsage`, `estimateAiTokens``completeText({ schoolId })` (anthropic.ts)
    `src/lib/sms/quota.ts``checkAndIncrementSmsQuota`, `getSmsQuotaUsage``sendSms` (dispatch.ts) AVANT OVH
    `src/lib/storage/quota.ts``checkStorageQuota`, `getStorageUsageMb`route upload documents élève
    `src/lib/email/quota.ts``checkAndIncrementEmailQuota`, `getEmailQuotaUsage``sendEmail({ schoolId })` AVANT Resend
    `src/lib/services/admin/costs/``getCostsOverview`, `buildCostsExportCsv`page `/stravoda-admin/costs`

    Codes d'erreur (`src/shared/errors.ts`) — tous mappés HTTP 429

    `QUOTA_EXCEEDED` (IA), `SMS_QUOTA_EXCEEDED`, `STORAGE_QUOTA_EXCEEDED`, `EMAIL_QUOTA_EXCEEDED`

    (clés i18n : `errors.aiQuotaExceeded`, `errors.smsQuotaExceeded`, `errors.storageQuotaExceeded`,

    `errors.emailQuotaExceeded`).

    Routes & UI

    • `GET /api/admin/costs/overview` (ADMIN) → vue coûts par école + totaux + coûts estimés.
    • `GET /api/admin/costs/export` (ADMIN) → export CSV (usage + overage SMS + coût estimé par école).
    • Page `/stravoda-admin/costs` (ADMIN) : tableau par école (badge rouge > 90 % IA/SMS, lien fiche école, colonne overage si > 0) + coûts fournisseurs incl. SMS hors forfait ; bandeau si Redis indisponible.
    • Dashboard gérant : carte « IA — X / 100 000 tokens » (barre vert → orange → rouge).

    Décisions clés

    • Vérifier AVANT d'incrémenter : aucun dépassement silencieux ; aucune incrémentation si le crédit

    restant est insuffisant.

    • SMS compté une seule fois par message (1ʳᵉ tentative, `retryCount === 0`) — une relance réutilise

    le crédit déjà décompté.

    • Alertes (80 % IA/email/SMS, 90 % storage) tracées en AuditLog (`*_QUOTA_WARNING` / `*_QUOTA_EXCEEDED`),

    jamais d'email récursif depuis les chemins SMS/email.

    • `schoolId` optionnel sur `completeText` / `sendEmail` : les chemins sans école (agent commercial

    public, emails système/admin) ne décomptent pas.

    Tests — `src/test/quotas.test.ts`

    IA mensuel/journalier dépassé → `QUOTA_EXCEEDED` + audit ; SMS journalier (100) / mensuel (500) bloqués ;

    storage > 500 Mo bloqué ; email > 1000 bloqué ; franchissement 80 % → `*_QUOTA_WARNING`.

    Multi-sites (14r-9 Phase 2)

    Visible si le groupe compte ≥2 établissements.

    • `lib/services/school/queries/group-sites.ts` — `listGroupSites(rootSchoolId)`
    • `lib/services/reports/monthly/multisite-aggregate.ts` — `aggregateMultiSiteReport` (synthèse + comparatif)
    • `lib/services/reports/monthly/multisite-export.ts` — `buildConsolidatedMultiSitePdf` + `buildPerSiteReportsZip`
    • `lib/pdf/monthly-multisite-report/` — PDF consolidé (couverture, pages site, comparatif)
    RouteAuthDescription
    `GET /api/reports/monthly/[period]/consolidated`GERANT, PDF_EXPORTPDF consolidé multi-sites
    `GET /api/reports/monthly/[period]/per-site`GERANT, PDF_EXPORTZIP (1 PDF mensuel par site)

    UI : section multi-sites dans `ReportsView` (`/dashboard/rapports`).

    Tests : `src/test/multisite-report.test.ts`.

    Rôle
    GET`/api/school/google-connect`GERANT — démarre OAuth
    GET`/api/school/google-connect/callback`GERANT — stocke tokens chiffrés
    GET`/api/reviews`GERANT
    POST`/api/reviews/[id]/approve`GERANT
    POST`/api/reviews/[id]/regenerate`GERANT
    POST`/api/reviews/[id]/ignore`GERANT
    GET`/api/cron/sync-reviews`CRON_SECRET
    GET`/api/cron/post-reviews`CRON_SECRET

    Isolation

    Toute requête `GoogleReview` filtre `schoolId` depuis la session.

    • Archivage école : nouvel enum `SchoolStatus (ACTIVE|ARCHIVED)` + `archivedAt` + `archivedBy`

    sur `School` (distinct de `deletedAt` qui reste le soft-delete). L'école archivée n'est pas

    supprimée (conservation 5 ans, obligations comptables).

    • ZIP : `jszip` → ZIP non chiffré stocké dans le bucket privé Supabase + URL signée courte durée

    (cohérent avec `student-documents`). Pas de chiffrement par mot de passe.

    • DPA : flux autonome (model + PDF + action « envoyer pour signature » réutilisant YouSign),

    déclenchable côté admin/gérant. Pas de câblage dans l'onboarding pour l'instant.

    • Rôles : archivage école + page preuves = `ADMIN` ; renvoi DPA = `SUPPORT+`.

    Sous-étapes (committables)

    > État : 14z-4 COMPLET — 14z-4a ✅ · 14z-4b ✅ · 14z-4c ✅ · 14z-4d ✅ · 14z-4e ✅.

    14z-4a — Consentement élève traçable

    • Migration : `Student.smsConsentIp String?`, `Student.marketingConsentIp String?`.
    • Capture l'IP (via `resolveClientIp`) au moment du consentement : `savePortalStudent`,

    `registerReferral`.

    • Export PDF `GET /api/exports/consents` (GERANT, rate `PDF_EXPORT`) : une ligne par consentement

    accordé — Prénom (anonymisé si `deletedAt`) · Date · IP · Canal (SMS/Marketing).

    • Audit `EXPORT_CONSENTS`.

    14z-4b — DPA (Data Processing Agreement)

    • Migration : model `DataProcessingAgreement` (`schoolId @unique`, `documentId?`, `status`,

    `signedAt?`, `signatureHash?`).

    • PDF `lib/pdf/dpa.tsx` (parties, objet, durée, nature des données, finalités, mesures de sécurité,

    sous-traitants `LEGAL_SUBPROCESSOR_NAMES`, droits, obligations).

    • Service `lib/services/dpa/*` : `generateDpa`, `sendDpaForSignature` (YouSign), `getDpaStatus`.
    • Routes : génération/signature + statut. Badge admin sur `ecoles/[id]`.

    14z-4c — Registre des traitements (Art. 30)

    • Données statiques (Stravoda responsable de traitement) en `lib/config/processing-register.ts` (6 traitements : élèves, facturation, SMS/emails, documents signés, monitoring Sentry, assistance IA Anthropic ; Resend cité dans facturation et communications ; Upstash hors registre — commentaire).
    • Page `/stravoda-admin/rgpd/registre` (SUPPORT). Export PDF + CSV. `error.tsx`.

    14z-4d — Effacement école (résiliation)

    • Migration : `SchoolStatus` + `School.archivedAt` + `School.archivedBy`.
    • `archiveSchool` (ADMIN, 3 étapes : vérification → anonymisation immédiate élèves → archivage),

    ZIP export final (JSON + CSV paiements + PDF historique agrégé) email gérant + bucket.

    • Filtre par défaut : masquer les écoles archivées des listes admin. Audit `SCHOOL_ARCHIVED`.

    14z-4e — Preuves légales centralisées

    • Page `/stravoda-admin/ecoles/[id]/preuves` (ADMIN) : sections RGPD / Facturation / Audit /

    Incidents. Export ZIP dossier complet (DPA signé · AuditLog CSV · Factures · Consentements).

    Critères d'acceptation

    • Isolation : tout service école scoppé `schoolId`. Admin cross-tenant via `requireStravodaAdmin`.
    • Résiliation école → ZIP généré + `School.status = ARCHIVED`.
    • DPA généré + signé → `status = VALIDE`.
    • Registre → PDF/CSV exportables.
    • Preuves → toutes sections remplies.
    • Consentement élève → IP loggée.
    • `tsc` 0 · `eslint` 0 · suite verte avant commit.
    • `isEmailSuspicious` / `countSimilarEmails` : match normalisé exact OU score > seuil.

    Anti-abus à l'inscription (`lib/security/signup-guard.ts`)

    `enforceSignupAntiAbuse(req, email)` appelé dans `POST /api/auth/signup` AVANT `signUp` (Supabase) :

    1. IP bloquée (`isIpBlocked`) → `403`.

    2. Email bloqué (exact normalisé ou pattern, `isEmailBlocked`) → `403`.

    3. Essai déjà utilisé (même `emailHash`) → `SecurityEvent(TRIAL_ABUSE_ATTEMPT, HIGH)` + `409`.

    4. Rate limit `SIGNUP_DAILY` (3 / IP / 24h) → `SecurityEvent(RATE_LIMIT_EXCEEDED, HIGH)` + `429`.

    5. Similarité d'email (scan des 100 derniers + volume par IP) → flag non bloquant +

    `SecurityEvent(EMAIL_SIMILARITY_DETECTED, MEDIUM)`, puis création du `TrialUsage`.

    > Décision : le blocage dur se fait sur l'email NORMALISÉ (réutilisation déterministe), pas sur

    > l'IP nue (NAT / réseaux d'entreprise → faux positifs massifs). L'IP est couverte par le rate-limit + le

    > scoring de similarité (flag), jamais par un blocage automatique à l'égalité d'IP.

    Blocage IP (Edge + serveur)

    • Edge (`middleware.ts` → `lib/security/ip-blocklist-edge.ts`) : lecture Redis (Upstash REST,

    compatible Edge) avant tout routing. Bloqué → `403` JSON. Fail-open (panne Redis ≠ blocage).

    • Serveur (`lib/security/ip-check.ts`, server-only) : `isIpBlocked` (cache Redis positif/négatif +

    fallback DB `BlockedEntity` puis re-cache) ; `assertIpAllowed(req)` pour les routes `/api` sensibles

    (le matcher middleware exclut `/api`). Clé partagée Edge↔serveur : `lib/security/redis-keys.ts`.

    Actions équipe (`lib/security/actions.ts`)

    `blockIp` / `blockEmail` / `blockPattern` / `banUser` / `unblock` — toutes auditées (`SECURITY_BLOCK` /

    `SECURITY_UNBLOCK`). `blockIp` propage le marqueur Redis (TTL = durée, permanent = 1 an borné).

    `banUser` désactive aussi le `User` (`isActive=false`). `unblock` purge Redis (IP) / réactive (USER).

    `resolveBlockExpiry(duration)` mappe `1h|24h|7d|permanent` → `expiresAt`.

    Stripe — essai (déjà existant, conservé)

    Le flux carte requise + zéro débit + essai 14j + débit J+14 reste géré par `createTrialSubscription`

    (SetupIntent, cf. `docs/modules/onboarding.md`). Ajouts de ce module :

    • `customer.subscription.trial_will_end` → `lib/stripe/trial.sendTrialEndingReminder` (email `TRIAL_ENDING`

    + SMS best-effort au gérant) + audit `TRIAL_ENDING_NOTIFIED`. (Stripe émet cet event ~3 j avant la fin.)

    • Transition essai→payant (`syncSubscriptionToSchool`, `TRIALING→ACTIVE`) → audit `TRIAL_CONVERTED`.

    Dashboard `/stravoda-admin/securite` (SUPPORT+ ; ANALYST → redirigé)

    Service `lib/services/admin/security/` (`overview`, `events`, `blocked`, `trial-abuse`, `windows` PUR) :

    • KPIs (24h critiques · abus essai 7j · IPs bloquées · comptes bannis).
    • Flux 48h (`SecurityFeed`) : filtres type/sévérité/résolu/période + actions (bloquer IP/email,

    résoudre, détail JSON).

    • Entités bloquées (`BlockedEntities`) : tabs IP/email/pattern/user + débloquer + preuves.
    • Abus essai flaggés (`FlaggedTrials`) : bloquer définitivement / légitimer.
    • Actions rapides (`QuickActions`) : 3 formulaires (bloquer IP, bannir, bloquer pattern).
    • Stats 30 j (barres CSS empilées par sévérité).

    Badge rouge sidebar = `countSecurityBadge` (alertes agrégées : auth échouée, événements HIGH/CRITICAL, SMS, incidents stale).

    Monitor sécurité admin (14z-5) — lecture seule sur données existantes

    Sections ajoutées au dashboard `/stravoda-admin/securite` (SUPPORT+) :

    1. Tentatives auth — `AuditLog` `LOGIN_FAILED` (24 h), alertes IP/global/honeypot.

    2. Activité suspecte — `AuditLog` filtrable, actions prioritaires surlignées, export CSV.

    3. Rate limits — snapshot Redis `stravoda:rl:*` (ADMIN seul).

    4. SMS FAILED — `SmsLog` 24 h, alerte si > 10 échecs.

    5. Sessions admin — `StravodaAdmin` actifs + dernière action audit ; révocation via `PATCH /api/admin/team/[id]`.

    6. Incidents stale — `IncidentReport` OPEN > 48 h.

    Widget résumé sur `/stravoda-admin` (SUPPORT+).

    Routes API (lecture seule sauf révocation existante) :

    • `GET /api/admin/security/overview` (SUPPORT+)
    • `GET /api/admin/security/failed-auth` (ADMIN)
    • `GET /api/admin/security/rate-limits` (ADMIN)
    • `GET /api/admin/security/audit-activity` (SUPPORT+)
    • `GET /api/admin/security/audit-export` (ADMIN)

    `signIn` journalise `LOGIN_FAILED` (email, IP, User-Agent) via `lib/audit/login-failed.ts`.

    Routes API (toutes `requireStravodaAdmin('SUPPORT')`, `RATE_LIMITS.ADMIN_API`)

    • `GET /api/admin/security/events`
    • `GET /api/admin/security/blocked`
    • `POST /api/admin/security/block`
    • `POST /api/admin/security/unblock`
    • `PATCH /api/admin/security/events/[id]/resolve`
    • `PATCH /api/admin/security/trials/[id]/legitimize`

    Tests

    `security-email-normalize`, `security-windows`, `security-signup-guard`, `security-actions`,

    `security-events`, `admin-security-routes` (RBAC), `security-middleware` (403 Edge).

    `GET /api/school/team`, `PATCH /api/school/team/[userId]`
    Multi-sitesparentSchoolId, childSchools`GET /api/school/multi-site` (lecture)
    RGPDexport + stats anonymisation`GET /api/school/export`, `POST /api/students/[id]/delete`
    DocumentscontractClausesText, formationPlanText`PATCH /api/school/document-templates`
    DREAL—existant `DrealReportCard`

    Widget & QR

    • URL publique lead : `/{locale}/e/inscription?school={schoolId}` (alias plan : `/e/onboarding?school=` → redirect)
    • Snippet : `GET /api/public/widget.js?school={schoolId}` (iframe vers la page inscription)
    • QR PNG : `GET /api/school/qr` (GERANT, encode l'URL inscription)

    Lead public

    `POST /api/public/leads` — rate limit `API_PUBLIC`, body Zod `PublicLeadSchema`. Crée un élève (`acquisitionSource: WIDGET`), enfile SMS onboarding (`onboarding.welcome`).

    Règles métier

    • Tarifs horaires : n'affectent jamais les forfaits existants (`computeDefaultPrice` à la création uniquement).
    • Agrément : alerte UI si expiry < `AGREMENT_RENEWAL_ALERT_DAYS_BEFORE` jours.
    • Logo : `validateUpload()` (JPEG/PNG), bucket public `school-logos`.
    • Mutations : `revalidateTag(\`school:${schoolId}:settings\`)`.
    • RBAC : édition GERANT ; lecture COMPTABLE sur onglets non sensibles.

    Service

    `lib/services/school/` — queries, mutations par section, export, lead, QR, widget snippet.

    `SmsLog` `QUEUED`. Le cron dispatch (5 min) draine : `status=QUEUED AND retryCount<3 AND

    (nextRetryAt IS NULL OR nextRetryAt<=now)`, borné `SMS_DISPATCH_BATCH_SIZE` (100).

    • Succès : `SENT` + `sentAt` + `providerMsgId` (id de job OVH).
    • Échec & `retryCount+1<3` : reste `QUEUED`, `retryCount++`, `nextRetryAt = now + 5min·2^retryCount` (5/10/20 min).
    • Échec & `retryCount+1≥3` : `FAILED` (terminal).
    • Blocages terminaux (jamais d'envoi OVH) : `NO_CONSENT`, `NO_RECIPIENT`, `RENDER_ERROR`.

    Consentement (BLOQUANT — `consent.ts`)

    `sendSms` refuse tout envoi si `student.smsConsent=false` (vérifié au dispatch, pas seulement à

    l'enfilage) → `FAILED/NO_CONSENT`, OVH jamais appelé. Les SMS système (sans `studentId`) ne sont

    pas soumis au consentement.

    Routage AAC

    Élève mineur (`isMinor(birthDate)`) en AAC (`isAAC`) → destinataires selon

    `School.smsAacRouting` : `BOTH` (défaut : élève + `aacParentPhone`) · `PARENT` · `STUDENT`. Parent

    absent → fallback élève (jamais d'envoi perdu). Un `SmsLog` peut générer 1 ou 2 envois OVH ; statut

    agrégé `SENT` si ≥1 succès.

    Templates (`render.ts` + `locales/fr/sms.json`)

    `SMS_TEMPLATE_KEYS` (constants) = source unique ; chaque valeur est un chemin pointé de `sms.json`

    (ex. `reminder.lesson24h`). `renderSms(key, ctx)` interpole `{{var}}` et throw RENDER_ERROR si une

    variable requise manque (sauf `penaltyNote`, légitimement vide). `composeMessage` au dispatch : `content`

    non vide + texte → envoyé tel quel ; `content` = URL → injecté dans les variables lien ; `content` vide

    → rendu depuis les relations. **Les rappels et la confirmation d'annulation rendent le message complet

    à l'enfilage** (toutes les données + lien dispo à ce moment).

    Crons de rappel (`reminders/`, 1 h, idempotents)

    RappelSourceIdempotence
    Leçon 24h (+ lien annulation)`Lesson` `PENDING/CONFIRMED`, `scheduledAt ∈ [+23h,+24h]`flag `reminder24Sent` (garde `updateMany`)
    Examen 2h`ExamDate` `EN_ATTENTE`, `scheduledAt ∈ [+1h,+2h]`flag `reminder2Sent`
    Forfait bas`Package` restant ∈ ]0 ; `LESSON_LOW_HOURS_THRESHOLD`[dédup `SmsLog (studentId, templateKey)` < 30 j
    Inactifélève actif, aucune leçon < `STUDENT_INACTIVE_WEEKS`, heures restantes > 0dédup 30 j

    Forfait bas / inactif : requêtes `$queryRaw` (comparaison de colonnes `totalHours-usedHours`) + lien

    portail élève (`PROFILE`, TTL 24h).

    SMS automatiques 7b (mêmes crons, idempotents)

    RappelSourceIdempotence
    Avis Google (`reputation.ts`)`ExamDate CONDUITE/RECU`, `updatedAt ∈ [−7j,−24h]`, `School.googleReviewUrl` présentflag `reviewRequestSent` (garde `updateMany`)
    Relance document (`documents.ts`)élève `selfOnboardedAt ≤ −24h`, un document requis encore manquantdédup `SmsLog (studentId, templateKey)` < 30 j

    Avis Google inerte si `School.googleReviewUrl` non configuré. Document manquant = 1ᵉʳ requis absent

    (REQUIRED_DOCUMENT_TYPES + `AUTORISATION_PARENTALE` si mineur ; présent = statut `VALIDE`/`EN_ATTENTE`),

    libellé via `common.documentType`, lien portail `PROFILE`.

    Campagnes par segment (`segments.ts` + `campaigns.ts`, 7b)

    Gate `marketingConsent` (promotionnel, distinct de `smsConsent` transactionnel) — appliqué au

    chargement des candidats (`loadSegmentCandidates`, scopé `schoolId`, filtrage en mémoire car 1 école).

    Segments : `ACTIVE` · `INACTIVE` (aucune leçon active < `STUDENT_INACTIVE_WEEKS`) · `LOW_HOURS`

    (restant ∈ ]0;seuil[) · `PERMIT` (+ `permitType`) · `NO_CODE` · `EXAM_SOON`.

    • `GET /api/sms/campaigns?segment&permitType` → `{ count }` (aperçu). `POST` → enfile un `SmsLog QUEUED`

    (`templateKey=campaign.custom`, `content`=texte libre sanitizé `cleanText`, jamais rendu via `sms.json`)

    par destinataire ; rate limit `SMS_SEND` (par école), RBAC GERANT, audit `SEND_CAMPAIGN`

    (segment + volume uniquement, jamais le contenu PII).

    • Le dispatcher 7a envoie le texte tel quel en respectant le consentement au dispatch.
    • `PATCH /api/sms/settings` → `School.googleReviewUrl` (vide = désactive le SMS d'avis).

    Lien d'annulation (`cancel-link.ts` + `cancel.ts`)

    Token dédié (distinct des `StudentLink` ONBOARDING/BOOKING/PROFILE, plafond 24h §18 inchangé) :

    JWT signé `STUDENT_LINK_SECRET`, payload `{ studentId, schoolId, lessonId, linkId, type:CANCEL }`,

    stocké haché (`StudentLink` type `CANCEL` + `lessonId`), TTL `SMS_CANCEL_LINK_TTL_HOURS=26`.

    Vérification DB + recroisement payload↔enregistrement à chaque requête.

    • `GET /api/cancel/[token]` → détails leçon + simulation de pénalité (pas d'écriture).
    • `POST /api/cancel/[token]` → `cancelLesson(cancelledBy:STUDENT)` (réutilise `resolveCancellationPolicy` :

    pénalité auto hors `cancellationDeadlineHours`) + enfile `booking.cancelled_student`.

    • Rate limit `STUDENT_LINK_USE` par token. Page publique `/[locale]/cancel/[token]` (hors middleware).

    Webhook OVH (`webhook.ts`)

    `POST /api/webhooks/sms` : Zod `OvhDlrSchema`, rate limit `WEBHOOK_SMS`. Corrélation par

    `providerMsgId` (id OVH opaque). `deliveryReceipt=1` → `DELIVERED` ; sinon `FAILED`. Idempotent :

    `updateMany` borné `status ∈ {SENT, QUEUED}` → un statut terminal n'est jamais régressé.

    > TODO déploiement : durcir l'origine (allowlist IP OVH / secret de callback).

    Schéma

    Migration `sms_dispatch` (7a) :

    • `School.smsAacRouting SmsAacRouting @default(BOTH)` + enum `SmsAacRouting { BOTH PARENT STUDENT }`.
    • `SmsLog.nextRetryAt DateTime?`, `SmsLog.providerMsgId String?`, index `[status, nextRetryAt]` + `[providerMsgId]`.
    • `StudentLink.lessonId String?` (lien CANCEL).

    Migration `sms_campaigns_reputation` (7b) :

    • `School.googleReviewUrl String?` (SMS avis ; inerte si absent).
    • `ExamDate.reviewRequestSent Boolean @default(false)` (idempotence SMS avis post-permis).

    Constantes

    `SMS_MAX_RETRIES=3`, `SMS_RETRY_BASE_MS=5min`, `SMS_DISPATCH_BATCH_SIZE=100`,

    `SMS_REMINDER_LESSON_WINDOW_H {23,24}`, `SMS_REMINDER_EXAM_WINDOW_H {1,2}`, `SMS_REMINDER_DEDUP_DAYS=30`,

    `SMS_REMINDER_BATCH_SIZE=200`, `SMS_CANCEL_LINK_TTL_HOURS=26`. 7b : `SMS_REVIEW_MIN_HOURS_AFTER=24`,

    `SMS_REVIEW_MAX_DAYS_AFTER=7`, `SMS_DOC_REMINDER_MIN_HOURS_AFTER=24`, `SMS_CAMPAIGN_MESSAGE_MAX=480`,

    `SMS_SEGMENT_LENGTH=160`, `SMS_CAMPAIGN_TEMPLATE_KEY='campaign.custom'`. Rate limits : `WEBHOOK_SMS`,

    `STUDENT_LINK_USE`, `SMS_SEND` (campagnes).

    Tests

    • `src/test/sms-service.test.ts` (7a) : consentement, routage AAC, backoff, idempotence cron.
    • `src/test/sms-campaigns.test.ts` (7b) : filtres de segments (LOW_HOURS/INACTIVE/PERMIT/NO_CODE/EXAM_SOON),

    gate marketing + isolation `schoolId`, audit sans PII, idempotence avis Google (`reviewRequestSent`),

    relance document (aucun envoi si tous présents).

    `vercel.json`

    `/api/cron/sms-dispatch` → `*/5 * * * *` · `/api/cron/sms-reminders` → `0 * * * *`.

    UI

    • `components/statistics/StatisticsView.tsx`
    • `components/statistics/OccupancySection.tsx` (FeatureGate + période + tableau moniteurs + courbe 4 semaines)

    Règles & garanties

    • Isolation : `schoolId` toujours issu de la session ; chaque requête/écriture re-scopée (`findFirst`/

    `updateMany` avec `schoolId`). Tests d'isolation dans `src/test/student-service.test.ts`.

    • Verrou optimiste : `Student.version` ; `updateStudent` via `updateMany({ where:{id,schoolId,version} })`

    → `count===0` ⇒ `CONFLICT` (`students.errors.versionConflict`).

    • Ghost Data : suppression interdite si paiements ou leçons `COMPLETED` liés (`CONFLICT`,

    `students.errors.ghostData`) — l'anonymisation (6b) prendra le relais. Le soft-delete pose `deletedAt`

    + `isActive=false` sans effacer les PII (conservation rétention légale ; anonymisation = cron 6b).

    • Audit : `CREATE/UPDATE/DELETE_STUDENT` + `VIEW_STUDENT` (lecture par un moniteur non référent)

    via `writeAuditLog` (SHA-256). GÉRANT/COMPTABLE non tracés en lecture (accès global légitime).

    • Validation : Zod sur toutes les entrées ; mineur (calcul `birthDate`) ⇒ contact d'urgence

    obligatoire ; téléphone E.164/national normalisé ; unicité téléphone par école.

    • Cache : mutations → `revalidateTag('school:${schoolId}:students'[, ':${id}'])` — jamais `revalidatePath`.
    • i18n : namespaces `students` + `errors` (chargés dans `i18n/request.ts`) ; labels permis via

    `PERMIT_CONFIG`/`permitTypes`. Zéro string en dur.

    • RBAC : GET liste/fiche = GERANT/COMPTABLE/MONITEUR ; toute écriture (POST/PATCH/DELETE) = GERANT.

    Périmètre livré (6b)

    • Self-onboarding : `POST /api/students/[id]/link` (GERANT) génère un `StudentLink` ONBOARDING (24 h),

    enfile un SMS d'invitation à l'élève (dispatch Étape 7) et retourne l'URL au gérant (copier/partager).

    Portail public `/[locale]/e/[token]` (hors middleware) → `GET/PATCH /api/portal/[token]` : l'élève

    complète un sous-ensemble de champs (jamais NEPH / notes internes / référent / permis). 1ʳᵉ

    complétion → `Student.selfOnboardedAt`, audit `SELF_ONBOARDED` + SMS gérant (one-shot).

    • Export RGPD (portabilité, art. 20) : `GET /api/students/[id]/export?format=json|csv` (GERANT,

    rate limit `PDF_EXPORT`) → audit `EXPORT_STUDENT_DATA`. JSON complet / CSV aplati champ-valeur.

    • Parrainage (track-only) : `GET /api/students/[id]/referral` (GERANT) génère/retourne le `referralCode`

    + lien. Page publique `/[locale]/parrainage/[code]` → `GET/POST /api/referral/[code]` (public, rate limit

    `REGISTER`) crée le filleul (`referredById`, `acquisitionSource='PARRAINAGE'`, schoolId dérivé serveur

    du code). Le crédit +1h (`REFERRAL_BONUS_HOURS`) est appliqué à l'Étape 8 (Facturation).

    • Cron anonymisation : `GET /api/cron/anonymize-students` (header `Authorization: Bearer ${CRON_SECRET}`,

    timing-safe) → `anonymizeExpiredStudents` : élèves soft-deleted depuis > `RGPD_RETENTION_YEARS`, PII →

    `RGPD_ERASED_PERSON_LABEL`/null, `anonymizedAt` posé, audit par élève, batch borné

    (`RGPD_ANONYMIZE_BATCH_SIZE`). Planifié via `vercel.json` (3 h du matin).

    Fichiers 6b

    lib/services/student/
      sms.ts        enqueueStudentSms (SMS lié élève, sans leçon — SmsLog.lessonId nullable)
      link.ts       createOnboardingLink (StudentLink + SMS invite + audit SEND_ONBOARDING_LINK)
      portal.ts     getPortalStudent / savePortalStudent (whitelist, consentements horodatés, notif one-shot)
      export.ts     buildStudentExport (JSON/CSV + audit EXPORT_STUDENT_DATA)
      referral.ts   getReferralShare / resolveReferral / registerReferral
      anonymize.ts  anonymizeExpiredStudents (cron RGPD, global cross-school)
    app/api/students/[id]/{link,referral,export}/route.ts
    app/api/portal/[token]/route.ts · app/api/referral/[code]/route.ts
    app/api/cron/anonymize-students/route.ts
    app/[locale]/e/[token]/page.tsx · app/[locale]/parrainage/[code]/page.tsx
    components/students/StudentPortalForm · ReferralSignupForm · StudentShareActions
    vercel.json   crons → /api/cron/anonymize-students (0 3 * * *)

    > Env requise : `CRON_SECRET` (≥16 car.) ajoutée à `src/env.ts` (fail-fast au démarrage). Sur Vercel,

    > définir la variable → Vercel l'injecte automatiquement dans l'en-tête `Authorization` des crons.

    6c-1 — Documents (Supabase Storage)

    • Stockage : bucket Supabase privé `student-documents` (constante `DOCUMENTS_BUCKET`). Chemin scopé

    `${schoolId}/${studentId}/${documentId}/${fileName}` (isolation par l'arborescence). En base, `Document.fileUrl`

    conserve le chemin (jamais une URL signée, qui expire). Accès lecture via URL signée courte

    (`DOCUMENT_SIGNED_URL_TTL_SEC` = 120 s) — aucune URL publique sur des PII (souvent de mineurs).

    • Client service-role : `lib/supabase/admin.ts` (`getSupabaseAdmin`, `server-only`, bypass RLS) ; lib d'accès

    `lib/storage/documents.ts` (`uploadDocumentFile` / `createSignedDocumentUrl` / `removeDocumentFile`,

    `ensureBucket` idempotent au 1ᵉʳ accès).

    • Service `student/documents.ts` :

    - `getStudentDocumentsChecklist(studentId, schoolId)` : fusionne les types requis

    (`REQUIRED_DOCUMENT_TYPES` = CNI/Photo/JDC/ASSR ; + `AUTORISATION_PARENTALE` si mineur) avec les

    documents existants, + liste les documents non requis déjà uploadés.

    - `uploadStudentDocument(...)` : upsert par (studentId, type) ; remplace le fichier précédent (remove),

    repasse en `EN_ATTENTE`, audit `UPLOAD_DOCUMENT`, `revalidateTag` scoppé.

    - `validateDocument(...)` : `VALIDE`/`INVALIDE` (`validatedAt`/`validatedBy`), audit `VALIDATE_DOCUMENT` ; si `VALIDE` sur `ASSR`/`ASSR_1`/`ASSR_2`/`ATTESTATION_CODE` → attestation PDF auto (`document-attestation.ts`, audit `GENERATE_VALIDATION_ATTESTATION`).

    - `getDocumentDownloadUrl(...)` : URL signée scoppée `schoolId` (document d'une autre école = introuvable).

    • Routes : `GET/POST /api/students/[id]/documents` (GET = checklist GERANT/COMPTABLE/MONITEUR ; POST = upload

    multipart `FILE_UPLOAD`, GERANT/COMPTABLE, `validateUpload` taille+MIME+magic bytes) ;

    `PATCH /api/documents/[id]` (valider/rejeter, GERANT/COMPTABLE) ;

    `GET /api/documents/[id]/download` (URL signée, GERANT/COMPTABLE/MONITEUR). L'ancienne route 501

    `/api/documents/upload` est supprimée.

    • UI : onglet DOCUMENTS = `components/students/StudentDocuments.tsx` (checklist serveur passée en

    `initialItems`, refetch après mutation ; upload/remplacement, valider/rejeter, téléchargement). `canManage`

    dérivé du rôle (GERANT/COMPTABLE).

    Fichiers 6c-1

    lib/supabase/admin.ts            getSupabaseAdmin (service-role, server-only)
    lib/storage/documents.ts         upload / signed URL / remove / ensureBucket (bucket privé)
    lib/services/student/documents.ts checklist + upload + validate + download
    shared/schemas/document.ts       DOCUMENT_TYPES, UploadDocumentSchema, ValidateDocumentSchema
    app/api/students/[id]/documents/route.ts   GET checklist · POST upload
    app/api/documents/[id]/route.ts            PATCH valider/rejeter
    app/api/documents/[id]/download/route.ts   GET URL signée
    components/students/StudentDocuments.tsx   onglet DOCUMENTS interactif

    > Env requise : `SUPABASE_SERVICE_ROLE_KEY` (déjà dans `src/env.ts`). Le bucket privé `student-documents`

    > est créé automatiquement au 1ᵉʳ upload (`ensureBucket`) si absent.

    6c-4a — Génération PDF (contrat + plan de formation)

    • Lib : `@react-pdf/renderer` (JSX serverless-safe). Templates dans `lib/pdf/` : `styles.ts`,

    `Header.tsx` (en-tête école — SIRET + n° agrément), `contract.tsx`, `formation-plan.tsx`, `render.tsx`

    (`renderToBuffer` → `Buffer`). Aucun texte juridique imposé par le code : les clauses (contrat) et

    le contenu (plan) viennent de l'école.

    • Clauses configurables : `School.contractClausesText` + `School.formationPlanText` (`@db.Text`, migration

    `school_document_templates`). Service `lib/services/school.service.ts` (`getSchoolDocumentTemplates` /

    `updateSchoolDocumentTemplates`, texte sanitizé via `cleanText`, `revalidateTag school:${id}:settings`).

    Route `PATCH /api/school/document-templates` (GERANT). UI : `/dashboard/parametres` (hub + section

    « Modèles de documents » via `components/settings/DocumentTemplatesForm.tsx`).

    • Génération : `student/generate-doc.ts` → `generateStudentDocument(schoolId, studentId, {type}, actorUserId)` :

    charge école (identité + clauses) + élève (+ volume horaire du dernier `Package`), rend le PDF, upload via

    `uploadDocumentBuffer` (bucket privé 6c-1), upsert `Document` par (studentId, type) en statut `EN_ATTENTE`

    (en attente de signature → `VALIDE`/`signedAt` en 6c-4b), audit `GENERATE_DOCUMENT`. Montant du contrat

    dérivé des forfaits de l'élève (somme des `priceTotal`, 8c-0) — plus de saisie manuelle ; élève sans

    forfait → montant omis. Route `POST /api/students/[id]/documents/generate`

    (GERANT, `runtime = 'nodejs'`, rate limit `PDF_EXPORT`). UI : boutons « Générer le contrat / le plan »

    dans `StudentDocuments`.

    • Tests : `student-generate-doc.test.ts` (5, rendu + stockage mockés : isolation, upsert, audit, montant dérivé / sans forfait)

    + `pdf-render.test.ts` (2, rendu réel → magic bytes `%PDF`).

    Fichiers 6c-4a

    lib/pdf/{styles.ts,types.ts,Header.tsx,contract.tsx,formation-plan.tsx,render.tsx}
    lib/services/school.service.ts          get/update modèles de documents (sanitize + revalidateTag)
    lib/services/student/generate-doc.ts    generateStudentDocument (rend PDF → upload → upsert Document)
    app/api/students/[id]/documents/generate/route.ts   POST (runtime nodejs)
    app/api/school/document-templates/route.ts          PATCH (GERANT)
    app/[locale]/dashboard/parametres/page.tsx          hub Paramètres
    components/settings/DocumentTemplatesForm.tsx        édition clauses (client)

    > Note : le devis est différé à l'Étape 8 (dépend du moteur de prix).

    6c-4b — Signature électronique YouSign (eIDAS)

    • Signataire : élève majeur = l'élève ; mineur = le représentant légal (champ `Student.aacParentEmail`,

    migration `student_aac_parent_email` ; le DOSSIER capte nom + email + téléphone parent). Auth OTP SMS,

    email d'invitation envoyé par YouSign (`delivery_mode=email`).

    • Lib `lib/yousign/` : `client.ts` (fetch Bearer, base URL sandbox/prod selon `YOUSIGN_ENV`, upload

    multipart, download), `signature.ts` (`createContractSignatureRequest` : draft → upload `signable_document`

    → signataire `electronic_signature`/`otp_sms`/champ signature → `activate` ; `downloadSignedContract`),

    `webhook.ts` (`verifyYousignSignature` : HMAC SHA-256 du corps brut, comparaison temps constant).

    • Service `student/signature.ts` : `sendDocumentForSignature` (types signables `CONTRAT_FORMATION` | `DEVIS`,

    re-scope le `Document` par schoolId+type, valide les contacts du signataire, télécharge le PDF stocké, crée la demande, stocke `youSignDocId`=requestId,

    audit `SEND_FOR_SIGNATURE`) ; `handleSignedContract` (récupère le PDF signé, re-stocke, `Document`→`VALIDE`

    + `signedAt` + `signatureHash` SHA-256, audit `DOCUMENT_SIGNED`, idempotent).

    • Routes : `POST /api/documents/[id]/sign` (GERANT/COMPTABLE, runtime nodejs) ;

    `POST /api/webhooks/yousign` (hors middleware — matcher exclut `/api` ; HMAC = seule authentification,

    corps lu en `req.text()` avant parsing).

    • env : `YOUSIGN_WEBHOOK_SECRET` (≥16). À configurer : souscription webhook côté dashboard YouSign

    (URL publique HTTPS, event `signature_request.done`). Les appels API restent à valider en sandbox.

    • UI : bouton « Envoyer pour signature » sur la ligne du contrat dans `StudentDocuments`.
    • Tests : `student-signature.test.ts` (7, client + stockage mockés).

    Fichiers 6c-4b

    lib/yousign/{client.ts,signature.ts,webhook.ts}
    lib/storage/documents.ts                 + downloadDocumentBuffer
    lib/services/student/signature.ts        sendDocumentForSignature / handleSignedContract
    app/api/documents/[id]/sign/route.ts     POST (envoi signature)
    app/api/webhooks/yousign/route.ts        POST (réception, HMAC)
    schema : Student.aacParentEmail ; env : YOUSIGN_WEBHOOK_SECRET

    6c-2 — Import CSV/XLSX

    • Formats : CSV (séparateur auto `, ; \t`, guillemets, BOM) + Excel `.xlsx` (exceljs). Parsing en

    matrice `string[][]` (`lib/import/parse.ts`, bornes taille 5 Mo / `STUDENT_IMPORT_MAX_ROWS`).

    • Mapping : auto-détection par alias d'en-têtes (FR/EN, accents/casse ignorés ; `lib/import/mapping.ts`)

    + correction manuelle dans l'UI. `IMPORT_FIELDS` = prénom, nom, téléphone (requis), email, naissance,

    permis, NEPH, adresse.

    • Validation : `ImportRowSchema` (Zod) avec coercition — date `DD/MM/YYYY`→ISO, permis texte→enum.
    • Dédup : par téléphone (existants en base + intra-fichier) → ligne ignorée + signalée.
    • Import partiel : `importStudents` valide chaque ligne, `createMany` des valides, rapport

    `{created, skipped, errors:[{row, reason}], createdStudentIds}` , audit `IMPORT_STUDENTS`.

    • Invitation email en masse post-import : `bulkInviteImportedStudents` — lien ONBOARDING

    (`createOnboardingLink` canal `EMAIL_ONLY`, sans SMS) + template `STUDENT_BULK_ONBOARDING_INVITE`.

    Quota email vérifié pour tout le lot avant envoi (refus total si dépassement). Audit

    `BULK_INVITE_STUDENTS`. Route `POST /api/students/import/bulk-invite` (preview via `{ preview: true }`).

    UI : panneau sur l'écran de résultat import (`BulkInvitePanel`).

    • Flux 2 étapes : `POST /api/students/import/preview` (en-têtes + échantillon + mapping auto) →

    l'UI ajuste → `POST /api/students/import` (fichier + mapping). GERANT, runtime nodejs, rate `FILE_UPLOAD`.

    • UI : page `/dashboard/eleves/import` (`StudentImport`) + bouton « Importer » sur la liste.
    • Tests : `student-import.test.ts` (5). Dép : `exceljs` (0 vuln critique).

    Fichiers 6c-2

    lib/import/{parse.ts,mapping.ts}
    shared/schemas/student-import.ts
    lib/services/student/import.ts           previewStudentImport / importStudents
    lib/services/student/bulk-invite.ts      bulkInviteImportedStudents / summarizeBulkInviteStudents
    app/api/students/import/preview/route.ts POST (aperçu + mapping auto)
    app/api/students/import/route.ts         POST (import effectif)
    app/api/students/import/bulk-invite/route.ts POST (invitation email en masse post-import)
    app/[locale]/dashboard/eleves/import/page.tsx + components/students/{StudentImport,BulkInvitePanel}.tsx

    6c-3 — Suivi pédagogique REMC + blocage ASSR/mineur

    Référentiel officiel (arrêté du 13 mai 2013), 6 compétences — libellés via i18n (`pedagogy.json`), jamais en dur :

    `C1` maîtriser le véhicule · `C2` respecter les règles · `C3` s'adapter à l'environnement · `C4` partager la route

    · `C5` savoir être · `C6` faire preuve d'autonomie. Échelle 3 niveaux : `NON_ABORDE → EN_COURS → ACQUIS`.

    • Modèle `StudentProgress` : `@@unique([studentId, competency])` (une ligne / compétence, `upsert`), `level`,

    `note?`, `version` (verrou optimiste incrémenté à chaque update), `assessedAt` (`@updatedAt`), `assessedById`

    FK `Instructor` nullable → moniteur évaluateur ; null si l'évaluation vient d'un gérant (sa trace reste

    dans l'`AuditLog` via `userId`). Index `schoolId` et `schoolId,studentId`. Enums `Competency` (C1–C6) / `ProgressLevel`.

    • Service `student/progress.ts` : `getStudentProgress` renvoie toujours les 6 compétences (défaut `NON_ABORDE`,

    ordre C1→C6) ; `upsertStudentProgress` résout l'`instructorId` de l'évaluateur depuis `User`, `upsert`, audit

    `UPDATE_PROGRESS`, `revalidateStudents`. Tout scopé `schoolId` (session).

    • Routes `GET/PUT /api/students/[id]/progress` — GERANT + MONITEUR (lecture & écriture), Zod

    (`UpsertProgressSchema`), rate `API_AUTHENTICATED`.

    • Blocage légal ASSR/mineur : `assertAssrForMinor(tx, …)` dans `lesson/validation.ts`, appelée **dans la

    transaction par `lesson/mutations/create.ts` et** `recurrence.ts`. Si `isMinor(birthDate, lessonDate)` ET

    pas de `Document{type:'ASSR', status:'VALIDE'}` (student+school) → `AppError('CONFLICT','planning.errors.assrRequired')`.

    • UI : onglet PÉDAGOGIE (`StudentProgressTab`) — 6 compétences, sélecteur 3 niveaux, sauvegarde optimiste

    par ligne (rollback + message si l'API échoue), affichage moniteur évaluateur + date ; données initiales chargées serveur.

    Fichiers 6c-3

    prisma/schema.prisma                         StudentProgress + enums Competency/ProgressLevel (migration student_progress)
    shared/schemas/progress.ts                   UpsertProgressSchema, COMPETENCIES, PROGRESS_LEVELS
    lib/services/student/progress.ts             getStudentProgress / upsertStudentProgress
    lib/services/lesson/validation.ts            assertAssrForMinor (+ clé planning.errors.assrRequired)
    lib/services/lesson/mutations/{create,recurrence}.ts  appel de la garde dans la transaction
    app/api/students/[id]/progress/route.ts      GET + PUT (GERANT+MONITEUR)
    components/students/StudentProgressTab.tsx   onglet PÉDAGOGIE (sauvegarde optimiste)
    locales/fr/pedagogy.json                     compétences + niveaux + libellés (wrappé, spread dans i18n/request.ts)
    test/{student-progress,lesson-assr}.test.ts  isolation + résolution évaluateur + blocage ASSR

    13b — Saisie résultats d'examens

    • Migration `exam_result_absent` : valeur `ABSENT` sur l'enum `ExamResult`.
    • Route `PATCH /api/exam-dates/[id]/result` — GERANT uniquement ; Zod `RecordExamResultSchema` ;

    transaction atomique `ExamDate` (+ `Student` si REÇU CODE : `codeObtained`, `codeObtainedAt`, `codeScore`).

    • Règles métier :

    - REÇU + CODE → mise à jour élève + audit `EXAM_RESULT_RECU`.

    - REÇU + CONDUITE → audit `EXAM_RESULT_RECU` ; SMS avis Google via cron existant (`reviewRequestSent=false`, fenêtre `updatedAt`).

    - ECHOUE + CONDUITE → `failureReasons` obligatoires (6 raisons i18n).

    - ABSENT → pas de raisons ; suggestion UI replanification.

    - ECHOUE + CONDUITE → suggestion UI RDV pédagogique.

    • UI : onglet EXAMENS enrichi (date · type · lieu · résultat) + bouton « Saisir le résultat » si `EN_ATTENTE`

    (`ExamResultDrawer`).

    Fichiers 13b

    prisma/migrations/20260625120000_exam_result_absent/
    shared/schemas/exam-date.ts                  RecordExamResultSchema + EXAM_FAILURE_REASONS
    shared/constants.ts                          CODE_EXAM_MAX_SCORE, EXAM_FAILURE_REASONS
    lib/services/student/exam-result.ts          recordExamResult (tx + audit + revalidateStudents)
    app/api/exam-dates/[id]/result/route.ts      PATCH (GERANT)
    components/students/ExamResultDrawer.tsx     tiroir saisie résultat
    components/students/StudentReadTabs.tsx      ExamsTab enrichi
    locales/fr/students.json                     exams.resultForm.* + failureReason.*
    test/exam-result.test.ts                     isolation + REÇU CODE + ECHOUE + audit

    Notes / limites assumées (6a)

    • Filtres agrégats (« forfait < 3h », « impayés ») : non exprimables en `findMany` Prisma (somme de

    relations) → calculés et mis en évidence côté client sur la page chargée. Filtre serveur exact =

    colonne dénormalisée à introduire en 6b si besoin.

    • Champs clear : un champ texte optionnel vidé (`''`) est traité comme « inchangé » (Zod `''→undefined`).

    Seul `primaryInstructorId` accepte un `null` explicite (retrait du référent).

    • DOSSIER : « lien code externe » et « date estimée d'examen » du plan ne sont pas des champs `Student`

    → différés (ajout de scalaires = migration dédiée si retenus).

    • VIEW_STUDENT par API : les MONITEUR n'ont pas encore d'UI fiche (layout dashboard = GERANT/COMPTABLE) ;

    l'audit lecture est exercé via la route et testé.

    14w — Pipeline élèves + prédiction

    • Migration `step_14w_pipeline` : `Student.readyForExam`, `readyForExamAt`, `readyForExamBy` ;

    enum `DocumentType.ATTESTATION_CODE`.

    • Vue Kanban : `/dashboard/eleves?view=pipeline` — 6 colonnes (INSCRIPTION → RECU_MOIS) ;

    toggle Liste/Pipeline dans `StudentsClient`.

    • Service `getPipelineStudents(schoolId)` — classification + prédiction (~X semaines, max 52).
    • Routes :

    - `GET /api/students/pipeline`

    - `PATCH /api/students/[id]/ready-for-exam` (GERANT + moniteur référent)

    - `POST /api/students/[id]/validate-code` (drawer pipeline → SMS `onboarding.code_validated`)

    - `POST /api/students/[id]/pipeline-relance` (SMS inactif manuel)

    - `POST /api/students/[id]/review-request` (avis Google manuel)

    • Espace élève : section code + upload `ATTESTATION_CODE` → push gérant.
    • Moniteur : cartes enrichies (`listMoniteurStudentCards`) — heures, REMC, badges.
    • Tests : `src/test/pipeline.test.ts` (classification, prédiction, isolation).
  • Onglet Documentation : nb questions sans réponse ce mois, top 10 questions fréquentes (clustering par texte tronqué), bouton Générer les Q&A manquantes (Claude propose jusqu'à `SUPPORT_GENERATE_QA_MAX` entrées depuis conversations non curées du mois).
  • Similarité : `shared/text-similarity.ts` (Levenshtein normalisé, pur, testable).

    Support IA diagnostique (14l)

    Principe : l'IA DIAGNOSTIQUE et GUIDE, elle n'agit JAMAIS seule. Lecture seule, zéro écriture autonome,

    zéro accès à une autre école. L'utilisateur exécute lui-même les actions en cliquant des deeplinks internes.

    • Opt-in explicite : `POST /api/support/chat` accepte `analyzeAccount: boolean`. Si `true` + session valide,

    le contexte étendu est chargé serveur (`buildAccountContext`, scopé `session.schoolId`).

    • Contexte étendu (zéro PII) : tier, permitTypes, onboarding, subscriptionStatus, agrementExpiry,

    Stripe Connect actif, compteurs (élèves actifs, leçons du mois, SMS `FAILED` 24 h, documents `EN_ATTENTE`),

    10 dernières actions d'audit (noms seuls, jamais les payloads). Jamais : noms, téléphones, montants, documents.

    • Réponses structurées + actions cliquables : le modèle propose des liens Markdown `Libellé` ;

    `parseDiagnosticActions` n'autorise QUE les chemins internes (`/dashboard`, `/moniteur`) — liens externes retirés (anti-phishing).

    • Escalade intelligente : après `SUPPORT_ESCALATION_TURN_THRESHOLD` (3) échanges encore `LOW` → message

    d'escalade + ticket `PENDING_HUMAN` + notification interne.

    • Audit : chaque analyse → `AuditLog` `SUPPORT_ACCOUNT_ANALYSIS` (payload `{ topics[], schoolId }`, zéro PII).
    • Drapeau : `SupportConversation.accountAnalyzed` (migration `support_diagnostic`).

    Logique pure : `lib/ai/support-diagnostic.ts` (`detectTopics`, `renderAccountContext`, `parseDiagnosticActions`,

    `DIAGNOSTIC_SYSTEM_PROMPT`). Lecture compte : `lib/services/support/account-context.ts` (server-only, scopé schoolId).

    Chat commercial landing unifié (14r-8)

    **Principe : le chat commercial public (landing) et le support authentifié partagent désormais le modèle

    `SupportConversation` et le back-office, distingués par `source` (`DASHBOARD` | `LANDING`).**

    • Widget landing (`SalesChatWidget` + `sales-chat-parts.tsx`) : message d'accueil auto, 3 suggestions

    cliquables (avant le 1er message), carte d'escalade (CTA `/signup` + `/contact`). Style aligné design system (`bg-brand`, `card`, `input-base`, bulles comme `SupportWidget`).

    • Persistance : `recordPublicChatTurn` / `countPublicUserTurns` (`public-conversations.ts`) — `schoolId=null`,

    `userId=null`, `source='LANDING'`, fil identifié par `sessionId`. L'isolation école reste intacte (les routes

    école filtrent toujours par `session.schoolId`).

    • Q&A SALES : `getActiveSalesQaForContext` injecte les Q&A `category='SALES'` actives dans le prompt du chat

    public (`buildSalesSystemPrompt(qa)`), prioritaires sur le KB statique `sales-knowledge.ts`. Seed idempotent

    via `seedSalesQa` (`sales-qa.ts`, données `config/sales-qa-seed.ts`, ≥ 20 entrées, prix dérivés des constantes).

    • Confiance + escalade : le prompt commercial pose le marqueur `[[CONFIDENCE:HIGH|LOW]]` (parsé par

    `parseConfidence`). Escalade si confiance `LOW` ou `PUBLIC_CHAT_ESCALATION_TURN_THRESHOLD` (5) tours

    visiteur atteints (compté en base) → `status='PENDING_HUMAN'` + `notifyPublicChatEscalation`

    (`SUPPORT_NOTIFICATION_EMAIL`, best-effort, zéro PII : sessionId + extrait) + carte d'escalade côté widget.

    • Admin : onglet « Chat Commercial » (`CommercialTab`) avec 3 sections — conversations LANDING (réutilise

    `ConversationsTab source="LANDING"`), base Q&A SALES (`QaTab category="SALES"` + bouton seed, ADMIN),

    performance (`CommercialStats` : conversations du mois, taux de résolution, top 10 questions via

    `getCommercialStats`). Badge rouge sur l'onglet si tickets `PENDING_HUMAN` LANDING. L'onglet « Conversations »

    est désormais scopé `source='DASHBOARD'` ; l'onglet « Base Q&A » exclut la catégorie `SALES`.

    Isolation / RBAC

    • `POST /api/support/chat` : `requireAuth` (gérant/moniteur). Contexte école dérivé serveur depuis

    `session.schoolId` (nom, tier, permis, onboarding) — jamais depuis le body. Conversations scopées

    `schoolId`+`userId` en lecture côté utilisateur.

    • `/api/admin/support/*` : `requireStravodaAdmin('SUPPORT')` (lecture/réponse/Q&A) ; `'ADMIN'` (sync docs).

    Modèles Prisma (migration `support_ia`)

    • `SupportDocument` (`id`, `title`, `content` Text, `sourceFile` @unique, `isActive`, `isPublic`, timestamps) —

    index GIN full-text créé en SQL brut dans la migration (cf. ci-dessous). `isPublic` → visible sur `/documentation`.

    • `SupportConversation` (`schoolId?`, `userId?`, `source` (14r-8), `sessionId`, `messages` Json, `status`,

    `aiConfidence`, `adminReply`, `adminRepliedAt`, `adminRepliedBy`, `addedToKnowledge` (14k),

    `accountAnalyzed` (14l), timestamps) — index `[schoolId,status]`, `[status,createdAt]`, `[sessionId]`,

    `[source,status]` (14r-8). `schoolId`/`userId` nullable depuis 14r-8 (conversations LANDING publiques).

    Migration `support_conversation_source`.

    • `SupportQA` (`question`, `answer` Text, `category`, `isActive`, `isPublic`, `usageCount`) — index

    `[isActive]`, `[isActive,isPublic]`. `isPublic` → visible sur `/aide` (catégorie `SALES` réservée au chat landing).

    Index full-text (à ajouter dans la migration)

    CREATE INDEX "SupportDocument_fulltext_idx"
      ON "SupportDocument"
      USING GIN (to_tsvector('french', title || ' ' || content));

    Contrats (services `lib/services/support/`)

    FonctionEntréeSortieRègle
    `handleSupportChat``{schoolId,userId,sessionId,message,currentPage?}``SupportChatResult`contexte serveur ; fail-safe IA → LOW + ticket
    `searchDocuments``query`, `limit?``{title,content}[]`full-text `websearch_to_tsquery('french')` + `ts_rank`
    `syncDocsFromModules`—`{synced,deactivated}`upsert par `sourceFile` ; fichiers disparus → `isActive=false`
    `listDocuments`—`SupportDocumentItem[]`inclut `isPublic`
    `listPublicDocuments`—`PublicDocumentItem[]``isActive` + `isPublic`
    `updateDocument``id`, `{isPublic?,isActive?}``SupportDocumentItem`sync ne modifie pas `isPublic`
    `listPublicQaGrouped`—`PublicHelpCategoryGroup[]``/aide`, exclut `SALES`
    `seedHelpQa`—`{created,skipped}`42 Q/R depuis `config/help-qa-seed.ts`
    `listQa` / `createQa` / `updateQa` / `importQaCsv`cf. Zod`SupportQaItem` / `SupportQaImportResult`import `;` ; lignes invalides ignorées
    `getActiveQaForContext` / `incrementQaUsage`— / `ids[]`Q&A actives (≤ `SUPPORT_QA_CONTEXT_MAX`)priorité sur la doc
    `listConversations` / `getConversationDetail`query / `id`page cursor / détailadmin, toutes écoles
    `replyConversation``id,adminId,reply``{userId}`→ `HUMAN_REPLIED` + message ajouté au fil
    `getConversationQaSuggestion``id``ConversationQaSuggestion`question + réponse + catégorie IA + similarQa
    `addConversationToKnowledge``id, CreateSupportQaInput``{qaId}`crée Q&A + `addedToKnowledge=true`
    `findSimilarQa``question``SimilarQaMatch \null`seuil 0,8
    `getKnowledgeDocStats`—stats mois + top 10conversations `PENDING_HUMAN` du mois
    `proposeMissingQa`—`QaProposal[]`IA sur conversations non curées du mois
    `getUserConversation``sessionId,schoolId,userId`fil ou `null`isolation stricte
    `notifyLowConfidence` / `notifyGerantReply`cf. code`void`best-effort ; zéro PII dans la notif interne

    Logique IA pure (`lib/ai/support-chat.ts`, testable)

    • `buildSupportPrompt` : contexte école + Q&A (prioritaires) + docs + historique + question.
    • `parseConfidence` : lit le marqueur `[[CONFIDENCE:HIGH|LOW]]` (retiré de l'affichage) ; fallback =

    détection de phrases de non-réponse → `LOW`.

    • `completeText` (`lib/ai/anthropic.ts`) : appel Claude (`SUPPORT_AI_MODEL`), timeout `SUPPORT_AI_TIMEOUT_MS`.

    Routes API

    MéthodeRouteRôleEffet
    POST`/api/support/chat`gérant/moniteurréponse IA + log conversation (+ ticket si LOW) ; 14l `analyzeAccount` → contexte compte + actions + audit `SUPPORT_ACCOUNT_ANALYSIS`
    GET`/api/support/chat?sessionId=`gérant/moniteurrecharge le fil (scopé)
    GET`/api/admin/support/conversations`SUPPORTliste (filtre statut + cursor)
    POST`/api/admin/support/conversations/[id]/reply`SUPPORTréponse humaine + notif gérant + audit `REPLY_SUPPORT`
    GET`/api/admin/support/conversations/[id]/qa-suggestion`SUPPORTsuggestion Q&A pré-remplie (14k)
    POST`/api/admin/support/conversations/[id]/add-to-knowledge`SUPPORTvalide Q&A + marque conversation + audit `ADD_SUPPORT_QA_FROM_CONV`
    GET`/api/admin/support/knowledge-stats`SUPPORTstats documentation (14k)
    POST`/api/admin/support/generate-qa`SUPPORTpropositions Q&A IA (14k)
    GET/POST`/api/admin/support/qa`SUPPORTliste / création Q&A
    PATCH`/api/admin/support/qa/[id]`SUPPORTmaj / désactivation Q&A
    POST`/api/admin/support/qa/import`SUPPORTimport CSV
    POST`/api/admin/support/sync-docs`ADMINsync base de connaissances + audit `SYNC_SUPPORT_DOCS`
    PATCH`/api/admin/support/documents/[id]`SUPPORTmaj `isPublic` / `isActive` d'un `SupportDocument` (visible sur `/documentation`)
    POST`/api/public/chat`public (visiteur)chat commercial landing : Q&A SALES + KB, persistance `source=LANDING`, escalade (14r-8)
    POST`/api/admin/seed-sales-qa`ADMINseed idempotent des Q&A commerciales `category=SALES` + audit `SEED_SALES_QA` (14r-8)
    POST`/api/admin/seed-help-qa`ADMINseed idempotent des 42 Q&A publiques `/aide` (hors `SALES`, `isPublic=true`) + audit `SEED_HELP_QA`
    GET`/api/admin/support/conversations?source=`SUPPORTliste scopée par source (`DASHBOARD`/`LANDING`, 14r-8)
    GET`/api/admin/support/qa?category=&excludeCategory=`SUPPORTliste Q&A filtrée par catégorie (14r-8)

    Env

    • `ANTHROPIC_API_KEY` (clé Claude), `SUPPORT_NOTIFICATION_EMAIL` (notif interne des tickets). Cf. `.env.example`.

    Dette / hors-périmètre

    • Retrieval full-text FR ; pg_vector reporté.
    • Widget = outil fonctionnel first-party authentifié → pas de gate de consentement cookies (la catégorie

    « Support chat » de 12g visait un chat tiers public, non implémenté).

    • Emailing admin (campagnes broadcast) = 12z-2.

    Tests (14k / 14l)

    `src/test/support-knowledge.test.ts` — similarité, `addConversationToKnowledge`, rejet si déjà curée.

    `src/test/support-diagnostic.test.ts` — détection sujets, parsing actions (rejet externes), contexte sans PII,

    analyse opt-in (contexte chargé + audit uniquement si `analyzeAccount=true`), isolation schoolId, escalade après 3 échanges.

    Route
    Rôle
    POST`/api/students/[id]/cpc`GERANT — crée CpcRecord + audit `CPC_SESSION_ADDED`
    PATCH`/api/cpc/[id]`GERANT — clôture session CPC + audit `CPC_SESSION_COMPLETED`
    PATCH`/api/students/[id]/medical-visit`GERANT — saisie visite médicale + audit `UPDATE_MEDICAL_VISIT`
    GET`/api/cpc/[id]/cerfa`GERANT — PDF CERFA 10372*05 + `certificateHash` SHA-256

    Documents requis (catégorie TRANSPORT)

    `VISITE_MEDICALE`, `CERTIFICAT_CPC`, `FIMO`, `CERFA_SUIVI` — via `shared/document-requirements.ts`.

    Accès CPC

    `hasCpcModule(tier)` → `true` uniquement si `tier === 'TRANSPORT'`.

    Isolation

    Toute requête `CpcRecord` filtre `schoolId` depuis la session serveur.

    Contrats de service

    • `listVehicles(schoolId, { isActive? }) : VehicleListItem[]` — véhicules de l'école (non supprimés),

    statuts CT/assurance dérivés des dates d'échéance et des seuils.

    • `createVehicle(schoolId, input, actorUserId) : { id }` — audit `CREATE_VEHICLE`.
    • `updateVehicle(schoolId, id, input, actorUserId) : { id }` — verrou optimiste `version` → `CONFLICT`

    (`vehicles.errors.versionConflict`) si périmée ; audit `UPDATE_VEHICLE`.

    • `softDeleteVehicle(schoolId, id, actorUserId) : { id }` — Ghost Data : `CONFLICT`

    (`vehicles.errors.hasFutureLessons`) si leçons futures PENDING/CONFIRMED ; audit `DELETE_VEHICLE`.

    Statuts d'échéance (CT / assurance)

    StatutCondition
    `unknown`date absente
    `expired`échéance dépassée
    `soon`échéance < seuil (`VEHICLE_CT_ALERT_DAYS_BEFORE` / `VEHICLE_INSURANCE_ALERT_DAYS`)
    `ok`au-delà du seuil

    `controlTechniqueDate` et `insuranceExpiry` sont interprétées comme des dates d'échéance (l'alerte se

    déclenche quand l'échéance approche ou est dépassée), cohérent avec l'alerte dashboard préexistante.

    Décisions / dette

    • Pas d'affectation `Vehicle↔Instructor` (le planning lie déjà `Lesson.vehicleId`).
    • `lastMaintenanceKm` supprimé — remplacé par `lastServiceDate` / `nextServiceDate`.
    • Pagination : liste non paginée (ensemble borné par le tier). Dénormaliser si volume important.
    • Verrou optimiste : migration `vehicle_version` (`Vehicle.version Int @default(0)`).