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

112 lines
3.6 KiB
TypeScript

import { DateTime } from 'luxon'
import Organization from '#models/organization'
/**
* Clock — abstraction time-sensitive de l'app.
*
* En prod (`org.demoMode = false`, ou pas d'orgId fourni), retourne
* `DateTime.utc()`. C'est l'API à utiliser **par défaut** dans tous les
* services qui font des comparaisons de dates (relance scheduler,
* checkin scheduler, dashboard KPIs, jobs BullMQ).
*
* En mode démo (`org.demoMode = true` ET `org.virtualNow != null`),
* retourne le `virtualNow` stocké sur l'org. C'est ce qui permet
* "d'avancer dans le temps" pendant une démo sans toucher au système.
*
* **Garde-fou prod** : si `orgId` est absent, on retourne toujours
* l'horloge système — on ne fait JAMAIS un side-effect demoMode si
* on n'a pas explicitement le contexte d'une org.
*
* Cette abstraction sert aussi aux tests (on pourra mock plus tard
* via un singleton injectable, V2). En V1, on lit la DB à chaque
* appel : c'est fonctionnel, et le cache n'a pas de sens vu que la
* valeur peut bouger pendant une démo.
*
* Cache : on prend un cache mémoire très court (250ms) pour ne pas
* spammer la DB quand un même handler appelle clock.now() plusieurs
* fois. Invalidé à chaque écriture (cf. setVirtualNow).
*/
type CachedClock = {
demoMode: boolean
virtualNow: DateTime | null
fetchedAt: number
}
const CACHE_TTL_MS = 250
const cache = new Map<string, CachedClock>()
async function loadOrg(orgId: string): Promise<CachedClock> {
const cached = cache.get(orgId)
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) return cached
const org = await Organization.find(orgId)
const entry: CachedClock = {
demoMode: org?.demoMode ?? false,
virtualNow: org?.virtualNow ?? null,
fetchedAt: Date.now(),
}
cache.set(orgId, entry)
return entry
}
/**
* Heure courante. Async pour permettre la lecture DB en mode démo.
*
* const now = await clock.now(invoice.organizationId)
*
* Si `orgId` est `null` / `undefined`, retourne `DateTime.utc()`
* synchronement-compatible (pas de side-effect démo).
*/
export async function now(orgId?: string | null): Promise<DateTime> {
if (!orgId) return DateTime.utc()
const c = await loadOrg(orgId)
if (c.demoMode && c.virtualNow) return c.virtualNow
return DateTime.utc()
}
/**
* Variante sync — utile dans des chemins où on n'a pas d'orgId
* (création de tokens auth par exemple). Comportement strictement
* identique à `DateTime.utc()`. Existe pour faciliter une migration
* incrémentale : remplacer `DateTime.utc()` ou `DateTime.now()` par
* `clockSync()` rend explicite que c'est une horloge système.
*/
export function nowSync(): DateTime {
return DateTime.utc()
}
/**
* Met à jour `virtual_now` sur une org (utilisé par /demo/tick).
* Invalide le cache pour que le prochain `now(orgId)` renvoie la
* nouvelle valeur sans attendre le TTL.
*/
export async function setVirtualNow(orgId: string, virtualNow: DateTime): Promise<void> {
const org = await Organization.findOrFail(orgId)
org.virtualNow = virtualNow
await org.save()
cache.delete(orgId)
}
/**
* Active/désactive le mode démo. Côté `start`, on initialise
* `virtualNow` à maintenant (UTC) si pas déjà set.
*/
export async function setDemoMode(orgId: string, enabled: boolean): Promise<void> {
const org = await Organization.findOrFail(orgId)
org.demoMode = enabled
if (enabled && !org.virtualNow) {
org.virtualNow = DateTime.utc()
}
if (!enabled) {
org.virtualNow = null
}
await org.save()
cache.delete(orgId)
}
/** Invalidation manuelle (tests). */
export function clearClockCache(): void {
cache.clear()
}