fix: JSONB space-after-colon in all registry LIKE/regex patterns

PostgreSQL JSONB value::text serializes JSON with spaces after colons
("number": "B-2026-00001") but all LIKE patterns searched for the
no-space format ("number":"B-2026-00001"), causing zero matches and
every new entry getting sequence #1.

Fixed in allocateSequenceNumber, recalculateSequence, and debug-sequences.
Added PATCH handler to migrate old-format entries (BTG/SDT/USW/GRP)
to new single-letter format (B/S/U/G).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-11 22:22:33 +02:00
parent 4b61d07ffd
commit 5cb438ef67
2 changed files with 88 additions and 20 deletions
@@ -27,7 +27,7 @@ export async function GET() {
Array<{ key: string; num: string | null; snippet: string }>
>(`
SELECT key,
SUBSTRING(value::text FROM '"number":"([^"]+)"') AS num,
SUBSTRING(value::text FROM '"number": "([^"]+)"') AS num,
SUBSTRING(value::text FROM 1 FOR 200) AS snippet
FROM "KeyValueStore"
WHERE namespace = 'registratura'
@@ -42,13 +42,13 @@ export async function GET() {
Array<{ prefix: string; maxSeq: number; count: number }>
>(`
SELECT
SUBSTRING(value::text FROM '"number":"([A-Z]-[0-9]{4})-') AS prefix,
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
SUBSTRING(value::text FROM '"number": "([A-Z]-[0-9]{4})-') AS prefix,
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
COUNT(*)::int AS count
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number":"[A-Z]-[0-9]{4}-[0-9]{5}"'
AND value::text ~ '"number": "[A-Z]-[0-9]{4}-[0-9]{5}"'
GROUP BY prefix
ORDER BY prefix
`);
@@ -58,13 +58,13 @@ export async function GET() {
Array<{ prefix: string; maxSeq: number; count: number }>
>(`
SELECT
SUBSTRING(value::text FROM '"number":"([A-Z]{3}-[0-9]{4})-') AS prefix,
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
SUBSTRING(value::text FROM '"number": "([A-Z]{3}-[0-9]{4})-') AS prefix,
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
COUNT(*)::int AS count
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number":"[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
AND value::text ~ '"number": "[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
GROUP BY prefix
ORDER BY prefix
`);
@@ -92,16 +92,16 @@ export async function POST() {
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
SELECT
gen_random_uuid()::text,
SUBSTRING(value::text FROM '"number":"([A-Z])-') AS company,
CAST(SUBSTRING(value::text FROM '"number":"[A-Z]-([0-9]{4})-') AS INTEGER) AS year,
SUBSTRING(value::text FROM '"number": "([A-Z])-') AS company,
CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-([0-9]{4})-') AS INTEGER) AS year,
'SEQ' AS type,
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
NOW(),
NOW()
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number":"[A-Z]-[0-9]{4}-[0-9]{5}"'
AND value::text ~ '"number": "[A-Z]-[0-9]{4}-[0-9]{5}"'
GROUP BY company, year, type
`);
@@ -110,22 +110,22 @@ export async function POST() {
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
SELECT
gen_random_uuid()::text,
CASE SUBSTRING(value::text FROM '"number":"([A-Z]{3})-')
CASE SUBSTRING(value::text FROM '"number": "([A-Z]{3})-')
WHEN 'BTG' THEN 'B'
WHEN 'USW' THEN 'U'
WHEN 'SDT' THEN 'S'
WHEN 'GRP' THEN 'G'
ELSE SUBSTRING(value::text FROM '"number":"([A-Z]{3})-')
ELSE SUBSTRING(value::text FROM '"number": "([A-Z]{3})-')
END AS company,
CAST(SUBSTRING(value::text FROM '"number":"[A-Z]{3}-([0-9]{4})-') AS INTEGER) AS year,
CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-([0-9]{4})-') AS INTEGER) AS year,
'SEQ' AS type,
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
NOW(),
NOW()
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number":"[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
AND value::text ~ '"number": "[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
GROUP BY company, year, type
ON CONFLICT (company, year, type)
DO UPDATE SET "lastSeq" = GREATEST("RegistrySequence"."lastSeq", EXCLUDED."lastSeq"),
@@ -140,3 +140,69 @@ export async function POST() {
message: "All counters reset from actual entries (both old and new format).",
});
}
/**
* PATCH — Migrate old-format entries (BTG/SDT/USW/GRP) to new format (B/S/U/G).
* Rewrites the "number" field inside the JSONB value for matching entries.
*/
export async function PATCH() {
const session = await getAuthSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Map old 3-letter prefixes to new single-letter
const migrations: Array<{ old: string; new: string }> = [
{ old: "BTG", new: "B" },
{ old: "SDT", new: "S" },
{ old: "USW", new: "U" },
{ old: "GRP", new: "G" },
];
const results: Array<{ prefix: string; updated: number }> = [];
for (const m of migrations) {
// Find entries with old-format numbers: BTG-2026-IN-00001, SDT-2026-OUT-00002, etc.
const entries = await prisma.$queryRawUnsafe<
Array<{ key: string; num: string }>
>(`
SELECT key,
SUBSTRING(value::text FROM '"number": "([^"]+)"') AS num
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number": "${m.old}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
`);
let updated = 0;
for (const entry of entries) {
if (!entry.num) continue;
// Parse: SDT-2026-OUT-00001 → S-2026-00001
const match = entry.num.match(
new RegExp(`^${m.old}-(\\d{4})-(?:IN|OUT|INT)-(\\d{5})$`)
);
if (!match) continue;
const newNumber = `${m.new}-${match[1]}-${match[2]}`;
// Update the JSONB value — replace the number field
await prisma.$executeRawUnsafe(`
UPDATE "KeyValueStore"
SET value = jsonb_set(value, '{number}', $1::jsonb)
WHERE namespace = 'registratura'
AND key = $2
`, JSON.stringify(newNumber), entry.key);
updated++;
}
results.push({ prefix: m.old, updated });
}
return NextResponse.json({
success: true,
migrations: results,
message: "Old-format entries migrated to new format. Run POST to reset counters.",
});
}
@@ -258,7 +258,8 @@ export async function allocateSequenceNumber(
// New format: B-2026-00001
// NOTE: Use [0-9] instead of \d — PostgreSQL POSIX regex may not support \d
const newLike = `%"number":"${companyPrefix}-${yr}-%"%`;
// NOTE: JSONB value::text serializes with a space after colons ("number": "...")
const newLike = `%"number": "${companyPrefix}-${yr}-%"%`;
const newRegex = `${companyPrefix}-${yr}-([0-9]{5})`;
const newMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX(
@@ -274,7 +275,7 @@ export async function allocateSequenceNumber(
// Old format: BTG-2026-IN-00001 / BTG-2026-OUT-00001
let oldMax = 0;
if (oldPrefix) {
const oldLike = `%"number":"${oldPrefix}-${yr}-%"%`;
const oldLike = `%"number": "${oldPrefix}-${yr}-%"%`;
const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`;
const oldMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX(
@@ -335,7 +336,8 @@ export async function recalculateSequence(
// Find max from new format (B-2026-00001)
// NOTE: Use [0-9] instead of \d — PostgreSQL POSIX regex may not support \d
const newLike = `%"number":"${companyPrefix}-${yr}-%"%`;
// NOTE: JSONB value::text serializes with a space after colons ("number": "...")
const newLike = `%"number": "${companyPrefix}-${yr}-%"%`;
const newRegex = `${companyPrefix}-${yr}-([0-9]{5})`;
const newRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX(
@@ -350,7 +352,7 @@ export async function recalculateSequence(
// Also check old format (BTG-2026-OUT-00001)
if (oldPrefix) {
const oldLike = `%"number":"${oldPrefix}-${yr}-%"%`;
const oldLike = `%"number": "${oldPrefix}-${yr}-%"%`;
const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`;
const oldRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX(