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 }