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>
205 lines
6.2 KiB
TypeScript
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),
|
|
},
|
|
})
|
|
}
|
|
}
|