Le guide complet pour exploiter Stravoda au quotidien.
> 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.
`AacTrip` — migration `20260630154353_aac_trip_logbook` :
`AacTripDeclaredBy` (STUDENT, PARENT), `AacTripStatus` (PENDING, VALIDATED, REJECTED).
| Route | Auth | Rate 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 |
Dashboard `/stravoda-admin/analytics` (ADMIN) : MRR, ARR, croissance MoM, churn, courbe 12 mois, usage produit, alertes business, cohortes rétention.
> 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.
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`).
`StravodaAdminSession = { adminId, supabaseId, email, role }`.
- `monthlyEquivalent(school): number` — prix mensuel équivalent (mensualise l'annuel : facteur
`1 - ANNUAL_DISCOUNT` = 0,8). Source prix = `SUBSCRIPTION_TIERS` + `STRIPE_MODULE_PRICE_EUR`
Journal des modifications avec diff `{ field, before, after }[]` stocké dans `AuditLog.payload` existant — zéro nouvelle table.
Chaque UPDATE lit l'état avant, calcule le diff après merge, appelle `writeAuditLog` :
| Action | Service |
|---|---|
| `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) |
> Statut : ✅ Fait (3a + 14r). Onboarding (3b) séparé. Stack : Next.js 16.2.9, @supabase/ssr, Prisma 7, Zod 4, next-intl 4.
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).
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`.
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/`).
| Route | Méthode | Rate limit | Effet (délègue au service) |
|---|---|---|---|
| `signup` | POST | `REGISTER` (3/1h) |
Spec : `specs/facturation.md`. Découpage 8a / 8b / 8c tous livrés (8c-0→8c-5).
`[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.
`vatExempt=true` (défaut) → « TVA non applicable, art. 293B du CGI ». Sinon « TVA {vatRate} % incluse (TTC) ».
| Fonction | Signature | Notes |
|---|---|---|
| `createPackage` | `(schoolId, CreatePackageInput, actorUserId) → {packageId}` | GERANT. Vérifie élève ∈ école. Audit `CREATE_PACKAGE`. |
| Méthode | Route | Rô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 |
> 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`.
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.
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`.
| Step | Label UI | Lien | Hook métier |
|---|---|---|---|
| `SCHOOL_INFO_CONFIGURED` | Infos école |
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.
`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`).
Champs chiffrés au repos : `Student.phone/email/neph`, `Instructor.phone`.
(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).
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`,
> É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`).
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.
- `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).
`src/test/growth-14z3.test.ts`
> 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`.
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.
semaine UTC de `startAt` ; bloque chaque semaine ce jour, sur la plage horaire de `[startAt,endAt]`
(≤ 24 h), à partir de la 1ʳᵉ occurrence.
Permettre aux écoles hors France métropolitaine d'utiliser Stravoda avec fuseau horaire, devise et langue UI adaptés.
| Champ | Modèle | Défaut | Usage |
|---|---|---|---|
| `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/`
Pages publiques sous `/[locale]/legal/*`. Contenu i18n (`locales/fr/legal.json`) — brouillon juridique, relecture avocat obligatoire avant prod.
| Route | Contenu |
|---|---|
| `/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`.
`LEGAL_DRAFT_MODE=true` (env serveur) → bannière rouge `LegalDraftBanner` sur toutes les pages `/legal/*`.
`LEGAL_ENTITY` dans `shared/constants.ts` — raison sociale, SIRET, contacts (placeholders à compléter avant mise en ligne).
Landing page + `LegalNav` interne — liens vers les 5 pages.
Types concernés (`INTERNAL_SIGNATURE_DOC_TYPES`) :
Flow :
1. Document créé avec statut `EN_ATTENTE_SIGNATURE`
2. `SignatureCanvas` capture PNG
3. `POST /api/documents/[id]/internal-sign` → SHA-256 → statut `SIGNE`
> Chat gérant ↔ moniteurs (pas d'élèves). Push notification via infra 14i.
`InternalMessage` : `schoolId`, `fromUserId`, `toUserId?` (null = broadcast équipe), `content`, `readAt?`.
`lib/services/messaging/` — queries, mutations (push), peers.
`src/test/features-14r7.test.ts` (push destinataire / broadcast).
> 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`.
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.
(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.
Clubs nautiques et écoles de voile : permis côtier/hauturier existants + VHF, CRR, permis fluvial pro.
`CERTIFICAT_MEDICAL_NAUTIQUE`, `ATTESTATION_NAGE` — en plus des permis VHF/CRR si sélectionnés.
| Méthode | Route | Rôle |
|---|---|---|
| POST | `/api/lessons/[id]/weather-cancel` | GERANT — annulation météo sans pénalité + audit `CANCEL_LESSON_WEATHER` |
Même règle multi-tenant : `schoolId` depuis session sur toutes les requêtes.
> 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`.
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"`.
> É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.
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.
`createParentLink(schoolId, studentId, actorUserId, locale)` (`parent/link.ts`) :
(`students.errors.parentLinkUnavailable`). L'AAC peut être majeur — la minorité n'est pas requise.
(`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).
> Source unique client-safe : `src/shared/permits.ts`. Config serveur dérivée : `lib/config/permit-types.ts`.
| Permis | Catégorie | Module Stripe addon |
|---|---|---|
| B, B96, BE, AM | VOITURE (AM = cyclo, tier base) | Non (inclus tier voiture) |
| A1, A2, A | MOTO | Oui si ≥1 permis moto (hors AM) |
| BATEAU_COTIER, BATEAU_HAUTURIER | BATEAU | Oui 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).
> 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.
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.
| Constante | Valeur |
|---|---|
| `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 |
Rapport PDF mensuel automatique pour chaque école ACTIVE/TRIALING.
`SchoolMonthlyReport` — une ligne par `(schoolId, period)` avec `dataJson`, `pdfUrl`, `pdfHash`, `sentAt`.
| Route | Auth | Description |
|---|---|---|
| `GET /api/reports/monthly/[period]` | GERANT, PDF_EXPORT | Télécharge le PDF archivé |
| `POST /api/reports/monthly/generate` | GERANT, PDF_EXPORT | Génération manuelle mois en cours |
| `GET /api/cron/school-monthly-report` | CRON_SECRET | Cron `0 * 1 * *` (filtre heure locale école) |
`lib/pdf/monthly-school-report/` — 4 pages : synthèse, moniteurs, finances, alertes.
Sync Google Business Profile, réponses Claude, délai humain, validation gérant pour avis 1–3 étoiles.
| Fonction | Fichier |
|---|---|
| `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` |
| Méthode | Route |
|---|
Vue consolidée `/stravoda-admin/rgpd/master` (ADMIN) : registre Art. 30, DPA par école, effacements, violations CNIL, export pack compliance ZIP.
Complète la conformité RGPD et construit le système de preuves légales. Étend l'existant
(anonymisation, AuditLog, YouSign, PDF) — ne duplique rien.
| Brique | Emplacement | ||
|---|---|---|---|
| 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` (`ADMIN | SUPPORT | ANALYST`) |
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).
+ `ip` + `userAgent`. `flagged`/`flagReason` (signal faible non bloquant). `schoolId` rempli a posteriori.
(null = permanent), `isActive` (false = débloqué, jamais supprimé), `evidence?`/`notes?`. `@@unique([type,value])`.
+ `resolved`/`resolvedAt`/`resolvedBy`.
Migration : `prisma/migrations/20260626153000_security_anti_abuse` (à appliquer via `prisma migrate dev`).
suppression sous-adressage `+tag` → suppression des points pour domaines « dotless » (Gmail).
`"john"/"j0hn" ≈ 0.875`. Seuil suspect `EMAIL_SIMILARITY_SUSPECT_THRESHOLD = 0.85`.
Hub `/dashboard/parametres` — configuration multi-onglets scopée `schoolId` (session serveur).
| Onglet | Champs Prisma | Route API |
|---|---|---|
| École | logoUrl, primaryColor, siret, phone, email, website, address, city, postalCode, agrementNumber, agrementExpiry | `PATCH /api/school/profile`, `POST /api/school/logo` |
| Tarifs | defaultHourlyRates (Json) | `PATCH /api/school/rates` |
| Abonnement | (Stripe) | existant `SubscriptionCard` + portal |
| Planning | openingHours, defaultLessonDuration, instructorBuffer, maxDailyInstructorHours | `PATCH /api/school/planning` |
| Annulations | cancellationDeadlineHours, cancellationPenaltyHours, smsAacRouting | `PATCH /api/school/cancellation` |
| SMS | smsSenderName, smsReminderHoursBefore, googleReviewUrl | `PATCH /api/school/sms-settings` |
| Équipe | User + Instructor |
> 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/`).
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`QUEUED → SENT → DELIVERED | FAILED`. Producteurs (planning 5c, élèves 6b, rappels 7a) créent des
> Page gérant `/dashboard/statistiques` : taux réussite code/conduite, leçons moyennes avant permis, graphique 12 mois, comparaison moniteurs, occupation planning (Pro+).
Constantes statiques `NATIONAL_BENCHMARKS` dans `shared/constants.ts` (mise à jour manuelle 1×/an).
| Service | Fichier | Rô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.
| Route | RBAC | Gate | |||
|---|---|---|---|---|---|
| `GET /api/stats/occupancy?period=month\ | week\ | 3m\ | custom&from=&to=` | GERANT | `advancedStats` Pro+ |
> 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.
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.
paiements | Examens | Documents`. DOSSIER éditable (drawer) ; les autres onglets affichent l'existant
en lecture seule (édition complète = 6b/6c/Étape 8).
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…)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.
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.
(14r-8), Base Q&A (CRUD + import CSV, hors catégorie `SALES`), Documentation (sync depuis `docs/modules/*.md`
+ stats auto-apprentissage 14k).
`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 ».
(1ʳᵉ question user + réponse admin + catégorie suggérée par Claude).
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`.
| Fonction | Fichier |
|---|---|
| `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` |
| Méthode |
|---|
> 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.
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.
(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 »).
- `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.
facturable, colonnes `schoolId;Nom;Formule;Modules;Montant mensuel;Date début;Statut`, `;` + BOM UTF-8.
(`vercel.json`) → `captureMrrSnapshot`.
pas admin (jamais d'écran d'erreur) → `AdminShell`.
bouton export rendu si `role === 'ADMIN'`).
désactivés « bientôt » jusqu'à leur livraison), badge « ADMIN STRAVODA », rôle + email, logout.
`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`).
`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.
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).
lignes cliquables → fiche.
> 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`.
`encrypt(JSON.stringify({ schoolId, adminId, exp }))` (AES-256-GCM, `ENCRYPTION_KEY`), TTL
`IMPERSONATION_TTL_MS = 1h`.
+ vérifie `exp > now`. `null` sur échec déchiffrement / forme invalide / expiré (jamais d'exception).
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.
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).
`/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`).
`{ schoolId: uuid }`. Vérifie l'existence de l'école → pose le cookie chiffré (TTL 1h) → audit
`START_IMPERSONATION` (`userId = adminId`, `schoolId = cible`).
`END_IMPERSONATION` (si contexte présent).
(SUPPORT/ADMIN) → POST `start` → `router.push('/dashboard')`.
layout dashboard quand `session.impersonatedBy` ≠ null (« MODE IMPERSONATION — [école] » + « Quitter »
→ POST `end` → `router.push('/stravoda-admin/ecoles')`).
`trialMax = now + TRIAL_EXPIRING_SOON_DAYS j` · `pastDueBefore = now − PAST_DUE_OVERDUE_DAYS j` ·
`monthStart = 1ᵉʳ du mois courant`.
- `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).
email (insensitive), rôle ∈ {GERANT, COMPTABLE} (les MONITEUR sont gérés par école, hors scope),
`deletedAt:null`. Renvoie `schoolName`, `isActive`, `lastLoginAt`.
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.
résiliations du mois) + historique des changements de formule. Lecture seule (aucune action).
(ADMIN, `confirm` avant désactivation) → `PATCH`, met à jour l'état local.
> 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`.
`createdAt`, `@@index([email])`). Pas de `firstName/lastName` : fournis par l'invité à l'activation.
createdAt) + `pendingInvitations` (invitations non utilisées non expirées : id/email/role/expiresAt).
`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).
hash, renvoie l'email ; audit `RESEND_ADMIN_INVITATION`.
(`CONFLICT`), pas de désactivation du dernier ADMIN actif (`CONFLICT`). Audit `ACTIVATE_ADMIN`/
`DEACTIVATE_ADMIN`.
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`.
(`firstName`, `lastName`, `password ≥ PASSWORD_MIN_LENGTH`) · `SetAdminActiveSchema` (`isActive`).
rate-limit par token (`STUDENT_LINK_USE`). GET → `{ email, role }` ; POST → active.
`TeamInviteForm`, `TeamMembersTable`, `TeamPendingInvitations` (`src/components/admin/team/`).
> 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`.
Une ligne uniquement quand un type est surchargé ; absence de ligne = défaut du registre.
`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>`).
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).
`sampleVars` (aperçu), `wired`, `managedBy?`, `tone?` (`'urgent'` → header rouge).
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.
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}`).
isOverridden, wired, managedBy, tone}[]`.
`UPDATE_EMAIL_TEMPLATE` (schoolId null, payload `{type}`).
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`).
> 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`).
`refuseLabel`, `customizeLabel`, `analyticsDefaultEnabled` (false), `supportChatDefaultEnabled` (false),
`updatedAt`. Une seule ligne ; absence de ligne = défauts du registre.
(`stravoda_cookie_consent`). Constantes durées/longueurs/cache → `shared/constants.ts`.
(schoolId null, sans PII).
la config (ou défauts). Rate limit `API_PUBLIC`.
personnaliser) + panneau « personnaliser » (toggles Analytics + Support). `preview` → rendu inline (éditeur).
consentement absent → fetch endpoint public → affiche le bandeau ; au choix → écrit le cookie 1 an + masque.
toggles + aperçu bas de page via `CookieBanner preview`).
> 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).
= `actorUserId`), `from?`/`to?` (datetime ISO, borne `createdAt`), `cursor?` (uuid). Tous optionnels.
cursor sur `id` (`take = PAGE+1`, `skip:1` si cursor). `ADMIN_LOGS_PAGE_SIZE = 50`.
`ADMIN_LOGS_EXPORT_MAX = 5000`** lignes pour borner l'export).
passe `canExport = ADMIN`).
table + « Charger plus » (cursor) + bouton Exporter CSV (ADMIN, `window.open` route export avec filtres).
> 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).
`status` (`DRAFT|SCHEDULED|SENT|FAILED`), `scheduledAt?`, `sentAt?`, `sentBy` (adminId), `resendBatchId?`.
`@@index([status, scheduledAt])`.
`sentAt?`. `@@index([campaignId, status])`.
ALL_INSTRUCTORS | INSTRUCTORS_OF_SCHOOL`. `CampaignSegmentSchema` (refine : `BY_TIER`→`tier`,
`BY_PERMIT`→`permitType`, `SPECIFIC_GERANT`/`INSTRUCTORS_OF_SCHOOL`→`schoolId`).
mode: 'SEND_NOW'|'SCHEDULE', scheduledAt? }` — refine : `scheduledAt` futur si `SCHEDULE`),
`ImproveSubjectSchema` (`{ subject }`), `CampaignsQuerySchema` (`{ cursor? }`).
`resend.batch.send`. Renvoie les index envoyés/échoués (best-effort, pas de double-envoi).
(interpolation `{{prenom}} {{ecole}} {{tier}} {{trialEnd}} {{dashboardUrl}}` + enveloppe email-safe).
destinataire), `countRecipients(segment)`. Source serveur uniquement (filtres `School`/`User`).
persiste campagne + recipients QUEUED ; `SEND_NOW`→dispatch immédiat, `SCHEDULE`→`scheduledAt`),
`resendBounces(id, adminId)` (re-QUEUE les `BOUNCED`).
`drainScheduledCampaigns(now)` (cron : passe les `SCHEDULED` échues en envoi + draine les `QUEUED` restants).
(`canSend = ADMIN`). `EmailsSubNav` (Transactionnels / Campagnes) ajouté aux deux pages emails.
sujet `[✨ Améliorer]` + éditeur HTML + variables + aperçu iframe sandbox + envoyer/planifier),
`SegmentPicker`, `CampaignHistory`, `CampaignDetailDrawer`.
`computeMrr` (réconciliation, TRIALING inclus, mensualisation annuelle, tier null), `getAdminDashboard`,
`buildMonthlyReportCsv`.
ANALYST→200 (recherche transmise) / cursor non-uuid→422 (Zod, service non appelé).
`take+1`, recherche `OR`, page vide → aucun groupBy) ; `getSchoolDetail` (métriques, revenus 0, NOT_FOUND).
`captureMrrSnapshot` (1 insert), `seedMrrSnapshots` (proxy `createdAt` sur N jours + idempotence).
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).
`getSubscriptionsOverview` (mapping des 3 alertes + dates + historique formules, lecture défensive payload).
`setUserActive` (NOT_FOUND, idempotent, audit DEACTIVATE/ACTIVATE_USER).
(centimes), 4 compteurs de statut, `@@index([snapshottedAt])`.
(Le seed reste un proxy `createdAt` ponctuel pour amorcer l'historique ; les snapshots quotidiens
suivants sont des points réels datés.)
`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.
actif » sur la désactivation (responsabilité ADMIN) — à ajouter si besoin de filet anti-lock-out.
Schéma query : `shared/schemas/audit.ts` (`entityType`, `from`, `to`, `userId`, `cursor`).
| Route | Rô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`) |
i18n : `locales/fr/audit.json`.
| 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).
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` | Template | Ton | Destinataire |
|---|---|---|---|
| `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).
`SignupSchema` · `LoginSchema` (+ `turnstileToken?`) · `ResetRequestSchema` · `ResetConfirmSchema`.
`shared/schemas/school-security.ts` — `UpdateSchoolSecuritySchema`.
`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`.
Confirm email = ON · Redirect URL `…/api/auth/callback` · Provider Google activé (Client ID + Secret).
| `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.
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).
(sinon `PackagesTab` lecture seule). Données initiales fournies par la RSC (pas de fetch en effet).
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.
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.
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).
Schéma : `Payment.refundedAt DateTime?`, `Payment.refundAmount Float?`.
Service (`billing/refund.ts`) : `createStripeRefund(schoolId, paymentId, { amount?, reason? }, actorUserId) → { refundId, amount, status }`.
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.
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).
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).
Schéma : modèle `Avoir` (existant) désormais alimenté — `amount`, `hoursRefunded`, `reason`, `pdfUrl?`, `processedAt`, `processedBy`, relations `package` + `student`, scopé `schoolId`.
Service (`billing/avoir.ts`) :
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`).
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).
---
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) :
Service (`subscription.service.ts`) :
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).
---
> 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`.
---
> 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`) :
CRUD établissements (`school/mutations/sites.ts`, tier `ECOLE` + MFA AAL2) :
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).
---
Cycle de vie complet de l'abonnement SaaS de l'école après la création de l'essai (8c-3).
`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`.
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`.
---
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`.
---
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`.
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).
| `/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` |
| Méthode | Route | Rôle | Effet |
|---|---|---|---|
| GET | `/api/dashboard/guide` | GERANT | état courant (+ `welcomePending`) |
| POST | `/api/dashboard/guide/dismiss` | GERANT | masquer la checklist |
| POST | `/api/dashboard/guide/reopen` | GERANT | réafficher |
| POST | `/api/dashboard/guide/welcome-seen` | GERANT | marquer overlay vu |
`ONBOARDING_COMPLETE` — best-effort via `notifyOnboardingGuideComplete` à la 5ᵉ étape.
`src/test/dashboard-guide.test.ts` — normalisation legacy, markStep, completion email, welcomePending, dismiss/reopen, stats admin.
`isKnownInboundSender`, import dedup, SMS dispatch, exports, PDF…).
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).
`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`).
`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é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.
chiffrement s'applique aussi dans les transactions interactives.
pré-migration lisibles, déploiement progressif sûr.
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 :
+ `history` (10 dernières `COMPLETED`, avec `instructorNote`).
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 ».
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`).
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.
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 }`.
`{ 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.
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.
(`eleve/documents.ts`, pur) sur la sortie de `getStudentDocumentsChecklist`. `items` = documents
requis (statut + nom de fichier) ; `contract` = `CONTRAT_FORMATION` `VALIDE` (sinon `null`).
(`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).
`{ 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.
contrat signé ; client `portalUpload(path, formData)` (multipart, sans `Content-Type` forcé).
| Fonction | Entrée | Sortie | Notes |
|---|---|---|---|
| `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` |
· `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é).
`components/eleve/{EleveAccessForm,EleveSpace,EleveLessonsTab,EleveBookingTab,EleveCancelButton,EleveProgressTab,EleveDocumentsTab,EleveContactTab,api,format,constants}`.
`booking`/`documents`) ; labels types via `documentType.*` (common) ; clés SMS `student.bookingConfirmed`,
`student.bookingRefused` (11b-2), `instructor.new_request_notify`.
`createSignedDocumentUrl`/`validateUpload` — aucune migration. Constantes `REQUIRED_DOCUMENT_TYPES`/
`MINOR_REQUIRED_DOCUMENT_TYPE`/`UPLOAD_*`/`DOCUMENT_SIGNED_URL_TTL_SEC`.
(`lib/services/moniteur/booking-requests.ts` — accept réutilise `createLessonInTx`, refuse → SMS
`student.bookingRefused`). Détails dans `docs/modules/moniteur.md`.
agrégat heures (+ clamp), anti-énumération.
moniteur, lead time, filtrage des créneaux (conflit/indispo/horaires), annulation scopée `studentId`
(isolation), `NOT_FOUND`/`CONFLICT`.
(contrat/devis → `FORBIDDEN`), download contrat `VALIDE` scopé `{id,studentId,schoolId}` (isolation),
refus non-contrat/non-`VALIDE`, `NOT_FOUND`, garde type de lien.
branché dans `assertNoConflict` (create + reschedule), `createRecurringLessons`, `replaceInstructor`,
`getReplacementOptions`. Erreur `planning.errors.instructorUnavailable`.
- `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.
+ plage récurrente ≤ 24 h.
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`Moniteurs de l'école (non supprimés) + stats agrégées. Nombre de requêtes constant (zéro N+1).
`CONFLICT` (`instructors.errors.versionConflict`) si périmée ; audit `UPDATE_INSTRUCTOR`.
(`instructors.errors.hasFutureLessons`) si leçons futures PENDING/CONFIRMED ; audit `DELETE_INSTRUCTOR`.
| Stat | Formule | `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érents | 0 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érents | jamais (0 € possible) |
Revenus = approximation : pas de lien `Leçon↔Forfait` (dette transverse). Attribution via élève
référent (`primaryInstructorId`). Libellé UI « estimation ».
un override par moniteur nécessiterait un champ + câblage dans la détection de conflits du planning.
réaliste < 50). `findMany` ordonné sans offset. Dénormaliser/paginer si une école dépasse l'échelle MVP.
(constant en nb de requêtes). Acceptable à l'échelle MVP ; dénormaliser si volume important.
leçons ; affinage fuseau école = dette commune planning). Pas d'édition (delete + recreate), récurrences
hebdomadaires seulement.
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).
- `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`.
+ `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`.
`GET/POST /api/activate/[token]` (public, rate `STUDENT_LINK_USE` par token haché, runtime nodejs).
`/[locale]/activate/[token]` + `ActivateForm` (email pré-rempli + mot de passe → succès + lien connexion).
`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).
`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.
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é.
`text/calendar; charset=utf-8` en pièce jointe (`Content-Disposition: attachment`).
(URL signée + token haché) qui se mettrait à jour dans le calendrier. Événements en UTC.
| Zone | Fichier | Pattern |
|---|---|---|
| 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` |
| Sous-bloc | Contenu |
|---|---|
| 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` |
| 10b | Accueil `/moniteur` : leçons du jour + compléter (+ note ≤500) / no-show + « mes prochains jours » + badge demandes (réel, 11b-2) + bouton incident (placeholder) |
| 11b-2 | Demandes `/moniteur/demandes` : liste des `BookingRequest` PENDING + accepter (crée la leçon, re-check conflit) / refuser (SMS élève) + badge accueil réel |
| 10c | Planning `/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 |
| 10e | Profil `/moniteur/profil` : infos (lecture) + indispos ponctuelles (vue/ajout/suppr, warn conflit) + iCal + stats du mois |
`instructorId` non nul, sinon `FORBIDDEN`, renvoie l'`instructorId`).
ignore tout `instructorId` passé en query (forcé serveur).
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)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èveapp/[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)garde `status ∈ {PENDING, CONFIRMED}` (idempotence : 2ᵉ tap / annulation concurrente → `CONFLICT`) ;
`updateMany` gardé par `version` (verrou optimiste) → `version++`. `revalidateTag(school:${id}:planning)`.
conflits sont calculés et renvoyés pour avertissement (aucune leçon modifiée).
`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.
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`.
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 }| Permis | Module facturé |
|---|---|
| `B`, `AM` | base voiture (0 €) |
| `A`, `A1`, `A2` | Module Moto (+29 €) |
| `BATEAU_COTIER`, `BATEAU_HAUTURIER` | Module Bateau (+29 €) |
- `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`.
Confirmation / reset partent via Resend (SMTP custom Supabase) — aucun changement de code, redirect URLs (`…/api/auth/callback`) inchangées.
`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).
`PARENT_ACCESS` dans `EMAIL_TEMPLATES_CONFIG`). Échec email non bloquant (`.catch`).
`requestParentPortalAccess(phone, locale)` (`parent/access.ts`) :
numéro normalisé (`PortalAccessSchema`). Aucune correspondance → silence (anti-énumération).
plafond téléphone Redis `portal-parent-access:phone` (`tryConsumeParentPortalAccessPhoneLimit`).
`getParentSpace(token): ParentSpace` (`parent/space.ts`) :
donc aucune mutation possible).
- 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).
Périmètre strict : jamais de facturation, NEPH, note interne, document ni action.
| Fonction | Entrée | Sortie | Notes |
|---|---|---|---|
| `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` |
`components/parent/{ParentSpace,ParentAccessForm}.tsx` (RSC/form, ≤250 l.),
bouton dans `components/students/StudentShareActions.tsx`.
`students.share.parentLink*` + `students.errors.parentLinkUnavailable` ; REMC via `pedagogy.*`,
types de leçon via `lessonType.*`. SMS `student.parentAccess` (`SMS_TEMPLATE_KEYS.PARENT_ACCESS`).
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`).
rate limit IP/téléphone), CTA lien expiré → `/parent/acces`.
Libellés C1–C4 par catégorie dans `locales/fr/pedagogy.json` → `competenciesByCategory.{VOITURE|MOTO|BATEAU}`. Composants : `StudentProgressTab`, `EleveProgressTab`, `ParentSpace` (prop `permitType`).
`src/test/permits.test.ts` — catégories, couleurs, tarifs par défaut.
npx prisma migrate dev --name add_permit_types_b96_bemoniteur/véhicule filtrés par type de permis, type de leçon, date/heure pré-remplie, durée = `school.defaultLessonDuration`, lieu optionnel).
avec verrou optimiste (`version`). Version périmée → toast « modifié entre-temps » + rechargement (revert de l'event).
- 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).
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`.
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`).
leçons actives du jour en `TO_RESCHEDULE` (initié par l'école → aucune pénalité), + SMS par élève.
- `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).
L'envoi réel (file + retry + rendu du template) est le module SMS (Étape 7) ; son dispatcher rendra le
contenu depuis `templateKey` + `lessonId`.
> 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`.
(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.
`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é.
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).
de `revalidatePlanning` → heures restantes à jour sur la fiche élève / espace élève.
- 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 } }`.
- `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.
- `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).
| Catégorie | Permis | Accent |
|---|---|---|
| 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.
| Statut | Fond | Texte |
|---|---|---|
| `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` |
`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.
encore dans le codebase (auth/abonnement n'auditent pas non plus) → à traiter comme module transverse uniforme,
pas un one-off planning.
À remplacer par un endpoint de recherche dédié au module Élèves (Étape 6).
rendu template) reste à construire ; il rendra `content` depuis `templateKey` + `lessonId`.
+ 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).
Acceptable à l'échelle d'une journée ; à optimiser si besoin.
| Fichier | Fonctions | Point 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` |
`QUOTA_EXCEEDED` (IA), `SMS_QUOTA_EXCEEDED`, `STORAGE_QUOTA_EXCEEDED`, `EMAIL_QUOTA_EXCEEDED`
(clés i18n : `errors.aiQuotaExceeded`, `errors.smsQuotaExceeded`, `errors.storageQuotaExceeded`,
`errors.emailQuotaExceeded`).
restant est insuffisant.
le crédit déjà décompté.
jamais d'email récursif depuis les chemins SMS/email.
public, emails système/admin) ne décomptent pas.
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`.
Visible si le groupe compte ≥2 établissements.
| Route | Auth | Description |
|---|---|---|
| `GET /api/reports/monthly/[period]/consolidated` | GERANT, PDF_EXPORT | PDF consolidé multi-sites |
| `GET /api/reports/monthly/[period]/per-site` | GERANT, PDF_EXPORT | ZIP (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 |
Toute requête `GoogleReview` filtre `schoolId` depuis la session.
sur `School` (distinct de `deletedAt` qui reste le soft-delete). L'école archivée n'est pas
supprimée (conservation 5 ans, obligations comptables).
(cohérent avec `student-documents`). Pas de chiffrement par mot de passe.
déclenchable côté admin/gérant. Pas de câblage dans l'onboarding pour l'instant.
> État : 14z-4 COMPLET — 14z-4a ✅ · 14z-4b ✅ · 14z-4c ✅ · 14z-4d ✅ · 14z-4e ✅.
`registerReferral`.
accordé — Prénom (anonymisé si `deletedAt`) · Date · IP · Canal (SMS/Marketing).
`signedAt?`, `signatureHash?`).
sous-traitants `LEGAL_SUBPROCESSOR_NAMES`, droits, obligations).
ZIP export final (JSON + CSV paiements + PDF historique agrégé) email gérant + bucket.
Incidents. Export ZIP dossier complet (DPA signé · AuditLog CSV · Factures · Consentements).
`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.
compatible Edge) avant tout routing. Bloqué → `403` JSON. Fail-open (panne Redis ≠ blocage).
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`.
`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`.
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 :
+ SMS best-effort au gérant) + audit `TRIAL_ENDING_NOTIFIED`. (Stripe émet cet event ~3 j avant la fin.)
Service `lib/services/admin/security/` (`overview`, `events`, `blocked`, `trial-abuse`, `windows` PUR) :
résoudre, détail JSON).
Badge rouge sidebar = `countSecurityBadge` (alertes agrégées : auth échouée, événements HIGH/CRITICAL, SMS, incidents stale).
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) :
`signIn` journalise `LOGIN_FAILED` (email, IP, User-Agent) via `lib/audit/login-failed.ts`.
`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-sites | parentSchoolId, childSchools | `GET /api/school/multi-site` (lecture) |
| RGPD | export + stats anonymisation | `GET /api/school/export`, `POST /api/students/[id]/delete` |
| Documents | contractClausesText, formationPlanText | `PATCH /api/school/document-templates` |
| DREAL | — | existant `DrealReportCard` |
`POST /api/public/leads` — rate limit `API_PUBLIC`, body Zod `PublicLeadSchema`. Crée un élève (`acquisitionSource: WIDGET`), enfile SMS onboarding (`onboarding.welcome`).
`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).
`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.
É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.
`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).
| Rappel | Source | Idempotence |
|---|---|---|
| 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 > 0 | dédup 30 j |
Forfait bas / inactif : requêtes `$queryRaw` (comparaison de colonnes `totalHours-usedHours`) + lien
portail élève (`PROFILE`, TTL 24h).
| Rappel | Source | Idempotence |
|---|---|---|
| Avis Google (`reputation.ts`) | `ExamDate CONDUITE/RECU`, `updatedAt ∈ [−7j,−24h]`, `School.googleReviewUrl` présent | flag `reviewRequestSent` (garde `updateMany`) |
| Relance document (`documents.ts`) | élève `selfOnboardedAt ≤ −24h`, un document requis encore manquant | dé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`.
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`.
(`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).
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.
pénalité auto hors `cancellationDeadlineHours`) + enfile `booking.cancelled_student`.
`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).
Migration `sms_dispatch` (7a) :
Migration `sms_campaigns_reputation` (7b) :
`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).
gate marketing + isolation `schoolId`, audit sans PII, idempotence avis Google (`reviewRequestSent`),
relance document (aucun envoi si tous présents).
`/api/cron/sms-dispatch` → `*/5 * * * *` · `/api/cron/sms-reminders` → `0 * * * *`.
`updateMany` avec `schoolId`). Tests d'isolation dans `src/test/student-service.test.ts`.
→ `count===0` ⇒ `CONFLICT` (`students.errors.versionConflict`).
`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).
via `writeAuditLog` (SHA-256). GÉRANT/COMPTABLE non tracés en lecture (accès global légitime).
obligatoire ; téléphone E.164/national normalisé ; unicité téléphone par école.
`PERMIT_CONFIG`/`permitTypes`. Zéro string en dur.
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).
rate limit `PDF_EXPORT`) → audit `EXPORT_STUDENT_DATA`. JSON complet / CSV aplati champ-valeur.
+ 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).
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).
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.
`${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).
`lib/storage/documents.ts` (`uploadDocumentFile` / `createSignedDocumentUrl` / `removeDocumentFile`,
`ensureBucket` idempotent au 1ᵉʳ accès).
- `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).
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.
`initialItems`, refetch après mutation ; upload/remplacement, valider/rejeter, téléchargement). `canManage`
dérivé du rôle (GERANT/COMPTABLE).
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.
`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.
`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`).
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`.
+ `pdf-render.test.ts` (2, rendu réel → magic bytes `%PDF`).
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).
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`).
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).
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).
`POST /api/webhooks/yousign` (hors middleware — matcher exclut `/api` ; HMAC = seule authentification,
corps lu en `req.text()` avant parsing).
(URL publique HTTPS, event `signature_request.done`). Les appels API restent à valider en sandbox.
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_SECRETmatrice `string[][]` (`lib/import/parse.ts`, bornes taille 5 Mo / `STUDENT_IMPORT_MAX_ROWS`).
+ correction manuelle dans l'UI. `IMPORT_FIELDS` = prénom, nom, téléphone (requis), email, naissance,
permis, NEPH, adresse.
`{created, skipped, errors:[{row, reason}], createdStudentIds}` , audit `IMPORT_STUDENTS`.
(`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`).
l'UI ajuste → `POST /api/students/import` (fichier + mapping). GERANT, runtime nodejs, rate `FILE_UPLOAD`.
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}.tsxRé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`.
`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`.
ordre C1→C6) ; `upsertStudentProgress` résout l'`instructorId` de l'évaluateur depuis `User`, `upsert`, audit
`UPDATE_PROGRESS`, `revalidateStudents`. Tout scopé `schoolId` (session).
(`UpsertProgressSchema`), rate `API_AUTHENTICATED`.
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')`.
par ligne (rollback + message si l'API échoue), affichage moniteur évaluateur + date ; données initiales chargées serveur.
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 ASSRtransaction atomique `ExamDate` (+ `Student` si REÇU CODE : `codeObtained`, `codeObtainedAt`, `codeScore`).
- 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.
(`ExamResultDrawer`).
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 + auditrelations) → 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.
Seul `primaryInstructorId` accepte un `null` explicite (retrait du référent).
→ différés (ajout de scalaires = migration dédiée si retenus).
l'audit lecture est exercé via la route et testé.
enum `DocumentType.ATTESTATION_CODE`.
toggle Liste/Pipeline dans `StudentsClient`.
- `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)
Similarité : `shared/text-similarity.ts` (Levenshtein normalisé, pur, testable).
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.
le contexte étendu est chargé serveur (`buildAccountContext`, scopé `session.schoolId`).
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.
`parseDiagnosticActions` n'autorise QUE les chemins internes (`/dashboard`, `/moniteur`) — liens externes retirés (anti-phishing).
d'escalade + ticket `PENDING_HUMAN` + notification interne.
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).
**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`).**
cliquables (avant le 1er message), carte d'escalade (CTA `/signup` + `/contact`). Style aligné design system (`bg-brand`, `card`, `input-base`, bulles comme `SupportWidget`).
`userId=null`, `source='LANDING'`, fil identifié par `sessionId`. L'isolation école reste intacte (les routes
école filtrent toujours par `session.schoolId`).
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).
`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.
`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`.
`session.schoolId` (nom, tier, permis, onboarding) — jamais depuis le body. Conversations scopées
`schoolId`+`userId` en lecture côté utilisateur.
index GIN full-text créé en SQL brut dans la migration (cf. ci-dessous). `isPublic` → visible sur `/documentation`.
`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`.
`[isActive]`, `[isActive,isPublic]`. `isPublic` → visible sur `/aide` (catégorie `SALES` réservée au chat landing).
CREATE INDEX "SupportDocument_fulltext_idx"
ON "SupportDocument"
USING GIN (to_tsvector('french', title || ' ' || content));| Fonction | Entrée | Sortie | Rè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étail | admin, 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 10 | conversations `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 |
détection de phrases de non-réponse → `LOW`.
| Méthode | Route | Rôle | Effet |
|---|---|---|---|
| POST | `/api/support/chat` | gérant/moniteur | réponse IA + log conversation (+ ticket si LOW) ; 14l `analyzeAccount` → contexte compte + actions + audit `SUPPORT_ACCOUNT_ANALYSIS` |
| GET | `/api/support/chat?sessionId=` | gérant/moniteur | recharge le fil (scopé) |
| GET | `/api/admin/support/conversations` | SUPPORT | liste (filtre statut + cursor) |
| POST | `/api/admin/support/conversations/[id]/reply` | SUPPORT | réponse humaine + notif gérant + audit `REPLY_SUPPORT` |
| GET | `/api/admin/support/conversations/[id]/qa-suggestion` | SUPPORT | suggestion Q&A pré-remplie (14k) |
| POST | `/api/admin/support/conversations/[id]/add-to-knowledge` | SUPPORT | valide Q&A + marque conversation + audit `ADD_SUPPORT_QA_FROM_CONV` |
| GET | `/api/admin/support/knowledge-stats` | SUPPORT | stats documentation (14k) |
| POST | `/api/admin/support/generate-qa` | SUPPORT | propositions Q&A IA (14k) |
| GET/POST | `/api/admin/support/qa` | SUPPORT | liste / création Q&A |
| PATCH | `/api/admin/support/qa/[id]` | SUPPORT | maj / désactivation Q&A |
| POST | `/api/admin/support/qa/import` | SUPPORT | import CSV |
| POST | `/api/admin/support/sync-docs` | ADMIN | sync base de connaissances + audit `SYNC_SUPPORT_DOCS` |
| PATCH | `/api/admin/support/documents/[id]` | SUPPORT | maj `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` | ADMIN | seed idempotent des Q&A commerciales `category=SALES` + audit `SEED_SALES_QA` (14r-8) |
| POST | `/api/admin/seed-help-qa` | ADMIN | seed idempotent des 42 Q&A publiques `/aide` (hors `SALES`, `isPublic=true`) + audit `SEED_HELP_QA` |
| GET | `/api/admin/support/conversations?source=` | SUPPORT | liste scopée par source (`DASHBOARD`/`LANDING`, 14r-8) |
| GET | `/api/admin/support/qa?category=&excludeCategory=` | SUPPORT | liste Q&A filtrée par catégorie (14r-8) |
« Support chat » de 12g visait un chat tiers public, non implémenté).
`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 |
`VISITE_MEDICALE`, `CERTIFICAT_CPC`, `FIMO`, `CERFA_SUIVI` — via `shared/document-requirements.ts`.
`hasCpcModule(tier)` → `true` uniquement si `tier === 'TRANSPORT'`.
Toute requête `CpcRecord` filtre `schoolId` depuis la session serveur.
statuts CT/assurance dérivés des dates d'échéance et des seuils.
(`vehicles.errors.versionConflict`) si périmée ; audit `UPDATE_VEHICLE`.
(`vehicles.errors.hasFutureLessons`) si leçons futures PENDING/CONFIRMED ; audit `DELETE_VEHICLE`.
| Statut | Condition |
|---|---|
| `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.