rubis/apps/api/app/services/demo/dispatch.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

141 lines
5.1 KiB
TypeScript

import { DateTime } from 'luxon'
import db from '@adonisjs/lucid/services/db'
import RelanceTask from '#models/relance_task'
import CheckinTask from '#models/checkin_task'
import * as clock from '#services/clock'
import { sendRelanceJob } from '#jobs/send_relance_job'
import { sendCheckinJob } from '#jobs/send_checkin_job'
/**
* Démo dispatch — quand le SPA tick l'horloge virtuelle, on cherche
* les tasks dont la date d'envoi virtuelle est dépassée et on les
* exécute synchronement (les handlers BullMQ sont appelés directement,
* skippant Redis — propre, aucune dépendance externe en démo).
*
* Les emails sont capturés (cf. captureEmailIfDemo dans mail_dispatcher),
* pas envoyés réellement.
*
* Idempotent : seules les tasks `scheduled` sont prises ; les déjà
* traitées sont ignorées. Si on tick deux fois avec la même `targetVirtualNow`,
* la deuxième tick ne fait rien.
*/
export type FiredEvent = {
kind: 'relance' | 'checkin'
taskId: string
invoiceId: string
/** Numéro facture pour le toast UI ("Relance F-2026-0042 envoyée"). */
invoiceNumero: string
/** ISO du DemoCapturedEmail créé — permet de l'afficher direct. */
capturedEmailId: string | null
/** Le moment virtuel où l'event s'est produit. */
firedAt: string
}
/**
* Avance virtual_now et fire les tasks dues. Retourne les events
* déclenchés (à afficher en UI + auto-pause de l'horloge).
*
* Note : on récupère les tasks AVANT de bumper virtual_now, puis on
* ajuste leur `sendAt` virtuel pour comparer correctement. Concrètement :
* - relance task.sendAt et checkin task.sendAt sont déjà des dates
* absolues (basées sur invoice.dueDate qui ne bouge pas)
* - on les compare donc à la cible (target virtualNow)
*
* Ordre d'exécution : par sendAt croissant — pour que les events
* soient présentés au user dans l'ordre temporel.
*/
export async function tickAndDispatch(
organizationId: string,
targetVirtualNow: DateTime
): Promise<FiredEvent[]> {
// Met à jour virtual_now AVANT de chercher les tasks → les handlers
// qui appellent clock.now() à l'intérieur lisent la valeur cible.
await clock.setVirtualNow(organizationId, targetVirtualNow)
// Tasks "à envoyer" : status scheduled + sendAt <= cible.
const dueRelances = await RelanceTask.query()
.where('organization_id', organizationId)
.where('status', 'scheduled')
.where('send_at', '<=', targetVirtualNow.toJSDate())
.preload('planStep')
.orderBy('send_at', 'asc')
const dueCheckins = await CheckinTask.query()
.where('organization_id', organizationId)
.where('status', 'scheduled')
.where('send_at', '<=', targetVirtualNow.toJSDate())
.orderBy('send_at', 'asc')
// Merge ordered par sendAt pour un récit chronologique.
type Pending =
| { type: 'relance'; task: RelanceTask }
| { type: 'checkin'; task: CheckinTask; plain: string | null }
const pending: Pending[] = []
for (const t of dueRelances) pending.push({ type: 'relance', task: t })
for (const t of dueCheckins) pending.push({ type: 'checkin', task: t, plain: null })
pending.sort((a, b) => a.task.sendAt.toMillis() - b.task.sendAt.toMillis())
const fired: FiredEvent[] = []
for (const p of pending) {
if (p.type === 'relance') {
// Le job a déjà la logique complète (mark sent, bump rubis, activity).
await sendRelanceJob({ taskId: p.task.id })
const invoice = await db
.from('invoices')
.where('id', p.task.invoiceId)
.select('id', 'numero')
.first()
const captured = await db
.from('demo_captured_emails')
.where('organization_id', organizationId)
.whereRaw(`(meta->>'invoiceId') = ?`, [p.task.invoiceId])
.orderBy('sent_at', 'desc')
.first()
fired.push({
kind: 'relance',
taskId: p.task.id,
invoiceId: p.task.invoiceId,
invoiceNumero: invoice?.numero ?? '',
capturedEmailId: captured?.id ?? null,
firedAt: targetVirtualNow.toISO()!,
})
} else {
// Pour le checkin job, on a besoin du plain token — pas stocké
// (on a juste le hash). En démo on regénère un "plain" volatile
// dérivé de l'ID — les liens paid/pending n'ont pas vraiment de
// sens ici (l'utilisateur ne va pas cliquer dessus en démo).
// On délègue quand même à sendCheckinJob qui appellera l'envoi
// (capturé), puis update task.status.
await sendCheckinJob({ taskId: p.task.id, plain: 'demo-' + p.task.id })
const invoice = await db
.from('invoices')
.where('id', p.task.invoiceId)
.select('id', 'numero')
.first()
const captured = await db
.from('demo_captured_emails')
.where('organization_id', organizationId)
.whereRaw(`(meta->>'invoiceId') = ?`, [p.task.invoiceId])
.orderBy('sent_at', 'desc')
.first()
fired.push({
kind: 'checkin',
taskId: p.task.id,
invoiceId: p.task.invoiceId,
invoiceNumero: invoice?.numero ?? '',
capturedEmailId: captured?.id ?? null,
firedAt: targetVirtualNow.toISO()!,
})
}
}
return fired
}