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
# Auth — MUST regenerate for production: openssl rand -hex 32
JWT_SECRET=generate-with-openssl-rand-hex-32
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# TOTP — MUST regenerate for production: openssl rand -hex 32
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
# File storage
NAS_PATH=Z:/02_PROJEKTY

View File

@@ -75,6 +75,19 @@ function NativeInput({
disabled,
}: NativeInputProps) {
const type = modeToInputType[mode] || "date";
// For time inputs, min/max must be in HH:mm format, not date format
const formatTimeMinMax = (val: string | undefined): string | undefined => {
if (!val) return undefined;
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
// otherwise it's not a valid time min/max — return undefined
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
if (val.includes(":")) return val.substring(0, 5);
return undefined;
};
const minProp =
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
const maxProp =
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
return (
<input
type={type}
@@ -84,8 +97,8 @@ function NativeInput({
className="admin-form-input"
required={required}
disabled={disabled}
min={minDate || undefined}
max={maxDate || undefined}
min={minProp}
max={maxProp}
/>
);
}

View File

@@ -126,7 +126,7 @@ export default function AttendanceShiftTable({
if (records.length === 0) {
return (
<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>
);
}
@@ -137,15 +137,15 @@ export default function AttendanceShiftTable({
<thead>
<tr>
<th>Datum</th>
<th>ZamÄstnanec</th>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>PĹĂ­chod</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekt</th>
<th>GPS</th>
<th>Poznámka</th>
<th>Poznámka</th>
<th>Akce</th>
</tr>
</thead>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -144,19 +144,19 @@ export default async function attendanceRoutes(
// --- action=attendance_users: users with attendance.record permission ---
if (action === "attendance_users") {
if (
!authData.permissions.includes("attendance.admin") &&
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.record")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const users = await prisma.users.findMany({
where: {
is_active: true,
roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
],
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
},
@@ -182,6 +182,12 @@ export default async function attendanceRoutes(
// --- action=project_logs: get project logs for a specific attendance record ---
if (action === "project_logs") {
if (
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.record")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const attendanceId = Number(query.attendance_id);
if (!attendanceId) return error(reply, "Missing attendance_id", 400);
const data = await attendanceService.getProjectLogs(attendanceId);
@@ -411,7 +417,7 @@ export default async function attendanceRoutes(
// PUT /api/admin/attendance/:id
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
{ preHandler: requirePermission("attendance.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

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

View File

@@ -135,6 +135,7 @@ export default async function companySettingsRoutes(
);
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
// Use upsert to avoid race condition between findFirst and create
let settings = await prisma.company_settings.findFirst({
select: {
id: true,
@@ -176,50 +177,100 @@ export default async function companySettingsRoutes(
});
if (!settings) {
settings = await prisma.company_settings.create({
data: {
company_name: "",
quotation_prefix: "N",
default_currency: "EUR",
default_vat_rate: 21.0,
},
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
break_threshold_hours: true,
break_duration_short: true,
break_duration_long: true,
clock_rounding_minutes: true,
invoice_alert_email: true,
leave_notify_email: true,
max_login_attempts: true,
lockout_minutes: true,
max_requests_per_minute: true,
available_vat_rates: true,
available_currencies: true,
smtp_from: true,
smtp_from_name: true,
offer_number_pattern: true,
order_number_pattern: true,
invoice_number_pattern: true,
},
// Wrap create in a transaction to handle race condition:
// another request may have created settings between findFirst and create
settings = await prisma.$transaction(async (tx) => {
// Double-check inside transaction
const existing = await tx.company_settings.findFirst({
select: { id: true },
});
if (existing) {
return tx.company_settings.findUnique({
where: { id: existing.id },
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
break_threshold_hours: true,
break_duration_short: true,
break_duration_long: true,
clock_rounding_minutes: true,
invoice_alert_email: true,
leave_notify_email: true,
max_login_attempts: true,
lockout_minutes: true,
max_requests_per_minute: true,
available_vat_rates: true,
available_currencies: true,
smtp_from: true,
smtp_from_name: true,
offer_number_pattern: true,
order_number_pattern: true,
invoice_number_pattern: true,
},
});
}
return tx.company_settings.create({
data: {
company_name: "",
quotation_prefix: "N",
default_currency: "EUR",
default_vat_rate: 21.0,
},
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
break_threshold_hours: true,
break_duration_short: true,
break_duration_long: true,
clock_rounding_minutes: true,
invoice_alert_email: true,
leave_notify_email: true,
max_login_attempts: true,
lockout_minutes: true,
max_requests_per_minute: true,
available_vat_rates: true,
available_currencies: true,
smtp_from: true,
smtp_from_name: true,
offer_number_pattern: true,
order_number_pattern: true,
invoice_number_pattern: true,
},
});
});
}
if (!settings) return error(reply, "Nastavení nenalezeno", 500);
@@ -429,7 +480,7 @@ export default async function companySettingsRoutes(
: existingOrder,
);
}
data.sync_version = (existing.sync_version ?? 0) + 1;
data.sync_version = { increment: 1 };
await prisma.company_settings.update({
where: { id: existing.id },

View File

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

View File

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

View File

@@ -241,71 +241,84 @@ export default async function leaveRequestsRoutes(
const totalHours = totalBusinessDays * 8;
for (const ac of attendanceCreates) {
const duplicate = await prisma.attendance.findFirst({
where: { user_id: ac.user_id, shift_date: ac.shift_date },
try {
await prisma.$transaction(async (tx) => {
// Check for duplicate attendance records inside the transaction
for (const ac of attendanceCreates) {
const duplicate = await tx.attendance.findFirst({
where: { user_id: ac.user_id, shift_date: ac.shift_date },
});
if (duplicate) {
throw new Error(
"Pro zvolené datumy již existují záznamy docházky",
);
}
}
// 1. Create attendance records for each business day
if (attendanceCreates.length > 0) {
await tx.attendance.createMany({ data: attendanceCreates });
}
// 2. Update leave balance (vacation/sick only — not unpaid)
if (leaveType === "vacation" || leaveType === "sick") {
const year = dateFrom.getFullYear();
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: existing.user_id, year },
});
if (existingBalance) {
const updateData: Record<string, unknown> = {
updated_at: new Date(),
};
if (leaveType === "vacation") {
updateData.vacation_used =
Number(existingBalance.vacation_used) + totalHours;
} else {
updateData.sick_used =
Number(existingBalance.sick_used) + totalHours;
}
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: updateData,
});
} else {
await tx.leave_balances.create({
data: {
user_id: existing.user_id,
year,
vacation_total: 160,
vacation_used: leaveType === "vacation" ? totalHours : 0,
sick_used: leaveType === "sick" ? totalHours : 0,
},
});
}
}
// 3. Update request status
await tx.leave_requests.update({
where: { id },
data: {
status: "approved" as leave_requests_status,
reviewer_id: authData.userId,
reviewed_at: new Date(),
},
});
});
if (duplicate) {
} catch (e) {
if (
e instanceof Error &&
e.message === "Pro zvolené datumy již existují záznamy docházky"
) {
return error(
reply,
"Pro zvolené datumy již existují záznamy docházky",
400,
);
}
throw e;
}
await prisma.$transaction(async (tx) => {
// 1. Create attendance records for each business day
if (attendanceCreates.length > 0) {
await tx.attendance.createMany({ data: attendanceCreates });
}
// 2. Update leave balance (vacation/sick only — not unpaid)
if (leaveType === "vacation" || leaveType === "sick") {
const year = dateFrom.getFullYear();
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: existing.user_id, year },
});
if (existingBalance) {
const updateData: Record<string, unknown> = {
updated_at: new Date(),
};
if (leaveType === "vacation") {
updateData.vacation_used =
Number(existingBalance.vacation_used) + totalHours;
} else {
updateData.sick_used =
Number(existingBalance.sick_used) + totalHours;
}
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: updateData,
});
} else {
await tx.leave_balances.create({
data: {
user_id: existing.user_id,
year,
vacation_total: 160,
vacation_used: leaveType === "vacation" ? totalHours : 0,
sick_used: leaveType === "sick" ? totalHours : 0,
},
});
}
}
// 3. Update request status
await tx.leave_requests.update({
where: { id },
data: {
status: "approved" as leave_requests_status,
reviewer_id: authData.userId,
reviewed_at: new Date(),
},
});
});
await logAudit({
request,
authData,

View File

@@ -38,12 +38,7 @@ export default async function profileRoutes(
const data: Record<string, unknown> = {};
if (body.email) {
const newEmail = String(body.email).trim();
const existing = await prisma.users.findFirst({
where: { email: newEmail, id: { not: userId } },
});
if (existing) return error(reply, "E-mail již existuje", 409);
data.email = newEmail;
data.email = String(body.email).trim();
}
if (body.first_name) data.first_name = String(body.first_name);
if (body.last_name) data.last_name = String(body.last_name);
@@ -65,7 +60,23 @@ export default async function profileRoutes(
data.password_changed_at = new Date();
}
await prisma.users.update({ where: { id: userId }, data });
// Wrap email uniqueness check and update in a transaction to prevent race condition
try {
await prisma.$transaction(async (tx) => {
if (data.email) {
const existing = await tx.users.findFirst({
where: { email: String(data.email), id: { not: userId } },
});
if (existing) throw new Error("EMAIL_EXISTS");
}
await tx.users.update({ where: { id: userId }, data });
});
} catch (e) {
if (e instanceof Error && e.message === "EMAIL_EXISTS") {
return error(reply, "E-mail již existuje", 409);
}
throw e;
}
await logAudit({
request,

View File

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

View File

@@ -13,6 +13,7 @@ import {
} from "../../schemas/received-invoices.schema";
import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { toCzk } from "../../services/exchange-rates";
import path from "path";
const VALID_STATUSES = ["unpaid", "paid"] as const;
@@ -126,9 +127,17 @@ export default async function receivedInvoicesRoutes(
return Math.round(total * 100) / 100;
};
// Also get all-time unpaid
// Also get all-time unpaid — use DB-level aggregation for count/sums
const stats = await prisma.received_invoices.aggregate({
where: { status: { not: "paid" }, is_deleted: false },
_sum: { amount: true, amount_czk: true },
_count: true,
});
// We still need per-currency breakdown for unpaid, so fetch only those
const allUnpaid = await prisma.received_invoices.findMany({
where: { status: { not: "paid" } },
where: { status: { not: "paid" }, is_deleted: false },
select: { amount: true, currency: true },
});
return success(reply, {
@@ -137,8 +146,10 @@ export default async function receivedInvoicesRoutes(
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
unpaid: aggregateByCurrency(allUnpaid, "amount"),
unpaid_czk: await sumCzk(allUnpaid, "amount"),
unpaid_count: allUnpaid.length,
unpaid_czk: stats._sum.amount_czk
? Math.round(Number(stats._sum.amount_czk) * 100) / 100
: await sumCzk(allUnpaid, "amount"),
unpaid_count: stats._count,
month_count: monthInvoices.length,
});
},
@@ -188,12 +199,10 @@ export default async function receivedInvoicesRoutes(
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = invoice.file_mime || "application/pdf";
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
return reply
.type(mime)
.header(
"Content-Disposition",
`inline; filename="${invoice.file_name}"`,
)
.header("Content-Disposition", `inline; filename="${safeFileName}"`)
.send(nasFile.data);
},
);
@@ -315,7 +324,9 @@ export default async function receivedInvoicesRoutes(
status: "unpaid",
notes: meta.notes ? String(meta.notes) : null,
uploaded_by: request.authData?.userId,
file_name: file.name,
file_name: nasResult.filePath
? path.basename(nasResult.filePath)
: file.name,
file_mime: file.mime,
file_size: file.size,
},
@@ -364,7 +375,7 @@ export default async function receivedInvoicesRoutes(
vat_rate: vatRate,
vat_amount:
vatRate > 0
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
? Math.round(((amount * vatRate) / 100) * 100) / 100
: 0,
issue_date: body.issue_date
? new Date(String(body.issue_date))
@@ -544,6 +555,9 @@ export default async function receivedInvoicesRoutes(
});
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
// Delete DB record first, then NAS file — avoids orphaned file if DB delete fails
await prisma.received_invoices.delete({ where: { id } });
if (existing.file_name) {
const relPath = nasFinancialsManager.buildReceivedPath(
existing.file_name,
@@ -552,8 +566,6 @@ export default async function receivedInvoicesRoutes(
);
nasFinancialsManager.deleteReceivedInvoice(relPath);
}
await prisma.received_invoices.delete({ where: { id } });
await logAudit({
request,
authData: request.authData,

View File

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

View File

@@ -188,17 +188,19 @@ export default async function scopeTemplatesRoutes(
});
if (Array.isArray(body.sections)) {
await prisma.scope_template_sections.deleteMany({
where: { scope_template_id: id },
});
await prisma.scope_template_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
scope_template_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
await prisma.$transaction(async (tx) => {
await tx.scope_template_sections.deleteMany({
where: { scope_template_id: id },
});
await tx.scope_template_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
scope_template_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
});
}

View File

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

View File

@@ -38,9 +38,15 @@ export default async function tripsRoutes(
mo = NaN;
}
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
// Use explicit date strings to avoid toJSON timezone shift
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = {
gte: new Date(yr, mo - 1, 1),
lt: new Date(yr, mo, 1),
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
}
@@ -75,15 +81,8 @@ export default async function tripsRoutes(
where: {
is_active: true,
roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: {
some: { permissions: { name: "trips.record" } },
},
},
],
role_permissions: {
some: { permissions: { name: "trips.record" } },
},
},
},
@@ -120,9 +119,17 @@ export default async function tripsRoutes(
if (filterUserId) where.user_id = filterUserId;
if (filterVehicleId) where.vehicle_id = filterVehicleId;
if (query.month && query.year) {
// Use explicit date strings to avoid toJSON timezone shift
const yr = Number(query.year);
const mo = Number(query.month);
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = {
gte: new Date(Number(query.year), Number(query.month) - 1, 1),
lt: new Date(Number(query.year), Number(query.month), 1),
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}

View File

@@ -131,6 +131,18 @@ export default async function vehiclesRoutes(
const existing = await prisma.vehicles.findUnique({ where: { id } });
if (!existing) return error(reply, "Vozidlo nenalezeno", 404);
// Check for linked trips before deleting
const tripCount = await prisma.trips.count({
where: { vehicle_id: id },
});
if (tripCount > 0) {
return error(
reply,
"Vozidlo má přiřazené jízdy a nelze jej smazat",
409,
);
}
await prisma.vehicles.delete({ where: { id } });
await logAudit({
request,

View File

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

View File

@@ -9,6 +9,7 @@ export const LoginSchema = z.object({
export const TotpVerifySchema = z.object({
login_token: z.string().min(1, "Token je povinný"),
totp_code: z.string().length(6, "Kód musí mít 6 číslic"),
remember_me: z.boolean().optional().default(false),
});
export const TotpBackupSchema = z.object({
@@ -16,6 +17,22 @@ export const TotpBackupSchema = z.object({
backup_code: z.string().min(1, "Záložní kód je povinný"),
});
export const TotpEnableSchema = z.object({
secret: z.string().min(1),
code: z.string().min(1),
password: z.string().optional(),
current_code: z.string().optional(),
});
export const TotpDisableSchema = z.object({
code: z.string().min(1),
password: z.string().optional(),
});
export const TotpRequiredSchema = z.object({
required: z.boolean(),
});
export type LoginInput = z.infer<typeof LoginSchema>;
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;

View File

@@ -4,13 +4,21 @@ const InvoiceItemSchema = z.object({
description: z.string().nullish(),
quantity: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 1)
.transform((v) => {
const n = Number(v);
if (isNaN(n) || n <= 0) throw new Error("Invalid quantity");
return n;
})
.optional()
.default(1),
unit: z.string().nullish(),
unit_price: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 0)
.transform((v) => {
const n = Number(v);
if (isNaN(n)) throw new Error("Invalid unit_price");
return n;
})
.optional()
.default(0),
vat_rate: z
@@ -73,7 +81,10 @@ export const UpdateInvoiceSchema = z.object({
bank_iban: z.string().nullish(),
bank_account: z.string().nullish(),
issued_by: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(),
customer_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
vat_rate: z
.union([z.number(), z.string()])
.transform((v) => Number(v))

View File

@@ -1,14 +1,18 @@
import { z } from "zod";
export const CreateLeaveRequestSchema = z.object({
leave_type: z.string().min(1, "Typ nepřítomnosti je povinný"),
leave_type: z
.enum(["vacation", "sick", "unpaid", "holiday"])
.default("vacation"),
date_from: z.string().min(1, "Datum od je povinné"),
date_to: z.string().min(1, "Datum do je povinné"),
notes: z.string().nullish(),
});
export const ReviewLeaveRequestSchema = z.object({
status: z.string().min(1, "Stav je povinný"),
status: z
.enum(["pending", "approved", "rejected", "cancelled"])
.default("pending"),
reviewer_note: z.string().nullish(),
});

View File

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

View File

@@ -96,7 +96,10 @@ export const UpdateOrderSchema = z.object({
scope_title: z.string().nullish(),
scope_description: z.string().nullish(),
notes: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(),
customer_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
vat_rate: z
.union([z.number(), z.string()])
.transform((v) => Number(v))

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import { z } from "zod";
export const CreateTripSchema = z.object({
vehicle_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
// user_id is optional here because the route injects it from authData.userId
// when the client doesn't provide it (see POST /trips handler)
user_id: z
.union([z.number(), z.string()])
.transform((v) => Number(v))

View File

@@ -6,7 +6,10 @@ export const CreateUserSchema = z.object({
password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"),
first_name: z.string().min(1, "Jméno je povinné"),
last_name: z.string().min(1, "Příjmení je povinné"),
role_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
role_id: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.optional(),
is_active: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional()
@@ -22,7 +25,10 @@ export const UpdateUserSchema = z.object({
),
first_name: z.string().optional(),
last_name: z.string().optional(),
role_id: z.union([z.number(), z.string(), z.null()]).optional(),
role_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
is_active: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional(),

View File

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

View File

@@ -4,21 +4,14 @@ import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays";
import { localDateStr } from "../utils/date";
import { getSystemSettings } from "./system-settings";
/** Get active users whose role has attendance.record permission (or admin role) */
/** Get active users whose role has attendance.record permission */
async function getAttendanceUsers() {
return prisma.users.findMany({
where: {
is_active: true,
roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
],
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
},
@@ -73,11 +66,13 @@ function calcWorkedHours(
}
const roundUp = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000;
return new Date(Math.ceil(d.getTime() / ms) * ms);
};
const roundDown = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000;
return new Date(Math.floor(d.getTime() / ms) * ms);
};
@@ -1143,48 +1138,50 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
let inserted = 0;
let skipped = 0;
for (const userId of data.user_ids.map(Number)) {
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(yr, mo - 1, day);
const dateStr = localDateStr(date);
const dow = date.getDay();
await prisma.$transaction(async (tx) => {
for (const userId of data.user_ids.map(Number)) {
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(yr, mo - 1, day);
const dateStr = localDateStr(date);
const dow = date.getDay();
if (dow === 0 || dow === 6) continue;
if (dow === 0 || dow === 6) continue;
if (existingSet.has(`${userId}:${dateStr}`)) {
skipped++;
continue;
}
if (existingSet.has(`${userId}:${dateStr}`)) {
skipped++;
continue;
}
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
if (isHoliday(dateStr)) {
await prisma.attendance.create({
if (isHoliday(dateStr)) {
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: "holiday",
leave_hours: 8,
},
});
inserted++;
continue;
}
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: "holiday",
leave_hours: 8,
arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`),
departure_time: new Date(`${dateStr}T${data.departure_time}:00`),
break_start: new Date(`${dateStr}T${data.break_start_time}:00`),
break_end: new Date(`${dateStr}T${data.break_end_time}:00`),
leave_type: "work",
},
});
inserted++;
continue;
}
await prisma.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`),
departure_time: new Date(`${dateStr}T${data.departure_time}:00`),
break_start: new Date(`${dateStr}T${data.break_start_time}:00`),
break_end: new Date(`${dateStr}T${data.break_end_time}:00`),
leave_type: "work",
},
});
inserted++;
}
}
});
let msg = `Vytvořeno ${inserted} záznamů`;
if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`;
@@ -1212,70 +1209,83 @@ export async function createLeave(data: LeaveData, authUserId: number) {
const end = new Date(dateTo);
let created = 0;
const current = new Date(start);
while (current <= end) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6) {
const dateStr = localDateStr(current);
const shiftDate = new Date(
current.getFullYear(),
current.getMonth(),
current.getDate(),
12,
0,
0,
);
const duplicate = await prisma.attendance.findFirst({
where: { user_id: userId, shift_date: shiftDate },
});
if (duplicate) {
return { error: "Pro zvolené datumy již existují záznamy docházky" };
try {
await prisma.$transaction(async (tx) => {
const current = new Date(start);
while (current <= end) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
const dateStr = localDateStr(current);
const shiftDate = new Date(
current.getFullYear(),
current.getMonth(),
current.getDate(),
12,
0,
0,
);
const duplicate = await tx.attendance.findFirst({
where: { user_id: userId, shift_date: shiftDate },
});
if (duplicate) {
throw new Error("Pro zvolené datumy již existují záznamy docházky");
}
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: leaveType,
leave_hours: data.leave_hours ? Number(data.leave_hours) : 8,
notes: data.notes ? String(data.notes) : null,
},
});
created++;
}
current.setDate(current.getDate() + 1);
}
await prisma.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: leaveType,
leave_hours: data.leave_hours ? Number(data.leave_hours) : 8,
notes: data.notes ? String(data.notes) : null,
},
});
created++;
}
current.setDate(current.getDate() + 1);
}
const totalLeaveHours =
created * (data.leave_hours ? Number(data.leave_hours) : 8);
if (
(leaveType === "vacation" || leaveType === "sick") &&
totalLeaveHours > 0
) {
const year = new Date(dateFrom).getFullYear();
const existingBalance = await prisma.leave_balances.findFirst({
where: { user_id: userId, year },
const totalLeaveHours =
created * (data.leave_hours ? Number(data.leave_hours) : 8);
if (
(leaveType === "vacation" || leaveType === "sick") &&
totalLeaveHours > 0
) {
const year = new Date(dateFrom).getFullYear();
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: userId, year },
});
if (existingBalance) {
const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used";
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: {
[updateField]:
Number(existingBalance[updateField]) + totalLeaveHours,
updated_at: new Date(),
},
});
} else {
await tx.leave_balances.create({
data: {
user_id: userId,
year,
vacation_total: 160,
vacation_used: leaveType === "vacation" ? totalLeaveHours : 0,
sick_used: leaveType === "sick" ? totalLeaveHours : 0,
},
});
}
}
});
if (existingBalance) {
const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used";
await prisma.leave_balances.update({
where: { id: existingBalance.id },
data: {
[updateField]: Number(existingBalance[updateField]) + totalLeaveHours,
updated_at: new Date(),
},
});
} else {
await prisma.leave_balances.create({
data: {
user_id: userId,
year,
vacation_total: 160,
vacation_used: leaveType === "vacation" ? totalLeaveHours : 0,
sick_used: leaveType === "sick" ? totalLeaveHours : 0,
},
});
} catch (err) {
if (
err instanceof Error &&
err.message === "Pro zvolené datumy již existují záznamy docházky"
) {
return { error: err.message };
}
throw err;
}
return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` };
@@ -1418,8 +1428,17 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Nemáte aktivní směnu bez přestávky." };
}
const msRound = settings.clock_rounding_minutes * 60 * 1000;
const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound);
let msRound = settings.clock_rounding_minutes * 60 * 1000;
if (
!settings.clock_rounding_minutes ||
settings.clock_rounding_minutes <= 0
) {
msRound = 0;
}
const breakStart =
msRound > 0
? new Date(Math.round(now.getTime() / msRound) * msRound)
: now;
const breakEnd = new Date(
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -109,20 +109,14 @@ async function previewNextSequence(
}
/**
* Decrement the sequence counter for a given type/year.
* Called after deleting a document so the number can be reused.
* Release a sequence number back to the pool.
* NOTE: Blindly decrementing can cause duplicate numbers if numbers were
* allocated after this one. Sequence numbers are consumed but not returned
* to the pool — this is intentionally a no-op.
*/
async function releaseSequence(type: string, year: number) {
try {
await prisma.$executeRaw`
UPDATE number_sequences
SET last_number = GREATEST(COALESCE(last_number, 0) - 1, 0)
WHERE \`type\` = ${type} AND \`year\` = ${year}
`;
} catch (err) {
// Non-fatal: log but don't fail the delete operation
console.error(`releaseSequence failed for ${type}/${year}:`, err);
}
async function releaseSequence(_type: string, _year: number) {
// No-op: decrementing can cause duplicate sequence numbers.
// Sequence numbers are consumed but never returned to the pool.
}
/** Verify a shared number is not already used by an order or project. */

View File

@@ -55,7 +55,9 @@ function enrichQuotation(q: any) {
0,
);
const vatAmount = q.apply_vat
? subtotal * ((Number(q.vat_rate) || 21) / 100)
? subtotal *
((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) /
100)
: 0;
const { quotation_items, scope_sections, ...rest } = q;
return {
@@ -138,11 +140,6 @@ export async function getOffer(id: number) {
}
export async function createOffer(body: Record<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) {
const taken = await isOfferNumberTaken(String(body.quotation_number));
if (taken) {
@@ -151,6 +148,11 @@ export async function createOffer(body: Record<string, any>) {
}
return prisma.$transaction(async (tx) => {
const quotationNumber =
body.quotation_number !== undefined && body.quotation_number !== null
? String(body.quotation_number)
: await generateOfferNumber(tx);
const quotation = await tx.quotations.create({
data: {
quotation_number: quotationNumber,
@@ -161,7 +163,7 @@ export async function createOffer(body: Record<string, any>) {
: null,
currency: body.currency ? String(body.currency) : "CZK",
language: body.language ? String(body.language) : "cs",
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
status: body.status ? String(body.status) : "active",
@@ -320,53 +322,55 @@ export async function duplicateOffer(id: number) {
});
if (!original) return null;
const nextOfferNumber = await generateOfferNumber();
return prisma.$transaction(async (tx) => {
const nextOfferNumber = await generateOfferNumber(tx);
const copy = await prisma.quotations.create({
data: {
quotation_number: nextOfferNumber,
project_code: original.project_code,
customer_id: original.customer_id,
valid_until: null,
currency: original.currency,
language: original.language,
vat_rate: original.vat_rate,
apply_vat: original.apply_vat,
exchange_rate: original.exchange_rate,
status: "active",
scope_title: original.scope_title,
scope_description: original.scope_description,
},
const copy = await tx.quotations.create({
data: {
quotation_number: nextOfferNumber,
project_code: original.project_code,
customer_id: original.customer_id,
valid_until: null,
currency: original.currency,
language: original.language,
vat_rate: original.vat_rate,
apply_vat: original.apply_vat,
exchange_rate: original.exchange_rate,
status: "active",
scope_title: original.scope_title,
scope_description: original.scope_description,
},
});
if (original.quotation_items.length > 0) {
await tx.quotation_items.createMany({
data: original.quotation_items.map((item) => ({
quotation_id: copy.id,
description: item.description,
item_description: item.item_description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),
});
}
if (original.scope_sections.length > 0) {
await tx.scope_sections.createMany({
data: original.scope_sections.map((s) => ({
quotation_id: copy.id,
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: s.position,
})),
});
}
return { copy, original };
});
if (original.quotation_items.length > 0) {
await prisma.quotation_items.createMany({
data: original.quotation_items.map((item) => ({
quotation_id: copy.id,
description: item.description,
item_description: item.item_description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),
});
}
if (original.scope_sections.length > 0) {
await prisma.scope_sections.createMany({
data: original.scope_sections.map((s) => ({
quotation_id: copy.id,
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: s.position,
})),
});
}
return { copy, original };
}
export async function invalidateOffer(id: number) {

View File

@@ -47,7 +47,9 @@ function enrichOrder(o: any) {
0,
);
const vatAmount = o.apply_vat
? subtotal * ((Number(o.vat_rate) || 21) / 100)
? subtotal *
((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) /
100)
: 0;
const { order_items, order_sections, ...rest } = o;
const invoice = o.invoices?.[0] || null;
@@ -126,7 +128,7 @@ export async function getOrder(id: number) {
},
invoices: {
select: { id: true, invoice_number: true, status: true },
take: 1,
orderBy: { id: "desc" },
},
},
});
@@ -290,66 +292,78 @@ interface CreateOrderData {
}
export async function createOrder(body: CreateOrderData) {
const orderNumber =
body.order_number !== undefined && body.order_number !== null
? String(body.order_number)
: await generateSharedNumber();
try {
return await prisma.$transaction(async (tx) => {
const orderNumber =
body.order_number !== undefined && body.order_number !== null
? String(body.order_number)
: await generateSharedNumber(tx);
if (body.order_number !== undefined && body.order_number !== null) {
const taken = await isOrderNumberTaken(String(body.order_number));
if (taken) {
return { error: "Číslo objednávky je již použito", status: 400 } as const;
}
}
if (body.order_number !== undefined && body.order_number !== null) {
const taken = await isOrderNumberTaken(String(body.order_number));
if (taken) {
throw Object.assign(new Error("Číslo objednávky je již použito"), {
status: 400,
});
}
}
return prisma.$transaction(async (tx) => {
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: body.customer_order_number ?? null,
quotation_id: body.quotation_id ?? null,
customer_id: body.customer_id ?? null,
status: body.status,
currency: body.currency,
language: body.language,
vat_rate: body.vat_rate,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate,
scope_title: body.scope_title ?? null,
scope_description: body.scope_description ?? null,
notes: body.notes ?? null,
},
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: body.customer_order_number ?? null,
quotation_id: body.quotation_id ?? null,
customer_id: body.customer_id ?? null,
status: body.status,
currency: body.currency,
language: body.language,
vat_rate: body.vat_rate,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate,
scope_title: body.scope_title ?? null,
scope_description: body.scope_description ?? null,
notes: body.notes ?? null,
},
});
if (Array.isArray(body.items)) {
await tx.order_items.createMany({
data: body.items.map((item, i) => ({
order_id: order.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.order_sections.createMany({
data: body.sections.map((s, i) => ({
order_id: order.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return { id: order.id, order_number: order.order_number };
});
if (Array.isArray(body.items)) {
await tx.order_items.createMany({
data: body.items.map((item, i) => ({
order_id: order.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error: err.message,
status: (err as Error & { status: number }).status,
};
}
if (Array.isArray(body.sections)) {
await tx.order_sections.createMany({
data: body.sections.map((s, i) => ({
order_id: order.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return { id: order.id, order_number: order.order_number };
});
throw err;
}
}
interface UpdateOrderData {
@@ -499,30 +513,34 @@ export async function deleteOrder(id: number) {
if (!existing)
return { error: "Objednávka nenalezena", status: 404 } as const;
// Clear quotation back-reference (matching PHP)
await prisma.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
// Delete linked project and its notes (matching PHP)
// Fetch linked projects before the transaction for number release later
const linkedProjects = await prisma.projects.findMany({
where: { order_id: id },
select: { id: true, created_at: true },
});
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
await prisma.project_notes.deleteMany({
where: { project_id: { in: projectIds } },
await prisma.$transaction(async (tx) => {
// Clear quotation back-reference (matching PHP)
await tx.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
await prisma.projects.deleteMany({ where: { order_id: id } });
}
// Explicitly clean up child rows
await prisma.order_items.deleteMany({ where: { order_id: id } });
await prisma.order_sections.deleteMany({ where: { order_id: id } });
// Delete linked project and its notes (matching PHP)
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
await tx.project_notes.deleteMany({
where: { project_id: { in: projectIds } },
});
await tx.projects.deleteMany({ where: { order_id: id } });
}
await prisma.orders.delete({ where: { id } });
// Explicitly clean up child rows
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.orders.delete({ where: { id } });
});
const releasedYears = new Set<number>();
const year = existing.created_at

View File

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

View File

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

View File

@@ -20,7 +20,9 @@ export const OTPAuth = {
return { valid: false, counter: null };
}
const currentCounter = Math.floor(Date.now() / 1000 / config.totp.period);
return { valid: true, counter: currentCounter + delta };
// Only advance counter for current or past codes, not future ones
const counterDelta = Math.min(delta, 0);
return { valid: true, counter: currentCounter + counterDelta };
} catch (err) {
console.error("TOTP verification error:", err);
return { valid: false, counter: null };

View File

@@ -19,5 +19,13 @@
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/admin", "src/context", "src/main.tsx", "src/App.tsx"]
"exclude": [
"node_modules",
"dist",
"src/admin",
"src/context",
"src/main.tsx",
"src/App.tsx",
"src/__tests__"
]
}