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

@@ -113,6 +113,9 @@ export default async function aiRoutes(app: FastifyInstance): Promise<void> {
fields?: ExtractedInvoice;
error?: string;
}> = [];
// True when the loop stops early because the budget was hit mid-batch —
// surfaced to the client so it knows trailing files were NOT processed.
let truncated = false;
for await (const part of parts) {
if (part.type !== "file") continue;
const buf = await part.toBuffer();
@@ -128,11 +131,14 @@ export default async function aiRoutes(app: FastifyInstance): Promise<void> {
}
// Re-check the budget between files so one batch can't blow far past it.
const over = await assertBudgetAvailable();
if (over) break;
if (over) {
truncated = true;
break;
}
}
if (results.length === 0)
return error(reply, "Nebyl nahrán žádný soubor", 400);
return success(reply, { invoices: results });
return success(reply, { invoices: results, truncated });
},
);

View File

@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
@@ -29,7 +29,7 @@ export default async function attendanceRoutes(
async (request, reply) => {
const authData = request.authData!;
const data = await attendanceService.getStatus(authData.userId);
return reply.send({ success: true, data });
return success(reply, data);
},
);
@@ -105,7 +105,7 @@ export default async function attendanceRoutes(
}
const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getBalances(yr);
return reply.send({ success: true, data });
return success(reply, data);
}
// --- action=workfund: monthly work fund overview ---
@@ -115,7 +115,7 @@ export default async function attendanceRoutes(
}
const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getWorkfund(yr);
return reply.send({ success: true, data });
return success(reply, data);
}
// --- action=project_report: monthly project hours ---
@@ -125,7 +125,7 @@ export default async function attendanceRoutes(
}
const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getProjectReport(yr);
return reply.send({ success: true, data });
return success(reply, data);
}
// --- action=print: attendance print data for admin ---
@@ -137,9 +137,13 @@ export default async function attendanceRoutes(
const monthStr = query.month
? String(query.month)
: localMonthStr(new Date());
const filterUserId = query.user_id ? Number(query.user_id) : null;
const parsedUserId = query.user_id ? Number(query.user_id) : null;
const filterUserId =
parsedUserId !== null && Number.isFinite(parsedUserId)
? parsedUserId
: null;
const data = await attendanceService.getPrintData(monthStr, filterUserId);
return reply.send({ success: true, data });
return success(reply, data);
}
// --- action=attendance_users: users with attendance.record permission ---
@@ -162,21 +166,21 @@ export default async function attendanceRoutes(
select: { id: true, first_name: true, last_name: true, username: true },
orderBy: { last_name: "asc" },
});
return reply.send({
success: true,
data: users.map((u) => ({
return success(
reply,
users.map((u) => ({
id: u.id,
first_name: u.first_name,
last_name: u.last_name,
username: u.username,
})),
});
);
}
// --- action=projects: active projects for attendance project switching ---
if (action === "projects") {
const data = await attendanceService.getActiveProjects();
return reply.send({ success: true, data });
return success(reply, data);
}
// --- action=project_logs: get project logs for a specific attendance record ---
@@ -185,28 +189,43 @@ export default async function attendanceRoutes(
return error(reply, "Nedostatečná oprávnění", 403);
}
const attendanceId = Number(query.attendance_id);
if (!attendanceId) return error(reply, "Chybí attendance_id", 400);
if (!attendanceId || !Number.isFinite(attendanceId))
return error(reply, "Chybí attendance_id", 400);
// Ownership check: a non-admin may only read logs for their own
// attendance record (mirrors the action=location path below).
const parentRecord =
await attendanceService.getLocationRecord(attendanceId);
if (!parentRecord) return error(reply, "Záznam nenalezen", 404);
const isAdmin = authData.permissions.includes("attendance.manage");
if (parentRecord.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const data = await attendanceService.getProjectLogs(attendanceId);
return reply.send({ success: true, data });
return success(reply, data);
}
// --- action=location: single record with GPS data ---
if (action === "location") {
const id = Number(query.id);
if (!id) return error(reply, "Chybí id záznamu", 400);
if (!id || !Number.isFinite(id))
return error(reply, "Chybí id záznamu", 400);
const record = await attendanceService.getLocationRecord(id);
if (!record) return error(reply, "Záznam nenalezen", 404);
const isAdmin = authData.permissions.includes("attendance.manage");
if (record.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nedostatečná oprávnění", 403);
}
return reply.send({ success: true, data: record });
return success(reply, record);
}
// --- Default: paginated records list ---
const { page, limit, skip, order } = parsePagination(query);
const isAdmin = authData.permissions.includes("attendance.manage");
const userId = query.user_id ? Number(query.user_id) : undefined;
const parsedUserId = query.user_id ? Number(query.user_id) : undefined;
const userId =
parsedUserId !== undefined && Number.isFinite(parsedUserId)
? parsedUserId
: undefined;
const result = await attendanceService.listAttendance({
page,
@@ -232,11 +251,11 @@ export default async function attendanceRoutes(
: undefined,
});
return reply.send({
success: true,
data: result.records,
pagination: buildPaginationMeta(result.total, result.page, result.limit),
});
return paginated(
reply,
result.records,
buildPaginationMeta(result.total, result.page, result.limit),
);
});
// POST /api/admin/attendance

View File

@@ -30,14 +30,31 @@ export default async function auditLogRoutes(
const where: Record<string, unknown> = {};
if (query.action) where.action = String(query.action);
if (query.entity_type) where.entity_type = String(query.entity_type);
if (query.user_id) where.user_id = Number(query.user_id);
if (query.user_id) {
const userId = Number(query.user_id);
if (!Number.isInteger(userId) || userId <= 0) {
return error(reply, "Neplatné ID uživatele", 400);
}
where.user_id = userId;
}
if (search) where.description = { contains: search };
if (query.date_from || query.date_to) {
const dateFilter: Record<string, Date> = {};
if (query.date_from) dateFilter.gte = new Date(String(query.date_from));
if (query.date_to)
dateFilter.lte = new Date(String(query.date_to) + "T23:59:59");
if (query.date_from) {
const from = new Date(String(query.date_from));
if (isNaN(from.getTime())) {
return error(reply, "Neplatné datum od", 400);
}
dateFilter.gte = from;
}
if (query.date_to) {
const to = new Date(String(query.date_to) + "T23:59:59");
if (isNaN(to.getTime())) {
return error(reply, "Neplatné datum do", 400);
}
dateFilter.lte = to;
}
where.created_at = dateFilter;
}

View File

@@ -310,7 +310,7 @@ export default async function companySettingsRoutes(
settings.custom_fields as string | null,
);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require("../../../package.json") as { version: string };
let available_vat_rates: number[] = [0, 10, 12, 15, 21];
@@ -354,7 +354,7 @@ export default async function companySettingsRoutes(
"/system-info",
{ preHandler: requirePermission("settings.system") },
async (request, reply) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require("../../../package.json") as { version: string };
const uptimeSec = process.uptime();
const days = Math.floor(uptimeSec / 86400);
@@ -464,8 +464,12 @@ export default async function companySettingsRoutes(
if (bodyRec[f] !== undefined)
data[f] = bodyRec[f] ? String(bodyRec[f]) : null;
}
if (body.default_vat_rate !== undefined)
data.default_vat_rate = Number(body.default_vat_rate);
if (body.default_vat_rate !== undefined) {
const vat = Number(body.default_vat_rate);
if (!Number.isFinite(vat))
return error(reply, "Neplatná sazba DPH", 400);
data.default_vat_rate = vat;
}
if (body.require_2fa !== undefined) data.require_2fa = !!body.require_2fa;
const numFields = [
@@ -478,7 +482,12 @@ export default async function companySettingsRoutes(
"max_requests_per_minute",
] as const;
for (const f of numFields) {
if (bodyRec[f] !== undefined) data[f] = Number(bodyRec[f]);
if (bodyRec[f] !== undefined) {
const n = Number(bodyRec[f]);
if (!Number.isFinite(n))
return error(reply, `Neplatná hodnota nastavení: ${f}`, 400);
data[f] = n;
}
}
if (body.available_vat_rates !== undefined)

View File

@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
@@ -97,11 +97,11 @@ export default async function customersRoutes(
};
});
return reply.send({
success: true,
data: enriched,
pagination: buildPaginationMeta(total, page, limit),
});
return paginated(
reply,
enriched,
buildPaginationMeta(total, page, limit),
);
},
);

View File

@@ -52,19 +52,23 @@ export default async function dashboardRoutes(
if (has("attendance.record") || has("attendance.manage")) {
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const cells = await resolveCell(userId, todayStr);
result.today_plan = await Promise.all(
cells.map(async (cell) => {
const cat = await prisma.plan_categories.findUnique({
where: { key: cell.category },
select: { label: true, color: true },
});
return {
...cell,
category_label: cat?.label ?? cell.category,
category_color: cat?.color ?? null,
};
}),
);
// Resolve all category labels in one query (was a per-cell N+1) then map.
const categoryKeys = [...new Set(cells.map((c) => c.category))];
const categories = categoryKeys.length
? await prisma.plan_categories.findMany({
where: { key: { in: categoryKeys } },
select: { key: true, label: true, color: true },
})
: [];
const categoryMap = new Map(categories.map((c) => [c.key, c]));
result.today_plan = cells.map((cell) => {
const cat = categoryMap.get(cell.category);
return {
...cell,
category_label: cat?.label ?? cell.category,
category_color: cat?.color ?? null,
};
});
}
// Attendance admin — only for attendance.manage
@@ -311,7 +315,7 @@ export default async function dashboardRoutes(
entity_type: log.entity_type ?? "",
description: log.description ?? "",
username: log.username ?? null,
created_at: log.created_at ?? "",
created_at: log.created_at,
}));
}

View File

@@ -7,7 +7,7 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date";
import { parseId } from "../../utils/response";
import { parseId, success } from "../../utils/response";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
@@ -448,7 +448,18 @@ export default async function invoicesPdfRoutes(
let suppEmail = "";
if (settings?.custom_fields) {
const raw = settings.custom_fields;
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
// Malformed JSON in custom_fields must degrade gracefully (no email)
// instead of 500-ing the whole invoice PDF.
let parsed: unknown;
try {
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch (e) {
request.log.warn(
e,
"Neplatný JSON v company_settings.custom_fields — e-mail dodavatele vynechán",
);
parsed = null;
}
if (parsed && typeof parsed === "object") {
const fields = (parsed as Record<string, unknown>).fields;
if (Array.isArray(fields)) {
@@ -1070,7 +1081,7 @@ ${indentCSS}
issueDate.getMonth() + 1,
pdfBuffer,
);
return reply.send({ success: true, message: "PDF uloženo" });
return success(reply, null, 200, "PDF uloženo");
}
return reply.type("text/html").send(html);

View File

@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
@@ -59,11 +59,11 @@ export default async function invoicesRoutes(
year: query.year ? Number(query.year) : undefined,
});
return reply.send({
success: true,
data: result.data,
pagination: buildPaginationMeta(result.total, page, limit),
});
return paginated(
reply,
result.data,
buildPaginationMeta(result.total, page, limit),
);
},
);

View File

@@ -7,7 +7,7 @@ import {
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
@@ -30,7 +30,12 @@ export default async function leaveRequestsRoutes(
const where: Record<string, unknown> = {};
if (!isAdmin || query.mine === "1") where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id);
else if (query.user_id) {
const filterUserId = Number(query.user_id);
if (!Number.isFinite(filterUserId))
return error(reply, "Neplatné ID uživatele", 400);
where.user_id = filterUserId;
}
if (query.status) {
const statuses = String(query.status).split(",");
where.status = statuses.length === 1 ? statuses[0] : { in: statuses };
@@ -54,11 +59,7 @@ export default async function leaveRequestsRoutes(
prisma.leave_requests.count({ where }),
]);
return reply.send({
success: true,
data: requests,
pagination: buildPaginationMeta(total, page, limit),
});
return paginated(reply, requests, buildPaginationMeta(total, page, limit));
});
fastify.post("/", { preHandler: requireAuth }, async (request, reply) => {
@@ -304,6 +305,9 @@ export default async function leaveRequestsRoutes(
data: {
status: "approved" as leave_requests_status,
reviewer_id: authData.userId,
reviewer_note: body.reviewer_note
? String(body.reviewer_note)
: null,
reviewed_at: new Date(),
},
});

View File

@@ -4,7 +4,7 @@ import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId } from "../../utils/response";
import { parseId, success } from "../../utils/response";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
@@ -75,8 +75,16 @@ function cleanQuillHtml(html: string | null | undefined): string {
// Strip event handlers
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
// Strip javascript: in href
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
// Strip javascript:/data:/vbscript: in href and src (defense-in-depth,
// matching the invoices/orders copies — DOMPurify runs first).
s = s.replace(
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'href="#"',
);
s = s.replace(
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'src=""',
);
// Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
@@ -758,7 +766,7 @@ ${indentCSS}
created.getFullYear(),
pdfBuffer,
);
return reply.send({ success: true, message: "PDF uloženo" });
return success(reply, null, 200, "PDF uloženo");
}
return reply.type("text/html").send(html);

View File

@@ -12,22 +12,25 @@ import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
const OrderPdfBodySchema = z
.object({
items: z
.array(
z.object({
description: z.string(),
quantity: z.number().min(0).finite(),
unit: z.string(),
unit_price: z.number().min(0).finite(),
is_included_in_total: z.boolean().optional(),
vat_rate: z.number().finite(),
}),
)
.optional(),
})
.passthrough();
// Plain object (Zod strips unknown keys rather than rejecting) — the frontend
// may send extra item fields (id, position, item_description) we don't use.
const OrderPdfItemSchema = z.object({
description: z.string().max(8000),
quantity: z.number().min(0).finite(),
unit: z.string().max(255),
unit_price: z.number().min(0).finite(),
is_included_in_total: z.boolean().optional(),
vat_rate: z.number().min(0).max(100).finite(),
});
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the
// handler no longer reads them as untyped passthrough keys.
const OrderPdfBodySchema = z.looseObject({
items: z.array(OrderPdfItemSchema).optional(),
lang: z.string().max(10).optional(),
applyVat: z.boolean().optional(),
});
/* ── Helpers ─────────────────────────────────────────────────────── */
@@ -290,8 +293,20 @@ export default async function ordersPdfRoutes(
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
const orderVatRate = Number(order.vat_rate) || 21;
// Use custom items from body if provided, otherwise order items
const customItemsRaw = body.items;
// The confirmation PDF can be rendered from client-supplied items (e.g.
// a live preview of unsaved edits on the detail page) OR from the
// stored order. Fabricating descriptions/prices that don't reflect the
// stored order is an editing action, so the custom-items path requires
// `orders.edit` (admins bypass). A view-only caller is silently served
// the STORED order items instead — preventing a `orders.view` holder
// from producing an "official" confirmation with invented figures.
const authData = request.authData;
const canUseCustomItems =
authData?.roleName === "admin" ||
!!authData?.permissions.includes("orders.edit");
const customItemsRaw =
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
let items: Array<{
description: string;
quantity: number;
@@ -301,7 +316,7 @@ export default async function ordersPdfRoutes(
vat_rate: number;
}> = [];
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
if (customItemsRaw && customItemsRaw.length > 0) {
items = customItemsRaw.map((it) => ({
description: it.description,
quantity: it.quantity,

View File

@@ -120,11 +120,17 @@ export default async function ordersRoutes(
// From-quotation flow (multipart with attachment)
if (fields.quotationId) {
const quotationId = parseInt(fields.quotationId, 10);
const customerOrderNumber = fields.customerOrderNumber || "";
if (!quotationId || isNaN(quotationId)) {
return error(reply, "Chybí ID nabídky", 400);
}
// Validate via the same schema as the JSON path — `intIdFromForm`
// is NaN-safe and rejects ≤0 / non-integer (raw parseInt would
// silently yield NaN).
const fromQuotParsed = parseBody(CreateOrderFromQuotationSchema, {
quotationId: fields.quotationId,
customerOrderNumber: fields.customerOrderNumber,
});
if ("error" in fromQuotParsed)
return error(reply, fromQuotParsed.error, 400);
const quotationId = fromQuotParsed.data.quotationId;
const customerOrderNumber = fromQuotParsed.data.customerOrderNumber;
const result = await createOrderFromQuotation({
quotationId,
customerOrderNumber,

View File

@@ -253,7 +253,7 @@ export default async function planRoutes(app: FastifyInstance) {
authData: request.authData,
action: "create",
entityType: "work_plan_entry",
entityId: (result.data as any).id,
entityId: result.data.id,
newValues: result.data,
description: result.description,
});
@@ -307,7 +307,8 @@ export default async function planRoutes(app: FastifyInstance) {
action: "update",
entityType: "work_plan_entry",
entityId: id,
oldValues: (result.oldData as any) ?? undefined,
oldValues:
(result.oldData as Record<string, unknown> | null) ?? undefined,
newValues: result.data as Record<string, unknown>,
description: result.description,
});
@@ -331,7 +332,8 @@ export default async function planRoutes(app: FastifyInstance) {
action: "delete",
entityType: "work_plan_entry",
entityId: id,
oldValues: (result.oldData as any) ?? undefined,
oldValues:
(result.oldData as Record<string, unknown> | null) ?? undefined,
description: result.description,
});
return success(reply, { ok: true }, 200, "Plán smazán");
@@ -358,7 +360,7 @@ export default async function planRoutes(app: FastifyInstance) {
authData: request.authData,
action: "create",
entityType: "work_plan_override",
entityId: (result.data as any).id,
entityId: result.data.id,
newValues: result.data,
description: result.description,
});
@@ -389,7 +391,8 @@ export default async function planRoutes(app: FastifyInstance) {
action: "update",
entityType: "work_plan_override",
entityId: id,
oldValues: (result.oldData as any) ?? undefined,
oldValues:
(result.oldData as Record<string, unknown> | null) ?? undefined,
newValues: result.data,
description: result.description,
});
@@ -413,7 +416,8 @@ export default async function planRoutes(app: FastifyInstance) {
action: "delete",
entityType: "work_plan_override",
entityId: id,
oldValues: (result.oldData as any) ?? undefined,
oldValues:
(result.oldData as Record<string, unknown> | null) ?? undefined,
description: result.description,
});
return success(reply, { ok: true }, 200, "Přepsání smazáno");

View File

@@ -7,8 +7,30 @@ import { config } from "../../config/env";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error } from "../../utils/response";
import { parseBody } from "../../schemas/common";
import { NasFileManager } from "../../services/nas-file-manager";
// Body for POST / (create folder). path defaults to root; the name length cap
// counts code-points (not UTF-16 units) to match the previous handler check.
// Path-traversal safety is enforced downstream by resolveProjectPath.
const CreateFolderSchema = z.object({
folder_name: z
.string()
.trim()
.min(1, "Název složky je povinný")
.refine((s) => [...s].length <= 100, {
message: "Název složky je příliš dlouhý (max 100 znaků)",
}),
path: z.string().optional().default(""),
});
// Body for PUT / (move/rename). Both endpoints are validated by
// resolveProjectPath downstream for traversal safety.
const MoveItemSchema = z.object({
from_path: z.string().min(1, "Zdrojová cesta je povinná"),
to_path: z.string().min(1, "Cílová cesta je povinná"),
});
const ProjectFilesQuerySchema = z.object({
project_id: z.string().min(1, "project_id je povinný"),
path: z.string().optional(),
@@ -117,13 +139,10 @@ export default async function projectFilesRoutes(
return error(reply, "Souborový systém není nakonfigurován", 500);
}
const body = request.body as Record<string, unknown>;
const folderName = String(body.folder_name || "").trim();
const path = String(body.path || "");
if (!folderName) return error(reply, "Název složky je povinný");
if ([...folderName].length > 100)
return error(reply, "Název složky je příliš dlouhý (max 100 znaků)");
const parsedBody = parseBody(CreateFolderSchema, request.body);
if ("error" in parsedBody) return error(reply, parsedBody.error, 400);
const folderName = parsedBody.data.folder_name;
const path = parsedBody.data.path;
// Auto-create project folder if it doesn't exist
if (!fm.projectFolderExists(project.project_number)) {
@@ -233,12 +252,10 @@ export default async function projectFilesRoutes(
return error(reply, "Souborový systém není nakonfigurován", 500);
}
const body = request.body as Record<string, unknown>;
const fromPath = String(body.from_path || "");
const toPath = String(body.to_path || "");
if (!fromPath || !toPath)
return error(reply, "Zdrojová i cílová cesta jsou povinné");
const parsedBody = parseBody(MoveItemSchema, request.body);
if ("error" in parsedBody) return error(reply, parsedBody.error, 400);
const fromPath = parsedBody.data.from_path;
const toPath = parsedBody.data.to_path;
const err = await fm.moveItem(project.project_number, fromPath, toPath);
if (err !== null) return error(reply, err);

View File

@@ -1,7 +1,8 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
@@ -20,6 +21,15 @@ import {
deleteProjectNote,
} from "../../services/projects.service";
// Body for DELETE /:id — optional delete_files flag. The body may be absent on
// a DELETE request, so default to an empty object and coerce truthy flags.
const DeleteProjectSchema = z.object({
delete_files: z
.union([z.boolean(), z.string(), z.number()])
.optional()
.transform((v) => v === true || v === 1 || v === "1" || v === "true"),
});
export default async function projectsRoutes(
fastify: FastifyInstance,
): Promise<void> {
@@ -30,6 +40,14 @@ export default async function projectsRoutes(
const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order, search } = parsePagination(query);
const parsedCustomerId = query.customer_id
? Number(query.customer_id)
: undefined;
const customerId =
parsedCustomerId !== undefined && Number.isFinite(parsedCustomerId)
? parsedCustomerId
: undefined;
const result = await listProjects({
page,
limit,
@@ -38,14 +56,14 @@ export default async function projectsRoutes(
order,
search,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
customer_id: customerId,
});
return reply.send({
success: true,
data: result.data,
pagination: buildPaginationMeta(result.total, page, limit),
});
return paginated(
reply,
result.data,
buildPaginationMeta(result.total, page, limit),
);
},
);
@@ -143,6 +161,15 @@ export default async function projectsRoutes(
return error(reply, note.error, (note as any).status ?? 400);
}
await logAudit({
request,
authData,
action: "create",
entityType: "project",
entityId: projectId,
description: `Přidána poznámka projektu`,
});
return success(reply, { note }, 201, "Poznámka byla přidána");
},
);
@@ -179,8 +206,9 @@ export default async function projectsRoutes(
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const deleteFiles = !!body?.delete_files;
const parsed = parseBody(DeleteProjectSchema, request.body ?? {});
if ("error" in parsed) return error(reply, parsed.error, 400);
const deleteFiles = parsed.data.delete_files;
const result = await deleteProject(id, deleteFiles);
if ("error" in result) {
if (result.error === "not_found")

View File

@@ -123,23 +123,21 @@ export default async function quotationsRoutes(
const data = await getOffer(id);
if (!data) return error(reply, "Nabídka nenalezena", 404);
const quotation = await prisma.quotations.findUnique({
where: { id },
select: { locked_by: true, locked_at: true },
});
// `getOffer` already loaded the quotation row, including locked_by /
// locked_at — reuse them instead of a second findUnique.
let lockedBy: {
user_id: number;
username: string;
full_name: string;
} | null = null;
if (quotation?.locked_by && quotation?.locked_at) {
const lockAge = Date.now() - new Date(quotation.locked_at).getTime();
if (data.locked_by && data.locked_at) {
const lockAge = Date.now() - new Date(data.locked_at).getTime();
if (
lockAge < LOCK_TIMEOUT_MS &&
quotation.locked_by !== request.authData!.userId
data.locked_by !== request.authData!.userId
) {
const lockUser = await prisma.users.findUnique({
where: { id: quotation.locked_by },
where: { id: data.locked_by },
select: {
id: true,
username: true,
@@ -283,7 +281,7 @@ export default async function quotationsRoutes(
return error(
reply,
result.error || "Neznámá chyba",
(result as any).status ?? 400,
"status" in result ? result.status : 400,
);
}
@@ -318,8 +316,13 @@ export default async function quotationsRoutes(
await nasOffersManager.deleteOfferPdf(
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
);
} catch {
// Non-fatal: NAS delete may fail if file does not exist
} catch (e) {
// Non-fatal: NAS delete may fail if the file does not exist — but
// never swallow silently (project never-swallow rule).
request.log.warn(
e,
`Smazání PDF nabídky ${existing.quotation_number} z NAS selhalo`,
);
}
}

View File

@@ -5,7 +5,7 @@ import { z } from "zod";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
@@ -86,11 +86,11 @@ export default async function receivedInvoicesRoutes(
prisma.received_invoices.count({ where }),
]);
return reply.send({
success: true,
data: invoices,
pagination: buildPaginationMeta(total, page, limit),
});
return paginated(
reply,
invoices,
buildPaginationMeta(total, page, limit),
);
},
);
@@ -132,7 +132,16 @@ export default async function receivedInvoicesRoutes(
let total = 0;
for (const inv of invs) {
const amount = Number(inv[field]) || 0;
total += await toCzk(amount, inv.currency);
// `toCzk` throws on an unknown/unsupported currency. A single bad row
// must not 500 the whole stats endpoint — log and skip it instead.
try {
total += await toCzk(amount, inv.currency);
} catch (e) {
request.log.warn(
e,
`Přeskočen převod na CZK pro měnu "${inv.currency}" ve statistikách přijatých faktur`,
);
}
}
return Math.round(total * 100) / 100;
};
@@ -391,10 +400,9 @@ export default async function receivedInvoicesRoutes(
amount,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: vatRate,
vat_amount:
vatRate > 0
? Math.round(((amount * vatRate) / 100) * 100) / 100
: 0,
// `amount` is the GROSS total (VAT included); VAT is the portion
// within it — consistent with the multipart create and update paths.
vat_amount: vatFromGross(amount, vatRate),
issue_date: body.issue_date
? new Date(String(body.issue_date))
: null,

View File

@@ -60,31 +60,54 @@ export default async function rolesRoutes(
"/",
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
// Privilege-escalation guard: only an admin may create roles (and thus
// attach arbitrary permission_ids). A non-admin `settings.roles` holder
// must not be able to mint a new high-privilege role.
if (request.authData!.roleName !== "admin") {
return error(reply, "Pouze administrátor může vytvářet role", 403);
}
const parsed = parseBody(CreateRoleSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const role = await prisma.roles.create({
data: {
name: String(body.name),
display_name: String(body.display_name),
description: body.description ? String(body.description) : null,
},
});
const name = String(body.name);
if (Array.isArray(body.permission_ids)) {
await prisma.$transaction(
(body.permission_ids as number[]).map((pid) =>
prisma.role_permissions.create({
data: {
role_id: role.id,
permission_id: pid,
},
}),
),
);
// Never allow minting another "admin" role (would shadow / escalate).
if (name.toLowerCase() === "admin") {
return error(reply, "Nelze vytvořit roli s názvem admin", 400);
}
// Enforce role-name uniqueness explicitly (clear 409 instead of a raw
// Prisma constraint 500).
const duplicate = await prisma.roles.findFirst({ where: { name } });
if (duplicate) {
return error(reply, "Role s tímto názvem již existuje", 409);
}
// Role insert + permission assignment in a SINGLE transaction so a
// partial failure can never leave an orphan role with no permissions.
const role = await prisma.$transaction(async (tx) => {
const created = await tx.roles.create({
data: {
name,
display_name: String(body.display_name),
description: body.description ? String(body.description) : null,
},
});
if (Array.isArray(body.permission_ids) && body.permission_ids.length) {
await tx.role_permissions.createMany({
data: (body.permission_ids as number[]).map((pid) => ({
role_id: created.id,
permission_id: pid,
})),
});
}
return created;
});
await logAudit({
request,
authData: request.authData,
@@ -103,6 +126,12 @@ export default async function rolesRoutes(
"/:id",
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
// Privilege-escalation guard: only an admin may modify roles or their
// permission sets.
if (request.authData!.roleName !== "admin") {
return error(reply, "Pouze administrátor může upravovat role", 403);
}
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateRoleSchema, request.body);
@@ -112,6 +141,13 @@ export default async function rolesRoutes(
const existing = await prisma.roles.findUnique({ where: { id } });
if (!existing) return error(reply, "Role nenalezena", 404);
// Mirror the DELETE guard: the built-in admin role's permission set is
// immutable (it is resolved as "all permissions" anyway). Block any
// attempt to rewrite it via permission_ids → no lockout / escalation.
if (existing.name === "admin" && Array.isArray(body.permission_ids)) {
return error(reply, "Oprávnění role admin nelze měnit", 400);
}
await prisma.roles.update({
where: { id },
data: {

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { parseBody } from "../../schemas/common";
import {
@@ -70,23 +71,41 @@ export default async function scopeTemplatesRoutes(
};
if (body.id) {
const itemId = Number(body.id);
const existingItem = await prisma.item_templates.findFirst({
where: { id: Number(body.id), is_deleted: false },
where: { id: itemId, is_deleted: false },
});
if (!existingItem) return error(reply, "Šablona nenalezena", 404);
await prisma.item_templates.updateMany({
where: { id: Number(body.id), is_deleted: false },
where: { id: itemId, is_deleted: false },
data: { ...itemData, modified_at: new Date() },
});
return success(
reply,
{ id: Number(body.id) },
200,
"Položka byla uložena",
);
await logAudit({
request,
authData: request.authData,
action: "update",
entityId: itemId,
description: `Upravena šablona položky '${itemData.name ?? itemId}'`,
oldValues: {
name: existingItem.name,
description: existingItem.description,
default_price: existingItem.default_price,
category: existingItem.category,
},
newValues: itemData,
});
return success(reply, { id: itemId }, 200, "Položka byla uložena");
}
const item = await prisma.item_templates.create({ data: itemData });
await logAudit({
request,
authData: request.authData,
action: "create",
entityId: item.id,
description: `Vytvořena šablona položky '${itemData.name ?? item.id}'`,
newValues: itemData,
});
return success(reply, { id: item.id }, 201, "Položka byla vytvořena");
}
@@ -120,6 +139,20 @@ export default async function scopeTemplatesRoutes(
return created;
});
await logAudit({
request,
authData: request.authData,
action: "create",
entityId: template.id,
description: `Vytvořena šablona rozsahu '${template.name ?? template.id}'`,
newValues: {
name: template.name,
title: template.title,
description: template.description,
sections: Array.isArray(body.sections) ? body.sections.length : 0,
},
});
return success(reply, { id: template.id }, 201, "Šablona byla vytvořena");
},
);
@@ -133,10 +166,23 @@ export default async function scopeTemplatesRoutes(
if (String(query.action) === "item" && query.id) {
const id = Number(query.id);
if (!Number.isInteger(id) || id <= 0)
return error(reply, "Neplatné ID", 400);
const existingItem = await prisma.item_templates.findFirst({
where: { id, is_deleted: false },
});
if (!existingItem) return error(reply, "Šablona nenalezena", 404);
await prisma.item_templates.update({
where: { id },
data: { is_deleted: true, modified_at: new Date() },
});
await logAudit({
request,
authData: request.authData,
action: "delete",
entityId: id,
description: `Smazána šablona položky '${existingItem.name ?? id}'`,
});
return success(reply, null, 200, "Šablona smazána");
}
@@ -210,6 +256,27 @@ export default async function scopeTemplatesRoutes(
});
}
await logAudit({
request,
authData: request.authData,
action: "update",
entityId: id,
description: `Upravena šablona rozsahu '${existing.name ?? id}'`,
oldValues: {
name: existing.name,
title: existing.title,
description: existing.description,
},
newValues: {
name: body.name,
title: body.title,
description: body.description,
sections: Array.isArray(body.sections)
? body.sections.length
: undefined,
},
});
return success(reply, { id }, 200, "Šablona byla uložena");
},
);
@@ -220,10 +287,21 @@ export default async function scopeTemplatesRoutes(
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.scope_templates.findFirst({
where: { id, is_deleted: false },
});
if (!existing) return error(reply, "Šablona nenalezena", 404);
await prisma.scope_templates.update({
where: { id },
data: { is_deleted: true, modified_at: new Date() },
});
await logAudit({
request,
authData: request.authData,
action: "delete",
entityId: id,
description: `Smazána šablona rozsahu '${existing.name ?? id}'`,
});
return success(reply, null, 200, "Šablona smazána");
},
);

View File

@@ -37,9 +37,12 @@ export default async function totpRoutes(
issuer: companyName,
label: request.authData!.email,
secret,
algorithm: "SHA1",
digits: 6,
period: 30,
// Keep the QR/URI params in lockstep with /enable verification and
// login (all read config.totp.*) so the scanned authenticator produces
// codes that verify under the same algorithm/digits/period.
algorithm: config.totp.algorithm,
digits: config.totp.digits,
period: config.totp.period,
});
return success(reply, {
@@ -84,11 +87,15 @@ export default async function totpRoutes(
}
}
// Verify the new secret with the SAME parameters login verification uses
// (src/utils/totp.ts → OTPAuth.verify reads config.totp.*). Hardcoding
// SHA1/6/30 here would let an enrolled secret verify under different
// params than login → lockout if the config ever diverges.
const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(secret),
algorithm: "SHA1",
digits: 6,
period: 30,
algorithm: config.totp.algorithm,
digits: config.totp.digits,
period: config.totp.period,
});
const delta = totp.validate({ token: code, window: 1 });
@@ -308,6 +315,7 @@ export default async function totpRoutes(
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
if (isMatch) {
matchIndex = i;
break; // early-exit: stop running bcrypt once a code matches
}
}

View File

@@ -1,8 +1,8 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { requireAnyPermission, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, paginated, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema";
@@ -10,77 +10,83 @@ import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema";
export default async function tripsRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
const hasTripsAccess =
authData.permissions.includes("trips.manage") ||
authData.permissions.includes("trips.record") ||
authData.permissions.includes("trips.history");
if (!hasTripsAccess) return error(reply, "Nedostatečná oprávnění", 403);
const isAdmin = authData.permissions.includes("trips.manage");
fastify.get(
"/",
{
preHandler: requireAnyPermission(
"trips.manage",
"trips.record",
"trips.history",
),
},
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
// Admin role bypasses permission checks entirely (requireAnyPermission
// lets it through with an empty permissions list), so treat the admin
// role as a manager for scoping purposes too.
const isAdmin =
authData.roleName === "admin" ||
authData.permissions.includes("trips.manage");
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id);
if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id);
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id);
if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id);
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
if (query.month) {
const monthStr = String(query.month);
let yr: number, mo: number;
if (monthStr.includes("-")) {
// Combined YYYY-MM format
const [yStr, mStr] = monthStr.split("-");
yr = Number(yStr);
mo = Number(mStr);
} else if (query.year) {
yr = Number(query.year);
mo = Number(query.month);
} else {
yr = NaN;
mo = NaN;
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
if (query.month) {
const monthStr = String(query.month);
let yr: number, mo: number;
if (monthStr.includes("-")) {
// Combined YYYY-MM format
const [yStr, mStr] = monthStr.split("-");
yr = Number(yStr);
mo = Number(mStr);
} else if (query.year) {
yr = Number(query.year);
mo = Number(query.month);
} else {
yr = NaN;
mo = NaN;
}
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
// Use explicit date strings to avoid toJSON timezone shift
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = {
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
}
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
// Use explicit date strings to avoid toJSON timezone shift
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = {
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
}
const [trips, total] = await Promise.all([
prisma.trips.findMany({
where,
skip,
take: limit,
orderBy: { trip_date: order },
include: {
users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: true } },
},
}),
prisma.trips.count({ where }),
]);
const [trips, total] = await Promise.all([
prisma.trips.findMany({
where,
skip,
take: limit,
orderBy: { trip_date: order },
include: {
users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: true } },
},
}),
prisma.trips.count({ where }),
]);
return reply.send({
success: true,
data: trips,
pagination: buildPaginationMeta(total, page, limit),
});
});
return paginated(reply, trips, buildPaginationMeta(total, page, limit));
},
);
// GET /api/admin/trips/users — users with trips.record permission
fastify.get(
"/users",
{ preHandler: requireAuth },
{ preHandler: requireAnyPermission("trips.record", "trips.manage") },
async (_request, reply) => {
const users = await prisma.users.findMany({
where: {
@@ -212,54 +218,70 @@ export default async function tripsRoutes(
},
);
fastify.post("/", { preHandler: requireAuth }, async (request, reply) => {
const parsed = parseBody(CreateTripSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authData = request.authData!;
fastify.post(
"/",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const parsed = parseBody(CreateTripSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authData = request.authData!;
const canRecord =
authData.permissions.includes("trips.manage") ||
authData.permissions.includes("trips.record");
if (!canRecord) return error(reply, "Nedostatečná oprávnění", 403);
if (body.end_km < body.start_km) {
return error(
reply,
"Konečný stav km nesmí být menší než počáteční",
400,
);
}
if (body.end_km < body.start_km) {
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
}
const vehicleId = Number(body.vehicle_id);
const trip = await prisma.trips.create({
data: {
vehicle_id: Number(body.vehicle_id),
user_id: body.user_id ? Number(body.user_id) : authData.userId,
trip_date: new Date(String(body.trip_date)),
start_km: Number(body.start_km),
end_km: Number(body.end_km),
route_from: String(body.route_from),
route_to: String(body.route_to),
is_business: !!body.is_business,
notes: body.notes ? String(body.notes) : null,
},
});
const trip = await prisma.trips.create({
data: {
vehicle_id: vehicleId,
user_id: body.user_id ? Number(body.user_id) : authData.userId,
trip_date: new Date(String(body.trip_date)),
start_km: Number(body.start_km),
end_km: Number(body.end_km),
route_from: String(body.route_from),
route_to: String(body.route_to),
is_business: !!body.is_business,
notes: body.notes ? String(body.notes) : null,
},
});
await prisma.vehicles.update({
where: { id: Number(body.vehicle_id) },
data: { actual_km: Number(body.end_km) },
});
// Recompute actual_km from MAX(end_km) across all trips (same as
// PUT/DELETE). Setting it unconditionally to this trip's end_km would
// regress the odometer when a back-dated trip with a lower end_km is
// entered after a later trip with a higher reading.
const maxTrip = await prisma.trips.findFirst({
where: { vehicle_id: vehicleId },
orderBy: { end_km: "desc" },
select: { end_km: true },
});
if (maxTrip) {
await prisma.vehicles.update({
where: { id: vehicleId },
data: { actual_km: Number(maxTrip.end_km) },
});
}
await logAudit({
request,
authData,
action: "create",
entityType: "trip",
entityId: trip.id,
description: `Vytvořena jízda`,
});
return success(reply, { id: trip.id }, 201, "Jízda byla zaznamenána");
});
await logAudit({
request,
authData,
action: "create",
entityType: "trip",
entityId: trip.id,
description: `Vytvořena jízda`,
});
return success(reply, { id: trip.id }, 201, "Jízda byla zaznamenána");
},
);
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
if (id === null) return;
@@ -333,7 +355,7 @@ export default async function tripsRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
if (id === null) return;

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { requireAnyPermission, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { parseBody } from "../../schemas/common";
@@ -12,35 +12,52 @@ import {
export default async function vehiclesRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
const vehicles = await prisma.vehicles.findMany({
orderBy: { name: "asc" },
});
// Read is consumed both by the Vehicles management page (vehicles.manage)
// and by the trip-recording/history pages (trips.*), which pick a vehicle
// without holding vehicles.manage. Allow any of those — but no longer a
// bare authenticated user (that was an information-disclosure gap).
fastify.get(
"/",
{
preHandler: requireAnyPermission(
"vehicles.manage",
"trips.record",
"trips.manage",
"trips.history",
),
},
async (_request, reply) => {
const vehicles = await prisma.vehicles.findMany({
orderBy: { name: "asc" },
});
// Compute current_km and trip_count from trips table
const tripStats = await prisma.trips.groupBy({
by: ["vehicle_id"],
_max: { end_km: true },
_count: { id: true },
});
const statsMap = new Map(
tripStats.map((s) => [
s.vehicle_id,
{ maxKm: s._max.end_km ?? 0, count: s._count.id },
]),
);
// Compute current_km and trip_count from trips table
const tripStats = await prisma.trips.groupBy({
by: ["vehicle_id"],
_max: { end_km: true },
_count: { id: true },
});
const statsMap = new Map(
tripStats.map((s) => [
s.vehicle_id,
{ maxKm: s._max.end_km ?? 0, count: s._count.id },
]),
);
const enriched = vehicles.map((v) => {
const stats = statsMap.get(v.id);
return {
...v,
current_km: stats ? Math.max(v.initial_km, stats.maxKm) : v.initial_km,
trip_count: stats?.count ?? 0,
};
});
const enriched = vehicles.map((v) => {
const stats = statsMap.get(v.id);
return {
...v,
current_km: stats
? Math.max(v.initial_km, stats.maxKm)
: v.initial_km,
trip_count: stats?.count ?? 0,
};
});
return success(reply, enriched);
});
return success(reply, enriched);
},
);
fastify.post(
"/",

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),
);
},
);
}