diff --git a/apps/api/.gitignore b/apps/api/.gitignore index dd7579a..e73859c 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -24,3 +24,7 @@ yarn-error.log # Platform specific .DS_Store + +# Storage (uploads locaux, logs dev) +storage/uploads +storage/logs diff --git a/apps/api/app/jobs/send_relance_job.ts b/apps/api/app/jobs/send_relance_job.ts index 3a416ef..3e59959 100644 --- a/apps/api/app/jobs/send_relance_job.ts +++ b/apps/api/app/jobs/send_relance_job.ts @@ -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) => { diff --git a/apps/api/app/services/mail_dispatcher.ts b/apps/api/app/services/mail_dispatcher.ts index 9a5e2a0..898d2c1 100644 --- a/apps/api/app/services/mail_dispatcher.ts +++ b/apps/api/app/services/mail_dispatcher.ts @@ -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 = { diff --git a/apps/api/app/services/relance_scheduler.ts b/apps/api/app/services/relance_scheduler.ts index 4d9cec4..ff0921b 100644 --- a/apps/api/app/services/relance_scheduler.ts +++ b/apps/api/app/services/relance_scheduler.ts @@ -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 diff --git a/apps/api/config/logger.ts b/apps/api/config/logger.ts index ba8c96a..f2c76c6 100644 --- a/apps/api/config/logger.ts +++ b/apps/api/config/logger.ts @@ -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, }, }, })