Compare commits

...

8 Commits

Author SHA1 Message Date
BOHA
4cabba395d v1.6.3: fix project file upload, filter responsible user by permission
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:26:32 +02:00
BOHA
59b478f262 v1.6.2: fix RichEditor auto-scroll and PDF offers multi-page header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:23:36 +02:00
BOHA
e4f14a24b7 fix: handle plain month number in attendance route, not just YYYY-MM
AttendanceAdmin sends ?year=YYYY&month=M (separate params), but the
route handler assumed month was always in "YYYY-MM" format. When
query.month was just "4", the split produced NaN for month and 4 for
year, causing the service to skip the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:50:20 +02:00
BOHA
3bd0d055d9 v1.6.0: fix offer items mobile layout and localStorage draft save/restore
- Fix items table description column width on mobile (was ~82px, now ~260px)
- Activate unused offers-items-table CSS class in OfferDetail form
- Save all form fields to localStorage draft (language, VAT, exchange rate were missing)
- Use DRAFT_KEY constant in loadOfferDraft, add error logging

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:23:42 +02:00
BOHA
746d17e182 fix: parse YYYY-MM month filter correctly in attendance history
The frontend sends month as "YYYY-MM" but the route handler was passing
it through Number() which parsed only the year portion, causing the
service to ignore the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:29:47 +02:00
BOHA
e96e51598a v1.5.8: fix audit log table layout (Skeleton outside tbody)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:08:15 +02:00
BOHA
9abec36f07 v1.5.7: fix Settings system tab crash and OffersTemplates tab gap
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:29:10 +02:00
BOHA
ecd8e3679f fix: replace stray role reference in system settings tab with inline placeholder
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:04:22 +02:00
15 changed files with 339 additions and 204 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.5.6", "version": "1.6.3",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useCallback, useEffect } from "react"; import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
import ReactQuill from "react-quill-new"; import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css"; import "react-quill-new/dist/quill.snow.css";
@@ -96,11 +96,14 @@ export default function RichEditor({
[onChange], [onChange],
); );
useEffect(() => { useLayoutEffect(() => {
if (!quillRef.current) return; if (!quillRef.current) return;
const editor = quillRef.current.getEditor(); const editor = quillRef.current.getEditor();
editor.format("font", "tahoma"); editor.format("font", "tahoma");
editor.format("size", "14px"); editor.format("size", "14px");
// Quill auto-focuses on mount with existing content, which scrolls
// the page to the editor. Blur to prevent unwanted scroll.
editor.blur();
}, []); }, []);
return ( return (

View File

@@ -1,12 +1,14 @@
import { queryOptions } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter"; import { jsonQuery } from "../apiAdapter";
export const userListOptions = () => export const userListOptions = (permission?: string) =>
queryOptions({ queryOptions({
queryKey: ["users"], queryKey: ["users", { permission }],
queryFn: () => { queryFn: () => {
// The users endpoint returns { success, data: { items, ... } } or { success, data: [...] } const url = permission
return jsonQuery<Record<string, unknown>>("/api/admin/users"); ? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100`
: "/api/admin/users?limit=100";
return jsonQuery<Record<string, unknown>>(url);
}, },
}); });

View File

@@ -491,10 +491,69 @@
display: none; display: none;
} }
@media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input {
font-size: 16px;
min-height: 44px;
padding: 9px 10px;
}
/* Give the description column more room by shrinking numeric columns */
.offers-col-qty {
width: 4rem !important;
}
.offers-col-unit {
width: 4rem !important;
}
.offers-col-price {
width: 5.5rem !important;
}
.offers-col-included {
width: 3.5rem !important;
}
.offers-col-total {
width: 5.5rem !important;
}
.offers-col-del {
width: 2.5rem !important;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.offers-items-table { .offers-items-table {
margin: 0 -1rem; margin: 0 -1rem;
width: calc(100% + 2rem); width: calc(100% + 2rem);
border-radius: 0;
border-left: none;
border-right: none;
}
.offers-items-table .admin-table {
min-width: 700px;
}
.offers-items-table .admin-table td {
padding: 6px;
font-size: 12px;
}
.offers-items-table .admin-table th {
font-size: 10px;
padding: 6px;
}
/* Further reduce numeric columns on very small screens */
.offers-col-qty {
width: 3.5rem !important;
}
.offers-col-unit {
width: 3.5rem !important;
}
.offers-col-price {
width: 5rem !important;
}
.offers-col-total {
width: 5rem !important;
} }
} }

View File

@@ -391,6 +391,10 @@ export default function AuditLog() {
> >
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-table-responsive"> <div className="admin-table-responsive">
<Skeleton
name="audit-log-rows"
loading={isPending}
fixture={
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
<tr> <tr>
@@ -403,20 +407,9 @@ export default function AuditLog() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<Skeleton
name="audit-log-rows"
loading={isPending}
fixture={
<div style={{ padding: "1rem" }}>
{Array.from({ length: 10 }, (_, i) => ( {Array.from({ length: 10 }, (_, i) => (
<div <tr key={i}>
key={i} <td>
style={{
display: "flex",
gap: "1rem",
marginBottom: "0.75rem",
}}
>
<div <div
style={{ style={{
width: 110, width: 110,
@@ -425,6 +418,8 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 80, width: 80,
@@ -433,6 +428,8 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 70, width: 70,
@@ -441,6 +438,8 @@ export default function AuditLog() {
borderRadius: 10, borderRadius: 10,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 80, width: 80,
@@ -449,14 +448,18 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
flex: 1, width: "100%",
height: 14, height: 14,
background: "var(--bg-tertiary)", background: "var(--bg-tertiary)",
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 90, width: 90,
@@ -465,12 +468,25 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</div> </td>
</tr>
))} ))}
</div> </tbody>
</table>
} }
> >
<> <table className="admin-table">
<thead>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{logs.length === 0 && ( {logs.length === 0 && (
<tr> <tr>
<td colSpan={6}> <td colSpan={6}>
@@ -518,10 +534,9 @@ export default function AuditLog() {
<td className="admin-mono">{log.user_ip || "-"}</td> <td className="admin-mono">{log.user_ip || "-"}</td>
</tr> </tr>
))} ))}
</>
</Skeleton>
</tbody> </tbody>
</table> </table>
</Skeleton>
</div> </div>
<Pagination <Pagination

View File

@@ -274,9 +274,10 @@ function loadOfferDraft(): {
sections?: unknown[]; sections?: unknown[];
} | null { } | null {
try { try {
const raw = localStorage.getItem("boha_offer_draft"); const raw = localStorage.getItem(DRAFT_KEY);
return raw ? JSON.parse(raw) : null; return raw ? JSON.parse(raw) : null;
} catch { } catch (e) {
console.error("Failed to load offer draft:", e);
return null; return null;
} }
} }
@@ -353,17 +354,28 @@ export default function OfferDetail() {
const draft = loadOfferDraft(); const draft = loadOfferDraft();
if (draft?.form) { if (draft?.form) {
return { return {
...emptyForm, quotation_number:
(draft.form.quotation_number as string) || emptyForm.quotation_number,
project_code: project_code:
(draft.form.project_code as string) || emptyForm.project_code, (draft.form.project_code as string) || emptyForm.project_code,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
customer_name: customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name, (draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at, created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until: valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until, (draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency, currency: (draft.form.currency as string) || emptyForm.currency,
customer_id: language: (draft.form.language as string) || emptyForm.language,
(draft.form.customer_id as number | null) ?? emptyForm.customer_id, vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
exchange_rate:
(draft.form.exchange_rate as string) || emptyForm.exchange_rate,
scope_title:
(draft.form.scope_title as string) || emptyForm.scope_title,
scope_description:
(draft.form.scope_description as string) ||
emptyForm.scope_description,
}; };
} }
return emptyForm; return emptyForm;
@@ -590,22 +602,27 @@ export default function OfferDetail() {
useEffect(() => { useEffect(() => {
if (isEdit) return; if (isEdit) return;
try { try {
const data = JSON.parse(debouncedDraft);
const draft = { const draft = {
form: { form: {
project_code: form.project_code, project_code: data.form.project_code ?? "",
customer_id: form.customer_id, customer_id: data.form.customer_id ?? null,
customer_name: form.customer_name, customer_name: data.form.customer_name ?? "",
created_at: form.created_at, created_at: data.form.created_at ?? "",
valid_until: form.valid_until, valid_until: data.form.valid_until ?? "",
currency: form.currency, currency: data.form.currency ?? "CZK",
language: data.form.language ?? "EN",
vat_rate: data.form.vat_rate ?? 21,
apply_vat: data.form.apply_vat ?? false,
exchange_rate: data.form.exchange_rate ?? "",
}, },
items, items: data.items,
sections, sections: data.sections,
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
}; };
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch { } catch (e) {
/* localStorage full or unavailable */ console.error("Failed to save offer draft:", e);
} }
}, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps }, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -697,8 +714,8 @@ export default function OfferDetail() {
if (!isEdit) { if (!isEdit) {
try { try {
localStorage.removeItem(DRAFT_KEY); localStorage.removeItem(DRAFT_KEY);
} catch { } catch (e) {
/* ignore */ console.error("Failed to remove offer draft:", e);
} }
} }
if (!isEdit && result.data?.id) { if (!isEdit && result.data?.id) {
@@ -1283,7 +1300,7 @@ export default function OfferDetail() {
</p> </p>
)} )}
<div className="admin-table-responsive"> <div className="offers-items-table">
<DndContext <DndContext
sensors={dndSensors} sensors={dndSensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@@ -1316,18 +1333,42 @@ export default function OfferDetail() {
<th style={{ width: "2.5rem", textAlign: "center" }}> <th style={{ width: "2.5rem", textAlign: "center" }}>
# #
</th> </th>
<th>Popis</th> <th className="offers-col-desc">Popis</th>
<th style={{ width: "5rem" }}>Množství</th> <th
<th style={{ width: "5rem" }}>Jednotka</th> className="offers-col-qty"
<th style={{ width: "7rem" }}>Cena/ks</th> style={{ width: "5rem" }}
<th style={{ width: "4rem", textAlign: "center" }}> >
Množství
</th>
<th
className="offers-col-unit"
style={{ width: "5rem" }}
>
Jednotka
</th>
<th
className="offers-col-price"
style={{ width: "7rem" }}
>
Cena/ks
</th>
<th
className="offers-col-included"
style={{ width: "4rem", textAlign: "center" }}
>
V ceně V ceně
</th> </th>
<th style={{ width: "7rem", textAlign: "right" }}> <th
className="offers-col-total"
style={{ width: "7rem", textAlign: "right" }}
>
Celkem Celkem
</th> </th>
{!isInvalidated && !isLockedByOther && ( {!isInvalidated && !isLockedByOther && (
<th style={{ width: "3rem" }} /> <th
className="offers-col-del"
style={{ width: "3rem" }}
/>
)} )}
</tr> </tr>
</thead> </thead>

View File

@@ -71,7 +71,7 @@ export default function OffersTemplates() {
</div> </div>
</motion.div> </motion.div>
<div className="admin-tabs"> <div className="admin-tabs mb-4">
<button <button
className={`admin-tab ${activeTab === "items" ? "active" : ""}`} className={`admin-tab ${activeTab === "items" ? "active" : ""}`}
onClick={() => setActiveTab("items")} onClick={() => setActiveTab("items")}

View File

@@ -105,7 +105,7 @@ export default function ProjectDetail() {
const isPending = projectQuery.isPending; const isPending = projectQuery.isPending;
const notes: Note[] = project?.project_notes || []; const notes: Note[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions()); const { data: usersData } = useQuery(userListOptions("projects.view"));
const rawUsers = ((usersData as Record<string, unknown>)?.items ?? const rawUsers = ((usersData as Record<string, unknown>)?.items ??
usersData ?? usersData ??
[]) as any[]; []) as any[];

View File

@@ -1546,15 +1546,31 @@ export default function Settings() {
</tbody> </tbody>
</table> </table>
) : ( ) : (
<Skeleton <div
name="settings-permissions" style={{
loading={ padding: "1rem",
!role.permissions || role.permissions.length === 0 display: "flex",
} flexDirection: "column",
fixture={<span>...</span>} gap: "0.5rem",
}}
> >
<span>{role.permissions?.length || 0} oprávnění</span> <div
</Skeleton> style={{
height: "0.75rem",
background: "#e0e0e0",
borderRadius: "4px",
width: "60%",
}}
/>
<div
style={{
height: "0.75rem",
background: "#e0e0e0",
borderRadius: "4px",
width: "40%",
}}
/>
</div>
)} )}
</div> </div>
</motion.div> </motion.div>

View File

@@ -220,8 +220,20 @@ export default async function attendanceRoutes(
userId, userId,
isAdmin, isAdmin,
authUserId: authData.userId, authUserId: authData.userId,
month: query.month ? Number(query.month) : undefined, month: query.month
year: query.year ? Number(query.year) : undefined, ? String(query.month).includes("-")
? Number(String(query.month).split("-")[1])
: Number(query.month)
: undefined,
year: query.month
? String(query.month).includes("-")
? Number(String(query.month).split("-")[0])
: query.year
? Number(query.year)
: undefined
: query.year
? Number(query.year)
: undefined,
}); });
return reply.send({ return reply.send({

View File

@@ -632,14 +632,6 @@ ${indentCSS}
border: none; border: none;
vertical-align: top; vertical-align: top;
} }
.logo-header {
text-align: right;
padding-bottom: 4mm;
}
.first-content {
margin-top: -26mm;
}
/* ---- Page break helpers ---- */ /* ---- Page break helpers ---- */
table.page-layout thead { display: table-header-group; } table.page-layout thead { display: table-header-group; }
table.items tbody tr { break-inside: avoid; } table.items tbody tr { break-inside: avoid; }
@@ -696,30 +688,16 @@ ${indentCSS}
display: block; display: block;
width: 100%; width: 100%;
} }
.first-content {
margin-top: 0 !important;
}
.logo-header {
text-align: right;
padding-bottom: 0;
margin-bottom: -18mm;
}
} }
</style> </style>
</head> </head>
<body> <body>
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ --> <!-- ============ QUOTATION (full header in thead repeats on every page) ============ -->
<div class="quotation-page"> <div class="quotation-page">
<table class="page-layout"> <table class="page-layout">
<thead> <thead>
<tr><td> <tr><td>
<div class="logo-header">${logoImg}</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="first-content">
<div class="page-header"> <div class="page-header">
<div class="left"> <div class="left">
<div class="page-title">${escapeHtml(t("title"))}</div> <div class="page-title">${escapeHtml(t("title"))}</div>
@@ -727,9 +705,13 @@ ${indentCSS}
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""} ${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
<div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div> <div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div> </div>
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
</div> </div>
<hr class="separator" /> <hr class="separator" />
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="addresses"> <div class="addresses">
<div class="address-block left"> <div class="address-block left">
<div class="address-label">${escapeHtml(t("customer"))}</div> <div class="address-label">${escapeHtml(t("customer"))}</div>
@@ -763,7 +745,6 @@ ${indentCSS}
${totalsHtml} ${totalsHtml}
</div> </div>
</div> </div>
</div>
</td></tr> </td></tr>
</tbody> </tbody>
</table> </table>

View File

@@ -156,7 +156,6 @@ export default async function projectFilesRoutes(
"/upload", "/upload",
{ {
preHandler: requirePermission("projects.files"), preHandler: requirePermission("projects.files"),
bodyLimit: config.nas.maxUploadSize,
}, },
async (request, reply) => { async (request, reply) => {
const parsedQuery = parseProjectFilesQuery(request.query); const parsedQuery = parseProjectFilesQuery(request.query);
@@ -183,13 +182,25 @@ export default async function projectFilesRoutes(
const rawFileName = file.filename; const rawFileName = file.filename;
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_"); const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
const buffer = await file.toBuffer();
const err = await fm.uploadFile( const err = await fm.uploadFile(
project.project_number, project.project_number,
subPath, subPath,
file.file, buffer,
fileName, fileName,
); );
if (err !== null) return error(reply, err); if (err !== null) {
request.log.error(
{
err,
project_number: project.project_number,
subPath,
fileName,
},
"uploadFile failed",
);
return error(reply, err);
}
await logAudit({ await logAudit({
request, request,

View File

@@ -22,8 +22,12 @@ export default async function usersRoutes(
"/", "/",
{ preHandler: requirePermission("users.view") }, { preHandler: requirePermission("users.view") },
async (request, reply) => { async (request, reply) => {
const params = parsePagination(request.query as Record<string, unknown>); const query = request.query as Record<string, unknown>;
const result = await listUsers(params); const params = parsePagination(query);
const permission = query.permission
? String(query.permission)
: undefined;
const result = await listUsers({ ...params, permission });
return paginated( return paginated(
reply, reply,

View File

@@ -1,6 +1,5 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { pipeline } from "stream/promises";
import { config } from "../config/env"; import { config } from "../config/env";
import { localDateStr, localTimeStr } from "../utils/date"; import { localDateStr, localTimeStr } from "../utils/date";
@@ -237,7 +236,7 @@ export class NasFileManager {
if (isLink) { if (isLink) {
try { try {
const target = fs.readlinkSync(fullPath); const target = fs.readlinkSync(fullPath);
item.link_target = target.replace(/\//g, "\\"); item.link_target = target;
} catch { } catch {
// ignore // ignore
} }
@@ -288,14 +287,14 @@ export class NasFileManager {
path: subPath, path: subPath,
items, items,
breadcrumb, breadcrumb,
full_path: realDirPath.replace(/\//g, "\\"), full_path: realDirPath,
}; };
} }
public async uploadFile( public async uploadFile(
projectNumber: string, projectNumber: string,
subPath: string, subPath: string,
fileStream: NodeJS.ReadableStream, fileBuffer: Buffer,
fileName: string, fileName: string,
): Promise<string | null> { ): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath); const dirPath = this.resolveProjectPath(projectNumber, subPath);
@@ -304,13 +303,20 @@ export class NasFileManager {
} }
try { try {
const stat = await fs.promises.stat(dirPath); await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o775 });
if (!stat.isDirectory()) { } catch {
return "Cílová složka neexistuje"; return "Cílová složka neexistuje";
} }
let stat: fs.Stats;
try {
stat = await fs.promises.stat(dirPath);
} catch { } catch {
return "Cílová složka neexistuje"; return "Cílová složka neexistuje";
} }
if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
const originalName = path.basename(fileName); const originalName = path.basename(fileName);
let safeName = this.sanitizeFilename(originalName); let safeName = this.sanitizeFilename(originalName);
@@ -323,46 +329,27 @@ export class NasFileManager {
return "Tento typ souboru není povolen"; return "Tento typ souboru není povolen";
} }
const tempPath = path.join( // MIME validation
require("os").tmpdir(), const typeResult = await FileType.fromBuffer(fileBuffer);
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
await pipeline(fileStream, fs.createWriteStream(tempPath));
} catch {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
try {
const typeResult = await FileType.fromFile(tempPath);
if (typeResult) { if (typeResult) {
if (this.isSuspiciousMime(typeResult.mime)) { if (this.isSuspiciousMime(typeResult.mime)) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě"; return "Obsah souboru neodpovídá jeho příponě";
} }
const expectedMime = ext ? MIME_MAP[ext] : null; const expectedMime = ext ? MIME_MAP[ext] : null;
if (expectedMime && typeResult.mime !== expectedMime) { if (expectedMime && typeResult.mime !== expectedMime) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě"; return "Obsah souboru neodpovídá jeho příponě";
} }
} }
} catch {
// If file-type fails, continue without MIME check
}
let destPath = dirPath + "/" + safeName; let destPath = dirPath + "/" + safeName;
// Attempt atomic rename; if destination exists, append counter // If destination exists, append counter
let renamed = false;
let attempts = 0; let attempts = 0;
const maxAttempts = 1000; const maxAttempts = 1000;
do { while (attempts < maxAttempts) {
try { try {
await fs.promises.rename(tempPath, destPath); await fs.promises.writeFile(destPath, fileBuffer, { flag: "wx" });
renamed = true; return null;
break;
} catch (err) { } catch (err) {
const e = err as NodeJS.ErrnoException; const e = err as NodeJS.ErrnoException;
if (e.code === "EEXIST") { if (e.code === "EEXIST") {
@@ -371,19 +358,14 @@ export class NasFileManager {
safeName = base + "_" + attempts + (ext ? "." + ext : ""); safeName = base + "_" + attempts + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName; destPath = dirPath + "/" + safeName;
} else { } else {
break; return `Nepodařilo se uložit soubor (${e?.message || String(e)})`;
}
} }
} }
} while (!renamed && attempts < maxAttempts);
if (!renamed) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor"; return "Nepodařilo se uložit soubor";
} }
return null;
}
public downloadFile( public downloadFile(
projectNumber: string, projectNumber: string,
filePath: string, filePath: string,

View File

@@ -33,6 +33,7 @@ export interface ListUsersParams {
sort: string; sort: string;
order: "asc" | "desc"; order: "asc" | "desc";
search: string; search: string;
permission?: string;
} }
export interface CreateUserData { export interface CreateUserData {
@@ -56,10 +57,10 @@ export interface UpdateUserData {
} }
export async function listUsers(params: ListUsersParams) { export async function listUsers(params: ListUsersParams) {
const { page, limit, skip, sort, order, search } = params; const { page, limit, skip, sort, order, search, permission } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where = search let where: any = search
? { ? {
OR: [ OR: [
{ username: { contains: search } }, { username: { contains: search } },
@@ -70,6 +71,14 @@ export async function listUsers(params: ListUsersParams) {
} }
: {}; : {};
if (permission) {
where.roles = {
role_permissions: {
some: { permissions: { name: permission } },
},
};
}
const [users, total] = await Promise.all([ const [users, total] = await Promise.all([
prisma.users.findMany({ prisma.users.findMany({
where, where,