fix(api): 23505 PG → 422 propre + schedulers Redis non-bloquants
- 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.
This commit is contained in:
parent
299f7beb63
commit
f1a9549b01
@ -18,6 +18,7 @@ import {
|
|||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
import { scheduleRelancesForInvoice } from '#services/relance_scheduler'
|
import { scheduleRelancesForInvoice } from '#services/relance_scheduler'
|
||||||
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
|
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
|
||||||
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import drive from '@adonisjs/drive/services/main'
|
import drive from '@adonisjs/drive/services/main'
|
||||||
import { createReadStream } from 'node:fs'
|
import { createReadStream } from 'node:fs'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
@ -223,10 +224,14 @@ export default class ImportBatchesController {
|
|||||||
await invoice.load('client')
|
await invoice.load('client')
|
||||||
await invoice.load('plan')
|
await invoice.load('plan')
|
||||||
|
|
||||||
|
try {
|
||||||
if (invoice.planId) {
|
if (invoice.planId) {
|
||||||
await scheduleRelancesForInvoice(invoice)
|
await scheduleRelancesForInvoice(invoice)
|
||||||
}
|
}
|
||||||
await scheduleCheckinForInvoice(invoice)
|
await scheduleCheckinForInvoice(invoice)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule relances/checkin')
|
||||||
|
}
|
||||||
|
|
||||||
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
|
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
scheduleCheckinForInvoice,
|
scheduleCheckinForInvoice,
|
||||||
cancelCheckinForInvoice,
|
cancelCheckinForInvoice,
|
||||||
} from '#services/checkin_scheduler'
|
} from '#services/checkin_scheduler'
|
||||||
|
import logger from '@adonisjs/core/services/logger'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
@ -305,12 +306,18 @@ export default class InvoicesController {
|
|||||||
await invoice.load('plan')
|
await invoice.load('plan')
|
||||||
|
|
||||||
// Programme les relances BullMQ si la facture a un plan + le check-in
|
// 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,
|
// (envoyé pile à dueDate). Hors tx — Redis ne participe pas aux
|
||||||
// on n'a pas besoin de cohérence DB.
|
// 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) {
|
if (invoice.planId) {
|
||||||
await scheduleRelancesForInvoice(invoice)
|
await scheduleRelancesForInvoice(invoice)
|
||||||
}
|
}
|
||||||
await scheduleCheckinForInvoice(invoice)
|
await scheduleCheckinForInvoice(invoice)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule relances/checkin')
|
||||||
|
}
|
||||||
|
|
||||||
return response.status(201).json({ data: serializeInvoice(invoice) })
|
return response.status(201).json({ data: serializeInvoice(invoice) })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,41 @@
|
|||||||
import app from '@adonisjs/core/services/app'
|
import app from '@adonisjs/core/services/app'
|
||||||
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
|
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
|
||||||
|
|
||||||
export default class HttpExceptionHandler extends ExceptionHandler {
|
/**
|
||||||
/**
|
* Exception handler API JSON-only. On normalise les erreurs DB / Vine
|
||||||
* In debug mode, the exception handler will display verbose errors
|
* en réponse `{ errors: [...] }` (cf. backend.md §6 pour le contrat).
|
||||||
* with pretty printed stack traces.
|
|
||||||
*/
|
*/
|
||||||
|
export default class HttpExceptionHandler extends ExceptionHandler {
|
||||||
protected debug = !app.inProduction
|
protected debug = !app.inProduction
|
||||||
|
|
||||||
/**
|
|
||||||
* The method is used for handling errors and returning
|
|
||||||
* response to the client
|
|
||||||
*/
|
|
||||||
async handle(error: unknown, ctx: HttpContext) {
|
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)
|
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) {
|
async report(error: unknown, ctx: HttpContext) {
|
||||||
return super.report(error, ctx)
|
return super.report(error, ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user