From a6b35dfe7a3adec8dd52d176ac0814a833d91854 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 15:24:46 +0200 Subject: [PATCH] feat(api): RelanceTask + CheckinTask + worker BullMQ qui envoie les relances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrations : - relance_tasks (uuid id, organization_id FK CASCADE [scope direct sans join], invoice_id FK CASCADE, plan_step_id FK RESTRICT, send_at, status ENUM scheduled/sent/cancelled/failed, sent_at, queue_job_id pour cancel via BullMQ.remove). Indexes (org,status), (invoice_id), (send_at). - checkin_tasks (uuid id, org_id, invoice_id, send_at, token_hash unique [SHA-256 du HMAC, TTL 24h], status ENUM scheduled/sent/answered/expired, answer 'paid'|'still_pending'). Pas encore branché — flow check-in arrivera dans un commit séparé (cf. backend.md §13.3). Schema rules : status enums + answer typés. Models RelanceTask + CheckinTask avec belongsTo Invoice / PlanStep. Service relance_scheduler.ts : - scheduleRelancesForInvoice(invoice) : pour chaque step du plan, calcule sendAt = dueDate + offsetDays. Si sendAt < now (facture importée en retard), on programme à `now + 1min` plutôt que skip — l'utilisateur "rattrape" une dette de relance, l'envoi immédiat est cohérent. Crée la RelanceTask + enqueue BullMQ avec delay, retry 5x exponential, jobId = `relance:` pour idempotency. Cancelle les tasks scheduled existantes avant de re-programmer (gestion changement de plan). - cancelFutureRelances(invoiceId, trx) : appelé par mark-paid pour stopper la chaîne. Service queue.ts : - getQueue(name) singleton lazy par queue - registerWorker(name, handler) avec concurrency 5, log failed/completed - shutdownQueue() pour le terminating hook Adonis start/queue.ts (preload) : registerWorker('relances', sendRelanceJob) seulement quand `app.getEnvironment() === 'web'` (pas en tests/REPL — pas de connexion Redis pendant Japa). Job send_relance_job.ts : - Idempotent : si task.status !== 'scheduled', no-op - Hook critique : si invoice paid/cancelled entre-temps, task.status = cancelled - Mise en demeure (step.requiresManualValidation) : on n'envoie PAS, on log un activity_event 'warning_drafted' (cf. CLAUDE.md → Principes : validation manuelle obligatoire) - Sinon : sendRelanceEmail + task.status=sent + invoice.rubisEarned+1 + organizations.rubis_count+1 + activity_event 'relance_sent'. Si invoice.status='pending', passe en 'in_relance' (sortie de l'état silencieux). Service mail_dispatcher.ts : sendRelanceEmail interpole step.subject/body via mini moteur Mustache-like (renderTemplate, services/template.ts) avec {{client.name}}/{{numero}}/{{amount}}/{{dueDate}}/{{signature}}, puis @adonisjs/mail.use(MAIL_DRIVER) → Mailpit en dev, Resend en prod. Texte brut V1. Triggers branchés : - InvoicesController.store : si planId, scheduleRelancesForInvoice après création - ImportBatchesController.validateDraft : pareil - InvoicesController.markPaid : cancelFutureRelances dans la même tx que le paiement #jobs/* ajouté aux imports package.json. Adonisrc preload start/queue.ts. Bruno : doc 05-Invoices/04 Create maj avec instructions pour tester l'envoi immédiat (dueDate dans le passé → relance à now+1min → email visible dans Mailpit http://localhost:8025). --- apps/api/adonisrc.ts | 1 + .../controllers/import_batches_controller.ts | 5 + .../app/controllers/invoices_controller.ts | 18 ++- apps/api/app/jobs/send_relance_job.ts | 115 +++++++++++++++++ apps/api/app/models/checkin_task.ts | 9 ++ apps/api/app/models/relance_task.ts | 13 ++ apps/api/app/services/mail_dispatcher.ts | 47 +++++++ apps/api/app/services/queue.ts | 60 +++++++++ apps/api/app/services/relance_scheduler.ts | 121 ++++++++++++++++++ apps/api/app/services/template.ts | 37 ++++++ ...778080001000_create_relance_tasks_table.ts | 60 +++++++++ ...778080001100_create_checkin_tasks_table.ts | 49 +++++++ apps/api/database/schema.ts | 52 ++++++++ apps/api/database/schema_rules.ts | 17 +++ apps/api/package.json | 1 + apps/api/start/queue.ts | 30 +++++ bruno/05-Invoices/04 Create.bru | 11 +- 17 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 apps/api/app/jobs/send_relance_job.ts create mode 100644 apps/api/app/models/checkin_task.ts create mode 100644 apps/api/app/models/relance_task.ts create mode 100644 apps/api/app/services/mail_dispatcher.ts create mode 100644 apps/api/app/services/queue.ts create mode 100644 apps/api/app/services/relance_scheduler.ts create mode 100644 apps/api/app/services/template.ts create mode 100644 apps/api/database/migrations/1778080001000_create_relance_tasks_table.ts create mode 100644 apps/api/database/migrations/1778080001100_create_checkin_tasks_table.ts create mode 100644 apps/api/start/queue.ts diff --git a/apps/api/adonisrc.ts b/apps/api/adonisrc.ts index 624913f..bd413b5 100644 --- a/apps/api/adonisrc.ts +++ b/apps/api/adonisrc.ts @@ -73,6 +73,7 @@ export default defineConfig({ () => import('#start/routes'), () => import('#start/kernel'), () => import('#start/validator'), + () => import('#start/queue'), ], /* diff --git a/apps/api/app/controllers/import_batches_controller.ts b/apps/api/app/controllers/import_batches_controller.ts index d78ca08..7be813d 100644 --- a/apps/api/app/controllers/import_batches_controller.ts +++ b/apps/api/app/controllers/import_batches_controller.ts @@ -16,6 +16,7 @@ import { type ImportSource, } from '#services/import_batch' import { recordActivity } from '#services/activity_recorder' +import { scheduleRelancesForInvoice } from '#services/relance_scheduler' import drive from '@adonisjs/drive/services/main' import { createReadStream } from 'node:fs' import { randomUUID } from 'node:crypto' @@ -221,6 +222,10 @@ export default class ImportBatchesController { await invoice.load('client') await invoice.load('plan') + if (invoice.planId) { + await scheduleRelancesForInvoice(invoice) + } + return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() }) } diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts index 8e58481..8267c0b 100644 --- a/apps/api/app/controllers/invoices_controller.ts +++ b/apps/api/app/controllers/invoices_controller.ts @@ -11,6 +11,10 @@ import db from '@adonisjs/lucid/services/db' import { DateTime } from 'luxon' import { resolveClient } from '#services/resolve_client' import { recordActivity } from '#services/activity_recorder' +import { + scheduleRelancesForInvoice, + cancelFutureRelances, +} from '#services/relance_scheduler' const PAGE_SIZE = 50 @@ -296,13 +300,20 @@ export default class InvoicesController { await invoice.load('client') await invoice.load('plan') + // Programme les relances BullMQ si la facture a un plan. Hors tx : + // les jobs sont posés dans Redis, on n'a pas besoin de cohérence DB + // (et BullMQ.add() retourne avant d'écrire à Redis sur certains modes). + if (invoice.planId) { + await scheduleRelancesForInvoice(invoice) + } + return response.status(201).json({ data: serializeInvoice(invoice) }) } /** * POST /invoices/:id/mark-paid * Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned - * et sur organization.rubisCount). + * et sur organization.rubisCount). Annule toutes les relances futures. */ async markPaid({ auth, params, response }: HttpContext) { const organizationId = requireOrgId(auth) @@ -343,6 +354,11 @@ export default class InvoicesController { meta: { invoiceId: invoice.id, clientId: invoice.clientId }, trx, }) + + // Annule toutes les relances futures programmées pour cette facture + // (idempotent, BullMQ.remove peut échouer silencieusement si le + // job a déjà été consommé). + await cancelFutureRelances(invoice.id, trx) }) return response.json({ data: serializeInvoice(invoice) }) diff --git a/apps/api/app/jobs/send_relance_job.ts b/apps/api/app/jobs/send_relance_job.ts new file mode 100644 index 0000000..af4d861 --- /dev/null +++ b/apps/api/app/jobs/send_relance_job.ts @@ -0,0 +1,115 @@ +import RelanceTask from '#models/relance_task' +import Invoice from '#models/invoice' +import User from '#models/user' +import { sendRelanceEmail } from '#services/mail_dispatcher' +import { recordActivity } from '#services/activity_recorder' +import db from '@adonisjs/lucid/services/db' +import { DateTime } from 'luxon' +import logger from '@adonisjs/core/services/logger' + +/** + * Worker BullMQ pour la queue `relances`. Idempotent : si la task n'est + * plus `scheduled` (déjà envoyée, annulée, ou échouée définitivement), + * no-op. + * + * Cas critiques : + * - Invoice payée/annulée entre temps → cancel la task (pas d'envoi) + * - Step `requires_manual_validation` (mise en demeure) → on n'envoie + * PAS, on log un activity_event 'warning_drafted' que l'utilisateur + * devra valider manuellement (cf. CLAUDE.md → Principes produit). + * - Sinon : envoi de l'email + bump rubis (1 rubis = 10 min libérées). + */ +export async function sendRelanceJob(jobData: { taskId: string }) { + const task = await RelanceTask.query() + .where('id', jobData.taskId) + .preload('planStep') + .first() + if (!task) { + logger.warn({ taskId: jobData.taskId }, 'relance task not found, skipping') + return + } + if (task.status !== 'scheduled') { + logger.info({ taskId: task.id, status: task.status }, 'relance task not scheduled, skipping') + return + } + + const invoice = await Invoice.query() + .where('id', task.invoiceId) + .preload('client') + .first() + if (!invoice) { + task.status = 'cancelled' + await task.save() + return + } + + // Hook critique : la facture peut avoir été payée entre la programmation + // et l'exécution. On vérifie avant d'envoyer. + if (invoice.status === 'paid' || invoice.status === 'cancelled') { + task.status = 'cancelled' + await task.save() + return + } + + const step = task.planStep + const user = await User.query().where('organization_id', invoice.organizationId).first() + + // Mise en demeure : on génère un brouillon, on n'envoie pas (cf. CLAUDE.md). + if (step.requiresManualValidation) { + 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() + await task.save() + + await recordActivity({ + organizationId: invoice.organizationId, + kind: 'warning_drafted', + label: `Brouillon mise en demeure prêt — ${invoice.client.name} (${invoice.numero})`, + meta: { + invoiceId: invoice.id, + clientId: invoice.clientId, + planStepOrder: step.order, + }, + trx, + }) + }) + return + } + + // Envoi normal + await sendRelanceEmail({ invoice, client: invoice.client, step, user }) + + await db.transaction(async (trx) => { + task.useTransaction(trx) + task.status = 'sent' + task.sentAt = DateTime.now() + await task.save() + + invoice.useTransaction(trx) + // Première relance envoyée → status passe en `in_relance` (la facture + // sort de l'état "pending" silencieux). + if (invoice.status === 'pending') { + invoice.status = 'in_relance' + } + invoice.rubisEarned = invoice.rubisEarned + 1 + await invoice.save() + + await trx + .from('organizations') + .where('id', invoice.organizationId) + .increment('rubis_count', 1) + + await recordActivity({ + organizationId: invoice.organizationId, + kind: 'relance_sent', + label: `Relance J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} envoyée à ${invoice.client.name}`, + meta: { + invoiceId: invoice.id, + clientId: invoice.clientId, + planStepOrder: step.order, + }, + trx, + }) + }) +} diff --git a/apps/api/app/models/checkin_task.ts b/apps/api/app/models/checkin_task.ts new file mode 100644 index 0000000..d3d1433 --- /dev/null +++ b/apps/api/app/models/checkin_task.ts @@ -0,0 +1,9 @@ +import { CheckinTaskSchema } from '#database/schema' +import { belongsTo } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import Invoice from '#models/invoice' + +export default class CheckinTask extends CheckinTaskSchema { + @belongsTo(() => Invoice) + declare invoice: BelongsTo +} diff --git a/apps/api/app/models/relance_task.ts b/apps/api/app/models/relance_task.ts new file mode 100644 index 0000000..bbf5ce9 --- /dev/null +++ b/apps/api/app/models/relance_task.ts @@ -0,0 +1,13 @@ +import { RelanceTaskSchema } from '#database/schema' +import { belongsTo } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import Invoice from '#models/invoice' +import PlanStep from '#models/plan_step' + +export default class RelanceTask extends RelanceTaskSchema { + @belongsTo(() => Invoice) + declare invoice: BelongsTo + + @belongsTo(() => PlanStep, { foreignKey: 'planStepId' }) + declare planStep: BelongsTo +} diff --git a/apps/api/app/services/mail_dispatcher.ts b/apps/api/app/services/mail_dispatcher.ts new file mode 100644 index 0000000..1ec4ab3 --- /dev/null +++ b/apps/api/app/services/mail_dispatcher.ts @@ -0,0 +1,47 @@ +import mail from '@adonisjs/mail/services/main' +import env from '#start/env' +import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template' +import type Invoice from '#models/invoice' +import type Client from '#models/client' +import type PlanStep from '#models/plan_step' +import type User from '#models/user' + +type RelancePayload = { + invoice: Invoice + client: Client + step: PlanStep + user: User | null +} + +/** + * Envoie un email de relance à un client à partir d'un step. + * Le subject/body du step contiennent des placeholders Mustache-like + * (`{{client.name}}`, `{{numero}}`, `{{amount}}`, `{{dueDate}}`, + * `{{signature}}`) qu'on interpole avant l'envoi. + * + * Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev, + * `resend` en prod). + */ +export async function sendRelanceEmail({ invoice, client, step, user }: RelancePayload) { + const vars = { + client: { name: client.name, email: client.email }, + numero: invoice.numero, + amount: formatAmountFr(invoice.amountTtcCents), + dueDate: formatDateFr(invoice.dueDate.toJSDate()), + issueDate: formatDateFr(invoice.issueDate.toJSDate()), + signature: user?.signature ?? user?.fullName ?? '', + } + + const subject = renderTemplate(step.subject, vars) + const body = renderTemplate(step.body, vars) + + 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")) + .to(client.email, client.name) + .subject(subject) + // Texte brut pour V1 — on ajoutera un template HTML quand on aura + // décidé d'un look graphique pour les relances. + .text(body) + }) +} diff --git a/apps/api/app/services/queue.ts b/apps/api/app/services/queue.ts new file mode 100644 index 0000000..07baabe --- /dev/null +++ b/apps/api/app/services/queue.ts @@ -0,0 +1,60 @@ +import { Queue, Worker, type Processor } from 'bullmq' +import { redisConnection, queueNames, type QueueName } from '#config/queue' +import logger from '@adonisjs/core/services/logger' + +/** + * Wrappers BullMQ partagés. Chaque queue a 1 instance Queue (producer) + * et N workers (consumers) avec le bon handler. + * + * V1 : on garde tout en mémoire process — workers et HTTP partagent le + * même Node. Quand le volume justifie le coût, on extrait les workers + * dans un Deployment K3s séparé (cf. backend.md §13.4). + */ + +const queues = new Map() +const workers: Worker[] = [] + +export function getQueue(name: QueueName): Queue { + let q = queues.get(name) + if (!q) { + q = new Queue(name, { connection: redisConnection }) + queues.set(name, q) + } + return q +} + +export type JobHandler = Processor + +/** + * Enregistre un Worker BullMQ sur une queue. Démarre tout de suite. + * Appelé par start/queue.ts au boot pour câbler les handlers. + */ +export function registerWorker(name: QueueName, handler: JobHandler): Worker { + const worker = new Worker(name, handler, { + connection: redisConnection, + concurrency: 5, + }) + worker.on('failed', (job, err) => { + logger.error({ err, queue: name, jobId: job?.id }, 'job failed') + }) + worker.on('completed', (job) => { + logger.info({ queue: name, jobId: job.id }, 'job completed') + }) + workers.push(worker) + return worker +} + +/** + * Stoppe proprement tous les workers + queues. Appelé au shutdown du + * process via Adonis terminating hook. + */ +export async function shutdownQueue(): Promise { + await Promise.all(workers.map((w) => w.close())) + await Promise.all(Array.from(queues.values()).map((q) => q.close())) +} + +/** + * Liste des noms de queue (re-export du config pour ne pas exposer la + * connection Redis ailleurs dans l'app). + */ +export const QUEUES = queueNames diff --git a/apps/api/app/services/relance_scheduler.ts b/apps/api/app/services/relance_scheduler.ts new file mode 100644 index 0000000..4579719 --- /dev/null +++ b/apps/api/app/services/relance_scheduler.ts @@ -0,0 +1,121 @@ +import { DateTime } from 'luxon' +import RelanceTask from '#models/relance_task' +import Plan from '#models/plan' +import Invoice from '#models/invoice' +import { getQueue } from '#services/queue' +import type { TransactionClientContract } from '@adonisjs/lucid/types/database' + +const RELANCE_QUEUE = 'relances' + +/** + * Programme toutes les relances d'une facture selon son plan. + * + * - Pour chaque step du plan, calcule sendAt = invoice.dueDate + offsetDays + * - Crée une RelanceTask `scheduled` + * - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now + * + * Si sendAt est dans le passé (cas : facture importée avec une dueDate + * ancienne), on programme quand même la task pour `now + 1 min` — l'user + * est probablement en train de "rattraper" un retard, l'envoi immédiat + * est cohérent. + * + * Idempotent par invoice.id : si des tasks `scheduled` existent déjà + * pour cette facture, on les annule avant de re-programmer (cas où on + * change de plan). + */ +export async function scheduleRelancesForInvoice( + invoice: Invoice, + trx?: TransactionClientContract +): Promise { + if (!invoice.planId) return [] + + const plan = await Plan.query(trx ? { client: trx } : undefined) + .where('id', invoice.planId) + .preload('steps', (q) => q.orderBy('order', 'asc')) + .first() + if (!plan) return [] + + // Cancel les tasks scheduled existantes (re-scheduling après changement + // de plan ou de dueDate). + const existing = await RelanceTask.query(trx ? { client: trx } : undefined) + .where('invoice_id', invoice.id) + .where('status', 'scheduled') + const queue = getQueue(RELANCE_QUEUE) + for (const t of existing) { + if (t.queueJobId) { + await queue.remove(t.queueJobId).catch(() => { + // Ignore — le job peut déjà être consommé. + }) + } + t.useTransaction(trx ?? null as never) + t.status = 'cancelled' + await t.save() + } + + const now = DateTime.now() + const created: RelanceTask[] = [] + + for (const step of plan.steps) { + const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays }) + const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw + + const task = await RelanceTask.create( + { + organizationId: invoice.organizationId, + invoiceId: invoice.id, + planStepId: step.id, + sendAt, + status: 'scheduled', + sentAt: null, + queueJobId: null, + }, + trx ? { client: trx } : undefined + ) + + const delay = Math.max(0, sendAt.toMillis() - now.toMillis()) + const job = await queue.add( + 'send-relance', + { taskId: task.id }, + { + delay, + // Idempotency : un seul job actif par task. + jobId: `relance:${task.id}`, + // Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec + // backoff (cf. backend.md §13.2). + attempts: 5, + backoff: { type: 'exponential', delay: 30_000 }, + } + ) + + task.queueJobId = job.id ?? null + await task.save() + created.push(task) + } + + return created +} + +/** + * Annule toutes les relances futures d'une facture (appelé quand on + * mark-paid ou cancel une invoice). Les tasks déjà `sent` restent + * intactes — c'est de l'historique. + */ +export async function cancelFutureRelances( + invoiceId: string, + trx?: TransactionClientContract +): Promise { + const tasks = await RelanceTask.query(trx ? { client: trx } : undefined) + .where('invoice_id', invoiceId) + .where('status', 'scheduled') + if (tasks.length === 0) return + + const queue = getQueue(RELANCE_QUEUE) + for (const t of tasks) { + if (t.queueJobId) { + await queue.remove(t.queueJobId).catch(() => {}) + } + t.useTransaction(trx ?? null as never) + t.status = 'cancelled' + await t.save() + } +} diff --git a/apps/api/app/services/template.ts b/apps/api/app/services/template.ts new file mode 100644 index 0000000..3f42a4d --- /dev/null +++ b/apps/api/app/services/template.ts @@ -0,0 +1,37 @@ +/** + * Mini interpolateur Mustache-like utilisé pour les sujets/corps des + * emails de relance. Supporte les chemins pointés (`{{client.name}}`). + * + * Volontairement simple : pas d'expressions, pas de conditions, pas de + * boucles. Si un chemin manque, retourne "" (silencieux — l'utilisateur + * verra un blanc, pas une exception). + */ +export function renderTemplate(template: string, vars: Record): string { + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => { + const parts = path.split('.') + let val: unknown = vars + for (const p of parts) { + if (val == null || typeof val !== 'object') return '' + val = (val as Record)[p] + } + return val == null ? '' : String(val) + }) +} + +/** + * Helper d'affichage montant : 12400 → "124,00 €". + */ +export function formatAmountFr(cents: number): string { + return new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency: 'EUR', + }).format(cents / 100) +} + +/** + * Helper d'affichage date : ISO/Date → "15/04/2026". + */ +export function formatDateFr(d: Date | string): string { + const date = typeof d === 'string' ? new Date(d) : d + return new Intl.DateTimeFormat('fr-FR').format(date) +} diff --git a/apps/api/database/migrations/1778080001000_create_relance_tasks_table.ts b/apps/api/database/migrations/1778080001000_create_relance_tasks_table.ts new file mode 100644 index 0000000..a8eaa78 --- /dev/null +++ b/apps/api/database/migrations/1778080001000_create_relance_tasks_table.ts @@ -0,0 +1,60 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'relance_tasks' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()')) + // Pas de FK org : passe par invoice → org. Mais on garde un cache + // pour les requêtes scope-by-org sans join (dashboard, jobs). + table + .uuid('organization_id') + .notNullable() + .references('id') + .inTable('organizations') + .onDelete('CASCADE') + table + .uuid('invoice_id') + .notNullable() + .references('id') + .inTable('invoices') + .onDelete('CASCADE') + table + .uuid('plan_step_id') + .notNullable() + .references('id') + .inTable('plan_steps') + // RESTRICT pour éviter qu'une suppression d'étape (cas rare V1 + // puisque l'édition de plan recrée tout) casse les tasks programmées. + // En pratique, l'édition de plan en cours fait un DELETE+INSERT + // des steps — les tasks pointant sur les vieux steps perdent + // leur référence et il faut les annuler. Géré côté édition plan. + .onDelete('RESTRICT') + + table.timestamp('send_at').notNullable() + table + .enum('status', ['scheduled', 'sent', 'cancelled', 'failed'], { + useNative: true, + enumName: 'relance_task_status', + }) + .notNullable() + .defaultTo('scheduled') + table.timestamp('sent_at').nullable() + // ID BullMQ pour pouvoir cancel le job programmé. + table.string('queue_job_id', 100).nullable() + + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').nullable() + + table.index(['organization_id', 'status']) + table.index(['invoice_id']) + table.index(['send_at']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + this.schema.raw('DROP TYPE IF EXISTS relance_task_status') + } +} diff --git a/apps/api/database/migrations/1778080001100_create_checkin_tasks_table.ts b/apps/api/database/migrations/1778080001100_create_checkin_tasks_table.ts new file mode 100644 index 0000000..94fac81 --- /dev/null +++ b/apps/api/database/migrations/1778080001100_create_checkin_tasks_table.ts @@ -0,0 +1,49 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'checkin_tasks' + + 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 + .uuid('invoice_id') + .notNullable() + .references('id') + .inTable('invoices') + .onDelete('CASCADE') + + table.timestamp('send_at').notNullable() + // Token signé HMAC, TTL 24h après émission. Stocké hashé. + table.string('token_hash', 64).notNullable().unique() + table + .enum('status', ['scheduled', 'sent', 'answered', 'expired'], { + useNative: true, + enumName: 'checkin_task_status', + }) + .notNullable() + .defaultTo('scheduled') + table.timestamp('sent_at').nullable() + table.timestamp('answered_at').nullable() + // Réponse de l'utilisateur via le lien email : 'paid' ou 'still_pending' + table.string('answer', 20).nullable() + + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').nullable() + + table.index(['organization_id', 'status']) + table.index(['invoice_id']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + this.schema.raw('DROP TYPE IF EXISTS checkin_task_status') + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 89896c8..897a2db 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -53,6 +53,33 @@ export class AuthAccessTokenSchema extends BaseModel { declare updatedAt: DateTime | null } +export class CheckinTaskSchema extends BaseModel { + static $columns = ['answer', 'answeredAt', 'createdAt', 'id', 'invoiceId', 'organizationId', 'sendAt', 'sentAt', 'status', 'tokenHash', 'updatedAt'] as const + $columns = CheckinTaskSchema.$columns + @column() + declare answer: 'paid' | 'still_pending' | null | null + @column.dateTime() + declare answeredAt: DateTime | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column({ isPrimary: true }) + declare id: string + @column() + declare invoiceId: string + @column() + declare organizationId: string + @column.dateTime() + declare sendAt: DateTime + @column.dateTime() + declare sentAt: DateTime | null + @column() + declare status: 'scheduled' | 'sent' | 'answered' | 'expired' + @column() + declare tokenHash: string + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} + export class ClientSchema extends BaseModel { static $columns = ['address', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const $columns = ClientSchema.$columns @@ -245,6 +272,31 @@ export class RefreshTokenSchema extends BaseModel { declare userId: string } +export class RelanceTaskSchema extends BaseModel { + static $columns = ['createdAt', 'id', 'invoiceId', 'organizationId', 'planStepId', 'queueJobId', 'sendAt', 'sentAt', 'status', 'updatedAt'] as const + $columns = RelanceTaskSchema.$columns + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column({ isPrimary: true }) + declare id: string + @column() + declare invoiceId: string + @column() + declare organizationId: string + @column() + declare planStepId: string + @column() + declare queueJobId: string | null + @column.dateTime() + declare sendAt: DateTime + @column.dateTime() + declare sentAt: DateTime | null + @column() + declare status: 'scheduled' | 'sent' | 'cancelled' | 'failed' + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} + export class UserSchema extends BaseModel { static $columns = ['createdAt', 'email', 'fullName', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const $columns = UserSchema.$columns diff --git a/apps/api/database/schema_rules.ts b/apps/api/database/schema_rules.ts index 4355a2b..a94c0ad 100644 --- a/apps/api/database/schema_rules.ts +++ b/apps/api/database/schema_rules.ts @@ -33,6 +33,23 @@ export default { }, }, }, + relance_tasks: { + columns: { + status: { + tsType: "'scheduled' | 'sent' | 'cancelled' | 'failed'", + }, + }, + }, + checkin_tasks: { + columns: { + status: { + tsType: "'scheduled' | 'sent' | 'answered' | 'expired'", + }, + answer: { + tsType: "'paid' | 'still_pending' | null", + }, + }, + }, import_drafts: { columns: { status: { diff --git a/apps/api/package.json b/apps/api/package.json index 341d561..6ab89a7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,6 +23,7 @@ "#models/*": "./app/models/*.js", "#mails/*": "./app/mails/*.js", "#services/*": "./app/services/*.js", + "#jobs/*": "./app/jobs/*.js", "#listeners/*": "./app/listeners/*.js", "#events/*": "./app/events/*.js", "#generated/*": "./.adonisjs/server/*.js", diff --git a/apps/api/start/queue.ts b/apps/api/start/queue.ts new file mode 100644 index 0000000..6274f3d --- /dev/null +++ b/apps/api/start/queue.ts @@ -0,0 +1,30 @@ +/* +|-------------------------------------------------------------------------- +| Queue workers +|-------------------------------------------------------------------------- +| +| Boot des workers BullMQ. V1 : on les démarre dans le même process que +| l'API HTTP (simple, suffisant tant que le volume reste petit). En prod +| K3s on les extraira dans un Deployment séparé (cf. backend.md §13.4). +| +*/ + +import app from '@adonisjs/core/services/app' +import logger from '@adonisjs/core/services/logger' +import { registerWorker, shutdownQueue } from '#services/queue' +import { sendRelanceJob } from '#jobs/send_relance_job' + +// On enregistre les workers seulement quand l'app sert HTTP — pas en +// mode test (pour ne pas connecter Redis pendant les tests Japa) ni en +// REPL (pour ne pas déclencher d'exécution latérale). +if (app.getEnvironment() === 'web') { + logger.info('booting BullMQ workers (relances)') + registerWorker<{ taskId: string }>('relances', async (job) => { + await sendRelanceJob(job.data) + }) + + app.terminating(async () => { + logger.info('shutting down BullMQ workers') + await shutdownQueue() + }) +} diff --git a/bruno/05-Invoices/04 Create.bru b/bruno/05-Invoices/04 Create.bru index 7d8fd87..7e444aa 100644 --- a/bruno/05-Invoices/04 Create.bru +++ b/bruno/05-Invoices/04 Create.bru @@ -50,6 +50,15 @@ docs { `client_email_required` Bonus +1 rubis à la création (gamification). - + + Si `planId` est fourni : programme automatiquement les RelanceTasks + BullMQ pour chaque step du plan (sendAt = dueDate + offsetDays). + Les jobs scheduled sont visibles via : + `docker exec rubis-redis redis-cli zrange bull:relances:delayed 0 -1` + + Pour tester l'envoi immédiat : passer une `dueDate` dans le passé → + la première RelanceTask est programmée à `now + 1min`. Mailpit + http://localhost:8025 affichera le mail capté ~1min plus tard. + Capture `invoiceId` dans l'env pour les requêtes suivantes. }