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>
141 lines
5.1 KiB
TypeScript
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
|
|
}
|