From aa6c1b5094ade5ade345ac4b164437ef81179d58 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 24 Apr 2026 08:45:37 +0200 Subject: [PATCH] refactor: fix all Low findings from FLAWS_REPORT audit - Auth: TOTP params from config, JWT error logging, audit log failure logging, replaced_by_hash validation on token rotation - Invoices: remove dead VAT code, consistent PDF permissions, WebP magic-byte detection, deduped exchange-rate fetches - Orders/Offers: multipart limit from config, use paginated() helper, payment method from DB in PDF - Projects: verify project exists before creating note - Attendance: action_type enum validation, consistent local-time shift_date construction, holiday attendance in work fund, trips.view permission on last-km query - Users: paginated() helper usage, remove duplicate dashboard keys, parallel currency conversion, single hashToken implementation - Frontend: memoized customInput, reliable print onload, modal prop standardization (isOpen), ConfirmModal type icons, id===0 key fallback, Login useCallback, CompanySettings ConfirmModal, Attendance timeout cleanup, Dashboard memoization, beforeunload dirty-state warnings on Invoice/Offer/Order detail - Schema: invoice_alert_log timestamp, config/env comment on Date.prototype.toJSON override - Utils: exchange-rate inflight dedup Co-Authored-By: Claude Opus 4.7 --- prisma/schema.prisma | 1 + src/admin/components/AdminDatePicker.tsx | 15 ++- src/admin/components/AttendanceShiftTable.tsx | 8 +- src/admin/components/BulkAttendanceModal.tsx | 8 +- src/admin/components/ConfirmModal.tsx | 78 ++++++++++-- src/admin/components/ShiftFormModal.tsx | 8 +- src/admin/hooks/useAttendanceAdmin.ts | 4 +- src/admin/pages/Attendance.tsx | 32 ++--- src/admin/pages/AttendanceAdmin.tsx | 6 +- src/admin/pages/AttendanceCreate.tsx | 33 ++--- src/admin/pages/CompanySettings.tsx | 37 +++++- src/admin/pages/Dashboard.tsx | 4 +- src/admin/pages/InvoiceDetail.tsx | 30 +++++ src/admin/pages/Login.tsx | 119 +++++++++++------- src/admin/pages/OfferDetail.tsx | 39 ++++++ src/admin/pages/OrderDetail.tsx | 29 +++++ src/config/env.ts | 9 ++ src/routes/admin/company-settings.ts | 6 +- src/routes/admin/dashboard.ts | 31 ++--- src/routes/admin/invoices-pdf.ts | 2 +- src/routes/admin/orders-pdf.ts | 4 +- src/routes/admin/orders.ts | 21 ++-- src/routes/admin/projects.ts | 3 + src/routes/admin/quotations.ts | 12 +- src/routes/admin/sessions.ts | 6 +- src/routes/admin/trips.ts | 2 +- src/routes/admin/users.ts | 16 +-- src/schemas/attendance.schema.ts | 2 +- src/services/attendance.service.ts | 23 ++-- src/services/audit.ts | 12 +- src/services/auth.ts | 11 +- src/services/exchange-rates.ts | 41 +++--- src/services/invoices.service.ts | 3 - src/services/projects.service.ts | 8 ++ src/utils/totp.ts | 9 +- 35 files changed, 466 insertions(+), 206 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f7be420..7e304a7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -630,6 +630,7 @@ model invoice_alert_log { invoice_id Int alert_type String @db.VarChar(20) // "3days" or "due" sent_at DateTime @default(now()) @db.DateTime(0) + created_at DateTime @default(now()) @db.DateTime(0) @@unique([invoice_type, invoice_id, alert_type]) } diff --git a/src/admin/components/AdminDatePicker.tsx b/src/admin/components/AdminDatePicker.tsx index 8912d8e..7b0b459 100644 --- a/src/admin/components/AdminDatePicker.tsx +++ b/src/admin/components/AdminDatePicker.tsx @@ -165,17 +165,22 @@ export default function AdminDatePicker({ return undefined; }; - const commonProps = { - selected: toDate(value), - onChange: handleChange, - locale: "cs", - customInput: ( + const customInput = useMemo( + () => ( ), + [required, placeholder, disabled], + ); + + const commonProps = { + selected: toDate(value), + onChange: handleChange, + locale: "cs", + customInput, minDate: parseMinMax(minDate), maxDate: parseMinMax(maxDate), popperPlacement: "bottom-start" as const, diff --git a/src/admin/components/AttendanceShiftTable.tsx b/src/admin/components/AttendanceShiftTable.tsx index 2ecc9ae..33a244e 100644 --- a/src/admin/components/AttendanceShiftTable.tsx +++ b/src/admin/components/AttendanceShiftTable.tsx @@ -87,7 +87,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode { } return ( - {log.project_name || `#${log.project_id}`} {durationValid ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})` : "—"} + {log.project_name || `#${log.project_id}`}{" "} + {durationValid + ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})` + : "—"} ); })} @@ -257,4 +260,3 @@ export default function AttendanceShiftTable({ ); } - diff --git a/src/admin/components/BulkAttendanceModal.tsx b/src/admin/components/BulkAttendanceModal.tsx index 31f15c8..ce98657 100644 --- a/src/admin/components/BulkAttendanceModal.tsx +++ b/src/admin/components/BulkAttendanceModal.tsx @@ -17,7 +17,7 @@ interface BulkAttendanceUser { } interface BulkAttendanceModalProps { - show: boolean; + isOpen: boolean; onClose: () => void; form: BulkAttendanceForm; setForm: (form: BulkAttendanceForm) => void; @@ -29,7 +29,7 @@ interface BulkAttendanceModalProps { } export default function BulkAttendanceModal({ - show, + isOpen, onClose, form, setForm, @@ -39,11 +39,11 @@ export default function BulkAttendanceModal({ toggleUser, toggleAllUsers, }: BulkAttendanceModalProps) { - useModalLock(show); + useModalLock(isOpen); return ( - {show && ( + {isOpen && ( + + + + + ); + case "info": + return ( + + + + + + ); + case "warning": + return ( + + + + + + ); + default: + return ( + + + + + + ); + } +} + export default function ConfirmModal({ isOpen, onClose, @@ -49,18 +114,7 @@ export default function ConfirmModal({ >
- - - - - +

{title} diff --git a/src/admin/components/ShiftFormModal.tsx b/src/admin/components/ShiftFormModal.tsx index 3990dea..fed35b1 100644 --- a/src/admin/components/ShiftFormModal.tsx +++ b/src/admin/components/ShiftFormModal.tsx @@ -68,7 +68,7 @@ interface ProjectLogRowProps { export interface ShiftFormModalProps { mode: "create" | "edit"; - show: boolean; + isOpen: boolean; onClose: () => void; onSubmit: () => void; form: ShiftFormData; @@ -196,7 +196,7 @@ function ProjectLogRow({ export default function ShiftFormModal({ mode, - show, + isOpen, onClose, onSubmit, form, @@ -208,7 +208,7 @@ export default function ShiftFormModal({ onShiftDateChange, editingRecord, }: ShiftFormModalProps) { - useModalLock(show); + useModalLock(isOpen); const isCreate = mode === "create"; const isWorkType = form.leave_type === "work"; @@ -240,7 +240,7 @@ export default function ShiftFormModal({ return ( - {show && ( + {isOpen && ( printWindow.print(); + printWindow.addEventListener("load", () => printWindow.print(), { + once: true, + }); } } } catch { diff --git a/src/admin/pages/Attendance.tsx b/src/admin/pages/Attendance.tsx index 36dc0e1..25b1319 100644 --- a/src/admin/pages/Attendance.tsx +++ b/src/admin/pages/Attendance.tsx @@ -108,7 +108,7 @@ export default function Attendance() { project_logs: [], active_project_id: null, }); - const [showLeaveModal, setShowLeaveModal] = useState(false); + const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [leaveForm, setLeaveForm] = useState({ leave_type: "vacation", date_from: new Date().toISOString().split("T")[0], @@ -122,10 +122,11 @@ export default function Attendance() { const [projectLogs, setProjectLogs] = useState([]); const [activeProjectId, setActiveProjectId] = useState(null); const [gpsConfirm, setGpsConfirm] = useState<{ - show: boolean; + isOpen: boolean; action: string | null; - }>({ show: false, action: null }); + }>({ isOpen: false, action: null }); const geoAbortRef = useRef(null); + const punchTimeoutRef = useRef | null>(null); const mountedRef = useRef(true); const latestActionRef = useRef(null); @@ -133,6 +134,7 @@ export default function Attendance() { return () => { mountedRef.current = false; if (geoAbortRef.current) geoAbortRef.current.abort(); + if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current); }; }, []); @@ -176,7 +178,7 @@ export default function Attendance() { loadProjects(); }, []); - useModalLock(showLeaveModal); + useModalLock(isLeaveModalOpen); if (!hasPermission("attendance.record")) return ; @@ -234,7 +236,7 @@ export default function Attendance() { errorMsg = "Vypršel časový limit"; } alert.error(errorMsg); - setGpsConfirm({ show: true, action }); + setGpsConfirm({ isOpen: true, action }); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, ); @@ -257,7 +259,7 @@ export default function Attendance() { if (result.success) { await fetchData(); - setTimeout(() => { + punchTimeoutRef.current = setTimeout(() => { alert.success(result.data?.message || result.message || "Uloženo"); }, 300); } else { @@ -368,7 +370,7 @@ export default function Attendance() { const result = await response.json(); if (result.success) { - setShowLeaveModal(false); + setIsLeaveModalOpen(false); await fetchData(); await new Promise((resolve) => setTimeout(resolve, 300)); alert.success( @@ -669,7 +671,7 @@ export default function Attendance() { {submitting ? "Zpracovávám..." : "Odchod"} )} + + setBankDeleteConfirm({ isOpen: false, id: null })} + onConfirm={confirmBankDelete} + title="Smazat bankovní účet" + message="Opravdu chcete smazat tento bankovní účet?" + confirmText="Smazat" + cancelText="Zrušit" + type="danger" + />

); } diff --git a/src/admin/pages/Dashboard.tsx b/src/admin/pages/Dashboard.tsx index e2e329b..6dbb3c3 100644 --- a/src/admin/pages/Dashboard.tsx +++ b/src/admin/pages/Dashboard.tsx @@ -129,7 +129,7 @@ export default function Dashboard() { }, [fetch2FAStatus]); // Punch (prichod/odchod) primo z dashboardu - const handleQuickPunch = () => { + const handleQuickPunch = useCallback(() => { const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival"; setPunching(true); @@ -167,7 +167,7 @@ export default function Dashboard() { () => submitPunch({}), { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, ); - }; + }, [dashData, alert, fetchDashboard]); // 2FA handlery const handleStart2FASetup = async () => { diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 6a4cf39..8feb09b 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -383,6 +383,8 @@ export default function InvoiceDetail() { const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(true); const [invoiceNumber, setInvoiceNumber] = useState(""); + const initialSnapshotRef = useRef(null); + const hasSetInitialSnapshot = useRef(false); const [customers, setCustomers] = useState([]); const [customerSearch, setCustomerSearch] = useState(""); @@ -664,6 +666,32 @@ export default function InvoiceDetail() { if (isEdit) fetchDetail(); }, [isEdit, fetchDetail]); + useEffect(() => { + if (loading) { + hasSetInitialSnapshot.current = false; + return; + } + if (!hasSetInitialSnapshot.current) { + initialSnapshotRef.current = JSON.stringify({ form, items }); + hasSetInitialSnapshot.current = true; + } + }, [loading, form, items]); + + const isDirty = useMemo(() => { + if (!initialSnapshotRef.current) return false; + return JSON.stringify({ form, items }) !== initialSnapshotRef.current; + }, [form, items]); + + useEffect(() => { + if (!isDirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + // ─── Due date calculation from issue date + days ─── useEffect(() => { if (!form.issue_date) return; @@ -826,6 +854,8 @@ export default function InvoiceDetail() { result.message || (isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"), ); + initialSnapshotRef.current = JSON.stringify({ form, items }); + hasSetInitialSnapshot.current = true; if (isEdit) { fetchDetail(); } else { diff --git a/src/admin/pages/Login.tsx b/src/admin/pages/Login.tsx index 38bc504..74d5660 100644 --- a/src/admin/pages/Login.tsx +++ b/src/admin/pages/Login.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { Navigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { useAuth } from "../context/AuthContext"; @@ -57,62 +57,91 @@ export default function Login() { return ; } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); - const result = await login(username, password, remember); + const result = await login(username, password, remember); - if (result.requires2FA) { - setLoginToken(result.loginToken ?? null); - setShow2FA(true); - setTotpCode(""); - setLoading(false); - } else if (!result.success) { - alert.error(result.error ?? "Chyba přihlášení"); - setShake(true); - setTimeout(() => setShake(false), 500); - setLoading(false); - } else { - alert.success("Úspěšně přihlášeno"); - setAnimatingOut(true); - setTimeout(() => setAnimatingOut(false), 400); - } - }; + if (result.requires2FA) { + setLoginToken(result.loginToken ?? null); + setShow2FA(true); + setTotpCode(""); + setLoading(false); + } else if (!result.success) { + alert.error(result.error ?? "Chyba přihlášení"); + setShake(true); + setTimeout(() => setShake(false), 500); + setLoading(false); + } else { + alert.success("Úspěšně přihlášeno"); + setAnimatingOut(true); + setTimeout(() => setAnimatingOut(false), 400); + } + }, + [ + username, + password, + remember, + login, + alert, + setLoading, + setShake, + setAnimatingOut, + setLoginToken, + setShow2FA, + setTotpCode, + ], + ); - const handle2FASubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!totpCode.trim()) return; + const handle2FASubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!totpCode.trim()) return; - setLoading(true); + setLoading(true); - const result = await verify2FA( - loginToken!, - totpCode.trim(), + const result = await verify2FA( + loginToken!, + totpCode.trim(), + remember, + useBackupCode, + ); + + if (!result.success) { + alert.error(result.error ?? "Chyba ověření"); + setShake(true); + setTimeout(() => setShake(false), 500); + setTotpCode(""); + if (totpInputRef.current) totpInputRef.current.focus(); + setLoading(false); + } else { + alert.success("Úspěšně přihlášeno"); + setAnimatingOut(true); + setTimeout(() => setAnimatingOut(false), 400); + } + }, + [ + totpCode, + loginToken, remember, useBackupCode, - ); + verify2FA, + alert, + setLoading, + setShake, + setTotpCode, + setAnimatingOut, + ], + ); - if (!result.success) { - alert.error(result.error ?? "Chyba ověření"); - setShake(true); - setTimeout(() => setShake(false), 500); - setTotpCode(""); - if (totpInputRef.current) totpInputRef.current.focus(); - setLoading(false); - } else { - alert.success("Úspěšně přihlášeno"); - setAnimatingOut(true); - setTimeout(() => setAnimatingOut(false), 400); - } - }; - - const handleBack = () => { + const handleBack = useCallback(() => { setShow2FA(false); setLoginToken(null); setTotpCode(""); setUseBackupCode(false); - }; + }, [setShow2FA, setLoginToken, setTotpCode, setUseBackupCode]); return ( (null); const heartbeatRef = useRef | null>(null); const unlockAbortRef = useRef(null); + const initialSnapshotRef = useRef(null); + const hasSetInitialSnapshot = useRef(false); useModalLock(showOrderModal); @@ -467,6 +470,34 @@ export default function OfferDetail() { if (isEdit) fetchDetail(); }, [isEdit, fetchDetail]); + useEffect(() => { + if (loading) { + hasSetInitialSnapshot.current = false; + return; + } + if (!hasSetInitialSnapshot.current) { + initialSnapshotRef.current = JSON.stringify({ form, items, sections }); + hasSetInitialSnapshot.current = true; + } + }, [loading, form, items, sections]); + + const isDirty = useMemo(() => { + if (!initialSnapshotRef.current) return false; + return ( + JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current + ); + }, [form, items, sections]); + + useEffect(() => { + if (!isDirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + useEffect(() => { const loadCustomers = async () => { try { @@ -678,6 +709,14 @@ export default function OfferDetail() { if (!isEdit && result.data?.id) { navigate(`/offers/${result.data.id}`); } + if (isEdit) { + initialSnapshotRef.current = JSON.stringify({ + form, + items, + sections, + }); + hasSetInitialSnapshot.current = true; + } } else { alert.error(result.error || "Nepodařilo se uložit nabídku"); } diff --git a/src/admin/pages/OrderDetail.tsx b/src/admin/pages/OrderDetail.tsx index 8d366e2..a3ae578 100644 --- a/src/admin/pages/OrderDetail.tsx +++ b/src/admin/pages/OrderDetail.tsx @@ -119,6 +119,8 @@ export default function OrderDetail() { const [deleteFiles, setDeleteFiles] = useState(false); const [showConfirmationModal, setShowConfirmationModal] = useState(false); const [confirmationLoading, setConfirmationLoading] = useState(false); + const initialNotesRef = useRef(null); + const hasSetInitialSnapshot = useRef(false); const fetchDetail = useCallback(async () => { try { @@ -144,6 +146,32 @@ export default function OrderDetail() { fetchDetail(); }, [fetchDetail]); + useEffect(() => { + if (loading) { + hasSetInitialSnapshot.current = false; + return; + } + if (!hasSetInitialSnapshot.current) { + initialNotesRef.current = notes; + hasSetInitialSnapshot.current = true; + } + }, [loading, notes]); + + const isDirty = useMemo(() => { + if (!initialNotesRef.current) return false; + return notes !== initialNotesRef.current; + }, [notes]); + + useEffect(() => { + if (!isDirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + const totals = useMemo(() => { if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 }; const subtotal = order.items.reduce((sum, item) => { @@ -197,6 +225,7 @@ export default function OrderDetail() { const result = await response.json(); if (result.success) { alert.success("Poznámky byly uloženy"); + initialNotesRef.current = notes; } else { alert.error(result.error || "Nepodařilo se uložit poznámky"); } diff --git a/src/config/env.ts b/src/config/env.ts index 90ea705..34ffb0a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -5,6 +5,8 @@ dotenv.config(); process.env.TZ = process.env.TZ || "Europe/Prague"; // Override Date.toJSON so JSON.stringify outputs local time (Europe/Prague). +// This is intentional and required for PHP migration compatibility: the legacy +// PHP API returned local Czech times, and the frontend relies on this format. // Prisma stores UTC in MySQL DATETIME columns. When reading, it creates // JS Date objects with correct UTC internals. The default toJSON() calls // toISOString() which returns UTC — this override uses local getters instead, @@ -50,6 +52,13 @@ export const config = { totp: { encryptionKey: required("TOTP_ENCRYPTION_KEY"), + algorithm: (process.env.TOTP_ALGORITHM || "SHA1") as "SHA1", + digits: parseInt(process.env.TOTP_DIGITS || "6", 10), + period: parseInt(process.env.TOTP_PERIOD || "30", 10), + loginTokenExpiryMinutes: parseInt( + process.env.LOGIN_TOKEN_EXPIRY_MINUTES || "5", + 10, + ), }, nas: { diff --git a/src/routes/admin/company-settings.ts b/src/routes/admin/company-settings.ts index 21c20ea..201290a 100644 --- a/src/routes/admin/company-settings.ts +++ b/src/routes/admin/company-settings.ts @@ -77,8 +77,12 @@ export default async function companySettingsRoutes( else if ( buf[0] === 0x52 && buf[1] === 0x49 && + buf[2] === 0x46 && + buf[3] === 0x46 && buf[8] === 0x57 && - buf[9] === 0x45 + buf[9] === 0x45 && + buf[10] === 0x42 && + buf[11] === 0x50 ) mime = "image/webp"; diff --git a/src/routes/admin/dashboard.ts b/src/routes/admin/dashboard.ts index edf8cba..8a9c525 100644 --- a/src/routes/admin/dashboard.ts +++ b/src/routes/admin/dashboard.ts @@ -200,23 +200,25 @@ export default async function dashboardRoutes( (revenueByCurrency[currency] || 0) + amount; } + const revenueConversions = await Promise.all( + Object.entries(revenueByCurrency).map(async ([currency, amount]) => ({ + amount: Math.round(amount * 100) / 100, + currency, + czk: await toCzk(Math.round(amount * 100) / 100, currency), + })), + ); + result.invoices = { - revenue_this_month: Object.entries(revenueByCurrency).map( - ([currency, amount]) => ({ - amount: Math.round(amount * 100) / 100, - currency, - }), - ), + revenue_this_month: revenueConversions.map(({ amount, currency }) => ({ + amount, + currency, + })), unpaid_count: unpaidCount, - revenue_czk: await (async () => { - let total = 0; - for (const [cur, amount] of Object.entries(revenueByCurrency)) { - total += await toCzk(Math.round(amount * 100) / 100, cur); - } - return Math.round(total * 100) / 100; - })(), + revenue_czk: + Math.round( + revenueConversions.reduce((sum, r) => sum + r.czk, 0) * 100, + ) / 100, }; - result.unpaid_invoices = unpaidCount; } // Orders — only for orders.view @@ -232,7 +234,6 @@ export default async function dashboardRoutes( where: { status: "pending" }, }); result.leave_pending = { count }; - result.pending_leave_requests = count; } // Recent activity — only for settings.audit (admin) diff --git a/src/routes/admin/invoices-pdf.ts b/src/routes/admin/invoices-pdf.ts index 0febf95..7bee4d3 100644 --- a/src/routes/admin/invoices-pdf.ts +++ b/src/routes/admin/invoices-pdf.ts @@ -267,7 +267,7 @@ export default async function invoicesPdfRoutes( ): Promise { fastify.get<{ Params: { id: string } }>( "/:id", - { preHandler: requirePermission("invoices.export") }, + { preHandler: requirePermission("invoices.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; diff --git a/src/routes/admin/orders-pdf.ts b/src/routes/admin/orders-pdf.ts index c7d8430..e77c47f 100644 --- a/src/routes/admin/orders-pdf.ts +++ b/src/routes/admin/orders-pdf.ts @@ -383,7 +383,9 @@ export default async function ordersPdfRoutes( }) .join(""); - const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer"; + const paymentMethod = + String((order as Record).payment_method || "") || + (lang === "cs" ? "převodem" : "Bank transfer"); let vatDetailHtml = ""; if (applyVat) { diff --git a/src/routes/admin/orders.ts b/src/routes/admin/orders.ts index ecd75d7..75e36d2 100644 --- a/src/routes/admin/orders.ts +++ b/src/routes/admin/orders.ts @@ -1,9 +1,10 @@ import { FastifyInstance } from "fastify"; 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 { config } from "../../config/env"; import { CreateOrderFromQuotationSchema, CreateOrderSchema, @@ -25,7 +26,9 @@ import multipart from "@fastify/multipart"; export default async function ordersRoutes( fastify: FastifyInstance, ): Promise { - await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } }); + await fastify.register(multipart, { + limits: { fileSize: config.nas.maxUploadSize }, + }); // GET /api/admin/orders/next-number fastify.get( @@ -54,15 +57,11 @@ export default async function ordersRoutes( customer_id: query.customer_id ? Number(query.customer_id) : undefined, }); - return reply.send({ - success: true, - data: result.data, - pagination: buildPaginationMeta( - result.total, - result.page, - result.limit, - ), - }); + return paginated( + reply, + result.data, + buildPaginationMeta(result.total, result.page, result.limit), + ); }, ); diff --git a/src/routes/admin/projects.ts b/src/routes/admin/projects.ts index 30b9794..1602acc 100644 --- a/src/routes/admin/projects.ts +++ b/src/routes/admin/projects.ts @@ -129,6 +129,9 @@ export default async function projectsRoutes( lastName: authData.lastName, content: parsed.data.content ?? undefined, }); + if (note && "error" in note) { + return error(reply, note.error, (note as any).status ?? 400); + } return success(reply, { note }, 201, "Poznámka byla přidána"); }, diff --git a/src/routes/admin/quotations.ts b/src/routes/admin/quotations.ts index 5fe81cf..33e73a1 100644 --- a/src/routes/admin/quotations.ts +++ b/src/routes/admin/quotations.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from "fastify"; 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 { @@ -44,11 +44,11 @@ export default async function quotationsRoutes( customer_id: query.customer_id ? Number(query.customer_id) : 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), + ); }, ); diff --git a/src/routes/admin/sessions.ts b/src/routes/admin/sessions.ts index c6e6119..4fa63b8 100644 --- a/src/routes/admin/sessions.ts +++ b/src/routes/admin/sessions.ts @@ -1,12 +1,8 @@ import { FastifyInstance } from "fastify"; -import crypto from "crypto"; import prisma from "../../config/database"; import { requireAuth } from "../../middleware/auth"; import { success, error } from "../../utils/response"; - -function hashToken(token: string): string { - return crypto.createHash("sha256").update(token).digest("hex"); -} +import { hashToken } from "../../services/auth"; /** Parse user-agent string into browser, OS, and device icon */ function parseUserAgent(ua: string | null): { diff --git a/src/routes/admin/trips.ts b/src/routes/admin/trips.ts index f66e531..b480cca 100644 --- a/src/routes/admin/trips.ts +++ b/src/routes/admin/trips.ts @@ -175,7 +175,7 @@ export default async function tripsRoutes( // Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0) fastify.get<{ Params: { vehicleId: string } }>( "/last-km/:vehicleId", - { preHandler: requireAuth }, + { preHandler: requirePermission("trips.view") }, async (request, reply) => { const vehicleId = parseInt(request.params.vehicleId, 10); if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400); diff --git a/src/routes/admin/users.ts b/src/routes/admin/users.ts index a386cd7..4be1686 100644 --- a/src/routes/admin/users.ts +++ b/src/routes/admin/users.ts @@ -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 { CreateUserSchema, UpdateUserSchema } from "../../schemas/users.schema"; @@ -25,15 +25,11 @@ export default async function usersRoutes( const params = parsePagination(request.query as Record); const result = await listUsers(params); - return reply.send({ - success: true, - data: result.users, - pagination: buildPaginationMeta( - result.total, - result.page, - result.limit, - ), - }); + return paginated( + reply, + result.users, + buildPaginationMeta(result.total, result.page, result.limit), + ); }, ); diff --git a/src/schemas/attendance.schema.ts b/src/schemas/attendance.schema.ts index 100f150..b20bded 100644 --- a/src/schemas/attendance.schema.ts +++ b/src/schemas/attendance.schema.ts @@ -22,7 +22,7 @@ export const AttendanceBalancesSchema = z.object({ .union([z.number(), z.string()]) .transform((v) => Number(v)) .optional(), - action_type: z.string(), + action_type: z.enum(["edit", "reset"]), vacation_total: z .union([z.number(), z.string()]) .transform((v) => Number(v)) diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index 5eaefdc..ca88587 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -577,6 +577,7 @@ export async function getWorkfund(year: number) { let worked = 0; let vacationHours = 0; let sickHours = 0; + let holidayHours = 0; for (const rec of recs) { const lt = (rec.leave_type as string) || "work"; @@ -593,12 +594,14 @@ export async function getWorkfund(year: number) { vacationHours += Number(rec.leave_hours) || 8; } else if (lt === "sick") { sickHours += Number(rec.leave_hours) || 8; + } else if (lt === "holiday") { + holidayHours += Number(rec.leave_hours) || 8; } } const userFund = fundToDate; const workedRound = Math.round(worked * 10) / 10; - const leaveHours = vacationHours + sickHours; + const leaveHours = vacationHours + sickHours + holidayHours; const covered = Math.round((worked + leaveHours) * 10) / 10; const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10); const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10); @@ -1153,7 +1156,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) { continue; } - const shiftDate = new Date(Date.UTC(yr, mo - 1, day, 12, 0, 0)); + const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0); if (isHoliday(dateStr)) { await prisma.attendance.create({ @@ -1215,14 +1218,12 @@ export async function createLeave(data: LeaveData, authUserId: number) { if (dow !== 0 && dow !== 6) { const dateStr = localDateStr(current); const shiftDate = new Date( - Date.UTC( - current.getFullYear(), - current.getMonth(), - current.getDate(), - 12, - 0, - 0, - ), + current.getFullYear(), + current.getMonth(), + current.getDate(), + 12, + 0, + 0, ); const duplicate = await prisma.attendance.findFirst({ where: { user_id: userId, shift_date: shiftDate }, @@ -1287,7 +1288,7 @@ export async function punchAction(userId: number, data: PunchData) { const y = now.getFullYear(), m = now.getMonth(), d = now.getDate(); - const today = new Date(Date.UTC(y, m, d, 12, 0, 0)); + const today = new Date(y, m, d, 12, 0, 0); const gpsLat = data.latitude != null && data.latitude !== "" diff --git a/src/services/audit.ts b/src/services/audit.ts index 78c166c..3984a36 100644 --- a/src/services/audit.ts +++ b/src/services/audit.ts @@ -54,6 +54,16 @@ export async function logAudit(params: { }, }); } catch (err) { - console.error("Failed to write audit log:", err); + console.error( + "Failed to write audit log:", + { + action: params.action, + entityType: params.entityType, + entityId: params.entityId, + description: params.description, + userId: params.authData?.userId, + }, + err, + ); } } diff --git a/src/services/auth.ts b/src/services/auth.ts index 175c50b..c10d167 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -185,7 +185,9 @@ export async function login( data: { user_id: user.id, token_hash: tokenHash, - expires_at: new Date(Date.now() + 5 * 60_000), // 5 minutes + expires_at: new Date( + Date.now() + config.totp.loginTokenExpiryMinutes * 60_000, + ), }, }); @@ -255,16 +257,18 @@ export async function refreshAccessToken( user_id: number; expires_at: Date; replaced_at: Date | null; + replaced_by_hash: string | null; remember_me: boolean | null; }> >` - SELECT id, user_id, expires_at, replaced_at, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE + SELECT id, user_id, expires_at, replaced_at, replaced_by_hash, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE `; const storedToken = tokens[0] ?? null; if ( !storedToken || storedToken.replaced_at || + storedToken.replaced_by_hash || new Date(storedToken.expires_at) < new Date() ) { return { type: "error", message: "Neplatný refresh token", status: 401 }; @@ -335,7 +339,8 @@ export async function verifyAccessToken( config.jwt.secret, ) as unknown as JwtPayload; return loadAuthData(payload.sub); - } catch { + } catch (err) { + console.error("JWT verification error:", err); return null; } } diff --git a/src/services/exchange-rates.ts b/src/services/exchange-rates.ts index 92968d5..83a9980 100644 --- a/src/services/exchange-rates.ts +++ b/src/services/exchange-rates.ts @@ -11,34 +11,43 @@ interface CnbRate { } const rateCache = new Map>(); +const inflight = new Map>>(); async function fetchRatesForDate( date?: string, ): Promise> { const key = date || "today"; if (rateCache.has(key)) return rateCache.get(key)!; + if (inflight.has(key)) return inflight.get(key)!; - try { - let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; - if (date) url += `&date=${date}`; + const promise = (async () => { + try { + let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; + if (date) url += `&date=${date}`; - const response = await fetch(url); - if (!response.ok) throw new Error(`CNB API: ${response.status}`); + const response = await fetch(url); + if (!response.ok) throw new Error(`CNB API: ${response.status}`); - const data = (await response.json()) as { rates: CnbRate[] }; - const rates: Record = { CZK: 1 }; + const data = (await response.json()) as { rates: CnbRate[] }; + const rates: Record = { CZK: 1 }; - for (const r of data.rates) { - rates[r.currencyCode] = r.rate / r.amount; + for (const r of data.rates) { + rates[r.currencyCode] = r.rate / r.amount; + } + + rateCache.set(key, rates); + return rates; + } catch (err) { + console.error("Failed to fetch CNB exchange rates:", err); + if (rateCache.has("today")) return rateCache.get("today")!; + throw new Error("Nepodařilo se získat aktuální kurzy z ČNB"); + } finally { + inflight.delete(key); } + })(); - rateCache.set(key, rates); - return rates; - } catch (err) { - console.error("Failed to fetch CNB exchange rates:", err); - if (rateCache.has("today")) return rateCache.get("today")!; - throw new Error("Nepodařilo se získat aktuální kurzy z ČNB"); - } + inflight.set(key, promise); + return promise; } /** Convert an amount from a given currency to CZK using CNB rates */ diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index ff6649c..b2824f7 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -260,9 +260,6 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) { amount: Math.round(amount * 100) / 100, currency, })); - let vatCzk = 0; - for (const [, v] of Object.entries(vatMap)) vatCzk += v; - // VAT also needs conversion let vatCzkConverted = 0; for (const [cur, amount] of Object.entries(vatMap)) { diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index 5caa784..9e415a2 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -220,6 +220,14 @@ export async function createProjectNote( content?: string; }, ) { + const project = await prisma.projects.findUnique({ + where: { id: projectId }, + select: { id: true }, + }); + if (!project) { + return { error: "Projekt nenalezen" as const, status: 404 }; + } + const note = await prisma.project_notes.create({ data: { project_id: projectId, diff --git a/src/utils/totp.ts b/src/utils/totp.ts index d931c13..a6f1c78 100644 --- a/src/utils/totp.ts +++ b/src/utils/totp.ts @@ -1,5 +1,6 @@ import * as OTPAuthLib from "otpauth"; import { decrypt } from "./encryption"; +import { config } from "../config/env"; export const OTPAuth = { verify( @@ -10,15 +11,15 @@ export const OTPAuth = { const secret = decrypt(encryptedSecret); 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 }); if (delta === null) { return { valid: false, counter: null }; } - const currentCounter = Math.floor(Date.now() / 1000 / 30); + const currentCounter = Math.floor(Date.now() / 1000 / config.totp.period); return { valid: true, counter: currentCounter + delta }; } catch (err) { console.error("TOTP verification error:", err);