feat(api): logs fichier en dev + traces du flow relance/mail
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy API / build-and-deploy (push) Successful in 1m7s

config/logger.ts: en dev, on duplique les logs vers
apps/api/storage/logs/app.log via un `multistream` pino (pretty stdout
+ JSON file en parallèle). Plus fiable que `transport.targets` qui
tourne dans un worker thread et fail silencieusement quand le path
n'est pas accessible.

Logs ciblés sur le pipeline relance pour debug rapide :
  - relance_scheduler : tâche créée + delaySec + queueJobId
  - send_relance_job  : pick-up / skip / envoi / OK / KO
  - mail_dispatcher   : driver actif (smtp/resend) + send OK / err

.gitignore : storage/uploads + storage/logs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 12:37:20 +02:00
parent 92a9fac62b
commit 68ed8f2ec6
5 changed files with 179 additions and 25 deletions

4
apps/api/.gitignore vendored
View File

@ -24,3 +24,7 @@ yarn-error.log
# Platform specific
.DS_Store
# Storage (uploads locaux, logs dev)
storage/uploads
storage/logs

View File

@ -20,16 +20,21 @@ import logger from '@adonisjs/core/services/logger'
* - Sinon : envoi de l'email + bump rubis (1 rubis = 10 min libérées).
*/
export async function sendRelanceJob(jobData: { taskId: string }) {
logger.info({ taskId: jobData.taskId }, 'sendRelanceJob: pick-up')
const task = await RelanceTask.query()
.where('id', jobData.taskId)
.preload('planStep')
.first()
if (!task) {
logger.warn({ taskId: jobData.taskId }, 'relance task not found, skipping')
logger.warn({ taskId: jobData.taskId }, 'sendRelanceJob: task not found, skipping')
return
}
if (task.status !== 'scheduled') {
logger.info({ taskId: task.id, status: task.status }, 'relance task not scheduled, skipping')
logger.info(
{ taskId: task.id, status: task.status },
'sendRelanceJob: task not scheduled (already sent / cancelled), skipping'
)
return
}
@ -39,6 +44,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
.preload('organization')
.first()
if (!invoice) {
logger.warn(
{ taskId: task.id, invoiceId: task.invoiceId },
'sendRelanceJob: invoice not found, cancelling task'
)
task.status = 'cancelled'
await task.save()
return
@ -47,6 +56,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
// 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') {
logger.info(
{ taskId: task.id, invoiceId: invoice.id, status: invoice.status },
'sendRelanceJob: invoice paid/cancelled between schedule and execution, cancelling task'
)
task.status = 'cancelled'
await task.save()
return
@ -79,6 +92,17 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
}
// Envoi normal
logger.info(
{
taskId: task.id,
invoiceId: invoice.id,
numero: invoice.numero,
to: invoice.client.email,
stepOrder: step.order,
offsetDays: step.offsetDays,
},
'sendRelanceJob: envoi mail relance'
)
await sendRelanceEmail({
invoice,
client: invoice.client,
@ -86,6 +110,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
user,
organization: invoice.organization,
})
logger.info(
{ taskId: task.id, invoiceId: invoice.id, numero: invoice.numero },
'sendRelanceJob: mail relance envoyé OK'
)
const sentAt = await clock.now(invoice.organizationId)
await db.transaction(async (trx) => {

View File

@ -1,4 +1,5 @@
import mail from '@adonisjs/mail/services/main'
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import { DateTime } from 'luxon'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
@ -112,23 +113,53 @@ export async function sendRelanceEmail({
body,
meta: { invoiceId: invoice.id, clientId: client.id, stepOrder: step.order },
})
if (captured) return // demo : ne pas envoyer pour de vrai
if (captured) {
logger.info(
{ invoiceId: invoice.id, numero: invoice.numero, to: client.email },
'sendRelanceEmail: capturé en mode démo (pas d\'envoi réel)'
)
return // demo : ne pas envoyer pour de vrai
}
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(fromAddress, fromName)
.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)
// Reply-To pointe sur l'utilisateur Rubis : si le client final répond
// à la relance, sa réponse arrive chez le patron de la TPE, pas dans
// notre boîte transactionnelle.
if (user?.email) {
m.replyTo(user.email, user.fullName ?? user.email)
}
})
const driver = env.get('MAIL_DRIVER', 'smtp')
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
to: client.email,
from: fromAddress,
driver,
subjectPreview: subject.slice(0, 80),
},
'sendRelanceEmail: envoi via driver'
)
try {
const mailer = mail.use(driver)
await mailer.send((m) => {
m.from(fromAddress, fromName)
.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)
// Reply-To pointe sur l'utilisateur Rubis : si le client final répond
// à la relance, sa réponse arrive chez le patron de la TPE, pas dans
// notre boîte transactionnelle.
if (user?.email) {
m.replyTo(user.email, user.fullName ?? user.email)
}
})
logger.info(
{ invoiceId: invoice.id, numero: invoice.numero, driver },
'sendRelanceEmail: send OK'
)
} catch (err) {
logger.error(
{ err, invoiceId: invoice.id, numero: invoice.numero, driver },
'sendRelanceEmail: échec envoi'
)
throw err
}
}
type CheckinPayload = {

View File

@ -4,6 +4,7 @@ import type Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import * as clock from '#services/clock'
import app from '@adonisjs/core/services/app'
import logger from '@adonisjs/core/services/logger'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
const RELANCE_QUEUE = 'relances'
@ -50,6 +51,14 @@ export async function scheduleRelancesForInvoice(
.where('invoice_id', invoice.id)
.whereIn('status', ['scheduled', 'sent'])
if (alreadyActive.length > 0) {
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
existingTasks: alreadyActive.length,
},
'scheduleRelancesForInvoice: tasks déjà actives, no-op'
)
return alreadyActive
}
@ -126,6 +135,39 @@ export async function scheduleRelancesForInvoice(
task.queueJobId = job?.id ?? null
await task.save()
created.push(task)
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
stepOrder: step.order,
offsetDays: step.offsetDays,
sendAt: sendAt.toISO(),
delayMs: delay,
delaySec: Math.round(delay / 1000),
taskId: task.id,
queueJobId: task.queueJobId,
enqueued: queue !== null,
},
'scheduleRelancesForInvoice: tâche créée + job BullMQ enqueué'
)
}
if (created.length === 0) {
logger.warn(
{ invoiceId: invoice.id, numero: invoice.numero, planId: plan.id, stepCount: steps.length },
'scheduleRelancesForInvoice: aucune tâche créée (plan vide ?)'
)
} else {
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
taskCount: created.length,
enqueueEnabled: queue !== null,
},
'scheduleRelancesForInvoice: terminé'
)
}
return created

View File

@ -1,6 +1,52 @@
import { mkdirSync } from 'node:fs'
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
import {
defineConfig,
syncDestination,
targets,
multistream,
destination,
} from '@adonisjs/core/logger'
/**
* En dev on duplique les logs vers `apps/api/storage/logs/app.log` pour
* pouvoir grep / tail / partager facilement (notamment quand un job BullMQ
* tourne en arrière-plan et qu'on veut voir si l'envoi mail s'est bien fait
* sans devoir scroller le terminal).
*
* En prod on garde uniquement stdout (les logs sont collectés par K8s via
* `kubectl logs`).
*
* Implémentation : on utilise `multistream` (pino) qui écrit en parallèle
* - vers la sortie sync pretty (TTY) la sortie habituelle dev
* - vers le fichier app.log en JSON (parseable, grep-able)
*
* Plus fiable que `transport.targets` qui tourne dans un worker thread et
* peut échouer silencieusement quand le path n'est pas accessible.
*/
const logFilePath = app.makePath('storage/logs/app.log')
if (!app.inProduction) {
mkdirSync(app.makePath('storage/logs'), { recursive: true })
}
const prettyStream = !app.inProduction ? await syncDestination() : null
const devDestination =
!app.inProduction && prettyStream
? multistream([
// Sortie console pretty (sync) — c'est la sortie habituelle dev.
{ stream: prettyStream },
// Fichier JSON ligne — destination pino classique avec append+mkdir.
{
stream: destination({
dest: logFilePath,
sync: true,
append: true,
mkdir: true,
}),
},
])
: undefined
const loggerConfig = defineConfig({
/**
@ -26,16 +72,19 @@ const loggerConfig = defineConfig({
level: env.get('LOG_LEVEL'),
/**
* Use sync destination in non-production for immediate flush.
* Dev : multistream (pretty stdout + JSON file).
* Prod : pas de destination AdonisJS bascule sur transport.
*/
destination: !app.inProduction ? await syncDestination() : undefined,
destination: devDestination,
/**
* Configure where logs are written.
* En prod : un seul transport vers stdout (collecté par K8s).
* En dev : pas de transport le multistream ci-dessus s'occupe de
* tout, et mélanger les deux fait que pino ignore le destination.
*/
transport: {
targets: [targets.file({ destination: 1 })],
},
transport: app.inProduction
? { targets: [targets.file({ destination: 1 })] }
: undefined,
},
},
})