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:
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user