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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function AttendanceShiftTable({
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento mÄ›sĂ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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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í");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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í");
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
|
||||
|
||||
@@ -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("; "),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" } }),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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()])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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__"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user