diff --git a/apps/api/app/controllers/checkin_controller.ts b/apps/api/app/controllers/checkin_controller.ts index 829e656..141c5f4 100644 --- a/apps/api/app/controllers/checkin_controller.ts +++ b/apps/api/app/controllers/checkin_controller.ts @@ -3,9 +3,9 @@ import Invoice from '#models/invoice' import { hashCheckinToken } from '#services/checkin_token' import { recordActivity } from '#services/activity_recorder' import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler' +import * as clock from '#services/clock' import db from '@adonisjs/lucid/services/db' import env from '#start/env' -import { DateTime } from 'luxon' import type { HttpContext } from '@adonisjs/core/http' const CHECKIN_TTL_HOURS = 24 @@ -45,7 +45,7 @@ async function resolveCheckin(token: string): Promise { // Expiration : 24h après l'envoi (sentAt). Tant qu'elle n'a pas été // envoyée, le link n'est pas censé exister côté user — sécurité belt. - if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < DateTime.now()) { + if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < (await clock.now(task.organizationId))) { task.status = 'expired' await task.save() return { redirect: spaRedirectUrl('expired') } @@ -77,17 +77,18 @@ export default class CheckinController { const { task, invoice } = result await db.transaction(async (trx) => { + const nowOrg = await clock.now(invoice.organizationId) task.useTransaction(trx) task.status = 'answered' task.answer = 'paid' - task.answeredAt = DateTime.now() + task.answeredAt = nowOrg await task.save() // Mark paid (mêmes effets que POST /invoices/:id/mark-paid). if (invoice.status !== 'paid') { invoice.useTransaction(trx) invoice.status = 'paid' - invoice.paidAt = DateTime.now() + invoice.paidAt = nowOrg invoice.rubisEarned = invoice.rubisEarned + 1 await invoice.save() @@ -133,7 +134,7 @@ export default class CheckinController { task.useTransaction(trx) task.status = 'answered' task.answer = 'still_pending' - task.answeredAt = DateTime.now() + task.answeredAt = await clock.now(invoice.organizationId) await task.save() }) diff --git a/apps/api/app/controllers/demo_controller.ts b/apps/api/app/controllers/demo_controller.ts new file mode 100644 index 0000000..9dfa2fc --- /dev/null +++ b/apps/api/app/controllers/demo_controller.ts @@ -0,0 +1,204 @@ +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), + }, + }) + } +} diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts index c6b41e0..ceec1b4 100644 --- a/apps/api/app/controllers/invoices_controller.ts +++ b/apps/api/app/controllers/invoices_controller.ts @@ -12,6 +12,7 @@ import { recordActivity } from '#services/activity_recorder' import { cancelFutureRelances } from '#services/relance_scheduler' import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler' import logger from '@adonisjs/core/services/logger' +import * as clock from '#services/clock' const PAGE_SIZE = 50 @@ -47,7 +48,9 @@ function serializeInvoice(i: Invoice) { */ function buildTimeline( invoice: Invoice, - relanceTasks: RelanceTask[] = [] + relanceTasks: RelanceTask[] = [], + // `now` injecté par le caller — orgs en mode démo lisent depuis virtualNow. + now: DateTime = DateTime.utc() ): Array<{ id: string state: 'past' | 'current' | 'future' @@ -70,7 +73,7 @@ function buildTimeline( if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') { const dueMs = invoice.dueDate.toMillis() - const nowMs = DateTime.now().toMillis() + const nowMs = now.toMillis() const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task])) let currentSet = false @@ -257,7 +260,7 @@ export default class InvoicesController { requiresManualValidation: s.requiresManualValidation, })), }, - timeline: buildTimeline(invoice, relanceTasks), + timeline: buildTimeline(invoice, relanceTasks, await clock.now(invoice.organizationId)), }, }) } @@ -348,7 +351,7 @@ export default class InvoicesController { await db.transaction(async (trx) => { invoice.useTransaction(trx) invoice.status = 'paid' - invoice.paidAt = DateTime.now() + invoice.paidAt = await clock.now(invoice.organizationId) invoice.rubisEarned = invoice.rubisEarned + 1 await invoice.save() diff --git a/apps/api/app/jobs/send_checkin_job.ts b/apps/api/app/jobs/send_checkin_job.ts index 91b2f72..8d81fbf 100644 --- a/apps/api/app/jobs/send_checkin_job.ts +++ b/apps/api/app/jobs/send_checkin_job.ts @@ -2,8 +2,8 @@ import CheckinTask from '#models/checkin_task' import Invoice from '#models/invoice' import User from '#models/user' import { sendCheckinEmail } from '#services/mail_dispatcher' +import * as clock from '#services/clock' import env from '#start/env' -import { DateTime } from 'luxon' import logger from '@adonisjs/core/services/logger' /** @@ -64,6 +64,6 @@ export async function sendCheckinJob(jobData: { taskId: string; plain: string }) }) task.status = 'sent' - task.sentAt = DateTime.now() + task.sentAt = await clock.now(invoice.organizationId) await task.save() } diff --git a/apps/api/app/jobs/send_relance_job.ts b/apps/api/app/jobs/send_relance_job.ts index 5261d6c..3a416ef 100644 --- a/apps/api/app/jobs/send_relance_job.ts +++ b/apps/api/app/jobs/send_relance_job.ts @@ -3,8 +3,8 @@ import Invoice from '#models/invoice' import User from '#models/user' import { sendRelanceEmail } from '#services/mail_dispatcher' import { recordActivity } from '#services/activity_recorder' +import * as clock from '#services/clock' import db from '@adonisjs/lucid/services/db' -import { DateTime } from 'luxon' import logger from '@adonisjs/core/services/logger' /** @@ -60,7 +60,7 @@ export async function sendRelanceJob(jobData: { taskId: string }) { await db.transaction(async (trx) => { task.useTransaction(trx) task.status = 'sent' // On considère la task "traitée" — le brouillon est l'output - task.sentAt = DateTime.now() + task.sentAt = await clock.now(invoice.organizationId) await task.save() await recordActivity({ @@ -87,10 +87,11 @@ export async function sendRelanceJob(jobData: { taskId: string }) { organization: invoice.organization, }) + const sentAt = await clock.now(invoice.organizationId) await db.transaction(async (trx) => { task.useTransaction(trx) task.status = 'sent' - task.sentAt = DateTime.now() + task.sentAt = sentAt await task.save() invoice.useTransaction(trx) diff --git a/apps/api/app/models/demo_captured_email.ts b/apps/api/app/models/demo_captured_email.ts new file mode 100644 index 0000000..3ef0d11 --- /dev/null +++ b/apps/api/app/models/demo_captured_email.ts @@ -0,0 +1,9 @@ +import { DemoCapturedEmailSchema } from '#database/schema' +import { belongsTo } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import Organization from '#models/organization' + +export default class DemoCapturedEmail extends DemoCapturedEmailSchema { + @belongsTo(() => Organization) + declare organization: BelongsTo +} diff --git a/apps/api/app/services/activity_recorder.ts b/apps/api/app/services/activity_recorder.ts index cb395d4..fc631b8 100644 --- a/apps/api/app/services/activity_recorder.ts +++ b/apps/api/app/services/activity_recorder.ts @@ -1,5 +1,6 @@ import { DateTime } from 'luxon' import ActivityEvent from '#models/activity_event' +import * as clock from '#services/clock' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted' @@ -33,7 +34,7 @@ export async function recordActivity(opts: RecordOpts): Promise { kind, label, meta, - at: at ?? DateTime.now(), + at: at ?? (await clock.now(organizationId)), }, trx ? { client: trx } : undefined ) diff --git a/apps/api/app/services/checkin_scheduler.ts b/apps/api/app/services/checkin_scheduler.ts index 9c37ce1..293159d 100644 --- a/apps/api/app/services/checkin_scheduler.ts +++ b/apps/api/app/services/checkin_scheduler.ts @@ -1,8 +1,8 @@ -import { DateTime } from 'luxon' import CheckinTask from '#models/checkin_task' import Invoice from '#models/invoice' import { getQueue } from '#services/queue' import { generateCheckinToken } from '#services/checkin_token' +import * as clock from '#services/clock' import app from '@adonisjs/core/services/app' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' @@ -47,7 +47,7 @@ export async function scheduleCheckinForInvoice( await t.save() } - const now = DateTime.now() + const now = await clock.now(invoice.organizationId) const sendAtRaw = invoice.dueDate const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw diff --git a/apps/api/app/services/clock.ts b/apps/api/app/services/clock.ts new file mode 100644 index 0000000..9a306db --- /dev/null +++ b/apps/api/app/services/clock.ts @@ -0,0 +1,111 @@ +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() + +async function loadOrg(orgId: string): Promise { + 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 { + 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 { + 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 { + 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() +} diff --git a/apps/api/app/services/dashboard.ts b/apps/api/app/services/dashboard.ts index 825ed68..9a9fa4b 100644 --- a/apps/api/app/services/dashboard.ts +++ b/apps/api/app/services/dashboard.ts @@ -1,6 +1,7 @@ import db from '@adonisjs/lucid/services/db' import Organization from '#models/organization' import { DateTime } from 'luxon' +import * as clock from '#services/clock' const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')" @@ -38,7 +39,7 @@ function startOfDay(d: DateTime): Date { * Le contrat reste stable côté SPA. */ export async function computeKpis(organizationId: string): Promise { - const now = DateTime.now() + const now = await clock.now(organizationId) const monthStart = startOfMonth(now) const todayStart = startOfDay(now) const prevMonthStart = startOfMonth(now.minus({ months: 1 })) @@ -128,7 +129,7 @@ export async function topLatePayers( organizationId: string, limit = 5 ): Promise> { - const today = startOfDay(DateTime.now()) + const today = startOfDay(await clock.now(organizationId)) const rows = await db .from('invoices') @@ -240,7 +241,7 @@ async function fetchPaidByMonth(params: { clientId?: string range: RangeMonths }): Promise { - const now = DateTime.utc() + const now = await clock.now(params.organizationId) const firstBucket = now.minus({ months: params.range - 1 }).startOf('month') const buckets = new Map() diff --git a/apps/api/app/services/demo/capture.ts b/apps/api/app/services/demo/capture.ts new file mode 100644 index 0000000..5e735ba --- /dev/null +++ b/apps/api/app/services/demo/capture.ts @@ -0,0 +1,48 @@ +import DemoCapturedEmail from '#models/demo_captured_email' +import * as clock from '#services/clock' +import Organization from '#models/organization' + +/** + * Service de capture d'emails en mode démo. + * + * Tout le code "démo" vit dans `services/demo/*` — la prod ne référence + * qu'une seule fonction (`captureEmailIfDemo`) depuis `mail_dispatcher`, + * pour minimiser le couplage. + * + * Si l'org est en mode démo : crée une `DemoCapturedEmail` avec le + * timestamp virtualNow et retourne `true` → caller doit ne PAS envoyer + * via Resend. + * + * Sinon : retourne `false` → comportement prod inchangé. + */ +export type CaptureInput = { + organizationId: string + kind: 'relance' | 'checkin' + to: { email: string; name?: string | null } + from: { email: string; name?: string | null } + replyTo?: string | null + subject: string + body: string + meta?: Record +} + +export async function captureEmailIfDemo(input: CaptureInput): Promise { + const org = await Organization.find(input.organizationId) + if (!org || !org.demoMode) return false + + const sentAt = await clock.now(input.organizationId) + await DemoCapturedEmail.create({ + organizationId: input.organizationId, + kind: input.kind, + toEmail: input.to.email, + toName: input.to.name ?? null, + fromEmail: input.from.email, + fromName: input.from.name ?? null, + replyTo: input.replyTo ?? null, + subject: input.subject, + body: input.body, + meta: input.meta ?? {}, + sentAt, + }) + return true +} diff --git a/apps/api/app/services/demo/dispatch.ts b/apps/api/app/services/demo/dispatch.ts new file mode 100644 index 0000000..bffb9ca --- /dev/null +++ b/apps/api/app/services/demo/dispatch.ts @@ -0,0 +1,140 @@ +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 { + // 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 +} diff --git a/apps/api/app/services/mail_dispatcher.ts b/apps/api/app/services/mail_dispatcher.ts index c6552a9..9a5e2a0 100644 --- a/apps/api/app/services/mail_dispatcher.ts +++ b/apps/api/app/services/mail_dispatcher.ts @@ -2,6 +2,8 @@ import mail from '@adonisjs/mail/services/main' import env from '#start/env' import { DateTime } from 'luxon' import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template' +import * as clock from '#services/clock' +import { captureEmailIfDemo } from '#services/demo/capture' import type Invoice from '#models/invoice' import type Client from '#models/client' import type PlanStep from '#models/plan_step' @@ -34,16 +36,19 @@ export function buildRelanceVars({ client, user, organization, + now = DateTime.utc(), }: { invoice: Pick client: Pick user: Pick | null organization?: Pick | null + /** `now` injecté pour respecter virtualNow en mode démo. */ + now?: DateTime }) { const dueDate = invoice.dueDate.toJSDate() - // Jours de retard arrondis à l'entier (UTC pour cohérence). + // Jours de retard arrondis à l'entier — démo-aware via `now` injecté. const daysLate = Math.floor( - DateTime.utc().startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days + now.startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days ) return { client: { @@ -80,14 +85,38 @@ export async function sendRelanceEmail({ user, organization, }: RelancePayload) { - const vars = buildRelanceVars({ invoice, client, user, organization }) + const vars = buildRelanceVars({ + invoice, + client, + user, + organization, + now: await clock.now(invoice.organizationId), + }) const subject = renderTemplate(step.subject, vars) const body = renderTemplate(step.body, vars) + const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr') + const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle") + + // FORK DÉMO — unique point où l'app dévie de la prod. Si l'org est + // en mode démo, on capture l'email dans demo_captured_emails au lieu + // de l'envoyer via Resend. Tout le reste du pipeline (idempotence, + // status update, rubis bump) tourne identique. + const captured = await captureEmailIfDemo({ + organizationId: invoice.organizationId, + kind: 'relance', + to: { email: client.email, name: client.name }, + from: { email: fromAddress, name: fromName }, + replyTo: user?.email ?? null, + subject, + body, + meta: { invoiceId: invoice.id, clientId: client.id, stepOrder: step.order }, + }) + if (captured) return // demo : ne pas envoyer pour de vrai const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp')) await mailer.send((m) => { - m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")) + m.from(fromAddress, fromName) .to(client.email, client.name) .subject(subject) // Texte brut pour V1 — on ajoutera un template HTML quand on aura @@ -144,9 +173,25 @@ Ces liens expirent dans 24h. Merci, L'équipe Rubis` + const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr') + const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle") + + // FORK DÉMO — capture si demoMode (cf. sendRelanceEmail). + const captured = await captureEmailIfDemo({ + organizationId: invoice.organizationId, + kind: 'checkin', + to: { email: user.email, name: user.fullName ?? user.email }, + from: { email: fromAddress, name: fromName }, + replyTo: null, + subject, + body, + meta: { invoiceId: invoice.id, clientId: client.id, paidUrl, pendingUrl }, + }) + if (captured) return + const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp')) await mailer.send((m) => { - m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")) + m.from(fromAddress, fromName) .to(user.email, user.fullName ?? user.email) .subject(subject) .text(body) diff --git a/apps/api/app/services/relance_scheduler.ts b/apps/api/app/services/relance_scheduler.ts index db375ab..4d9cec4 100644 --- a/apps/api/app/services/relance_scheduler.ts +++ b/apps/api/app/services/relance_scheduler.ts @@ -1,8 +1,8 @@ -import { DateTime } from 'luxon' import RelanceTask from '#models/relance_task' import Plan from '#models/plan' import type Invoice from '#models/invoice' import { getQueue } from '#services/queue' +import * as clock from '#services/clock' import app from '@adonisjs/core/services/app' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' @@ -70,7 +70,7 @@ export async function scheduleRelancesForInvoice( await t.save() } - const now = DateTime.now() + const now = await clock.now(invoice.organizationId) const created: RelanceTask[] = [] const steps = plan.steps.slice().sort((a, b) => a.order - b.order) const firstOverdueStep = steps.find( diff --git a/apps/api/database/migrations/1778080001500_add_demo_mode_to_organizations_table.ts b/apps/api/database/migrations/1778080001500_add_demo_mode_to_organizations_table.ts new file mode 100644 index 0000000..0a81fe1 --- /dev/null +++ b/apps/api/database/migrations/1778080001500_add_demo_mode_to_organizations_table.ts @@ -0,0 +1,42 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Mode démo sur les organisations. + * + * - `demo_mode` : flag activé/désactivé. Quand true, les services + * time-sensitive (relances, check-ins, KPIs dashboard) lisent + * `virtual_now` au lieu de `Date.now()`. Les emails sont capturés + * dans la table demo_captured_emails au lieu d'être envoyés via + * Resend. + * + * - `virtual_now` : horloge virtuelle. Permet d'avancer le temps + * pendant une démo (1 jour démo ≈ 800 ms en réel par défaut). + * Null = on ne touche à rien (= comportement prod normal). + * + * - `demo_speed_factor` : multiplicateur d'accélération côté UI. + * Stocké côté serveur pour la persistance entre sessions, mais + * le tick reste piloté par le client. + * + * Tous les champs sont à `null`/`false` par défaut → zéro impact sur + * les organisations existantes (cf. priorité produit : la prod est + * intacte tant que demo_mode reste off). + */ +export default class extends BaseSchema { + protected tableName = 'organizations' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.boolean('demo_mode').notNullable().defaultTo(false) + table.timestamp('virtual_now', { useTz: true }).nullable() + table.smallint('demo_speed_factor').notNullable().defaultTo(1) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('demo_mode') + table.dropColumn('virtual_now') + table.dropColumn('demo_speed_factor') + }) + } +} diff --git a/apps/api/database/migrations/1778080001600_create_demo_captured_emails_table.ts b/apps/api/database/migrations/1778080001600_create_demo_captured_emails_table.ts new file mode 100644 index 0000000..a719a63 --- /dev/null +++ b/apps/api/database/migrations/1778080001600_create_demo_captured_emails_table.ts @@ -0,0 +1,53 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Boîte de réception démo : quand `org.demo_mode = true`, les emails + * de relance et de check-in ne partent PAS via Resend mais sont stockés + * ici. Lus par le SPA via `/demo/inbox` pour montrer "ce que le client + * recevrait" pendant la démo live. + * + * - kind : type d'email (relance ou checkin) + * - to / from : adresses telles qu'on les aurait envoyées + * - subject / body : interpolés (toutes les variables résolues) + * - sent_at : DateTime virtuelle de l'envoi (= virtual_now au moment T) + * - meta : invoiceId / clientId pour permettre des liens dans l'UI + * + * Pas de relation FK : on garde une trace même si la facture est + * supprimée ensuite (l'inbox démo doit être robuste à un reset partiel). + */ +export default class extends BaseSchema { + protected tableName = 'demo_captured_emails' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()')) + table + .uuid('organization_id') + .notNullable() + .references('id') + .inTable('organizations') + .onDelete('CASCADE') + + table.string('kind', 16).notNullable() // 'relance' | 'checkin' + table.string('to_email', 254).notNullable() + table.string('to_name', 200).nullable() + table.string('from_email', 254).notNullable() + table.string('from_name', 200).nullable() + table.string('reply_to', 254).nullable() + table.string('subject', 200).notNullable() + table.text('body').notNullable() + table.jsonb('meta').notNullable().defaultTo('{}') + table.timestamp('sent_at', { useTz: true }).notNullable() + + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').nullable() + + // Lectures principales : la dernière inbox d'une org, en ordre desc. + table.index(['organization_id', 'sent_at']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 759ecc4..5fde5fe 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -109,6 +109,39 @@ export class ClientSchema extends BaseModel { declare updatedAt: DateTime | null } +export class DemoCapturedEmailSchema extends BaseModel { + static $columns = ['body', 'createdAt', 'fromEmail', 'fromName', 'id', 'kind', 'meta', 'organizationId', 'replyTo', 'sentAt', 'subject', 'toEmail', 'toName', 'updatedAt'] as const + $columns = DemoCapturedEmailSchema.$columns + @column() + declare body: string + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column() + declare fromEmail: string + @column() + declare fromName: string | null + @column({ isPrimary: true }) + declare id: string + @column() + declare kind: string + @column() + declare meta: any + @column() + declare organizationId: string + @column() + declare replyTo: string | null + @column.dateTime() + declare sentAt: DateTime + @column() + declare subject: string + @column() + declare toEmail: string + @column() + declare toName: string | null + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} + export class ImportBatchSchema extends BaseModel { static $columns = ['createdAt', 'id', 'organizationId', 'updatedAt'] as const $columns = ImportBatchSchema.$columns @@ -185,10 +218,14 @@ export class InvoiceSchema extends BaseModel { } export class OrganizationSchema extends BaseModel { - static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const + static $columns = ['createdAt', 'demoMode', 'demoSpeedFactor', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt', 'virtualNow'] as const $columns = OrganizationSchema.$columns @column.dateTime({ autoCreate: true }) declare createdAt: DateTime + @column() + declare demoMode: boolean + @column() + declare demoSpeedFactor: number @column({ isPrimary: true }) declare id: string @column() @@ -203,6 +240,8 @@ export class OrganizationSchema extends BaseModel { declare siret: string | null @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime | null + @column.dateTime() + declare virtualNow: DateTime | null } export class PlanStepSchema extends BaseModel { diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index a62eef5..72672ca 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -141,6 +141,22 @@ router .as('plans') .use(middleware.auth()) + /** + * Demo — auth requise. Mode démo opt-in par org (cf. CLAUDE.md → + * Architecture). Routes opérantes seulement si `org.demo_mode = true`. + */ + router + .group(() => { + router.post('start', [controllers.Demo, 'start']).as('start') + router.post('end', [controllers.Demo, 'end']).as('end') + router.post('tick', [controllers.Demo, 'tick']).as('tick') + router.get('state', [controllers.Demo, 'state']).as('state') + router.get('inbox', [controllers.Demo, 'inbox']).as('inbox') + }) + .prefix('demo') + .as('demo') + .use(middleware.auth()) + /** * Dashboard — auth requise. Calculs agrégés on-the-fly (pas de cache V1). */ diff --git a/apps/web/src/components/demo/DemoClock.tsx b/apps/web/src/components/demo/DemoClock.tsx new file mode 100644 index 0000000..ecf439f --- /dev/null +++ b/apps/web/src/components/demo/DemoClock.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from "react"; +import { Play, Pause, X, Gauge } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + SPEED_OPTIONS, + type Speed, + useDemoEnd, + useDemoState, + useDemoTick, +} from "@/lib/demo"; +import { Gem } from "@/components/brand/Gem"; +import { DemoEmailSlide } from "./DemoEmailSlide"; + +/** + * Horloge virtuelle de démo — visible top-right de _app, uniquement + * quand `org.demoMode = true`. + * + * Anatomie : + * ┌─────────────────────────────────────┐ + * │ vendredi 18 mai 2026 │ + * │ ◆────●───────────── J+5 / →prochain│ + * │ [▶] 1x 2x 5x [↻] [×] │ + * └─────────────────────────────────────┘ + * + * - Date pleine, font display, mise à jour live à chaque frame + * - Rail rubis-glow avec une pastille qui glisse de virtualNow vers le + * prochain event (proportion calculée backend → SPA) + * - Play/Pause + sélecteur de vitesse 1x/2x/5x + * - Bouton fermer = `/demo/end` + * + * Quand un event est déclenché, la slide-over droite s'ouvre avec + * l'email capturé. L'horloge est en pause tant que tous les events + * en attente n'ont pas été acquittés (clic "Continuer"). + */ + +const FR_DATE = new Intl.DateTimeFormat("fr-FR", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", +}); + +export function DemoClock() { + const { data: state } = useDemoState(); + const endMutation = useDemoEnd(); + + const enabled = state?.demoMode === true; + const tick = useDemoTick({ + enabled, + initialVirtualNow: state?.virtualNow, + }); + + // Re-sync local virtualNow quand le backend change (start/reset) + useEffect(() => { + if (state?.virtualNow) tick.resetTo(state.virtualNow); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state?.virtualNow, enabled]); + + if (!enabled) return null; + + const dateStr = FR_DATE.format(tick.virtualNow); + + // Progression vers le prochain event (0..1) — sert au rail visuel. + const progress = computeProgress({ + virtualNow: tick.virtualNow, + nextEventAt: state?.nextEventAt ?? null, + }); + + const hasPending = tick.pendingEvents.length > 0; + + return ( + <> +
+ {/* En-tête : date + tag DÉMO */} +
+
+

+ {dateStr} +

+

+ Mode démo · horloge virtuelle +

+
+ +
+ + {/* Rail rubis-glow avec pastille qui glisse */} +
+
+
+
+ + {/* Controls */} +
+ + + + +

+ {state?.nextEventAt + ? `→ ${shortNextLabel(tick.virtualNow, state.nextEventAt)}` + : "aucun event en file"} +

+
+
+ + {/* Slide-over : empile les events fired à acquitter un par un */} + {hasPending && ( + + )} + + ); +} + +function SpeedSelector({ + value, + onChange, +}: { + value: Speed; + onChange: (s: Speed) => void; +}) { + return ( +
+
+ ); +} + +function computeProgress({ + virtualNow, + nextEventAt, +}: { + virtualNow: Date; + nextEventAt: string | null; +}): number { + if (!nextEventAt) return 0; + const next = new Date(nextEventAt).getTime(); + // On affiche la progression sur une fenêtre de 30 jours autour du prochain event + // (évite que la pastille soit collée à 0% ou 100% en permanence). + const start = next - 30 * 86400000; + const now = virtualNow.getTime(); + if (now <= start) return 0; + if (now >= next) return 1; + return (now - start) / (next - start); +} + +function shortNextLabel(now: Date, iso: string): string { + const next = new Date(iso).getTime(); + const diffMs = next - now.getTime(); + if (diffMs <= 0) return "imminent"; + const days = Math.round(diffMs / 86400000); + if (days <= 0) return "aujourd'hui"; + return `dans ${days} j`; +} diff --git a/apps/web/src/components/demo/DemoEmailSlide.tsx b/apps/web/src/components/demo/DemoEmailSlide.tsx new file mode 100644 index 0000000..98090da --- /dev/null +++ b/apps/web/src/components/demo/DemoEmailSlide.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from "react"; +import { Mail, ArrowRight, X } from "lucide-react"; + +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import type { DemoCapturedEmail, FiredEvent } from "@/lib/demo"; +import { Button } from "@/components/ui/Button"; + +/** + * Slide-over droite affichant l'email qui vient d'être déclenché. + * Tant que l'utilisateur n'a pas cliqué "Continuer la démo", l'horloge + * reste en pause — c'est l'effet "le temps s'est arrêté pour montrer". + */ +const FR_DATETIME = new Intl.DateTimeFormat("fr-FR", { + weekday: "long", + day: "numeric", + month: "long", + hour: "2-digit", + minute: "2-digit", +}); + +export function DemoEmailSlide({ + event, + remaining, + virtualNow, + onContinue, +}: { + event: FiredEvent; + remaining: number; + virtualNow: Date; + onContinue: () => void; +}) { + const [email, setEmail] = useState(null); + + useEffect(() => { + if (!event.capturedEmailId) { + setEmail(null); + return; + } + let cancelled = false; + void api + .get("/api/v1/demo/inbox") + .then((list) => { + if (cancelled) return; + const found = list.find((e) => e.id === event.capturedEmailId); + setEmail(found ?? null); + }) + .catch(() => setEmail(null)); + return () => { + cancelled = true; + }; + }, [event.capturedEmailId]); + + // Petit slide-in subtil — pas une animation tape-à-l'œil, on tient la DA. + return ( + <> + {/* Backdrop discret — on ne ferme pas au clic dehors (volontaire : + l'utilisateur DOIT acquitter pour reprendre la démo). */} + + + {/* Horloge démo — auto-cachée si org.demoMode = false */} +
); } diff --git a/apps/web/src/lib/demo.ts b/apps/web/src/lib/demo.ts new file mode 100644 index 0000000..5b7044e --- /dev/null +++ b/apps/web/src/lib/demo.ts @@ -0,0 +1,243 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +import { api } from "./api"; +import { queryKeys } from "./queryKeys"; + +/** + * Mode démo côté SPA — horloge virtuelle + boucle de tick. + * + * L'idée : avancer virtualNow LOCALEMENT (smooth via rAF) puis sync + * périodiquement avec le backend qui fire les tasks dues. Quand un + * event est déclenché, on auto-pause et on pousse l'email dans une + * file pour la slide-over. + * + * Tout le code démo est isolé ici et dans /components/demo. La prod + * ne charge ce hook que pour les orgs avec `demoMode = true`. + */ + +export type FiredEvent = { + kind: "relance" | "checkin"; + taskId: string; + invoiceId: string; + invoiceNumero: string; + capturedEmailId: string | null; + firedAt: string; +}; + +export type DemoState = { + demoMode: boolean; + virtualNow?: string; // ISO + speedFactor?: number; + nextEventAt?: string | null; + inboxCount?: number; +}; + +export type DemoCapturedEmail = { + id: string; + kind: "relance" | "checkin"; + from: { email: string; name: string | null }; + to: { email: string; name: string | null }; + replyTo: string | null; + subject: string; + body: string; + meta: Record; + sentAt: string; +}; + +/** + * Vitesse : nombre de jours-démo par seconde réelle. + * 1 = 1 jour-démo / seconde (= 30 jours en 30s, lecture posée pour démo) + * 2 = 2 jours/s, etc. + */ +export const SPEED_OPTIONS = [1, 2, 5] as const; +export type Speed = (typeof SPEED_OPTIONS)[number]; + +/** + * Interval entre 2 syncs backend pendant la lecture, en ms. + * Trade-off : plus court = plus réactif aux events, plus de charge. + */ +const SYNC_INTERVAL_MS = 250; + +export const queryKeysDemo = { + state: () => ["demo", "state"] as const, + inbox: () => ["demo", "inbox"] as const, +}; + +/** + * Hook principal — query l'état démo. Si demoMode=false, retourne juste + * le flag. Le composant DemoClock se monte conditionnellement. + */ +export function useDemoState() { + return useQuery({ + queryKey: queryKeysDemo.state(), + queryFn: () => api.get("/api/v1/demo/state"), + staleTime: 0, + refetchOnWindowFocus: false, + }); +} + +export function useDemoInbox(enabled: boolean) { + return useQuery({ + queryKey: queryKeysDemo.inbox(), + queryFn: () => api.get("/api/v1/demo/inbox"), + enabled, + staleTime: 0, + refetchOnWindowFocus: false, + }); +} + +export function useDemoStart() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post("/api/v1/demo/start"), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: queryKeysDemo.state() }); + void qc.invalidateQueries({ queryKey: queryKeysDemo.inbox() }); + void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() }); + }, + }); +} + +export function useDemoEnd() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post<{ demoMode: false }>("/api/v1/demo/end"), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: queryKeysDemo.state() }); + void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() }); + }, + }); +} + +/** + * Boucle de tick — avance virtualNow localement (rAF) et sync le + * backend périodiquement. Quand fired events > 0 : auto-pause + + * push event dans la file. + * + * Retourne : + * - virtualNow : Date locale, mise à jour à chaque frame + * - playing : true si la pastille avance + * - speed : 1 | 2 | 5 + * - play / pause / setSpeed : controls + * - pendingEvents : queue des events fired (à dépiler par l'UI quand + * l'utilisateur les acquitte) + * - acknowledge : retire le 1er event de la file (et quand vide, l'UI + * sait qu'on peut reprendre) + * + * @param initialVirtualNow ISO string de la backend lors du mount + * @param enabled false = ne pas démarrer la boucle (pour orgs non-démo) + */ +export function useDemoTick(params: { + enabled: boolean; + initialVirtualNow?: string; +}) { + const { enabled, initialVirtualNow } = params; + const qc = useQueryClient(); + + const [virtualNow, setVirtualNow] = useState(() => + initialVirtualNow ? new Date(initialVirtualNow) : new Date(), + ); + const [playing, setPlaying] = useState(false); + const [speed, setSpeed] = useState(1); + const [pendingEvents, setPendingEvents] = useState([]); + + // Ref pour le scheduler — on évite de recréer la closure à chaque render. + const lastTickRef = useRef(performance.now()); + const lastSyncRef = useRef(0); + const virtualNowRef = useRef(virtualNow); + virtualNowRef.current = virtualNow; + const playingRef = useRef(playing); + playingRef.current = playing; + const speedRef = useRef(speed); + speedRef.current = speed; + + // Sync avec le backend. + const syncWithBackend = async () => { + const target = virtualNowRef.current.toISOString(); + try { + const { firedEvents } = await api.post<{ + virtualNow: string; + firedEvents: FiredEvent[]; + }>("/api/v1/demo/tick", { virtualNow: target }); + if (firedEvents.length > 0) { + // Auto-pause + push events + playingRef.current = false; + setPlaying(false); + setPendingEvents((prev) => [...prev, ...firedEvents]); + // Refresh inbox + dashboard pour que l'UI reflète l'état réel + void qc.invalidateQueries({ queryKey: queryKeysDemo.inbox() }); + void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() }); + } + } catch { + // Erreur réseau / backend down → on pause silencieusement, l'UI + // reste cohérente, l'utilisateur peut retenter. + playingRef.current = false; + setPlaying(false); + } + }; + + // Boucle rAF + useEffect(() => { + if (!enabled) return; + let raf = 0; + const loop = (now: number) => { + raf = requestAnimationFrame(loop); + if (!playingRef.current) { + lastTickRef.current = now; + return; + } + const elapsedRealMs = now - lastTickRef.current; + lastTickRef.current = now; + // speed = jours-démo par seconde réelle. + // → 1 ms réelle = (speed / 1000) jour-démo = (speed * 86_400_000 / 1000) ms virtuelles. + const advanceMs = (speedRef.current * elapsedRealMs * 86_400_000) / 1000; + const next = new Date(virtualNowRef.current.getTime() + advanceMs); + virtualNowRef.current = next; + setVirtualNow(next); + + // Sync avec backend toutes les SYNC_INTERVAL_MS + if (now - lastSyncRef.current >= SYNC_INTERVAL_MS) { + lastSyncRef.current = now; + void syncWithBackend(); + } + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled]); + + const acknowledge = () => { + setPendingEvents((prev) => prev.slice(1)); + }; + + // L'UI considère qu'on peut "play" si on n'a aucun event en attente. + const canPlay = pendingEvents.length === 0; + + return useMemo( + () => ({ + virtualNow, + playing, + speed, + pendingEvents, + canPlay, + play: () => { + if (!pendingEvents.length) { + lastTickRef.current = performance.now(); + setPlaying(true); + } + }, + pause: () => setPlaying(false), + setSpeed: (s: Speed) => setSpeed(s), + acknowledge, + // Reset à la valeur backend (utile après /demo/start) + resetTo: (iso: string) => { + const d = new Date(iso); + virtualNowRef.current = d; + setVirtualNow(d); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [virtualNow, playing, speed, pendingEvents, canPlay], + ); +} diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts index 6456ec2..73e7c11 100644 --- a/apps/web/src/lib/queryKeys.ts +++ b/apps/web/src/lib/queryKeys.ts @@ -21,6 +21,7 @@ export const queryKeys = { detail: (id: string) => ["clients", "detail", id] as const, }, dashboard: { + all: () => ["dashboard"] as const, kpis: () => ["dashboard", "kpis"] as const, activity: () => ["dashboard", "activity"] as const, }, diff --git a/apps/web/src/routes/_app/parametres.tsx b/apps/web/src/routes/_app/parametres.tsx index 7efb956..0f0b7e3 100644 --- a/apps/web/src/routes/_app/parametres.tsx +++ b/apps/web/src/routes/_app/parametres.tsx @@ -5,6 +5,7 @@ import { AccountForm } from "@/components/settings/AccountForm"; import { OrganizationForm } from "@/components/settings/OrganizationForm"; import { SignatureForm } from "@/components/settings/SignatureForm"; import { DangerZone } from "@/components/settings/DangerZone"; +import { DemoToggle } from "@/components/demo/DemoToggle"; export const Route = createFileRoute("/_app/parametres")({ component: ParametresPage, @@ -63,6 +64,18 @@ function ParametresPage() { + + Faire vivre Rubis en accéléré + + } + description="Mode démo : horloge virtuelle qui avance dans le temps, emails capturés au lieu d'être envoyés à de vrais clients. Idéal pour montrer Rubis à un prospect." + > + + +