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>
112 lines
3.6 KiB
TypeScript
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()
|
|
}
|