rubis/apps/api/app/controllers/demo_controller.ts
ordinarthur 933c6496b1 feat(demo): mode démo live — horloge virtuelle + emails capturés
Permet de faire vivre Rubis en accéléré pour démontrer le produit à des
prospects, SANS impacter la prod. Les vrais users ont demoMode=false par
défaut → toute la logique démo est court-circuitée.

Architecture (priorité : zéro impact prod, codebase propre)

Phase 1 — Abstraction Clock
- Migration : organizations.demo_mode + virtual_now + demo_speed_factor
  (défaut false/null/1, zéro effet sur les orgs existantes)
- services/clock.ts : now(orgId?) → DateTime.utc() en prod, virtualNow
  en démo. Cache mémoire 250ms pour pas spammer la DB. Helpers
  setVirtualNow / setDemoMode pour les transitions.
- Refacto 7 fichiers : relance_scheduler, checkin_scheduler, dashboard,
  send_relance_job, send_checkin_job, mail_dispatcher (buildRelanceVars
  daysLate), activity_recorder, checkin_controller, invoices_controller
  (buildTimeline + markPaid). DateTime.now() → clock.now(orgId).
- Tests existants (51) passent identique → preuve que la prod est intacte.

Phase 2 — Capture emails + dispatch
- Migration : demo_captured_emails (kind, to, from, subject, body, sent_at,
  meta) — index sur (org, sent_at desc) pour l'inbox.
- services/demo/capture.ts : captureEmailIfDemo() — UNIQUE point de fork
  dans la prod (deux lignes dans mail_dispatcher : if captured return).
  Hors démo, fonction retourne false → flux Resend inchangé.
- services/demo/dispatch.ts : tickAndDispatch(orgId, target) → bump
  virtual_now, trouve les tasks dues (relance + checkin), invoke les
  handlers existants synchronement (skip BullMQ, propre). Retourne les
  events fired pour l'UI.
- POST /api/v1/demo/{start,end,tick} + GET /demo/{state,inbox}, toutes
  protégées par requireDemoOrg() (403 si demoMode=false).

Phase 3 — UI horloge "vivante"
- lib/demo.ts : useDemoState, useDemoTick (boucle rAF locale qui avance
  virtualNow à `speed * elapsed` jours/sec, sync backend toutes les
  250ms, auto-pause sur fired events). Pas de boutons +1j/+3j —
  l'horloge tourne vraiment.
- DemoClock (top-right, fixed) : date pleine en font display, rail
  rubis-glow avec pastille ◆ qui glisse vers le prochain event,
  play/pause + sélecteur 1x/2x/5x. Auto-cachée si demoMode=false.
- DemoEmailSlide : slide-over droite quand event fires — affiche
  l'email capturé (de/à/sujet/body) façon vrai client mail. Pause
  forcée tant que tous les events ne sont pas acquittés ("comme si
  le temps était vraiment passé").
- DemoToggle dans /parametres : démarrer/quitter le mode démo, avec
  copy explicite ("emails capturés, pas envoyés à de vrais clients").

Le code démo vit isolé dans services/demo/, controllers/demo_controller.ts,
components/demo/, lib/demo.ts. La prod ne référence ces fichiers QUE
via captureEmailIfDemo dans mail_dispatcher.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 10:42:59 +02:00

205 lines
6.2 KiB
TypeScript

import { DateTime } from 'luxon'
import vine from '@vinejs/vine'
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
import DemoCapturedEmail from '#models/demo_captured_email'
import RelanceTask from '#models/relance_task'
import CheckinTask from '#models/checkin_task'
import * as clock from '#services/clock'
import { tickAndDispatch } from '#services/demo/dispatch'
const tickValidator = vine.create({
/** ISO 8601 — la cible vers laquelle avancer. */
virtualNow: vine.string(),
})
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
async function requireDemoOrg(orgId: string): Promise<Organization> {
const org = await Organization.findOrFail(orgId)
if (!org.demoMode) {
throw new Exception('Mode démo désactivé pour cette organisation', {
status: 403,
code: 'demo_disabled',
})
}
return org
}
/**
* Endpoints du mode démo.
*
* Garde-fou de sécurité : tous les endpoints sauf `/start` et `/end`
* vérifient `org.demoMode = true` avant d'opérer. La prod ne peut donc
* jamais se retrouver à exécuter du code démo par accident, même si
* un endpoint était appelé par erreur.
*/
export default class DemoController {
/**
* POST /api/v1/demo/start
*
* Active le mode démo sur l'org du user. virtual_now = maintenant.
* Wipe les emails capturés précédents (reset propre).
*/
async start({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
await db.transaction(async (trx) => {
const org = await Organization.findOrFail(orgId, { client: trx })
org.useTransaction(trx)
org.demoMode = true
org.virtualNow = DateTime.utc()
await org.save()
// Reset l'inbox démo précédente
await DemoCapturedEmail.query({ client: trx }).where('organization_id', orgId).delete()
})
// Force un reload du cache clock
await clock.setVirtualNow(orgId, DateTime.utc())
const org = await Organization.findOrFail(orgId)
return response.json({
data: {
demoMode: org.demoMode,
virtualNow: org.virtualNow?.toISO(),
speedFactor: org.demoSpeedFactor,
},
})
}
/**
* POST /api/v1/demo/end
*
* Désactive le mode démo. virtual_now=null → clock.now() retombe sur
* Date.now() à la prochaine lecture.
*/
async end({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
await clock.setDemoMode(orgId, false)
return response.json({ data: { demoMode: false } })
}
/**
* POST /api/v1/demo/tick { virtualNow: ISO }
*
* Avance virtual_now à la cible et fire les tasks dues entre l'ancien
* et le nouveau virtual_now. Retourne les events déclenchés.
*
* Le client appelle cet endpoint depuis sa boucle rAF (typiquement
* toutes les 250ms quand il joue, avec virtualNow incrémenté de
* `speed * elapsed`).
*/
async tick({ auth, request, response }: HttpContext) {
const orgId = requireOrgId(auth)
await requireDemoOrg(orgId)
const { virtualNow } = await request.validateUsing(tickValidator)
const target = DateTime.fromISO(virtualNow, { zone: 'utc' })
if (!target.isValid) {
throw new Exception('virtualNow ISO invalide', { status: 422, code: 'invalid_iso' })
}
const fired = await tickAndDispatch(orgId, target)
return response.json({
data: {
virtualNow: target.toISO(),
firedEvents: fired,
},
})
}
/**
* GET /api/v1/demo/inbox
*
* Liste des emails capturés pour l'org démo, plus récent en tête.
* Le SPA lit cet endpoint pour la slide-over "boîte de réception".
*/
async inbox({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
await requireDemoOrg(orgId)
const emails = await DemoCapturedEmail.query()
.where('organization_id', orgId)
.orderBy('sent_at', 'desc')
.limit(50)
return response.json({
data: emails.map((e) => ({
id: e.id,
kind: e.kind,
from: { email: e.fromEmail, name: e.fromName },
to: { email: e.toEmail, name: e.toName },
replyTo: e.replyTo,
subject: e.subject,
body: e.body,
meta: e.meta,
sentAt: e.sentAt.toISO(),
})),
})
}
/**
* GET /api/v1/demo/state
*
* État courant : virtualNow, prochaine task à fire (pour que le SPA
* sache quand auto-pause), nombre d'emails en inbox.
* Lu au mount du SPA pour reprendre une démo en cours.
*/
async state({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
const org = await Organization.findOrFail(orgId)
if (!org.demoMode) {
return response.json({ data: { demoMode: false } })
}
// Prochaine task scheduled — relance ou checkin, la plus proche.
const nextRelance = await RelanceTask.query()
.where('organization_id', orgId)
.where('status', 'scheduled')
.orderBy('send_at', 'asc')
.first()
const nextCheckin = await CheckinTask.query()
.where('organization_id', orgId)
.where('status', 'scheduled')
.orderBy('send_at', 'asc')
.first()
let nextEventAt: string | null = null
if (nextRelance && nextCheckin) {
nextEventAt =
nextRelance.sendAt < nextCheckin.sendAt
? nextRelance.sendAt.toISO()
: nextCheckin.sendAt.toISO()
} else if (nextRelance) {
nextEventAt = nextRelance.sendAt.toISO()
} else if (nextCheckin) {
nextEventAt = nextCheckin.sendAt.toISO()
}
const inboxCount = await DemoCapturedEmail.query()
.where('organization_id', orgId)
.count('* as total')
.first()
return response.json({
data: {
demoMode: true,
virtualNow: org.virtualNow?.toISO(),
speedFactor: org.demoSpeedFactor,
nextEventAt,
inboxCount: Number((inboxCount as unknown as { $extras: { total: string } })?.$extras?.total ?? 0),
},
})
}
}