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 { 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), }, }) } }