fix: security, validation, and data integrity fixes across 53 files

- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
  for inactive/locked users, locked_until check in loadAuthData, TOTP
  fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
  foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
  and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
  relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
  duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
  leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
  null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
  numbering.service releaseSequence no-op, nas-offers filename sanitize,
  Content-Disposition header injection fix, mojibake Czech strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-28 08:40:38 +02:00
parent 7f07032bf2
commit d7c7fbad88
52 changed files with 927 additions and 573 deletions

View File

@@ -7,13 +7,13 @@ HOST=127.0.0.1
APP_ENV=local APP_ENV=local
# Auth — MUST regenerate for production: openssl rand -hex 32 # 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 ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600 REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# TOTP — MUST regenerate for production: openssl rand -hex 32 # 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 # File storage
NAS_PATH=Z:/02_PROJEKTY NAS_PATH=Z:/02_PROJEKTY

View File

@@ -75,6 +75,19 @@ function NativeInput({
disabled, disabled,
}: NativeInputProps) { }: NativeInputProps) {
const type = modeToInputType[mode] || "date"; 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 ( return (
<input <input
type={type} type={type}
@@ -84,8 +97,8 @@ function NativeInput({
className="admin-form-input" className="admin-form-input"
required={required} required={required}
disabled={disabled} disabled={disabled}
min={minDate || undefined} min={minProp}
max={maxDate || undefined} max={maxProp}
/> />
); );
} }

View File

@@ -126,7 +126,7 @@ export default function AttendanceShiftTable({
if (records.length === 0) { if (records.length === 0) {
return ( return (
<div className="admin-empty-state"> <div className="admin-empty-state">
<p>Za tento mÄ­c nejsou žádnĂ© záznamy.</p> <p>Za tento měsíc nejsou žádné záznamy.</p>
</div> </div>
); );
} }
@@ -137,15 +137,15 @@ export default function AttendanceShiftTable({
<thead> <thead>
<tr> <tr>
<th>Datum</th> <th>Datum</th>
<th>ZamÄstnanec</th> <th>Zaměstnanec</th>
<th>Typ</th> <th>Typ</th>
<th>PĹĂ­chod</th> <th>Příchod</th>
<th>Pauza</th> <th>Pauza</th>
<th>Odchod</th> <th>Odchod</th>
<th>Hodiny</th> <th>Hodiny</th>
<th>Projekt</th> <th>Projekt</th>
<th>GPS</th> <th>GPS</th>
<th>Poznámka</th> <th>Poznámka</th>
<th>Akce</th> <th>Akce</th>
</tr> </tr>
</thead> </thead>

View File

@@ -57,7 +57,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
{ id, message, type: type as Alert["type"] }, { id, message, type: type as Alert["type"] },
]); ]);
if (duration > 0) { if (duration > 0) {
const timeoutId = setTimeout(() => removeAlert(id), duration); const timeoutId = setTimeout(() => {
timeoutsRef.current.delete(timeoutId);
removeAlert(id);
}, duration);
timeoutsRef.current.add(timeoutId); timeoutsRef.current.add(timeoutId);
} }
return id; return id;

View File

@@ -268,6 +268,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
login_token: loginToken, login_token: loginToken,
totp_code: code, totp_code: code,
remember_me: remember, remember_me: remember,
isBackup,
}), }),
}); });
const data = await response.json(); const data = await response.json();

View File

@@ -43,6 +43,7 @@ export default function useListData<T = unknown>(
const [initialLoad, setInitialLoad] = useState(true); const [initialLoad, setInitialLoad] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null); const [pagination, setPagination] = useState<PaginationData | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const extraParamsKey = Object.entries(extraParams) const extraParamsKey = Object.entries(extraParams)
@@ -100,8 +101,10 @@ export default function useListData<T = unknown>(
} }
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") return; if (err instanceof Error && err.name === "AbortError") return;
if (!mountedRef.current) return;
alert.error(errorMsg); alert.error(errorMsg);
} finally { } finally {
if (!mountedRef.current) return;
setLoading(false); setLoading(false);
setInitialLoad(false); setInitialLoad(false);
} }
@@ -117,8 +120,10 @@ export default function useListData<T = unknown>(
]); ]);
useEffect(() => { useEffect(() => {
mountedRef.current = true;
fetchData(); fetchData();
return () => { return () => {
mountedRef.current = false;
if (abortRef.current) abortRef.current.abort(); if (abortRef.current) abortRef.current.abort();
}; };
}, [fetchData]); }, [fetchData]);

View File

@@ -634,7 +634,8 @@ export default function Attendance() {
{projectLogs.length > 0 && ( {projectLogs.length > 0 && (
<div className="attendance-project-logs"> <div className="attendance-project-logs">
{projectLogs.map((log, i) => { {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 const end = log.ended_at
? new Date(log.ended_at) ? new Date(log.ended_at)
: new Date(); : new Date();
@@ -786,11 +787,12 @@ export default function Attendance() {
}} }}
> >
{shiftLogs.map((log, i) => { {shiftLogs.map((log, i) => {
if (!log.started_at) return null;
const mins = log.ended_at const mins = log.ended_at
? Math.floor( ? Math.floor(
(new Date(log.ended_at).getTime() - (new Date(log.ended_at).getTime() -
new Date( new Date(
log.started_at!, log.started_at,
).getTime()) / ).getTime()) /
60000, 60000,
) )

View File

@@ -449,8 +449,15 @@ export default function InvoiceDetail() {
}>({ show: false, status: null }); }>({ show: false, status: null });
const [pdfLoading, setPdfLoading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
// ─── Data loading ─── // ─── Data loading ───
useEffect(() => { useEffect(() => {
@@ -915,7 +922,8 @@ export default function InvoiceDetail() {
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url; if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba připojení"); alert.error("Chyba připojení");

View File

@@ -163,8 +163,8 @@ export default function LeaveApproval() {
: []), : []),
].sort( ].sort(
(a: LeaveRequest, b: LeaveRequest) => (a: LeaveRequest, b: LeaveRequest) =>
new Date(b.reviewed_at!).getTime() - (b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) -
new Date(a.reviewed_at!).getTime(), (a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0),
); );
setProcessedRequests(all); setProcessedRequests(all);

View File

@@ -323,6 +323,7 @@ export default function OfferDetail() {
const [customerOrderNumber, setCustomerOrderNumber] = useState(""); const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null); const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [companySettings, setCompanySettings] = useState<{ const [companySettings, setCompanySettings] = useState<{
default_currency: string; default_currency: string;
default_vat_rate: number; default_vat_rate: number;
@@ -341,6 +342,12 @@ export default function OfferDetail() {
useModalLock(showOrderModal); useModalLock(showOrderModal);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
useEffect(() => { useEffect(() => {
apiFetch(`${API_BASE}/company-settings`) apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json()) .then((r) => r.json())
@@ -829,7 +836,8 @@ export default function OfferDetail() {
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url; if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba při generování PDF"); alert.error("Chyba při generování PDF");

View File

@@ -3,6 +3,7 @@ import {
useEffect, useEffect,
useCallback, useCallback,
useMemo, useMemo,
useRef,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
@@ -121,6 +122,13 @@ export default function OrderDetail() {
const [confirmationLoading, setConfirmationLoading] = useState(false); const [confirmationLoading, setConfirmationLoading] = useState(false);
const initialNotesRef = useRef<string | null>(null); const initialNotesRef = useRef<string | null>(null);
const hasSetInitialSnapshot = useRef(false); const hasSetInitialSnapshot = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const fetchDetail = useCallback(async () => { const fetchDetail = useCallback(async () => {
try { try {
@@ -249,7 +257,8 @@ export default function OrderDetail() {
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url; if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba připojení"); alert.error("Chyba připojení");
@@ -293,7 +302,8 @@ export default function OrderDetail() {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
alert.error("Chyba připojení"); alert.error("Chyba připojení");
} finally { } finally {

View File

@@ -161,6 +161,7 @@ export default function ReceivedInvoices({
const [statsLoading, setStatsLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0); const slideDirection = useRef(0);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [slideKey, setSlideKey] = useState(0); const [slideKey, setSlideKey] = useState(0);
const prevMonth = useRef(statsMonth); const prevMonth = useRef(statsMonth);
const prevYear = useRef(statsYear); const prevYear = useRef(statsYear);
@@ -185,6 +186,12 @@ export default function ReceivedInvoices({
useModalLock(uploadOpen || editOpen); useModalLock(uploadOpen || editOpen);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
useEffect(() => { useEffect(() => {
const prev = prevYear.current * 12 + prevMonth.current; const prev = prevYear.current * 12 + prevMonth.current;
const curr = statsYear * 12 + statsMonth; const curr = statsYear * 12 + statsMonth;
@@ -516,7 +523,8 @@ export default function ReceivedInvoices({
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url; if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000); const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch { } catch {
newWindow?.close(); newWindow?.close();
alert.error("Chyba připojení"); alert.error("Chyba připojení");

View File

@@ -53,11 +53,11 @@ export const config = {
totp: { totp: {
encryptionKey: required("TOTP_ENCRYPTION_KEY"), encryptionKey: required("TOTP_ENCRYPTION_KEY"),
algorithm: (process.env.TOTP_ALGORITHM || "SHA1") as "SHA1", algorithm: (process.env.TOTP_ALGORITHM || "SHA1") as "SHA1",
digits: parseInt(process.env.TOTP_DIGITS || "6", 10), digits: Math.max(6, parseInt(process.env.TOTP_DIGITS || "6", 10) || 6),
period: parseInt(process.env.TOTP_PERIOD || "30", 10), period: Math.max(15, parseInt(process.env.TOTP_PERIOD || "30", 10) || 30),
loginTokenExpiryMinutes: parseInt( loginTokenExpiryMinutes: Math.max(
process.env.LOGIN_TOKEN_EXPIRY_MINUTES || "5", 1,
10, parseInt(process.env.LOGIN_TOKEN_EXPIRY_MINUTES || "5", 10) || 5,
), ),
}, },

View File

@@ -12,6 +12,8 @@ export async function securityHeaders(
"Permissions-Policy", "Permissions-Policy",
"camera=(), microphone=(), geolocation=(self)", "camera=(), microphone=(), geolocation=(self)",
); );
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
if (config.isProduction) { if (config.isProduction) {
reply.header( reply.header(
@@ -27,6 +29,9 @@ export async function securityHeaders(
"font-src 'self' https://fonts.gstatic.com", "font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org", "img-src 'self' data: blob: https://*.tile.openstreetmap.org",
"connect-src 'self' https://nominatim.openstreetmap.org", "connect-src 'self' https://nominatim.openstreetmap.org",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
].join("; "), ].join("; "),
); );
} }

View File

@@ -144,21 +144,21 @@ export default async function attendanceRoutes(
// --- action=attendance_users: users with attendance.record permission --- // --- action=attendance_users: users with attendance.record permission ---
if (action === "attendance_users") { 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({ const users = await prisma.users.findMany({
where: { where: {
is_active: true, is_active: true,
roles: { roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: { role_permissions: {
some: { permissions: { name: "attendance.record" } }, some: { permissions: { name: "attendance.record" } },
}, },
}, },
],
},
},
}, },
select: { id: true, first_name: true, last_name: true, username: true }, select: { id: true, first_name: true, last_name: true, username: true },
orderBy: { last_name: "asc" }, orderBy: { last_name: "asc" },
@@ -182,6 +182,12 @@ export default async function attendanceRoutes(
// --- action=project_logs: get project logs for a specific attendance record --- // --- action=project_logs: get project logs for a specific attendance record ---
if (action === "project_logs") { 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); const attendanceId = Number(query.attendance_id);
if (!attendanceId) return error(reply, "Missing attendance_id", 400); if (!attendanceId) return error(reply, "Missing attendance_id", 400);
const data = await attendanceService.getProjectLogs(attendanceId); const data = await attendanceService.getProjectLogs(attendanceId);
@@ -411,7 +417,7 @@ export default async function attendanceRoutes(
// PUT /api/admin/attendance/:id // PUT /api/admin/attendance/:id
fastify.put<{ Params: { id: string } }>( fastify.put<{ Params: { id: string } }>(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requirePermission("attendance.edit") },
async (request, reply) => { async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;

View File

@@ -95,7 +95,7 @@ export default async function authRoutes(
{ {
config: { config: {
rateLimit: { rateLimit: {
max: 20, max: 5,
timeWindow: "1 minute", timeWindow: "1 minute",
}, },
}, },
@@ -104,10 +104,8 @@ export default async function authRoutes(
async (request, reply) => { async (request, reply) => {
const parsed = parseBody(TotpVerifySchema, request.body); const parsed = parseBody(TotpVerifySchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const { login_token, totp_code } = parsed.data; const { login_token, totp_code, remember_me } = parsed.data;
const rawBody = request.body as unknown as Record<string, unknown>; const rememberMe = remember_me ?? false;
const rememberMe =
rawBody.remember_me === true || rawBody.remember_me === "true";
const tokenHash = crypto const tokenHash = crypto
.createHash("sha256") .createHash("sha256")
@@ -130,8 +128,6 @@ export default async function authRoutes(
const storedTokenId = Number(storedToken.id); const storedTokenId = Number(storedToken.id);
const storedUserId = Number(storedToken.user_id); const storedUserId = Number(storedToken.user_id);
await tx.totp_login_tokens.delete({ where: { id: storedTokenId } });
const user = await tx.users.findUnique({ const user = await tx.users.findUnique({
where: { id: storedUserId }, where: { id: storedUserId },
include: { roles: true }, include: { roles: true },
@@ -141,11 +137,15 @@ export default async function authRoutes(
return { error: "Uživatel nenalezen", status: 401 }; 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()) { if (user.locked_until && new Date(user.locked_until) > new Date()) {
return { error: "Účet je dočasně uzamčen", status: 429 }; return { error: "Účet je dočasně uzamčen", status: 429 };
} }
return { user }; return { user, storedTokenId };
}); });
if ("error" in totpResult) { if ("error" in totpResult) {
@@ -183,6 +183,11 @@ export default async function authRoutes(
return error(reply, "TOTP kód již byl použit", 401); 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) // Reset failed attempts and update last login (TOTP verified = successful login)
await prisma.users.update({ await prisma.users.update({
where: { id: user.id }, where: { id: user.id },
@@ -234,6 +239,16 @@ export default async function authRoutes(
}); });
setRefreshCookie(reply, refreshTokenRaw, rememberMe); 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 }); return success(reply, { access_token: accessToken, user: authData });
}, },
); );
@@ -278,7 +293,7 @@ export default async function authRoutes(
); );
// POST /api/admin/logout // POST /api/admin/logout
fastify.post("/logout", async (request, reply) => { fastify.post("/logout", { bodyLimit: 10240 }, async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token; const refreshTokenRaw = request.cookies.refresh_token;
if (refreshTokenRaw) { if (refreshTokenRaw) {
await logout(refreshTokenRaw); await logout(refreshTokenRaw);

View File

@@ -135,6 +135,7 @@ export default async function companySettingsRoutes(
); );
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => { fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
// Use upsert to avoid race condition between findFirst and create
let settings = await prisma.company_settings.findFirst({ let settings = await prisma.company_settings.findFirst({
select: { select: {
id: true, id: true,
@@ -176,7 +177,56 @@ export default async function companySettingsRoutes(
}); });
if (!settings) { if (!settings) {
settings = await prisma.company_settings.create({ // 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: { data: {
company_name: "", company_name: "",
quotation_prefix: "N", quotation_prefix: "N",
@@ -221,6 +271,7 @@ export default async function companySettingsRoutes(
invoice_number_pattern: true, invoice_number_pattern: true,
}, },
}); });
});
} }
if (!settings) return error(reply, "Nastavení nenalezeno", 500); if (!settings) return error(reply, "Nastavení nenalezeno", 500);
@@ -429,7 +480,7 @@ export default async function companySettingsRoutes(
: existingOrder, : existingOrder,
); );
} }
data.sync_version = (existing.sync_version ?? 0) + 1; data.sync_version = { increment: 1 };
await prisma.company_settings.update({ await prisma.company_settings.update({
where: { id: existing.id }, where: { id: existing.id },

View File

@@ -180,9 +180,9 @@ export default async function dashboardRoutes(
// Invoices — only for invoices.view // Invoices — only for invoices.view
if (has("invoices.view")) { if (has("invoices.view")) {
// $queryRaw template literal interpolation with Date objects fails on // $queryRaw template literal interpolation with Date objects fails on
// MySQL when Date.toJSON is overridden — pass strings instead. // MySQL when Date.toJSON is overridden — pass explicit date strings instead.
const monthStartStr = monthStart.toJSON(); const monthStartStr = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}-01`;
const monthEndStr = monthEnd.toJSON(); const monthEndStr = `${monthEnd.getFullYear()}-${String(monthEnd.getMonth() + 1).padStart(2, "0")}-01`;
const [unpaidCount, revenueAgg] = await Promise.all([ const [unpaidCount, revenueAgg] = await Promise.all([
prisma.invoices.count({ where: { status: "issued" } }), prisma.invoices.count({ where: { status: "issued" } }),

View File

@@ -26,10 +26,16 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
export default async function invoicesRoutes( export default async function invoicesRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
): Promise<void> { ): Promise<void> {
// 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) => { fastify.addHook("onRequest", async (request) => {
if (request.method !== "GET") return; if (request.method !== "GET") return;
if (Date.now() - lastOverdueCheck > OVERDUE_CHECK_INTERVAL) {
lastOverdueCheck = Date.now();
await markOverdueInvoices(); await markOverdueInvoices();
}
}); });
// GET /api/admin/invoices // GET /api/admin/invoices
@@ -226,12 +232,10 @@ export default async function invoicesRoutes(
const file = nasFinancialsManager.readIssuedInvoice(relPath); const file = nasFinancialsManager.readIssuedInvoice(relPath);
if (!file) return error(reply, "PDF soubor nenalezen", 404); if (!file) return error(reply, "PDF soubor nenalezen", 404);
const safeName = invoice.invoice_number.replace(/[\r\n"]/g, "");
return reply return reply
.type("application/pdf") .type("application/pdf")
.header( .header("Content-Disposition", `inline; filename="${safeName}.pdf"`)
"Content-Disposition",
`inline; filename="${invoice.invoice_number}.pdf"`,
)
.send(file.data); .send(file.data);
}, },
); );

View File

@@ -241,20 +241,20 @@ export default async function leaveRequestsRoutes(
const totalHours = totalBusinessDays * 8; const totalHours = totalBusinessDays * 8;
try {
await prisma.$transaction(async (tx) => {
// Check for duplicate attendance records inside the transaction
for (const ac of attendanceCreates) { for (const ac of attendanceCreates) {
const duplicate = await prisma.attendance.findFirst({ const duplicate = await tx.attendance.findFirst({
where: { user_id: ac.user_id, shift_date: ac.shift_date }, where: { user_id: ac.user_id, shift_date: ac.shift_date },
}); });
if (duplicate) { if (duplicate) {
return error( throw new Error(
reply,
"Pro zvolené datumy již existují záznamy docházky", "Pro zvolené datumy již existují záznamy docházky",
400,
); );
} }
} }
await prisma.$transaction(async (tx) => {
// 1. Create attendance records for each business day // 1. Create attendance records for each business day
if (attendanceCreates.length > 0) { if (attendanceCreates.length > 0) {
await tx.attendance.createMany({ data: attendanceCreates }); await tx.attendance.createMany({ data: attendanceCreates });
@@ -305,6 +305,19 @@ export default async function leaveRequestsRoutes(
}, },
}); });
}); });
} 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 logAudit({ await logAudit({
request, request,

View File

@@ -38,12 +38,7 @@ export default async function profileRoutes(
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
if (body.email) { if (body.email) {
const newEmail = String(body.email).trim(); data.email = 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;
} }
if (body.first_name) data.first_name = String(body.first_name); if (body.first_name) data.first_name = String(body.first_name);
if (body.last_name) data.last_name = String(body.last_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(); 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({ await logAudit({
request, request,

View File

@@ -73,10 +73,11 @@ export default async function projectFilesRoutes(
if (!result) return error(reply, "Soubor nebyl nalezen", 404); if (!result) return error(reply, "Soubor nebyl nalezen", 404);
const stream = fs.createReadStream(result.filePath); const stream = fs.createReadStream(result.filePath);
const encodedName = encodeURIComponent(result.fileName);
return reply return reply
.header( .header(
"Content-Disposition", "Content-Disposition",
`attachment; filename="${encodeURIComponent(result.fileName)}"`, `attachment; filename*=UTF-8''${encodedName}`,
) )
.header("Content-Type", result.mime) .header("Content-Type", result.mime)
.header("X-Content-Type-Options", "nosniff") .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"); if (!file) return error(reply, "Nebyl nahrán žádný soubor");
const subPath = parsedQuery.data.path || ""; const subPath = parsedQuery.data.path || "";
const fileName = file.filename; const rawFileName = file.filename;
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
const err = await fm.uploadFile( const err = await fm.uploadFile(
project.project_number, project.project_number,

View File

@@ -13,6 +13,7 @@ import {
} from "../../schemas/received-invoices.schema"; } from "../../schemas/received-invoices.schema";
import { nasFinancialsManager } from "../../services/nas-financials-manager"; import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { toCzk } from "../../services/exchange-rates"; import { toCzk } from "../../services/exchange-rates";
import path from "path";
const VALID_STATUSES = ["unpaid", "paid"] as const; const VALID_STATUSES = ["unpaid", "paid"] as const;
@@ -126,9 +127,17 @@ export default async function receivedInvoicesRoutes(
return Math.round(total * 100) / 100; 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({ 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, { return success(reply, {
@@ -137,8 +146,10 @@ export default async function receivedInvoicesRoutes(
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"), vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"), vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
unpaid: aggregateByCurrency(allUnpaid, "amount"), unpaid: aggregateByCurrency(allUnpaid, "amount"),
unpaid_czk: await sumCzk(allUnpaid, "amount"), unpaid_czk: stats._sum.amount_czk
unpaid_count: allUnpaid.length, ? Math.round(Number(stats._sum.amount_czk) * 100) / 100
: await sumCzk(allUnpaid, "amount"),
unpaid_count: stats._count,
month_count: monthInvoices.length, month_count: monthInvoices.length,
}); });
}, },
@@ -188,12 +199,10 @@ export default async function receivedInvoicesRoutes(
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404); if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = invoice.file_mime || "application/pdf"; const mime = invoice.file_mime || "application/pdf";
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
return reply return reply
.type(mime) .type(mime)
.header( .header("Content-Disposition", `inline; filename="${safeFileName}"`)
"Content-Disposition",
`inline; filename="${invoice.file_name}"`,
)
.send(nasFile.data); .send(nasFile.data);
}, },
); );
@@ -315,7 +324,9 @@ export default async function receivedInvoicesRoutes(
status: "unpaid", status: "unpaid",
notes: meta.notes ? String(meta.notes) : null, notes: meta.notes ? String(meta.notes) : null,
uploaded_by: request.authData?.userId, uploaded_by: request.authData?.userId,
file_name: file.name, file_name: nasResult.filePath
? path.basename(nasResult.filePath)
: file.name,
file_mime: file.mime, file_mime: file.mime,
file_size: file.size, file_size: file.size,
}, },
@@ -364,7 +375,7 @@ export default async function receivedInvoicesRoutes(
vat_rate: vatRate, vat_rate: vatRate,
vat_amount: vat_amount:
vatRate > 0 vatRate > 0
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100 ? Math.round(((amount * vatRate) / 100) * 100) / 100
: 0, : 0,
issue_date: body.issue_date issue_date: body.issue_date
? new Date(String(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); 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) { if (existing.file_name) {
const relPath = nasFinancialsManager.buildReceivedPath( const relPath = nasFinancialsManager.buildReceivedPath(
existing.file_name, existing.file_name,
@@ -552,8 +566,6 @@ export default async function receivedInvoicesRoutes(
); );
nasFinancialsManager.deleteReceivedInvoice(relPath); nasFinancialsManager.deleteReceivedInvoice(relPath);
} }
await prisma.received_invoices.delete({ where: { id } });
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,

View File

@@ -62,12 +62,16 @@ export default async function rolesRoutes(
}); });
if (Array.isArray(body.permission_ids)) { if (Array.isArray(body.permission_ids)) {
await prisma.role_permissions.createMany({ await prisma.$transaction(
data: (body.permission_ids as number[]).map((pid) => ({ (body.permission_ids as number[]).map((pid) =>
prisma.role_permissions.create({
data: {
role_id: role.id, role_id: role.id,
permission_id: pid, permission_id: pid,
})), },
}); }),
),
);
} }
await logAudit({ await logAudit({

View File

@@ -188,10 +188,11 @@ export default async function scopeTemplatesRoutes(
}); });
if (Array.isArray(body.sections)) { if (Array.isArray(body.sections)) {
await prisma.scope_template_sections.deleteMany({ await prisma.$transaction(async (tx) => {
await tx.scope_template_sections.deleteMany({
where: { scope_template_id: id }, where: { scope_template_id: id },
}); });
await prisma.scope_template_sections.createMany({ await tx.scope_template_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({ data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
scope_template_id: id, scope_template_id: id,
title: s.title ?? null, title: s.title ?? null,
@@ -200,6 +201,7 @@ export default async function scopeTemplatesRoutes(
position: s.position ?? i, position: s.position ?? i,
})), })),
}); });
});
} }
return success(reply, { id }, 200, "Šablona byla uložena"); return success(reply, { id }, 200, "Šablona byla uložena");

View File

@@ -6,11 +6,17 @@ import { requireAuth, requirePermission } from "../../middleware/auth";
import { success, error } from "../../utils/response"; import { success, error } from "../../utils/response";
import { encrypt } from "../../utils/encryption"; import { encrypt } from "../../utils/encryption";
import { getSystemSettings } from "../../services/system-settings"; import { getSystemSettings } from "../../services/system-settings";
import { config } from "../../config/env";
import { OTPAuth } from "../../utils/totp"; import { OTPAuth } from "../../utils/totp";
import * as OTPAuthLib from "otpauth"; import * as OTPAuthLib from "otpauth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { parseBody } from "../../schemas/common"; 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( export default async function totpRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
@@ -47,12 +53,9 @@ export default async function totpRoutes(
"/enable", "/enable",
{ preHandler: requireAuth, bodyLimit: 10240 }, { preHandler: requireAuth, bodyLimit: 10240 },
async (request, reply) => { async (request, reply) => {
const body = request.body as Record<string, unknown>; const parsed = parseBody(TotpEnableSchema, request.body);
const { secret, code } = body; if ("error" in parsed) return error(reply, parsed.error, 400);
const { secret, code, password, current_code } = parsed.data;
if (!secret || !code) {
return error(reply, "Secret a kód jsou povinné", 400);
}
const user = await prisma.users.findUnique({ const user = await prisma.users.findUnique({
where: { id: request.authData!.userId }, 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) return error(reply, "Uživatel nenalezen", 404);
if (user.totp_enabled) { if (user.totp_enabled) {
if (!body.current_code) { if (!current_code) {
return error( return error(
reply, reply,
"Aktuální TOTP kód je povinný pro změnu 2FA", "Aktuální TOTP kód je povinný pro změnu 2FA",
400, 400,
); );
} }
const verifyResult = OTPAuth.verify( const verifyResult = OTPAuth.verify(user.totp_secret!, current_code);
user.totp_secret!,
String(body.current_code),
);
if (!verifyResult.valid) { if (!verifyResult.valid) {
return error(reply, "Neplatný aktuální TOTP kód", 400); return error(reply, "Neplatný aktuální TOTP kód", 400);
} }
} else { } else {
if (!body.password) { if (!password) {
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400); return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
} }
const valid = await bcrypt.compare( const valid = await bcrypt.compare(password, user.password_hash);
String(body.password),
user.password_hash,
);
if (!valid) { if (!valid) {
return error(reply, "Nesprávné heslo", 400); return error(reply, "Nesprávné heslo", 400);
} }
} }
const totp = new OTPAuthLib.TOTP({ const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(String(secret)), secret: OTPAuthLib.Secret.fromBase32(secret),
algorithm: "SHA1", algorithm: "SHA1",
digits: 6, digits: 6,
period: 30, period: 30,
}); });
const delta = totp.validate({ token: String(code), window: 1 }); const delta = totp.validate({ token: code, window: 1 });
if (delta === null) { if (delta === null) {
return error(reply, "Neplatný TOTP kód", 400); return error(reply, "Neplatný TOTP kód", 400);
} }
@@ -103,9 +100,11 @@ export default async function totpRoutes(
const backupCodesPlain: string[] = []; const backupCodesPlain: string[] = [];
const backupCodesHashed: string[] = []; const backupCodesHashed: string[] = [];
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
const code = crypto.randomBytes(4).toString("hex").toUpperCase(); const plainCode = crypto.randomBytes(4).toString("hex").toUpperCase();
backupCodesPlain.push(code); backupCodesPlain.push(plainCode);
backupCodesHashed.push(bcrypt.hashSync(code, 10)); backupCodesHashed.push(
await bcrypt.hash(plainCode, config.security.bcryptCost),
);
} }
const encryptedSecret = encrypt(String(secret)); const encryptedSecret = encrypt(String(secret));
@@ -140,11 +139,9 @@ export default async function totpRoutes(
"/disable", "/disable",
{ preHandler: requireAuth }, { preHandler: requireAuth },
async (request, reply) => { async (request, reply) => {
const body = request.body as Record<string, unknown>; const parsed = parseBody(TotpDisableSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
if (!body.code) { const { code } = parsed.data;
return error(reply, "TOTP kód je povinný pro deaktivaci", 400);
}
const user = await prisma.users.findUnique({ const user = await prisma.users.findUnique({
where: { id: request.authData!.userId }, where: { id: request.authData!.userId },
@@ -153,7 +150,7 @@ export default async function totpRoutes(
return error(reply, "2FA není aktivní", 400); 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) { if (!verifyResult.valid) {
return error(reply, "Neplatný TOTP kód", 400); return error(reply, "Neplatný TOTP kód", 400);
} }
@@ -214,10 +211,9 @@ export default async function totpRoutes(
bodyLimit: 10240, bodyLimit: 10240,
}, },
async (request, reply) => { async (request, reply) => {
const body = request.body as Record<string, unknown>; const parsed = parseBody(TotpRequiredSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const required = const required = parsed.data.required;
body.required === true || body.required === 1 || body.required === "1";
const settings = await prisma.company_settings.findFirst({ const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true }, select: { require_2fa: true },
@@ -339,7 +335,9 @@ export default async function totpRoutes(
return { error: "Neplatný záložní kód", status: 401 }; 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); backupCodes.splice(matchIndex, 1);
await tx.users.update({ await tx.users.update({
@@ -408,6 +406,14 @@ export default async function totpRoutes(
maxAge: config.jwt.refreshTokenSessionExpiry, 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 }); return success(reply, { access_token: accessToken, user: authData });
}, },
); );

View File

@@ -38,9 +38,15 @@ export default async function tripsRoutes(
mo = NaN; mo = NaN;
} }
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) { 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 = { where.trip_date = {
gte: new Date(yr, mo - 1, 1), gte: new Date(monthStart),
lt: new Date(yr, mo, 1), lt: new Date(nextMonth),
}; };
} }
} }
@@ -75,17 +81,10 @@ export default async function tripsRoutes(
where: { where: {
is_active: true, is_active: true,
roles: { roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: { role_permissions: {
some: { permissions: { name: "trips.record" } }, some: { permissions: { name: "trips.record" } },
}, },
}, },
],
},
},
}, },
select: { select: {
id: true, id: true,
@@ -120,9 +119,17 @@ export default async function tripsRoutes(
if (filterUserId) where.user_id = filterUserId; if (filterUserId) where.user_id = filterUserId;
if (filterVehicleId) where.vehicle_id = filterVehicleId; if (filterVehicleId) where.vehicle_id = filterVehicleId;
if (query.month && query.year) { 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 = { where.trip_date = {
gte: new Date(Number(query.year), Number(query.month) - 1, 1), gte: new Date(monthStart),
lt: new Date(Number(query.year), Number(query.month), 1), lt: new Date(nextMonth),
}; };
} }

View File

@@ -131,6 +131,18 @@ export default async function vehiclesRoutes(
const existing = await prisma.vehicles.findUnique({ where: { id } }); const existing = await prisma.vehicles.findUnique({ where: { id } });
if (!existing) return error(reply, "Vozidlo nenalezeno", 404); 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 prisma.vehicles.delete({ where: { id } });
await logAudit({ await logAudit({
request, request,

View File

@@ -55,7 +55,10 @@ export const AttendanceLeaveSchema = z.object({
.optional(), .optional(),
date_from: z.string().min(1, "Datum je povinné"), date_from: z.string().min(1, "Datum je povinné"),
date_to: z.string().optional(), 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 leave_hours: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
@@ -65,16 +68,8 @@ export const AttendanceLeaveSchema = z.object({
const ProjectLogSchema = z.object({ const ProjectLogSchema = z.object({
project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
hours: z hours: z.coerce.number().min(0).default(0),
.union([z.number(), z.string()]) minutes: z.coerce.number().min(0).default(0),
.transform((v) => Number(v) || 0)
.optional()
.default(0),
minutes: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 0)
.optional()
.default(0),
}); });
export const AttendancePunchSchema = z.object({ export const AttendancePunchSchema = z.object({
@@ -124,7 +119,10 @@ export const CreateAttendanceSchema = z.object({
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
.nullish(), .nullish(),
leave_type: z.string().optional().default("work"), leave_type: z
.enum(["work", "vacation", "sick", "holiday", "unpaid"])
.optional()
.default("work"),
leave_hours: z leave_hours: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))
@@ -138,8 +136,13 @@ export const UpdateAttendanceSchema = z.object({
break_start: z.union([z.string(), z.null()]).optional(), break_start: z.union([z.string(), z.null()]).optional(),
break_end: z.union([z.string(), z.null()]).optional(), break_end: z.union([z.string(), z.null()]).optional(),
notes: z.string().nullish(), notes: z.string().nullish(),
project_id: z.union([z.number(), z.string(), z.null()]).optional(), project_id: z
leave_type: z.string().optional(), .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(), leave_hours: z.union([z.number(), z.string(), z.null()]).optional(),
project_logs: z.array(ProjectLogSchema).optional(), project_logs: z.array(ProjectLogSchema).optional(),
}); });

View File

@@ -9,6 +9,7 @@ export const LoginSchema = z.object({
export const TotpVerifySchema = z.object({ export const TotpVerifySchema = z.object({
login_token: z.string().min(1, "Token je povinný"), login_token: z.string().min(1, "Token je povinný"),
totp_code: z.string().length(6, "Kód musí mít 6 číslic"), 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({ 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ý"), 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<typeof LoginSchema>; export type LoginInput = z.infer<typeof LoginSchema>;
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>; export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>; export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;

View File

@@ -4,13 +4,21 @@ const InvoiceItemSchema = z.object({
description: z.string().nullish(), description: z.string().nullish(),
quantity: z quantity: z
.union([z.number(), z.string()]) .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() .optional()
.default(1), .default(1),
unit: z.string().nullish(), unit: z.string().nullish(),
unit_price: z unit_price: z
.union([z.number(), z.string()]) .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() .optional()
.default(0), .default(0),
vat_rate: z vat_rate: z
@@ -73,7 +81,10 @@ export const UpdateInvoiceSchema = z.object({
bank_iban: z.string().nullish(), bank_iban: z.string().nullish(),
bank_account: z.string().nullish(), bank_account: z.string().nullish(),
issued_by: 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 vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))

View File

@@ -1,14 +1,18 @@
import { z } from "zod"; import { z } from "zod";
export const CreateLeaveRequestSchema = z.object({ 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_from: z.string().min(1, "Datum od je povinné"),
date_to: z.string().min(1, "Datum do je povinné"), date_to: z.string().min(1, "Datum do je povinné"),
notes: z.string().nullish(), notes: z.string().nullish(),
}); });
export const ReviewLeaveRequestSchema = z.object({ 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(), reviewer_note: z.string().nullish(),
}); });

View File

@@ -66,7 +66,7 @@ export const CreateQuotationSchema = z.object({
.optional() .optional()
.default(1.0), .default(1.0),
status: z status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"]) .enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.optional() .optional()
.default("nova"), .default("nova"),
scope_title: z.string().nullish(), 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" }) .refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(), .optional(),
status: z status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"]) .enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.optional(), .optional(),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
scope_description: z.string().nullish(), scope_description: z.string().nullish(),

View File

@@ -96,7 +96,10 @@ export const UpdateOrderSchema = z.object({
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
scope_description: z.string().nullish(), scope_description: z.string().nullish(),
notes: 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 vat_rate: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))

View File

@@ -34,10 +34,22 @@ export const UpdateProjectSchema = z.object({
name: z.string().nullish(), name: z.string().nullish(),
status: z.string().optional(), status: z.string().optional(),
notes: z.string().nullish(), notes: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(), customer_id: z
responsible_user_id: z.union([z.number(), z.string(), z.null()]).optional(), .union([z.number(), z.string(), z.null()])
quotation_id: z.union([z.number(), z.string(), z.null()]).optional(), .transform((v) => (v === null ? null : Number(v)))
order_id: z.union([z.number(), z.string(), z.null()]).optional(), .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(), start_date: z.union([z.string(), z.null()]).optional(),
end_date: z.union([z.string(), z.null()]).optional(), end_date: z.union([z.string(), z.null()]).optional(),
}); });

View File

@@ -24,7 +24,7 @@ export const CreateReceivedInvoiceSchema = z.object({
.default(0), .default(0),
issue_date: z.string().nullish(), issue_date: z.string().nullish(),
due_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(), notes: z.string().nullish(),
}); });
@@ -48,7 +48,7 @@ export const UpdateReceivedInvoiceSchema = z.object({
issue_date: z.union([z.string(), z.null()]).optional(), issue_date: z.union([z.string(), z.null()]).optional(),
due_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(), paid_date: z.union([z.string(), z.null()]).optional(),
status: z.string().optional(), status: z.enum(["unpaid", "paid"]).optional(),
notes: z.string().nullish(), notes: z.string().nullish(),
month: z month: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])

View File

@@ -2,6 +2,8 @@ import { z } from "zod";
export const CreateTripSchema = z.object({ export const CreateTripSchema = z.object({
vehicle_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), 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 user_id: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => Number(v)) .transform((v) => Number(v))

View File

@@ -6,7 +6,10 @@ export const CreateUserSchema = z.object({
password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"), password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"),
first_name: z.string().min(1, "Jméno je povinné"), first_name: z.string().min(1, "Jméno je povinné"),
last_name: z.string().min(1, "Příjmení 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 is_active: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional() .optional()
@@ -22,7 +25,10 @@ export const UpdateUserSchema = z.object({
), ),
first_name: z.string().optional(), first_name: z.string().optional(),
last_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 is_active: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional(), .optional(),

View File

@@ -1,4 +1,5 @@
import Fastify from "fastify"; import Fastify from "fastify";
import type { ScheduledTask } from "node-cron";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit"; import rateLimit from "@fastify/rate-limit";
@@ -42,7 +43,7 @@ const app = Fastify({
}); });
async function start() { async function start() {
let invoiceAlertCron: any = null; let invoiceAlertCron: ScheduledTask | null = null;
// --- Plugins --- // --- Plugins ---
await app.register(cors, { await app.register(cors, {

View File

@@ -4,23 +4,16 @@ import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays";
import { localDateStr } from "../utils/date"; import { localDateStr } from "../utils/date";
import { getSystemSettings } from "./system-settings"; 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() { async function getAttendanceUsers() {
return prisma.users.findMany({ return prisma.users.findMany({
where: { where: {
is_active: true, is_active: true,
roles: { roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: { role_permissions: {
some: { permissions: { name: "attendance.record" } }, some: { permissions: { name: "attendance.record" } },
}, },
}, },
],
},
},
}, },
select: { id: true, first_name: true, last_name: true }, select: { id: true, first_name: true, last_name: true },
orderBy: { last_name: "asc" }, orderBy: { last_name: "asc" },
@@ -73,11 +66,13 @@ function calcWorkedHours(
} }
const roundUp = (d: Date, minutes: number) => { const roundUp = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000; const ms = minutes * 60 * 1000;
return new Date(Math.ceil(d.getTime() / ms) * ms); return new Date(Math.ceil(d.getTime() / ms) * ms);
}; };
const roundDown = (d: Date, minutes: number) => { const roundDown = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000; const ms = minutes * 60 * 1000;
return new Date(Math.floor(d.getTime() / ms) * ms); return new Date(Math.floor(d.getTime() / ms) * ms);
}; };
@@ -1143,6 +1138,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
let inserted = 0; let inserted = 0;
let skipped = 0; let skipped = 0;
await prisma.$transaction(async (tx) => {
for (const userId of data.user_ids.map(Number)) { for (const userId of data.user_ids.map(Number)) {
for (let day = 1; day <= daysInMonth; day++) { for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(yr, mo - 1, day); const date = new Date(yr, mo - 1, day);
@@ -1159,7 +1155,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
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)) { if (isHoliday(dateStr)) {
await prisma.attendance.create({ await tx.attendance.create({
data: { data: {
user_id: userId, user_id: userId,
shift_date: shiftDate, shift_date: shiftDate,
@@ -1171,7 +1167,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
continue; continue;
} }
await prisma.attendance.create({ await tx.attendance.create({
data: { data: {
user_id: userId, user_id: userId,
shift_date: shiftDate, shift_date: shiftDate,
@@ -1185,6 +1181,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
inserted++; inserted++;
} }
} }
});
let msg = `Vytvořeno ${inserted} záznamů`; let msg = `Vytvořeno ${inserted} záznamů`;
if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`; if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`;
@@ -1212,10 +1209,12 @@ export async function createLeave(data: LeaveData, authUserId: number) {
const end = new Date(dateTo); const end = new Date(dateTo);
let created = 0; let created = 0;
try {
await prisma.$transaction(async (tx) => {
const current = new Date(start); const current = new Date(start);
while (current <= end) { while (current <= end) {
const dow = current.getDay(); const dow = current.getDay();
if (dow !== 0 && dow !== 6) { if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
const dateStr = localDateStr(current); const dateStr = localDateStr(current);
const shiftDate = new Date( const shiftDate = new Date(
current.getFullYear(), current.getFullYear(),
@@ -1225,13 +1224,13 @@ export async function createLeave(data: LeaveData, authUserId: number) {
0, 0,
0, 0,
); );
const duplicate = await prisma.attendance.findFirst({ const duplicate = await tx.attendance.findFirst({
where: { user_id: userId, shift_date: shiftDate }, where: { user_id: userId, shift_date: shiftDate },
}); });
if (duplicate) { if (duplicate) {
return { error: "Pro zvolené datumy již existují záznamy docházky" }; throw new Error("Pro zvolené datumy již existují záznamy docházky");
} }
await prisma.attendance.create({ await tx.attendance.create({
data: { data: {
user_id: userId, user_id: userId,
shift_date: shiftDate, shift_date: shiftDate,
@@ -1252,21 +1251,22 @@ export async function createLeave(data: LeaveData, authUserId: number) {
totalLeaveHours > 0 totalLeaveHours > 0
) { ) {
const year = new Date(dateFrom).getFullYear(); const year = new Date(dateFrom).getFullYear();
const existingBalance = await prisma.leave_balances.findFirst({ const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: userId, year }, where: { user_id: userId, year },
}); });
if (existingBalance) { if (existingBalance) {
const updateField = const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used"; leaveType === "vacation" ? "vacation_used" : "sick_used";
await prisma.leave_balances.update({ await tx.leave_balances.update({
where: { id: existingBalance.id }, where: { id: existingBalance.id },
data: { data: {
[updateField]: Number(existingBalance[updateField]) + totalLeaveHours, [updateField]:
Number(existingBalance[updateField]) + totalLeaveHours,
updated_at: new Date(), updated_at: new Date(),
}, },
}); });
} else { } else {
await prisma.leave_balances.create({ await tx.leave_balances.create({
data: { data: {
user_id: userId, user_id: userId,
year, year,
@@ -1277,6 +1277,16 @@ export async function createLeave(data: LeaveData, authUserId: number) {
}); });
} }
} }
});
} 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` }; 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." }; return { error: "Nemáte aktivní směnu bez přestávky." };
} }
const msRound = settings.clock_rounding_minutes * 60 * 1000; let msRound = settings.clock_rounding_minutes * 60 * 1000;
const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound); 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( const breakEnd = new Date(
breakStart.getTime() + settings.break_duration_long * 60 * 1000, breakStart.getTime() + settings.break_duration_long * 60 * 1000,
); );

View File

@@ -51,6 +51,9 @@ async function loadAuthData(userId: number): Promise<AuthData | null> {
if (!user || !user.is_active) return null; 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 isAdmin = user.roles?.name === "admin";
const permissions = isAdmin const permissions = isAdmin
? (await prisma.permissions.findMany({ select: { name: true } })).map( ? (await prisma.permissions.findMany({ select: { name: true } })).map(
@@ -111,6 +114,7 @@ export async function login(
} }
if (!user.is_active) { if (!user.is_active) {
await bcrypt.compare(password, DUMMY_HASH); // timing-safe
request.log.warn(`Login failed for deactivated user: ${username}`); request.log.warn(`Login failed for deactivated user: ${username}`);
return { return {
type: "error", type: "error",
@@ -120,6 +124,7 @@ export async function login(
} }
if (user.locked_until && new Date(user.locked_until) > new Date()) { 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}`); request.log.warn(`Login failed for locked user: ${username}`);
return { return {
type: "error", type: "error",
@@ -319,7 +324,7 @@ export async function refreshAccessToken(
accessToken, accessToken,
refreshToken: newRefreshTokenRaw, refreshToken: newRefreshTokenRaw,
user: authData, user: authData,
rememberMe: storedToken.remember_me ?? false, rememberMe: rememberMe,
}; };
}); });
} }
@@ -341,10 +346,9 @@ export async function verifyAccessToken(
token: string, token: string,
): Promise<AuthData | null> { ): Promise<AuthData | null> {
try { try {
const payload = jwt.verify( const payload = jwt.verify(token, config.jwt.secret, {
token, algorithms: ["HS256"],
config.jwt.secret, }) as unknown as JwtPayload;
) as unknown as JwtPayload;
return loadAuthData(payload.sub); return loadAuthData(payload.sub);
} catch (err) { } catch (err) {
console.error("JWT verification error:", err); console.error("JWT verification error:", err);

View File

@@ -11,12 +11,17 @@ interface CnbRate {
} }
const rateCache = new Map<string, Record<string, number>>(); const rateCache = new Map<string, Record<string, number>>();
let rateCacheTime = 0;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const inflight = new Map<string, Promise<Record<string, number>>>(); const inflight = new Map<string, Promise<Record<string, number>>>();
async function fetchRatesForDate( async function fetchRatesForDate(
date?: string, date?: string,
): Promise<Record<string, number>> { ): Promise<Record<string, number>> {
const key = date || "today"; const key = date || "today";
if (Date.now() - rateCacheTime > CACHE_TTL_MS) {
rateCache.clear();
}
if (rateCache.has(key)) return rateCache.get(key)!; if (rateCache.has(key)) return rateCache.get(key)!;
if (inflight.has(key)) return inflight.get(key)!; if (inflight.has(key)) return inflight.get(key)!;
@@ -36,6 +41,7 @@ async function fetchRatesForDate(
} }
rateCache.set(key, rates); rateCache.set(key, rates);
rateCacheTime = Date.now();
return rates; return rates;
} catch (err) { } catch (err) {
console.error("Failed to fetch CNB exchange rates:", err); console.error("Failed to fetch CNB exchange rates:", err);

View File

@@ -37,6 +37,7 @@ export async function checkInvoiceAlerts(): Promise<void> {
if (!alertEmail) return; if (!alertEmail) return;
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = localDateStr(today); const todayStr = localDateStr(today);
const in3days = new Date(today); const in3days = new Date(today);
in3days.setDate(in3days.getDate() + 3); in3days.setDate(in3days.getDate() + 3);

View File

@@ -55,8 +55,13 @@ function computeInvoiceTotals(
const vatAmount = applyVat const vatAmount = applyVat
? items.reduce((s, i) => { ? items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0); const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
const vat = const vatRate =
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100); 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; return s + Math.round(vat * 100) / 100;
}, 0) }, 0)
: 0; : 0;
@@ -343,7 +348,7 @@ export async function createInvoice(body: Record<string, any>) {
customer_id: body.customer_id ? Number(body.customer_id) : null, customer_id: body.customer_id ? Number(body.customer_id) : null,
status: body.status ? String(body.status) : "issued", status: body.status ? String(body.status) : "issued",
currency: body.currency ? String(body.currency) : "CZK", 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, apply_vat: body.apply_vat !== false,
payment_method: body.payment_method ? String(body.payment_method) : null, payment_method: body.payment_method ? String(body.payment_method) : null,
constant_symbol: body.constant_symbol constant_symbol: body.constant_symbol

View File

@@ -32,8 +32,8 @@ class NasOffersManager {
pdfBuffer: Buffer, pdfBuffer: Buffer,
): string | null { ): string | null {
const { prefix, seq } = this.parseParts(quotationNumber); const { prefix, seq } = this.parseParts(quotationNumber);
const folderName = `${prefix}_${seq}`; const folderName = this.sanitizeFilename(`${prefix}_${seq}`);
const fileName = `${year}_${prefix}_${seq}.pdf`; const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf";
const dir = this.ensureDir(year, folderName); const dir = this.ensureDir(year, folderName);
if (!dir) return null; if (!dir) return null;
@@ -81,8 +81,8 @@ class NasOffersManager {
/** Build the relative NAS path for a given quotation number + year */ /** Build the relative NAS path for a given quotation number + year */
buildRelativePath(quotationNumber: string, year: number): string { buildRelativePath(quotationNumber: string, year: number): string {
const { prefix, seq } = this.parseParts(quotationNumber); const { prefix, seq } = this.parseParts(quotationNumber);
const folderName = `${prefix}_${seq}`; const folderName = this.sanitizeFilename(`${prefix}_${seq}`);
const fileName = `${year}_${prefix}_${seq}.pdf`; const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf";
return `${year}/${folderName}/${fileName}`; return `${year}/${folderName}/${fileName}`;
} }
@@ -92,8 +92,10 @@ class NasOffersManager {
} { } {
const parts = quotationNumber.split("/"); const parts = quotationNumber.split("/");
return { return {
prefix: parts.length >= 2 ? parts[parts.length - 2] : "NA", prefix: this.sanitizeFilename(
seq: parts[parts.length - 1], parts.length >= 2 ? parts[parts.length - 2] : "NA",
),
seq: this.sanitizeFilename(parts[parts.length - 1]),
}; };
} }

View File

@@ -109,20 +109,14 @@ async function previewNextSequence(
} }
/** /**
* Decrement the sequence counter for a given type/year. * Release a sequence number back to the pool.
* Called after deleting a document so the number can be reused. * 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) { async function releaseSequence(_type: string, _year: number) {
try { // No-op: decrementing can cause duplicate sequence numbers.
await prisma.$executeRaw` // Sequence numbers are consumed but never returned to the pool.
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);
}
} }
/** Verify a shared number is not already used by an order or project. */ /** Verify a shared number is not already used by an order or project. */

View File

@@ -55,7 +55,9 @@ function enrichQuotation(q: any) {
0, 0,
); );
const vatAmount = q.apply_vat 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; : 0;
const { quotation_items, scope_sections, ...rest } = q; const { quotation_items, scope_sections, ...rest } = q;
return { return {
@@ -138,11 +140,6 @@ export async function getOffer(id: number) {
} }
export async function createOffer(body: Record<string, any>) { export async function createOffer(body: Record<string, any>) {
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) { if (body.quotation_number !== undefined && body.quotation_number !== null) {
const taken = await isOfferNumberTaken(String(body.quotation_number)); const taken = await isOfferNumberTaken(String(body.quotation_number));
if (taken) { if (taken) {
@@ -151,6 +148,11 @@ export async function createOffer(body: Record<string, any>) {
} }
return prisma.$transaction(async (tx) => { 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({ const quotation = await tx.quotations.create({
data: { data: {
quotation_number: quotationNumber, quotation_number: quotationNumber,
@@ -161,7 +163,7 @@ export async function createOffer(body: Record<string, any>) {
: null, : null,
currency: body.currency ? String(body.currency) : "CZK", currency: body.currency ? String(body.currency) : "CZK",
language: body.language ? String(body.language) : "cs", 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, apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0, exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
status: body.status ? String(body.status) : "active", status: body.status ? String(body.status) : "active",
@@ -320,9 +322,10 @@ export async function duplicateOffer(id: number) {
}); });
if (!original) return null; if (!original) return null;
const nextOfferNumber = await generateOfferNumber(); return prisma.$transaction(async (tx) => {
const nextOfferNumber = await generateOfferNumber(tx);
const copy = await prisma.quotations.create({ const copy = await tx.quotations.create({
data: { data: {
quotation_number: nextOfferNumber, quotation_number: nextOfferNumber,
project_code: original.project_code, project_code: original.project_code,
@@ -340,7 +343,7 @@ export async function duplicateOffer(id: number) {
}); });
if (original.quotation_items.length > 0) { if (original.quotation_items.length > 0) {
await prisma.quotation_items.createMany({ await tx.quotation_items.createMany({
data: original.quotation_items.map((item) => ({ data: original.quotation_items.map((item) => ({
quotation_id: copy.id, quotation_id: copy.id,
description: item.description, description: item.description,
@@ -355,7 +358,7 @@ export async function duplicateOffer(id: number) {
} }
if (original.scope_sections.length > 0) { if (original.scope_sections.length > 0) {
await prisma.scope_sections.createMany({ await tx.scope_sections.createMany({
data: original.scope_sections.map((s) => ({ data: original.scope_sections.map((s) => ({
quotation_id: copy.id, quotation_id: copy.id,
title: s.title, title: s.title,
@@ -367,6 +370,7 @@ export async function duplicateOffer(id: number) {
} }
return { copy, original }; return { copy, original };
});
} }
export async function invalidateOffer(id: number) { export async function invalidateOffer(id: number) {

View File

@@ -47,7 +47,9 @@ function enrichOrder(o: any) {
0, 0,
); );
const vatAmount = o.apply_vat 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; : 0;
const { order_items, order_sections, ...rest } = o; const { order_items, order_sections, ...rest } = o;
const invoice = o.invoices?.[0] || null; const invoice = o.invoices?.[0] || null;
@@ -126,7 +128,7 @@ export async function getOrder(id: number) {
}, },
invoices: { invoices: {
select: { id: true, invoice_number: true, status: true }, select: { id: true, invoice_number: true, status: true },
take: 1, orderBy: { id: "desc" },
}, },
}, },
}); });
@@ -290,19 +292,22 @@ interface CreateOrderData {
} }
export async function createOrder(body: CreateOrderData) { export async function createOrder(body: CreateOrderData) {
try {
return await prisma.$transaction(async (tx) => {
const orderNumber = const orderNumber =
body.order_number !== undefined && body.order_number !== null body.order_number !== undefined && body.order_number !== null
? String(body.order_number) ? String(body.order_number)
: await generateSharedNumber(); : await generateSharedNumber(tx);
if (body.order_number !== undefined && body.order_number !== null) { if (body.order_number !== undefined && body.order_number !== null) {
const taken = await isOrderNumberTaken(String(body.order_number)); const taken = await isOrderNumberTaken(String(body.order_number));
if (taken) { if (taken) {
return { error: "Číslo objednávky je již použito", status: 400 } as const; 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({ const order = await tx.orders.create({
data: { data: {
order_number: orderNumber, order_number: orderNumber,
@@ -350,6 +355,15 @@ export async function createOrder(body: CreateOrderData) {
return { id: order.id, order_number: order.order_number }; return { id: order.id, order_number: order.order_number };
}); });
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error: err.message,
status: (err as Error & { status: number }).status,
};
}
throw err;
}
} }
interface UpdateOrderData { interface UpdateOrderData {
@@ -499,30 +513,34 @@ export async function deleteOrder(id: number) {
if (!existing) if (!existing)
return { error: "Objednávka nenalezena", status: 404 } as const; return { error: "Objednávka nenalezena", status: 404 } as const;
// 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 },
});
await prisma.$transaction(async (tx) => {
// Clear quotation back-reference (matching PHP) // Clear quotation back-reference (matching PHP)
await prisma.quotations.updateMany({ await tx.quotations.updateMany({
where: { order_id: id }, where: { order_id: id },
data: { order_id: null }, data: { order_id: null },
}); });
// Delete linked project and its notes (matching PHP) // Delete linked project and its notes (matching PHP)
const linkedProjects = await prisma.projects.findMany({
where: { order_id: id },
select: { id: true, created_at: true },
});
if (linkedProjects.length > 0) { if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id); const projectIds = linkedProjects.map((p) => p.id);
await prisma.project_notes.deleteMany({ await tx.project_notes.deleteMany({
where: { project_id: { in: projectIds } }, where: { project_id: { in: projectIds } },
}); });
await prisma.projects.deleteMany({ where: { order_id: id } }); await tx.projects.deleteMany({ where: { order_id: id } });
} }
// Explicitly clean up child rows // Explicitly clean up child rows
await prisma.order_items.deleteMany({ where: { order_id: id } }); await tx.order_items.deleteMany({ where: { order_id: id } });
await prisma.order_sections.deleteMany({ where: { order_id: id } }); await tx.order_sections.deleteMany({ where: { order_id: id } });
await prisma.orders.delete({ where: { id } }); await tx.orders.delete({ where: { id } });
});
const releasedYears = new Set<number>(); const releasedYears = new Set<number>();
const year = existing.created_at const year = existing.created_at

View File

@@ -212,17 +212,18 @@ export async function updateUser(
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
try {
await prisma.$transaction(async (tx) => {
if (body.username !== undefined) { if (body.username !== undefined) {
const newUsername = String(body.username).trim(); const newUsername = String(body.username).trim();
if (newUsername !== existing.username) { if (newUsername !== existing.username) {
const existingUsername = await prisma.users.findFirst({ const existingUsername = await tx.users.findFirst({
where: { username: newUsername }, where: { username: newUsername },
}); });
if (existingUsername) { if (existingUsername) {
return { throw Object.assign(new Error("Uživatelské jméno již existuje"), {
error: "Uživatelské jméno již existuje",
status: 409, status: 409,
} as const; });
} }
} }
data.username = newUsername; data.username = newUsername;
@@ -231,28 +232,37 @@ export async function updateUser(
if (body.email !== undefined) { if (body.email !== undefined) {
const newEmail = String(body.email).trim(); const newEmail = String(body.email).trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
return { error: "Neplatný formát e-mailu", status: 400 } as const; throw Object.assign(new Error("Neplatný formát e-mailu"), {
status: 400,
});
} }
const existingEmail = await prisma.users.findFirst({ const existingEmail = await tx.users.findFirst({
where: { email: newEmail, id: { not: id } }, where: { email: newEmail, id: { not: id } },
}); });
if (existingEmail) { if (existingEmail) {
return { error: "E-mail již existuje", status: 409 } as const; throw Object.assign(new Error("E-mail již existuje"), {
status: 409,
});
} }
data.email = newEmail; data.email = newEmail;
} }
if (body.first_name !== undefined) data.first_name = String(body.first_name); 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.last_name !== undefined) data.last_name = String(body.last_name);
if (body.role_id !== undefined) if (body.role_id !== undefined)
data.role_id = body.role_id ? Number(body.role_id) : null; data.role_id = body.role_id ? Number(body.role_id) : null;
if (body.is_active !== undefined) if (body.is_active !== undefined)
data.is_active = data.is_active =
body.is_active === true || body.is_active === 1 || body.is_active === "1"; body.is_active === true ||
body.is_active === 1 ||
body.is_active === "1";
if (body.password) { if (body.password) {
const newPassword = String(body.password); const newPassword = String(body.password);
if (newPassword.length < 8) { if (newPassword.length < 8) {
return { error: "Heslo musí mít alespoň 8 znaků", status: 400 } as const; throw Object.assign(new Error("Heslo musí mít alespoň 8 znaků"), {
status: 400,
});
} }
data.password_hash = await bcrypt.hash( data.password_hash = await bcrypt.hash(
newPassword, newPassword,
@@ -261,7 +271,17 @@ export async function updateUser(
data.password_changed_at = new Date(); data.password_changed_at = new Date();
} }
await prisma.users.update({ where: { id }, data }); await tx.users.update({ where: { id }, data });
});
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error: err.message,
status: (err as Error & { status: number }).status,
} as const;
}
throw err;
}
return { id, username: existing.username } as const; 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; return { error: "Uživatel nenalezen", status: 404 } as const;
} }
await prisma.refresh_tokens.deleteMany({ where: { user_id: id } }); await prisma.$transaction(async (tx) => {
await prisma.users.delete({ where: { id } }); await tx.refresh_tokens.deleteMany({ where: { user_id: id } });
await tx.users.delete({ where: { id } });
});
return { id, username: existing.username } as const; return { id, username: existing.username } as const;
} }

View File

@@ -50,7 +50,7 @@ export interface AuthData {
roleId: number | null; roleId: number | null;
roleName: string | null; roleName: string | null;
permissions: string[]; permissions: string[];
totp_enabled?: boolean; totp_enabled: boolean;
require_2fa?: boolean; require_2fa?: boolean;
} }
@@ -102,6 +102,8 @@ export interface PaginationQuery {
export type AuditAction = export type AuditAction =
| "login" | "login"
| "login_totp"
| "login_backup"
| "logout" | "logout"
| "login_failed" | "login_failed"
| "create" | "create"

View File

@@ -20,7 +20,9 @@ export const OTPAuth = {
return { valid: false, counter: null }; return { valid: false, counter: null };
} }
const currentCounter = Math.floor(Date.now() / 1000 / config.totp.period); 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) { } catch (err) {
console.error("TOTP verification error:", err); console.error("TOTP verification error:", err);
return { valid: false, counter: null }; return { valid: false, counter: null };

View File

@@ -19,5 +19,13 @@
} }
}, },
"include": ["src/**/*"], "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__"
]
} }