security: fix all Critical and High findings from FLAWS_REPORT audit
- Auth: pessimistic locking on login tokens and refresh token rotation, backup code attempt counter, rate limiting verification - Schema: unique constraints on business numbers, FK relations, unsigned/signed alignment, attendance duplicate prevention - Invoices/PDFs: DOMPurify sanitization, bounded queries in stats and alerts, VAT rounding, Puppeteer error handling - Orders/Offers: transactional parent+child creation, Zod NaN refinement, status enums, uniqueness checks - Projects/Files: path traversal protection, streamed uploads, permission guards, query param validation - Attendance/HR: duplicate checks, ownership validation, GPS restrictions, trip distance validation - Frontend: modal lock reference counting, XSS escaping in print HTML, ref mutation fixes, accessibility attributes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -100,6 +100,9 @@ export default async function attendanceRoutes(
|
||||
|
||||
// --- action=balances: leave balance overview for all users ---
|
||||
if (action === "balances") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getBalances(yr);
|
||||
return reply.send({ success: true, data });
|
||||
@@ -107,6 +110,9 @@ export default async function attendanceRoutes(
|
||||
|
||||
// --- action=workfund: monthly work fund overview ---
|
||||
if (action === "workfund") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getWorkfund(yr);
|
||||
return reply.send({ success: true, data });
|
||||
@@ -114,6 +120,9 @@ export default async function attendanceRoutes(
|
||||
|
||||
// --- action=project_report: monthly project hours ---
|
||||
if (action === "project_report") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getProjectReport(yr);
|
||||
return reply.send({ success: true, data });
|
||||
@@ -185,6 +194,10 @@ export default async function attendanceRoutes(
|
||||
if (!id) return error(reply, "Missing id", 400);
|
||||
const record = await attendanceService.getLocationRecord(id);
|
||||
if (!record) return error(reply, "Záznam nenalezen", 404);
|
||||
const isAdmin = authData.permissions.includes("attendance.admin");
|
||||
if (record.user_id !== authData.userId && !isAdmin) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
return reply.send({ success: true, data: record });
|
||||
}
|
||||
|
||||
@@ -294,6 +307,14 @@ export default async function attendanceRoutes(
|
||||
if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400);
|
||||
const leaveBody = leaveParsed.data;
|
||||
|
||||
if (
|
||||
leaveBody.user_id != null &&
|
||||
leaveBody.user_id !== authData.userId &&
|
||||
!authData.permissions.includes("attendance.admin")
|
||||
) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
|
||||
const result = await attendanceService.createLeave(
|
||||
{
|
||||
user_id: leaveBody.user_id,
|
||||
@@ -342,6 +363,14 @@ export default async function attendanceRoutes(
|
||||
if ("error" in stdParsed) return error(reply, stdParsed.error, 400);
|
||||
const body = stdParsed.data;
|
||||
|
||||
if (
|
||||
body.user_id != null &&
|
||||
body.user_id !== authData.userId &&
|
||||
!authData.permissions.includes("attendance.admin")
|
||||
) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
|
||||
const result = await attendanceService.createAttendance(
|
||||
{
|
||||
user_id: body.user_id,
|
||||
@@ -364,6 +393,8 @@ export default async function attendanceRoutes(
|
||||
},
|
||||
authData.userId,
|
||||
);
|
||||
if ("error" in result)
|
||||
return error(reply, result.error!, result.status ?? 400);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
@@ -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, paginated, error } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
|
||||
@@ -53,6 +54,13 @@ export default async function auditLogRoutes(
|
||||
// days === 0 means "delete all" (from frontend "Vše" option)
|
||||
if (days === 0 || body.action === "all") {
|
||||
const result = await prisma.audit_logs.deleteMany({});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "audit_logs",
|
||||
description: `Uživatel ${request.authData?.username ?? "unknown"} smazal všechny audit logy, počet: ${result.count}`,
|
||||
});
|
||||
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
|
||||
}
|
||||
|
||||
@@ -62,6 +70,13 @@ export default async function auditLogRoutes(
|
||||
const result = await prisma.audit_logs.deleteMany({
|
||||
where: { created_at: { lt: cutoff } },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "audit_logs",
|
||||
description: `Uživatel ${request.authData?.username ?? "unknown"} smazal audit logy starší než ${days} dní, počet: ${result.count}`,
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
null,
|
||||
|
||||
@@ -92,7 +92,15 @@ export default async function authRoutes(
|
||||
// POST /api/admin/login/totp
|
||||
fastify.post<{ Body: TotpVerifyRequest }>(
|
||||
"/login/totp",
|
||||
{ bodyLimit: 10240 },
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 20,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpVerifySchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
@@ -106,20 +114,42 @@ export default async function authRoutes(
|
||||
.update(login_token)
|
||||
.digest("hex");
|
||||
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
const totpResult = await prisma.$transaction(async (tx) => {
|
||||
const tokens = await tx.$queryRaw<
|
||||
Array<{ id: number; user_id: number; expires_at: Date }>
|
||||
>`
|
||||
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
|
||||
`;
|
||||
const storedToken = tokens[0] ?? null;
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return { error: "Neplatný nebo expirovaný login token", status: 401 };
|
||||
}
|
||||
|
||||
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
const user = await tx.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_secret) {
|
||||
return { error: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
return { error: "Účet je dočasně uzamčen", status: 429 };
|
||||
}
|
||||
|
||||
return { user };
|
||||
});
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, "Neplatný nebo expirovaný login token", 401);
|
||||
if ("error" in totpResult) {
|
||||
return error(reply, totpResult.error!, totpResult.status!);
|
||||
}
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_secret) {
|
||||
const user = totpResult.user;
|
||||
if (!user.totp_secret) {
|
||||
return error(reply, "Uživatel nenalezen", 401);
|
||||
}
|
||||
|
||||
@@ -128,8 +158,6 @@ export default async function authRoutes(
|
||||
return error(reply, "Neplatný TOTP kód", 401);
|
||||
}
|
||||
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
@@ -186,31 +214,43 @@ export default async function authRoutes(
|
||||
);
|
||||
|
||||
// POST /api/admin/refresh
|
||||
fastify.post("/refresh", { bodyLimit: 10240 }, async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (!refreshTokenRaw) {
|
||||
return error(reply, "Refresh token chybí", 401);
|
||||
}
|
||||
fastify.post(
|
||||
"/refresh",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (!refreshTokenRaw) {
|
||||
return error(reply, "Refresh token chybí", 401);
|
||||
}
|
||||
|
||||
const result = await refreshAccessToken(refreshTokenRaw, request);
|
||||
const result = await refreshAccessToken(refreshTokenRaw, request);
|
||||
|
||||
if (result.type === "error") {
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
if (result.type === "error") {
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
|
||||
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
|
||||
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/logout
|
||||
fastify.post("/logout", async (request, reply) => {
|
||||
|
||||
@@ -74,6 +74,13 @@ export default async function companySettingsRoutes(
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (
|
||||
buf[0] === 0x52 &&
|
||||
buf[1] === 0x49 &&
|
||||
buf[8] === 0x57 &&
|
||||
buf[9] === 0x45
|
||||
)
|
||||
mime = "image/webp";
|
||||
|
||||
return reply
|
||||
.type(mime)
|
||||
@@ -324,15 +331,12 @@ export default async function companySettingsRoutes(
|
||||
nas: {
|
||||
projects: {
|
||||
configured: projectNas.isConfigured(),
|
||||
path: config.nas.path || "—",
|
||||
},
|
||||
financials: {
|
||||
configured: nasFinancialsManager.isConfigured(),
|
||||
path: config.nas.financialsPath || "—",
|
||||
},
|
||||
offers: {
|
||||
configured: nasOffersManager.isConfigured(),
|
||||
path: config.nas.offersPath || "—",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,54 +56,58 @@ function decodeCustomFields(raw: string | null): {
|
||||
export default async function customersRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(
|
||||
request.query as Record<string, unknown>,
|
||||
);
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
|
||||
|
||||
const where = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ company_id: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.customers.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
include: { _count: { select: { quotations: true } } },
|
||||
}),
|
||||
prisma.customers.count({ where }),
|
||||
]);
|
||||
|
||||
const enriched = customers.map((c) => {
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(
|
||||
c.custom_fields,
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("customers.view") },
|
||||
async (request, reply) => {
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(
|
||||
request.query as Record<string, unknown>,
|
||||
);
|
||||
return {
|
||||
...c,
|
||||
custom_fields,
|
||||
customer_field_order,
|
||||
quotation_count: c._count?.quotations ?? 0,
|
||||
};
|
||||
});
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: enriched,
|
||||
pagination: buildPaginationMeta(total, page, limit),
|
||||
});
|
||||
});
|
||||
const where = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ company_id: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.customers.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
include: { _count: { select: { quotations: true } } },
|
||||
}),
|
||||
prisma.customers.count({ where }),
|
||||
]);
|
||||
|
||||
const enriched = customers.map((c) => {
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(
|
||||
c.custom_fields,
|
||||
);
|
||||
return {
|
||||
...c,
|
||||
custom_fields,
|
||||
customer_field_order,
|
||||
quotation_count: c._count?.quotations ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: enriched,
|
||||
pagination: buildPaginationMeta(total, page, limit),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
{ preHandler: requirePermission("customers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
@@ -7,6 +7,12 @@ 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 createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -50,6 +56,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
@@ -78,7 +85,12 @@ function buildAddressLines(
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData =
|
||||
@@ -250,185 +262,189 @@ export default async function invoicesPdfRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.export") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
const lang = query.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
try {
|
||||
const lang = query.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
if (!invoice) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const items = await prisma.invoice_items.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
customer = (await prisma.customers.findUnique({
|
||||
where: { id: invoice.customer_id },
|
||||
})) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
let orderNumber = "";
|
||||
let orderDate = "";
|
||||
if (invoice.order_id) {
|
||||
const orderRow = await prisma.orders.findUnique({
|
||||
where: { id: invoice.order_id },
|
||||
select: {
|
||||
order_number: true,
|
||||
customer_order_number: true,
|
||||
created_at: true,
|
||||
},
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (orderRow) {
|
||||
orderNumber = escapeHtml(
|
||||
String(
|
||||
orderRow.customer_order_number || orderRow.order_number || "",
|
||||
),
|
||||
);
|
||||
if (orderRow.created_at) {
|
||||
orderDate = formatDate(orderRow.created_at);
|
||||
|
||||
if (!invoice) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const items = await prisma.invoice_items.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
customer = (await prisma.customers.findUnique({
|
||||
where: { id: invoice.customer_id },
|
||||
})) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
let orderNumber = "";
|
||||
let orderDate = "";
|
||||
if (invoice.order_id) {
|
||||
const orderRow = await prisma.orders.findUnique({
|
||||
where: { id: invoice.order_id },
|
||||
select: {
|
||||
order_number: true,
|
||||
customer_order_number: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
if (orderRow) {
|
||||
orderNumber = escapeHtml(
|
||||
String(
|
||||
orderRow.customer_order_number || orderRow.order_number || "",
|
||||
),
|
||||
);
|
||||
if (orderRow.created_at) {
|
||||
orderDate = formatDate(orderRow.created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
||||
subtotal += lineSubtotal;
|
||||
const rate = Number(item.vat_rate);
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
if (applyVat) {
|
||||
vatSummary[key].vat += (lineSubtotal * rate) / 100;
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
}
|
||||
|
||||
let totalVat = 0;
|
||||
for (const data of Object.values(vatSummary)) {
|
||||
totalVat += data.vat;
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
// QR code - SPAYD payment format
|
||||
let qrSvg = "";
|
||||
try {
|
||||
const spaydParts = [
|
||||
"SPD*1.0",
|
||||
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
|
||||
"AM:" + totalToPay.toFixed(2),
|
||||
"CC:" + currency,
|
||||
"X-VS:" + (invoice.invoice_number || ""),
|
||||
"X-KS:" + (invoice.constant_symbol || "0308"),
|
||||
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
|
||||
];
|
||||
const spaydString = spaydParts.join("*");
|
||||
qrSvg = await QRCode.toString(spaydString, {
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 200,
|
||||
});
|
||||
} catch {
|
||||
// QR generation failed — leave empty
|
||||
}
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
// VAT recapitulation (always in CZK — Czech tax requirement)
|
||||
const isForeign = currency.toUpperCase() !== "CZK";
|
||||
const issueDateStr = invoice.issue_date
|
||||
? localDateStr(new Date(invoice.issue_date))
|
||||
: undefined;
|
||||
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{
|
||||
rate: number;
|
||||
base: number;
|
||||
vat: number;
|
||||
total: number;
|
||||
}> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
for (const item of items) {
|
||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
||||
subtotal += lineSubtotal;
|
||||
const rate = Number(item.vat_rate);
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
if (applyVat) {
|
||||
vatSummary[key].vat +=
|
||||
Math.round(((lineSubtotal * rate) / 100) * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
let totalVat = 0;
|
||||
for (const data of Object.values(vatSummary)) {
|
||||
totalVat += data.vat;
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
// QR code - SPAYD payment format
|
||||
let qrSvg = "";
|
||||
try {
|
||||
const spaydParts = [
|
||||
"SPD*1.0",
|
||||
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
|
||||
"AM:" + totalToPay.toFixed(2),
|
||||
"CC:" + currency,
|
||||
"X-VS:" + (invoice.invoice_number || ""),
|
||||
"X-KS:" + (invoice.constant_symbol || "0308"),
|
||||
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
|
||||
];
|
||||
const spaydString = spaydParts.join("*");
|
||||
qrSvg = await QRCode.toString(spaydString, {
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 200,
|
||||
});
|
||||
} catch {
|
||||
// QR generation failed — leave empty
|
||||
}
|
||||
|
||||
// Supplier email/web from custom_fields
|
||||
let suppEmail = "";
|
||||
if (settings?.custom_fields) {
|
||||
const raw = settings.custom_fields;
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const fields = (parsed as Record<string, unknown>).fields;
|
||||
if (Array.isArray(fields)) {
|
||||
for (const f of fields) {
|
||||
if (f.name && f.name.toLowerCase() === "email" && f.value) {
|
||||
suppEmail = String(f.value);
|
||||
// VAT recapitulation (always in CZK — Czech tax requirement)
|
||||
const isForeign = currency.toUpperCase() !== "CZK";
|
||||
const issueDateStr = invoice.issue_date
|
||||
? localDateStr(new Date(invoice.issue_date))
|
||||
: undefined;
|
||||
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{
|
||||
rate: number;
|
||||
base: number;
|
||||
vat: number;
|
||||
total: number;
|
||||
}> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
// Supplier email/web from custom_fields
|
||||
let suppEmail = "";
|
||||
if (settings?.custom_fields) {
|
||||
const raw = settings.custom_fields;
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const fields = (parsed as Record<string, unknown>).fields;
|
||||
if (Array.isArray(fields)) {
|
||||
for (const f of fields) {
|
||||
if (f.name && f.name.toLowerCase() === "email" && f.value) {
|
||||
suppEmail = String(f.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceNumber = escapeHtml(invoice.invoice_number);
|
||||
const invoiceNumber = escapeHtml(invoice.invoice_number);
|
||||
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
const unitPrice = Number(item.unit_price);
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const vatRate = Number(item.vat_rate);
|
||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
const unitPrice = Number(item.unit_price);
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const vatRate = Number(item.vat_rate);
|
||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
|
||||
return `<tr>
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||
@@ -438,55 +454,55 @@ export default async function invoicesPdfRoutes(
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const vatRecapHtml = vatRecap
|
||||
.map(
|
||||
(vr) => `<tr>
|
||||
const vatRecapHtml = vatRecap
|
||||
.map(
|
||||
(vr) => `<tr>
|
||||
<td class="right">${formatNum(vr.base)}</td>
|
||||
<td class="center">${Math.floor(vr.rate)}%</td>
|
||||
<td class="right">${formatNum(vr.vat)}</td>
|
||||
<td class="right">${formatNum(vr.total)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
)
|
||||
.join("");
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<!-- Poznamky -->
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -1000,33 +1016,36 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Save PDF to NAS
|
||||
if (nasFinancialsManager.isConfigured() && invoice.invoice_number) {
|
||||
const issueDate = invoice.issue_date
|
||||
? new Date(invoice.issue_date)
|
||||
: new Date();
|
||||
const saveMode = query.save === "1";
|
||||
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
|
||||
const pdfPromise = htmlToPdf(html)
|
||||
.then((pdfBuffer) => {
|
||||
nasFinancialsManager.saveIssuedInvoicePdf(
|
||||
invoice.invoice_number!,
|
||||
issueDate.getFullYear(),
|
||||
issueDate.getMonth() + 1,
|
||||
pdfBuffer,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
request.log.error(err, "Failed to save invoice PDF to NAS");
|
||||
});
|
||||
|
||||
if (saveMode) {
|
||||
await pdfPromise;
|
||||
// Save PDF to NAS
|
||||
if (
|
||||
saveMode &&
|
||||
nasFinancialsManager.isConfigured() &&
|
||||
invoice.invoice_number
|
||||
) {
|
||||
const issueDate = invoice.issue_date
|
||||
? new Date(invoice.issue_date)
|
||||
: new Date();
|
||||
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
nasFinancialsManager.saveIssuedInvoicePdf(
|
||||
invoice.invoice_number!,
|
||||
issueDate.getFullYear(),
|
||||
issueDate.getMonth() + 1,
|
||||
pdfBuffer,
|
||||
);
|
||||
return reply.send({ success: true, message: "PDF uloženo" });
|
||||
}
|
||||
}
|
||||
|
||||
return reply.type("text/html").send(html);
|
||||
return reply.type("text/html").send(html);
|
||||
} catch (err) {
|
||||
request.log.error(err, "PDF generation failed");
|
||||
return reply
|
||||
.status(500)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,10 +191,8 @@ export default async function invoicesRoutes(
|
||||
if (!existing) return error(reply, "Faktura nenalezena", 404);
|
||||
|
||||
// Delete PDF from NAS
|
||||
if (existing.invoice_number && existing.issue_date) {
|
||||
const d = new Date(existing.issue_date);
|
||||
const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${existing.invoice_number}.pdf`;
|
||||
nasFinancialsManager.deleteIssuedInvoice(relPath);
|
||||
if (existing.invoice_number) {
|
||||
await nasFinancialsManager.cleanIssuedInvoice(existing.invoice_number);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -241,6 +241,19 @@ export default async function leaveRequestsRoutes(
|
||||
|
||||
const totalHours = totalBusinessDays * 8;
|
||||
|
||||
for (const ac of attendanceCreates) {
|
||||
const duplicate = await prisma.attendance.findFirst({
|
||||
where: { user_id: ac.user_id, shift_date: ac.shift_date },
|
||||
});
|
||||
if (duplicate) {
|
||||
return error(
|
||||
reply,
|
||||
"Pro zvolené datumy již existují záznamy docházky",
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Create attendance records for each business day
|
||||
if (attendanceCreates.length > 0) {
|
||||
@@ -331,6 +344,7 @@ export default async function leaveRequestsRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.leave_requests.findUnique({
|
||||
@@ -342,6 +356,10 @@ export default async function leaveRequestsRoutes(
|
||||
return error(reply, "Lze zrušit pouze čekající žádosti", 400);
|
||||
}
|
||||
|
||||
if (existing.user_id !== authData.userId) {
|
||||
return error(reply, "Nemáte oprávnění zrušit tuto žádost", 403);
|
||||
}
|
||||
|
||||
await prisma.leave_requests.update({
|
||||
where: { id },
|
||||
data: { status: "cancelled" },
|
||||
|
||||
@@ -4,6 +4,12 @@ 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 createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
@@ -73,6 +79,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
// Replace with regular space (outside of tags)
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
@@ -102,7 +109,12 @@ function buildAddressLines(
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData =
|
||||
@@ -201,7 +213,8 @@ export default async function offersPdfRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const query = request.query as Record<string, string>;
|
||||
|
||||
try {
|
||||
@@ -349,7 +362,7 @@ export default async function offersPdfRoutes(
|
||||
if (title)
|
||||
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
scopeHtml += "</div>";
|
||||
@@ -761,28 +774,24 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const saveMode = query.save === "1";
|
||||
|
||||
// Save PDF to NAS
|
||||
if (nasOffersManager.isConfigured() && quotation.quotation_number) {
|
||||
if (
|
||||
saveMode &&
|
||||
nasOffersManager.isConfigured() &&
|
||||
quotation.quotation_number
|
||||
) {
|
||||
const created = quotation.created_at
|
||||
? new Date(quotation.created_at)
|
||||
: new Date();
|
||||
const saveMode = query.save === "1";
|
||||
const pdfPromise = htmlToPdf(html)
|
||||
.then((pdfBuffer) => {
|
||||
nasOffersManager.saveOfferPdf(
|
||||
quotation.quotation_number!,
|
||||
created.getFullYear(),
|
||||
pdfBuffer,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
request.log.error(err, "Failed to save offer PDF to NAS");
|
||||
});
|
||||
|
||||
if (saveMode) {
|
||||
await pdfPromise;
|
||||
return reply.send({ success: true, message: "PDF uloženo" });
|
||||
}
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
nasOffersManager.saveOfferPdf(
|
||||
quotation.quotation_number!,
|
||||
created.getFullYear(),
|
||||
pdfBuffer,
|
||||
);
|
||||
return reply.send({ success: true, message: "PDF uloženo" });
|
||||
}
|
||||
|
||||
return reply.type("text/html").send(html);
|
||||
|
||||
@@ -3,6 +3,31 @@ import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId, error } from "../../utils/response";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { z } from "zod";
|
||||
import createDOMPurify from "dompurify";
|
||||
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();
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -46,6 +71,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
@@ -74,7 +100,12 @@ function buildAddressLines(
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData =
|
||||
@@ -213,129 +244,133 @@ export default async function ordersPdfRoutes(
|
||||
"/:id/confirmation",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const body = request.body || {};
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
const currency = order.currency || "CZK";
|
||||
const applyVat =
|
||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
||||
const orderVatRate = Number(order.vat_rate) || 21;
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
// Use custom items from body if provided, otherwise order items
|
||||
const customItemsRaw = body.items;
|
||||
let items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}> = [];
|
||||
const currency = order.currency || "CZK";
|
||||
const applyVat =
|
||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
||||
const orderVatRate = Number(order.vat_rate) || 21;
|
||||
|
||||
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it: Record<string, unknown>) => ({
|
||||
description: String(it.description || ""),
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: String(it.unit || ""),
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total:
|
||||
it.is_included_in_total !== false && it.is_included_in_total !== 0,
|
||||
vat_rate: Number(it.vat_rate) || orderVatRate,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
vat_rate: orderVatRate,
|
||||
}));
|
||||
}
|
||||
// Use custom items from body if provided, otherwise order items
|
||||
const customItemsRaw = body.items;
|
||||
let items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}> = [];
|
||||
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
subtotal += lineTotal;
|
||||
const rate = item.vat_rate;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineTotal;
|
||||
if (applyVat) {
|
||||
const lineVat = (lineTotal * rate) / 100;
|
||||
vatSummary[key].vat += lineVat;
|
||||
totalVat += lineVat;
|
||||
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
vat_rate: it.vat_rate,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
vat_rate: orderVatRate,
|
||||
}));
|
||||
}
|
||||
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
subtotal += lineTotal;
|
||||
const rate = item.vat_rate;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineTotal;
|
||||
if (applyVat) {
|
||||
const lineVat = (lineTotal * rate) / 100;
|
||||
vatSummary[key].vat += lineVat;
|
||||
totalVat += lineVat;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineSubtotal = item.quantity * item.unit_price;
|
||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals =
|
||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
return `<tr>
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineSubtotal = item.quantity * item.unit_price;
|
||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals =
|
||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||
@@ -345,45 +380,45 @@ export default async function ordersPdfRoutes(
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
|
||||
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -846,13 +881,20 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
.send(pdfBuffer);
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
.send(pdfBuffer);
|
||||
} catch (err) {
|
||||
request.log.error(err, "PDF generation failed");
|
||||
return reply
|
||||
.status(500)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,12 +88,10 @@ export default async function ordersRoutes(
|
||||
const attachment = await getOrderAttachment(id);
|
||||
if (!attachment) return error(reply, "Příloha nenalezena", 404);
|
||||
|
||||
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${attachment.filename}"`,
|
||||
)
|
||||
.header("Content-Disposition", `inline; filename="${safeFilename}"`)
|
||||
.send(attachment.data);
|
||||
},
|
||||
);
|
||||
@@ -209,6 +207,7 @@ export default async function ordersRoutes(
|
||||
const body = manualParsed.data;
|
||||
|
||||
const result = await createOrder(body);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
@@ -75,6 +75,14 @@ export default async function profileRoutes(
|
||||
}
|
||||
|
||||
await prisma.users.update({ where: { id: userId }, data });
|
||||
|
||||
if (body.current_password && body.new_password) {
|
||||
await prisma.refresh_tokens.updateMany({
|
||||
where: { user_id: userId, replaced_at: null },
|
||||
data: { replaced_at: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
return success(reply, null, 200, "Profil aktualizován");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "fs";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { z } from "zod";
|
||||
import prisma from "../../config/database";
|
||||
import { config } from "../../config/env";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
@@ -8,6 +9,28 @@ import { logAudit } from "../../services/audit";
|
||||
import { success, error } from "../../utils/response";
|
||||
import { NasFileManager } from "../../services/nas-file-manager";
|
||||
|
||||
const ProjectFilesQuerySchema = z.object({
|
||||
project_id: z.string().min(1, "project_id je povinný"),
|
||||
path: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
});
|
||||
|
||||
function parseProjectFilesQuery(
|
||||
query: unknown,
|
||||
):
|
||||
| { data: { project_id: string; path?: string; action?: string } }
|
||||
| { error: string } {
|
||||
try {
|
||||
const data = ProjectFilesQuerySchema.parse(query);
|
||||
return { data };
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
return { error: e.issues.map((err) => err.message).join(", ") };
|
||||
}
|
||||
return { error: "Neplatné parametry dotazu" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function projectFilesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
@@ -30,8 +53,10 @@ export default async function projectFilesRoutes(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const { project_id: projectIdStr, path: subPath = "" } = parsedQuery.data;
|
||||
const projectId = Number(projectIdStr);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
|
||||
@@ -39,9 +64,7 @@ export default async function projectFilesRoutes(
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const subPath = query.path || "";
|
||||
|
||||
if (query.action === "download") {
|
||||
if (parsedQuery.data.action === "download") {
|
||||
if (!subPath) return error(reply, "Cesta k souboru je povinná");
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
@@ -81,8 +104,9 @@ export default async function projectFilesRoutes(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const projectId = Number(parsedQuery.data.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
@@ -105,7 +129,11 @@ export default async function projectFilesRoutes(
|
||||
fm.createProjectFolder(project.project_number, project.name || "");
|
||||
}
|
||||
|
||||
const err = fm.createFolder(project.project_number, path, folderName);
|
||||
const err = await fm.createFolder(
|
||||
project.project_number,
|
||||
path,
|
||||
folderName,
|
||||
);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
@@ -130,8 +158,9 @@ export default async function projectFilesRoutes(
|
||||
bodyLimit: config.nas.maxUploadSize,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const projectId = Number(parsedQuery.data.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
@@ -149,14 +178,13 @@ export default async function projectFilesRoutes(
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, "Nebyl nahrán žádný soubor");
|
||||
|
||||
const subPath = query.path || "";
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const subPath = parsedQuery.data.path || "";
|
||||
const fileName = file.filename;
|
||||
|
||||
const err = await fm.uploadFile(
|
||||
project.project_number,
|
||||
subPath,
|
||||
fileBuffer,
|
||||
file.file,
|
||||
fileName,
|
||||
);
|
||||
if (err !== null) return error(reply, err);
|
||||
@@ -180,8 +208,9 @@ export default async function projectFilesRoutes(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const projectId = Number(parsedQuery.data.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
@@ -198,7 +227,7 @@ export default async function projectFilesRoutes(
|
||||
if (!fromPath || !toPath)
|
||||
return error(reply, "Zdrojová i cílová cesta jsou povinné");
|
||||
|
||||
const err = fm.moveItem(project.project_number, fromPath, toPath);
|
||||
const err = await fm.moveItem(project.project_number, fromPath, toPath);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
@@ -221,8 +250,9 @@ export default async function projectFilesRoutes(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
|
||||
const projectId = Number(parsedQuery.data.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
@@ -232,7 +262,7 @@ export default async function projectFilesRoutes(
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const filePath = query.path || "";
|
||||
const filePath = parsedQuery.data.path || "";
|
||||
if (!filePath) return error(reply, "Cesta k souboru je povinná");
|
||||
|
||||
const err = await fm.deleteItem(project.project_number, filePath);
|
||||
|
||||
@@ -69,6 +69,9 @@ export default async function projectsRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const project = await createProject(parsed.data);
|
||||
if ("error" in project) {
|
||||
return error(reply, project.error, (project as any).status ?? 400);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
@@ -245,6 +245,8 @@ export default async function quotationsRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const quotation = await createOffer(parsed.data);
|
||||
if ("error" in quotation)
|
||||
return error(reply, quotation.error!, quotation.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -312,9 +314,13 @@ export default async function quotationsRoutes(
|
||||
// Delete PDF from NAS
|
||||
if (existing.quotation_number && existing.created_at) {
|
||||
const yr = new Date(existing.created_at).getFullYear();
|
||||
nasOffersManager.deleteOfferPdf(
|
||||
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
|
||||
);
|
||||
try {
|
||||
await nasOffersManager.deleteOfferPdf(
|
||||
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
|
||||
);
|
||||
} catch {
|
||||
// Non-fatal: NAS delete may fail if file does not exist
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -111,13 +111,15 @@ export default async function rolesRoutes(
|
||||
});
|
||||
|
||||
if (Array.isArray(body.permission_ids)) {
|
||||
await prisma.role_permissions.deleteMany({ where: { role_id: id } });
|
||||
await prisma.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
});
|
||||
await prisma.$transaction([
|
||||
prisma.role_permissions.deleteMany({ where: { role_id: id } }),
|
||||
prisma.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -54,6 +54,39 @@ export default async function totpRoutes(
|
||||
return error(reply, "Secret a kód jsou povinné", 400);
|
||||
}
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: request.authData!.userId },
|
||||
});
|
||||
if (!user) return error(reply, "Uživatel nenalezen", 404);
|
||||
|
||||
if (user.totp_enabled) {
|
||||
if (!body.current_code) {
|
||||
return error(
|
||||
reply,
|
||||
"Aktuální TOTP kód je povinný pro změnu 2FA",
|
||||
400,
|
||||
);
|
||||
}
|
||||
const isValid = OTPAuth.verify(
|
||||
user.totp_secret!,
|
||||
String(body.current_code),
|
||||
);
|
||||
if (!isValid) {
|
||||
return error(reply, "Neplatný aktuální TOTP kód", 400);
|
||||
}
|
||||
} else {
|
||||
if (!body.password) {
|
||||
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
|
||||
}
|
||||
const valid = await bcrypt.compare(
|
||||
String(body.password),
|
||||
user.password_hash,
|
||||
);
|
||||
if (!valid) {
|
||||
return error(reply, "Nesprávné heslo", 400);
|
||||
}
|
||||
}
|
||||
|
||||
const totp = new OTPAuthLib.TOTP({
|
||||
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
|
||||
algorithm: "SHA1",
|
||||
@@ -185,10 +218,26 @@ export default async function totpRoutes(
|
||||
|
||||
const required =
|
||||
body.required === true || body.required === 1 || body.required === "1";
|
||||
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { require_2fa: true },
|
||||
});
|
||||
const oldValue = settings?.require_2fa ?? false;
|
||||
|
||||
await prisma.company_settings.updateMany({
|
||||
data: { require_2fa: required },
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "company_settings",
|
||||
description: `Povinné 2FA změněno z ${oldValue ? "zapnuto" : "vypnuto"} na ${required ? "zapnuto" : "vypnuto"}`,
|
||||
oldValues: { require_2fa: oldValue },
|
||||
newValues: { require_2fa: required },
|
||||
});
|
||||
|
||||
const message = required
|
||||
? "2FA je nyní povinné pro všechny uživatele"
|
||||
: "2FA již není povinné";
|
||||
@@ -200,7 +249,15 @@ export default async function totpRoutes(
|
||||
// POST - verify backup code (pre-auth, no requireAuth)
|
||||
fastify.post(
|
||||
"/backup-verify",
|
||||
{ bodyLimit: 10240 },
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 5,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpBackupSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
@@ -211,52 +268,95 @@ export default async function totpRoutes(
|
||||
.update(login_token)
|
||||
.digest("hex");
|
||||
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
const settings = await getSystemSettings();
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, "Neplatný nebo expirovaný login token", 401);
|
||||
}
|
||||
const txResult = await prisma.$transaction(async (tx) => {
|
||||
const tokens = await tx.$queryRaw<
|
||||
Array<{ id: number; user_id: number; expires_at: Date }>
|
||||
>`
|
||||
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
|
||||
`;
|
||||
const storedToken = tokens[0] ?? null;
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_backup_codes) {
|
||||
return error(reply, "Uživatel nenalezen", 401);
|
||||
}
|
||||
|
||||
const backupCodes: string[] = JSON.parse(
|
||||
user.totp_backup_codes as string,
|
||||
);
|
||||
let matchIndex = -1;
|
||||
|
||||
for (let i = 0; i < backupCodes.length; i++) {
|
||||
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
|
||||
if (isMatch) {
|
||||
matchIndex = i;
|
||||
break;
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return { error: "Neplatný nebo expirovaný login token", status: 401 };
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return error(reply, "Neplatný záložní kód", 401);
|
||||
}
|
||||
const user = await tx.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
backupCodes.splice(matchIndex, 1);
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totp_backup_codes: JSON.stringify(backupCodes),
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
last_login: new Date(),
|
||||
},
|
||||
if (!user || !user.totp_backup_codes) {
|
||||
return { error: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return { error: "Účet je deaktivován", status: 401 };
|
||||
}
|
||||
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
return { error: "Účet je dočasně uzamčen", status: 429 };
|
||||
}
|
||||
|
||||
const backupCodes: string[] = JSON.parse(
|
||||
user.totp_backup_codes as string,
|
||||
);
|
||||
let matchIndex = -1;
|
||||
|
||||
for (let i = 0; i < backupCodes.length; i++) {
|
||||
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
|
||||
if (isMatch) {
|
||||
matchIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex === -1) {
|
||||
const newFailedAttempts = (user.failed_login_attempts ?? 0) + 1;
|
||||
if (newFailedAttempts >= settings.max_login_attempts) {
|
||||
await tx.totp_login_tokens.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failed_login_attempts: newFailedAttempts,
|
||||
locked_until: new Date(
|
||||
Date.now() + settings.lockout_minutes * 60_000,
|
||||
),
|
||||
},
|
||||
});
|
||||
return { error: "Účet je dočasně uzamčen", status: 429 };
|
||||
}
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: { failed_login_attempts: newFailedAttempts },
|
||||
});
|
||||
return { error: "Neplatný záložní kód", status: 401 };
|
||||
}
|
||||
|
||||
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
backupCodes.splice(matchIndex, 1);
|
||||
await tx.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totp_backup_codes: JSON.stringify(backupCodes),
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
last_login: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { user };
|
||||
});
|
||||
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
if ("error" in txResult) {
|
||||
return error(reply, txResult.error!, txResult.status!);
|
||||
}
|
||||
|
||||
const user = txResult.user;
|
||||
|
||||
// Create tokens (same as /login/totp flow)
|
||||
const { loadAuthData } = await import("../../services/auth");
|
||||
|
||||
@@ -206,6 +206,10 @@ export default async function tripsRoutes(
|
||||
const body = parsed.data;
|
||||
const authData = request.authData!;
|
||||
|
||||
if (body.end_km < body.start_km) {
|
||||
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
|
||||
}
|
||||
|
||||
const trip = await prisma.trips.create({
|
||||
data: {
|
||||
vehicle_id: Number(body.vehicle_id),
|
||||
@@ -247,6 +251,18 @@ export default async function tripsRoutes(
|
||||
const body = parsed.data;
|
||||
const authData = request.authData!;
|
||||
|
||||
if (
|
||||
body.end_km != null &&
|
||||
body.start_km != null &&
|
||||
body.end_km < body.start_km
|
||||
) {
|
||||
return error(
|
||||
reply,
|
||||
"Konečný stav km nesmí být menší než počáteční",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.trips.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Jízda nenalezena", 404);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -59,15 +60,18 @@ export default async function usersRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const result = await createUser({
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
role_id: body.role_id,
|
||||
is_active: body.is_active,
|
||||
});
|
||||
const result = await createUser(
|
||||
{
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
role_id: body.role_id,
|
||||
is_active: body.is_active,
|
||||
},
|
||||
request.authData?.roleName ?? undefined,
|
||||
);
|
||||
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
@@ -106,9 +110,20 @@ export default async function usersRoutes(
|
||||
? Number(parsed.data.role_id)
|
||||
: (parsed.data.role_id as number | null | undefined),
|
||||
};
|
||||
const result = await updateUser(id, userData);
|
||||
const result = await updateUser(
|
||||
id,
|
||||
userData,
|
||||
request.authData?.roleName ?? undefined,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
if (parsed.data.password) {
|
||||
await prisma.refresh_tokens.updateMany({
|
||||
where: { user_id: id, replaced_at: null },
|
||||
data: { replaced_at: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
|
||||
Reference in New Issue
Block a user