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:
ordinarthur 2026-05-06 15:24:46 +02:00
parent 19dd71bd93
commit a6b35dfe7a
17 changed files with 644 additions and 2 deletions

View File

@ -73,6 +73,7 @@ export default defineConfig({
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/validator'),
() => import('#start/queue'),
],
/*

View File

@ -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() })
}

View File

@ -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) })

View 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,
})
})
}

View 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>
}

View 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>
}

View 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)
})
}

View 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

View 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 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()
}
}

View 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)
}

View File

@ -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')
}
}

View File

@ -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')
}
}

View File

@ -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

View File

@ -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: {

View File

@ -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
View 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()
})
}

View File

@ -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.
}