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/kernel'),
|
||||
() => import('#start/validator'),
|
||||
() => import('#start/queue'),
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@ -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() })
|
||||
}
|
||||
|
||||
|
||||
@ -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) })
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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",
|
||||
|
||||
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`
|
||||
|
||||
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.
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user