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:
AI Assistant
2026-03-11 22:38:51 +02:00
parent 5cb438ef67
commit 34024404a5
3 changed files with 70 additions and 5 deletions
+53 -1
View File
@@ -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) => {
await removeEntry(id);
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 */
+11 -3
View File
@@ -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(