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>
This commit is contained in:
parent
6eb9ca4120
commit
933c6496b1
@ -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<ResolvedTask> {
|
||||
|
||||
// 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()
|
||||
})
|
||||
|
||||
|
||||
204
apps/api/app/controllers/demo_controller.ts
Normal file
204
apps/api/app/controllers/demo_controller.ts
Normal file
@ -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<Organization> {
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
9
apps/api/app/models/demo_captured_email.ts
Normal file
9
apps/api/app/models/demo_captured_email.ts
Normal file
@ -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<typeof Organization>
|
||||
}
|
||||
@ -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<ActivityEvent> {
|
||||
kind,
|
||||
label,
|
||||
meta,
|
||||
at: at ?? DateTime.now(),
|
||||
at: at ?? (await clock.now(organizationId)),
|
||||
},
|
||||
trx ? { client: trx } : undefined
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
111
apps/api/app/services/clock.ts
Normal file
111
apps/api/app/services/clock.ts
Normal file
@ -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<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()
|
||||
}
|
||||
@ -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<DashboardKpis> {
|
||||
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<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
|
||||
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<PaidByMonthPoint[]> {
|
||||
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<string, PaidByMonthPoint>()
|
||||
|
||||
48
apps/api/app/services/demo/capture.ts
Normal file
48
apps/api/app/services/demo/capture.ts
Normal file
@ -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<string, unknown>
|
||||
}
|
||||
|
||||
export async function captureEmailIfDemo(input: CaptureInput): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
140
apps/api/app/services/demo/dispatch.ts
Normal file
140
apps/api/app/services/demo/dispatch.ts
Normal file
@ -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<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
|
||||
}
|
||||
@ -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<Invoice, 'numero' | 'amountTtcCents' | 'dueDate' | 'issueDate'>
|
||||
client: Pick<Client, 'name' | 'email' | 'contactFirstName' | 'contactLastName'>
|
||||
user: Pick<User, 'fullName' | 'signature' | 'email'> | null
|
||||
organization?: Pick<Organization, 'name'> | 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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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).
|
||||
*/
|
||||
|
||||
224
apps/web/src/components/demo/DemoClock.tsx
Normal file
224
apps/web/src/components/demo/DemoClock.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-4 right-4 z-30 w-[300px]",
|
||||
"rounded-card border border-rubis-glow bg-white shadow-card",
|
||||
"px-4 py-3",
|
||||
)}
|
||||
>
|
||||
{/* En-tête : date + tag DÉMO */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<p className="font-display text-[14.5px] font-bold leading-tight text-ink capitalize tabular-nums">
|
||||
{dateStr}
|
||||
</p>
|
||||
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold mt-0.5">
|
||||
Mode démo · horloge virtuelle
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => endMutation.mutate()}
|
||||
className="size-6 flex items-center justify-center rounded-full text-ink-3 hover:text-rubis-deep hover:bg-rubis-glow/40 transition-colors"
|
||||
aria-label="Quitter le mode démo"
|
||||
title="Quitter le mode démo"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rail rubis-glow avec pastille qui glisse */}
|
||||
<div className="relative h-2 mb-3 mt-1">
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-line" />
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-px bg-rubis transition-[width] duration-300"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -top-1 size-3 rotate-45 bg-rubis shadow-rubis transition-[left] duration-300"
|
||||
style={{ left: `calc(${progress * 100}% - 6px)` }}
|
||||
/>
|
||||
<Gem
|
||||
size={10}
|
||||
aria-hidden="true"
|
||||
className="absolute -top-1 -left-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (tick.playing ? tick.pause() : tick.play())}
|
||||
disabled={hasPending}
|
||||
className={cn(
|
||||
"size-9 flex items-center justify-center rounded-default",
|
||||
"bg-rubis text-white shadow-rubis hover:bg-rubis-deep transition-colors",
|
||||
"disabled:bg-line disabled:text-ink-3 disabled:shadow-none disabled:cursor-not-allowed",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
)}
|
||||
aria-label={tick.playing ? "Pause" : "Lecture"}
|
||||
>
|
||||
{tick.playing ? <Pause size={14} /> : <Play size={14} className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
<SpeedSelector value={tick.speed} onChange={tick.setSpeed} />
|
||||
|
||||
<p className="ml-auto text-[10.5px] text-ink-3 italic tabular-nums">
|
||||
{state?.nextEventAt
|
||||
? `→ ${shortNextLabel(tick.virtualNow, state.nextEventAt)}`
|
||||
: "aucun event en file"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slide-over : empile les events fired à acquitter un par un */}
|
||||
{hasPending && (
|
||||
<DemoEmailSlide
|
||||
event={tick.pendingEvents[0]!}
|
||||
remaining={tick.pendingEvents.length - 1}
|
||||
virtualNow={tick.virtualNow}
|
||||
onContinue={tick.acknowledge}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Speed;
|
||||
onChange: (s: Speed) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Vitesse"
|
||||
className="inline-flex items-center gap-1 rounded-default border border-line bg-cream-2/40 px-1.5 py-0.5"
|
||||
>
|
||||
<Gauge size={11} className="text-ink-3" aria-hidden="true" />
|
||||
{SPEED_OPTIONS.map((s) => {
|
||||
const active = s === value;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onChange(s)}
|
||||
className={cn(
|
||||
"h-6 px-1.5 rounded-sharp text-[11px] font-semibold tabular-nums transition-colors",
|
||||
active
|
||||
? "bg-rubis text-white"
|
||||
: "text-ink-2 hover:bg-cream-2",
|
||||
)}
|
||||
>
|
||||
{s}x
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
154
apps/web/src/components/demo/DemoEmailSlide.tsx
Normal file
154
apps/web/src/components/demo/DemoEmailSlide.tsx
Normal file
@ -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<DemoCapturedEmail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!event.capturedEmailId) {
|
||||
setEmail(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void api
|
||||
.get<DemoCapturedEmail[]>("/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). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed inset-0 z-40 bg-ink/10 backdrop-blur-[2px]"
|
||||
/>
|
||||
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-label="Email reçu pendant la démo"
|
||||
className={cn(
|
||||
"fixed top-0 right-0 z-50 h-screen w-full max-w-[520px]",
|
||||
"bg-cream border-l border-line shadow-card",
|
||||
"flex flex-col",
|
||||
"animate-in slide-in-from-right duration-200",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b border-line bg-white px-5 py-4">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 size-9 flex items-center justify-center rounded-full",
|
||||
event.kind === "relance" ? "bg-rubis text-white" : "bg-rubis-glow text-rubis-deep",
|
||||
)}
|
||||
>
|
||||
<Mail size={15} />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-display text-[14.5px] font-bold text-ink leading-tight">
|
||||
{event.kind === "relance" ? "Relance envoyée" : "Check-in envoyé"}
|
||||
</p>
|
||||
<p className="text-[11.5px] text-ink-3 capitalize">
|
||||
{FR_DATETIME.format(virtualNow)} · facture {event.invoiceNumero}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email rendu façon vrai client mail */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5">
|
||||
{email ? (
|
||||
<article className="rounded-card border border-line bg-white shadow-soft overflow-hidden">
|
||||
<div className="border-b border-line bg-cream-2/50 px-5 py-3 space-y-1">
|
||||
<p className="text-[11.5px] text-ink-3">
|
||||
<span className="font-semibold text-ink-2">De :</span>{" "}
|
||||
{email.from.name} <{email.from.email}>
|
||||
</p>
|
||||
<p className="text-[11.5px] text-ink-3">
|
||||
<span className="font-semibold text-ink-2">À :</span>{" "}
|
||||
{email.to.name ? `${email.to.name} — ` : ""}
|
||||
{email.to.email}
|
||||
</p>
|
||||
{email.replyTo && (
|
||||
<p className="text-[11.5px] text-ink-3">
|
||||
<span className="font-semibold text-ink-2">Reply-To :</span>{" "}
|
||||
{email.replyTo}
|
||||
</p>
|
||||
)}
|
||||
<p className="font-display text-[15px] font-bold text-ink mt-1.5 leading-tight">
|
||||
{email.subject}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<pre className="whitespace-pre-wrap font-sans text-[13.5px] leading-relaxed text-ink-2">
|
||||
{email.body}
|
||||
</pre>
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
<p className="text-[13px] italic text-ink-3 text-center py-8">
|
||||
Chargement de l'email…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-line bg-white px-5 py-4 flex items-center justify-between">
|
||||
<p className="text-[11.5px] text-ink-3 italic">
|
||||
{remaining > 0
|
||||
? `${remaining} autre${remaining > 1 ? "s" : ""} email${remaining > 1 ? "s" : ""} à voir`
|
||||
: "Cliquez pour reprendre la démo"}
|
||||
</p>
|
||||
<Button size="sm" onClick={onContinue}>
|
||||
Continuer <ArrowRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="absolute top-3 right-3 size-7 flex items-center justify-center rounded-full text-ink-3 hover:bg-cream-2"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/components/demo/DemoToggle.tsx
Normal file
102
apps/web/src/components/demo/DemoToggle.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import { Sparkles, Power } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useDemoEnd, useDemoStart, useDemoState } from "@/lib/demo";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
/**
|
||||
* Bouton "Mode démo" pour /parametres.
|
||||
*
|
||||
* Active le flag `demo_mode` sur l'org du user, qui :
|
||||
* - Diverte les emails vers une boîte capturée (pas de Resend)
|
||||
* - Active l'horloge virtuelle (top-right)
|
||||
* - Permet d'avancer le temps en accéléré
|
||||
*
|
||||
* À désactiver après une démo pour repasser en prod normale.
|
||||
*/
|
||||
export function DemoToggle() {
|
||||
const { data: state } = useDemoState();
|
||||
const startMutation = useDemoStart();
|
||||
const endMutation = useDemoEnd();
|
||||
const [confirmEnd, setConfirmEnd] = useState(false);
|
||||
|
||||
const isDemo = state?.demoMode === true;
|
||||
|
||||
if (isDemo) {
|
||||
return (
|
||||
<Card padding="md" className="border-rubis bg-rubis-glow/30">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
||||
Mode démo actif
|
||||
</p>
|
||||
<p className="mt-1 font-display text-[16px] font-bold text-ink">
|
||||
L'horloge virtuelle tourne
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12.5px] text-ink-2 leading-snug max-w-md">
|
||||
Les emails sont capturés dans la boîte démo (pas envoyés à de
|
||||
vrais clients). Vos KPIs et le DSO suivent l'horloge virtuelle.
|
||||
Quittez le mode démo pour revenir au temps réel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
{confirmEnd ? (
|
||||
<>
|
||||
<p className="text-[12.5px] text-ink-2">
|
||||
Sûr ? L'inbox démo sera conservée mais l'horloge revient au
|
||||
temps réel.
|
||||
</p>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
loading={endMutation.isPending}
|
||||
onClick={() => {
|
||||
endMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success("Mode démo désactivé.");
|
||||
setConfirmEnd(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Power size={13} /> Quitter le mode démo
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirmEnd(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" onClick={() => setConfirmEnd(true)}>
|
||||
<Power size={13} /> Quitter le mode démo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<p className="text-[12.5px] text-ink-2 leading-snug max-w-md mb-3">
|
||||
Active une horloge virtuelle qui permet d'avancer dans le temps en
|
||||
accéléré. Idéal pour montrer Rubis en condition réelle pendant une
|
||||
démo : les emails apparaissent en direct, ne partent pas chez de vrais
|
||||
clients.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={startMutation.isPending}
|
||||
onClick={() => {
|
||||
startMutation.mutate(undefined, {
|
||||
onSuccess: () => toast.success("Mode démo activé. Horloge en pause."),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Sparkles size={13} /> Démarrer une démo
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button";
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
import { AppTopbar } from "./AppTopbar";
|
||||
import { MobileTabBar } from "./MobileTabBar";
|
||||
import { DemoClock } from "@/components/demo/DemoClock";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
ManualInvoiceProvider,
|
||||
@ -95,6 +96,9 @@ function AppLayoutInner({ children, title, subtitle, actions }: AppLayoutProps)
|
||||
</div>
|
||||
|
||||
<MobileTabBar />
|
||||
|
||||
{/* Horloge démo — auto-cachée si org.demoMode = false */}
|
||||
<DemoClock />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
243
apps/web/src/lib/demo.ts
Normal file
243
apps/web/src/lib/demo.ts
Normal file
@ -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<string, unknown>;
|
||||
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<DemoState>("/api/v1/demo/state"),
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDemoInbox(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeysDemo.inbox(),
|
||||
queryFn: () => api.get<DemoCapturedEmail[]>("/api/v1/demo/inbox"),
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDemoStart() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => api.post<DemoState>("/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<Date>(() =>
|
||||
initialVirtualNow ? new Date(initialVirtualNow) : new Date(),
|
||||
);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState<Speed>(1);
|
||||
const [pendingEvents, setPendingEvents] = useState<FiredEvent[]>([]);
|
||||
|
||||
// Ref pour le scheduler — on évite de recréer la closure à chaque render.
|
||||
const lastTickRef = useRef<number>(performance.now());
|
||||
const lastSyncRef = useRef<number>(0);
|
||||
const virtualNowRef = useRef<Date>(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],
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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() {
|
||||
<SignatureForm />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
eyebrow="Démonstration"
|
||||
title={
|
||||
<>
|
||||
Faire vivre Rubis en <em className="text-rubis">accéléré</em>
|
||||
</>
|
||||
}
|
||||
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."
|
||||
>
|
||||
<DemoToggle />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
eyebrow="Zone danger"
|
||||
title="Déconnexion et compte"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user