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
|
APP_ENV=local
|
||||||
|
|
||||||
# Auth — MUST regenerate for production: openssl rand -hex 32
|
# Auth — MUST regenerate for production: openssl rand -hex 32
|
||||||
JWT_SECRET=generate-with-openssl-rand-hex-32
|
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||||
ACCESS_TOKEN_EXPIRY=900
|
ACCESS_TOKEN_EXPIRY=900
|
||||||
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
||||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
||||||
|
|
||||||
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
||||||
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
|
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||||
|
|
||||||
# File storage
|
# File storage
|
||||||
NAS_PATH=Z:/02_PROJEKTY
|
NAS_PATH=Z:/02_PROJEKTY
|
||||||
|
|||||||
@@ -75,6 +75,19 @@ function NativeInput({
|
|||||||
disabled,
|
disabled,
|
||||||
}: NativeInputProps) {
|
}: NativeInputProps) {
|
||||||
const type = modeToInputType[mode] || "date";
|
const type = modeToInputType[mode] || "date";
|
||||||
|
// For time inputs, min/max must be in HH:mm format, not date format
|
||||||
|
const formatTimeMinMax = (val: string | undefined): string | undefined => {
|
||||||
|
if (!val) return undefined;
|
||||||
|
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
|
||||||
|
// otherwise it's not a valid time min/max — return undefined
|
||||||
|
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
|
||||||
|
if (val.includes(":")) return val.substring(0, 5);
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const minProp =
|
||||||
|
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
|
||||||
|
const maxProp =
|
||||||
|
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
@@ -84,8 +97,8 @@ function NativeInput({
|
|||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={minDate || undefined}
|
min={minProp}
|
||||||
max={maxDate || undefined}
|
max={maxProp}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default function AttendanceShiftTable({
|
|||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-empty-state">
|
<div className="admin-empty-state">
|
||||||
<p>Za tento mÄ›sĂc nejsou žádnĂ© záznamy.</p>
|
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,15 +137,15 @@ export default function AttendanceShiftTable({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Datum</th>
|
<th>Datum</th>
|
||||||
<th>Zaměstnanec</th>
|
<th>Zaměstnanec</th>
|
||||||
<th>Typ</th>
|
<th>Typ</th>
|
||||||
<th>PĹ™Ăchod</th>
|
<th>Příchod</th>
|
||||||
<th>Pauza</th>
|
<th>Pauza</th>
|
||||||
<th>Odchod</th>
|
<th>Odchod</th>
|
||||||
<th>Hodiny</th>
|
<th>Hodiny</th>
|
||||||
<th>Projekt</th>
|
<th>Projekt</th>
|
||||||
<th>GPS</th>
|
<th>GPS</th>
|
||||||
<th>Poznámka</th>
|
<th>Poznámka</th>
|
||||||
<th>Akce</th>
|
<th>Akce</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
|||||||
{ id, message, type: type as Alert["type"] },
|
{ id, message, type: type as Alert["type"] },
|
||||||
]);
|
]);
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
const timeoutId = setTimeout(() => removeAlert(id), duration);
|
const timeoutId = setTimeout(() => {
|
||||||
|
timeoutsRef.current.delete(timeoutId);
|
||||||
|
removeAlert(id);
|
||||||
|
}, duration);
|
||||||
timeoutsRef.current.add(timeoutId);
|
timeoutsRef.current.add(timeoutId);
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
login_token: loginToken,
|
login_token: loginToken,
|
||||||
totp_code: code,
|
totp_code: code,
|
||||||
remember_me: remember,
|
remember_me: remember,
|
||||||
|
isBackup,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default function useListData<T = unknown>(
|
|||||||
const [initialLoad, setInitialLoad] = useState(true);
|
const [initialLoad, setInitialLoad] = useState(true);
|
||||||
const [pagination, setPagination] = useState<PaginationData | null>(null);
|
const [pagination, setPagination] = useState<PaginationData | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
||||||
const extraParamsKey = Object.entries(extraParams)
|
const extraParamsKey = Object.entries(extraParams)
|
||||||
@@ -100,8 +101,10 @@ export default function useListData<T = unknown>(
|
|||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error && err.name === "AbortError") return;
|
if (err instanceof Error && err.name === "AbortError") return;
|
||||||
|
if (!mountedRef.current) return;
|
||||||
alert.error(errorMsg);
|
alert.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setInitialLoad(false);
|
setInitialLoad(false);
|
||||||
}
|
}
|
||||||
@@ -117,8 +120,10 @@ export default function useListData<T = unknown>(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
fetchData();
|
fetchData();
|
||||||
return () => {
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
if (abortRef.current) abortRef.current.abort();
|
if (abortRef.current) abortRef.current.abort();
|
||||||
};
|
};
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|||||||
@@ -634,7 +634,8 @@ export default function Attendance() {
|
|||||||
{projectLogs.length > 0 && (
|
{projectLogs.length > 0 && (
|
||||||
<div className="attendance-project-logs">
|
<div className="attendance-project-logs">
|
||||||
{projectLogs.map((log, i) => {
|
{projectLogs.map((log, i) => {
|
||||||
const start = new Date(log.started_at!);
|
if (!log.started_at) return null;
|
||||||
|
const start = new Date(log.started_at);
|
||||||
const end = log.ended_at
|
const end = log.ended_at
|
||||||
? new Date(log.ended_at)
|
? new Date(log.ended_at)
|
||||||
: new Date();
|
: new Date();
|
||||||
@@ -786,11 +787,12 @@ export default function Attendance() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{shiftLogs.map((log, i) => {
|
{shiftLogs.map((log, i) => {
|
||||||
|
if (!log.started_at) return null;
|
||||||
const mins = log.ended_at
|
const mins = log.ended_at
|
||||||
? Math.floor(
|
? Math.floor(
|
||||||
(new Date(log.ended_at).getTime() -
|
(new Date(log.ended_at).getTime() -
|
||||||
new Date(
|
new Date(
|
||||||
log.started_at!,
|
log.started_at,
|
||||||
).getTime()) /
|
).getTime()) /
|
||||||
60000,
|
60000,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -449,8 +449,15 @@ export default function InvoiceDetail() {
|
|||||||
}>({ show: false, status: null });
|
}>({ show: false, status: null });
|
||||||
const [pdfLoading, setPdfLoading] = useState(false);
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
|
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ─── Data loading ───
|
// ─── Data loading ───
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -915,7 +922,8 @@ export default function InvoiceDetail() {
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
if (newWindow) newWindow.location.href = url;
|
if (newWindow) newWindow.location.href = url;
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
blobTimeoutsRef.current.push(timeoutId);
|
||||||
} catch {
|
} catch {
|
||||||
newWindow?.close();
|
newWindow?.close();
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
|
|||||||
@@ -163,8 +163,8 @@ export default function LeaveApproval() {
|
|||||||
: []),
|
: []),
|
||||||
].sort(
|
].sort(
|
||||||
(a: LeaveRequest, b: LeaveRequest) =>
|
(a: LeaveRequest, b: LeaveRequest) =>
|
||||||
new Date(b.reviewed_at!).getTime() -
|
(b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) -
|
||||||
new Date(a.reviewed_at!).getTime(),
|
(a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
setProcessedRequests(all);
|
setProcessedRequests(all);
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ export default function OfferDetail() {
|
|||||||
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
|
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
|
||||||
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
||||||
const [pdfLoading, setPdfLoading] = useState(false);
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
const [companySettings, setCompanySettings] = useState<{
|
const [companySettings, setCompanySettings] = useState<{
|
||||||
default_currency: string;
|
default_currency: string;
|
||||||
default_vat_rate: number;
|
default_vat_rate: number;
|
||||||
@@ -341,6 +342,12 @@ export default function OfferDetail() {
|
|||||||
|
|
||||||
useModalLock(showOrderModal);
|
useModalLock(showOrderModal);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch(`${API_BASE}/company-settings`)
|
apiFetch(`${API_BASE}/company-settings`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -829,7 +836,8 @@ export default function OfferDetail() {
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
if (newWindow) newWindow.location.href = url;
|
if (newWindow) newWindow.location.href = url;
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
blobTimeoutsRef.current.push(timeoutId);
|
||||||
} catch {
|
} catch {
|
||||||
newWindow?.close();
|
newWindow?.close();
|
||||||
alert.error("Chyba při generování PDF");
|
alert.error("Chyba při generování PDF");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
@@ -121,6 +122,13 @@ export default function OrderDetail() {
|
|||||||
const [confirmationLoading, setConfirmationLoading] = useState(false);
|
const [confirmationLoading, setConfirmationLoading] = useState(false);
|
||||||
const initialNotesRef = useRef<string | null>(null);
|
const initialNotesRef = useRef<string | null>(null);
|
||||||
const hasSetInitialSnapshot = useRef(false);
|
const hasSetInitialSnapshot = useRef(false);
|
||||||
|
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchDetail = useCallback(async () => {
|
const fetchDetail = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -249,7 +257,8 @@ export default function OrderDetail() {
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
if (newWindow) newWindow.location.href = url;
|
if (newWindow) newWindow.location.href = url;
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
blobTimeoutsRef.current.push(timeoutId);
|
||||||
} catch {
|
} catch {
|
||||||
newWindow?.close();
|
newWindow?.close();
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
@@ -293,7 +302,8 @@ export default function OrderDetail() {
|
|||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
blobTimeoutsRef.current.push(timeoutId);
|
||||||
} catch {
|
} catch {
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export default function ReceivedInvoices({
|
|||||||
const [statsLoading, setStatsLoading] = useState(true);
|
const [statsLoading, setStatsLoading] = useState(true);
|
||||||
const hasLoadedOnce = useRef(false);
|
const hasLoadedOnce = useRef(false);
|
||||||
const slideDirection = useRef(0);
|
const slideDirection = useRef(0);
|
||||||
|
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
const [slideKey, setSlideKey] = useState(0);
|
const [slideKey, setSlideKey] = useState(0);
|
||||||
const prevMonth = useRef(statsMonth);
|
const prevMonth = useRef(statsMonth);
|
||||||
const prevYear = useRef(statsYear);
|
const prevYear = useRef(statsYear);
|
||||||
@@ -185,6 +186,12 @@ export default function ReceivedInvoices({
|
|||||||
|
|
||||||
useModalLock(uploadOpen || editOpen);
|
useModalLock(uploadOpen || editOpen);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = prevYear.current * 12 + prevMonth.current;
|
const prev = prevYear.current * 12 + prevMonth.current;
|
||||||
const curr = statsYear * 12 + statsMonth;
|
const curr = statsYear * 12 + statsMonth;
|
||||||
@@ -516,7 +523,8 @@ export default function ReceivedInvoices({
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
if (newWindow) newWindow.location.href = url;
|
if (newWindow) newWindow.location.href = url;
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
blobTimeoutsRef.current.push(timeoutId);
|
||||||
} catch {
|
} catch {
|
||||||
newWindow?.close();
|
newWindow?.close();
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ export const config = {
|
|||||||
totp: {
|
totp: {
|
||||||
encryptionKey: required("TOTP_ENCRYPTION_KEY"),
|
encryptionKey: required("TOTP_ENCRYPTION_KEY"),
|
||||||
algorithm: (process.env.TOTP_ALGORITHM || "SHA1") as "SHA1",
|
algorithm: (process.env.TOTP_ALGORITHM || "SHA1") as "SHA1",
|
||||||
digits: parseInt(process.env.TOTP_DIGITS || "6", 10),
|
digits: Math.max(6, parseInt(process.env.TOTP_DIGITS || "6", 10) || 6),
|
||||||
period: parseInt(process.env.TOTP_PERIOD || "30", 10),
|
period: Math.max(15, parseInt(process.env.TOTP_PERIOD || "30", 10) || 30),
|
||||||
loginTokenExpiryMinutes: parseInt(
|
loginTokenExpiryMinutes: Math.max(
|
||||||
process.env.LOGIN_TOKEN_EXPIRY_MINUTES || "5",
|
1,
|
||||||
10,
|
parseInt(process.env.LOGIN_TOKEN_EXPIRY_MINUTES || "5", 10) || 5,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export async function securityHeaders(
|
|||||||
"Permissions-Policy",
|
"Permissions-Policy",
|
||||||
"camera=(), microphone=(), geolocation=(self)",
|
"camera=(), microphone=(), geolocation=(self)",
|
||||||
);
|
);
|
||||||
|
reply.header("Cross-Origin-Opener-Policy", "same-origin");
|
||||||
|
reply.header("Cross-Origin-Resource-Policy", "same-origin");
|
||||||
|
|
||||||
if (config.isProduction) {
|
if (config.isProduction) {
|
||||||
reply.header(
|
reply.header(
|
||||||
@@ -27,6 +29,9 @@ export async function securityHeaders(
|
|||||||
"font-src 'self' https://fonts.gstatic.com",
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
"img-src 'self' data: blob: https://*.tile.openstreetmap.org",
|
"img-src 'self' data: blob: https://*.tile.openstreetmap.org",
|
||||||
"connect-src 'self' https://nominatim.openstreetmap.org",
|
"connect-src 'self' https://nominatim.openstreetmap.org",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
].join("; "),
|
].join("; "),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,21 +144,21 @@ export default async function attendanceRoutes(
|
|||||||
|
|
||||||
// --- action=attendance_users: users with attendance.record permission ---
|
// --- action=attendance_users: users with attendance.record permission ---
|
||||||
if (action === "attendance_users") {
|
if (action === "attendance_users") {
|
||||||
|
if (
|
||||||
|
!authData.permissions.includes("attendance.admin") &&
|
||||||
|
!authData.permissions.includes("attendance.view") &&
|
||||||
|
!authData.permissions.includes("attendance.record")
|
||||||
|
) {
|
||||||
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
|
}
|
||||||
const users = await prisma.users.findMany({
|
const users = await prisma.users.findMany({
|
||||||
where: {
|
where: {
|
||||||
is_active: true,
|
is_active: true,
|
||||||
roles: {
|
roles: {
|
||||||
is: {
|
|
||||||
OR: [
|
|
||||||
{ name: "admin" },
|
|
||||||
{
|
|
||||||
role_permissions: {
|
role_permissions: {
|
||||||
some: { permissions: { name: "attendance.record" } },
|
some: { permissions: { name: "attendance.record" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
select: { id: true, first_name: true, last_name: true, username: true },
|
select: { id: true, first_name: true, last_name: true, username: true },
|
||||||
orderBy: { last_name: "asc" },
|
orderBy: { last_name: "asc" },
|
||||||
@@ -182,6 +182,12 @@ export default async function attendanceRoutes(
|
|||||||
|
|
||||||
// --- action=project_logs: get project logs for a specific attendance record ---
|
// --- action=project_logs: get project logs for a specific attendance record ---
|
||||||
if (action === "project_logs") {
|
if (action === "project_logs") {
|
||||||
|
if (
|
||||||
|
!authData.permissions.includes("attendance.view") &&
|
||||||
|
!authData.permissions.includes("attendance.record")
|
||||||
|
) {
|
||||||
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
|
}
|
||||||
const attendanceId = Number(query.attendance_id);
|
const attendanceId = Number(query.attendance_id);
|
||||||
if (!attendanceId) return error(reply, "Missing attendance_id", 400);
|
if (!attendanceId) return error(reply, "Missing attendance_id", 400);
|
||||||
const data = await attendanceService.getProjectLogs(attendanceId);
|
const data = await attendanceService.getProjectLogs(attendanceId);
|
||||||
@@ -411,7 +417,7 @@ export default async function attendanceRoutes(
|
|||||||
// PUT /api/admin/attendance/:id
|
// PUT /api/admin/attendance/:id
|
||||||
fastify.put<{ Params: { id: string } }>(
|
fastify.put<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requireAuth },
|
{ preHandler: requirePermission("attendance.edit") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default async function authRoutes(
|
|||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
max: 20,
|
max: 5,
|
||||||
timeWindow: "1 minute",
|
timeWindow: "1 minute",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -104,10 +104,8 @@ export default async function authRoutes(
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = parseBody(TotpVerifySchema, request.body);
|
const parsed = parseBody(TotpVerifySchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
const { login_token, totp_code } = parsed.data;
|
const { login_token, totp_code, remember_me } = parsed.data;
|
||||||
const rawBody = request.body as unknown as Record<string, unknown>;
|
const rememberMe = remember_me ?? false;
|
||||||
const rememberMe =
|
|
||||||
rawBody.remember_me === true || rawBody.remember_me === "true";
|
|
||||||
|
|
||||||
const tokenHash = crypto
|
const tokenHash = crypto
|
||||||
.createHash("sha256")
|
.createHash("sha256")
|
||||||
@@ -130,8 +128,6 @@ export default async function authRoutes(
|
|||||||
const storedTokenId = Number(storedToken.id);
|
const storedTokenId = Number(storedToken.id);
|
||||||
const storedUserId = Number(storedToken.user_id);
|
const storedUserId = Number(storedToken.user_id);
|
||||||
|
|
||||||
await tx.totp_login_tokens.delete({ where: { id: storedTokenId } });
|
|
||||||
|
|
||||||
const user = await tx.users.findUnique({
|
const user = await tx.users.findUnique({
|
||||||
where: { id: storedUserId },
|
where: { id: storedUserId },
|
||||||
include: { roles: true },
|
include: { roles: true },
|
||||||
@@ -141,11 +137,15 @@ export default async function authRoutes(
|
|||||||
return { error: "Uživatel nenalezen", status: 401 };
|
return { error: "Uživatel nenalezen", status: 401 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.is_active) {
|
||||||
|
return { error: "Účet je deaktivován", status: 401 };
|
||||||
|
}
|
||||||
|
|
||||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||||
return { error: "Účet je dočasně uzamčen", status: 429 };
|
return { error: "Účet je dočasně uzamčen", status: 429 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user };
|
return { user, storedTokenId };
|
||||||
});
|
});
|
||||||
|
|
||||||
if ("error" in totpResult) {
|
if ("error" in totpResult) {
|
||||||
@@ -183,6 +183,11 @@ export default async function authRoutes(
|
|||||||
return error(reply, "TOTP kód již byl použit", 401);
|
return error(reply, "TOTP kód již byl použit", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTP verified successfully — now consume the login token
|
||||||
|
await prisma.totp_login_tokens.delete({
|
||||||
|
where: { id: totpResult.storedTokenId },
|
||||||
|
});
|
||||||
|
|
||||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||||
await prisma.users.update({
|
await prisma.users.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
@@ -234,6 +239,16 @@ export default async function authRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
setRefreshCookie(reply, refreshTokenRaw, rememberMe);
|
setRefreshCookie(reply, refreshTokenRaw, rememberMe);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
request,
|
||||||
|
authData: authData,
|
||||||
|
action: "login_totp",
|
||||||
|
entityType: "user",
|
||||||
|
entityId: user.id,
|
||||||
|
description: `TOTP přihlášení uživatele ${user.username}`,
|
||||||
|
});
|
||||||
|
|
||||||
return success(reply, { access_token: accessToken, user: authData });
|
return success(reply, { access_token: accessToken, user: authData });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -278,7 +293,7 @@ export default async function authRoutes(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/admin/logout
|
// POST /api/admin/logout
|
||||||
fastify.post("/logout", async (request, reply) => {
|
fastify.post("/logout", { bodyLimit: 10240 }, async (request, reply) => {
|
||||||
const refreshTokenRaw = request.cookies.refresh_token;
|
const refreshTokenRaw = request.cookies.refresh_token;
|
||||||
if (refreshTokenRaw) {
|
if (refreshTokenRaw) {
|
||||||
await logout(refreshTokenRaw);
|
await logout(refreshTokenRaw);
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export default async function companySettingsRoutes(
|
|||||||
);
|
);
|
||||||
|
|
||||||
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
|
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
|
||||||
|
// Use upsert to avoid race condition between findFirst and create
|
||||||
let settings = await prisma.company_settings.findFirst({
|
let settings = await prisma.company_settings.findFirst({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -176,7 +177,56 @@ export default async function companySettingsRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
settings = await prisma.company_settings.create({
|
// Wrap create in a transaction to handle race condition:
|
||||||
|
// another request may have created settings between findFirst and create
|
||||||
|
settings = await prisma.$transaction(async (tx) => {
|
||||||
|
// Double-check inside transaction
|
||||||
|
const existing = await tx.company_settings.findFirst({
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return tx.company_settings.findUnique({
|
||||||
|
where: { id: existing.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
company_name: true,
|
||||||
|
street: true,
|
||||||
|
city: true,
|
||||||
|
postal_code: true,
|
||||||
|
country: true,
|
||||||
|
company_id: true,
|
||||||
|
vat_id: true,
|
||||||
|
custom_fields: true,
|
||||||
|
quotation_prefix: true,
|
||||||
|
default_currency: true,
|
||||||
|
default_vat_rate: true,
|
||||||
|
uuid: true,
|
||||||
|
modified_at: true,
|
||||||
|
is_deleted: true,
|
||||||
|
sync_version: true,
|
||||||
|
order_type_code: true,
|
||||||
|
invoice_type_code: true,
|
||||||
|
require_2fa: true,
|
||||||
|
break_threshold_hours: true,
|
||||||
|
break_duration_short: true,
|
||||||
|
break_duration_long: true,
|
||||||
|
clock_rounding_minutes: true,
|
||||||
|
invoice_alert_email: true,
|
||||||
|
leave_notify_email: true,
|
||||||
|
max_login_attempts: true,
|
||||||
|
lockout_minutes: true,
|
||||||
|
max_requests_per_minute: true,
|
||||||
|
available_vat_rates: true,
|
||||||
|
available_currencies: true,
|
||||||
|
smtp_from: true,
|
||||||
|
smtp_from_name: true,
|
||||||
|
offer_number_pattern: true,
|
||||||
|
order_number_pattern: true,
|
||||||
|
invoice_number_pattern: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tx.company_settings.create({
|
||||||
data: {
|
data: {
|
||||||
company_name: "",
|
company_name: "",
|
||||||
quotation_prefix: "N",
|
quotation_prefix: "N",
|
||||||
@@ -221,6 +271,7 @@ export default async function companySettingsRoutes(
|
|||||||
invoice_number_pattern: true,
|
invoice_number_pattern: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!settings) return error(reply, "Nastavení nenalezeno", 500);
|
if (!settings) return error(reply, "Nastavení nenalezeno", 500);
|
||||||
|
|
||||||
@@ -429,7 +480,7 @@ export default async function companySettingsRoutes(
|
|||||||
: existingOrder,
|
: existingOrder,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
data.sync_version = (existing.sync_version ?? 0) + 1;
|
data.sync_version = { increment: 1 };
|
||||||
|
|
||||||
await prisma.company_settings.update({
|
await prisma.company_settings.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
|
|||||||
@@ -180,9 +180,9 @@ export default async function dashboardRoutes(
|
|||||||
// Invoices — only for invoices.view
|
// Invoices — only for invoices.view
|
||||||
if (has("invoices.view")) {
|
if (has("invoices.view")) {
|
||||||
// $queryRaw template literal interpolation with Date objects fails on
|
// $queryRaw template literal interpolation with Date objects fails on
|
||||||
// MySQL when Date.toJSON is overridden — pass strings instead.
|
// MySQL when Date.toJSON is overridden — pass explicit date strings instead.
|
||||||
const monthStartStr = monthStart.toJSON();
|
const monthStartStr = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
const monthEndStr = monthEnd.toJSON();
|
const monthEndStr = `${monthEnd.getFullYear()}-${String(monthEnd.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
|
||||||
const [unpaidCount, revenueAgg] = await Promise.all([
|
const [unpaidCount, revenueAgg] = await Promise.all([
|
||||||
prisma.invoices.count({ where: { status: "issued" } }),
|
prisma.invoices.count({ where: { status: "issued" } }),
|
||||||
|
|||||||
@@ -26,10 +26,16 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
|||||||
export default async function invoicesRoutes(
|
export default async function invoicesRoutes(
|
||||||
fastify: FastifyInstance,
|
fastify: FastifyInstance,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Auto-update overdue invoices on GET requests only (matches PHP behavior)
|
// Auto-update overdue invoices on GET requests, throttled to once per hour
|
||||||
|
let lastOverdueCheck = 0;
|
||||||
|
const OVERDUE_CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
fastify.addHook("onRequest", async (request) => {
|
fastify.addHook("onRequest", async (request) => {
|
||||||
if (request.method !== "GET") return;
|
if (request.method !== "GET") return;
|
||||||
|
if (Date.now() - lastOverdueCheck > OVERDUE_CHECK_INTERVAL) {
|
||||||
|
lastOverdueCheck = Date.now();
|
||||||
await markOverdueInvoices();
|
await markOverdueInvoices();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/admin/invoices
|
// GET /api/admin/invoices
|
||||||
@@ -226,12 +232,10 @@ export default async function invoicesRoutes(
|
|||||||
const file = nasFinancialsManager.readIssuedInvoice(relPath);
|
const file = nasFinancialsManager.readIssuedInvoice(relPath);
|
||||||
if (!file) return error(reply, "PDF soubor nenalezen", 404);
|
if (!file) return error(reply, "PDF soubor nenalezen", 404);
|
||||||
|
|
||||||
|
const safeName = invoice.invoice_number.replace(/[\r\n"]/g, "");
|
||||||
return reply
|
return reply
|
||||||
.type("application/pdf")
|
.type("application/pdf")
|
||||||
.header(
|
.header("Content-Disposition", `inline; filename="${safeName}.pdf"`)
|
||||||
"Content-Disposition",
|
|
||||||
`inline; filename="${invoice.invoice_number}.pdf"`,
|
|
||||||
)
|
|
||||||
.send(file.data);
|
.send(file.data);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -241,20 +241,20 @@ export default async function leaveRequestsRoutes(
|
|||||||
|
|
||||||
const totalHours = totalBusinessDays * 8;
|
const totalHours = totalBusinessDays * 8;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Check for duplicate attendance records inside the transaction
|
||||||
for (const ac of attendanceCreates) {
|
for (const ac of attendanceCreates) {
|
||||||
const duplicate = await prisma.attendance.findFirst({
|
const duplicate = await tx.attendance.findFirst({
|
||||||
where: { user_id: ac.user_id, shift_date: ac.shift_date },
|
where: { user_id: ac.user_id, shift_date: ac.shift_date },
|
||||||
});
|
});
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
return error(
|
throw new Error(
|
||||||
reply,
|
|
||||||
"Pro zvolené datumy již existují záznamy docházky",
|
"Pro zvolené datumy již existují záznamy docházky",
|
||||||
400,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
// 1. Create attendance records for each business day
|
// 1. Create attendance records for each business day
|
||||||
if (attendanceCreates.length > 0) {
|
if (attendanceCreates.length > 0) {
|
||||||
await tx.attendance.createMany({ data: attendanceCreates });
|
await tx.attendance.createMany({ data: attendanceCreates });
|
||||||
@@ -305,6 +305,19 @@ export default async function leaveRequestsRoutes(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Error &&
|
||||||
|
e.message === "Pro zvolené datumy již existují záznamy docházky"
|
||||||
|
) {
|
||||||
|
return error(
|
||||||
|
reply,
|
||||||
|
"Pro zvolené datumy již existují záznamy docházky",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -38,12 +38,7 @@ export default async function profileRoutes(
|
|||||||
|
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
if (body.email) {
|
if (body.email) {
|
||||||
const newEmail = String(body.email).trim();
|
data.email = String(body.email).trim();
|
||||||
const existing = await prisma.users.findFirst({
|
|
||||||
where: { email: newEmail, id: { not: userId } },
|
|
||||||
});
|
|
||||||
if (existing) return error(reply, "E-mail již existuje", 409);
|
|
||||||
data.email = newEmail;
|
|
||||||
}
|
}
|
||||||
if (body.first_name) data.first_name = String(body.first_name);
|
if (body.first_name) data.first_name = String(body.first_name);
|
||||||
if (body.last_name) data.last_name = String(body.last_name);
|
if (body.last_name) data.last_name = String(body.last_name);
|
||||||
@@ -65,7 +60,23 @@ export default async function profileRoutes(
|
|||||||
data.password_changed_at = new Date();
|
data.password_changed_at = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.users.update({ where: { id: userId }, data });
|
// Wrap email uniqueness check and update in a transaction to prevent race condition
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (data.email) {
|
||||||
|
const existing = await tx.users.findFirst({
|
||||||
|
where: { email: String(data.email), id: { not: userId } },
|
||||||
|
});
|
||||||
|
if (existing) throw new Error("EMAIL_EXISTS");
|
||||||
|
}
|
||||||
|
await tx.users.update({ where: { id: userId }, data });
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "EMAIL_EXISTS") {
|
||||||
|
return error(reply, "E-mail již existuje", 409);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -73,10 +73,11 @@ export default async function projectFilesRoutes(
|
|||||||
if (!result) return error(reply, "Soubor nebyl nalezen", 404);
|
if (!result) return error(reply, "Soubor nebyl nalezen", 404);
|
||||||
|
|
||||||
const stream = fs.createReadStream(result.filePath);
|
const stream = fs.createReadStream(result.filePath);
|
||||||
|
const encodedName = encodeURIComponent(result.fileName);
|
||||||
return reply
|
return reply
|
||||||
.header(
|
.header(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
`attachment; filename="${encodeURIComponent(result.fileName)}"`,
|
`attachment; filename*=UTF-8''${encodedName}`,
|
||||||
)
|
)
|
||||||
.header("Content-Type", result.mime)
|
.header("Content-Type", result.mime)
|
||||||
.header("X-Content-Type-Options", "nosniff")
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
@@ -179,7 +180,8 @@ export default async function projectFilesRoutes(
|
|||||||
if (!file) return error(reply, "Nebyl nahrán žádný soubor");
|
if (!file) return error(reply, "Nebyl nahrán žádný soubor");
|
||||||
|
|
||||||
const subPath = parsedQuery.data.path || "";
|
const subPath = parsedQuery.data.path || "";
|
||||||
const fileName = file.filename;
|
const rawFileName = file.filename;
|
||||||
|
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
|
||||||
|
|
||||||
const err = await fm.uploadFile(
|
const err = await fm.uploadFile(
|
||||||
project.project_number,
|
project.project_number,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../../schemas/received-invoices.schema";
|
} from "../../schemas/received-invoices.schema";
|
||||||
import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
||||||
import { toCzk } from "../../services/exchange-rates";
|
import { toCzk } from "../../services/exchange-rates";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
||||||
|
|
||||||
@@ -126,9 +127,17 @@ export default async function receivedInvoicesRoutes(
|
|||||||
return Math.round(total * 100) / 100;
|
return Math.round(total * 100) / 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Also get all-time unpaid
|
// Also get all-time unpaid — use DB-level aggregation for count/sums
|
||||||
|
const stats = await prisma.received_invoices.aggregate({
|
||||||
|
where: { status: { not: "paid" }, is_deleted: false },
|
||||||
|
_sum: { amount: true, amount_czk: true },
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We still need per-currency breakdown for unpaid, so fetch only those
|
||||||
const allUnpaid = await prisma.received_invoices.findMany({
|
const allUnpaid = await prisma.received_invoices.findMany({
|
||||||
where: { status: { not: "paid" } },
|
where: { status: { not: "paid" }, is_deleted: false },
|
||||||
|
select: { amount: true, currency: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return success(reply, {
|
return success(reply, {
|
||||||
@@ -137,8 +146,10 @@ export default async function receivedInvoicesRoutes(
|
|||||||
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
|
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
|
||||||
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
|
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
|
||||||
unpaid: aggregateByCurrency(allUnpaid, "amount"),
|
unpaid: aggregateByCurrency(allUnpaid, "amount"),
|
||||||
unpaid_czk: await sumCzk(allUnpaid, "amount"),
|
unpaid_czk: stats._sum.amount_czk
|
||||||
unpaid_count: allUnpaid.length,
|
? Math.round(Number(stats._sum.amount_czk) * 100) / 100
|
||||||
|
: await sumCzk(allUnpaid, "amount"),
|
||||||
|
unpaid_count: stats._count,
|
||||||
month_count: monthInvoices.length,
|
month_count: monthInvoices.length,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -188,12 +199,10 @@ export default async function receivedInvoicesRoutes(
|
|||||||
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||||||
|
|
||||||
const mime = invoice.file_mime || "application/pdf";
|
const mime = invoice.file_mime || "application/pdf";
|
||||||
|
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
|
||||||
return reply
|
return reply
|
||||||
.type(mime)
|
.type(mime)
|
||||||
.header(
|
.header("Content-Disposition", `inline; filename="${safeFileName}"`)
|
||||||
"Content-Disposition",
|
|
||||||
`inline; filename="${invoice.file_name}"`,
|
|
||||||
)
|
|
||||||
.send(nasFile.data);
|
.send(nasFile.data);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -315,7 +324,9 @@ export default async function receivedInvoicesRoutes(
|
|||||||
status: "unpaid",
|
status: "unpaid",
|
||||||
notes: meta.notes ? String(meta.notes) : null,
|
notes: meta.notes ? String(meta.notes) : null,
|
||||||
uploaded_by: request.authData?.userId,
|
uploaded_by: request.authData?.userId,
|
||||||
file_name: file.name,
|
file_name: nasResult.filePath
|
||||||
|
? path.basename(nasResult.filePath)
|
||||||
|
: file.name,
|
||||||
file_mime: file.mime,
|
file_mime: file.mime,
|
||||||
file_size: file.size,
|
file_size: file.size,
|
||||||
},
|
},
|
||||||
@@ -364,7 +375,7 @@ export default async function receivedInvoicesRoutes(
|
|||||||
vat_rate: vatRate,
|
vat_rate: vatRate,
|
||||||
vat_amount:
|
vat_amount:
|
||||||
vatRate > 0
|
vatRate > 0
|
||||||
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
|
? Math.round(((amount * vatRate) / 100) * 100) / 100
|
||||||
: 0,
|
: 0,
|
||||||
issue_date: body.issue_date
|
issue_date: body.issue_date
|
||||||
? new Date(String(body.issue_date))
|
? new Date(String(body.issue_date))
|
||||||
@@ -544,6 +555,9 @@ export default async function receivedInvoicesRoutes(
|
|||||||
});
|
});
|
||||||
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
|
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
|
||||||
|
|
||||||
|
// Delete DB record first, then NAS file — avoids orphaned file if DB delete fails
|
||||||
|
await prisma.received_invoices.delete({ where: { id } });
|
||||||
|
|
||||||
if (existing.file_name) {
|
if (existing.file_name) {
|
||||||
const relPath = nasFinancialsManager.buildReceivedPath(
|
const relPath = nasFinancialsManager.buildReceivedPath(
|
||||||
existing.file_name,
|
existing.file_name,
|
||||||
@@ -552,8 +566,6 @@ export default async function receivedInvoicesRoutes(
|
|||||||
);
|
);
|
||||||
nasFinancialsManager.deleteReceivedInvoice(relPath);
|
nasFinancialsManager.deleteReceivedInvoice(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.received_invoices.delete({ where: { id } });
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
authData: request.authData,
|
authData: request.authData,
|
||||||
|
|||||||
@@ -62,12 +62,16 @@ export default async function rolesRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(body.permission_ids)) {
|
if (Array.isArray(body.permission_ids)) {
|
||||||
await prisma.role_permissions.createMany({
|
await prisma.$transaction(
|
||||||
data: (body.permission_ids as number[]).map((pid) => ({
|
(body.permission_ids as number[]).map((pid) =>
|
||||||
|
prisma.role_permissions.create({
|
||||||
|
data: {
|
||||||
role_id: role.id,
|
role_id: role.id,
|
||||||
permission_id: pid,
|
permission_id: pid,
|
||||||
})),
|
},
|
||||||
});
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -188,10 +188,11 @@ export default async function scopeTemplatesRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(body.sections)) {
|
if (Array.isArray(body.sections)) {
|
||||||
await prisma.scope_template_sections.deleteMany({
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.scope_template_sections.deleteMany({
|
||||||
where: { scope_template_id: id },
|
where: { scope_template_id: id },
|
||||||
});
|
});
|
||||||
await prisma.scope_template_sections.createMany({
|
await tx.scope_template_sections.createMany({
|
||||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||||
scope_template_id: id,
|
scope_template_id: id,
|
||||||
title: s.title ?? null,
|
title: s.title ?? null,
|
||||||
@@ -200,6 +201,7 @@ export default async function scopeTemplatesRoutes(
|
|||||||
position: s.position ?? i,
|
position: s.position ?? i,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return success(reply, { id }, 200, "Šablona byla uložena");
|
return success(reply, { id }, 200, "Šablona byla uložena");
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ import { requireAuth, requirePermission } from "../../middleware/auth";
|
|||||||
import { success, error } from "../../utils/response";
|
import { success, error } from "../../utils/response";
|
||||||
import { encrypt } from "../../utils/encryption";
|
import { encrypt } from "../../utils/encryption";
|
||||||
import { getSystemSettings } from "../../services/system-settings";
|
import { getSystemSettings } from "../../services/system-settings";
|
||||||
|
import { config } from "../../config/env";
|
||||||
import { OTPAuth } from "../../utils/totp";
|
import { OTPAuth } from "../../utils/totp";
|
||||||
import * as OTPAuthLib from "otpauth";
|
import * as OTPAuthLib from "otpauth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
import { TotpBackupSchema } from "../../schemas/auth.schema";
|
import {
|
||||||
|
TotpBackupSchema,
|
||||||
|
TotpEnableSchema,
|
||||||
|
TotpDisableSchema,
|
||||||
|
TotpRequiredSchema,
|
||||||
|
} from "../../schemas/auth.schema";
|
||||||
|
|
||||||
export default async function totpRoutes(
|
export default async function totpRoutes(
|
||||||
fastify: FastifyInstance,
|
fastify: FastifyInstance,
|
||||||
@@ -47,12 +53,9 @@ export default async function totpRoutes(
|
|||||||
"/enable",
|
"/enable",
|
||||||
{ preHandler: requireAuth, bodyLimit: 10240 },
|
{ preHandler: requireAuth, bodyLimit: 10240 },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const body = request.body as Record<string, unknown>;
|
const parsed = parseBody(TotpEnableSchema, request.body);
|
||||||
const { secret, code } = body;
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
|
const { secret, code, password, current_code } = parsed.data;
|
||||||
if (!secret || !code) {
|
|
||||||
return error(reply, "Secret a kód jsou povinné", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.users.findUnique({
|
const user = await prisma.users.findUnique({
|
||||||
where: { id: request.authData!.userId },
|
where: { id: request.authData!.userId },
|
||||||
@@ -60,41 +63,35 @@ export default async function totpRoutes(
|
|||||||
if (!user) return error(reply, "Uživatel nenalezen", 404);
|
if (!user) return error(reply, "Uživatel nenalezen", 404);
|
||||||
|
|
||||||
if (user.totp_enabled) {
|
if (user.totp_enabled) {
|
||||||
if (!body.current_code) {
|
if (!current_code) {
|
||||||
return error(
|
return error(
|
||||||
reply,
|
reply,
|
||||||
"Aktuální TOTP kód je povinný pro změnu 2FA",
|
"Aktuální TOTP kód je povinný pro změnu 2FA",
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const verifyResult = OTPAuth.verify(
|
const verifyResult = OTPAuth.verify(user.totp_secret!, current_code);
|
||||||
user.totp_secret!,
|
|
||||||
String(body.current_code),
|
|
||||||
);
|
|
||||||
if (!verifyResult.valid) {
|
if (!verifyResult.valid) {
|
||||||
return error(reply, "Neplatný aktuální TOTP kód", 400);
|
return error(reply, "Neplatný aktuální TOTP kód", 400);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!body.password) {
|
if (!password) {
|
||||||
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
|
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
|
||||||
}
|
}
|
||||||
const valid = await bcrypt.compare(
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
String(body.password),
|
|
||||||
user.password_hash,
|
|
||||||
);
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return error(reply, "Nesprávné heslo", 400);
|
return error(reply, "Nesprávné heslo", 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totp = new OTPAuthLib.TOTP({
|
const totp = new OTPAuthLib.TOTP({
|
||||||
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
|
secret: OTPAuthLib.Secret.fromBase32(secret),
|
||||||
algorithm: "SHA1",
|
algorithm: "SHA1",
|
||||||
digits: 6,
|
digits: 6,
|
||||||
period: 30,
|
period: 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
const delta = totp.validate({ token: String(code), window: 1 });
|
const delta = totp.validate({ token: code, window: 1 });
|
||||||
if (delta === null) {
|
if (delta === null) {
|
||||||
return error(reply, "Neplatný TOTP kód", 400);
|
return error(reply, "Neplatný TOTP kód", 400);
|
||||||
}
|
}
|
||||||
@@ -103,9 +100,11 @@ export default async function totpRoutes(
|
|||||||
const backupCodesPlain: string[] = [];
|
const backupCodesPlain: string[] = [];
|
||||||
const backupCodesHashed: string[] = [];
|
const backupCodesHashed: string[] = [];
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
|
const plainCode = crypto.randomBytes(4).toString("hex").toUpperCase();
|
||||||
backupCodesPlain.push(code);
|
backupCodesPlain.push(plainCode);
|
||||||
backupCodesHashed.push(bcrypt.hashSync(code, 10));
|
backupCodesHashed.push(
|
||||||
|
await bcrypt.hash(plainCode, config.security.bcryptCost),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedSecret = encrypt(String(secret));
|
const encryptedSecret = encrypt(String(secret));
|
||||||
@@ -140,11 +139,9 @@ export default async function totpRoutes(
|
|||||||
"/disable",
|
"/disable",
|
||||||
{ preHandler: requireAuth },
|
{ preHandler: requireAuth },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const body = request.body as Record<string, unknown>;
|
const parsed = parseBody(TotpDisableSchema, request.body);
|
||||||
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
if (!body.code) {
|
const { code } = parsed.data;
|
||||||
return error(reply, "TOTP kód je povinný pro deaktivaci", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.users.findUnique({
|
const user = await prisma.users.findUnique({
|
||||||
where: { id: request.authData!.userId },
|
where: { id: request.authData!.userId },
|
||||||
@@ -153,7 +150,7 @@ export default async function totpRoutes(
|
|||||||
return error(reply, "2FA není aktivní", 400);
|
return error(reply, "2FA není aktivní", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const verifyResult = OTPAuth.verify(user.totp_secret, String(body.code));
|
const verifyResult = OTPAuth.verify(user.totp_secret, code);
|
||||||
if (!verifyResult.valid) {
|
if (!verifyResult.valid) {
|
||||||
return error(reply, "Neplatný TOTP kód", 400);
|
return error(reply, "Neplatný TOTP kód", 400);
|
||||||
}
|
}
|
||||||
@@ -214,10 +211,9 @@ export default async function totpRoutes(
|
|||||||
bodyLimit: 10240,
|
bodyLimit: 10240,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const body = request.body as Record<string, unknown>;
|
const parsed = parseBody(TotpRequiredSchema, request.body);
|
||||||
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
const required =
|
const required = parsed.data.required;
|
||||||
body.required === true || body.required === 1 || body.required === "1";
|
|
||||||
|
|
||||||
const settings = await prisma.company_settings.findFirst({
|
const settings = await prisma.company_settings.findFirst({
|
||||||
select: { require_2fa: true },
|
select: { require_2fa: true },
|
||||||
@@ -339,7 +335,9 @@ export default async function totpRoutes(
|
|||||||
return { error: "Neplatný záložní kód", status: 401 };
|
return { error: "Neplatný záložní kód", status: 401 };
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
await tx.totp_login_tokens.delete({
|
||||||
|
where: { id: Number(storedToken.id) },
|
||||||
|
});
|
||||||
|
|
||||||
backupCodes.splice(matchIndex, 1);
|
backupCodes.splice(matchIndex, 1);
|
||||||
await tx.users.update({
|
await tx.users.update({
|
||||||
@@ -408,6 +406,14 @@ export default async function totpRoutes(
|
|||||||
maxAge: config.jwt.refreshTokenSessionExpiry,
|
maxAge: config.jwt.refreshTokenSessionExpiry,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
request,
|
||||||
|
action: "login_backup",
|
||||||
|
entityType: "user",
|
||||||
|
entityId: user.id,
|
||||||
|
description: `Backup code login for user ${user.username}`,
|
||||||
|
});
|
||||||
|
|
||||||
return success(reply, { access_token: accessToken, user: authData });
|
return success(reply, { access_token: accessToken, user: authData });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,9 +38,15 @@ export default async function tripsRoutes(
|
|||||||
mo = NaN;
|
mo = NaN;
|
||||||
}
|
}
|
||||||
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
|
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
|
||||||
|
// Use explicit date strings to avoid toJSON timezone shift
|
||||||
|
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
||||||
|
const nextMonth =
|
||||||
|
mo === 12
|
||||||
|
? `${yr + 1}-01-01`
|
||||||
|
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
||||||
where.trip_date = {
|
where.trip_date = {
|
||||||
gte: new Date(yr, mo - 1, 1),
|
gte: new Date(monthStart),
|
||||||
lt: new Date(yr, mo, 1),
|
lt: new Date(nextMonth),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,17 +81,10 @@ export default async function tripsRoutes(
|
|||||||
where: {
|
where: {
|
||||||
is_active: true,
|
is_active: true,
|
||||||
roles: {
|
roles: {
|
||||||
is: {
|
|
||||||
OR: [
|
|
||||||
{ name: "admin" },
|
|
||||||
{
|
|
||||||
role_permissions: {
|
role_permissions: {
|
||||||
some: { permissions: { name: "trips.record" } },
|
some: { permissions: { name: "trips.record" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -120,9 +119,17 @@ export default async function tripsRoutes(
|
|||||||
if (filterUserId) where.user_id = filterUserId;
|
if (filterUserId) where.user_id = filterUserId;
|
||||||
if (filterVehicleId) where.vehicle_id = filterVehicleId;
|
if (filterVehicleId) where.vehicle_id = filterVehicleId;
|
||||||
if (query.month && query.year) {
|
if (query.month && query.year) {
|
||||||
|
// Use explicit date strings to avoid toJSON timezone shift
|
||||||
|
const yr = Number(query.year);
|
||||||
|
const mo = Number(query.month);
|
||||||
|
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
||||||
|
const nextMonth =
|
||||||
|
mo === 12
|
||||||
|
? `${yr + 1}-01-01`
|
||||||
|
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
||||||
where.trip_date = {
|
where.trip_date = {
|
||||||
gte: new Date(Number(query.year), Number(query.month) - 1, 1),
|
gte: new Date(monthStart),
|
||||||
lt: new Date(Number(query.year), Number(query.month), 1),
|
lt: new Date(nextMonth),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,18 @@ export default async function vehiclesRoutes(
|
|||||||
const existing = await prisma.vehicles.findUnique({ where: { id } });
|
const existing = await prisma.vehicles.findUnique({ where: { id } });
|
||||||
if (!existing) return error(reply, "Vozidlo nenalezeno", 404);
|
if (!existing) return error(reply, "Vozidlo nenalezeno", 404);
|
||||||
|
|
||||||
|
// Check for linked trips before deleting
|
||||||
|
const tripCount = await prisma.trips.count({
|
||||||
|
where: { vehicle_id: id },
|
||||||
|
});
|
||||||
|
if (tripCount > 0) {
|
||||||
|
return error(
|
||||||
|
reply,
|
||||||
|
"Vozidlo má přiřazené jízdy a nelze jej smazat",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.vehicles.delete({ where: { id } });
|
await prisma.vehicles.delete({ where: { id } });
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ export const AttendanceLeaveSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
date_from: z.string().min(1, "Datum je povinné"),
|
date_from: z.string().min(1, "Datum je povinné"),
|
||||||
date_to: z.string().optional(),
|
date_to: z.string().optional(),
|
||||||
leave_type: z.string().optional().default("vacation"),
|
leave_type: z
|
||||||
|
.enum(["vacation", "sick", "unpaid", "holiday"])
|
||||||
|
.optional()
|
||||||
|
.default("vacation"),
|
||||||
leave_hours: z
|
leave_hours: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
@@ -65,16 +68,8 @@ export const AttendanceLeaveSchema = z.object({
|
|||||||
|
|
||||||
const ProjectLogSchema = z.object({
|
const ProjectLogSchema = z.object({
|
||||||
project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
|
project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
|
||||||
hours: z
|
hours: z.coerce.number().min(0).default(0),
|
||||||
.union([z.number(), z.string()])
|
minutes: z.coerce.number().min(0).default(0),
|
||||||
.transform((v) => Number(v) || 0)
|
|
||||||
.optional()
|
|
||||||
.default(0),
|
|
||||||
minutes: z
|
|
||||||
.union([z.number(), z.string()])
|
|
||||||
.transform((v) => Number(v) || 0)
|
|
||||||
.optional()
|
|
||||||
.default(0),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AttendancePunchSchema = z.object({
|
export const AttendancePunchSchema = z.object({
|
||||||
@@ -124,7 +119,10 @@ export const CreateAttendanceSchema = z.object({
|
|||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
.nullish(),
|
.nullish(),
|
||||||
leave_type: z.string().optional().default("work"),
|
leave_type: z
|
||||||
|
.enum(["work", "vacation", "sick", "holiday", "unpaid"])
|
||||||
|
.optional()
|
||||||
|
.default("work"),
|
||||||
leave_hours: z
|
leave_hours: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
@@ -138,8 +136,13 @@ export const UpdateAttendanceSchema = z.object({
|
|||||||
break_start: z.union([z.string(), z.null()]).optional(),
|
break_start: z.union([z.string(), z.null()]).optional(),
|
||||||
break_end: z.union([z.string(), z.null()]).optional(),
|
break_end: z.union([z.string(), z.null()]).optional(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
project_id: z.union([z.number(), z.string(), z.null()]).optional(),
|
project_id: z
|
||||||
leave_type: z.string().optional(),
|
.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
|
.optional(),
|
||||||
|
leave_type: z
|
||||||
|
.enum(["work", "vacation", "sick", "holiday", "unpaid"])
|
||||||
|
.optional(),
|
||||||
leave_hours: z.union([z.number(), z.string(), z.null()]).optional(),
|
leave_hours: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||||
project_logs: z.array(ProjectLogSchema).optional(),
|
project_logs: z.array(ProjectLogSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const LoginSchema = z.object({
|
|||||||
export const TotpVerifySchema = z.object({
|
export const TotpVerifySchema = z.object({
|
||||||
login_token: z.string().min(1, "Token je povinný"),
|
login_token: z.string().min(1, "Token je povinný"),
|
||||||
totp_code: z.string().length(6, "Kód musí mít 6 číslic"),
|
totp_code: z.string().length(6, "Kód musí mít 6 číslic"),
|
||||||
|
remember_me: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TotpBackupSchema = z.object({
|
export const TotpBackupSchema = z.object({
|
||||||
@@ -16,6 +17,22 @@ export const TotpBackupSchema = z.object({
|
|||||||
backup_code: z.string().min(1, "Záložní kód je povinný"),
|
backup_code: z.string().min(1, "Záložní kód je povinný"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const TotpEnableSchema = z.object({
|
||||||
|
secret: z.string().min(1),
|
||||||
|
code: z.string().min(1),
|
||||||
|
password: z.string().optional(),
|
||||||
|
current_code: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TotpDisableSchema = z.object({
|
||||||
|
code: z.string().min(1),
|
||||||
|
password: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TotpRequiredSchema = z.object({
|
||||||
|
required: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
export type LoginInput = z.infer<typeof LoginSchema>;
|
export type LoginInput = z.infer<typeof LoginSchema>;
|
||||||
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
|
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
|
||||||
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;
|
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ const InvoiceItemSchema = z.object({
|
|||||||
description: z.string().nullish(),
|
description: z.string().nullish(),
|
||||||
quantity: z
|
quantity: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v) || 1)
|
.transform((v) => {
|
||||||
|
const n = Number(v);
|
||||||
|
if (isNaN(n) || n <= 0) throw new Error("Invalid quantity");
|
||||||
|
return n;
|
||||||
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.default(1),
|
.default(1),
|
||||||
unit: z.string().nullish(),
|
unit: z.string().nullish(),
|
||||||
unit_price: z
|
unit_price: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v) || 0)
|
.transform((v) => {
|
||||||
|
const n = Number(v);
|
||||||
|
if (isNaN(n)) throw new Error("Invalid unit_price");
|
||||||
|
return n;
|
||||||
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.default(0),
|
.default(0),
|
||||||
vat_rate: z
|
vat_rate: z
|
||||||
@@ -73,7 +81,10 @@ export const UpdateInvoiceSchema = z.object({
|
|||||||
bank_iban: z.string().nullish(),
|
bank_iban: z.string().nullish(),
|
||||||
bank_account: z.string().nullish(),
|
bank_account: z.string().nullish(),
|
||||||
issued_by: z.string().nullish(),
|
issued_by: z.string().nullish(),
|
||||||
customer_id: z.union([z.number(), z.string(), z.null()]).optional(),
|
customer_id: z
|
||||||
|
.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
|
.optional(),
|
||||||
vat_rate: z
|
vat_rate: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const CreateLeaveRequestSchema = z.object({
|
export const CreateLeaveRequestSchema = z.object({
|
||||||
leave_type: z.string().min(1, "Typ nepřítomnosti je povinný"),
|
leave_type: z
|
||||||
|
.enum(["vacation", "sick", "unpaid", "holiday"])
|
||||||
|
.default("vacation"),
|
||||||
date_from: z.string().min(1, "Datum od je povinné"),
|
date_from: z.string().min(1, "Datum od je povinné"),
|
||||||
date_to: z.string().min(1, "Datum do je povinné"),
|
date_to: z.string().min(1, "Datum do je povinné"),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ReviewLeaveRequestSchema = z.object({
|
export const ReviewLeaveRequestSchema = z.object({
|
||||||
status: z.string().min(1, "Stav je povinný"),
|
status: z
|
||||||
|
.enum(["pending", "approved", "rejected", "cancelled"])
|
||||||
|
.default("pending"),
|
||||||
reviewer_note: z.string().nullish(),
|
reviewer_note: z.string().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const CreateQuotationSchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.default(1.0),
|
.default(1.0),
|
||||||
status: z
|
status: z
|
||||||
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"])
|
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
|
||||||
.optional()
|
.optional()
|
||||||
.default("nova"),
|
.default("nova"),
|
||||||
scope_title: z.string().nullish(),
|
scope_title: z.string().nullish(),
|
||||||
@@ -99,7 +99,7 @@ export const UpdateQuotationSchema = z.object({
|
|||||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||||
.optional(),
|
.optional(),
|
||||||
status: z
|
status: z
|
||||||
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"])
|
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
|
||||||
.optional(),
|
.optional(),
|
||||||
scope_title: z.string().nullish(),
|
scope_title: z.string().nullish(),
|
||||||
scope_description: z.string().nullish(),
|
scope_description: z.string().nullish(),
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ export const UpdateOrderSchema = z.object({
|
|||||||
scope_title: z.string().nullish(),
|
scope_title: z.string().nullish(),
|
||||||
scope_description: z.string().nullish(),
|
scope_description: z.string().nullish(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
customer_id: z.union([z.number(), z.string(), z.null()]).optional(),
|
customer_id: z
|
||||||
|
.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
|
.optional(),
|
||||||
vat_rate: z
|
vat_rate: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
|
|||||||
@@ -34,10 +34,22 @@ export const UpdateProjectSchema = z.object({
|
|||||||
name: z.string().nullish(),
|
name: z.string().nullish(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
customer_id: z.union([z.number(), z.string(), z.null()]).optional(),
|
customer_id: z
|
||||||
responsible_user_id: z.union([z.number(), z.string(), z.null()]).optional(),
|
.union([z.number(), z.string(), z.null()])
|
||||||
quotation_id: z.union([z.number(), z.string(), z.null()]).optional(),
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
order_id: z.union([z.number(), z.string(), z.null()]).optional(),
|
.optional(),
|
||||||
|
responsible_user_id: z
|
||||||
|
.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
|
.optional(),
|
||||||
|
quotation_id: z
|
||||||
|
.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
|
.optional(),
|
||||||
|
order_id: z
|
||||||
|
.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
|
.optional(),
|
||||||
start_date: z.union([z.string(), z.null()]).optional(),
|
start_date: z.union([z.string(), z.null()]).optional(),
|
||||||
end_date: z.union([z.string(), z.null()]).optional(),
|
end_date: z.union([z.string(), z.null()]).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const CreateReceivedInvoiceSchema = z.object({
|
|||||||
.default(0),
|
.default(0),
|
||||||
issue_date: z.string().nullish(),
|
issue_date: z.string().nullish(),
|
||||||
due_date: z.string().nullish(),
|
due_date: z.string().nullish(),
|
||||||
status: z.string().optional().default("unpaid"),
|
status: z.enum(["unpaid", "paid"]).optional().default("unpaid"),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export const UpdateReceivedInvoiceSchema = z.object({
|
|||||||
issue_date: z.union([z.string(), z.null()]).optional(),
|
issue_date: z.union([z.string(), z.null()]).optional(),
|
||||||
due_date: z.union([z.string(), z.null()]).optional(),
|
due_date: z.union([z.string(), z.null()]).optional(),
|
||||||
paid_date: z.union([z.string(), z.null()]).optional(),
|
paid_date: z.union([z.string(), z.null()]).optional(),
|
||||||
status: z.string().optional(),
|
status: z.enum(["unpaid", "paid"]).optional(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
month: z
|
month: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const CreateTripSchema = z.object({
|
export const CreateTripSchema = z.object({
|
||||||
vehicle_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
|
vehicle_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
|
||||||
|
// user_id is optional here because the route injects it from authData.userId
|
||||||
|
// when the client doesn't provide it (see POST /trips handler)
|
||||||
user_id: z
|
user_id: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ export const CreateUserSchema = z.object({
|
|||||||
password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"),
|
password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"),
|
||||||
first_name: z.string().min(1, "Jméno je povinné"),
|
first_name: z.string().min(1, "Jméno je povinné"),
|
||||||
last_name: z.string().min(1, "Příjmení je povinné"),
|
last_name: z.string().min(1, "Příjmení je povinné"),
|
||||||
role_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
|
role_id: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((v) => Number(v))
|
||||||
|
.optional(),
|
||||||
is_active: z
|
is_active: z
|
||||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||||
.optional()
|
.optional()
|
||||||
@@ -22,7 +25,10 @@ export const UpdateUserSchema = z.object({
|
|||||||
),
|
),
|
||||||
first_name: z.string().optional(),
|
first_name: z.string().optional(),
|
||||||
last_name: z.string().optional(),
|
last_name: z.string().optional(),
|
||||||
role_id: z.union([z.number(), z.string(), z.null()]).optional(),
|
role_id: z
|
||||||
|
.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
|
.optional(),
|
||||||
is_active: z
|
is_active: z
|
||||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
|
import type { ScheduledTask } from "node-cron";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
@@ -42,7 +43,7 @@ const app = Fastify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
let invoiceAlertCron: any = null;
|
let invoiceAlertCron: ScheduledTask | null = null;
|
||||||
|
|
||||||
// --- Plugins ---
|
// --- Plugins ---
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
|
|||||||
@@ -4,23 +4,16 @@ import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays";
|
|||||||
import { localDateStr } from "../utils/date";
|
import { localDateStr } from "../utils/date";
|
||||||
import { getSystemSettings } from "./system-settings";
|
import { getSystemSettings } from "./system-settings";
|
||||||
|
|
||||||
/** Get active users whose role has attendance.record permission (or admin role) */
|
/** Get active users whose role has attendance.record permission */
|
||||||
async function getAttendanceUsers() {
|
async function getAttendanceUsers() {
|
||||||
return prisma.users.findMany({
|
return prisma.users.findMany({
|
||||||
where: {
|
where: {
|
||||||
is_active: true,
|
is_active: true,
|
||||||
roles: {
|
roles: {
|
||||||
is: {
|
|
||||||
OR: [
|
|
||||||
{ name: "admin" },
|
|
||||||
{
|
|
||||||
role_permissions: {
|
role_permissions: {
|
||||||
some: { permissions: { name: "attendance.record" } },
|
some: { permissions: { name: "attendance.record" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
select: { id: true, first_name: true, last_name: true },
|
select: { id: true, first_name: true, last_name: true },
|
||||||
orderBy: { last_name: "asc" },
|
orderBy: { last_name: "asc" },
|
||||||
@@ -73,11 +66,13 @@ function calcWorkedHours(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roundUp = (d: Date, minutes: number) => {
|
const roundUp = (d: Date, minutes: number) => {
|
||||||
|
if (!minutes || minutes <= 0) return d;
|
||||||
const ms = minutes * 60 * 1000;
|
const ms = minutes * 60 * 1000;
|
||||||
return new Date(Math.ceil(d.getTime() / ms) * ms);
|
return new Date(Math.ceil(d.getTime() / ms) * ms);
|
||||||
};
|
};
|
||||||
|
|
||||||
const roundDown = (d: Date, minutes: number) => {
|
const roundDown = (d: Date, minutes: number) => {
|
||||||
|
if (!minutes || minutes <= 0) return d;
|
||||||
const ms = minutes * 60 * 1000;
|
const ms = minutes * 60 * 1000;
|
||||||
return new Date(Math.floor(d.getTime() / ms) * ms);
|
return new Date(Math.floor(d.getTime() / ms) * ms);
|
||||||
};
|
};
|
||||||
@@ -1143,6 +1138,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
|||||||
let inserted = 0;
|
let inserted = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
for (const userId of data.user_ids.map(Number)) {
|
for (const userId of data.user_ids.map(Number)) {
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
const date = new Date(yr, mo - 1, day);
|
const date = new Date(yr, mo - 1, day);
|
||||||
@@ -1159,7 +1155,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
|||||||
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
|
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
|
||||||
|
|
||||||
if (isHoliday(dateStr)) {
|
if (isHoliday(dateStr)) {
|
||||||
await prisma.attendance.create({
|
await tx.attendance.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
shift_date: shiftDate,
|
shift_date: shiftDate,
|
||||||
@@ -1171,7 +1167,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.attendance.create({
|
await tx.attendance.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
shift_date: shiftDate,
|
shift_date: shiftDate,
|
||||||
@@ -1185,6 +1181,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
|||||||
inserted++;
|
inserted++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let msg = `Vytvořeno ${inserted} záznamů`;
|
let msg = `Vytvořeno ${inserted} záznamů`;
|
||||||
if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`;
|
if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`;
|
||||||
@@ -1212,10 +1209,12 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
|||||||
const end = new Date(dateTo);
|
const end = new Date(dateTo);
|
||||||
let created = 0;
|
let created = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
const current = new Date(start);
|
const current = new Date(start);
|
||||||
while (current <= end) {
|
while (current <= end) {
|
||||||
const dow = current.getDay();
|
const dow = current.getDay();
|
||||||
if (dow !== 0 && dow !== 6) {
|
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
|
||||||
const dateStr = localDateStr(current);
|
const dateStr = localDateStr(current);
|
||||||
const shiftDate = new Date(
|
const shiftDate = new Date(
|
||||||
current.getFullYear(),
|
current.getFullYear(),
|
||||||
@@ -1225,13 +1224,13 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const duplicate = await prisma.attendance.findFirst({
|
const duplicate = await tx.attendance.findFirst({
|
||||||
where: { user_id: userId, shift_date: shiftDate },
|
where: { user_id: userId, shift_date: shiftDate },
|
||||||
});
|
});
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
return { error: "Pro zvolené datumy již existují záznamy docházky" };
|
throw new Error("Pro zvolené datumy již existují záznamy docházky");
|
||||||
}
|
}
|
||||||
await prisma.attendance.create({
|
await tx.attendance.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
shift_date: shiftDate,
|
shift_date: shiftDate,
|
||||||
@@ -1252,21 +1251,22 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
|||||||
totalLeaveHours > 0
|
totalLeaveHours > 0
|
||||||
) {
|
) {
|
||||||
const year = new Date(dateFrom).getFullYear();
|
const year = new Date(dateFrom).getFullYear();
|
||||||
const existingBalance = await prisma.leave_balances.findFirst({
|
const existingBalance = await tx.leave_balances.findFirst({
|
||||||
where: { user_id: userId, year },
|
where: { user_id: userId, year },
|
||||||
});
|
});
|
||||||
if (existingBalance) {
|
if (existingBalance) {
|
||||||
const updateField =
|
const updateField =
|
||||||
leaveType === "vacation" ? "vacation_used" : "sick_used";
|
leaveType === "vacation" ? "vacation_used" : "sick_used";
|
||||||
await prisma.leave_balances.update({
|
await tx.leave_balances.update({
|
||||||
where: { id: existingBalance.id },
|
where: { id: existingBalance.id },
|
||||||
data: {
|
data: {
|
||||||
[updateField]: Number(existingBalance[updateField]) + totalLeaveHours,
|
[updateField]:
|
||||||
|
Number(existingBalance[updateField]) + totalLeaveHours,
|
||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await prisma.leave_balances.create({
|
await tx.leave_balances.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
year,
|
year,
|
||||||
@@ -1277,6 +1277,16 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
err.message === "Pro zvolené datumy již existují záznamy docházky"
|
||||||
|
) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` };
|
return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` };
|
||||||
}
|
}
|
||||||
@@ -1418,8 +1428,17 @@ export async function punchAction(userId: number, data: PunchData) {
|
|||||||
return { error: "Nemáte aktivní směnu bez přestávky." };
|
return { error: "Nemáte aktivní směnu bez přestávky." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const msRound = settings.clock_rounding_minutes * 60 * 1000;
|
let msRound = settings.clock_rounding_minutes * 60 * 1000;
|
||||||
const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound);
|
if (
|
||||||
|
!settings.clock_rounding_minutes ||
|
||||||
|
settings.clock_rounding_minutes <= 0
|
||||||
|
) {
|
||||||
|
msRound = 0;
|
||||||
|
}
|
||||||
|
const breakStart =
|
||||||
|
msRound > 0
|
||||||
|
? new Date(Math.round(now.getTime() / msRound) * msRound)
|
||||||
|
: now;
|
||||||
const breakEnd = new Date(
|
const breakEnd = new Date(
|
||||||
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
|
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ async function loadAuthData(userId: number): Promise<AuthData | null> {
|
|||||||
|
|
||||||
if (!user || !user.is_active) return null;
|
if (!user || !user.is_active) return null;
|
||||||
|
|
||||||
|
if (user.locked_until && new Date(user.locked_until) > new Date())
|
||||||
|
return null;
|
||||||
|
|
||||||
const isAdmin = user.roles?.name === "admin";
|
const isAdmin = user.roles?.name === "admin";
|
||||||
const permissions = isAdmin
|
const permissions = isAdmin
|
||||||
? (await prisma.permissions.findMany({ select: { name: true } })).map(
|
? (await prisma.permissions.findMany({ select: { name: true } })).map(
|
||||||
@@ -111,6 +114,7 @@ export async function login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user.is_active) {
|
if (!user.is_active) {
|
||||||
|
await bcrypt.compare(password, DUMMY_HASH); // timing-safe
|
||||||
request.log.warn(`Login failed for deactivated user: ${username}`);
|
request.log.warn(`Login failed for deactivated user: ${username}`);
|
||||||
return {
|
return {
|
||||||
type: "error",
|
type: "error",
|
||||||
@@ -120,6 +124,7 @@ export async function login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||||
|
await bcrypt.compare(password, DUMMY_HASH); // timing-safe
|
||||||
request.log.warn(`Login failed for locked user: ${username}`);
|
request.log.warn(`Login failed for locked user: ${username}`);
|
||||||
return {
|
return {
|
||||||
type: "error",
|
type: "error",
|
||||||
@@ -319,7 +324,7 @@ export async function refreshAccessToken(
|
|||||||
accessToken,
|
accessToken,
|
||||||
refreshToken: newRefreshTokenRaw,
|
refreshToken: newRefreshTokenRaw,
|
||||||
user: authData,
|
user: authData,
|
||||||
rememberMe: storedToken.remember_me ?? false,
|
rememberMe: rememberMe,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -341,10 +346,9 @@ export async function verifyAccessToken(
|
|||||||
token: string,
|
token: string,
|
||||||
): Promise<AuthData | null> {
|
): Promise<AuthData | null> {
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(
|
const payload = jwt.verify(token, config.jwt.secret, {
|
||||||
token,
|
algorithms: ["HS256"],
|
||||||
config.jwt.secret,
|
}) as unknown as JwtPayload;
|
||||||
) as unknown as JwtPayload;
|
|
||||||
return loadAuthData(payload.sub);
|
return loadAuthData(payload.sub);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("JWT verification error:", err);
|
console.error("JWT verification error:", err);
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ interface CnbRate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rateCache = new Map<string, Record<string, number>>();
|
const rateCache = new Map<string, Record<string, number>>();
|
||||||
|
let rateCacheTime = 0;
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
const inflight = new Map<string, Promise<Record<string, number>>>();
|
const inflight = new Map<string, Promise<Record<string, number>>>();
|
||||||
|
|
||||||
async function fetchRatesForDate(
|
async function fetchRatesForDate(
|
||||||
date?: string,
|
date?: string,
|
||||||
): Promise<Record<string, number>> {
|
): Promise<Record<string, number>> {
|
||||||
const key = date || "today";
|
const key = date || "today";
|
||||||
|
if (Date.now() - rateCacheTime > CACHE_TTL_MS) {
|
||||||
|
rateCache.clear();
|
||||||
|
}
|
||||||
if (rateCache.has(key)) return rateCache.get(key)!;
|
if (rateCache.has(key)) return rateCache.get(key)!;
|
||||||
if (inflight.has(key)) return inflight.get(key)!;
|
if (inflight.has(key)) return inflight.get(key)!;
|
||||||
|
|
||||||
@@ -36,6 +41,7 @@ async function fetchRatesForDate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
rateCache.set(key, rates);
|
rateCache.set(key, rates);
|
||||||
|
rateCacheTime = Date.now();
|
||||||
return rates;
|
return rates;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch CNB exchange rates:", err);
|
console.error("Failed to fetch CNB exchange rates:", err);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
|||||||
if (!alertEmail) return;
|
if (!alertEmail) return;
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
const todayStr = localDateStr(today);
|
const todayStr = localDateStr(today);
|
||||||
const in3days = new Date(today);
|
const in3days = new Date(today);
|
||||||
in3days.setDate(in3days.getDate() + 3);
|
in3days.setDate(in3days.getDate() + 3);
|
||||||
|
|||||||
@@ -55,8 +55,13 @@ function computeInvoiceTotals(
|
|||||||
const vatAmount = applyVat
|
const vatAmount = applyVat
|
||||||
? items.reduce((s, i) => {
|
? items.reduce((s, i) => {
|
||||||
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
||||||
const vat =
|
const vatRate =
|
||||||
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100);
|
i.vat_rate != null && i.vat_rate !== ""
|
||||||
|
? Number(i.vat_rate)
|
||||||
|
: defaultVatRate != null
|
||||||
|
? Number(defaultVatRate)
|
||||||
|
: 21;
|
||||||
|
const vat = base * (vatRate / 100);
|
||||||
return s + Math.round(vat * 100) / 100;
|
return s + Math.round(vat * 100) / 100;
|
||||||
}, 0)
|
}, 0)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -343,7 +348,7 @@ export async function createInvoice(body: Record<string, any>) {
|
|||||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||||
status: body.status ? String(body.status) : "issued",
|
status: body.status ? String(body.status) : "issued",
|
||||||
currency: body.currency ? String(body.currency) : "CZK",
|
currency: body.currency ? String(body.currency) : "CZK",
|
||||||
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
apply_vat: body.apply_vat !== false,
|
||||||
payment_method: body.payment_method ? String(body.payment_method) : null,
|
payment_method: body.payment_method ? String(body.payment_method) : null,
|
||||||
constant_symbol: body.constant_symbol
|
constant_symbol: body.constant_symbol
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class NasOffersManager {
|
|||||||
pdfBuffer: Buffer,
|
pdfBuffer: Buffer,
|
||||||
): string | null {
|
): string | null {
|
||||||
const { prefix, seq } = this.parseParts(quotationNumber);
|
const { prefix, seq } = this.parseParts(quotationNumber);
|
||||||
const folderName = `${prefix}_${seq}`;
|
const folderName = this.sanitizeFilename(`${prefix}_${seq}`);
|
||||||
const fileName = `${year}_${prefix}_${seq}.pdf`;
|
const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf";
|
||||||
|
|
||||||
const dir = this.ensureDir(year, folderName);
|
const dir = this.ensureDir(year, folderName);
|
||||||
if (!dir) return null;
|
if (!dir) return null;
|
||||||
@@ -81,8 +81,8 @@ class NasOffersManager {
|
|||||||
/** Build the relative NAS path for a given quotation number + year */
|
/** Build the relative NAS path for a given quotation number + year */
|
||||||
buildRelativePath(quotationNumber: string, year: number): string {
|
buildRelativePath(quotationNumber: string, year: number): string {
|
||||||
const { prefix, seq } = this.parseParts(quotationNumber);
|
const { prefix, seq } = this.parseParts(quotationNumber);
|
||||||
const folderName = `${prefix}_${seq}`;
|
const folderName = this.sanitizeFilename(`${prefix}_${seq}`);
|
||||||
const fileName = `${year}_${prefix}_${seq}.pdf`;
|
const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf";
|
||||||
return `${year}/${folderName}/${fileName}`;
|
return `${year}/${folderName}/${fileName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +92,10 @@ class NasOffersManager {
|
|||||||
} {
|
} {
|
||||||
const parts = quotationNumber.split("/");
|
const parts = quotationNumber.split("/");
|
||||||
return {
|
return {
|
||||||
prefix: parts.length >= 2 ? parts[parts.length - 2] : "NA",
|
prefix: this.sanitizeFilename(
|
||||||
seq: parts[parts.length - 1],
|
parts.length >= 2 ? parts[parts.length - 2] : "NA",
|
||||||
|
),
|
||||||
|
seq: this.sanitizeFilename(parts[parts.length - 1]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,20 +109,14 @@ async function previewNextSequence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrement the sequence counter for a given type/year.
|
* Release a sequence number back to the pool.
|
||||||
* Called after deleting a document so the number can be reused.
|
* NOTE: Blindly decrementing can cause duplicate numbers if numbers were
|
||||||
|
* allocated after this one. Sequence numbers are consumed but not returned
|
||||||
|
* to the pool — this is intentionally a no-op.
|
||||||
*/
|
*/
|
||||||
async function releaseSequence(type: string, year: number) {
|
async function releaseSequence(_type: string, _year: number) {
|
||||||
try {
|
// No-op: decrementing can cause duplicate sequence numbers.
|
||||||
await prisma.$executeRaw`
|
// Sequence numbers are consumed but never returned to the pool.
|
||||||
UPDATE number_sequences
|
|
||||||
SET last_number = GREATEST(COALESCE(last_number, 0) - 1, 0)
|
|
||||||
WHERE \`type\` = ${type} AND \`year\` = ${year}
|
|
||||||
`;
|
|
||||||
} catch (err) {
|
|
||||||
// Non-fatal: log but don't fail the delete operation
|
|
||||||
console.error(`releaseSequence failed for ${type}/${year}:`, err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verify a shared number is not already used by an order or project. */
|
/** Verify a shared number is not already used by an order or project. */
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ function enrichQuotation(q: any) {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const vatAmount = q.apply_vat
|
const vatAmount = q.apply_vat
|
||||||
? subtotal * ((Number(q.vat_rate) || 21) / 100)
|
? subtotal *
|
||||||
|
((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) /
|
||||||
|
100)
|
||||||
: 0;
|
: 0;
|
||||||
const { quotation_items, scope_sections, ...rest } = q;
|
const { quotation_items, scope_sections, ...rest } = q;
|
||||||
return {
|
return {
|
||||||
@@ -138,11 +140,6 @@ export async function getOffer(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createOffer(body: Record<string, any>) {
|
export async function createOffer(body: Record<string, any>) {
|
||||||
const quotationNumber =
|
|
||||||
body.quotation_number !== undefined && body.quotation_number !== null
|
|
||||||
? String(body.quotation_number)
|
|
||||||
: await generateOfferNumber();
|
|
||||||
|
|
||||||
if (body.quotation_number !== undefined && body.quotation_number !== null) {
|
if (body.quotation_number !== undefined && body.quotation_number !== null) {
|
||||||
const taken = await isOfferNumberTaken(String(body.quotation_number));
|
const taken = await isOfferNumberTaken(String(body.quotation_number));
|
||||||
if (taken) {
|
if (taken) {
|
||||||
@@ -151,6 +148,11 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const quotationNumber =
|
||||||
|
body.quotation_number !== undefined && body.quotation_number !== null
|
||||||
|
? String(body.quotation_number)
|
||||||
|
: await generateOfferNumber(tx);
|
||||||
|
|
||||||
const quotation = await tx.quotations.create({
|
const quotation = await tx.quotations.create({
|
||||||
data: {
|
data: {
|
||||||
quotation_number: quotationNumber,
|
quotation_number: quotationNumber,
|
||||||
@@ -161,7 +163,7 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
: null,
|
: null,
|
||||||
currency: body.currency ? String(body.currency) : "CZK",
|
currency: body.currency ? String(body.currency) : "CZK",
|
||||||
language: body.language ? String(body.language) : "cs",
|
language: body.language ? String(body.language) : "cs",
|
||||||
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
apply_vat: body.apply_vat !== false,
|
||||||
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
|
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
|
||||||
status: body.status ? String(body.status) : "active",
|
status: body.status ? String(body.status) : "active",
|
||||||
@@ -320,9 +322,10 @@ export async function duplicateOffer(id: number) {
|
|||||||
});
|
});
|
||||||
if (!original) return null;
|
if (!original) return null;
|
||||||
|
|
||||||
const nextOfferNumber = await generateOfferNumber();
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const nextOfferNumber = await generateOfferNumber(tx);
|
||||||
|
|
||||||
const copy = await prisma.quotations.create({
|
const copy = await tx.quotations.create({
|
||||||
data: {
|
data: {
|
||||||
quotation_number: nextOfferNumber,
|
quotation_number: nextOfferNumber,
|
||||||
project_code: original.project_code,
|
project_code: original.project_code,
|
||||||
@@ -340,7 +343,7 @@ export async function duplicateOffer(id: number) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (original.quotation_items.length > 0) {
|
if (original.quotation_items.length > 0) {
|
||||||
await prisma.quotation_items.createMany({
|
await tx.quotation_items.createMany({
|
||||||
data: original.quotation_items.map((item) => ({
|
data: original.quotation_items.map((item) => ({
|
||||||
quotation_id: copy.id,
|
quotation_id: copy.id,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
@@ -355,7 +358,7 @@ export async function duplicateOffer(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (original.scope_sections.length > 0) {
|
if (original.scope_sections.length > 0) {
|
||||||
await prisma.scope_sections.createMany({
|
await tx.scope_sections.createMany({
|
||||||
data: original.scope_sections.map((s) => ({
|
data: original.scope_sections.map((s) => ({
|
||||||
quotation_id: copy.id,
|
quotation_id: copy.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
@@ -367,6 +370,7 @@ export async function duplicateOffer(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { copy, original };
|
return { copy, original };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateOffer(id: number) {
|
export async function invalidateOffer(id: number) {
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ function enrichOrder(o: any) {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const vatAmount = o.apply_vat
|
const vatAmount = o.apply_vat
|
||||||
? subtotal * ((Number(o.vat_rate) || 21) / 100)
|
? subtotal *
|
||||||
|
((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) /
|
||||||
|
100)
|
||||||
: 0;
|
: 0;
|
||||||
const { order_items, order_sections, ...rest } = o;
|
const { order_items, order_sections, ...rest } = o;
|
||||||
const invoice = o.invoices?.[0] || null;
|
const invoice = o.invoices?.[0] || null;
|
||||||
@@ -126,7 +128,7 @@ export async function getOrder(id: number) {
|
|||||||
},
|
},
|
||||||
invoices: {
|
invoices: {
|
||||||
select: { id: true, invoice_number: true, status: true },
|
select: { id: true, invoice_number: true, status: true },
|
||||||
take: 1,
|
orderBy: { id: "desc" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -290,19 +292,22 @@ interface CreateOrderData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrder(body: CreateOrderData) {
|
export async function createOrder(body: CreateOrderData) {
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
const orderNumber =
|
const orderNumber =
|
||||||
body.order_number !== undefined && body.order_number !== null
|
body.order_number !== undefined && body.order_number !== null
|
||||||
? String(body.order_number)
|
? String(body.order_number)
|
||||||
: await generateSharedNumber();
|
: await generateSharedNumber(tx);
|
||||||
|
|
||||||
if (body.order_number !== undefined && body.order_number !== null) {
|
if (body.order_number !== undefined && body.order_number !== null) {
|
||||||
const taken = await isOrderNumberTaken(String(body.order_number));
|
const taken = await isOrderNumberTaken(String(body.order_number));
|
||||||
if (taken) {
|
if (taken) {
|
||||||
return { error: "Číslo objednávky je již použito", status: 400 } as const;
|
throw Object.assign(new Error("Číslo objednávky je již použito"), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
|
||||||
const order = await tx.orders.create({
|
const order = await tx.orders.create({
|
||||||
data: {
|
data: {
|
||||||
order_number: orderNumber,
|
order_number: orderNumber,
|
||||||
@@ -350,6 +355,15 @@ export async function createOrder(body: CreateOrderData) {
|
|||||||
|
|
||||||
return { id: order.id, order_number: order.order_number };
|
return { id: order.id, order_number: order.order_number };
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && "status" in err) {
|
||||||
|
return {
|
||||||
|
error: err.message,
|
||||||
|
status: (err as Error & { status: number }).status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateOrderData {
|
interface UpdateOrderData {
|
||||||
@@ -499,30 +513,34 @@ export async function deleteOrder(id: number) {
|
|||||||
if (!existing)
|
if (!existing)
|
||||||
return { error: "Objednávka nenalezena", status: 404 } as const;
|
return { error: "Objednávka nenalezena", status: 404 } as const;
|
||||||
|
|
||||||
|
// Fetch linked projects before the transaction for number release later
|
||||||
|
const linkedProjects = await prisma.projects.findMany({
|
||||||
|
where: { order_id: id },
|
||||||
|
select: { id: true, created_at: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
// Clear quotation back-reference (matching PHP)
|
// Clear quotation back-reference (matching PHP)
|
||||||
await prisma.quotations.updateMany({
|
await tx.quotations.updateMany({
|
||||||
where: { order_id: id },
|
where: { order_id: id },
|
||||||
data: { order_id: null },
|
data: { order_id: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete linked project and its notes (matching PHP)
|
// Delete linked project and its notes (matching PHP)
|
||||||
const linkedProjects = await prisma.projects.findMany({
|
|
||||||
where: { order_id: id },
|
|
||||||
select: { id: true, created_at: true },
|
|
||||||
});
|
|
||||||
if (linkedProjects.length > 0) {
|
if (linkedProjects.length > 0) {
|
||||||
const projectIds = linkedProjects.map((p) => p.id);
|
const projectIds = linkedProjects.map((p) => p.id);
|
||||||
await prisma.project_notes.deleteMany({
|
await tx.project_notes.deleteMany({
|
||||||
where: { project_id: { in: projectIds } },
|
where: { project_id: { in: projectIds } },
|
||||||
});
|
});
|
||||||
await prisma.projects.deleteMany({ where: { order_id: id } });
|
await tx.projects.deleteMany({ where: { order_id: id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicitly clean up child rows
|
// Explicitly clean up child rows
|
||||||
await prisma.order_items.deleteMany({ where: { order_id: id } });
|
await tx.order_items.deleteMany({ where: { order_id: id } });
|
||||||
await prisma.order_sections.deleteMany({ where: { order_id: id } });
|
await tx.order_sections.deleteMany({ where: { order_id: id } });
|
||||||
|
|
||||||
await prisma.orders.delete({ where: { id } });
|
await tx.orders.delete({ where: { id } });
|
||||||
|
});
|
||||||
|
|
||||||
const releasedYears = new Set<number>();
|
const releasedYears = new Set<number>();
|
||||||
const year = existing.created_at
|
const year = existing.created_at
|
||||||
|
|||||||
@@ -212,17 +212,18 @@ export async function updateUser(
|
|||||||
|
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
if (body.username !== undefined) {
|
if (body.username !== undefined) {
|
||||||
const newUsername = String(body.username).trim();
|
const newUsername = String(body.username).trim();
|
||||||
if (newUsername !== existing.username) {
|
if (newUsername !== existing.username) {
|
||||||
const existingUsername = await prisma.users.findFirst({
|
const existingUsername = await tx.users.findFirst({
|
||||||
where: { username: newUsername },
|
where: { username: newUsername },
|
||||||
});
|
});
|
||||||
if (existingUsername) {
|
if (existingUsername) {
|
||||||
return {
|
throw Object.assign(new Error("Uživatelské jméno již existuje"), {
|
||||||
error: "Uživatelské jméno již existuje",
|
|
||||||
status: 409,
|
status: 409,
|
||||||
} as const;
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
data.username = newUsername;
|
data.username = newUsername;
|
||||||
@@ -231,28 +232,37 @@ export async function updateUser(
|
|||||||
if (body.email !== undefined) {
|
if (body.email !== undefined) {
|
||||||
const newEmail = String(body.email).trim();
|
const newEmail = String(body.email).trim();
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||||||
return { error: "Neplatný formát e-mailu", status: 400 } as const;
|
throw Object.assign(new Error("Neplatný formát e-mailu"), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const existingEmail = await prisma.users.findFirst({
|
const existingEmail = await tx.users.findFirst({
|
||||||
where: { email: newEmail, id: { not: id } },
|
where: { email: newEmail, id: { not: id } },
|
||||||
});
|
});
|
||||||
if (existingEmail) {
|
if (existingEmail) {
|
||||||
return { error: "E-mail již existuje", status: 409 } as const;
|
throw Object.assign(new Error("E-mail již existuje"), {
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
data.email = newEmail;
|
data.email = newEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.first_name !== undefined) data.first_name = String(body.first_name);
|
if (body.first_name !== undefined)
|
||||||
|
data.first_name = String(body.first_name);
|
||||||
if (body.last_name !== undefined) data.last_name = String(body.last_name);
|
if (body.last_name !== undefined) data.last_name = String(body.last_name);
|
||||||
if (body.role_id !== undefined)
|
if (body.role_id !== undefined)
|
||||||
data.role_id = body.role_id ? Number(body.role_id) : null;
|
data.role_id = body.role_id ? Number(body.role_id) : null;
|
||||||
if (body.is_active !== undefined)
|
if (body.is_active !== undefined)
|
||||||
data.is_active =
|
data.is_active =
|
||||||
body.is_active === true || body.is_active === 1 || body.is_active === "1";
|
body.is_active === true ||
|
||||||
|
body.is_active === 1 ||
|
||||||
|
body.is_active === "1";
|
||||||
if (body.password) {
|
if (body.password) {
|
||||||
const newPassword = String(body.password);
|
const newPassword = String(body.password);
|
||||||
if (newPassword.length < 8) {
|
if (newPassword.length < 8) {
|
||||||
return { error: "Heslo musí mít alespoň 8 znaků", status: 400 } as const;
|
throw Object.assign(new Error("Heslo musí mít alespoň 8 znaků"), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
data.password_hash = await bcrypt.hash(
|
data.password_hash = await bcrypt.hash(
|
||||||
newPassword,
|
newPassword,
|
||||||
@@ -261,7 +271,17 @@ export async function updateUser(
|
|||||||
data.password_changed_at = new Date();
|
data.password_changed_at = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.users.update({ where: { id }, data });
|
await tx.users.update({ where: { id }, data });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && "status" in err) {
|
||||||
|
return {
|
||||||
|
error: err.message,
|
||||||
|
status: (err as Error & { status: number }).status,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return { id, username: existing.username } as const;
|
return { id, username: existing.username } as const;
|
||||||
}
|
}
|
||||||
@@ -276,8 +296,10 @@ export async function deleteUser(id: number, currentUserId?: number) {
|
|||||||
return { error: "Uživatel nenalezen", status: 404 } as const;
|
return { error: "Uživatel nenalezen", status: 404 } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.refresh_tokens.deleteMany({ where: { user_id: id } });
|
await prisma.$transaction(async (tx) => {
|
||||||
await prisma.users.delete({ where: { id } });
|
await tx.refresh_tokens.deleteMany({ where: { user_id: id } });
|
||||||
|
await tx.users.delete({ where: { id } });
|
||||||
|
});
|
||||||
|
|
||||||
return { id, username: existing.username } as const;
|
return { id, username: existing.username } as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export interface AuthData {
|
|||||||
roleId: number | null;
|
roleId: number | null;
|
||||||
roleName: string | null;
|
roleName: string | null;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
totp_enabled?: boolean;
|
totp_enabled: boolean;
|
||||||
require_2fa?: boolean;
|
require_2fa?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +102,8 @@ export interface PaginationQuery {
|
|||||||
|
|
||||||
export type AuditAction =
|
export type AuditAction =
|
||||||
| "login"
|
| "login"
|
||||||
|
| "login_totp"
|
||||||
|
| "login_backup"
|
||||||
| "logout"
|
| "logout"
|
||||||
| "login_failed"
|
| "login_failed"
|
||||||
| "create"
|
| "create"
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export const OTPAuth = {
|
|||||||
return { valid: false, counter: null };
|
return { valid: false, counter: null };
|
||||||
}
|
}
|
||||||
const currentCounter = Math.floor(Date.now() / 1000 / config.totp.period);
|
const currentCounter = Math.floor(Date.now() / 1000 / config.totp.period);
|
||||||
return { valid: true, counter: currentCounter + delta };
|
// Only advance counter for current or past codes, not future ones
|
||||||
|
const counterDelta = Math.min(delta, 0);
|
||||||
|
return { valid: true, counter: currentCounter + counterDelta };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("TOTP verification error:", err);
|
console.error("TOTP verification error:", err);
|
||||||
return { valid: false, counter: null };
|
return { valid: false, counter: null };
|
||||||
|
|||||||
@@ -19,5 +19,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/admin", "src/context", "src/main.tsx", "src/App.tsx"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"src/admin",
|
||||||
|
"src/context",
|
||||||
|
"src/main.tsx",
|
||||||
|
"src/App.tsx",
|
||||||
|
"src/__tests__"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user