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

@@ -41,6 +41,36 @@ import {
UpdateInventorySessionSchema,
} from "../../schemas/warehouse.schema";
// Hard cap on the number of source documents (issues/receipts) a report will
// scan before aggregating/expanding. Bounds worst-case work + response size on
// a wide date range; the per-report pagination then pages the derived rows.
const REPORT_SOURCE_CAP = 5000;
/**
* Coerce a querystring `status` filter to a plain string, or undefined if it
* isn't a usable scalar. Querystrings can be arrays/objects (`?status[]=x`);
* passing one straight into a Prisma string `where` is a type mismatch that
* 500s. Returning undefined for non-strings makes a bad value a no-op filter
* instead of a crash.
*/
function statusFilter(raw: unknown): string | undefined {
return typeof raw === "string" && raw.length > 0 ? raw : undefined;
}
/**
* Coerce a querystring numeric filter (FK id etc.) to a finite number, or
* undefined if it isn't parseable. A junk value becomes a no-op filter rather
* than a NaN reaching Prisma (which 500s).
*/
function numFilter(raw: unknown): number | undefined {
if (raw === undefined || raw === null || raw === "") return undefined;
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
// NOTE: this file bundles 8 warehouse sub-entities (~2400 lines). Splitting it
// per sub-entity (items/receipts/issues/reservations/inventory/reports) is a
// tracked refactor, deferred — see REVIEW_FINDINGS.md.
export default async function warehouseRoutes(
fastify: FastifyInstance,
): Promise<void> {
@@ -976,11 +1006,13 @@ export default async function warehouseRoutes(
const where: Record<string, unknown>[] = [];
if (query.status) {
where.push({ status: query.status });
const status = statusFilter(query.status);
if (status) {
where.push({ status });
}
if (query.supplier_id) {
where.push({ supplier_id: Number(query.supplier_id) });
const supplierId = numFilter(query.supplier_id);
if (supplierId !== undefined) {
where.push({ supplier_id: supplierId });
}
if (query.date_from) {
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
@@ -1146,35 +1178,39 @@ export default async function warehouseRoutes(
if (body.notes !== undefined) updateData.notes = body.notes;
updateData.modified_at = new Date();
// Replace items if provided
if (body.items) {
await prisma.sklad_receipt_lines.deleteMany({
where: { receipt_id: id },
});
await prisma.sklad_receipt_lines.createMany({
data: body.items.map((item) => ({
receipt_id: id,
item_id: item.item_id,
quantity: item.quantity,
unit_price: item.unit_price,
location_id: item.location_id ?? null,
notes: item.notes ?? null,
})),
});
}
// Replace items + persist header atomically: the line deleteMany +
// createMany and the header update run in ONE transaction, so a failure
// mid-replacement can't leave the draft with zero (or partial) lines.
const updated = await prisma.$transaction(async (tx) => {
if (body.items) {
await tx.sklad_receipt_lines.deleteMany({
where: { receipt_id: id },
});
await tx.sklad_receipt_lines.createMany({
data: body.items.map((item) => ({
receipt_id: id,
item_id: item.item_id,
quantity: item.quantity,
unit_price: item.unit_price,
location_id: item.location_id ?? null,
notes: item.notes ?? null,
})),
});
}
const updated = await prisma.sklad_receipts.update({
where: { id },
data: updateData,
include: {
supplier: { select: { id: true, name: true } },
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
location: { select: { id: true, code: true, name: true } },
return tx.sklad_receipts.update({
where: { id },
data: updateData,
include: {
supplier: { select: { id: true, name: true } },
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
location: { select: { id: true, code: true, name: true } },
},
},
},
},
});
});
await logAudit({
@@ -1291,15 +1327,32 @@ export default async function warehouseRoutes(
);
if ("error" in result) return error(reply, result.error, 500);
const attachment = await prisma.sklad_receipt_attachments.create({
data: {
receipt_id: id,
file_name: part.filename,
file_mime: part.mimetype,
file_size: buffer.length,
file_path: result.filePath,
},
});
// Compensating delete: the NAS write already succeeded above. If the DB
// insert now fails we must remove the just-written file, otherwise it is
// orphaned on the NAS with no DB row pointing at it.
let attachment: Awaited<
ReturnType<typeof prisma.sklad_receipt_attachments.create>
>;
try {
attachment = await prisma.sklad_receipt_attachments.create({
data: {
receipt_id: id,
file_name: part.filename,
file_mime: part.mimetype,
file_size: buffer.length,
file_path: result.filePath,
},
});
} catch (e) {
const removed = nasFinancialsManager.deleteReceivedInvoice(
result.filePath,
);
request.log.error(
{ err: e, filePath: result.filePath, orphanRemoved: removed },
"warehouse attachment DB insert failed after NAS write; rolled back NAS file",
);
return error(reply, "Přílohu se nepodařilo uložit", 500);
}
await logAudit({
request,
@@ -1336,8 +1389,18 @@ export default async function warehouseRoutes(
return error(reply, "Příloha nepatří k tomuto příjmu", 400);
}
// Delete file from NAS
nasFinancialsManager.deleteReceivedInvoice(attachment.file_path);
// Delete file from NAS. A failure here is non-fatal (we still remove the
// DB row) but must not be silent — log it so an undeleted NAS file is
// visible rather than leaking quietly.
const nasDeleted = nasFinancialsManager.deleteReceivedInvoice(
attachment.file_path,
);
if (!nasDeleted) {
request.log.error(
{ filePath: attachment.file_path, attachmentId },
"warehouse attachment NAS delete failed; DB row will still be removed",
);
}
await prisma.sklad_receipt_attachments.delete({
where: { id: attachmentId },
@@ -1376,11 +1439,13 @@ export default async function warehouseRoutes(
const where: Record<string, unknown>[] = [];
if (query.status) {
where.push({ status: query.status });
const status = statusFilter(query.status);
if (status) {
where.push({ status });
}
if (query.project_id) {
where.push({ project_id: Number(query.project_id) });
const projectId = numFilter(query.project_id);
if (projectId !== undefined) {
where.push({ project_id: projectId });
}
if (query.date_from) {
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
@@ -1595,17 +1660,21 @@ export default async function warehouseRoutes(
if (body.notes !== undefined) updateData.notes = body.notes;
updateData.modified_at = new Date();
// Resolved replacement lines (null = caller did not send `items`, so the
// lines are left untouched). Populated/validated below, then persisted
// atomically with the header update.
let resolvedItems: Array<{
item_id: number;
batch_id: number;
quantity: number;
location_id: number | null;
reservation_id: number | null;
notes: string | null;
}> | null = null;
// Replace items if provided
if (body.items) {
// Resolve FIFO batches for items without batch_id
const resolvedItems: Array<{
item_id: number;
batch_id: number;
quantity: number;
location_id: number | null;
reservation_id: number | null;
notes: string | null;
}> = [];
resolvedItems = [];
for (const item of body.items) {
if (item.batch_id != null) {
@@ -1656,34 +1725,44 @@ export default async function warehouseRoutes(
reservationValidation.status,
);
}
await prisma.sklad_issue_lines.deleteMany({
where: { issue_id: id },
});
await prisma.sklad_issue_lines.createMany({
data: resolvedItems.map((item) => ({
issue_id: id,
item_id: item.item_id,
batch_id: item.batch_id,
quantity: item.quantity,
location_id: item.location_id,
reservation_id: item.reservation_id,
notes: item.notes,
})),
});
}
const updated = await prisma.sklad_issues.update({
where: { id },
data: updateData,
include: {
project: { select: { id: true, name: true, project_number: true } },
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
// Replace lines + persist header atomically: the line deleteMany +
// createMany and the header update run in ONE transaction, so a failure
// mid-replacement can't leave the draft with zero (or partial) lines.
const linesToWrite = resolvedItems;
const updated = await prisma.$transaction(async (tx) => {
if (linesToWrite) {
await tx.sklad_issue_lines.deleteMany({
where: { issue_id: id },
});
await tx.sklad_issue_lines.createMany({
data: linesToWrite.map((item) => ({
issue_id: id,
item_id: item.item_id,
batch_id: item.batch_id,
quantity: item.quantity,
location_id: item.location_id,
reservation_id: item.reservation_id,
notes: item.notes,
})),
});
}
return tx.sklad_issues.update({
where: { id },
data: updateData,
include: {
project: {
select: { id: true, name: true, project_number: true },
},
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
},
},
},
},
});
});
await logAudit({
@@ -1781,14 +1860,17 @@ export default async function warehouseRoutes(
const where: Record<string, unknown>[] = [];
if (query.item_id) {
where.push({ item_id: Number(query.item_id) });
const itemId = numFilter(query.item_id);
if (itemId !== undefined) {
where.push({ item_id: itemId });
}
if (query.project_id) {
where.push({ project_id: Number(query.project_id) });
const projectId = numFilter(query.project_id);
if (projectId !== undefined) {
where.push({ project_id: projectId });
}
if (query.status) {
where.push({ status: query.status });
const status = statusFilter(query.status);
if (status) {
where.push({ status });
}
const filter = where.length > 0 ? { AND: where } : {};
@@ -1897,8 +1979,9 @@ export default async function warehouseRoutes(
const query = request.query as Record<string, unknown>;
const where: Record<string, unknown> = {};
if (query.status) {
where.status = query.status;
const status = statusFilter(query.status);
if (status) {
where.status = status;
}
const [sessions, total] = await Promise.all([
@@ -2054,9 +2137,19 @@ export default async function warehouseRoutes(
};
if (body.notes !== undefined) updateData.notes = body.notes;
// Replace items if provided, recalculating system_qty and difference
// Replace items if provided, recalculating system_qty and difference.
// The system_qty/difference computation is read-only and runs first;
// the resulting lines are then persisted atomically with the header.
let itemsData: Array<{
item_id: number;
location_id: number | null;
system_qty: number;
actual_qty: number;
difference: number;
notes: string | null;
}> | null = null;
if (body.items) {
const itemsData = await Promise.all(
itemsData = await Promise.all(
body.items.map(async (item) => {
let systemQty = 0;
@@ -2086,29 +2179,37 @@ export default async function warehouseRoutes(
};
}),
);
await prisma.sklad_inventory_lines.deleteMany({
where: { session_id: id },
});
await prisma.sklad_inventory_lines.createMany({
data: itemsData.map((item) => ({
session_id: id,
...item,
})),
});
}
const updated = await prisma.sklad_inventory_sessions.update({
where: { id },
data: updateData,
include: {
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
location: { select: { id: true, code: true, name: true } },
// Replace lines + persist header atomically: the line deleteMany +
// createMany and the header update run in ONE transaction, so a failure
// mid-replacement can't leave the session with zero (or partial) lines.
const linesToWrite = itemsData;
const updated = await prisma.$transaction(async (tx) => {
if (linesToWrite) {
await tx.sklad_inventory_lines.deleteMany({
where: { session_id: id },
});
await tx.sklad_inventory_lines.createMany({
data: linesToWrite.map((item) => ({
session_id: id,
...item,
})),
});
}
return tx.sklad_inventory_sessions.update({
where: { id },
data: updateData,
include: {
items: {
include: {
item: { select: { id: true, name: true, unit: true } },
location: { select: { id: true, code: true, name: true } },
},
},
},
},
});
});
await logAudit({
@@ -2159,25 +2260,40 @@ export default async function warehouseRoutes(
// REPORTS
// =============================================================
// GET /reports/stock-status — all items with stock info
// GET /reports/stock-status — items with stock info (paginated)
// Pagination + a hard `take` cap (parsePagination clamps limit to ≤100) keep
// this from returning an unbounded result set. Response stays `data`-as-array
// (paginated() adds a top-level `pagination` meta the existing clients ignore).
fastify.get(
"/reports/stock-status",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip } = parsePagination(query, {
defaultLimit: 2000,
maxLimit: 5000,
});
const where: Record<string, unknown> = { is_active: true };
if (query.category_id) {
where.category_id = Number(query.category_id);
// NaN-guard the category filter: a junk value yields no filter rather
// than a NaN reaching Prisma (which 500s) or silently matching nothing.
if (query.category_id !== undefined) {
const categoryId = Number(query.category_id);
if (!Number.isNaN(categoryId)) where.category_id = categoryId;
}
const items = await prisma.sklad_items.findMany({
where,
orderBy: { name: "asc" },
include: {
category: { select: { id: true, name: true } },
},
});
const [items, total] = await Promise.all([
prisma.sklad_items.findMany({
where,
orderBy: { name: "asc" },
skip,
take: limit,
include: {
category: { select: { id: true, name: true } },
},
}),
prisma.sklad_items.count({ where }),
]);
const enriched = await Promise.all(
items.map(async (item) => {
@@ -2202,20 +2318,35 @@ export default async function warehouseRoutes(
}),
);
return success(reply, enriched);
return paginated(
reply,
enriched,
buildPaginationMeta(total, page, limit),
);
},
);
// GET /reports/project-consumption — aggregated material consumed per project
// Bounded two ways: (1) at most REPORT_SOURCE_CAP source issues are scanned
// for the aggregate, and (2) the aggregated rows are paginated. This prevents
// an unbounded scan/response on a wide date range. Response stays
// `data`-as-array via paginated().
fastify.get(
"/reports/project-consumption",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip } = parsePagination(query, {
defaultLimit: 2000,
maxLimit: 5000,
});
const issueWhere: Record<string, unknown>[] = [{ status: "CONFIRMED" }];
if (query.project_id) {
issueWhere.push({ project_id: Number(query.project_id) });
// NaN-guard the project filter (junk value -> no filter, not a 500).
if (query.project_id !== undefined) {
const projectId = Number(query.project_id);
if (!Number.isNaN(projectId))
issueWhere.push({ project_id: projectId });
}
if (query.date_from) {
issueWhere.push({
@@ -2230,6 +2361,8 @@ export default async function warehouseRoutes(
const issues = await prisma.sklad_issues.findMany({
where: { AND: issueWhere },
take: REPORT_SOURCE_CAP,
orderBy: { id: "desc" },
include: {
project: { select: { id: true, name: true, project_number: true } },
items: {
@@ -2278,16 +2411,31 @@ export default async function warehouseRoutes(
}
}
return success(reply, Array.from(consumptionMap.values()));
const allRows = Array.from(consumptionMap.values());
const pageRows = allRows.slice(skip, skip + limit);
return paginated(
reply,
pageRows,
buildPaginationMeta(allRows.length, page, limit),
);
},
);
// GET /reports/movement-log — combined list of confirmed receipts + issues
// Bounded: each source query is capped at REPORT_SOURCE_CAP rows and the
// merged, date-sorted movement list is paginated. Without this a wide date
// range expands to an unbounded number of movement lines. Response stays
// `data`-as-array via paginated().
fastify.get(
"/reports/movement-log",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip } = parsePagination(query, {
defaultLimit: 2000,
maxLimit: 5000,
});
const receiptWhere: Record<string, unknown>[] = [{ status: "CONFIRMED" }];
const issueWhere: Record<string, unknown>[] = [{ status: "CONFIRMED" }];
@@ -2306,6 +2454,7 @@ export default async function warehouseRoutes(
const [receipts, issues] = await Promise.all([
prisma.sklad_receipts.findMany({
where: { AND: receiptWhere },
take: REPORT_SOURCE_CAP,
include: {
supplier: { select: { id: true, name: true } },
items: {
@@ -2318,6 +2467,7 @@ export default async function warehouseRoutes(
}),
prisma.sklad_issues.findMany({
where: { AND: issueWhere },
take: REPORT_SOURCE_CAP,
include: {
project: { select: { id: true, name: true, project_number: true } },
items: {
@@ -2383,17 +2533,35 @@ export default async function warehouseRoutes(
return dateB - dateA;
});
return success(reply, movements);
const pageMovements = movements.slice(skip, skip + limit);
return paginated(
reply,
pageMovements,
buildPaginationMeta(movements.length, page, limit),
);
},
);
// GET /reports/below-minimum — items below minimum stock level
// GET /reports/below-minimum — items below minimum stock level (paginated)
// getBelowMinimumItems() now does ONE grouped aggregate (no per-item N+1),
// and the result is paginated here so the response can't grow unbounded.
fastify.get(
"/reports/below-minimum",
{ preHandler: requirePermission("warehouse.view") },
async (_request, reply) => {
const items = await getBelowMinimumItems();
return success(reply, items);
async (request, reply) => {
const { page, limit, skip } = parsePagination(
request.query as Record<string, unknown>,
{ defaultLimit: 2000, maxLimit: 5000 },
);
const all = await getBelowMinimumItems();
const pageItems = all.slice(skip, skip + limit);
return paginated(
reply,
pageItems,
buildPaginationMeta(all.length, page, limit),
);
},
);
}