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 { v4 as uuid } from "uuid";
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { getAuthSession } from "@/core/auth";
|
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 {
|
import {
|
||||||
logAuditEvent,
|
logAuditEvent,
|
||||||
computeEntryDiff,
|
computeEntryDiff,
|
||||||
@@ -429,6 +430,57 @@ export async function DELETE(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
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);
|
await deleteEntryFromDB(id);
|
||||||
|
|
||||||
// Recalculate counter so next allocation reads the correct max
|
// Recalculate counter so next allocation reads the correct max
|
||||||
|
|||||||
@@ -216,7 +216,12 @@ export function RegistraturaModule() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
await removeEntry(id);
|
await removeEntry(id);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Eroare la ștergere";
|
||||||
|
alert(msg);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** All closes go through the close dialog */
|
/** All closes go through the close dialog */
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
getAllEntries,
|
getAllEntries,
|
||||||
getFullEntry,
|
getFullEntry,
|
||||||
saveEntry,
|
saveEntry,
|
||||||
deleteEntry,
|
|
||||||
} from "../services/registry-service";
|
} from "../services/registry-service";
|
||||||
import type { RegistryAuditEvent } from "../types";
|
import type { RegistryAuditEvent } from "../types";
|
||||||
import {
|
import {
|
||||||
@@ -111,10 +110,19 @@ export function useRegistry() {
|
|||||||
|
|
||||||
const removeEntry = useCallback(
|
const removeEntry = useCallback(
|
||||||
async (id: string) => {
|
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();
|
await refresh();
|
||||||
},
|
},
|
||||||
[storage, blobStorage, refresh],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeEntry = useCallback(
|
const closeEntry = useCallback(
|
||||||
|
|||||||
Reference in New Issue
Block a user