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:
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 with regular space (outside of tags)
|
||||
s = s.replace(/( )/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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
"/",
|
||||
|
||||
@@ -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