From c5f0c9eed6bb6c06f76a029c685c2c24a8867f46 Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 2 Oct 2026 17:05:14 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20r=C3=A9tablir=20la=20suppression=20de=20?= =?UTF-8?q?facture=20en=20recette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drizzle/0042_deleted_invoices_blacklist.sql | 11 ++++++++ drizzle/meta/_journal.json | 9 ++++++- server/db.ts | 26 +++++++++++++----- server/invoiceDeletion.test.ts | 30 +++++++++++++++++++++ server/invoiceDeletionError.ts | 10 +++++++ server/routers.ts | 14 +++++++++- 6 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 drizzle/0042_deleted_invoices_blacklist.sql create mode 100644 server/invoiceDeletion.test.ts create mode 100644 server/invoiceDeletionError.ts diff --git a/drizzle/0042_deleted_invoices_blacklist.sql b/drizzle/0042_deleted_invoices_blacklist.sql new file mode 100644 index 0000000..c99b1c9 --- /dev/null +++ b/drizzle/0042_deleted_invoices_blacklist.sql @@ -0,0 +1,11 @@ +-- Rattrapage additif : certains environnements historiques possèdent le code +-- de blacklist sans avoir exécuté la migration 0035 correspondante. +CREATE TABLE IF NOT EXISTS `deletedInvoices` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `invoiceNumber` varchar(100) NOT NULL, + `totalAmount` varchar(50), + `supplierName` varchar(255), + `deletedAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`) +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 028c0d2..0de8c91 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -295,6 +295,13 @@ "when": 1790785209695, "tag": "0041_acoustic_microchip", "breakpoints": true + }, + { + "idx": 42, + "version": "5", + "when": 1790960584104, + "tag": "0042_deleted_invoices_blacklist", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/server/db.ts b/server/db.ts index 8347d5d..799b4d5 100644 --- a/server/db.ts +++ b/server/db.ts @@ -291,12 +291,26 @@ export async function deleteInvoice(id: number) { // Enregistrer dans la blacklist avant suppression const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1); if (invoice[0] && invoice[0].invoiceNumber) { - await db.insert(deletedInvoices).values({ - userId: invoice[0].userId, - invoiceNumber: invoice[0].invoiceNumber, - totalAmount: invoice[0].totalAmount ?? undefined, - supplierName: invoice[0].supplierName ?? undefined, - }).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } }); + const blacklistEntry = await db.select({ id: deletedInvoices.id }) + .from(deletedInvoices) + .where(and( + eq(deletedInvoices.userId, invoice[0].userId), + eq(deletedInvoices.invoiceNumber, invoice[0].invoiceNumber), + )) + .limit(1); + + if (blacklistEntry[0]) { + await db.update(deletedInvoices) + .set({ deletedAt: new Date() }) + .where(eq(deletedInvoices.id, blacklistEntry[0].id)); + } else { + await db.insert(deletedInvoices).values({ + userId: invoice[0].userId, + invoiceNumber: invoice[0].invoiceNumber, + totalAmount: invoice[0].totalAmount ?? undefined, + supplierName: invoice[0].supplierName ?? undefined, + }); + } } await db.delete(invoices).where(eq(invoices.id, id)); } diff --git a/server/invoiceDeletion.test.ts b/server/invoiceDeletion.test.ts new file mode 100644 index 0000000..2c9c686 --- /dev/null +++ b/server/invoiceDeletion.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { INVOICE_DELETE_ERROR, getSafeInvoiceDeletionError } from "./invoiceDeletionError"; + +const migrationPath = resolve(process.cwd(), "drizzle/0042_deleted_invoices_blacklist.sql"); +const dbPath = resolve(process.cwd(), "server/db.ts"); + +describe("suppression de facture", () => { + it("ne renvoie jamais les détails SQL au navigateur", () => { + const error = new Error("Failed query: insert into `deletedInvoices` values (?, ?, ?)"); + + expect(getSafeInvoiceDeletionError(error)).toBe(INVOICE_DELETE_ERROR); + }); + + it("crée la blacklist de manière additive et idempotente", () => { + const migration = readFileSync(migrationPath, "utf8"); + + expect(migration).toContain("CREATE TABLE IF NOT EXISTS `deletedInvoices`"); + expect(migration).toContain("CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`)"); + }); + + it("met à jour une entrée existante sans exiger de contrainte unique historique", () => { + const source = readFileSync(dbPath, "utf8"); + + expect(source).toContain("const blacklistEntry = await db.select"); + expect(source).toContain("await db.update(deletedInvoices)"); + expect(source).not.toContain("}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });"); + }); +}); diff --git a/server/invoiceDeletionError.ts b/server/invoiceDeletionError.ts new file mode 100644 index 0000000..8a353a3 --- /dev/null +++ b/server/invoiceDeletionError.ts @@ -0,0 +1,10 @@ +/** Message utilisateur sans détails de requête ni données de facture. */ +export const INVOICE_DELETE_ERROR = "La facture ne peut pas être supprimée pour le moment. Réessayez dans quelques instants."; + +/** + * Les erreurs de persistance peuvent contenir une requête SQL et des paramètres. + * Ils restent réservés aux journaux serveur et ne doivent jamais atteindre le navigateur. + */ +export function getSafeInvoiceDeletionError(_error: unknown): string { + return INVOICE_DELETE_ERROR; +} diff --git a/server/routers.ts b/server/routers.ts index 6630cf4..cc6f359 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -110,6 +110,7 @@ import { resolveBapExportDestination } from "./exportDestinationResolver"; import { createEmailImportAccountController, startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService"; import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService"; import { getSafeWebImportSourceError, logWebImportSourceSaveFailure } from "./webImportSourcesError"; +import { getSafeInvoiceDeletionError } from "./invoiceDeletionError"; import { getManualWebImportAvailability, runManualWebImport } from "./manualWebImport"; import { TRPCError } from "@trpc/server"; import { processFreeproExcel } from "./freeproService"; @@ -509,7 +510,18 @@ export const appRouter = router({ throw new TRPCError({ code: "NOT_FOUND" }); } - await deleteInvoice(input.id); + try { + await deleteInvoice(input.id); + } catch (error) { + console.error("[InvoiceDelete] Archive or deletion failed", { + invoiceId: input.id, + error: error instanceof Error ? error.message : String(error), + }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: getSafeInvoiceDeletionError(error), + }); + } return { success: true }; }),