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