diff --git a/src/app/api/registratura/route.ts b/src/app/api/registratura/route.ts index 4094a50..5634645 100644 --- a/src/app/api/registratura/route.ts +++ b/src/app/api/registratura/route.ts @@ -431,6 +431,12 @@ export async function DELETE(req: NextRequest) { await deleteEntryFromDB(id); + // Recalculate counter so next allocation reads the correct max + await recalculateSequence( + existing.company as CompanyId, + existing.direction as RegistryDirection, + ); + await logAuditEvent({ entryId: id, entryNumber: existing.number, diff --git a/src/modules/registratura/services/registry-service.ts b/src/modules/registratura/services/registry-service.ts index 920e296..8df092c 100644 --- a/src/modules/registratura/services/registry-service.ts +++ b/src/modules/registratura/services/registry-service.ts @@ -218,7 +218,12 @@ export function generateRegistryNumber( * Allocate the next sequence number atomically via PostgreSQL. * Format: BTG-2026-IN-00001 * - * Uses INSERT ... ON CONFLICT ... UPDATE RETURNING for race-condition safety. + * Uses an interactive Prisma transaction with pg_advisory_xact_lock to + * serialize concurrent allocations. Always reads the **actual max + * sequence from existing entries** (the source of truth) so the counter + * never drifts — even after entry deletions, company reassignments, or + * any other mutation that could leave the RegistrySequence counter stale. + * * Must be called from server-side only (API routes). */ export async function allocateSequenceNumber( @@ -233,17 +238,52 @@ export async function allocateSequenceNumber( const typeCode = DIRECTION_TYPE_CODE[direction]; const yr = year ?? new Date().getFullYear(); - const result = await prisma.$queryRaw>` - INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt") - VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${typeCode}, 1, NOW(), NOW()) - ON CONFLICT (company, year, type) - DO UPDATE SET "lastSeq" = "RegistrySequence"."lastSeq" + 1, "updatedAt" = NOW() - RETURNING "lastSeq" - `; + const seq = await prisma.$transaction(async (tx) => { + // Advisory lock scoped to this transaction — serializes concurrent + // allocations for the same company/year/type combination. + const lockKey = `registratura:${companyPrefix}-${yr}-${typeCode}`; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`; + + // 1. Find actual max sequence from entries stored in KeyValueStore. + // This is the SOURCE OF TRUTH — entries might have been deleted, + // moved between companies, etc., making the RegistrySequence + // counter stale. + const numberLike = `%"number":"${companyPrefix}-${yr}-${typeCode}-%"%`; + const regexPat = `${companyPrefix}-${yr}-${typeCode}-(\\d{5})`; + const maxRows = await tx.$queryRaw>` + SELECT MAX( + CAST(SUBSTRING(value::text FROM ${regexPat}) AS INTEGER) + ) AS "maxSeq" + FROM "KeyValueStore" + WHERE namespace = 'registratura' + AND key LIKE 'entry:%' + AND value::text LIKE ${numberLike} + `; + const actualMax = maxRows[0]?.maxSeq ?? 0; + + // 2. Also read the current counter as a safety net (should never be + // higher than actualMax if everything is consistent, but we take + // the MAX of both just in case). + const counterRows = await tx.$queryRaw>` + SELECT "lastSeq" FROM "RegistrySequence" + WHERE company = ${companyPrefix} AND year = ${yr} AND type = ${typeCode} + `; + const counterVal = counterRows[0]?.lastSeq ?? 0; + + // 3. Next sequence = max(actual entries, counter) + 1 + const nextSeq = Math.max(actualMax, counterVal) + 1; + + // 4. Upsert the counter to the new value + await tx.$executeRaw` + INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt") + VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${typeCode}, ${nextSeq}, NOW(), NOW()) + ON CONFLICT (company, year, type) + DO UPDATE SET "lastSeq" = ${nextSeq}, "updatedAt" = NOW() + `; + + return nextSeq; + }); - const row = result[0]; - if (!row) throw new Error("Failed to allocate sequence number"); - const seq = row.lastSeq; const padded = String(seq).padStart(5, "0"); return {