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