fix: rétablir la suppression de facture en recette
This commit is contained in:
11
drizzle/0042_deleted_invoices_blacklist.sql
Normal file
11
drizzle/0042_deleted_invoices_blacklist.sql
Normal file
@@ -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`)
|
||||||
|
);
|
||||||
@@ -295,6 +295,13 @@
|
|||||||
"when": 1790785209695,
|
"when": 1790785209695,
|
||||||
"tag": "0041_acoustic_microchip",
|
"tag": "0041_acoustic_microchip",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 42,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1790960584104,
|
||||||
|
"tag": "0042_deleted_invoices_blacklist",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
26
server/db.ts
26
server/db.ts
@@ -291,12 +291,26 @@ export async function deleteInvoice(id: number) {
|
|||||||
// Enregistrer dans la blacklist avant suppression
|
// Enregistrer dans la blacklist avant suppression
|
||||||
const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
|
const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
|
||||||
if (invoice[0] && invoice[0].invoiceNumber) {
|
if (invoice[0] && invoice[0].invoiceNumber) {
|
||||||
await db.insert(deletedInvoices).values({
|
const blacklistEntry = await db.select({ id: deletedInvoices.id })
|
||||||
userId: invoice[0].userId,
|
.from(deletedInvoices)
|
||||||
invoiceNumber: invoice[0].invoiceNumber,
|
.where(and(
|
||||||
totalAmount: invoice[0].totalAmount ?? undefined,
|
eq(deletedInvoices.userId, invoice[0].userId),
|
||||||
supplierName: invoice[0].supplierName ?? undefined,
|
eq(deletedInvoices.invoiceNumber, invoice[0].invoiceNumber),
|
||||||
}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });
|
))
|
||||||
|
.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));
|
await db.delete(invoices).where(eq(invoices.id, id));
|
||||||
}
|
}
|
||||||
|
|||||||
30
server/invoiceDeletion.test.ts
Normal file
30
server/invoiceDeletion.test.ts
Normal file
@@ -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() } });");
|
||||||
|
});
|
||||||
|
});
|
||||||
10
server/invoiceDeletionError.ts
Normal file
10
server/invoiceDeletionError.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ import { resolveBapExportDestination } from "./exportDestinationResolver";
|
|||||||
import { createEmailImportAccountController, startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
|
import { createEmailImportAccountController, startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
|
||||||
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||||
import { getSafeWebImportSourceError, logWebImportSourceSaveFailure } from "./webImportSourcesError";
|
import { getSafeWebImportSourceError, logWebImportSourceSaveFailure } from "./webImportSourcesError";
|
||||||
|
import { getSafeInvoiceDeletionError } from "./invoiceDeletionError";
|
||||||
import { getManualWebImportAvailability, runManualWebImport } from "./manualWebImport";
|
import { getManualWebImportAvailability, runManualWebImport } from "./manualWebImport";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { processFreeproExcel } from "./freeproService";
|
import { processFreeproExcel } from "./freeproService";
|
||||||
@@ -509,7 +510,18 @@ export const appRouter = router({
|
|||||||
throw new TRPCError({ code: "NOT_FOUND" });
|
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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user