Admin : CRUD des scénarios de didacticiel + analytics de complétion #44

Closed
opened 2026-09-05 19:36:32 +02:00 by kyuno · 0 comments
Owner

Contexte

Suite à l'issue #42
(tutoriel guidé) : permettre à un admin de créer/modifier/supprimer les
scénarios de didacticiel depuis l'interface admin, plutôt que de les avoir
codés en dur. Bonus : analytics sur leur taux de complétion.

Ça révise directement la conception de #42, qui prévoyait un registre
TourDefinition[] statique en code (fichier TypeScript) avec des titres/
contenus en clés i18n. Une fois éditable depuis un formulaire admin, le
contenu d'une étape ne peut plus raisonnablement être une clé i18n (un admin
ne va pas éditer translation.json à la main) — il doit devenir du texte
libre stocké en base, exactement comme le nom/la description d'une recette
sont déjà du texte libre plutôt que des clés i18n (la règle "tout texte
affiché vient d'i18next" concerne le chrome de l'UI codé en dur, pas le
contenu généré/administré dynamiquement — même distinction déjà appliquée
partout ailleurs dans ce repo). #42 doit être implémenté après ou avec
cette issue-ci
, en consommant ce modèle plutôt que le registre statique
initialement prévu — un commentaire sera ajouté sur #42 pour le signaler.

Modèle de données

/// Catalogue des cibles de surlignage valides — même esprit que TechStep/Unit/Diet :
/// une valeur de référence maintenue par le code (chaque clé correspond à un
/// attribut data-tour-<key> qui existe réellement quelque part dans le
/// frontend). Un admin choisit parmi ces cibles en construisant un scénario,
/// il ne peut pas en inventer une nouvelle depuis le formulaire — ajouter une
/// vraie nouvelle cible nécessite toujours un changement de code (poser le
/// data-tour-* sur un élément) + une ligne seedée ici, comme ajouter un
/// TechStep nécessite un changement du corpus d'entraînement.
model TourTarget {
  key         String @id
  description String // pour aider l'admin à choisir : "Bouton + d'un créneau du planning"

  steps TourScenarioStep[]
  @@map("tour_target")
}

model TourScenario {
  id        Int      @id @default(autoincrement())
  key       String   @unique // "general" | "techStepMetadata" | libre pour un nouveau scénario admin
  name      String   // libellé admin, ex. "Tour d'orientation"
  enabled   Boolean  @default(true) // désactiver sans supprimer
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  steps    TourScenarioStep[]
  progress TourProgress[]
  @@map("tour_scenario")
}

model TourScenarioStep {
  id         Int    @id @default(autoincrement())
  scenarioId Int    @map("scenario_id")
  order      Int
  route      String? // route à naviguer avant cette étape ; null = même route que la précédente
  targetKey  String  @map("target_key")
  title      String  // texte libre, saisi par l'admin — pas une clé i18n
  content    String

  scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
  target   TourTarget   @relation(fields: [targetKey], references: [key])

  @@unique([scenarioId, order])
  @@map("tour_scenario_step")
}

/// Révise la version prévue par #42 : `tourKey: String` devient une vraie FK
/// vers TourScenario plutôt qu'une chaîne libre.
model TourProgress {
  userProfileId Int      @map("user_profile_id")
  scenarioId    Int      @map("scenario_id")
  completedAt   DateTime @default(now()) @map("completed_at")

  userProfile UserProfile  @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
  scenario    TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)

  @@id([userProfileId, scenarioId])
  @@map("tour_progress")
}

Suppression d'un scénario → cascade sur ses étapes et sur les lignes de
progression (cohérent : si le scénario n'existe plus, son statut "vu" par
tel ou tel utilisateur n'a plus de sens).

API admin (/admin/api/tours, requireAdmin, même agrégateur que

admin.routes.ts's adminRouter.use("/catalog", ...) etc.)

Route Rôle
GET /admin/api/tours Liste les scénarios avec leurs étapes
GET /admin/api/tours/targets Liste TourTarget (pour le select du formulaire)
POST /admin/api/tours Crée un scénario (key, name)
PATCH /admin/api/tours/:id Édite name/enabled et remplace la liste d'étapes en bloc — même convention que recipe.service.ts's updateRecipe, qui supprime/recrée tous les Step d'une recette plutôt qu'un patch partiel, pas une nouveauté à inventer
DELETE /admin/api/tours/:id Supprime le scénario

API publique (lecture, pour le moteur de tour de #42)

GET /reference/tours (public, sans requireAuth — même famille que
/reference/diets//allergies/etc.) → scénarios activés avec leurs
étapes, forme consommée directement par useTour() (#42) au lieu de son
registre statique initialement prévu.

Interface admin

Nouvelle page /admin/didacticiels (+ entrée dans AdminLayout.tsx's
NAV_ITEMS, même array que Dashboard/Monitoring/Corrections/Catalogue) :
liste des scénarios (nom, activé/désactivé, nombre d'étapes) → éditeur par
scénario (nom, activé, liste d'étapes réordonnables avec route/cible/titre/
contenu, ajout/suppression d'étape). Nouveau scénario = mêmes champs, key
saisie une fois à la création.

Bonus — analytics de complétion

Coût quasi nul, l'infrastructure existe déjà en quasi-totalité :
admin-metrics.service.ts's getMetrics() agrège déjà tout
AnalyticsEvent par type en séries temporelles journalières
(events: MetricsEventSeries[], lignes 190-204), et
DashboardPage.tsx (lignes 120-135) rend déjà cette liste génériquement
(metrics.events.map(...)) — un type d'événement inédit apparaît sur le
dashboard sans aucun changement frontend. Il suffit donc d'émettre,
côté POST /profile/tours/:key/complete (#42) :

analytics.recordEvent("tour.completed", { actorId: userId, context: { tourKey } });

et pareillement "tour.skipped" si un tour est passé plutôt que terminé
(distinction utile pour juger si un tour est trop long) — même fonction
analytics.recordEvent que planning.item_added/tech_step.correction_submitted
etc., rien de neuf à construire à ce niveau.

Fichiers à toucher

  • apps/api/prisma/schema.prisma — TourTarget/TourScenario/
    TourScenarioStep, TourProgress révisé + migration + seed initial des
    TourTarget correspondant aux data-tour-* posés par #42.
  • apps/api/src/modules/admin/admin-tours.routes.ts/.service.ts (nouveaux)
    • branchement dans admin.routes.ts.
  • apps/api/src/modules/reference/ — ajoute GET /reference/tours.
  • apps/web/src/pages/admin/tours/ (nouveau) — page CRUD.
  • apps/web/src/layouts/AdminLayout.tsx — entrée de nav.
  • Emission analytics.recordEvent("tour.completed"/"tour.skipped", ...)
    dans le endpoint de complétion de #42.

Tests

  • Mocha : CRUD complet du scénario (création, remplacement des étapes en
    bloc, suppression en cascade) ; GET /reference/tours ne renvoie que les
    scénarios activés.
  • Cypress : parcours admin création/édition/suppression d'un scénario
    (mock cy.intercept, même convention que le reste de la suite admin).

Hors scope

  • Prévisualisation en direct d'un scénario depuis l'admin (ouvrir l'app pour
    voir le rendu réel) — juste l'édition des données pour cette itération.
  • Rétro-mesure de complétion pour les tours déjà vus avant ce ticket (aucun
    historique n'existe avant que #42 ne soit codé de toute façon).

🤖 Filed by Claude Code

## Contexte Suite à [l'issue #42](https://git.dev.kyuno.fr/kyuno/batchCooking/issues/42) (tutoriel guidé) : permettre à un admin de créer/modifier/supprimer les scénarios de didacticiel depuis l'interface admin, plutôt que de les avoir codés en dur. Bonus : analytics sur leur taux de complétion. **Ça révise directement la conception de #42**, qui prévoyait un registre `TourDefinition[]` statique en code (fichier TypeScript) avec des titres/ contenus en clés i18n. Une fois éditable depuis un formulaire admin, le contenu d'une étape ne peut plus raisonnablement être une clé i18n (un admin ne va pas éditer `translation.json` à la main) — il doit devenir du texte libre stocké en base, exactement comme le nom/la description d'une recette sont déjà du texte libre plutôt que des clés i18n (la règle "tout texte affiché vient d'i18next" concerne le chrome de l'UI codé en dur, pas le contenu généré/administré dynamiquement — même distinction déjà appliquée partout ailleurs dans ce repo). **#42 doit être implémenté après ou avec cette issue-ci**, en consommant ce modèle plutôt que le registre statique initialement prévu — un commentaire sera ajouté sur #42 pour le signaler. ## Modèle de données ```prisma /// Catalogue des cibles de surlignage valides — même esprit que TechStep/Unit/Diet : /// une valeur de référence maintenue par le code (chaque clé correspond à un /// attribut data-tour-<key> qui existe réellement quelque part dans le /// frontend). Un admin choisit parmi ces cibles en construisant un scénario, /// il ne peut pas en inventer une nouvelle depuis le formulaire — ajouter une /// vraie nouvelle cible nécessite toujours un changement de code (poser le /// data-tour-* sur un élément) + une ligne seedée ici, comme ajouter un /// TechStep nécessite un changement du corpus d'entraînement. model TourTarget { key String @id description String // pour aider l'admin à choisir : "Bouton + d'un créneau du planning" steps TourScenarioStep[] @@map("tour_target") } model TourScenario { id Int @id @default(autoincrement()) key String @unique // "general" | "techStepMetadata" | libre pour un nouveau scénario admin name String // libellé admin, ex. "Tour d'orientation" enabled Boolean @default(true) // désactiver sans supprimer createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") steps TourScenarioStep[] progress TourProgress[] @@map("tour_scenario") } model TourScenarioStep { id Int @id @default(autoincrement()) scenarioId Int @map("scenario_id") order Int route String? // route à naviguer avant cette étape ; null = même route que la précédente targetKey String @map("target_key") title String // texte libre, saisi par l'admin — pas une clé i18n content String scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) target TourTarget @relation(fields: [targetKey], references: [key]) @@unique([scenarioId, order]) @@map("tour_scenario_step") } /// Révise la version prévue par #42 : `tourKey: String` devient une vraie FK /// vers TourScenario plutôt qu'une chaîne libre. model TourProgress { userProfileId Int @map("user_profile_id") scenarioId Int @map("scenario_id") completedAt DateTime @default(now()) @map("completed_at") userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) @@id([userProfileId, scenarioId]) @@map("tour_progress") } ``` Suppression d'un scénario → cascade sur ses étapes et sur les lignes de progression (cohérent : si le scénario n'existe plus, son statut "vu" par tel ou tel utilisateur n'a plus de sens). ## API admin (`/admin/api/tours`, `requireAdmin`, même agrégateur que `admin.routes.ts`'s `adminRouter.use("/catalog", ...)` etc.) | Route | Rôle | |---|---| | `GET /admin/api/tours` | Liste les scénarios avec leurs étapes | | `GET /admin/api/tours/targets` | Liste `TourTarget` (pour le select du formulaire) | | `POST /admin/api/tours` | Crée un scénario (`key`, `name`) | | `PATCH /admin/api/tours/:id` | Édite `name`/`enabled` **et** remplace la liste d'étapes en bloc — même convention que `recipe.service.ts`'s `updateRecipe`, qui supprime/recrée tous les `Step` d'une recette plutôt qu'un patch partiel, pas une nouveauté à inventer | | `DELETE /admin/api/tours/:id` | Supprime le scénario | ## API publique (lecture, pour le moteur de tour de #42) `GET /reference/tours` (public, sans `requireAuth` — même famille que `/reference/diets`/`/allergies`/etc.) → scénarios **activés** avec leurs étapes, forme consommée directement par `useTour()` (#42) au lieu de son registre statique initialement prévu. ## Interface admin Nouvelle page `/admin/didacticiels` (+ entrée dans `AdminLayout.tsx`'s `NAV_ITEMS`, même array que Dashboard/Monitoring/Corrections/Catalogue) : liste des scénarios (nom, activé/désactivé, nombre d'étapes) → éditeur par scénario (nom, activé, liste d'étapes réordonnables avec route/cible/titre/ contenu, ajout/suppression d'étape). Nouveau scénario = mêmes champs, `key` saisie une fois à la création. ## Bonus — analytics de complétion **Coût quasi nul, l'infrastructure existe déjà en quasi-totalité** : `admin-metrics.service.ts`'s `getMetrics()` agrège déjà **tout** `AnalyticsEvent` par `type` en séries temporelles journalières (`events: MetricsEventSeries[]`, lignes 190-204), et `DashboardPage.tsx` (lignes 120-135) rend déjà cette liste **génériquement** (`metrics.events.map(...)`) — un type d'événement inédit apparaît sur le dashboard **sans aucun changement frontend**. Il suffit donc d'émettre, côté `POST /profile/tours/:key/complete` (#42) : ```ts analytics.recordEvent("tour.completed", { actorId: userId, context: { tourKey } }); ``` et pareillement `"tour.skipped"` si un tour est passé plutôt que terminé (distinction utile pour juger si un tour est trop long) — même fonction `analytics.recordEvent` que `planning.item_added`/`tech_step.correction_submitted` etc., rien de neuf à construire à ce niveau. ## Fichiers à toucher - `apps/api/prisma/schema.prisma` — `TourTarget`/`TourScenario`/ `TourScenarioStep`, `TourProgress` révisé + migration + seed initial des `TourTarget` correspondant aux `data-tour-*` posés par #42. - `apps/api/src/modules/admin/admin-tours.routes.ts`/`.service.ts` (nouveaux) + branchement dans `admin.routes.ts`. - `apps/api/src/modules/reference/` — ajoute `GET /reference/tours`. - `apps/web/src/pages/admin/tours/` (nouveau) — page CRUD. - `apps/web/src/layouts/AdminLayout.tsx` — entrée de nav. - Emission `analytics.recordEvent("tour.completed"/"tour.skipped", ...)` dans le endpoint de complétion de #42. ## Tests - Mocha : CRUD complet du scénario (création, remplacement des étapes en bloc, suppression en cascade) ; `GET /reference/tours` ne renvoie que les scénarios activés. - Cypress : parcours admin création/édition/suppression d'un scénario (mock `cy.intercept`, même convention que le reste de la suite admin). ## Hors scope - Prévisualisation en direct d'un scénario depuis l'admin (ouvrir l'app pour voir le rendu réel) — juste l'édition des données pour cette itération. - Rétro-mesure de complétion pour les tours déjà vus avant ce ticket (aucun historique n'existe avant que #42 ne soit codé de toute façon). --- 🤖 Filed by [Claude Code](https://claude.com/claude-code)
kyuno added the
planned
enhancement
labels 2026-09-05 19:36:32 +02:00
kyuno added the
Low
label 2026-09-05 19:41:18 +02:00
kyuno closed this issue 2026-09-06 07:27:17 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: kyuno/batchCooking#44
No description provided.