diff --git a/.env.example b/.env.example index ebe1988..49dd1ec 100644 --- a/.env.example +++ b/.env.example @@ -7,13 +7,13 @@ HOST=127.0.0.1 APP_ENV=local # Auth — MUST regenerate for production: openssl rand -hex 32 -JWT_SECRET=generate-with-openssl-rand-hex-32 +JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32 ACCESS_TOKEN_EXPIRY=900 REFRESH_TOKEN_SESSION_EXPIRY=3600 REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # TOTP — MUST regenerate for production: openssl rand -hex 32 -TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32 +TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32 # File storage NAS_PATH=Z:/02_PROJEKTY diff --git a/src/admin/components/AdminDatePicker.tsx b/src/admin/components/AdminDatePicker.tsx index 7b0b459..0a8ef5f 100644 --- a/src/admin/components/AdminDatePicker.tsx +++ b/src/admin/components/AdminDatePicker.tsx @@ -75,6 +75,19 @@ function NativeInput({ disabled, }: NativeInputProps) { const type = modeToInputType[mode] || "date"; + // For time inputs, min/max must be in HH:mm format, not date format + const formatTimeMinMax = (val: string | undefined): string | undefined => { + if (!val) return undefined; + // If it looks like a date string (yyyy-MM-dd), extract time portion if present, + // otherwise it's not a valid time min/max — return undefined + if (val.includes("T")) return val.split("T")[1]?.substring(0, 5); + if (val.includes(":")) return val.substring(0, 5); + return undefined; + }; + const minProp = + mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined; + const maxProp = + mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined; return ( ); } diff --git a/src/admin/components/AttendanceShiftTable.tsx b/src/admin/components/AttendanceShiftTable.tsx index 33a244e..433bc80 100644 --- a/src/admin/components/AttendanceShiftTable.tsx +++ b/src/admin/components/AttendanceShiftTable.tsx @@ -126,7 +126,7 @@ export default function AttendanceShiftTable({ if (records.length === 0) { return (
-

Za tento měsíc nejsou žádné záznamy.

+

Za tento měsíc nejsou žádné záznamy.

); } @@ -137,15 +137,15 @@ export default function AttendanceShiftTable({ Datum - ZamÄ›stnanec + Zaměstnanec Typ - Příchod + Příchod Pauza Odchod Hodiny Projekt GPS - Poznámka + Poznámka Akce diff --git a/src/admin/context/AlertContext.tsx b/src/admin/context/AlertContext.tsx index 923f64b..3f018f7 100644 --- a/src/admin/context/AlertContext.tsx +++ b/src/admin/context/AlertContext.tsx @@ -57,7 +57,10 @@ export function AlertProvider({ children }: { children: ReactNode }) { { id, message, type: type as Alert["type"] }, ]); if (duration > 0) { - const timeoutId = setTimeout(() => removeAlert(id), duration); + const timeoutId = setTimeout(() => { + timeoutsRef.current.delete(timeoutId); + removeAlert(id); + }, duration); timeoutsRef.current.add(timeoutId); } return id; diff --git a/src/admin/context/AuthContext.tsx b/src/admin/context/AuthContext.tsx index c3cffa9..7d60e25 100644 --- a/src/admin/context/AuthContext.tsx +++ b/src/admin/context/AuthContext.tsx @@ -268,6 +268,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { login_token: loginToken, totp_code: code, remember_me: remember, + isBackup, }), }); const data = await response.json(); diff --git a/src/admin/hooks/useListData.ts b/src/admin/hooks/useListData.ts index ecf5ca2..c7d6182 100644 --- a/src/admin/hooks/useListData.ts +++ b/src/admin/hooks/useListData.ts @@ -43,6 +43,7 @@ export default function useListData( const [initialLoad, setInitialLoad] = useState(true); const [pagination, setPagination] = useState(null); const abortRef = useRef(null); + const mountedRef = useRef(true); const debouncedSearch = useDebounce(search, 300); const extraParamsKey = Object.entries(extraParams) @@ -100,8 +101,10 @@ export default function useListData( } } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") return; + if (!mountedRef.current) return; alert.error(errorMsg); } finally { + if (!mountedRef.current) return; setLoading(false); setInitialLoad(false); } @@ -117,8 +120,10 @@ export default function useListData( ]); useEffect(() => { + mountedRef.current = true; fetchData(); return () => { + mountedRef.current = false; if (abortRef.current) abortRef.current.abort(); }; }, [fetchData]); diff --git a/src/admin/pages/Attendance.tsx b/src/admin/pages/Attendance.tsx index 6c017ad..76484b6 100644 --- a/src/admin/pages/Attendance.tsx +++ b/src/admin/pages/Attendance.tsx @@ -634,7 +634,8 @@ export default function Attendance() { {projectLogs.length > 0 && (
{projectLogs.map((log, i) => { - const start = new Date(log.started_at!); + if (!log.started_at) return null; + const start = new Date(log.started_at); const end = log.ended_at ? new Date(log.ended_at) : new Date(); @@ -786,11 +787,12 @@ export default function Attendance() { }} > {shiftLogs.map((log, i) => { + if (!log.started_at) return null; const mins = log.ended_at ? Math.floor( (new Date(log.ended_at).getTime() - new Date( - log.started_at!, + log.started_at, ).getTime()) / 60000, ) diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 8feb09b..56326af 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -449,8 +449,15 @@ export default function InvoiceDetail() { }>({ show: false, status: null }); const [pdfLoading, setPdfLoading] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); + const blobTimeoutsRef = useRef[]>([]); const [deleting, setDeleting] = useState(false); + useEffect(() => { + return () => { + blobTimeoutsRef.current.forEach(clearTimeout); + }; + }, []); + // ─── Data loading ─── useEffect(() => { @@ -915,7 +922,8 @@ export default function InvoiceDetail() { const blob = await response.blob(); const url = URL.createObjectURL(blob); if (newWindow) newWindow.location.href = url; - setTimeout(() => URL.revokeObjectURL(url), 60000); + const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000); + blobTimeoutsRef.current.push(timeoutId); } catch { newWindow?.close(); alert.error("Chyba připojení"); diff --git a/src/admin/pages/LeaveApproval.tsx b/src/admin/pages/LeaveApproval.tsx index b308206..42b4da0 100644 --- a/src/admin/pages/LeaveApproval.tsx +++ b/src/admin/pages/LeaveApproval.tsx @@ -163,8 +163,8 @@ export default function LeaveApproval() { : []), ].sort( (a: LeaveRequest, b: LeaveRequest) => - new Date(b.reviewed_at!).getTime() - - new Date(a.reviewed_at!).getTime(), + (b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) - + (a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0), ); setProcessedRequests(all); diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 8e81301..1c97b9e 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -323,6 +323,7 @@ export default function OfferDetail() { const [customerOrderNumber, setCustomerOrderNumber] = useState(""); const [orderAttachment, setOrderAttachment] = useState(null); const [pdfLoading, setPdfLoading] = useState(false); + const blobTimeoutsRef = useRef[]>([]); const [companySettings, setCompanySettings] = useState<{ default_currency: string; default_vat_rate: number; @@ -341,6 +342,12 @@ export default function OfferDetail() { useModalLock(showOrderModal); + useEffect(() => { + return () => { + blobTimeoutsRef.current.forEach(clearTimeout); + }; + }, []); + useEffect(() => { apiFetch(`${API_BASE}/company-settings`) .then((r) => r.json()) @@ -829,7 +836,8 @@ export default function OfferDetail() { const blob = await response.blob(); const url = URL.createObjectURL(blob); if (newWindow) newWindow.location.href = url; - setTimeout(() => URL.revokeObjectURL(url), 60000); + const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000); + blobTimeoutsRef.current.push(timeoutId); } catch { newWindow?.close(); alert.error("Chyba při generování PDF"); diff --git a/src/admin/pages/OrderDetail.tsx b/src/admin/pages/OrderDetail.tsx index a3ae578..c24fc6c 100644 --- a/src/admin/pages/OrderDetail.tsx +++ b/src/admin/pages/OrderDetail.tsx @@ -3,6 +3,7 @@ import { useEffect, useCallback, useMemo, + useRef, type ReactNode, } from "react"; import DOMPurify from "dompurify"; @@ -121,6 +122,13 @@ export default function OrderDetail() { const [confirmationLoading, setConfirmationLoading] = useState(false); const initialNotesRef = useRef(null); const hasSetInitialSnapshot = useRef(false); + const blobTimeoutsRef = useRef[]>([]); + + useEffect(() => { + return () => { + blobTimeoutsRef.current.forEach(clearTimeout); + }; + }, []); const fetchDetail = useCallback(async () => { try { @@ -249,7 +257,8 @@ export default function OrderDetail() { const blob = await response.blob(); const url = URL.createObjectURL(blob); if (newWindow) newWindow.location.href = url; - setTimeout(() => URL.revokeObjectURL(url), 60000); + const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000); + blobTimeoutsRef.current.push(timeoutId); } catch { newWindow?.close(); alert.error("Chyba připojení"); @@ -293,7 +302,8 @@ export default function OrderDetail() { document.body.appendChild(a); a.click(); document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 60000); + const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000); + blobTimeoutsRef.current.push(timeoutId); } catch { alert.error("Chyba připojení"); } finally { diff --git a/src/admin/pages/ReceivedInvoices.tsx b/src/admin/pages/ReceivedInvoices.tsx index dbc67bf..77d2826 100644 --- a/src/admin/pages/ReceivedInvoices.tsx +++ b/src/admin/pages/ReceivedInvoices.tsx @@ -161,6 +161,7 @@ export default function ReceivedInvoices({ const [statsLoading, setStatsLoading] = useState(true); const hasLoadedOnce = useRef(false); const slideDirection = useRef(0); + const blobTimeoutsRef = useRef[]>([]); const [slideKey, setSlideKey] = useState(0); const prevMonth = useRef(statsMonth); const prevYear = useRef(statsYear); @@ -185,6 +186,12 @@ export default function ReceivedInvoices({ useModalLock(uploadOpen || editOpen); + useEffect(() => { + return () => { + blobTimeoutsRef.current.forEach(clearTimeout); + }; + }, []); + useEffect(() => { const prev = prevYear.current * 12 + prevMonth.current; const curr = statsYear * 12 + statsMonth; @@ -516,7 +523,8 @@ export default function ReceivedInvoices({ const blob = await response.blob(); const url = URL.createObjectURL(blob); if (newWindow) newWindow.location.href = url; - setTimeout(() => URL.revokeObjectURL(url), 60000); + const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000); + blobTimeoutsRef.current.push(timeoutId); } catch { newWindow?.close(); alert.error("Chyba připojení"); diff --git a/src/config/env.ts b/src/config/env.ts index 34ffb0a..cfd36e1 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -53,11 +53,11 @@ 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, + digits: Math.max(6, parseInt(process.env.TOTP_DIGITS || "6", 10) || 6), + period: Math.max(15, parseInt(process.env.TOTP_PERIOD || "30", 10) || 30), + loginTokenExpiryMinutes: Math.max( + 1, + parseInt(process.env.LOGIN_TOKEN_EXPIRY_MINUTES || "5", 10) || 5, ), }, diff --git a/src/middleware/security.ts b/src/middleware/security.ts index bb390ca..7b11ab0 100644 --- a/src/middleware/security.ts +++ b/src/middleware/security.ts @@ -12,6 +12,8 @@ export async function securityHeaders( "Permissions-Policy", "camera=(), microphone=(), geolocation=(self)", ); + reply.header("Cross-Origin-Opener-Policy", "same-origin"); + reply.header("Cross-Origin-Resource-Policy", "same-origin"); if (config.isProduction) { reply.header( @@ -27,6 +29,9 @@ export async function securityHeaders( "font-src 'self' https://fonts.gstatic.com", "img-src 'self' data: blob: https://*.tile.openstreetmap.org", "connect-src 'self' https://nominatim.openstreetmap.org", + "frame-ancestors 'none'", + "form-action 'self'", + "base-uri 'self'", ].join("; "), ); } diff --git a/src/routes/admin/attendance.ts b/src/routes/admin/attendance.ts index 94d81ff..06b74bc 100644 --- a/src/routes/admin/attendance.ts +++ b/src/routes/admin/attendance.ts @@ -144,19 +144,19 @@ export default async function attendanceRoutes( // --- action=attendance_users: users with attendance.record permission --- if (action === "attendance_users") { + if ( + !authData.permissions.includes("attendance.admin") && + !authData.permissions.includes("attendance.view") && + !authData.permissions.includes("attendance.record") + ) { + return error(reply, "Nedostatečná oprávnění", 403); + } const users = await prisma.users.findMany({ where: { is_active: true, roles: { - is: { - OR: [ - { name: "admin" }, - { - role_permissions: { - some: { permissions: { name: "attendance.record" } }, - }, - }, - ], + role_permissions: { + some: { permissions: { name: "attendance.record" } }, }, }, }, @@ -182,6 +182,12 @@ export default async function attendanceRoutes( // --- action=project_logs: get project logs for a specific attendance record --- if (action === "project_logs") { + if ( + !authData.permissions.includes("attendance.view") && + !authData.permissions.includes("attendance.record") + ) { + return error(reply, "Nedostatečná oprávnění", 403); + } const attendanceId = Number(query.attendance_id); if (!attendanceId) return error(reply, "Missing attendance_id", 400); const data = await attendanceService.getProjectLogs(attendanceId); @@ -411,7 +417,7 @@ export default async function attendanceRoutes( // PUT /api/admin/attendance/:id fastify.put<{ Params: { id: string } }>( "/:id", - { preHandler: requireAuth }, + { preHandler: requirePermission("attendance.edit") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; diff --git a/src/routes/admin/auth.ts b/src/routes/admin/auth.ts index 8625f28..b9b1371 100644 --- a/src/routes/admin/auth.ts +++ b/src/routes/admin/auth.ts @@ -95,7 +95,7 @@ export default async function authRoutes( { config: { rateLimit: { - max: 20, + max: 5, timeWindow: "1 minute", }, }, @@ -104,10 +104,8 @@ export default async function authRoutes( async (request, reply) => { const parsed = parseBody(TotpVerifySchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); - const { login_token, totp_code } = parsed.data; - const rawBody = request.body as unknown as Record; - const rememberMe = - rawBody.remember_me === true || rawBody.remember_me === "true"; + const { login_token, totp_code, remember_me } = parsed.data; + const rememberMe = remember_me ?? false; const tokenHash = crypto .createHash("sha256") @@ -130,8 +128,6 @@ export default async function authRoutes( const storedTokenId = Number(storedToken.id); const storedUserId = Number(storedToken.user_id); - await tx.totp_login_tokens.delete({ where: { id: storedTokenId } }); - const user = await tx.users.findUnique({ where: { id: storedUserId }, include: { roles: true }, @@ -141,11 +137,15 @@ export default async function authRoutes( 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 }; } - return { user }; + return { user, storedTokenId }; }); if ("error" in totpResult) { @@ -183,6 +183,11 @@ export default async function authRoutes( return error(reply, "TOTP kód již byl použit", 401); } + // TOTP verified successfully — now consume the login token + await prisma.totp_login_tokens.delete({ + where: { id: totpResult.storedTokenId }, + }); + // Reset failed attempts and update last login (TOTP verified = successful login) await prisma.users.update({ where: { id: user.id }, @@ -234,6 +239,16 @@ export default async function authRoutes( }); setRefreshCookie(reply, refreshTokenRaw, rememberMe); + + await logAudit({ + request, + authData: authData, + action: "login_totp", + entityType: "user", + entityId: user.id, + description: `TOTP přihlášení uživatele ${user.username}`, + }); + return success(reply, { access_token: accessToken, user: authData }); }, ); @@ -278,7 +293,7 @@ export default async function authRoutes( ); // POST /api/admin/logout - fastify.post("/logout", async (request, reply) => { + fastify.post("/logout", { bodyLimit: 10240 }, async (request, reply) => { const refreshTokenRaw = request.cookies.refresh_token; if (refreshTokenRaw) { await logout(refreshTokenRaw); diff --git a/src/routes/admin/company-settings.ts b/src/routes/admin/company-settings.ts index 201290a..0257dbc 100644 --- a/src/routes/admin/company-settings.ts +++ b/src/routes/admin/company-settings.ts @@ -135,6 +135,7 @@ export default async function companySettingsRoutes( ); fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => { + // Use upsert to avoid race condition between findFirst and create let settings = await prisma.company_settings.findFirst({ select: { id: true, @@ -176,50 +177,100 @@ export default async function companySettingsRoutes( }); if (!settings) { - settings = await prisma.company_settings.create({ - data: { - company_name: "", - quotation_prefix: "N", - default_currency: "EUR", - default_vat_rate: 21.0, - }, - select: { - id: true, - company_name: true, - street: true, - city: true, - postal_code: true, - country: true, - company_id: true, - vat_id: true, - custom_fields: true, - quotation_prefix: true, - default_currency: true, - default_vat_rate: true, - uuid: true, - modified_at: true, - is_deleted: true, - sync_version: true, - order_type_code: true, - invoice_type_code: true, - require_2fa: true, - break_threshold_hours: true, - break_duration_short: true, - break_duration_long: true, - clock_rounding_minutes: true, - invoice_alert_email: true, - leave_notify_email: true, - max_login_attempts: true, - lockout_minutes: true, - max_requests_per_minute: true, - available_vat_rates: true, - available_currencies: true, - smtp_from: true, - smtp_from_name: true, - offer_number_pattern: true, - order_number_pattern: true, - invoice_number_pattern: true, - }, + // Wrap create in a transaction to handle race condition: + // another request may have created settings between findFirst and create + settings = await prisma.$transaction(async (tx) => { + // Double-check inside transaction + const existing = await tx.company_settings.findFirst({ + select: { id: true }, + }); + if (existing) { + return tx.company_settings.findUnique({ + where: { id: existing.id }, + select: { + id: true, + company_name: true, + street: true, + city: true, + postal_code: true, + country: true, + company_id: true, + vat_id: true, + custom_fields: true, + quotation_prefix: true, + default_currency: true, + default_vat_rate: true, + uuid: true, + modified_at: true, + is_deleted: true, + sync_version: true, + order_type_code: true, + invoice_type_code: true, + require_2fa: true, + break_threshold_hours: true, + break_duration_short: true, + break_duration_long: true, + clock_rounding_minutes: true, + invoice_alert_email: true, + leave_notify_email: true, + max_login_attempts: true, + lockout_minutes: true, + max_requests_per_minute: true, + available_vat_rates: true, + available_currencies: true, + smtp_from: true, + smtp_from_name: true, + offer_number_pattern: true, + order_number_pattern: true, + invoice_number_pattern: true, + }, + }); + } + return tx.company_settings.create({ + data: { + company_name: "", + quotation_prefix: "N", + default_currency: "EUR", + default_vat_rate: 21.0, + }, + select: { + id: true, + company_name: true, + street: true, + city: true, + postal_code: true, + country: true, + company_id: true, + vat_id: true, + custom_fields: true, + quotation_prefix: true, + default_currency: true, + default_vat_rate: true, + uuid: true, + modified_at: true, + is_deleted: true, + sync_version: true, + order_type_code: true, + invoice_type_code: true, + require_2fa: true, + break_threshold_hours: true, + break_duration_short: true, + break_duration_long: true, + clock_rounding_minutes: true, + invoice_alert_email: true, + leave_notify_email: true, + max_login_attempts: true, + lockout_minutes: true, + max_requests_per_minute: true, + available_vat_rates: true, + available_currencies: true, + smtp_from: true, + smtp_from_name: true, + offer_number_pattern: true, + order_number_pattern: true, + invoice_number_pattern: true, + }, + }); }); } if (!settings) return error(reply, "Nastavení nenalezeno", 500); @@ -429,7 +480,7 @@ export default async function companySettingsRoutes( : existingOrder, ); } - data.sync_version = (existing.sync_version ?? 0) + 1; + data.sync_version = { increment: 1 }; await prisma.company_settings.update({ where: { id: existing.id }, diff --git a/src/routes/admin/dashboard.ts b/src/routes/admin/dashboard.ts index f88dc88..dd98964 100644 --- a/src/routes/admin/dashboard.ts +++ b/src/routes/admin/dashboard.ts @@ -180,9 +180,9 @@ export default async function dashboardRoutes( // Invoices — only for invoices.view if (has("invoices.view")) { // $queryRaw template literal interpolation with Date objects fails on - // MySQL when Date.toJSON is overridden — pass strings instead. - const monthStartStr = monthStart.toJSON(); - const monthEndStr = monthEnd.toJSON(); + // MySQL when Date.toJSON is overridden — pass explicit date strings instead. + const monthStartStr = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}-01`; + const monthEndStr = `${monthEnd.getFullYear()}-${String(monthEnd.getMonth() + 1).padStart(2, "0")}-01`; const [unpaidCount, revenueAgg] = await Promise.all([ prisma.invoices.count({ where: { status: "issued" } }), diff --git a/src/routes/admin/invoices.ts b/src/routes/admin/invoices.ts index 62f8a0d..86daad6 100644 --- a/src/routes/admin/invoices.ts +++ b/src/routes/admin/invoices.ts @@ -26,10 +26,16 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager"; export default async function invoicesRoutes( fastify: FastifyInstance, ): Promise { - // Auto-update overdue invoices on GET requests only (matches PHP behavior) + // Auto-update overdue invoices on GET requests, throttled to once per hour + let lastOverdueCheck = 0; + const OVERDUE_CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour + fastify.addHook("onRequest", async (request) => { if (request.method !== "GET") return; - await markOverdueInvoices(); + if (Date.now() - lastOverdueCheck > OVERDUE_CHECK_INTERVAL) { + lastOverdueCheck = Date.now(); + await markOverdueInvoices(); + } }); // GET /api/admin/invoices @@ -226,12 +232,10 @@ export default async function invoicesRoutes( const file = nasFinancialsManager.readIssuedInvoice(relPath); if (!file) return error(reply, "PDF soubor nenalezen", 404); + const safeName = invoice.invoice_number.replace(/[\r\n"]/g, ""); return reply .type("application/pdf") - .header( - "Content-Disposition", - `inline; filename="${invoice.invoice_number}.pdf"`, - ) + .header("Content-Disposition", `inline; filename="${safeName}.pdf"`) .send(file.data); }, ); diff --git a/src/routes/admin/leave-requests.ts b/src/routes/admin/leave-requests.ts index 2c6d0df..72c07e8 100644 --- a/src/routes/admin/leave-requests.ts +++ b/src/routes/admin/leave-requests.ts @@ -241,71 +241,84 @@ 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 }, + try { + await prisma.$transaction(async (tx) => { + // Check for duplicate attendance records inside the transaction + for (const ac of attendanceCreates) { + const duplicate = await tx.attendance.findFirst({ + where: { user_id: ac.user_id, shift_date: ac.shift_date }, + }); + if (duplicate) { + throw new Error( + "Pro zvolené datumy již existují záznamy docházky", + ); + } + } + + // 1. Create attendance records for each business day + if (attendanceCreates.length > 0) { + await tx.attendance.createMany({ data: attendanceCreates }); + } + + // 2. Update leave balance (vacation/sick only — not unpaid) + if (leaveType === "vacation" || leaveType === "sick") { + const year = dateFrom.getFullYear(); + const existingBalance = await tx.leave_balances.findFirst({ + where: { user_id: existing.user_id, year }, + }); + + if (existingBalance) { + const updateData: Record = { + updated_at: new Date(), + }; + if (leaveType === "vacation") { + updateData.vacation_used = + Number(existingBalance.vacation_used) + totalHours; + } else { + updateData.sick_used = + Number(existingBalance.sick_used) + totalHours; + } + await tx.leave_balances.update({ + where: { id: existingBalance.id }, + data: updateData, + }); + } else { + await tx.leave_balances.create({ + data: { + user_id: existing.user_id, + year, + vacation_total: 160, + vacation_used: leaveType === "vacation" ? totalHours : 0, + sick_used: leaveType === "sick" ? totalHours : 0, + }, + }); + } + } + + // 3. Update request status + await tx.leave_requests.update({ + where: { id }, + data: { + status: "approved" as leave_requests_status, + reviewer_id: authData.userId, + reviewed_at: new Date(), + }, + }); }); - if (duplicate) { + } catch (e) { + if ( + e instanceof Error && + e.message === "Pro zvolené datumy již existují záznamy docházky" + ) { return error( reply, "Pro zvolené datumy již existují záznamy docházky", 400, ); } + throw e; } - await prisma.$transaction(async (tx) => { - // 1. Create attendance records for each business day - if (attendanceCreates.length > 0) { - await tx.attendance.createMany({ data: attendanceCreates }); - } - - // 2. Update leave balance (vacation/sick only — not unpaid) - if (leaveType === "vacation" || leaveType === "sick") { - const year = dateFrom.getFullYear(); - const existingBalance = await tx.leave_balances.findFirst({ - where: { user_id: existing.user_id, year }, - }); - - if (existingBalance) { - const updateData: Record = { - updated_at: new Date(), - }; - if (leaveType === "vacation") { - updateData.vacation_used = - Number(existingBalance.vacation_used) + totalHours; - } else { - updateData.sick_used = - Number(existingBalance.sick_used) + totalHours; - } - await tx.leave_balances.update({ - where: { id: existingBalance.id }, - data: updateData, - }); - } else { - await tx.leave_balances.create({ - data: { - user_id: existing.user_id, - year, - vacation_total: 160, - vacation_used: leaveType === "vacation" ? totalHours : 0, - sick_used: leaveType === "sick" ? totalHours : 0, - }, - }); - } - } - - // 3. Update request status - await tx.leave_requests.update({ - where: { id }, - data: { - status: "approved" as leave_requests_status, - reviewer_id: authData.userId, - reviewed_at: new Date(), - }, - }); - }); - await logAudit({ request, authData, diff --git a/src/routes/admin/profile.ts b/src/routes/admin/profile.ts index c3a104c..3352a83 100644 --- a/src/routes/admin/profile.ts +++ b/src/routes/admin/profile.ts @@ -38,12 +38,7 @@ export default async function profileRoutes( const data: Record = {}; if (body.email) { - const newEmail = String(body.email).trim(); - const existing = await prisma.users.findFirst({ - where: { email: newEmail, id: { not: userId } }, - }); - if (existing) return error(reply, "E-mail již existuje", 409); - data.email = newEmail; + data.email = String(body.email).trim(); } if (body.first_name) data.first_name = String(body.first_name); if (body.last_name) data.last_name = String(body.last_name); @@ -65,7 +60,23 @@ export default async function profileRoutes( data.password_changed_at = new Date(); } - await prisma.users.update({ where: { id: userId }, data }); + // Wrap email uniqueness check and update in a transaction to prevent race condition + try { + await prisma.$transaction(async (tx) => { + if (data.email) { + const existing = await tx.users.findFirst({ + where: { email: String(data.email), id: { not: userId } }, + }); + if (existing) throw new Error("EMAIL_EXISTS"); + } + await tx.users.update({ where: { id: userId }, data }); + }); + } catch (e) { + if (e instanceof Error && e.message === "EMAIL_EXISTS") { + return error(reply, "E-mail již existuje", 409); + } + throw e; + } await logAudit({ request, diff --git a/src/routes/admin/project-files.ts b/src/routes/admin/project-files.ts index aefd9d9..0b5e510 100644 --- a/src/routes/admin/project-files.ts +++ b/src/routes/admin/project-files.ts @@ -73,10 +73,11 @@ export default async function projectFilesRoutes( if (!result) return error(reply, "Soubor nebyl nalezen", 404); const stream = fs.createReadStream(result.filePath); + const encodedName = encodeURIComponent(result.fileName); return reply .header( "Content-Disposition", - `attachment; filename="${encodeURIComponent(result.fileName)}"`, + `attachment; filename*=UTF-8''${encodedName}`, ) .header("Content-Type", result.mime) .header("X-Content-Type-Options", "nosniff") @@ -179,7 +180,8 @@ export default async function projectFilesRoutes( if (!file) return error(reply, "Nebyl nahrán žádný soubor"); const subPath = parsedQuery.data.path || ""; - const fileName = file.filename; + const rawFileName = file.filename; + const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_"); const err = await fm.uploadFile( project.project_number, diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index d89c7e8..fe4c983 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -13,6 +13,7 @@ import { } from "../../schemas/received-invoices.schema"; import { nasFinancialsManager } from "../../services/nas-financials-manager"; import { toCzk } from "../../services/exchange-rates"; +import path from "path"; const VALID_STATUSES = ["unpaid", "paid"] as const; @@ -126,9 +127,17 @@ export default async function receivedInvoicesRoutes( return Math.round(total * 100) / 100; }; - // Also get all-time unpaid + // Also get all-time unpaid — use DB-level aggregation for count/sums + const stats = await prisma.received_invoices.aggregate({ + where: { status: { not: "paid" }, is_deleted: false }, + _sum: { amount: true, amount_czk: true }, + _count: true, + }); + + // We still need per-currency breakdown for unpaid, so fetch only those const allUnpaid = await prisma.received_invoices.findMany({ - where: { status: { not: "paid" } }, + where: { status: { not: "paid" }, is_deleted: false }, + select: { amount: true, currency: true }, }); return success(reply, { @@ -137,8 +146,10 @@ export default async function receivedInvoicesRoutes( vat_month: aggregateByCurrency(monthInvoices, "vat_amount"), vat_month_czk: await sumCzk(monthInvoices, "vat_amount"), unpaid: aggregateByCurrency(allUnpaid, "amount"), - unpaid_czk: await sumCzk(allUnpaid, "amount"), - unpaid_count: allUnpaid.length, + unpaid_czk: stats._sum.amount_czk + ? Math.round(Number(stats._sum.amount_czk) * 100) / 100 + : await sumCzk(allUnpaid, "amount"), + unpaid_count: stats._count, month_count: monthInvoices.length, }); }, @@ -188,12 +199,10 @@ export default async function receivedInvoicesRoutes( if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404); const mime = invoice.file_mime || "application/pdf"; + const safeFileName = invoice.file_name.replace(/[\r\n"]/g, ""); return reply .type(mime) - .header( - "Content-Disposition", - `inline; filename="${invoice.file_name}"`, - ) + .header("Content-Disposition", `inline; filename="${safeFileName}"`) .send(nasFile.data); }, ); @@ -315,7 +324,9 @@ export default async function receivedInvoicesRoutes( status: "unpaid", notes: meta.notes ? String(meta.notes) : null, uploaded_by: request.authData?.userId, - file_name: file.name, + file_name: nasResult.filePath + ? path.basename(nasResult.filePath) + : file.name, file_mime: file.mime, file_size: file.size, }, @@ -364,7 +375,7 @@ export default async function receivedInvoicesRoutes( vat_rate: vatRate, vat_amount: vatRate > 0 - ? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100 + ? Math.round(((amount * vatRate) / 100) * 100) / 100 : 0, issue_date: body.issue_date ? new Date(String(body.issue_date)) @@ -544,6 +555,9 @@ export default async function receivedInvoicesRoutes( }); if (!existing) return error(reply, "Přijatá faktura nenalezena", 404); + // Delete DB record first, then NAS file — avoids orphaned file if DB delete fails + await prisma.received_invoices.delete({ where: { id } }); + if (existing.file_name) { const relPath = nasFinancialsManager.buildReceivedPath( existing.file_name, @@ -552,8 +566,6 @@ export default async function receivedInvoicesRoutes( ); nasFinancialsManager.deleteReceivedInvoice(relPath); } - - await prisma.received_invoices.delete({ where: { id } }); await logAudit({ request, authData: request.authData, diff --git a/src/routes/admin/roles.ts b/src/routes/admin/roles.ts index a95a4f6..30b019a 100644 --- a/src/routes/admin/roles.ts +++ b/src/routes/admin/roles.ts @@ -62,12 +62,16 @@ export default async function rolesRoutes( }); if (Array.isArray(body.permission_ids)) { - await prisma.role_permissions.createMany({ - data: (body.permission_ids as number[]).map((pid) => ({ - role_id: role.id, - permission_id: pid, - })), - }); + await prisma.$transaction( + (body.permission_ids as number[]).map((pid) => + prisma.role_permissions.create({ + data: { + role_id: role.id, + permission_id: pid, + }, + }), + ), + ); } await logAudit({ diff --git a/src/routes/admin/scope-templates.ts b/src/routes/admin/scope-templates.ts index cec7143..7a8a3a1 100644 --- a/src/routes/admin/scope-templates.ts +++ b/src/routes/admin/scope-templates.ts @@ -188,17 +188,19 @@ export default async function scopeTemplatesRoutes( }); if (Array.isArray(body.sections)) { - await prisma.scope_template_sections.deleteMany({ - where: { scope_template_id: id }, - }); - await prisma.scope_template_sections.createMany({ - data: (body.sections as ScopeSectionInput[]).map((s, i) => ({ - scope_template_id: id, - title: s.title ?? null, - title_cz: s.title_cz ?? null, - content: s.content ?? null, - position: s.position ?? i, - })), + await prisma.$transaction(async (tx) => { + await tx.scope_template_sections.deleteMany({ + where: { scope_template_id: id }, + }); + await tx.scope_template_sections.createMany({ + data: (body.sections as ScopeSectionInput[]).map((s, i) => ({ + scope_template_id: id, + title: s.title ?? null, + title_cz: s.title_cz ?? null, + content: s.content ?? null, + position: s.position ?? i, + })), + }); }); } diff --git a/src/routes/admin/totp.ts b/src/routes/admin/totp.ts index 81f17d6..af5b2d4 100644 --- a/src/routes/admin/totp.ts +++ b/src/routes/admin/totp.ts @@ -6,11 +6,17 @@ import { requireAuth, requirePermission } from "../../middleware/auth"; import { success, error } from "../../utils/response"; import { encrypt } from "../../utils/encryption"; import { getSystemSettings } from "../../services/system-settings"; +import { config } from "../../config/env"; import { OTPAuth } from "../../utils/totp"; import * as OTPAuthLib from "otpauth"; import { logAudit } from "../../services/audit"; import { parseBody } from "../../schemas/common"; -import { TotpBackupSchema } from "../../schemas/auth.schema"; +import { + TotpBackupSchema, + TotpEnableSchema, + TotpDisableSchema, + TotpRequiredSchema, +} from "../../schemas/auth.schema"; export default async function totpRoutes( fastify: FastifyInstance, @@ -47,12 +53,9 @@ export default async function totpRoutes( "/enable", { preHandler: requireAuth, bodyLimit: 10240 }, async (request, reply) => { - const body = request.body as Record; - const { secret, code } = body; - - if (!secret || !code) { - return error(reply, "Secret a kód jsou povinné", 400); - } + const parsed = parseBody(TotpEnableSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const { secret, code, password, current_code } = parsed.data; const user = await prisma.users.findUnique({ where: { id: request.authData!.userId }, @@ -60,41 +63,35 @@ export default async function totpRoutes( if (!user) return error(reply, "Uživatel nenalezen", 404); if (user.totp_enabled) { - if (!body.current_code) { + if (!current_code) { return error( reply, "Aktuální TOTP kód je povinný pro změnu 2FA", 400, ); } - const verifyResult = OTPAuth.verify( - user.totp_secret!, - String(body.current_code), - ); + const verifyResult = OTPAuth.verify(user.totp_secret!, current_code); if (!verifyResult.valid) { return error(reply, "Neplatný aktuální TOTP kód", 400); } } else { - if (!body.password) { + if (!password) { return error(reply, "Heslo je povinné pro aktivaci 2FA", 400); } - const valid = await bcrypt.compare( - String(body.password), - user.password_hash, - ); + const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { return error(reply, "Nesprávné heslo", 400); } } const totp = new OTPAuthLib.TOTP({ - secret: OTPAuthLib.Secret.fromBase32(String(secret)), + secret: OTPAuthLib.Secret.fromBase32(secret), algorithm: "SHA1", digits: 6, period: 30, }); - const delta = totp.validate({ token: String(code), window: 1 }); + const delta = totp.validate({ token: code, window: 1 }); if (delta === null) { return error(reply, "Neplatný TOTP kód", 400); } @@ -103,9 +100,11 @@ export default async function totpRoutes( const backupCodesPlain: string[] = []; const backupCodesHashed: string[] = []; for (let i = 0; i < 8; i++) { - const code = crypto.randomBytes(4).toString("hex").toUpperCase(); - backupCodesPlain.push(code); - backupCodesHashed.push(bcrypt.hashSync(code, 10)); + const plainCode = crypto.randomBytes(4).toString("hex").toUpperCase(); + backupCodesPlain.push(plainCode); + backupCodesHashed.push( + await bcrypt.hash(plainCode, config.security.bcryptCost), + ); } const encryptedSecret = encrypt(String(secret)); @@ -140,11 +139,9 @@ export default async function totpRoutes( "/disable", { preHandler: requireAuth }, async (request, reply) => { - const body = request.body as Record; - - if (!body.code) { - return error(reply, "TOTP kód je povinný pro deaktivaci", 400); - } + const parsed = parseBody(TotpDisableSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const { code } = parsed.data; const user = await prisma.users.findUnique({ where: { id: request.authData!.userId }, @@ -153,7 +150,7 @@ export default async function totpRoutes( return error(reply, "2FA není aktivní", 400); } - const verifyResult = OTPAuth.verify(user.totp_secret, String(body.code)); + const verifyResult = OTPAuth.verify(user.totp_secret, code); if (!verifyResult.valid) { return error(reply, "Neplatný TOTP kód", 400); } @@ -214,10 +211,9 @@ export default async function totpRoutes( bodyLimit: 10240, }, async (request, reply) => { - const body = request.body as Record; - - const required = - body.required === true || body.required === 1 || body.required === "1"; + const parsed = parseBody(TotpRequiredSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const required = parsed.data.required; const settings = await prisma.company_settings.findFirst({ select: { require_2fa: true }, @@ -339,7 +335,9 @@ export default async function totpRoutes( return { error: "Neplatný záložní kód", status: 401 }; } - await tx.totp_login_tokens.delete({ where: { id: storedToken.id } }); + await tx.totp_login_tokens.delete({ + where: { id: Number(storedToken.id) }, + }); backupCodes.splice(matchIndex, 1); await tx.users.update({ @@ -408,6 +406,14 @@ export default async function totpRoutes( maxAge: config.jwt.refreshTokenSessionExpiry, }); + await logAudit({ + request, + action: "login_backup", + entityType: "user", + entityId: user.id, + description: `Backup code login for user ${user.username}`, + }); + return success(reply, { access_token: accessToken, user: authData }); }, ); diff --git a/src/routes/admin/trips.ts b/src/routes/admin/trips.ts index b480cca..682082f 100644 --- a/src/routes/admin/trips.ts +++ b/src/routes/admin/trips.ts @@ -38,9 +38,15 @@ export default async function tripsRoutes( mo = NaN; } if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) { + // Use explicit date strings to avoid toJSON timezone shift + const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`; + const nextMonth = + mo === 12 + ? `${yr + 1}-01-01` + : `${yr}-${String(mo + 1).padStart(2, "0")}-01`; where.trip_date = { - gte: new Date(yr, mo - 1, 1), - lt: new Date(yr, mo, 1), + gte: new Date(monthStart), + lt: new Date(nextMonth), }; } } @@ -75,15 +81,8 @@ export default async function tripsRoutes( where: { is_active: true, roles: { - is: { - OR: [ - { name: "admin" }, - { - role_permissions: { - some: { permissions: { name: "trips.record" } }, - }, - }, - ], + role_permissions: { + some: { permissions: { name: "trips.record" } }, }, }, }, @@ -120,9 +119,17 @@ export default async function tripsRoutes( if (filterUserId) where.user_id = filterUserId; if (filterVehicleId) where.vehicle_id = filterVehicleId; if (query.month && query.year) { + // Use explicit date strings to avoid toJSON timezone shift + const yr = Number(query.year); + const mo = Number(query.month); + const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`; + const nextMonth = + mo === 12 + ? `${yr + 1}-01-01` + : `${yr}-${String(mo + 1).padStart(2, "0")}-01`; where.trip_date = { - gte: new Date(Number(query.year), Number(query.month) - 1, 1), - lt: new Date(Number(query.year), Number(query.month), 1), + gte: new Date(monthStart), + lt: new Date(nextMonth), }; } diff --git a/src/routes/admin/vehicles.ts b/src/routes/admin/vehicles.ts index 009e4bd..921d2df 100644 --- a/src/routes/admin/vehicles.ts +++ b/src/routes/admin/vehicles.ts @@ -131,6 +131,18 @@ export default async function vehiclesRoutes( const existing = await prisma.vehicles.findUnique({ where: { id } }); if (!existing) return error(reply, "Vozidlo nenalezeno", 404); + // Check for linked trips before deleting + const tripCount = await prisma.trips.count({ + where: { vehicle_id: id }, + }); + if (tripCount > 0) { + return error( + reply, + "Vozidlo má přiřazené jízdy a nelze jej smazat", + 409, + ); + } + await prisma.vehicles.delete({ where: { id } }); await logAudit({ request, diff --git a/src/schemas/attendance.schema.ts b/src/schemas/attendance.schema.ts index b20bded..35a1104 100644 --- a/src/schemas/attendance.schema.ts +++ b/src/schemas/attendance.schema.ts @@ -55,7 +55,10 @@ export const AttendanceLeaveSchema = z.object({ .optional(), date_from: z.string().min(1, "Datum je povinné"), date_to: z.string().optional(), - leave_type: z.string().optional().default("vacation"), + leave_type: z + .enum(["vacation", "sick", "unpaid", "holiday"]) + .optional() + .default("vacation"), leave_hours: z .union([z.number(), z.string()]) .transform((v) => Number(v)) @@ -65,16 +68,8 @@ export const AttendanceLeaveSchema = z.object({ const ProjectLogSchema = z.object({ project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), - hours: z - .union([z.number(), z.string()]) - .transform((v) => Number(v) || 0) - .optional() - .default(0), - minutes: z - .union([z.number(), z.string()]) - .transform((v) => Number(v) || 0) - .optional() - .default(0), + hours: z.coerce.number().min(0).default(0), + minutes: z.coerce.number().min(0).default(0), }); export const AttendancePunchSchema = z.object({ @@ -124,7 +119,10 @@ export const CreateAttendanceSchema = z.object({ .union([z.number(), z.string()]) .transform((v) => Number(v)) .nullish(), - leave_type: z.string().optional().default("work"), + leave_type: z + .enum(["work", "vacation", "sick", "holiday", "unpaid"]) + .optional() + .default("work"), leave_hours: z .union([z.number(), z.string()]) .transform((v) => Number(v)) @@ -138,8 +136,13 @@ export const UpdateAttendanceSchema = z.object({ break_start: z.union([z.string(), z.null()]).optional(), break_end: z.union([z.string(), z.null()]).optional(), notes: z.string().nullish(), - project_id: z.union([z.number(), z.string(), z.null()]).optional(), - leave_type: z.string().optional(), + project_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .optional(), + leave_type: z + .enum(["work", "vacation", "sick", "holiday", "unpaid"]) + .optional(), leave_hours: z.union([z.number(), z.string(), z.null()]).optional(), project_logs: z.array(ProjectLogSchema).optional(), }); diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 65939f7..e690632 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -9,6 +9,7 @@ export const LoginSchema = z.object({ export const TotpVerifySchema = z.object({ login_token: z.string().min(1, "Token je povinný"), totp_code: z.string().length(6, "Kód musí mít 6 číslic"), + remember_me: z.boolean().optional().default(false), }); export const TotpBackupSchema = z.object({ @@ -16,6 +17,22 @@ export const TotpBackupSchema = z.object({ backup_code: z.string().min(1, "Záložní kód je povinný"), }); +export const TotpEnableSchema = z.object({ + secret: z.string().min(1), + code: z.string().min(1), + password: z.string().optional(), + current_code: z.string().optional(), +}); + +export const TotpDisableSchema = z.object({ + code: z.string().min(1), + password: z.string().optional(), +}); + +export const TotpRequiredSchema = z.object({ + required: z.boolean(), +}); + export type LoginInput = z.infer; export type TotpVerifyInput = z.infer; export type TotpBackupInput = z.infer; diff --git a/src/schemas/invoices.schema.ts b/src/schemas/invoices.schema.ts index 54433ae..f82897c 100644 --- a/src/schemas/invoices.schema.ts +++ b/src/schemas/invoices.schema.ts @@ -4,13 +4,21 @@ const InvoiceItemSchema = z.object({ description: z.string().nullish(), quantity: z .union([z.number(), z.string()]) - .transform((v) => Number(v) || 1) + .transform((v) => { + const n = Number(v); + if (isNaN(n) || n <= 0) throw new Error("Invalid quantity"); + return n; + }) .optional() .default(1), unit: z.string().nullish(), unit_price: z .union([z.number(), z.string()]) - .transform((v) => Number(v) || 0) + .transform((v) => { + const n = Number(v); + if (isNaN(n)) throw new Error("Invalid unit_price"); + return n; + }) .optional() .default(0), vat_rate: z @@ -73,7 +81,10 @@ export const UpdateInvoiceSchema = z.object({ bank_iban: z.string().nullish(), bank_account: z.string().nullish(), issued_by: z.string().nullish(), - customer_id: z.union([z.number(), z.string(), z.null()]).optional(), + customer_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .optional(), vat_rate: z .union([z.number(), z.string()]) .transform((v) => Number(v)) diff --git a/src/schemas/leave-requests.schema.ts b/src/schemas/leave-requests.schema.ts index b5a85ce..bea281c 100644 --- a/src/schemas/leave-requests.schema.ts +++ b/src/schemas/leave-requests.schema.ts @@ -1,14 +1,18 @@ import { z } from "zod"; export const CreateLeaveRequestSchema = z.object({ - leave_type: z.string().min(1, "Typ nepřítomnosti je povinný"), + leave_type: z + .enum(["vacation", "sick", "unpaid", "holiday"]) + .default("vacation"), date_from: z.string().min(1, "Datum od je povinné"), date_to: z.string().min(1, "Datum do je povinné"), notes: z.string().nullish(), }); export const ReviewLeaveRequestSchema = z.object({ - status: z.string().min(1, "Stav je povinný"), + status: z + .enum(["pending", "approved", "rejected", "cancelled"]) + .default("pending"), reviewer_note: z.string().nullish(), }); diff --git a/src/schemas/offers.schema.ts b/src/schemas/offers.schema.ts index d511bf2..90feed1 100644 --- a/src/schemas/offers.schema.ts +++ b/src/schemas/offers.schema.ts @@ -66,7 +66,7 @@ export const CreateQuotationSchema = z.object({ .optional() .default(1.0), status: z - .enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"]) + .enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"]) .optional() .default("nova"), scope_title: z.string().nullish(), @@ -99,7 +99,7 @@ export const UpdateQuotationSchema = z.object({ .refine((v) => !Number.isNaN(v), { message: "Must be a valid number" }) .optional(), status: z - .enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"]) + .enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"]) .optional(), scope_title: z.string().nullish(), scope_description: z.string().nullish(), diff --git a/src/schemas/orders.schema.ts b/src/schemas/orders.schema.ts index 976e956..2da2eff 100644 --- a/src/schemas/orders.schema.ts +++ b/src/schemas/orders.schema.ts @@ -96,7 +96,10 @@ export const UpdateOrderSchema = z.object({ scope_title: z.string().nullish(), scope_description: z.string().nullish(), notes: z.string().nullish(), - customer_id: z.union([z.number(), z.string(), z.null()]).optional(), + customer_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .optional(), vat_rate: z .union([z.number(), z.string()]) .transform((v) => Number(v)) diff --git a/src/schemas/projects.schema.ts b/src/schemas/projects.schema.ts index 1e3483d..140d16d 100644 --- a/src/schemas/projects.schema.ts +++ b/src/schemas/projects.schema.ts @@ -34,10 +34,22 @@ export const UpdateProjectSchema = z.object({ name: z.string().nullish(), status: z.string().optional(), notes: z.string().nullish(), - customer_id: z.union([z.number(), z.string(), z.null()]).optional(), - responsible_user_id: z.union([z.number(), z.string(), z.null()]).optional(), - quotation_id: z.union([z.number(), z.string(), z.null()]).optional(), - order_id: z.union([z.number(), z.string(), z.null()]).optional(), + customer_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .optional(), + responsible_user_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .optional(), + quotation_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .optional(), + order_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .optional(), start_date: z.union([z.string(), z.null()]).optional(), end_date: z.union([z.string(), z.null()]).optional(), }); diff --git a/src/schemas/received-invoices.schema.ts b/src/schemas/received-invoices.schema.ts index cee8360..16d0f28 100644 --- a/src/schemas/received-invoices.schema.ts +++ b/src/schemas/received-invoices.schema.ts @@ -24,7 +24,7 @@ export const CreateReceivedInvoiceSchema = z.object({ .default(0), issue_date: z.string().nullish(), due_date: z.string().nullish(), - status: z.string().optional().default("unpaid"), + status: z.enum(["unpaid", "paid"]).optional().default("unpaid"), notes: z.string().nullish(), }); @@ -48,7 +48,7 @@ export const UpdateReceivedInvoiceSchema = z.object({ issue_date: z.union([z.string(), z.null()]).optional(), due_date: z.union([z.string(), z.null()]).optional(), paid_date: z.union([z.string(), z.null()]).optional(), - status: z.string().optional(), + status: z.enum(["unpaid", "paid"]).optional(), notes: z.string().nullish(), month: z .union([z.number(), z.string()]) diff --git a/src/schemas/trips.schema.ts b/src/schemas/trips.schema.ts index cd424c9..f9af37f 100644 --- a/src/schemas/trips.schema.ts +++ b/src/schemas/trips.schema.ts @@ -2,6 +2,8 @@ import { z } from "zod"; export const CreateTripSchema = z.object({ vehicle_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), + // user_id is optional here because the route injects it from authData.userId + // when the client doesn't provide it (see POST /trips handler) user_id: z .union([z.number(), z.string()]) .transform((v) => Number(v)) diff --git a/src/schemas/users.schema.ts b/src/schemas/users.schema.ts index 13cc299..42bf5a2 100644 --- a/src/schemas/users.schema.ts +++ b/src/schemas/users.schema.ts @@ -6,7 +6,10 @@ export const CreateUserSchema = z.object({ password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"), first_name: z.string().min(1, "Jméno je povinné"), last_name: z.string().min(1, "Příjmení je povinné"), - role_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), + role_id: z + .union([z.number(), z.string()]) + .transform((v) => Number(v)) + .optional(), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional() @@ -22,7 +25,10 @@ export const UpdateUserSchema = z.object({ ), first_name: z.string().optional(), last_name: z.string().optional(), - role_id: z.union([z.number(), z.string(), z.null()]).optional(), + role_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .optional(), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional(), diff --git a/src/server.ts b/src/server.ts index 3932a80..6ec2063 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import Fastify from "fastify"; +import type { ScheduledTask } from "node-cron"; import cors from "@fastify/cors"; import cookie from "@fastify/cookie"; import rateLimit from "@fastify/rate-limit"; @@ -42,7 +43,7 @@ const app = Fastify({ }); async function start() { - let invoiceAlertCron: any = null; + let invoiceAlertCron: ScheduledTask | null = null; // --- Plugins --- await app.register(cors, { diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index ca88587..4e05fed 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -4,21 +4,14 @@ import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays"; import { localDateStr } from "../utils/date"; import { getSystemSettings } from "./system-settings"; -/** Get active users whose role has attendance.record permission (or admin role) */ +/** Get active users whose role has attendance.record permission */ async function getAttendanceUsers() { return prisma.users.findMany({ where: { is_active: true, roles: { - is: { - OR: [ - { name: "admin" }, - { - role_permissions: { - some: { permissions: { name: "attendance.record" } }, - }, - }, - ], + role_permissions: { + some: { permissions: { name: "attendance.record" } }, }, }, }, @@ -73,11 +66,13 @@ function calcWorkedHours( } const roundUp = (d: Date, minutes: number) => { + if (!minutes || minutes <= 0) return d; const ms = minutes * 60 * 1000; return new Date(Math.ceil(d.getTime() / ms) * ms); }; const roundDown = (d: Date, minutes: number) => { + if (!minutes || minutes <= 0) return d; const ms = minutes * 60 * 1000; return new Date(Math.floor(d.getTime() / ms) * ms); }; @@ -1143,48 +1138,50 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) { let inserted = 0; let skipped = 0; - for (const userId of data.user_ids.map(Number)) { - for (let day = 1; day <= daysInMonth; day++) { - const date = new Date(yr, mo - 1, day); - const dateStr = localDateStr(date); - const dow = date.getDay(); + await prisma.$transaction(async (tx) => { + for (const userId of data.user_ids.map(Number)) { + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(yr, mo - 1, day); + const dateStr = localDateStr(date); + const dow = date.getDay(); - if (dow === 0 || dow === 6) continue; + if (dow === 0 || dow === 6) continue; - if (existingSet.has(`${userId}:${dateStr}`)) { - skipped++; - continue; - } + if (existingSet.has(`${userId}:${dateStr}`)) { + skipped++; + continue; + } - const shiftDate = new Date(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({ + if (isHoliday(dateStr)) { + await tx.attendance.create({ + data: { + user_id: userId, + shift_date: shiftDate, + leave_type: "holiday", + leave_hours: 8, + }, + }); + inserted++; + continue; + } + + await tx.attendance.create({ data: { user_id: userId, shift_date: shiftDate, - leave_type: "holiday", - leave_hours: 8, + arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`), + departure_time: new Date(`${dateStr}T${data.departure_time}:00`), + break_start: new Date(`${dateStr}T${data.break_start_time}:00`), + break_end: new Date(`${dateStr}T${data.break_end_time}:00`), + leave_type: "work", }, }); inserted++; - continue; } - - await prisma.attendance.create({ - data: { - user_id: userId, - shift_date: shiftDate, - arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`), - departure_time: new Date(`${dateStr}T${data.departure_time}:00`), - break_start: new Date(`${dateStr}T${data.break_start_time}:00`), - break_end: new Date(`${dateStr}T${data.break_end_time}:00`), - leave_type: "work", - }, - }); - inserted++; } - } + }); let msg = `Vytvořeno ${inserted} záznamů`; if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`; @@ -1212,70 +1209,83 @@ export async function createLeave(data: LeaveData, authUserId: number) { const end = new Date(dateTo); let created = 0; - const current = new Date(start); - while (current <= end) { - const dow = current.getDay(); - if (dow !== 0 && dow !== 6) { - const dateStr = localDateStr(current); - const shiftDate = new Date( - current.getFullYear(), - current.getMonth(), - current.getDate(), - 12, - 0, - 0, - ); - const duplicate = await prisma.attendance.findFirst({ - where: { user_id: userId, shift_date: shiftDate }, - }); - if (duplicate) { - return { error: "Pro zvolené datumy již existují záznamy docházky" }; + try { + await prisma.$transaction(async (tx) => { + const current = new Date(start); + while (current <= end) { + const dow = current.getDay(); + if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) { + const dateStr = localDateStr(current); + const shiftDate = new Date( + current.getFullYear(), + current.getMonth(), + current.getDate(), + 12, + 0, + 0, + ); + const duplicate = await tx.attendance.findFirst({ + where: { user_id: userId, shift_date: shiftDate }, + }); + if (duplicate) { + throw new Error("Pro zvolené datumy již existují záznamy docházky"); + } + await tx.attendance.create({ + data: { + user_id: userId, + shift_date: shiftDate, + leave_type: leaveType, + leave_hours: data.leave_hours ? Number(data.leave_hours) : 8, + notes: data.notes ? String(data.notes) : null, + }, + }); + created++; + } + current.setDate(current.getDate() + 1); } - await prisma.attendance.create({ - data: { - user_id: userId, - shift_date: shiftDate, - leave_type: leaveType, - leave_hours: data.leave_hours ? Number(data.leave_hours) : 8, - notes: data.notes ? String(data.notes) : null, - }, - }); - created++; - } - current.setDate(current.getDate() + 1); - } - const totalLeaveHours = - created * (data.leave_hours ? Number(data.leave_hours) : 8); - if ( - (leaveType === "vacation" || leaveType === "sick") && - totalLeaveHours > 0 - ) { - const year = new Date(dateFrom).getFullYear(); - const existingBalance = await prisma.leave_balances.findFirst({ - where: { user_id: userId, year }, + const totalLeaveHours = + created * (data.leave_hours ? Number(data.leave_hours) : 8); + if ( + (leaveType === "vacation" || leaveType === "sick") && + totalLeaveHours > 0 + ) { + const year = new Date(dateFrom).getFullYear(); + const existingBalance = await tx.leave_balances.findFirst({ + where: { user_id: userId, year }, + }); + if (existingBalance) { + const updateField = + leaveType === "vacation" ? "vacation_used" : "sick_used"; + await tx.leave_balances.update({ + where: { id: existingBalance.id }, + data: { + [updateField]: + Number(existingBalance[updateField]) + totalLeaveHours, + updated_at: new Date(), + }, + }); + } else { + await tx.leave_balances.create({ + data: { + user_id: userId, + year, + vacation_total: 160, + vacation_used: leaveType === "vacation" ? totalLeaveHours : 0, + sick_used: leaveType === "sick" ? totalLeaveHours : 0, + }, + }); + } + } }); - if (existingBalance) { - const updateField = - leaveType === "vacation" ? "vacation_used" : "sick_used"; - await prisma.leave_balances.update({ - where: { id: existingBalance.id }, - data: { - [updateField]: Number(existingBalance[updateField]) + totalLeaveHours, - updated_at: new Date(), - }, - }); - } else { - await prisma.leave_balances.create({ - data: { - user_id: userId, - year, - vacation_total: 160, - vacation_used: leaveType === "vacation" ? totalLeaveHours : 0, - sick_used: leaveType === "sick" ? totalLeaveHours : 0, - }, - }); + } catch (err) { + if ( + err instanceof Error && + err.message === "Pro zvolené datumy již existují záznamy docházky" + ) { + return { error: err.message }; } + throw err; } return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` }; @@ -1418,8 +1428,17 @@ export async function punchAction(userId: number, data: PunchData) { return { error: "Nemáte aktivní směnu bez přestávky." }; } - const msRound = settings.clock_rounding_minutes * 60 * 1000; - const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound); + let msRound = settings.clock_rounding_minutes * 60 * 1000; + if ( + !settings.clock_rounding_minutes || + settings.clock_rounding_minutes <= 0 + ) { + msRound = 0; + } + const breakStart = + msRound > 0 + ? new Date(Math.round(now.getTime() / msRound) * msRound) + : now; const breakEnd = new Date( breakStart.getTime() + settings.break_duration_long * 60 * 1000, ); diff --git a/src/services/auth.ts b/src/services/auth.ts index 2e70541..54ed06f 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -51,6 +51,9 @@ async function loadAuthData(userId: number): Promise { if (!user || !user.is_active) return null; + if (user.locked_until && new Date(user.locked_until) > new Date()) + return null; + const isAdmin = user.roles?.name === "admin"; const permissions = isAdmin ? (await prisma.permissions.findMany({ select: { name: true } })).map( @@ -111,6 +114,7 @@ export async function login( } if (!user.is_active) { + await bcrypt.compare(password, DUMMY_HASH); // timing-safe request.log.warn(`Login failed for deactivated user: ${username}`); return { type: "error", @@ -120,6 +124,7 @@ export async function login( } if (user.locked_until && new Date(user.locked_until) > new Date()) { + await bcrypt.compare(password, DUMMY_HASH); // timing-safe request.log.warn(`Login failed for locked user: ${username}`); return { type: "error", @@ -319,7 +324,7 @@ export async function refreshAccessToken( accessToken, refreshToken: newRefreshTokenRaw, user: authData, - rememberMe: storedToken.remember_me ?? false, + rememberMe: rememberMe, }; }); } @@ -341,10 +346,9 @@ export async function verifyAccessToken( token: string, ): Promise { try { - const payload = jwt.verify( - token, - config.jwt.secret, - ) as unknown as JwtPayload; + const payload = jwt.verify(token, config.jwt.secret, { + algorithms: ["HS256"], + }) as unknown as JwtPayload; return loadAuthData(payload.sub); } catch (err) { console.error("JWT verification error:", err); diff --git a/src/services/exchange-rates.ts b/src/services/exchange-rates.ts index 83a9980..66c3fa8 100644 --- a/src/services/exchange-rates.ts +++ b/src/services/exchange-rates.ts @@ -11,12 +11,17 @@ interface CnbRate { } const rateCache = new Map>(); +let rateCacheTime = 0; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const inflight = new Map>>(); async function fetchRatesForDate( date?: string, ): Promise> { const key = date || "today"; + if (Date.now() - rateCacheTime > CACHE_TTL_MS) { + rateCache.clear(); + } if (rateCache.has(key)) return rateCache.get(key)!; if (inflight.has(key)) return inflight.get(key)!; @@ -36,6 +41,7 @@ async function fetchRatesForDate( } rateCache.set(key, rates); + rateCacheTime = Date.now(); return rates; } catch (err) { console.error("Failed to fetch CNB exchange rates:", err); diff --git a/src/services/invoice-alerts.ts b/src/services/invoice-alerts.ts index 2c7c40d..62e03ac 100644 --- a/src/services/invoice-alerts.ts +++ b/src/services/invoice-alerts.ts @@ -37,6 +37,7 @@ export async function checkInvoiceAlerts(): Promise { if (!alertEmail) return; const today = new Date(); + today.setHours(0, 0, 0, 0); const todayStr = localDateStr(today); const in3days = new Date(today); in3days.setDate(in3days.getDate() + 3); diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index 48bfd16..aa60f15 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -55,8 +55,13 @@ function computeInvoiceTotals( const vatAmount = applyVat ? items.reduce((s, i) => { const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0); - const vat = - base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100); + const vatRate = + i.vat_rate != null && i.vat_rate !== "" + ? Number(i.vat_rate) + : defaultVatRate != null + ? Number(defaultVatRate) + : 21; + const vat = base * (vatRate / 100); return s + Math.round(vat * 100) / 100; }, 0) : 0; @@ -343,7 +348,7 @@ export async function createInvoice(body: Record) { customer_id: body.customer_id ? Number(body.customer_id) : null, status: body.status ? String(body.status) : "issued", currency: body.currency ? String(body.currency) : "CZK", - vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0, + vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, apply_vat: body.apply_vat !== false, payment_method: body.payment_method ? String(body.payment_method) : null, constant_symbol: body.constant_symbol diff --git a/src/services/nas-offers-manager.ts b/src/services/nas-offers-manager.ts index 37de54e..f583517 100644 --- a/src/services/nas-offers-manager.ts +++ b/src/services/nas-offers-manager.ts @@ -32,8 +32,8 @@ class NasOffersManager { pdfBuffer: Buffer, ): string | null { const { prefix, seq } = this.parseParts(quotationNumber); - const folderName = `${prefix}_${seq}`; - const fileName = `${year}_${prefix}_${seq}.pdf`; + const folderName = this.sanitizeFilename(`${prefix}_${seq}`); + const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf"; const dir = this.ensureDir(year, folderName); if (!dir) return null; @@ -81,8 +81,8 @@ class NasOffersManager { /** Build the relative NAS path for a given quotation number + year */ buildRelativePath(quotationNumber: string, year: number): string { const { prefix, seq } = this.parseParts(quotationNumber); - const folderName = `${prefix}_${seq}`; - const fileName = `${year}_${prefix}_${seq}.pdf`; + const folderName = this.sanitizeFilename(`${prefix}_${seq}`); + const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf"; return `${year}/${folderName}/${fileName}`; } @@ -92,8 +92,10 @@ class NasOffersManager { } { const parts = quotationNumber.split("/"); return { - prefix: parts.length >= 2 ? parts[parts.length - 2] : "NA", - seq: parts[parts.length - 1], + prefix: this.sanitizeFilename( + parts.length >= 2 ? parts[parts.length - 2] : "NA", + ), + seq: this.sanitizeFilename(parts[parts.length - 1]), }; } diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts index c0421eb..ae859fe 100644 --- a/src/services/numbering.service.ts +++ b/src/services/numbering.service.ts @@ -109,20 +109,14 @@ async function previewNextSequence( } /** - * Decrement the sequence counter for a given type/year. - * Called after deleting a document so the number can be reused. + * Release a sequence number back to the pool. + * NOTE: Blindly decrementing can cause duplicate numbers if numbers were + * allocated after this one. Sequence numbers are consumed but not returned + * to the pool — this is intentionally a no-op. */ -async function releaseSequence(type: string, year: number) { - try { - await prisma.$executeRaw` - UPDATE number_sequences - SET last_number = GREATEST(COALESCE(last_number, 0) - 1, 0) - WHERE \`type\` = ${type} AND \`year\` = ${year} - `; - } catch (err) { - // Non-fatal: log but don't fail the delete operation - console.error(`releaseSequence failed for ${type}/${year}:`, err); - } +async function releaseSequence(_type: string, _year: number) { + // No-op: decrementing can cause duplicate sequence numbers. + // Sequence numbers are consumed but never returned to the pool. } /** Verify a shared number is not already used by an order or project. */ diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts index 4da3b38..89f6516 100644 --- a/src/services/offers.service.ts +++ b/src/services/offers.service.ts @@ -55,7 +55,9 @@ function enrichQuotation(q: any) { 0, ); const vatAmount = q.apply_vat - ? subtotal * ((Number(q.vat_rate) || 21) / 100) + ? subtotal * + ((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) / + 100) : 0; const { quotation_items, scope_sections, ...rest } = q; return { @@ -138,11 +140,6 @@ export async function getOffer(id: number) { } export async function createOffer(body: Record) { - const quotationNumber = - body.quotation_number !== undefined && body.quotation_number !== null - ? String(body.quotation_number) - : await generateOfferNumber(); - if (body.quotation_number !== undefined && body.quotation_number !== null) { const taken = await isOfferNumberTaken(String(body.quotation_number)); if (taken) { @@ -151,6 +148,11 @@ export async function createOffer(body: Record) { } return prisma.$transaction(async (tx) => { + const quotationNumber = + body.quotation_number !== undefined && body.quotation_number !== null + ? String(body.quotation_number) + : await generateOfferNumber(tx); + const quotation = await tx.quotations.create({ data: { quotation_number: quotationNumber, @@ -161,7 +163,7 @@ export async function createOffer(body: Record) { : null, currency: body.currency ? String(body.currency) : "CZK", language: body.language ? String(body.language) : "cs", - vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0, + vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, apply_vat: body.apply_vat !== false, exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0, status: body.status ? String(body.status) : "active", @@ -320,53 +322,55 @@ export async function duplicateOffer(id: number) { }); if (!original) return null; - const nextOfferNumber = await generateOfferNumber(); + return prisma.$transaction(async (tx) => { + const nextOfferNumber = await generateOfferNumber(tx); - const copy = await prisma.quotations.create({ - data: { - quotation_number: nextOfferNumber, - project_code: original.project_code, - customer_id: original.customer_id, - valid_until: null, - currency: original.currency, - language: original.language, - vat_rate: original.vat_rate, - apply_vat: original.apply_vat, - exchange_rate: original.exchange_rate, - status: "active", - scope_title: original.scope_title, - scope_description: original.scope_description, - }, + const copy = await tx.quotations.create({ + data: { + quotation_number: nextOfferNumber, + project_code: original.project_code, + customer_id: original.customer_id, + valid_until: null, + currency: original.currency, + language: original.language, + vat_rate: original.vat_rate, + apply_vat: original.apply_vat, + exchange_rate: original.exchange_rate, + status: "active", + scope_title: original.scope_title, + scope_description: original.scope_description, + }, + }); + + if (original.quotation_items.length > 0) { + await tx.quotation_items.createMany({ + data: original.quotation_items.map((item) => ({ + quotation_id: copy.id, + description: item.description, + item_description: item.item_description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + is_included_in_total: item.is_included_in_total, + position: item.position, + })), + }); + } + + if (original.scope_sections.length > 0) { + await tx.scope_sections.createMany({ + data: original.scope_sections.map((s) => ({ + quotation_id: copy.id, + title: s.title, + title_cz: s.title_cz, + content: s.content, + position: s.position, + })), + }); + } + + return { copy, original }; }); - - if (original.quotation_items.length > 0) { - await prisma.quotation_items.createMany({ - data: original.quotation_items.map((item) => ({ - quotation_id: copy.id, - description: item.description, - item_description: item.item_description, - quantity: item.quantity, - unit: item.unit, - unit_price: item.unit_price, - is_included_in_total: item.is_included_in_total, - position: item.position, - })), - }); - } - - if (original.scope_sections.length > 0) { - await prisma.scope_sections.createMany({ - data: original.scope_sections.map((s) => ({ - quotation_id: copy.id, - title: s.title, - title_cz: s.title_cz, - content: s.content, - position: s.position, - })), - }); - } - - return { copy, original }; } export async function invalidateOffer(id: number) { diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index 7c9ca79..7d1edce 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -47,7 +47,9 @@ function enrichOrder(o: any) { 0, ); const vatAmount = o.apply_vat - ? subtotal * ((Number(o.vat_rate) || 21) / 100) + ? subtotal * + ((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) / + 100) : 0; const { order_items, order_sections, ...rest } = o; const invoice = o.invoices?.[0] || null; @@ -126,7 +128,7 @@ export async function getOrder(id: number) { }, invoices: { select: { id: true, invoice_number: true, status: true }, - take: 1, + orderBy: { id: "desc" }, }, }, }); @@ -290,66 +292,78 @@ interface CreateOrderData { } export async function createOrder(body: CreateOrderData) { - const orderNumber = - body.order_number !== undefined && body.order_number !== null - ? String(body.order_number) - : await generateSharedNumber(); + try { + return await prisma.$transaction(async (tx) => { + const orderNumber = + body.order_number !== undefined && body.order_number !== null + ? String(body.order_number) + : await generateSharedNumber(tx); - if (body.order_number !== undefined && body.order_number !== null) { - const taken = await isOrderNumberTaken(String(body.order_number)); - if (taken) { - return { error: "Číslo objednávky je již použito", status: 400 } as const; - } - } + if (body.order_number !== undefined && body.order_number !== null) { + const taken = await isOrderNumberTaken(String(body.order_number)); + if (taken) { + throw Object.assign(new Error("Číslo objednávky je již použito"), { + status: 400, + }); + } + } - return prisma.$transaction(async (tx) => { - const order = await tx.orders.create({ - data: { - order_number: orderNumber, - customer_order_number: body.customer_order_number ?? null, - quotation_id: body.quotation_id ?? null, - customer_id: body.customer_id ?? null, - status: body.status, - currency: body.currency, - language: body.language, - vat_rate: body.vat_rate, - apply_vat: body.apply_vat !== false, - exchange_rate: body.exchange_rate, - scope_title: body.scope_title ?? null, - scope_description: body.scope_description ?? null, - notes: body.notes ?? null, - }, + const order = await tx.orders.create({ + data: { + order_number: orderNumber, + customer_order_number: body.customer_order_number ?? null, + quotation_id: body.quotation_id ?? null, + customer_id: body.customer_id ?? null, + status: body.status, + currency: body.currency, + language: body.language, + vat_rate: body.vat_rate, + apply_vat: body.apply_vat !== false, + exchange_rate: body.exchange_rate, + scope_title: body.scope_title ?? null, + scope_description: body.scope_description ?? null, + notes: body.notes ?? null, + }, + }); + + if (Array.isArray(body.items)) { + await tx.order_items.createMany({ + data: body.items.map((item, i) => ({ + order_id: order.id, + description: item.description ?? null, + item_description: item.item_description ?? null, + quantity: item.quantity ?? 1, + unit: item.unit ?? null, + unit_price: item.unit_price ?? 0, + is_included_in_total: item.is_included_in_total !== false, + position: item.position ?? i, + })), + }); + } + + if (Array.isArray(body.sections)) { + await tx.order_sections.createMany({ + data: body.sections.map((s, i) => ({ + order_id: order.id, + title: s.title ?? null, + title_cz: s.title_cz ?? null, + content: s.content ?? null, + position: s.position ?? i, + })), + }); + } + + return { id: order.id, order_number: order.order_number }; }); - - if (Array.isArray(body.items)) { - await tx.order_items.createMany({ - data: body.items.map((item, i) => ({ - order_id: order.id, - description: item.description ?? null, - item_description: item.item_description ?? null, - quantity: item.quantity ?? 1, - unit: item.unit ?? null, - unit_price: item.unit_price ?? 0, - is_included_in_total: item.is_included_in_total !== false, - position: item.position ?? i, - })), - }); + } catch (err) { + if (err instanceof Error && "status" in err) { + return { + error: err.message, + status: (err as Error & { status: number }).status, + }; } - - if (Array.isArray(body.sections)) { - await tx.order_sections.createMany({ - data: body.sections.map((s, i) => ({ - order_id: order.id, - title: s.title ?? null, - title_cz: s.title_cz ?? null, - content: s.content ?? null, - position: s.position ?? i, - })), - }); - } - - return { id: order.id, order_number: order.order_number }; - }); + throw err; + } } interface UpdateOrderData { @@ -499,30 +513,34 @@ export async function deleteOrder(id: number) { if (!existing) return { error: "Objednávka nenalezena", status: 404 } as const; - // Clear quotation back-reference (matching PHP) - await prisma.quotations.updateMany({ - where: { order_id: id }, - data: { order_id: null }, - }); - - // Delete linked project and its notes (matching PHP) + // Fetch linked projects before the transaction for number release later const linkedProjects = await prisma.projects.findMany({ where: { order_id: id }, select: { id: true, created_at: true }, }); - if (linkedProjects.length > 0) { - const projectIds = linkedProjects.map((p) => p.id); - await prisma.project_notes.deleteMany({ - where: { project_id: { in: projectIds } }, + + await prisma.$transaction(async (tx) => { + // Clear quotation back-reference (matching PHP) + await tx.quotations.updateMany({ + where: { order_id: id }, + data: { order_id: null }, }); - await prisma.projects.deleteMany({ where: { order_id: id } }); - } - // Explicitly clean up child rows - await prisma.order_items.deleteMany({ where: { order_id: id } }); - await prisma.order_sections.deleteMany({ where: { order_id: id } }); + // Delete linked project and its notes (matching PHP) + if (linkedProjects.length > 0) { + const projectIds = linkedProjects.map((p) => p.id); + await tx.project_notes.deleteMany({ + where: { project_id: { in: projectIds } }, + }); + await tx.projects.deleteMany({ where: { order_id: id } }); + } - await prisma.orders.delete({ where: { id } }); + // Explicitly clean up child rows + await tx.order_items.deleteMany({ where: { order_id: id } }); + await tx.order_sections.deleteMany({ where: { order_id: id } }); + + await tx.orders.delete({ where: { id } }); + }); const releasedYears = new Set(); const year = existing.created_at diff --git a/src/services/users.service.ts b/src/services/users.service.ts index 88eae2a..1bfdc17 100644 --- a/src/services/users.service.ts +++ b/src/services/users.service.ts @@ -212,57 +212,77 @@ export async function updateUser( const data: Record = {}; - if (body.username !== undefined) { - const newUsername = String(body.username).trim(); - if (newUsername !== existing.username) { - const existingUsername = await prisma.users.findFirst({ - where: { username: newUsername }, - }); - if (existingUsername) { - return { - error: "Uživatelské jméno již existuje", - status: 409, - } as const; + try { + await prisma.$transaction(async (tx) => { + if (body.username !== undefined) { + const newUsername = String(body.username).trim(); + if (newUsername !== existing.username) { + const existingUsername = await tx.users.findFirst({ + where: { username: newUsername }, + }); + if (existingUsername) { + throw Object.assign(new Error("Uživatelské jméno již existuje"), { + status: 409, + }); + } + } + data.username = newUsername; } - } - data.username = newUsername; - } - if (body.email !== undefined) { - const newEmail = String(body.email).trim(); - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { - return { error: "Neplatný formát e-mailu", status: 400 } as const; - } - const existingEmail = await prisma.users.findFirst({ - where: { email: newEmail, id: { not: id } }, + if (body.email !== undefined) { + const newEmail = String(body.email).trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { + throw Object.assign(new Error("Neplatný formát e-mailu"), { + status: 400, + }); + } + const existingEmail = await tx.users.findFirst({ + where: { email: newEmail, id: { not: id } }, + }); + if (existingEmail) { + throw Object.assign(new Error("E-mail již existuje"), { + status: 409, + }); + } + data.email = newEmail; + } + + if (body.first_name !== undefined) + data.first_name = String(body.first_name); + if (body.last_name !== undefined) data.last_name = String(body.last_name); + if (body.role_id !== undefined) + data.role_id = body.role_id ? Number(body.role_id) : null; + if (body.is_active !== undefined) + data.is_active = + body.is_active === true || + body.is_active === 1 || + body.is_active === "1"; + if (body.password) { + const newPassword = String(body.password); + if (newPassword.length < 8) { + throw Object.assign(new Error("Heslo musí mít alespoň 8 znaků"), { + status: 400, + }); + } + data.password_hash = await bcrypt.hash( + newPassword, + config.security.bcryptCost, + ); + data.password_changed_at = new Date(); + } + + await tx.users.update({ where: { id }, data }); }); - if (existingEmail) { - return { error: "E-mail již existuje", status: 409 } as const; + } catch (err) { + if (err instanceof Error && "status" in err) { + return { + error: err.message, + status: (err as Error & { status: number }).status, + } as const; } - data.email = newEmail; + throw err; } - if (body.first_name !== undefined) data.first_name = String(body.first_name); - if (body.last_name !== undefined) data.last_name = String(body.last_name); - if (body.role_id !== undefined) - data.role_id = body.role_id ? Number(body.role_id) : null; - if (body.is_active !== undefined) - data.is_active = - body.is_active === true || body.is_active === 1 || body.is_active === "1"; - if (body.password) { - const newPassword = String(body.password); - if (newPassword.length < 8) { - return { error: "Heslo musí mít alespoň 8 znaků", status: 400 } as const; - } - data.password_hash = await bcrypt.hash( - newPassword, - config.security.bcryptCost, - ); - data.password_changed_at = new Date(); - } - - await prisma.users.update({ where: { id }, data }); - return { id, username: existing.username } as const; } @@ -276,8 +296,10 @@ export async function deleteUser(id: number, currentUserId?: number) { return { error: "Uživatel nenalezen", status: 404 } as const; } - await prisma.refresh_tokens.deleteMany({ where: { user_id: id } }); - await prisma.users.delete({ where: { id } }); + await prisma.$transaction(async (tx) => { + await tx.refresh_tokens.deleteMany({ where: { user_id: id } }); + await tx.users.delete({ where: { id } }); + }); return { id, username: existing.username } as const; } diff --git a/src/types/index.ts b/src/types/index.ts index f653b97..d479a88 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,7 +50,7 @@ export interface AuthData { roleId: number | null; roleName: string | null; permissions: string[]; - totp_enabled?: boolean; + totp_enabled: boolean; require_2fa?: boolean; } @@ -102,6 +102,8 @@ export interface PaginationQuery { export type AuditAction = | "login" + | "login_totp" + | "login_backup" | "logout" | "login_failed" | "create" diff --git a/src/utils/totp.ts b/src/utils/totp.ts index a6f1c78..32fe246 100644 --- a/src/utils/totp.ts +++ b/src/utils/totp.ts @@ -20,7 +20,9 @@ export const OTPAuth = { return { valid: false, counter: null }; } const currentCounter = Math.floor(Date.now() / 1000 / config.totp.period); - return { valid: true, counter: currentCounter + delta }; + // Only advance counter for current or past codes, not future ones + const counterDelta = Math.min(delta, 0); + return { valid: true, counter: currentCounter + counterDelta }; } catch (err) { console.error("TOTP verification error:", err); return { valid: false, counter: null }; diff --git a/tsconfig.server.json b/tsconfig.server.json index 40c7104..b0129ac 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -19,5 +19,13 @@ } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/admin", "src/context", "src/main.tsx", "src/App.tsx"] + "exclude": [ + "node_modules", + "dist", + "src/admin", + "src/context", + "src/main.tsx", + "src/App.tsx", + "src/__tests__" + ] }