feat(api): RelanceTask + CheckinTask + worker BullMQ qui envoie les relances
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:<taskId>` 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).
This commit is contained in:
parent
19dd71bd93
commit
a6b35dfe7a
@ -73,6 +73,7 @@ export default defineConfig({
|
|||||||
() => import('#start/routes'),
|
() => import('#start/routes'),
|
||||||
() => import('#start/kernel'),
|
() => import('#start/kernel'),
|
||||||
() => import('#start/validator'),
|
() => import('#start/validator'),
|
||||||
|
() => import('#start/queue'),
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
type ImportSource,
|
type ImportSource,
|
||||||
} from '#services/import_batch'
|
} from '#services/import_batch'
|
||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
|
import { scheduleRelancesForInvoice } from '#services/relance_scheduler'
|
||||||
import drive from '@adonisjs/drive/services/main'
|
import drive from '@adonisjs/drive/services/main'
|
||||||
import { createReadStream } from 'node:fs'
|
import { createReadStream } from 'node:fs'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
@ -221,6 +222,10 @@ export default class ImportBatchesController {
|
|||||||
await invoice.load('client')
|
await invoice.load('client')
|
||||||
await invoice.load('plan')
|
await invoice.load('plan')
|
||||||
|
|
||||||
|
if (invoice.planId) {
|
||||||
|
await scheduleRelancesForInvoice(invoice)
|
||||||
|
}
|
||||||
|
|
||||||
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
|
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import db from '@adonisjs/lucid/services/db'
|
|||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { resolveClient } from '#services/resolve_client'
|
import { resolveClient } from '#services/resolve_client'
|
||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
|
import {
|
||||||
|
scheduleRelancesForInvoice,
|
||||||
|
cancelFutureRelances,
|
||||||
|
} from '#services/relance_scheduler'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
@ -296,13 +300,20 @@ export default class InvoicesController {
|
|||||||
await invoice.load('client')
|
await invoice.load('client')
|
||||||
await invoice.load('plan')
|
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) })
|
return response.status(201).json({ data: serializeInvoice(invoice) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /invoices/:id/mark-paid
|
* POST /invoices/:id/mark-paid
|
||||||
* Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned
|
* 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) {
|
async markPaid({ auth, params, response }: HttpContext) {
|
||||||
const organizationId = requireOrgId(auth)
|
const organizationId = requireOrgId(auth)
|
||||||
@ -343,6 +354,11 @@ export default class InvoicesController {
|
|||||||
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
|
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
|
||||||
trx,
|
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) })
|
return response.json({ data: serializeInvoice(invoice) })
|
||||||
|
|||||||
115
apps/api/app/jobs/send_relance_job.ts
Normal file
115
apps/api/app/jobs/send_relance_job.ts
Normal file
@ -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 — <b>${invoice.client.name}</b> (${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 à <b>${invoice.client.name}</b>`,
|
||||||
|
meta: {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
clientId: invoice.clientId,
|
||||||
|
planStepOrder: step.order,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
9
apps/api/app/models/checkin_task.ts
Normal file
9
apps/api/app/models/checkin_task.ts
Normal file
@ -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<typeof Invoice>
|
||||||
|
}
|
||||||
13
apps/api/app/models/relance_task.ts
Normal file
13
apps/api/app/models/relance_task.ts
Normal file
@ -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<typeof Invoice>
|
||||||
|
|
||||||
|
@belongsTo(() => PlanStep, { foreignKey: 'planStepId' })
|
||||||
|
declare planStep: BelongsTo<typeof PlanStep>
|
||||||
|
}
|
||||||
47
apps/api/app/services/mail_dispatcher.ts
Normal file
47
apps/api/app/services/mail_dispatcher.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
60
apps/api/app/services/queue.ts
Normal file
60
apps/api/app/services/queue.ts
Normal file
@ -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<QueueName, Queue>()
|
||||||
|
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<T = unknown> = Processor<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T = unknown>(name: QueueName, handler: JobHandler<T>): Worker {
|
||||||
|
const worker = new Worker<T>(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<void> {
|
||||||
|
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
|
||||||
121
apps/api/app/services/relance_scheduler.ts
Normal file
121
apps/api/app/services/relance_scheduler.ts
Normal file
@ -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<RelanceTask[]> {
|
||||||
|
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<void> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/api/app/services/template.ts
Normal file
37
apps/api/app/services/template.ts
Normal file
@ -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, unknown>): 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<string, unknown>)[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)
|
||||||
|
}
|
||||||
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,6 +53,33 @@ export class AuthAccessTokenSchema extends BaseModel {
|
|||||||
declare updatedAt: DateTime | null
|
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 {
|
export class ClientSchema extends BaseModel {
|
||||||
static $columns = ['address', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const
|
static $columns = ['address', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const
|
||||||
$columns = ClientSchema.$columns
|
$columns = ClientSchema.$columns
|
||||||
@ -245,6 +272,31 @@ export class RefreshTokenSchema extends BaseModel {
|
|||||||
declare userId: string
|
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 {
|
export class UserSchema extends BaseModel {
|
||||||
static $columns = ['createdAt', 'email', 'fullName', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
|
static $columns = ['createdAt', 'email', 'fullName', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
|
||||||
$columns = UserSchema.$columns
|
$columns = UserSchema.$columns
|
||||||
|
|||||||
@ -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: {
|
import_drafts: {
|
||||||
columns: {
|
columns: {
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"#models/*": "./app/models/*.js",
|
"#models/*": "./app/models/*.js",
|
||||||
"#mails/*": "./app/mails/*.js",
|
"#mails/*": "./app/mails/*.js",
|
||||||
"#services/*": "./app/services/*.js",
|
"#services/*": "./app/services/*.js",
|
||||||
|
"#jobs/*": "./app/jobs/*.js",
|
||||||
"#listeners/*": "./app/listeners/*.js",
|
"#listeners/*": "./app/listeners/*.js",
|
||||||
"#events/*": "./app/events/*.js",
|
"#events/*": "./app/events/*.js",
|
||||||
"#generated/*": "./.adonisjs/server/*.js",
|
"#generated/*": "./.adonisjs/server/*.js",
|
||||||
|
|||||||
30
apps/api/start/queue.ts
Normal file
30
apps/api/start/queue.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -50,6 +50,15 @@ docs {
|
|||||||
`client_email_required`
|
`client_email_required`
|
||||||
|
|
||||||
Bonus +1 rubis à la création (gamification).
|
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.
|
Capture `invoiceId` dans l'env pour les requêtes suivantes.
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user