fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import prisma from "../config/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
// Prisma transaction client (omit methods not available inside $transaction)
|
||||
@@ -57,7 +58,16 @@ export async function getNextSequence(
|
||||
year: number,
|
||||
tx?: TxClient,
|
||||
): Promise<number> {
|
||||
const exec = async (client: TxClient) => {
|
||||
// Read-modify-write under a row lock. The very first allocation of a
|
||||
// (type, year) pair has no row to lock yet, so two concurrent callers can
|
||||
// both see zero rows and both attempt the INSERT — the @@unique([type,year])
|
||||
// constraint then raises P2002 on the loser. `allowInsertRetry` lets us catch
|
||||
// that one case and fall back to the locked read-then-update path, so the
|
||||
// first-of-year race returns the next sequence instead of a 500.
|
||||
const exec = async (
|
||||
client: TxClient,
|
||||
allowInsertRetry: boolean,
|
||||
): Promise<number> => {
|
||||
const existing = await client.$queryRaw<
|
||||
Array<{ id: number; last_number: number }>
|
||||
>`
|
||||
@@ -67,11 +77,24 @@ export async function getNextSequence(
|
||||
`;
|
||||
|
||||
if (existing.length === 0) {
|
||||
await client.$executeRaw`
|
||||
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
|
||||
VALUES (${type}, ${year}, 1)
|
||||
`;
|
||||
return 1;
|
||||
try {
|
||||
await client.$executeRaw`
|
||||
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
|
||||
VALUES (${type}, ${year}, 1)
|
||||
`;
|
||||
return 1;
|
||||
} catch (err) {
|
||||
// Concurrent first-of-year insert won the race. Retry the locked
|
||||
// read-then-update exactly once; the row now exists so this resolves.
|
||||
if (
|
||||
allowInsertRetry &&
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return exec(client, false);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const next = existing[0].last_number + 1;
|
||||
@@ -84,9 +107,9 @@ export async function getNextSequence(
|
||||
};
|
||||
|
||||
if (tx) {
|
||||
return exec(tx);
|
||||
return exec(tx, true);
|
||||
}
|
||||
return prisma.$transaction(exec);
|
||||
return prisma.$transaction((client) => exec(client, true));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,12 +143,7 @@ async function releaseSequence(
|
||||
) {
|
||||
if (!deletedNumber) return;
|
||||
|
||||
const existing = await prisma.$queryRaw<Array<{ last_number: number }>>`
|
||||
SELECT last_number FROM number_sequences
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${year}
|
||||
`;
|
||||
if (existing.length === 0) return;
|
||||
|
||||
// Settings are read-only here and don't need to be inside the lock window.
|
||||
const settings = await getSettings();
|
||||
const pattern =
|
||||
type === "shared"
|
||||
@@ -141,20 +159,33 @@ async function releaseSequence(
|
||||
: "";
|
||||
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
|
||||
|
||||
const highestNumber = applyPattern(pattern, {
|
||||
year,
|
||||
prefix,
|
||||
code,
|
||||
seq: existing[0].last_number,
|
||||
});
|
||||
|
||||
if (deletedNumber === highestNumber && existing[0].last_number > 0) {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE number_sequences
|
||||
SET \`last_number\` = ${existing[0].last_number - 1}
|
||||
// Lock the sequence row for the duration of the decrement so a concurrent
|
||||
// getNextSequence (which also takes FOR UPDATE) can't read-modify-write the
|
||||
// same row in between — that interleaving could otherwise lose a decrement or
|
||||
// decrement an in-use number (gap/dup).
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.$queryRaw<Array<{ last_number: number }>>`
|
||||
SELECT last_number FROM number_sequences
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${year}
|
||||
FOR UPDATE
|
||||
`;
|
||||
}
|
||||
if (existing.length === 0) return;
|
||||
|
||||
const highestNumber = applyPattern(pattern, {
|
||||
year,
|
||||
prefix,
|
||||
code,
|
||||
seq: existing[0].last_number,
|
||||
});
|
||||
|
||||
if (deletedNumber === highestNumber && existing[0].last_number > 0) {
|
||||
await tx.$executeRaw`
|
||||
UPDATE number_sequences
|
||||
SET \`last_number\` = ${existing[0].last_number - 1}
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${year}
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Verify a shared number is not already used by an order or project. */
|
||||
|
||||
Reference in New Issue
Block a user