From f1a9549b018edf8f1c8be735149fedb444b819d9 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 15:39:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(api):=2023505=20PG=20=E2=86=92=20422=20prop?= =?UTF-8?q?re=20+=20schedulers=20Redis=20non-bloquants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExceptionHandler : convertit les violations de contrainte unique PG (23505) en réponse `{ errors: [{ code: 'duplicate', field, message }] }` 422 au lieu d'un 500 avec stack pg-protocol. Extrait le nom de colonne via regex sur le `detail` PG. - InvoicesController.store + ImportBatchesController.validateDraft : wrap les appels schedulers (Redis side-effect, hors tx) dans try/catch + logger.warn. Si Redis flanche, l'invoice est créée et la requête HTTP retourne 201 normalement — l'utilisateur peut re-déclencher la programmation plus tard. Évite qu'une panne Redis casse le path de saisie. --- .../controllers/import_batches_controller.ts | 11 +++-- .../app/controllers/invoices_controller.ts | 17 +++++--- apps/api/app/exceptions/handler.ts | 42 ++++++++++++------- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/apps/api/app/controllers/import_batches_controller.ts b/apps/api/app/controllers/import_batches_controller.ts index 43abcaf..1fdbee6 100644 --- a/apps/api/app/controllers/import_batches_controller.ts +++ b/apps/api/app/controllers/import_batches_controller.ts @@ -18,6 +18,7 @@ import { import { recordActivity } from '#services/activity_recorder' import { scheduleRelancesForInvoice } from '#services/relance_scheduler' import { scheduleCheckinForInvoice } from '#services/checkin_scheduler' +import logger from '@adonisjs/core/services/logger' import drive from '@adonisjs/drive/services/main' import { createReadStream } from 'node:fs' import { randomUUID } from 'node:crypto' @@ -223,10 +224,14 @@ export default class ImportBatchesController { await invoice.load('client') await invoice.load('plan') - if (invoice.planId) { - await scheduleRelancesForInvoice(invoice) + try { + if (invoice.planId) { + await scheduleRelancesForInvoice(invoice) + } + await scheduleCheckinForInvoice(invoice) + } catch (err) { + logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule relances/checkin') } - await scheduleCheckinForInvoice(invoice) return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() }) } diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts index 84326a8..2b532de 100644 --- a/apps/api/app/controllers/invoices_controller.ts +++ b/apps/api/app/controllers/invoices_controller.ts @@ -19,6 +19,7 @@ import { scheduleCheckinForInvoice, cancelCheckinForInvoice, } from '#services/checkin_scheduler' +import logger from '@adonisjs/core/services/logger' const PAGE_SIZE = 50 @@ -305,12 +306,18 @@ export default class InvoicesController { await invoice.load('plan') // Programme les relances BullMQ si la facture a un plan + le check-in - // (envoyé pile à dueDate). Hors tx : les jobs sont posés dans Redis, - // on n'a pas besoin de cohérence DB. - if (invoice.planId) { - await scheduleRelancesForInvoice(invoice) + // (envoyé pile à dueDate). Hors tx — Redis ne participe pas aux + // garanties DB. On ne fait pas planter la requête HTTP si Redis est + // down : la facture est créée, l'utilisateur peut re-déclencher la + // programmation plus tard. + try { + if (invoice.planId) { + await scheduleRelancesForInvoice(invoice) + } + await scheduleCheckinForInvoice(invoice) + } catch (err) { + logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule relances/checkin') } - await scheduleCheckinForInvoice(invoice) return response.status(201).json({ data: serializeInvoice(invoice) }) } diff --git a/apps/api/app/exceptions/handler.ts b/apps/api/app/exceptions/handler.ts index 30ddc51..2ee1081 100644 --- a/apps/api/app/exceptions/handler.ts +++ b/apps/api/app/exceptions/handler.ts @@ -1,27 +1,41 @@ import app from '@adonisjs/core/services/app' import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http' +/** + * Exception handler API JSON-only. On normalise les erreurs DB / Vine + * en réponse `{ errors: [...] }` (cf. backend.md §6 pour le contrat). + */ export default class HttpExceptionHandler extends ExceptionHandler { - /** - * In debug mode, the exception handler will display verbose errors - * with pretty printed stack traces. - */ protected debug = !app.inProduction - /** - * The method is used for handling errors and returning - * response to the client - */ async handle(error: unknown, ctx: HttpContext) { + // Postgres unique violation → 422 avec field/code stables, pas un + // 500 avec stack pg-protocol. + if ( + error && + typeof error === 'object' && + 'code' in error && + (error as { code: unknown }).code === '23505' + ) { + const detail = String((error as { detail?: unknown }).detail ?? '') + // Tente d'extraire le nom de colonne du message PG ("Key (numero, ...)"). + const fieldMatch = detail.match(/Key \(([^)]+)\)=/) + const field = fieldMatch?.[1]?.split(',')[0]?.trim() + ctx.response.status(422) + return ctx.response.json({ + errors: [ + { + code: 'duplicate', + message: 'Cette valeur existe déjà.', + field: field ?? undefined, + }, + ], + }) + } + return super.handle(error, ctx) } - /** - * The method is used to report error to the logging service or - * the a third party error monitoring service. - * - * @note You should not attempt to send a response from this method. - */ async report(error: unknown, ctx: HttpContext) { return super.report(error, ctx) }