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 {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user