fix: prevent deleting registry entries that would create sequence gaps
Only the last entry in a company+year sequence can be deleted. Trying to delete an earlier number (e.g. #2 when #3 exists) returns a 409 error with a Romanian message explaining the restriction. Also routes UI deletes through the API (like create/update) so they get proper audit logging and sequence recalculation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,8 @@ import type { Prisma } from "@prisma/client";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { getAuthSession } from "@/core/auth";
|
||||
import { allocateSequenceNumber, recalculateSequence } from "@/modules/registratura/services/registry-service";
|
||||
import { allocateSequenceNumber, recalculateSequence, parseRegistryNumber } from "@/modules/registratura/services/registry-service";
|
||||
import { REGISTRY_COMPANY_PREFIX, OLD_COMPANY_PREFIX } from "@/modules/registratura/types";
|
||||
import {
|
||||
logAuditEvent,
|
||||
computeEntryDiff,
|
||||
@@ -429,6 +430,57 @@ export async function DELETE(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only allow deleting the LAST entry in the sequence (prevent gaps)
|
||||
const parsed = parseRegistryNumber(existing.number);
|
||||
if (parsed) {
|
||||
const companyPrefix =
|
||||
parsed.format === "current"
|
||||
? parsed.company
|
||||
: REGISTRY_COMPANY_PREFIX[existing.company as CompanyId] ?? parsed.company;
|
||||
const yr = parsed.year;
|
||||
const oldPrefix = OLD_COMPANY_PREFIX[companyPrefix] ?? "";
|
||||
|
||||
// Find the actual max sequence for this company+year
|
||||
// NOTE: JSONB value::text serializes with space after colons
|
||||
const newLike = `%"number": "${companyPrefix}-${yr}-%"%`;
|
||||
const newRegex = `${companyPrefix}-${yr}-([0-9]{5})`;
|
||||
const newMaxRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING(value::text FROM ${newRegex}) AS INTEGER)
|
||||
) AS "maxSeq"
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text LIKE ${newLike}
|
||||
`;
|
||||
let maxSeq = newMaxRows[0]?.maxSeq ?? 0;
|
||||
|
||||
// Also check old format
|
||||
if (oldPrefix) {
|
||||
const oldLike = `%"number": "${oldPrefix}-${yr}-%"%`;
|
||||
const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`;
|
||||
const oldMaxRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING(value::text FROM ${oldRegex}) AS INTEGER)
|
||||
) AS "maxSeq"
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text LIKE ${oldLike}
|
||||
`;
|
||||
maxSeq = Math.max(maxSeq, oldMaxRows[0]?.maxSeq ?? 0);
|
||||
}
|
||||
|
||||
if (parsed.sequence < maxSeq) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Nu poți șterge ${existing.number} — există înregistrări cu numere mai mari (max: ${maxSeq}). Doar ultimul număr din secvență poate fi șters.`,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await deleteEntryFromDB(id);
|
||||
|
||||
// Recalculate counter so next allocation reads the correct max
|
||||
|
||||
@@ -216,7 +216,12 @@ export function RegistraturaModule() {
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await removeEntry(id);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Eroare la ștergere";
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
|
||||
/** All closes go through the close dialog */
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
getAllEntries,
|
||||
getFullEntry,
|
||||
saveEntry,
|
||||
deleteEntry,
|
||||
} from "../services/registry-service";
|
||||
import type { RegistryAuditEvent } from "../types";
|
||||
import {
|
||||
@@ -111,10 +110,19 @@ export function useRegistry() {
|
||||
|
||||
const removeEntry = useCallback(
|
||||
async (id: string) => {
|
||||
await deleteEntry(storage, blobStorage, id);
|
||||
// Use the API for sequence validation + audit logging
|
||||
const res = await fetch(`/api/registratura?id=${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to delete entry");
|
||||
}
|
||||
|
||||
await refresh();
|
||||
},
|
||||
[storage, blobStorage, refresh],
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const closeEntry = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user