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:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -48,6 +48,29 @@ async function resolveCategoryLabel(key: string): Promise<string> {
return cat?.label ?? fallbackCategoryLabel(key);
}
/**
* Resolve everything an audit description needs (user name, project name,
* category label) for a single mutation in ONE parallel batch instead of two
* sequential awaits (resolvePlanLabels then resolveCategoryLabel). Same three
* queries, but fired together so the audit lookup adds one round-trip, not two.
* The produced description is byte-for-byte identical to the previous code.
*/
async function resolveAuditLabels(
userId: number,
projectId: number | null,
category: string,
): Promise<{
userName: string;
projectName: string | null;
categoryLabel: string;
}> {
const [{ userName, projectName }, categoryLabel] = await Promise.all([
resolvePlanLabels(userId, projectId),
resolveCategoryLabel(category),
]);
return { userName, projectName, categoryLabel };
}
/** Returns an error result if the category key is not an active category. */
async function assertActiveCategory(
key: string,
@@ -129,7 +152,9 @@ export async function resolveCell(
const overrides = await prisma.work_plan_overrides.findMany({
where: { user_id: userId, shift_date: date, is_deleted: false },
orderBy: { created_at: "desc" },
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect },
});
@@ -156,7 +181,9 @@ export async function resolveCell(
date_to: { gte: date },
is_deleted: false,
},
orderBy: { created_at: "desc" },
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect },
});
@@ -201,7 +228,9 @@ export async function resolveGrid(
date_from: { lte: dateTo },
date_to: { gte: dateFrom },
},
orderBy: { created_at: "desc" },
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: { projects: projectSelect },
}),
prisma.work_plan_overrides.findMany({
@@ -210,7 +239,9 @@ export async function resolveGrid(
is_deleted: false,
shift_date: { gte: dateFrom, lte: dateTo },
},
orderBy: { created_at: "desc" },
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: { projects: projectSelect },
}),
]);
@@ -420,8 +451,28 @@ async function assertEntryCapAvailable(
return null;
}
/** Create a work_plan_entries row. Past dates require force=true. */
export async function createEntry(
type CreatedEntry = {
id: number;
user_id: number;
date_from: Date;
date_to: Date;
project_id: number | null;
category: string;
note: string;
created_by: number;
created_at: Date | null;
updated_at: Date | null;
is_deleted: boolean | null;
};
/**
* Core create logic shared by the public createEntry and bulkCreateEntries.
* `resolveLabels` controls the audit-description lookup: the single-mutation
* route needs it, but bulkCreateEntries discards the per-entry description (it
* builds its own summary), so it skips the 3 label queries per run entirely —
* removing the per-run audit N+1.
*/
async function createEntryCore(
input: {
user_id: number;
date_from: string;
@@ -432,21 +483,8 @@ export async function createEntry(
},
actorUserId: number,
force: boolean,
): Promise<
Result<{
id: number;
user_id: number;
date_from: Date;
date_to: Date;
project_id: number | null;
category: string;
note: string;
created_by: number;
created_at: Date | null;
updated_at: Date | null;
is_deleted: boolean | null;
}>
> {
resolveLabels: boolean,
): Promise<Result<CreatedEntry>> {
// Range check
if (input.date_to < input.date_from) {
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
@@ -480,13 +518,18 @@ export async function createEntry(
},
});
const { userName, projectName } = await resolvePlanLabels(
if (!resolveLabels) {
return { data: created, oldData: null };
}
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
created.user_id,
created.project_id,
created.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(created.category),
categoryLabel,
projectName,
dateFrom: input.date_from,
dateTo: input.date_to,
@@ -496,6 +539,22 @@ export async function createEntry(
return { data: created, oldData: null, description };
}
/** Create a work_plan_entries row. Past dates require force=true. */
export async function createEntry(
input: {
user_id: number;
date_from: string;
date_to: string;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
force: boolean,
): Promise<Result<CreatedEntry>> {
return createEntryCore(input, actorUserId, force, true);
}
/** Update a work_plan_entries row. Partial update. */
export async function updateEntry(
id: number,
@@ -546,13 +605,14 @@ export async function updateEntry(
},
});
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
updated.user_id,
updated.project_id,
updated.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(updated.category),
categoryLabel,
projectName,
dateFrom: isoDay(updated.date_from),
dateTo: isoDay(updated.date_to),
@@ -581,13 +641,14 @@ export async function deleteEntry(
data: { is_deleted: true },
});
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
existing.user_id,
existing.project_id,
existing.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(existing.category),
categoryLabel,
projectName,
dateFrom: isoDay(existing.date_from),
dateTo: isoDay(existing.date_to),
@@ -660,13 +721,14 @@ export async function createOverride(
throw e;
}
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
created.user_id,
created.project_id,
created.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(created.category),
categoryLabel,
projectName,
dateFrom: input.shift_date,
dateTo: input.shift_date,
@@ -708,13 +770,14 @@ export async function updateOverride(
},
});
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
updated.user_id,
updated.project_id,
updated.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(updated.category),
categoryLabel,
projectName,
dateFrom: isoDay(updated.shift_date),
dateTo: isoDay(updated.shift_date),
@@ -745,13 +808,14 @@ export async function deleteOverride(
data: { is_deleted: true },
});
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
existing.user_id,
existing.project_id,
existing.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(existing.category),
categoryLabel,
projectName,
dateFrom: isoDay(existing.shift_date),
dateTo: isoDay(existing.shift_date),
@@ -869,7 +933,10 @@ export async function bulkCreateEntries(
const segFrom = days[runStart].toISOString().slice(0, 10);
const segTo = days[i - 1].toISOString().slice(0, 10);
const runDays = i - runStart;
const res = await createEntry(
// Skip audit-label resolution: bulk builds its own summary and discards
// each entry's description, so resolving labels per run would be pure
// wasted queries (the per-run audit N+1 the audit flagged).
const res = await createEntryCore(
{
user_id: userId,
date_from: segFrom,
@@ -880,6 +947,7 @@ export async function bulkCreateEntries(
},
actorUserId,
false,
false,
);
if ("data" in res) {
createdEntries++;