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

@@ -1,4 +1,5 @@
import prisma from "../config/database";
import { Prisma } from "@prisma/client";
import {
generateSharedNumber,
previewSharedNumber,
@@ -43,6 +44,32 @@ const ORDER_ALLOWED_SORT_FIELDS = [
"created_at",
];
// Order status -> linked-project status (matching PHP).
const ORDER_TO_PROJECT_STATUS: Record<string, string> = {
v_realizaci: "aktivni",
dokoncena: "dokonceny",
stornovana: "zruseny",
};
/**
* Propagate an order status change onto its linked project(s). No-op when the
* new status has no project-status mapping. Accepts a Prisma client (the tx
* client inside a transaction, or the base client otherwise) so both update
* branches share one implementation.
*/
async function syncProjectStatus(
client: Prisma.TransactionClient,
orderId: number,
newStatus: string,
): Promise<void> {
const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus];
if (!projectStatus) return;
await client.projects.updateMany({
where: { order_id: orderId },
data: { status: projectStatus },
});
}
function enrichOrder(o: any) {
const subtotal = o.order_items
.filter((i: any) => i.is_included_in_total !== false)
@@ -523,18 +550,7 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
const statusMap: Record<string, string> = {
v_realizaci: "aktivni",
dokoncena: "dokonceny",
stornovana: "zruseny",
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await tx.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
await syncProjectStatus(tx, id, String(body.status));
}
if (Array.isArray(body.items)) {
@@ -570,18 +586,7 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
const statusMap: Record<string, string> = {
v_realizaci: "aktivni",
dokoncena: "dokonceny",
stornovana: "zruseny",
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await prisma.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
await syncProjectStatus(prisma, id, String(body.status));
}
}
@@ -601,69 +606,85 @@ export async function deleteOrder(id: number, deleteFiles = false) {
select: { id: true, created_at: true, project_number: true },
});
// Guard: projects may have non-cascaded warehouse refs (sklad_issues,
// sklad_reservations, attendance_project_logs all use project_id as a
// non-nullable FK with no onDelete). Surface as 409 (resource conflict)
// rather than letting Prisma throw P2003 -> 500.
//
// Only ACTIVE records count: a CANCELLED issue/restored batch or
// CANCELLED reservation is audit-trail-only (cancelIssue() has already
// restored the batch qty; cancelReservation() zeroes remaining_qty).
// Attendance project logs are time-records — they always block, since
// deleting the project would orphan the time record. The user must
// re-assign the attendance log to a different project first.
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
const [activeIssuesCount, activeReservationsCount, attendanceLogCount] =
await Promise.all([
prisma.sklad_issues.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
prisma.sklad_reservations.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
prisma.attendance_project_logs.count({
try {
await prisma.$transaction(async (tx) => {
// Guard: projects may have non-cascaded warehouse refs (sklad_issues,
// sklad_reservations, attendance_project_logs all use project_id as a
// non-nullable FK with no onDelete). Surface as 409 (resource conflict)
// rather than letting Prisma throw P2003 -> 500. The count check runs
// INSIDE the transaction so a row can't be re-introduced between the
// guard and the project delete (closes the narrow re-intro window).
//
// Only ACTIVE records count: a CANCELLED issue/restored batch or
// CANCELLED reservation is audit-trail-only (cancelIssue() has already
// restored the batch qty; cancelReservation() zeroes remaining_qty).
// Attendance project logs are time-records — they always block, since
// deleting the project would orphan the time record. The user must
// re-assign the attendance log to a different project first.
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
const [activeIssuesCount, activeReservationsCount, attendanceLogCount] =
await Promise.all([
tx.sklad_issues.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
tx.sklad_reservations.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
tx.attendance_project_logs.count({
where: { project_id: { in: projectIds } },
}),
]);
if (
activeIssuesCount + activeReservationsCount + attendanceLogCount >
0
) {
throw Object.assign(
new Error(
"Nelze smazat objednávku, protože navázaný projekt má aktivní skladové výdeje, rezervace nebo docházkové záznamy. Zrušte je nebo přeřaďte docházku na jiný projekt.",
),
{ status: 409 },
);
}
}
// Clear quotation back-reference (matching PHP)
await tx.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
// Delete linked project and its notes (matching PHP)
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
await tx.project_notes.deleteMany({
where: { project_id: { in: projectIds } },
}),
]);
if (activeIssuesCount + activeReservationsCount + attendanceLogCount > 0) {
});
await tx.projects.deleteMany({ where: { order_id: id } });
}
// Explicitly clean up child rows
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.orders.delete({ where: { id } });
});
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error:
"Nelze smazat objednávku, protože navázaný projekt má aktivní skladové výdeje, rezervace nebo docházkové záznamy. Zrušte je nebo přeřaďte docházku na jiný projekt.",
status: 409,
error: err.message,
status: (err as Error & { status: number }).status,
} as const;
}
throw err;
}
await prisma.$transaction(async (tx) => {
// Clear quotation back-reference (matching PHP)
await tx.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
// Delete linked project and its notes (matching PHP)
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
await tx.project_notes.deleteMany({
where: { project_id: { in: projectIds } },
});
await tx.projects.deleteMany({ where: { order_id: id } });
}
// Explicitly clean up child rows
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.orders.delete({ where: { id } });
});
// Best-effort NAS folder cleanup for the order's project(s), outside the
// transaction and only when the user ticked the "delete folder" checkbox in
// the order delete modal. Non-fatal: a NAS error must not undo the