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__"
+ ]
}