diff --git a/.claude/hooks/block-env.js b/.claude/hooks/block-env.js index 0601b8f..5c48662 100644 --- a/.claude/hooks/block-env.js +++ b/.claude/hooks/block-env.js @@ -1,14 +1,23 @@ // Block direct .env file edits (not .env.example, .env.production, .env.test) -let data = ''; -process.stdin.on('data', chunk => data += chunk); -process.stdin.on('end', () => { +// +// Hook exit-code contract (Claude Code PreToolUse): +// exit 0 = allow (stdout JSON is only parsed on exit 0) +// exit 2 = BLOCK — the reason must be written to STDERR +// any other exit code = non-blocking error (the tool call proceeds) +// So to actually block, we must print to stderr and exit 2. +let data = ""; +process.stdin.on("data", (chunk) => (data += chunk)); +process.stdin.on("end", () => { try { const input = JSON.parse(data); - const filePath = input.tool_input?.file_path || ''; - const basename = filePath.split('/').pop().split('\\').pop(); - if (basename === '.env') { - console.log(JSON.stringify({ decision: 'block', reason: 'Direct .env edits are blocked. Use .env.example instead.' })); - process.exit(1); + const filePath = input.tool_input?.file_path || ""; + const basename = filePath.split("/").pop().split("\\").pop(); + if (basename === ".env") { + console.error("Direct .env edits are blocked. Use .env.example instead."); + process.exit(2); } - } catch {} + } catch { + // Deliberate fail-open: on unparseable input exit 0 (allow). Exiting 2 + // here would block EVERY Edit/Write if the hook payload format changed. + } }); diff --git a/docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md b/docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md new file mode 100644 index 0000000..bdc76e9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md @@ -0,0 +1,188 @@ +# 2026-06-10 — Audit fix pass #1: all verified CRITICAL/HIGH findings + +Source: `REVIEW_FINDINGS.md` (2026-06-09 full audit, 304 files). Scope of this pass: +the 19 verified per-file **HIGH** findings + the project-level **CRITICAL** dependency +finding. The two project-level HIGH _structural refactors_ (service-layer extraction for +6 route files; PDF template dedup) are explicitly **out of scope** — they are +multi-thousand-line refactors that deserve their own pass. + +Execution: subagent-driven development — one implementer per task (sequential, shared +test DB forbids parallel test runs), spec-compliance review then code-quality review +after each. No commits until the user asks. Final full gates: +`npx tsc -b --noEmit` · `npx vitest run` · `npm run lint` · `npm run build`. + +--- + +## Task H — Dependencies (project-level CRITICAL) — done inline by controller + +- Remove `concurrently` from devDependencies (unused; carries both critical advisories + GHSA-w7jw-789q-3m8p / shell-quote CVSS 8.1). +- Add `"overrides": { "@hono/node-server": "^1.19.13" }` (clears the 3 moderate + advisories from the prisma → @prisma/dev chain; do NOT take npm's Prisma 6 downgrade). +- Remove deprecated stub types `@types/dompurify`, `@types/bcryptjs`; move `@types/jsdom` + to devDependencies. +- Add `qrcode` + `@types/qrcode` (needed by Task B to replace the CSP-blocked external + QR service). +- `npm install`, verify `npm audit` (criticals+moderates gone), typecheck. + +## Task A — Attendance create/edit broken since 519edce (top-5 #1) + +Findings (both verified HIGH): + +- `src/admin/pages/AttendanceCreate.tsx:80-99` — handleSubmit posts the raw form whose + shape the API does not accept: `arrival_date`/`break_start_date`/`break_start_time`/ + `break_end_*`/`departure_date` are not in CreateAttendanceSchema (silently stripped — + breaks and overnight dates never save); times are never combined with dates (unlike + useAttendanceAdmin's combineDatetime), so a validated "HH:MM" would reach + createAttendance which does `new Date('08:00')` → Invalid Date → 500; and since commit + 519edce the always-sent empty-string time fields (`arrival_time: ""`) fail timeString + validation, so EVERY submit from this page — including leave records — returns 400. +- `src/admin/pages/AttendanceAdmin.tsx:407-442` (logic in `useAttendanceAdmin.ts`) — + create/edit modal submits send combined `YYYY-MM-DDTHH:MM:00` datetimes for + arrival/departure/break, but since 519edce CreateAttendanceSchema/UpdateAttendanceSchema + validate them with `timeString` (HH:MM only) — every create/edit containing a time is + rejected 400 "Čas musí být ve formátu HH:MM" (and the service itself expects full + datetimes via `new Date(...)`); additionally `break_start`/`break_end` are absent from + CreateAttendanceSchema, so breaks are silently stripped on create. + +Direction: first `git show 519edce` to understand the intent; the schema contradicts +both client and service, so the fix is to make the schemas validate the combined +local-datetime wire format the client sends and the service consumes (lenient helper in +`src/schemas/common.ts` per repo convention), add `break_start`/`break_end` to the create +schema, and rewrite AttendanceCreate.tsx's submit to combine date+time and omit empty +optionals. TDD: route-level regression tests (work record with times+breaks, overnight, +leave record without times, update) in `src/__tests__/`. + +## Task B — 2FA: backup-code login, enrollment QR, dashboard status (top-5 #2) + +Findings (all verified HIGH): + +- `src/admin/context/AuthContext.tsx:263-272` — backup-code login broken: verify2FA + always POSTs to /login/totp with the code as totp_code plus an isBackup flag the server + ignores; TotpVerifySchema requires exactly 6 digits so 8-char backup codes always 400. + The real endpoint POST /api/admin/totp/backup-verify (expects backup_code) is never + called anywhere in the frontend — lockout for any user who lost their authenticator. +- `src/admin/components/dashboard/DashProfile.tsx:556` — QR `` from api.qrserver.com + is blocked by the production CSP (img-src 'self' data: blob: + osm tiles), so prod 2FA + enrollment shows a broken image. (Also a privacy bug: the TOTP secret is sent to a + third-party URL.) Fix: generate the QR locally with the `qrcode` package + (`QRCode.toDataURL(otpauthUrl)`) — data: is already CSP-allowed. +- `src/admin/pages/Dashboard.tsx:89-91` — totpEnabled derived from require2FAOptions() + which returns the COMPANY-WIDE require_2fa policy flag, not the user's enrollment; the + per-user endpoint (totp.ts:190-197) is never queried. Wrong "2FA Aktivní/Neaktivní" and + wrong button shown. + +Direction: read `src/routes/admin/totp.ts` first; wire Login/AuthContext so a backup code +goes to backup-verify and completes the login-token flow. If the server endpoint doesn't +fit the loginToken flow, extend it minimally (single-use code consumption + logAudit +preserved). Server test for backup-verify login path if server is touched. + +## Task C — Invoice/issued-order edit integrity (top-5 #3) + +Findings (all verified HIGH): + +- `src/admin/pages/InvoiceDetail.tsx:728` — edit hydration overwrites each item's + persisted per-line VAT with the invoice-level rate (`vat_rate: Number(inv.vat_rate) || 21`) + though invoice_items.vat_rate is a real per-item column — re-saving a mixed-rate invoice + silently corrupts per-line VAT in the DB; `|| 21` also turns legitimate 0% into 21% + (same falsy-zero at line 689). +- `src/admin/pages/InvoiceDetail.tsx:1891-1902` — editing "Text fakturace (na PDF)" is + silently lost: payload includes billing_text but UpdateInvoiceSchema + (src/schemas/invoices.schema.ts:46-67) has no billing_text field, so Zod strips it + (updateInvoice supports it via strFields). Add the field to the schema + server test. +- `src/admin/pages/IssuedOrderDetail.tsx:606-611` — hydration falsy-fallbacks: + `quantity: Number(it.quantity) || 1` turns saved 0 into 1; `vat_rate: Number(it.vat_rate) +|| Number(d.vat_rate) || 21` turns a saved 0% line into 21% — displayed wrong and + silently persisted on next save. +- `src/admin/pages/IssuedOrderDetail.tsx:826-839` — handleStatusChange never mirrors the + new status into form.status, so StatusChip, the `editable` flag and the delete-button + gate use the stale status after every transition. + +Use Number.isFinite-style coercion that respects 0 (e.g. `const n = Number(x); +Number.isFinite(n) ? n : fallback`). + +## Task D — Trips: truncated lists/totals + dropped vehicle edit (top-5 #4a) + +Findings (all verified HIGH): + +- `src/admin/lib/queries/trips.ts:52-63` — tripListOptions consumes the paginated + endpoint via plain jsonQuery, discarding pagination meta; Trips.tsx uses default limit + 25 and TripsAdmin perPage:100 (server hard cap) — both silently truncate with no + pagination controls. +- `src/admin/lib/queries/trips.ts:95-103` — tripHistoryOptions sends no page/per_page → + 25 rows; TripsHistory.tsx computes monthly km/business totals from the truncated rows. +- `src/admin/pages/Trips.tsx:324-336` — stat cards computed from the 25-row page; + header labels stats "current month" though the query has no month filter. +- `src/admin/pages/TripsAdmin.tsx:295,665-676` — edit modal sends vehicle_id but + UpdateTripSchema (src/schemas/trips.schema.ts:23-31) has no vehicle_id and the PUT + handler never applies it — vehicle change silently dropped. Add to schema + (nullableIntIdFromForm) + apply in route + audit + server test. +- `src/admin/pages/TripsHistory.tsx:98-104,121-128` — same 25-row truncation for the + month view and its stat cards. + +Direction for totals: prefer a server-side aggregate (the repo already has per-currency +totals endpoints for invoices/orders as the pattern) over fetching all pages client-side; +lists get real pagination using the existing `usePaginatedQuery`/`Pagination` kit pieces +used elsewhere. Keep the month-filter semantics of TripsHistory correct (totals must +cover the WHOLE month, not one page). + +## Task E — orders.service.ts: PDF blob in every response (top-5 #4b) + +Finding (verified HIGH): `src/services/orders.service.ts:154-176,196-216,228-263,570,683` +— attachment_data (full PDF blob, Bytes) is fetched on every query with no select/omit +and spread into API responses by enrichOrder/getOrder; getOrderTotals loads every blob +for the whole filtered set just to sum totals; updateOrder/deleteOrder pre-reads pull the +blob to check status/number. A dedicated /:id/attachment endpoint already exists. +Exclude the blob from list/detail/totals/pre-reads (keep has_attachment-style boolean if +the FE needs it — check FE usage), keep the attachment endpoint working, extend +`src/__tests__/orders-list.test.ts` to assert attachment_data is absent. + +## Task F — Warehouse: unit enum 400s + dead attachment download + +Findings (both verified HIGH): + +- `src/admin/pages/WarehouseItemDetail.tsx:451-462` — "Jednotka" is free-text but the + server requires UnitEnum ("ks","m","kg","bal","sada","m2","m3","l") — any other value + 400s create/update. Make it a Select over the enum values (single source: mirror the + server enum list). +- `src/admin/pages/WarehouseReceiptDetail.tsx:251-259` — attachment link targets + GET /warehouse/receipts/:id/attachments/:attachmentId which does not exist (only POST + upload + DELETE are registered in warehouse.ts), and a plain sends no + Authorization header anyway. Add the GET download route (requirePermission, correct + content-type/disposition) + authenticated blob download in the UI (fetch via apiFetch → + object URL, like other binary downloads in the repo). Server test for the new route. + +## Task G — Small fixes + +- `.claude/hooks/block-env.js:10-11` (verified HIGH) — block decision never takes + effect: prints {decision:'block'} JSON to stdout then exits 1; Claude Code only parses + stdout JSON on exit 0 and only blocks on exit 2 (reason on stderr). Fix: exit 2 with the + reason on stderr. +- `src/admin/pages/Projects.tsx:153-154` (verified HIGH) — create modal submits + start_date/end_date initialized to "" which isoDateString.nullish() rejects → creating + a project without filling BOTH dates always 400s. Send null/omit when empty. + +--- + +## Status + +- [x] H — dependencies (controller, inline) — criticals+moderates cleared; only the + 2 known-mitigated quill lows remain +- [x] A — attendance create/edit — dateTimeString helpers, break fields, rewritten + AttendanceCreate payload; 6 regression tests +- [x] B — 2FA — backup-verify wired (+ remember-me parity server-side), local QR via + qrcode lib, per-user totp status; 6 tests +- [x] C — invoice/issued-order edit integrity — numberOr (shared in formatters.ts), + billing_text in UpdateInvoiceSchema, status mirror; 2 tests +- [x] D — trips — paginated queries + Pagination UI on 3 pages, GET /trips/stats + (buildTripsWhere shared), vehicle_id on PUT + both-vehicle odometer recompute, + print rebuilt (sync window.open, escaped string template); 7 tests +- [x] E — orders attachment_data excluded from all non-binary reads (incl. + numbering/invoices/orders-pdf callers); 4 tests +- [x] F — warehouse unit Select + GET receipt attachment route + authenticated blob + download; RFC 5987 content-disposition helper (also fixes received-invoices + + orders Czech-filename 500); 6 tests +- [x] G — block-env hook exit 2/stderr (verified empirically), Projects empty dates + → null +- [x] Final gates: tsc -b clean · vitest 30 files / 342 tests green · lint 0 errors · + build OK. Whole-diff 3-lens review: see final report. diff --git a/package-lock.json b/package-lock.json index e70a1dd..c86d87d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "app-ts", - "version": "2.1.5", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "app-ts", - "version": "2.1.5", + "version": "2.2.0", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.102.0", @@ -26,7 +26,6 @@ "@prisma/adapter-mariadb": "^7.8.0", "@prisma/client": "^7.8.0", "@tanstack/react-query": "^5.100.5", - "@types/jsdom": "^28.0.1", "bcryptjs": "^3.0.3", "date-fns": "^4.1.0", "dompurify": "^3.3.3", @@ -52,8 +51,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/bcryptjs": "^2.4.6", - "@types/dompurify": "^3.0.5", + "@types/jsdom": "^28.0.1", "@types/jsonwebtoken": "^9.0.10", "@types/leaflet": "^1.9.21", "@types/node": "^25.5.0", @@ -64,7 +62,6 @@ "@types/react-dom": "^19.2.3", "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^6.0.1", - "concurrently": "^9.2.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", @@ -1729,9 +1726,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -3010,13 +3007,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3042,16 +3032,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/dompurify": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", - "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/trusted-types": "*" - } - }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3076,6 +3056,7 @@ "version": "28.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz", "integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -3088,6 +3069,7 @@ "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz", "integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -3236,14 +3218,15 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, "license": "MIT" }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "devOptional": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@types/yauzl": { "version": "2.10.3", @@ -4154,36 +4137,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", @@ -4297,31 +4250,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -4713,6 +4641,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -5835,16 +5764,6 @@ "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", "license": "MIT" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -7258,6 +7177,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -8087,16 +8007,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -8244,19 +8154,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -8577,22 +8474,6 @@ "node": ">=14.18.0" } }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -8817,16 +8698,6 @@ "node": ">=20" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", diff --git a/package.json b/package.json index 318a1db..1b9a6ad 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "@prisma/adapter-mariadb": "^7.8.0", "@prisma/client": "^7.8.0", "@tanstack/react-query": "^5.100.5", - "@types/jsdom": "^28.0.1", "bcryptjs": "^3.0.3", "date-fns": "^4.1.0", "dompurify": "^3.3.3", @@ -73,8 +72,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/bcryptjs": "^2.4.6", - "@types/dompurify": "^3.0.5", + "@types/jsdom": "^28.0.1", "@types/jsonwebtoken": "^9.0.10", "@types/leaflet": "^1.9.21", "@types/node": "^25.5.0", @@ -85,7 +83,6 @@ "@types/react-dom": "^19.2.3", "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^6.0.1", - "concurrently": "^9.2.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", @@ -98,5 +95,8 @@ "typescript-eslint": "^8.61.0", "vite": "^8.0.0", "vitest": "^4.1.0" + }, + "overrides": { + "@hono/node-server": "^1.19.13" } } diff --git a/src/__tests__/attendance.test.ts b/src/__tests__/attendance.test.ts new file mode 100644 index 0000000..0c77eca --- /dev/null +++ b/src/__tests__/attendance.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import Fastify from "fastify"; +import cookie from "@fastify/cookie"; +import rateLimit from "@fastify/rate-limit"; +import jwt from "jsonwebtoken"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import attendanceRoutes from "../routes/admin/attendance"; + +// --------------------------------------------------------------------------- +// Regression tests for the attendance create/edit wire format. +// +// The admin create/edit forms send COMBINED local datetimes +// ("YYYY-MM-DDTHH:MM:00", built by combineDatetime from a date + time input) +// for arrival_time / departure_time / break_start / break_end, and the +// service parses them with `new Date(...)`. Commit 519edce validated these +// fields with `timeString` (bare HH:MM), which rejected every create/edit +// containing a time, and CreateAttendanceSchema lacked break_start/break_end +// entirely, so breaks were silently stripped on create. These tests pin the +// combined-datetime contract at the HTTP route level. +// --------------------------------------------------------------------------- + +// All fixtures live on far-future dates so they can never collide with real +// rows in the throwaway test DB; cleanup deletes exactly this range. +const RANGE_START = new Date("2098-01-01T00:00:00"); +const RANGE_END = new Date("2099-01-01T00:00:00"); + +let app: Awaited>; +let adminUserId: number; +let adminToken: string; + +async function buildApp() { + const a = Fastify({ logger: false }); + await a.register(cookie); + await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + a.addHook("onRequest", securityHeaders); + await a.register(attendanceRoutes, { prefix: "/api/admin/attendance" }); + return a; +} + +/** + * Generate a JWT access token. `requireAuth` re-loads auth data from the DB + * via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here. + */ +function generateToken(user: { + id: number; + username: string; + roleName: string | null; +}): string { + return jwt.sign( + { sub: user.id, username: user.username, role: user.roleName }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +} + +async function authPost(path: string, token: string, body: unknown) { + return app.inject({ + method: "POST", + url: path, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + payload: body as object, + }); +} + +async function authPut(path: string, token: string, body: unknown) { + return app.inject({ + method: "PUT", + url: path, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + payload: body as object, + }); +} + +async function cleanupFixtureRows() { + await prisma.attendance.deleteMany({ + where: { + user_id: adminUserId, + shift_date: { gte: RANGE_START, lt: RANGE_END }, + }, + }); +} + +beforeAll(async () => { + app = await buildApp(); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + include: { roles: true }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminUserId = admin.id; + adminToken = generateToken({ + id: admin.id, + username: admin.username, + roleName: admin.roles?.name ?? null, + }); + + await cleanupFixtureRows(); +}); + +beforeEach(async () => { + await cleanupFixtureRows(); +}); + +afterAll(async () => { + await cleanupFixtureRows(); + if (app) await app.close(); +}); + +describe("POST /api/admin/attendance (combined datetimes)", () => { + it("creates a work record with arrival+departure datetimes and persists them", async () => { + const arrival = "2098-03-10T08:00:00"; + const departure = "2098-03-10T16:30:00"; + + const res = await authPost("/api/admin/attendance", adminToken, { + user_id: adminUserId, + shift_date: "2098-03-10", + leave_type: "work", + arrival_time: arrival, + departure_time: departure, + notes: "attendance_wire_test work", + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.success).toBe(true); + + const rec = await prisma.attendance.findUnique({ + where: { id: body.data.id }, + }); + expect(rec).toBeTruthy(); + expect(rec!.leave_type).toBe("work"); + expect(rec!.arrival_time?.getTime()).toBe(new Date(arrival).getTime()); + expect(rec!.departure_time?.getTime()).toBe(new Date(departure).getTime()); + }); + + it("persists break_start/break_end on create (previously silently stripped)", async () => { + const res = await authPost("/api/admin/attendance", adminToken, { + user_id: adminUserId, + shift_date: "2098-03-11", + leave_type: "work", + arrival_time: "2098-03-11T08:00:00", + departure_time: "2098-03-11T16:30:00", + break_start: "2098-03-11T12:00:00", + break_end: "2098-03-11T12:30:00", + notes: "attendance_wire_test break", + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.success).toBe(true); + + const rec = await prisma.attendance.findUnique({ + where: { id: body.data.id }, + }); + expect(rec!.break_start?.getTime()).toBe( + new Date("2098-03-11T12:00:00").getTime(), + ); + expect(rec!.break_end?.getTime()).toBe( + new Date("2098-03-11T12:30:00").getTime(), + ); + }); + + it("creates a leave record with NO time fields", async () => { + const res = await authPost("/api/admin/attendance", adminToken, { + user_id: adminUserId, + shift_date: "2098-03-12", + leave_type: "vacation", + leave_hours: 8, + notes: "attendance_wire_test leave", + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.success).toBe(true); + + const rec = await prisma.attendance.findUnique({ + where: { id: body.data.id }, + }); + expect(rec!.leave_type).toBe("vacation"); + expect(Number(rec!.leave_hours)).toBe(8); + expect(rec!.arrival_time).toBeNull(); + expect(rec!.departure_time).toBeNull(); + }); + + it('tolerates "" for unset datetime fields (old form payloads) → null', async () => { + // The AttendanceCreate page historically always sent empty strings for + // the unfilled time fields — even for leave records — which 400'd EVERY + // submit after 519edce. The schema must treat "" as "not set". + const res = await authPost("/api/admin/attendance", adminToken, { + user_id: adminUserId, + shift_date: "2098-03-13", + leave_type: "sick", + leave_hours: 8, + arrival_time: "", + departure_time: "", + break_start: "", + break_end: "", + notes: "attendance_wire_test empty strings", + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.success).toBe(true); + + const rec = await prisma.attendance.findUnique({ + where: { id: body.data.id }, + }); + expect(rec!.leave_type).toBe("sick"); + expect(rec!.arrival_time).toBeNull(); + expect(rec!.departure_time).toBeNull(); + expect(rec!.break_start).toBeNull(); + expect(rec!.break_end).toBeNull(); + }); +}); + +describe("PUT /api/admin/attendance/:id (combined datetimes)", () => { + it("updates a record with combined datetimes (incl. overnight departure)", async () => { + const created = await prisma.attendance.create({ + data: { + user_id: adminUserId, + shift_date: new Date(2098, 2, 14, 12, 0, 0), + leave_type: "work", + arrival_time: new Date("2098-03-14T08:00:00"), + departure_time: new Date("2098-03-14T16:30:00"), + notes: "attendance_wire_test update", + }, + }); + + const res = await authPut( + `/api/admin/attendance/${created.id}`, + adminToken, + { + leave_type: "work", + arrival_time: "2098-03-14T22:00:00", + departure_time: "2098-03-15T06:00:00", + break_start: "2098-03-15T02:00:00", + break_end: "2098-03-15T02:30:00", + notes: "attendance_wire_test updated", + }, + ); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + + const rec = await prisma.attendance.findUnique({ + where: { id: created.id }, + }); + expect(rec!.arrival_time?.getTime()).toBe( + new Date("2098-03-14T22:00:00").getTime(), + ); + expect(rec!.departure_time?.getTime()).toBe( + new Date("2098-03-15T06:00:00").getTime(), + ); + expect(rec!.break_start?.getTime()).toBe( + new Date("2098-03-15T02:00:00").getTime(), + ); + expect(rec!.break_end?.getTime()).toBe( + new Date("2098-03-15T02:30:00").getTime(), + ); + expect(rec!.notes).toBe("attendance_wire_test updated"); + }); + + it("clears times when null is sent (leave-type edit)", async () => { + const created = await prisma.attendance.create({ + data: { + user_id: adminUserId, + shift_date: new Date(2098, 2, 16, 12, 0, 0), + leave_type: "work", + arrival_time: new Date("2098-03-16T08:00:00"), + departure_time: new Date("2098-03-16T16:30:00"), + notes: "attendance_wire_test to-leave", + }, + }); + + const res = await authPut( + `/api/admin/attendance/${created.id}`, + adminToken, + { + leave_type: "vacation", + leave_hours: 8, + arrival_time: null, + departure_time: null, + break_start: null, + break_end: null, + notes: "attendance_wire_test to-leave", + }, + ); + + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + + const rec = await prisma.attendance.findUnique({ + where: { id: created.id }, + }); + expect(rec!.leave_type).toBe("vacation"); + expect(rec!.arrival_time).toBeNull(); + expect(rec!.departure_time).toBeNull(); + }); +}); diff --git a/src/__tests__/invoices.service.test.ts b/src/__tests__/invoices.service.test.ts index a2d4b4b..ab8e797 100644 --- a/src/__tests__/invoices.service.test.ts +++ b/src/__tests__/invoices.service.test.ts @@ -1,6 +1,12 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { Prisma } from "@prisma/client"; -import { invoiceTotalWithVat } from "../services/invoices.service"; +import prisma from "../config/database"; +import { + invoiceTotalWithVat, + createInvoice, + updateInvoice, +} from "../services/invoices.service"; +import { UpdateInvoiceSchema } from "../schemas/invoices.schema"; // `invoiceTotalWithVat` receives a Prisma row whose money columns are real // `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using @@ -118,3 +124,58 @@ describe("invoiceTotalWithVat", () => { expect(invoiceTotalWithVat(inv)).toBe(968); }); }); + +/* -------------------------------------------------------------------------- */ +/* PUT regression — billing_text must survive UpdateInvoiceSchema */ +/* -------------------------------------------------------------------------- */ + +// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the +// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object, +// which STRIPS unknown keys — so a field missing from the schema is silently +// dropped before the service ever sees it, while the client still gets a +// success response. This block mirrors that exact flow against the real DB. + +describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => { + const invoiceIds: number[] = []; + + afterEach(async () => { + for (const id of invoiceIds) + await prisma.invoices.deleteMany({ where: { id } }); + invoiceIds.length = 0; + }); + + it("persists an edited billing_text (the schema must not strip it)", async () => { + const draft = await createInvoice({ billing_text: "Původní text" }); + invoiceIds.push(draft.id); + + // Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice. + const parsed = UpdateInvoiceSchema.parse({ + billing_text: "Fakturujeme Vám dle objednávky č. 123", + }); + expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123"); + + const res = await updateInvoice(draft.id, parsed); + expect("error" in res).toBe(false); + + const row = await prisma.invoices.findUnique({ where: { id: draft.id } }); + expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123"); + }); + + it("clears billing_text when null is sent, and leaves it untouched when absent", async () => { + const draft = await createInvoice({ billing_text: "Text na PDF" }); + invoiceIds.push(draft.id); + + // Absent from the body -> updateInvoice's `!== undefined` guard skips it. + await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" })); + let row = await prisma.invoices.findUnique({ where: { id: draft.id } }); + expect(row?.billing_text).toBe("Text na PDF"); + + // Explicit null -> cleared (the strFields path maps falsy -> null). + await updateInvoice( + draft.id, + UpdateInvoiceSchema.parse({ billing_text: null }), + ); + row = await prisma.invoices.findUnique({ where: { id: draft.id } }); + expect(row?.billing_text).toBeNull(); + }); +}); diff --git a/src/__tests__/orders-list.test.ts b/src/__tests__/orders-list.test.ts index 0a16010..add344e 100644 --- a/src/__tests__/orders-list.test.ts +++ b/src/__tests__/orders-list.test.ts @@ -1,6 +1,17 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import cookie from "@fastify/cookie"; +import rateLimit from "@fastify/rate-limit"; +import jwt from "jsonwebtoken"; import prisma from "../config/database"; -import { createOrder, listOrders } from "../services/orders.service"; +import { config } from "../config/env"; +import ordersRoutes from "../routes/admin/orders"; +import { + createOrder, + listOrders, + getOrder, + getOrderAttachment, +} from "../services/orders.service"; const createdOrderIds: number[] = []; const createdCustomerIds: number[] = []; @@ -80,6 +91,166 @@ describe("listOrders search filter", () => { }); }); +describe("order attachment payload hygiene", () => { + // The PO attachment blob must be served ONLY by GET /:id/attachment — + // list/detail payloads carry attachment_name as the existence signal. + const PDF_BYTES = Buffer.from("%PDF-1.4 orders-list-test-attachment-bytes"); + const TEST_ADMIN = "orders_list_test_admin"; + + let app: Awaited>; + let adminToken: string; + let adminUserId: number; + + async function buildOrdersApp() { + const fastify = Fastify({ logger: false }); + await fastify.register(cookie); + await fastify.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + // multipart is registered inside ordersRoutes itself + await fastify.register(ordersRoutes, { prefix: "/api/admin/orders" }); + return fastify; + } + + beforeAll(async () => { + app = await buildOrdersApp(); + + // Clean up any leftover test user from a previous failed run + await prisma.users.deleteMany({ where: { username: TEST_ADMIN } }); + + const adminRole = await prisma.roles.findFirst({ + where: { name: "admin" }, + }); + if (!adminRole) + throw new Error("Admin role not found — seed the database first"); + + const adminUser = await prisma.users.create({ + data: { + username: TEST_ADMIN, + email: `${TEST_ADMIN}@test.local`, + password_hash: + "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", + first_name: "Orders", + last_name: "ListTest", + is_active: true, + role_id: adminRole.id, + }, + }); + adminUserId = adminUser.id; + adminToken = jwt.sign( + { sub: adminUser.id, username: adminUser.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); + }); + + afterAll(async () => { + await prisma.users.deleteMany({ where: { id: adminUserId } }); + await app.close(); + }); + + /** One order WITH a stored PO attachment, one WITHOUT. */ + async function makeOrderPair() { + const customer = await makeCustomer(); + const withRes = await createOrder( + { ...baseOrder, customer_id: customer.id, create_project: false }, + PDF_BYTES, + "po-test.pdf", + ); + if (!("id" in withRes)) failResult(withRes); + createdOrderIds.push(withRes.id!); + + const withoutRes = await createOrder({ + ...baseOrder, + customer_id: customer.id, + create_project: false, + }); + if (!("id" in withoutRes)) failResult(withoutRes); + createdOrderIds.push(withoutRes.id!); + + return { withId: withRes.id!, withoutId: withoutRes.id! }; + } + + it("listOrders rows never contain attachment_data; attachment_name signals existence", async () => { + const { withId, withoutId } = await makeOrderPair(); + + const list = await listOrders(baseListParams); + const withRow = list.data.find((o) => o.id === withId); + const withoutRow = list.data.find((o) => o.id === withoutId); + expect(withRow).toBeTruthy(); + expect(withoutRow).toBeTruthy(); + + expect("attachment_data" in withRow!).toBe(false); + expect("attachment_data" in withoutRow!).toBe(false); + expect(withRow!.attachment_name).toBe("po-test.pdf"); + expect(withoutRow!.attachment_name).toBeNull(); + }); + + it("getOrder detail never contains attachment_data but keeps attachment_name", async () => { + const { withId, withoutId } = await makeOrderPair(); + + const withDetail = await getOrder(withId); + const withoutDetail = await getOrder(withoutId); + expect(withDetail).toBeTruthy(); + expect(withoutDetail).toBeTruthy(); + + expect("attachment_data" in withDetail!).toBe(false); + expect("attachment_data" in withoutDetail!).toBe(false); + expect(withDetail!.attachment_name).toBe("po-test.pdf"); + expect(withoutDetail!.attachment_name).toBeNull(); + }); + + it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => { + const { withId, withoutId } = await makeOrderPair(); + const headers = { authorization: `Bearer ${adminToken}` }; + + const listRes = await app.inject({ + method: "GET", + url: "/api/admin/orders?sort=id&order=desc&per_page=100", + headers, + }); + expect(listRes.statusCode).toBe(200); + const listJson = JSON.parse(listRes.body); + expect(listJson.data.map((o: { id: number }) => o.id)).toContain(withId); + expect(listRes.body).not.toContain("attachment_data"); + + const detailRes = await app.inject({ + method: "GET", + url: `/api/admin/orders/${withId}`, + headers, + }); + expect(detailRes.statusCode).toBe(200); + expect(detailRes.body).not.toContain("attachment_data"); + expect(JSON.parse(detailRes.body).data.attachment_name).toBe("po-test.pdf"); + + // The dedicated endpoint is the ONE place the blob is served — byte-exact. + const attRes = await app.inject({ + method: "GET", + url: `/api/admin/orders/${withId}/attachment`, + headers, + }); + expect(attRes.statusCode).toBe(200); + expect(attRes.headers["content-type"]).toContain("application/pdf"); + expect(Buffer.from(attRes.rawPayload).equals(PDF_BYTES)).toBe(true); + + const noAttRes = await app.inject({ + method: "GET", + url: `/api/admin/orders/${withoutId}/attachment`, + headers, + }); + expect(noAttRes.statusCode).toBe(404); + }); + + it("getOrderAttachment round-trips the stored bytes (and null without one)", async () => { + const { withId, withoutId } = await makeOrderPair(); + + const att = await getOrderAttachment(withId); + expect(att).toBeTruthy(); + expect(att!.filename).toBe("po-test.pdf"); + expect(att!.data.equals(PDF_BYTES)).toBe(true); + + expect(await getOrderAttachment(withoutId)).toBeNull(); + }); +}); + describe("listOrders month filter", () => { it("returns only orders whose created_at is in the given month", async () => { const customer = await makeCustomer(); diff --git a/src/__tests__/totp-backup.test.ts b/src/__tests__/totp-backup.test.ts new file mode 100644 index 0000000..fd0c648 --- /dev/null +++ b/src/__tests__/totp-backup.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import crypto from "crypto"; +import bcrypt from "bcryptjs"; +import * as OTPAuthLib from "otpauth"; +import { buildApp, extractCookie } from "./helpers"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import { encrypt } from "../utils/encryption"; + +let app: Awaited>; + +// Fixture prefix so cleanup never touches real data. +const N = "totp_backup_test_"; +const TEST_USERNAME = `${N}user`; + +// Plaintext backup codes seeded for the fixture user (the enable route stores +// bcrypt hashes of 8-char uppercase hex codes — mirror that format). +const BACKUP_CODE_1 = "AAAA1111"; +const BACKUP_CODE_2 = "BBBB2222"; + +let testUserId: number; + +/** Pull the raw Set-Cookie header line for a given cookie name (with attrs). */ +function rawSetCookie(res: { headers: Record }, name: string) { + const cookies = res.headers["set-cookie"]; + if (!cookies) return undefined; + const arr = Array.isArray(cookies) ? cookies : [cookies]; + return arr.find((c) => typeof c === "string" && c.startsWith(`${name}=`)) as + | string + | undefined; +} + +/** Create a pending TOTP login token the same way /login does (sha256 hash). */ +async function issueLoginToken(userId: number): Promise { + const raw = crypto.randomBytes(32).toString("hex"); + const hash = crypto.createHash("sha256").update(raw).digest("hex"); + await prisma.totp_login_tokens.create({ + data: { + user_id: userId, + token_hash: hash, + expires_at: new Date(Date.now() + 5 * 60_000), + }, + }); + return raw; +} + +// /totp/backup-verify carries its own per-IP rate limit +// (config.rateLimit.loginTotp, default 5/min) and this file legitimately makes +// more attempts than that — give each request a distinct source IP. +let ipCounter = 0; +function postBackupVerify(payload: Record) { + ipCounter += 1; + return app.inject({ + method: "POST", + url: "/api/admin/totp/backup-verify", + payload, + remoteAddress: `10.99.0.${ipCounter}`, + }); +} + +async function fixtureUser() { + const user = await prisma.users.findUniqueOrThrow({ + where: { id: testUserId }, + }); + return user; +} + +beforeAll(async () => { + app = await buildApp(); + + // Clean up any leftover fixture from a previous failed run. + const leftovers = await prisma.users.findMany({ + where: { username: { startsWith: N } }, + select: { id: true }, + }); + if (leftovers.length) { + const ids = leftovers.map((u) => u.id); + await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } }); + await prisma.totp_login_tokens.deleteMany({ + where: { user_id: { in: ids } }, + }); + await prisma.users.deleteMany({ where: { username: { startsWith: N } } }); + } + + const role = await prisma.roles.findFirst(); + if (!role) throw new Error("Test setup: no roles found — seed the database"); + + const hashedCodes = [ + await bcrypt.hash(BACKUP_CODE_1, config.security.bcryptCost), + await bcrypt.hash(BACKUP_CODE_2, config.security.bcryptCost), + ]; + + const user = await prisma.users.create({ + data: { + username: TEST_USERNAME, + email: `${N}user@test.local`, + password_hash: await bcrypt.hash( + "irrelevant", + config.security.bcryptCost, + ), + first_name: "TotpBackup", + last_name: "Test", + is_active: true, + totp_enabled: true, + totp_secret: encrypt(new OTPAuthLib.Secret().base32), + totp_backup_codes: JSON.stringify(hashedCodes), + role_id: role.id, + }, + }); + testUserId = user.id; +}); + +afterAll(async () => { + await prisma.refresh_tokens + .deleteMany({ where: { user_id: testUserId } }) + .catch(() => {}); + await prisma.totp_login_tokens + .deleteMany({ where: { user_id: testUserId } }) + .catch(() => {}); + await prisma.users + .deleteMany({ where: { username: { startsWith: N } } }) + .catch(() => {}); + await app.close(); +}); + +describe("POST /api/admin/totp/backup-verify", () => { + it("returns 400 for missing fields", async () => { + const res = await postBackupVerify({}); + expect(res.statusCode).toBe(400); + expect(res.json().success).toBe(false); + }); + + it("returns 401 for an invalid login token", async () => { + const res = await postBackupVerify({ + login_token: "not-a-real-token", + backup_code: BACKUP_CODE_1, + }); + expect(res.statusCode).toBe(401); + expect(res.json().success).toBe(false); + }); + + it("rejects a wrong backup code without consuming the stored codes", async () => { + const loginToken = await issueLoginToken(testUserId); + const res = await postBackupVerify({ + login_token: loginToken, + backup_code: "ZZZZ9999", + }); + expect(res.statusCode).toBe(401); + expect(res.json().success).toBe(false); + + // Both backup codes must still be stored — nothing consumed. + const user = await fixtureUser(); + expect(JSON.parse(user.totp_backup_codes as string)).toHaveLength(2); + + // Reset the failed-attempt counter so this test can't lock the fixture + // user for later tests (max_login_attempts comes from system settings). + await prisma.users.update({ + where: { id: testUserId }, + data: { failed_login_attempts: 0 }, + }); + await prisma.totp_login_tokens.deleteMany({ + where: { user_id: testUserId }, + }); + }); + + it("issues tokens for a valid login token + backup code and consumes both (single-use)", async () => { + const loginToken = await issueLoginToken(testUserId); + const res = await postBackupVerify({ + login_token: loginToken, + backup_code: BACKUP_CODE_1, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(typeof body.data.access_token).toBe("string"); + expect(body.data.access_token.length).toBeGreaterThan(0); + expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry); + expect(body.data.user.userId).toBe(testUserId); + + // Same login completion as the TOTP path: httpOnly refresh cookie set. + const cookie = extractCookie(res, "refresh_token"); + expect(cookie).toBeTruthy(); + const raw = rawSetCookie(res, "refresh_token")!; + expect(raw).toMatch(/HttpOnly/i); + expect(raw).toMatch(/SameSite=Strict/i); + + // The used backup code must be removed (single-use). + const user = await fixtureUser(); + const remaining: string[] = JSON.parse(user.totp_backup_codes as string); + expect(remaining).toHaveLength(1); + + // The login token must be consumed: replaying it (even with the second, + // still-valid backup code) is rejected. + const replay = await postBackupVerify({ + login_token: loginToken, + backup_code: BACKUP_CODE_2, + }); + expect(replay.statusCode).toBe(401); + }); + + it("rejects reuse of an already-consumed backup code", async () => { + const loginToken = await issueLoginToken(testUserId); + const res = await postBackupVerify({ + login_token: loginToken, + backup_code: BACKUP_CODE_1, + }); + expect(res.statusCode).toBe(401); + expect(res.json().success).toBe(false); + + await prisma.users.update({ + where: { id: testUserId }, + data: { failed_login_attempts: 0 }, + }); + await prisma.totp_login_tokens.deleteMany({ + where: { user_id: testUserId }, + }); + }); + + it("honors remember_me with a long-lived refresh token (parity with /login/totp)", async () => { + const loginToken = await issueLoginToken(testUserId); + const res = await postBackupVerify({ + login_token: loginToken, + backup_code: BACKUP_CODE_2, + remember_me: true, + }); + expect(res.statusCode).toBe(200); + + const raw = rawSetCookie(res, "refresh_token")!; + expect(raw).toMatch( + new RegExp(`Max-Age=${config.jwt.refreshTokenRememberExpiry}`, "i"), + ); + + const refreshRow = await prisma.refresh_tokens.findFirst({ + where: { user_id: testUserId }, + orderBy: { id: "desc" }, + }); + expect(refreshRow?.remember_me).toBe(true); + }); +}); diff --git a/src/__tests__/trips.test.ts b/src/__tests__/trips.test.ts new file mode 100644 index 0000000..652d39c --- /dev/null +++ b/src/__tests__/trips.test.ts @@ -0,0 +1,541 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import Fastify from "fastify"; +import cookie from "@fastify/cookie"; +import rateLimit from "@fastify/rate-limit"; +import jwt from "jsonwebtoken"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import tripsRoutes from "../routes/admin/trips"; + +// --------------------------------------------------------------------------- +// Route-level tests for the trips domain: +// +// 1. PUT /trips/:id with vehicle_id actually moves the trip to the new vehicle +// (UpdateTripSchema previously had no vehicle_id, so the edit modal's +// vehicle change was silently dropped while the UI reported success) and +// recomputes actual_km on BOTH vehicles. +// 2. GET /trips/stats aggregates count/total/business/private km over the +// WHOLE filtered set (not one 25-row page), honors the month filter in +// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes +// non-managers to their own trips exactly like the list does. +// --------------------------------------------------------------------------- + +/** Note-prefix for test-created rows so cleanup never touches real data. */ +const N = "trips_test_"; + +let app: Awaited>; +let adminUserId: number; +let adminToken: string; +let scopeUserId: number; +let scopeRoleId: number; +let scopeToken: string; +let vehicleAId: number; +let vehicleBId: number; + +async function buildApp() { + const a = Fastify({ logger: false }); + await a.register(cookie); + await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + a.addHook("onRequest", securityHeaders); + await a.register(tripsRoutes, { prefix: "/api/admin/trips" }); + return a; +} + +/** + * Generate a JWT access token. `requireAuth` re-loads auth data from the DB + * via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here. + */ +function generateToken(user: { + id: number; + username: string; + roleName: string | null; +}): string { + return jwt.sign( + { sub: user.id, username: user.username, role: user.roleName }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +} + +async function authGet(path: string, token: string) { + return app.inject({ + method: "GET", + url: path, + headers: { Authorization: `Bearer ${token}` }, + }); +} + +async function authPut(path: string, token: string, body: unknown) { + return app.inject({ + method: "PUT", + url: path, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + payload: body as object, + }); +} + +function tripRow(params: { + userId: number; + vehicleId: number; + date: string; + startKm: number; + dist: number; + isBusiness: boolean; +}) { + return { + user_id: params.userId, + vehicle_id: params.vehicleId, + trip_date: new Date(params.date), + start_km: params.startKm, + end_km: params.startKm + params.dist, + route_from: "Praha", + route_to: "Brno", + is_business: params.isBusiness, + notes: `${N}fixture`, + }; +} + +async function cleanupFixtureTrips() { + await prisma.trips.deleteMany({ where: { notes: { contains: N } } }); +} + +beforeAll(async () => { + app = await buildApp(); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + include: { roles: true }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminUserId = admin.id; + adminToken = generateToken({ + id: admin.id, + username: admin.username, + roleName: admin.roles?.name ?? null, + }); + + // Dedicated non-admin user with trips.record + trips.history (but NOT + // trips.manage) for the own-trips scoping tests. + const stamp = Date.now().toString(36); + const role = await prisma.roles.create({ + data: { + name: `${N}role_${stamp}`, + display_name: "Trips Scope Test", + }, + }); + scopeRoleId = role.id; + const perms = await prisma.permissions.findMany({ + where: { name: { in: ["trips.record", "trips.history"] } }, + select: { id: true }, + }); + if (perms.length !== 2) + throw new Error( + "Test setup: trips.record/trips.history permissions missing", + ); + await prisma.role_permissions.createMany({ + data: perms.map((p) => ({ role_id: role.id, permission_id: p.id })), + }); + const scopeUser = await prisma.users.create({ + data: { + username: `${N}user_${stamp}`, + first_name: "Trips", + last_name: "Scope", + email: `${N}${stamp}@test.local`, + password_hash: + "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali", + role_id: role.id, + is_active: true, + }, + }); + scopeUserId = scopeUser.id; + if (scopeUserId === adminUserId) + throw new Error("scope user must be distinct from admin"); + scopeToken = generateToken({ + id: scopeUser.id, + username: scopeUser.username, + roleName: role.name, + }); + + // Two dedicated vehicles (spz is unique, VarChar(20)). + const vehicleA = await prisma.vehicles.create({ + data: { spz: `TTA-${stamp}`, name: "Trips Test A", initial_km: 500 }, + }); + const vehicleB = await prisma.vehicles.create({ + data: { spz: `TTB-${stamp}`, name: "Trips Test B", initial_km: 200 }, + }); + vehicleAId = vehicleA.id; + vehicleBId = vehicleB.id; + + await cleanupFixtureTrips(); +}); + +beforeEach(async () => { + await cleanupFixtureTrips(); +}); + +afterAll(async () => { + await cleanupFixtureTrips(); + await prisma.vehicles + .deleteMany({ where: { id: { in: [vehicleAId, vehicleBId] } } }) + .catch(() => {}); + if (scopeUserId) + await prisma.users + .deleteMany({ where: { id: scopeUserId } }) + .catch(() => {}); + if (scopeRoleId) + await prisma.roles + .deleteMany({ where: { id: scopeRoleId } }) + .catch(() => {}); + if (app) await app.close(); +}); + +describe("PUT /api/admin/trips/:id — vehicle_id", () => { + it("changes the trip's vehicle and recomputes actual_km on both vehicles", async () => { + const trip = await prisma.trips.create({ + data: tripRow({ + userId: adminUserId, + vehicleId: vehicleAId, + date: "2098-04-10", + startKm: 1000, + dist: 500, + isBusiness: true, + }), + }); + + // The edit form sends the id as a string (Select value) — intIdFromForm + // must coerce it. + const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, { + vehicle_id: String(vehicleBId), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + + const updated = await prisma.trips.findUnique({ where: { id: trip.id } }); + expect(updated!.vehicle_id).toBe(vehicleBId); + + // New vehicle picks up the trip's odometer reading… + const vehB = await prisma.vehicles.findUnique({ + where: { id: vehicleBId }, + }); + expect(vehB!.actual_km).toBe(1500); + // …and the old vehicle falls back to initial_km (no trips left on it). + const vehA = await prisma.vehicles.findUnique({ + where: { id: vehicleAId }, + }); + expect(vehA!.actual_km).toBe(500); + }); + + it("recomputes actual_km when end_km changes and the vehicle stays the same", async () => { + // Two trips on vehicle A — the recompute must take MAX(end_km) over ALL + // of the vehicle's trips, not just the edited one. + const trip1 = await prisma.trips.create({ + data: tripRow({ + userId: adminUserId, + vehicleId: vehicleAId, + date: "2098-09-10", + startKm: 1000, + dist: 500, // end_km 1500 + isBusiness: true, + }), + }); + const trip2 = await prisma.trips.create({ + data: tripRow({ + userId: adminUserId, + vehicleId: vehicleAId, + date: "2098-09-12", + startKm: 1800, + dist: 200, // end_km 2000 + isBusiness: true, + }), + }); + + // Raising the MAX trip's end_km (vehicle UNCHANGED) moves the odometer up… + const raise = await authPut(`/api/admin/trips/${trip2.id}`, adminToken, { + end_km: 2200, + }); + expect(raise.statusCode).toBe(200); + let vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } }); + expect(vehA!.actual_km).toBe(2200); + + // …while editing the non-MAX trip recomputes but keeps MAX(end_km). + const lower = await authPut(`/api/admin/trips/${trip1.id}`, adminToken, { + end_km: 1600, + }); + expect(lower.statusCode).toBe(200); + const updated = await prisma.trips.findUnique({ where: { id: trip1.id } }); + expect(updated!.end_km).toBe(1600); + expect(updated!.vehicle_id).toBe(vehicleAId); + vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } }); + expect(vehA!.actual_km).toBe(2200); + }); + + it("writes an audit row with oldValues/newValues on update", async () => { + const trip = await prisma.trips.create({ + data: tripRow({ + userId: adminUserId, + vehicleId: vehicleAId, + date: "2098-10-10", + startKm: 100, + dist: 50, // end_km 150 + isBusiness: true, + }), + }); + + const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, { + end_km: 180, + route_to: "Ostrava", + }); + expect(res.statusCode).toBe(200); + + const audit = await prisma.audit_logs.findFirst({ + where: { action: "update", entity_type: "trip", entity_id: trip.id }, + orderBy: { id: "desc" }, + }); + expect(audit).not.toBeNull(); + expect(audit!.user_id).toBe(adminUserId); + + // oldValues = the full pre-update row, newValues = the changed fields. + expect(audit!.old_values).toBeTruthy(); + expect(audit!.new_values).toBeTruthy(); + const oldValues = JSON.parse(audit!.old_values!); + const newValues = JSON.parse(audit!.new_values!); + expect(oldValues.end_km).toBe(150); + expect(oldValues.route_to).toBe("Brno"); + expect(oldValues.vehicle_id).toBe(vehicleAId); + expect(newValues).toEqual({ end_km: 180, route_to: "Ostrava" }); + }); + + it("rejects an invalid vehicle_id with 400", async () => { + const trip = await prisma.trips.create({ + data: tripRow({ + userId: adminUserId, + vehicleId: vehicleAId, + date: "2098-04-11", + startKm: 100, + dist: 50, + isBusiness: true, + }), + }); + + const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, { + vehicle_id: "abc", + }); + expect(res.statusCode).toBe(400); + + const unchanged = await prisma.trips.findUnique({ where: { id: trip.id } }); + expect(unchanged!.vehicle_id).toBe(vehicleAId); + }); +}); + +describe("GET /api/admin/trips/stats", () => { + it("aggregates over the WHOLE filtered set, beyond one 25-row page", async () => { + // 20 business trips of 10 km + 10 private trips of 7 km in 2098-05. + const rows = []; + for (let i = 0; i < 20; i++) { + rows.push( + tripRow({ + userId: adminUserId, + vehicleId: vehicleAId, + date: "2098-05-10", + startKm: 1000 + i * 10, + dist: 10, + isBusiness: true, + }), + ); + } + for (let i = 0; i < 10; i++) { + rows.push( + tripRow({ + userId: adminUserId, + vehicleId: vehicleBId, + date: "2098-05-15", + startKm: 2000 + i * 7, + dist: 7, + isBusiness: false, + }), + ); + } + await prisma.trips.createMany({ data: rows }); + + // The list truncates to the default 25-row page but reports the real total… + const listRes = await authGet( + "/api/admin/trips?month=5&year=2098", + adminToken, + ); + expect(listRes.statusCode).toBe(200); + const listBody = listRes.json(); + expect(listBody.data).toHaveLength(25); + expect(listBody.pagination.total).toBe(30); + expect(listBody.pagination.total_pages).toBe(2); + + // …while the stats endpoint covers all 30 rows. + const res = await authGet( + "/api/admin/trips/stats?month=5&year=2098", + adminToken, + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.data).toEqual({ + count: 30, + total_km: 20 * 10 + 10 * 7, + business_km: 200, + private_km: 70, + }); + }); + + it("honors the month filter in both wire formats", async () => { + await prisma.trips.createMany({ + data: [ + tripRow({ + userId: adminUserId, + vehicleId: vehicleAId, + date: "2098-06-10", + startKm: 100, + dist: 10, + isBusiness: true, + }), + tripRow({ + userId: adminUserId, + vehicleId: vehicleAId, + date: "2098-06-12", + startKm: 110, + dist: 20, + isBusiness: false, + }), + tripRow({ + userId: adminUserId, + vehicleId: vehicleAId, + date: "2098-07-05", + startKm: 130, + dist: 40, + isBusiness: true, + }), + ], + }); + + // Combined "YYYY-MM" format (TripsHistory) + const june = await authGet( + "/api/admin/trips/stats?month=2098-06", + adminToken, + ); + expect(june.statusCode).toBe(200); + expect(june.json().data).toEqual({ + count: 2, + total_km: 30, + business_km: 10, + private_km: 20, + }); + + // Split month+year format (TripsAdmin) + const july = await authGet( + "/api/admin/trips/stats?month=7&year=2098", + adminToken, + ); + expect(july.statusCode).toBe(200); + expect(july.json().data).toEqual({ + count: 1, + total_km: 40, + business_km: 40, + private_km: 0, + }); + }); + + it("scopes non-managers to their own trips (user_id param ignored)", async () => { + await prisma.trips.createMany({ + data: [ + // 2 trips for the scope user, 3 for the admin — same month. + tripRow({ + userId: scopeUserId, + vehicleId: vehicleAId, + date: "2098-08-05", + startKm: 100, + dist: 10, + isBusiness: true, + }), + tripRow({ + userId: scopeUserId, + vehicleId: vehicleAId, + date: "2098-08-06", + startKm: 110, + dist: 15, + isBusiness: false, + }), + tripRow({ + userId: adminUserId, + vehicleId: vehicleBId, + date: "2098-08-07", + startKm: 200, + dist: 100, + isBusiness: true, + }), + tripRow({ + userId: adminUserId, + vehicleId: vehicleBId, + date: "2098-08-08", + startKm: 300, + dist: 100, + isBusiness: true, + }), + tripRow({ + userId: adminUserId, + vehicleId: vehicleBId, + date: "2098-08-09", + startKm: 400, + dist: 100, + isBusiness: true, + }), + ], + }); + + // Non-manager: only own trips, even when explicitly requesting another user. + const own = await authGet( + "/api/admin/trips/stats?month=8&year=2098", + scopeToken, + ); + expect(own.statusCode).toBe(200); + expect(own.json().data).toEqual({ + count: 2, + total_km: 25, + business_km: 10, + private_km: 15, + }); + + const spoofed = await authGet( + `/api/admin/trips/stats?month=8&year=2098&user_id=${adminUserId}`, + scopeToken, + ); + expect(spoofed.statusCode).toBe(200); + expect(spoofed.json().data.count).toBe(2); + + // The list applies the exact same scoping. + const list = await authGet( + "/api/admin/trips?month=8&year=2098", + scopeToken, + ); + expect(list.statusCode).toBe(200); + expect(list.json().data).toHaveLength(2); + + // Manager (admin) sees everything in the month. + const all = await authGet( + "/api/admin/trips/stats?month=8&year=2098", + adminToken, + ); + expect(all.statusCode).toBe(200); + expect(all.json().data.count).toBe(5); + expect(all.json().data.total_km).toBe(325); + + // Manager can filter to a single user. + const filtered = await authGet( + `/api/admin/trips/stats?month=8&year=2098&user_id=${scopeUserId}`, + adminToken, + ); + expect(filtered.statusCode).toBe(200); + expect(filtered.json().data.count).toBe(2); + }); +}); diff --git a/src/__tests__/warehouse.test.ts b/src/__tests__/warehouse.test.ts index 2f058f6..619732d 100644 --- a/src/__tests__/warehouse.test.ts +++ b/src/__tests__/warehouse.test.ts @@ -1,4 +1,13 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + afterEach, + vi, +} from "vitest"; import Fastify from "fastify"; import cookie from "@fastify/cookie"; import rateLimit from "@fastify/rate-limit"; @@ -6,6 +15,7 @@ import jwt from "jsonwebtoken"; import prisma from "../config/database"; import { config } from "../config/env"; import warehouseRoutes from "../routes/admin/warehouse"; +import { nasInvoicesManager } from "../services/nas-financials-manager"; import { securityHeaders } from "../middleware/security"; // --------------------------------------------------------------------------- @@ -879,6 +889,177 @@ describe("Receipt flow", () => { }); }); +// --------------------------------------------------------------------------- +// 5b. Receipt attachment download +// --------------------------------------------------------------------------- + +describe("Receipt attachment download", () => { + let receiptId: number; + let otherReceiptId: number; + let attachmentId: number; + let diacriticAttachmentId: number; + const fileBytes = Buffer.from("%PDF-1.4 fake attachment bytes", "utf8"); + const filePath = "Přijaté/2026/06/wh_test_priloha.pdf"; + const diacriticFileName = "příloha č. 1.pdf"; + + beforeAll(async () => { + // Item + two receipts (attachment belongs to the first one only) + const itemRes = await app.inject({ + method: "POST", + url: "/api/admin/warehouse/items", + headers: authHeader(adminToken), + payload: { name: `${N}item_att_download`, unit: "ks" }, + }); + const itemId = itemRes.json().data.id; + + const receiptRes = await app.inject({ + method: "POST", + url: "/api/admin/warehouse/receipts", + headers: authHeader(adminToken), + payload: { + notes: `${N}att_download_a`, + items: [{ item_id: itemId, quantity: 1, unit_price: 10 }], + }, + }); + receiptId = receiptRes.json().data.id; + + const otherReceiptRes = await app.inject({ + method: "POST", + url: "/api/admin/warehouse/receipts", + headers: authHeader(adminToken), + payload: { + notes: `${N}att_download_b`, + items: [{ item_id: itemId, quantity: 1, unit_price: 10 }], + }, + }); + otherReceiptId = otherReceiptRes.json().data.id; + + // Attachment row created directly — the upload endpoint needs a writable + // NAS, which is not available in the test environment. The NAS read is + // stubbed per-test on the singleton (same temp-dir/stub seam as the + // nas-document-manager tests); the DB rows and route logic stay real. + const attachment = await prisma.sklad_receipt_attachments.create({ + data: { + receipt_id: receiptId, + file_name: "wh_test_priloha.pdf", + file_mime: "application/pdf", + file_size: fileBytes.length, + file_path: filePath, + }, + }); + attachmentId = attachment.id; + + // Diacritic filename — the norm for a Czech company. Node throws on + // header values with chars > U+00FF, so this exercises the RFC 5987 path. + const diacriticAttachment = await prisma.sklad_receipt_attachments.create({ + data: { + receipt_id: receiptId, + file_name: diacriticFileName, + file_mime: "application/pdf", + file_size: fileBytes.length, + file_path: "Přijaté/2026/06/wh_test_priloha_diakritika.pdf", + }, + }); + diacriticAttachmentId = diacriticAttachment.id; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("downloads an existing attachment with correct content-type and bytes", async () => { + const readSpy = vi + .spyOn(nasInvoicesManager, "readReceived") + .mockReturnValue({ data: fileBytes, fileName: "wh_test_priloha.pdf" }); + + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`, + headers: authHeader(adminToken), + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/pdf"); + expect(res.headers["content-disposition"]).toBe( + `attachment; filename="wh_test_priloha.pdf"; filename*=UTF-8''wh_test_priloha.pdf`, + ); + expect(res.rawPayload.equals(fileBytes)).toBe(true); + expect(readSpy).toHaveBeenCalledWith(filePath); + }); + + it("downloads an attachment with a Czech diacritic filename (RFC 5987)", async () => { + vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue({ + data: fileBytes, + fileName: diacriticFileName, + }); + + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${diacriticAttachmentId}`, + headers: authHeader(adminToken), + }); + + expect(res.statusCode).toBe(200); + const disposition = res.headers["content-disposition"]; + // The real UTF-8 name travels percent-encoded in filename* (RFC 5987) … + expect(disposition).toContain( + `filename*=UTF-8''${encodeURIComponent(diacriticFileName)}`, + ); + // … alongside an ASCII-only fallback, so the header never carries + // chars > U+00FF (which Node's setHeader rejects → 500). + expect(disposition).toBe( + `attachment; filename="p__loha _. 1.pdf"; filename*=UTF-8''p%C5%99%C3%ADloha%20%C4%8D.%201.pdf`, + ); + expect(res.rawPayload.equals(fileBytes)).toBe(true); + }); + + it("returns 404 when the attachment belongs to a different receipt", async () => { + const readSpy = vi.spyOn(nasInvoicesManager, "readReceived"); + + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/receipts/${otherReceiptId}/attachments/${attachmentId}`, + headers: authHeader(adminToken), + }); + + expect(res.statusCode).toBe(404); + expect(res.json().success).toBe(false); + // Ownership is rejected before any NAS access + expect(readSpy).not.toHaveBeenCalled(); + }); + + it("returns 404 for a nonexistent attachment id", async () => { + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/receipts/${receiptId}/attachments/99999999`, + headers: authHeader(adminToken), + }); + expect(res.statusCode).toBe(404); + expect(res.json().success).toBe(false); + }); + + it("returns 404 when the file is missing on the NAS", async () => { + vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue(null); + + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`, + headers: authHeader(adminToken), + }); + expect(res.statusCode).toBe(404); + expect(res.json().success).toBe(false); + }); + + it("rejects download without warehouse.view permission (403)", async () => { + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`, + headers: authHeader(noPermToken), + }); + expect(res.statusCode).toBe(403); + }); +}); + // --------------------------------------------------------------------------- // 6. Issue flow // --------------------------------------------------------------------------- diff --git a/src/admin/components/dashboard/DashProfile.tsx b/src/admin/components/dashboard/DashProfile.tsx index 445ab62..75b315a 100644 --- a/src/admin/components/dashboard/DashProfile.tsx +++ b/src/admin/components/dashboard/DashProfile.tsx @@ -1,6 +1,7 @@ import { useState, useRef } from "react"; import { motion, useReducedMotion } from "framer-motion"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import QRCode from "qrcode"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; @@ -134,6 +135,28 @@ export default function DashProfile({ // locks scroll like the kit dialogs. useDialogScrollLock(show2FASetup); + // Generate the enrollment QR LOCALLY from the otpauth URI. Never send the + // URI to an external QR service: the production CSP (img-src) blocks it and + // it would leak the TOTP secret to a third party. A data: URL is allowed by + // the CSP. Derived via useQuery (not an effect) per repo conventions. + const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({ + queryKey: ["totp", "qr", totpQrUri], + enabled: !!totpQrUri, + staleTime: Infinity, + // The URI embeds the TOTP secret — drop it from the cache as soon as the + // enrollment UI unmounts instead of keeping it for the default 5-min GC. + gcTime: 0, + retry: false, + queryFn: async () => { + try { + return await QRCode.toDataURL(totpQrUri!, { width: 200, margin: 2 }); + } catch (err) { + console.error("DashProfile: generování QR kódu selhalo", err); + throw err; + } + }, + }); + const [showModal, setShowModal] = useState(false); const [formData, setFormData] = useState({ username: "", @@ -549,11 +572,11 @@ export default function DashProfile({ Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.) - {totpQrUri && ( + {totpQrUri && totpQrDataUrl && ( )} + {totpQrUri && totpQrFailed && ( + + QR kód se nepodařilo vygenerovat. Zadejte prosím klíč do + aplikace ručně. + + )} {totpSecret && ( => { // Deduplicate concurrent refresh calls — token rotation means only one call can succeed @@ -260,17 +260,32 @@ export function AuthProvider({ children }: { children: ReactNode }) { ) => { setError(null); try { - const response = await fetch(`${API_BASE}/login/totp`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - login_token: loginToken, - totp_code: code, - remember_me: remember, - isBackup, - }), - }); + // Backup codes have their own endpoint + schema (8-char codes would + // fail /login/totp's strict 6-digit TotpVerifySchema). Both endpoints + // complete the same login flow: tokens issued + refresh cookie set. + const response = await fetch( + isBackup + ? `${API_BASE}/totp/backup-verify` + : `${API_BASE}/login/totp`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify( + isBackup + ? { + login_token: loginToken, + backup_code: code, + remember_me: remember, + } + : { + login_token: loginToken, + totp_code: code, + remember_me: remember, + }, + ), + }, + ); const data = await response.json(); if (data.success) { setAccessTokenFn(data.data.access_token, data.data.expires_in); diff --git a/src/admin/hooks/useAttendanceAdmin.ts b/src/admin/hooks/useAttendanceAdmin.ts index abe716c..7b06912 100644 --- a/src/admin/hooks/useAttendanceAdmin.ts +++ b/src/admin/hooks/useAttendanceAdmin.ts @@ -6,6 +6,7 @@ import { calcProjectMinutesTotal, calcFormWorkMinutes, calculateWorkMinutes, + combineDatetime, getDatePart, getTimePart, formatDate, @@ -133,9 +134,6 @@ interface DeleteConfirmState { const API_BASE = "/api/admin"; -const combineDatetime = (date: string, time: string): string | null => - date && time ? `${date}T${time}:00` : null; - /** * Compute per-user totals from raw attendance records. * This replaces the server-side `user_totals` that the PHP backend returned. diff --git a/src/admin/lib/queries/settings.ts b/src/admin/lib/queries/settings.ts index 76e11e3..dcde74f 100644 --- a/src/admin/lib/queries/settings.ts +++ b/src/admin/lib/queries/settings.ts @@ -86,3 +86,13 @@ export const require2FAOptions = () => queryFn: () => jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"), }); + +// Per-user 2FA enrollment status (users.totp_enabled) — NOT the company-wide +// require_2fa policy above. Mutations that enable/disable 2FA must invalidate +// the broad ["totp"] domain key. +export const totpStatusOptions = () => + queryOptions({ + queryKey: ["totp", "status"], + queryFn: () => + jsonQuery<{ totp_enabled: boolean }>("/api/admin/totp/status"), + }); diff --git a/src/admin/lib/queries/trips.ts b/src/admin/lib/queries/trips.ts index 2a4fd1e..587944b 100644 --- a/src/admin/lib/queries/trips.ts +++ b/src/admin/lib/queries/trips.ts @@ -1,5 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; -import { jsonQuery } from "../apiAdapter"; +import { jsonQuery, paginatedJsonQuery } from "../apiAdapter"; export interface TripVehicle { id: number; @@ -59,7 +59,9 @@ export const tripListOptions = (filters: { if (filters.page) params.set("page", String(filters.page)); if (filters.perPage) params.set("per_page", String(filters.perPage)); const qs = params.toString(); - return jsonQuery(`/api/admin/trips${qs ? `?${qs}` : ""}`); + return paginatedJsonQuery( + `/api/admin/trips${qs ? `?${qs}` : ""}`, + ); }, }); @@ -81,6 +83,8 @@ export const tripHistoryOptions = (filters: { month?: string; vehicleId?: number; userId?: number; + page?: number; + perPage?: number; }) => queryOptions({ queryKey: [ @@ -90,6 +94,8 @@ export const tripHistoryOptions = (filters: { month: filters.month, vehicleId: filters.vehicleId, userId: filters.userId, + page: filters.page, + perPage: filters.perPage, }, ], queryFn: () => { @@ -98,7 +104,53 @@ export const tripHistoryOptions = (filters: { if (filters.vehicleId) params.set("vehicle_id", String(filters.vehicleId)); if (filters.userId) params.set("user_id", String(filters.userId)); + if (filters.page) params.set("page", String(filters.page)); + if (filters.perPage) params.set("per_page", String(filters.perPage)); const qs = params.toString(); - return jsonQuery(`/api/admin/trips${qs ? `?${qs}` : ""}`); + return paginatedJsonQuery( + `/api/admin/trips${qs ? `?${qs}` : ""}`, + ); + }, + }); + +export interface TripStats { + count: number; + total_km: number; + business_km: number; + private_km: number; +} + +// Count/km totals over the WHOLE filtered set (not one page) — hits +// /trips/stats with the same filters the lists use. `month` accepts either a +// numeric month (paired with `year`, TripsAdmin style) or a combined +// "YYYY-MM" string (TripsHistory style); the server supports both. +export const tripStatsOptions = (filters: { + month?: number | string; + year?: number; + vehicleId?: number; + userId?: number; +}) => + queryOptions({ + queryKey: [ + "trips", + "stats", + { + month: filters.month, + year: filters.year, + vehicleId: filters.vehicleId, + userId: filters.userId, + }, + ], + queryFn: () => { + const params = new URLSearchParams(); + if (filters.month) params.set("month", String(filters.month)); + if (filters.year) params.set("year", String(filters.year)); + if (filters.vehicleId) + params.set("vehicle_id", String(filters.vehicleId)); + if (filters.userId) params.set("user_id", String(filters.userId)); + const qs = params.toString(); + return jsonQuery( + `/api/admin/trips/stats${qs ? `?${qs}` : ""}`, + ); }, }); diff --git a/src/admin/pages/AttendanceCreate.tsx b/src/admin/pages/AttendanceCreate.tsx index d965591..217c966 100644 --- a/src/admin/pages/AttendanceCreate.tsx +++ b/src/admin/pages/AttendanceCreate.tsx @@ -10,6 +10,7 @@ import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import { useApiMutation } from "../lib/queries/mutations"; import { todayLocalStr } from "../utils/formatters"; +import { combineDatetime } from "../utils/attendanceHelpers"; import { Button, Card, @@ -41,6 +42,24 @@ interface CreateForm { notes: string; } +/** + * Wire payload for POST /attendance (CreateAttendanceSchema). The raw form + * above is NOT sent directly: its separate date+time inputs are combined into + * "YYYY-MM-DDTHH:MM:00" datetimes (same semantics as useAttendanceAdmin's + * create modal), and unset optional fields are null — never "". + */ +interface CreatePayload { + user_id: number; + shift_date: string; + leave_type: string; + notes: string | null; + leave_hours?: number; + arrival_time?: string | null; + departure_time?: string | null; + break_start?: string | null; + break_end?: string | null; +} + export default function AttendanceCreate() { const alert = useAlert(); const { hasPermission } = useAuth(); @@ -52,7 +71,7 @@ export default function AttendanceCreate() { })); const [submitting, setSubmitting] = useState(false); - const createMutation = useApiMutation({ + const createMutation = useApiMutation({ url: () => `${API_BASE}/attendance`, method: () => "POST", invalidate: ["attendance", "users"], @@ -88,7 +107,36 @@ export default function AttendanceCreate() { setSubmitting(true); try { - const result = await createMutation.mutateAsync(form); + const isLeave = form.leave_type !== "work"; + const payload: CreatePayload = { + user_id: Number(form.user_id), + shift_date: form.shift_date, + leave_type: form.leave_type, + notes: form.notes || null, + }; + + if (isLeave) { + payload.leave_hours = form.leave_hours || 8; + } else { + payload.arrival_time = combineDatetime( + form.arrival_date, + form.arrival_time, + ); + payload.departure_time = combineDatetime( + form.departure_date, + form.departure_time, + ); + payload.break_start = combineDatetime( + form.break_start_date, + form.break_start_time, + ); + payload.break_end = combineDatetime( + form.break_end_date, + form.break_end_time, + ); + } + + const result = await createMutation.mutateAsync(payload); alert.success(result?.message || "Uloženo"); navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`); } catch (e) { diff --git a/src/admin/pages/Dashboard.tsx b/src/admin/pages/Dashboard.tsx index 2f95e8a..b8d5e2e 100644 --- a/src/admin/pages/Dashboard.tsx +++ b/src/admin/pages/Dashboard.tsx @@ -8,7 +8,7 @@ import { useAuth } from "../context/AuthContext"; import { useAlert } from "../context/AlertContext"; import apiFetch from "../utils/api"; import { dashboardOptions } from "../lib/queries/dashboard"; -import { require2FAOptions } from "../lib/queries/settings"; +import { totpStatusOptions } from "../lib/queries/settings"; import { getCzechDate } from "../utils/dashboardHelpers"; import { useApiMutation } from "../lib/queries/mutations"; import { Card, Button, StatusChip, PageEnter } from "../ui"; @@ -86,9 +86,11 @@ export default function Dashboard() { const { data: dashDataRaw, isPending: dashLoading } = useQuery(dashboardOptions()); const dashData = dashDataRaw as DashData | undefined; + // Personal 2FA enrollment (users.totp_enabled) — NOT the company-wide + // require_2fa policy flag (the banner below uses user.require2FA for that). const { data: totpData, isPending: totpLoading } = - useQuery(require2FAOptions()); - const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled; + useQuery(totpStatusOptions()); + const totpEnabled = totpData?.totp_enabled ?? !!user?.totpEnabled; const punchMutation = useApiMutation< Record, @@ -176,6 +178,7 @@ export default function Dashboard() { }); const data = await response.json(); if (data.success) { + queryClient.invalidateQueries({ queryKey: ["totp"] }); queryClient.invalidateQueries({ queryKey: ["settings"] }); queryClient.invalidateQueries({ queryKey: ["dashboard"] }); queryClient.invalidateQueries({ queryKey: ["users"] }); @@ -206,6 +209,7 @@ export default function Dashboard() { }); const data = await response.json(); if (data.success) { + queryClient.invalidateQueries({ queryKey: ["totp"] }); queryClient.invalidateQueries({ queryKey: ["settings"] }); queryClient.invalidateQueries({ queryKey: ["dashboard"] }); queryClient.invalidateQueries({ queryKey: ["users"] }); diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index ff94429..1393e48 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -51,7 +51,12 @@ import { invoiceDetailOptions } from "../lib/queries/invoices"; import { offerCustomersOptions } from "../lib/queries/offers"; import { bankAccountsOptions } from "../lib/queries/common"; import { jsonQuery } from "../lib/apiAdapter"; -import { formatCurrency, formatDate, todayLocalStr } from "../utils/formatters"; +import { + formatCurrency, + formatDate, + numberOr, + todayLocalStr, +} from "../utils/formatters"; import { normalizeDateStr } from "../utils/attendanceHelpers"; import { Button, @@ -686,7 +691,7 @@ export default function InvoiceDetail() { tax_date: normalizeDateStr(inv.tax_date), currency: inv.currency || "CZK", apply_vat: Number(inv.apply_vat) ? 1 : 0, - vat_rate: Number(inv.vat_rate) || 21, + vat_rate: numberOr(inv.vat_rate, 21), payment_method: inv.payment_method || "Příkazem", constant_symbol: inv.constant_symbol || "0308", issued_by: inv.issued_by || "", @@ -725,7 +730,11 @@ export default function InvoiceDetail() { quantity: Number(item.quantity) || 1, unit: item.unit || "", unit_price: Number(item.unit_price) || 0, - vat_rate: Number(inv.vat_rate) || 21, + // Per-line VAT: hydrate from the ITEM's own stored rate (a real DB + // column returned by the detail endpoint) — falling back to the + // invoice-level rate only when the line carries none. Hydrating + // from the invoice rate corrupted mixed-rate invoices on re-save. + vat_rate: numberOr(item.vat_rate, numberOr(inv.vat_rate, 21)), })) : []; if (mappedItems.length > 0) { @@ -781,8 +790,10 @@ export default function InvoiceDetail() { // Pre-fill from order if (fromOrderId && orderDataQuery.data) { const order = orderDataQuery.data; - const vatRate = - Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21); + const vatRate = numberOr( + order.vat_rate, + companySettings?.default_vat_rate ?? 21, + ); setForm((prev) => ({ ...prev, customer_id: order.customer_id as number, diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index e1ea61e..d0c9b6e 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -44,7 +44,7 @@ import { jsonQuery } from "../lib/apiAdapter"; import { offerCustomersOptions, type Customer } from "../lib/queries/offers"; import { issuedOrderDetailOptions } from "../lib/queries/issued-orders"; import { companySettingsOptions } from "../lib/queries/settings"; -import { formatCurrency, todayLocalStr } from "../utils/formatters"; +import { formatCurrency, numberOr, todayLocalStr } from "../utils/formatters"; import { normalizeDateStr } from "../utils/attendanceHelpers"; import { Button, @@ -585,7 +585,7 @@ export default function IssuedOrderDetail() { customer_name: d.customer_name ?? "", currency: d.currency || "CZK", apply_vat: d.apply_vat !== false, - vat_rate: Number(d.vat_rate) || 21, + vat_rate: numberOr(d.vat_rate, 21), order_date: normalizeDateStr(d.order_date), delivery_date: normalizeDateStr(d.delivery_date), language: d.language || "cs", @@ -605,10 +605,10 @@ export default function IssuedOrderDetail() { id: it.id, description: it.description || "", item_description: it.item_description || "", - quantity: Number(it.quantity) || 1, + quantity: numberOr(it.quantity, 1), unit: it.unit || "", unit_price: Number(it.unit_price) || 0, - vat_rate: Number(it.vat_rate) || Number(d.vat_rate) || 21, + vat_rate: numberOr(it.vat_rate, numberOr(d.vat_rate, 21)), })) : []; if (mapped.length > 0) setItems(mapped); @@ -831,6 +831,12 @@ export default function IssuedOrderDetail() { try { await statusMutation.mutateAsync({ status: newStatus }); alert.success("Stav byl změněn"); + // Mirror the new status into the form (same as handleSubmit's success + // path): the one-shot hydration won't re-run (dataReady stays true), so + // without this the StatusChip, the `editable` flag and the delete-button + // gate would keep reading the stale status until a full remount. The PO + // number is repopulated by the detail-query sync effect. + setForm((prev) => ({ ...prev, status: newStatus })); } catch (err) { alert.error(err instanceof Error ? err.message : "Chyba připojení"); } finally { diff --git a/src/admin/pages/Projects.tsx b/src/admin/pages/Projects.tsx index e0c53bd..c7f9ce7 100644 --- a/src/admin/pages/Projects.tsx +++ b/src/admin/pages/Projects.tsx @@ -172,7 +172,13 @@ export default function Projects() { enabled: showCreate, }); - const createMutation = useApiMutation({ + const createMutation = useApiMutation< + Omit & { + start_date: string | null; + end_date: string | null; + }, + { id: number } + >({ url: () => `${API_BASE}/projects`, method: () => "POST", invalidate: ["projects", "orders", "offers", "invoices", "attendance"], @@ -204,7 +210,13 @@ export default function Projects() { setCreating(true); try { - await createMutation.mutateAsync(createForm); + // Server's isoDateString.nullish() accepts null but rejects "" — + // send empty date fields as null so the create doesn't 400. + await createMutation.mutateAsync({ + ...createForm, + start_date: createForm.start_date || null, + end_date: createForm.end_date || null, + }); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { diff --git a/src/admin/pages/Trips.tsx b/src/admin/pages/Trips.tsx index b1667c3..cb283e4 100644 --- a/src/admin/pages/Trips.tsx +++ b/src/admin/pages/Trips.tsx @@ -12,9 +12,11 @@ import { formatKm, todayLocalStr } from "../utils/formatters"; import apiFetch from "../utils/api"; import { tripListOptions, + tripStatsOptions, tripVehiclesOptions, type BackendTrip, } from "../lib/queries/trips"; +import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { useApiMutation } from "../lib/queries/mutations"; import { Button, @@ -158,12 +160,20 @@ export default function Trips() { const alert = useAlert(); const { hasPermission } = useAuth(); - const { data: tripsData, isPending: tripsLoading } = useQuery( - tripListOptions({}), - ); - const { data: vehiclesData } = useQuery(tripVehiclesOptions()); + // "Poslední jízdy" widget — only the 10 newest rows are shown, so fetch + // exactly those; the stat cards come from the server-side stats endpoint + // (computed over the whole filtered set, not one page). + const { items: trips, isPending: tripsLoading } = + usePaginatedQuery(tripListOptions({ perPage: 10 })); - const trips = tripsData ?? []; + // Stat cards are a personal "this month" summary — the header subtitle + // shows the current month, so the stats query is scoped to match it. + const now = new Date(); + const { data: stats } = useQuery( + tripStatsOptions({ month: now.getMonth() + 1, year: now.getFullYear() }), + ); + + const { data: vehiclesData } = useQuery(tripVehiclesOptions()); const vehicles = vehiclesData ?? []; const [showModal, setShowModal] = useState(false); @@ -321,19 +331,14 @@ export default function Trips() { return ; } - const totals = trips.reduce( - (acc, t) => { - const dist = t.distance ?? t.end_km - t.start_km; - acc.count++; - acc.total += dist; - if (t.is_business) acc.business += dist; - else acc.private += dist; - return acc; - }, - { total: 0, business: 0, private: 0, count: 0 }, - ); + const totals = { + count: stats?.count ?? 0, + total: stats?.total_km ?? 0, + business: stats?.business_km ?? 0, + private: stats?.private_km ?? 0, + }; - const recentTrips = trips.slice(0, 10); + const recentTrips = trips; const columns: DataColumn[] = [ { diff --git a/src/admin/pages/TripsAdmin.tsx b/src/admin/pages/TripsAdmin.tsx index 68ab05c..eb088c4 100644 --- a/src/admin/pages/TripsAdmin.tsx +++ b/src/admin/pages/TripsAdmin.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link as RouterLink } from "react-router-dom"; import Box from "@mui/material/Box"; @@ -9,11 +9,15 @@ import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import { tripListOptions, + tripStatsOptions, tripVehiclesOptions, tripUsersOptions, type BackendTrip, + type TripStats, } from "../lib/queries/trips"; import { companySettingsOptions } from "../lib/queries/settings"; +import { jsonQuery } from "../lib/apiAdapter"; +import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { formatDate } from "../utils/attendanceHelpers"; import { formatKm } from "../utils/formatters"; import { useApiMutation } from "../lib/queries/mutations"; @@ -28,6 +32,7 @@ import { Select, DateField, MonthField, + Pagination, StatusChip, FilterBar, LoadingState, @@ -83,6 +88,190 @@ function mapTrip(bt: BackendTrip): Trip { }; } +/** + * Escape user-origin strings before interpolating them into the print HTML + * (the JSX hidden-div approach escaped automatically; the string template + * must do it explicitly). + */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const PRINT_STYLES = ` + * { margin: 0; padding: 0; box-sizing: border-box; } + body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-size: 10px; + line-height: 1.4; + color: #000; + background: #fff; + padding: 10mm; + } + .print-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 2px solid #333; + } + .print-header-left { display: flex; align-items: center; gap: 12px; } + .print-logo { height: 40px; width: auto; } + .print-header-text { text-align: left; } + .print-header-right { text-align: right; } + .print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; } + .print-header .company { font-size: 11px; color: #666; } + .print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; } + .print-header .filters { font-size: 10px; color: #666; } + .print-header .generated { font-size: 9px; color: #888; margin-top: 5px; } + .summary { + display: flex; + justify-content: space-around; + margin-bottom: 15px; + padding: 10px; + background: #f5f5f5; + border: 1px solid #ddd; + } + .summary-item { text-align: center; } + .summary-value { font-size: 14px; font-weight: 700; } + .summary-label { font-size: 9px; color: #666; } + table { width: 100%; border-collapse: collapse; margin-bottom: 15px; } + th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; } + th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; } + td { font-size: 9px; } + tr:nth-child(even) { background: #f9f9f9; } + .text-center { text-align: center; } + .text-right { text-align: right; } + tfoot td { background: #eee; font-weight: 600; } + .badge { + display: inline-block; + padding: 1px 4px; + border-radius: 2px; + font-size: 8px; + font-weight: 500; + } + .badge-success { background: #dcfce7; color: #16a34a; } + .badge-warning { background: #fef3c7; color: #d97706; } + @media print { + body { padding: 5mm; } + @page { size: A4 landscape; margin: 5mm; } + thead { display: table-header-group; } + } +`; + +/** + * Build the full print document from the fetched /trips/print data set — + * no hidden-div/innerHTML round-trip, so there is nothing to race against + * React's commit. + */ +function buildPrintHtml(opts: { + trips: Trip[]; + totals: TripStats; + periodName: string; + companyName: string; + vehicleName: string | null; + userName: string | null; + logoUrl: string; +}): string { + const { trips, totals } = opts; + + const rows = trips + .map( + (trip) => ` + + ${formatDate(trip.trip_date)} + ${escapeHtml(trip.driver_name)} + ${escapeHtml(trip.spz)} + ${escapeHtml(trip.route_from)} → ${escapeHtml(trip.route_to)} + ${formatKm(trip.start_km)} - ${formatKm(trip.end_km)} + ${formatKm(trip.distance)} km + + + ${trip.is_business ? "Služební" : "Soukromá"} + + + ${escapeHtml(trip.notes || "")} + `, + ) + .join(""); + + return ` + + + + + + Kniha jízd - ${escapeHtml(opts.periodName)} + + + + + + + + KNIHA JÍZD + ${escapeHtml(opts.companyName)} + + + + ${escapeHtml(opts.periodName)} + ${opts.vehicleName ? `Vozidlo: ${escapeHtml(opts.vehicleName)}` : ""} + ${opts.userName ? `Řidič: ${escapeHtml(opts.userName)}` : ""} + Vygenerováno: ${new Date().toLocaleString("cs-CZ")} + + + + + + ${totals.count} + Počet jízd + + + ${formatKm(totals.total_km)} km + Celkem + + + ${formatKm(totals.business_km)} km + Služební + + + ${formatKm(totals.private_km)} km + Soukromé + + + + + + + Datum + Řidič + Vozidlo + Trasa + Stav km + Vzdálenost + Typ + Poznámka + + + ${rows} + + + Celkem: + ${formatKm(totals.total_km)} km + + + + + + + `; +} + const PrintIcon = ( (null); + const [page, setPage] = useState(1); const [showEditModal, setShowEditModal] = useState(false); const [editingTrip, setEditingTrip] = useState(null); @@ -217,16 +406,27 @@ export default function TripsAdmin() { const { data: companySettings } = useQuery(companySettingsOptions()); const companyName = companySettings?.company_name ?? ""; - const { data: tripsData, isPending } = useQuery( - tripListOptions({ - month: Number(filterPeriod.slice(5, 7)) || undefined, - year: Number(filterPeriod.slice(0, 4)) || undefined, - vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined, - userId: filterUserId ? Number(filterUserId) : undefined, - perPage: 100, - }), - ); - const trips = (tripsData ?? []).map(mapTrip); + // ONE shared filter object for the list, stats AND print requests so the + // three can never drift apart. An empty MonthField value yields + // month/year = undefined (omitted from the request), mirroring the + // `Number(...) || undefined` guard the queries always used. + const filters = { + month: Number(filterPeriod.slice(5, 7)) || undefined, + year: Number(filterPeriod.slice(0, 4)) || undefined, + vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined, + userId: filterUserId ? Number(filterUserId) : undefined, + }; + + const { + items: tripsItems, + pagination, + isPending, + } = usePaginatedQuery(tripListOptions({ ...filters, page })); + const trips = tripsItems.map(mapTrip); + + // Stat cards: totals over the WHOLE filtered set (server-side aggregate), + // not just the visible page. + const { data: stats } = useQuery(tripStatsOptions(filters)); // useApiMutation JSON.stringifies the whole TIn as the request body, so // TIn must match the backend schema (UpdateTripSchema) shape directly — @@ -318,10 +518,12 @@ export default function TripsAdmin() { }; const getPeriodName = () => - new Date( - Number(filterPeriod.slice(0, 4)), - Number(filterPeriod.slice(5, 7)) - 1, - ).toLocaleString("cs-CZ", { month: "long", year: "numeric" }); + filterPeriod + ? new Date( + Number(filterPeriod.slice(0, 4)), + Number(filterPeriod.slice(5, 7)) - 1, + ).toLocaleString("cs-CZ", { month: "long", year: "numeric" }) + : "Všechna období"; const getSelectedVehicleName = () => { if (!filterVehicleId) return null; const v = vehicles.find((v) => String(v.id) === filterVehicleId); @@ -333,94 +535,61 @@ export default function TripsAdmin() { return u?.name || null; }; - const handlePrint = () => { - const periodName = getPeriodName(); + const handlePrint = async () => { + // Open the print window SYNCHRONOUSLY, inside the click's transient + // activation — calling window.open after the network await can fall + // outside the activation window and get popup-blocked (which previously + // failed silently). + const printWindow = window.open("", "_blank"); + if (!printWindow) { + alert.error("Tisk byl zablokován prohlížečem"); + return; + } - setTimeout(() => { - if (printRef.current) { - const content = printRef.current.innerHTML; - const printWindow = window.open("", "_blank"); - if (!printWindow) return; - printWindow.document.write(` - - - - - - Kniha jízd - ${periodName} - - - - ${content} - - - `); - printWindow.document.close(); - printWindow.onload = () => { - printWindow.print(); - }; - } - }, 100); + // Fetch the FULL filtered set for the printout — the list query is + // paginated, so printing from it would truncate to the visible page. + let printTrips: Trip[]; + let printTotals: TripStats; + try { + const params = new URLSearchParams(); + if (filters.month) params.set("month", String(filters.month)); + if (filters.year) params.set("year", String(filters.year)); + if (filters.vehicleId) + params.set("vehicle_id", String(filters.vehicleId)); + if (filters.userId) params.set("user_id", String(filters.userId)); + const data = await jsonQuery<{ trips: BackendTrip[]; totals: TripStats }>( + `${API_BASE}/trips/print?${params.toString()}`, + ); + printTrips = data.trips.map(mapTrip); + printTotals = data.totals; + } catch (e) { + printWindow.close(); + console.error("Trip print data fetch failed:", e); + alert.error(e instanceof Error ? e.message : "Chyba připojení"); + return; + } + + // The user closed the popup while the data was loading — a deliberate + // cancel, not an error. + if (printWindow.closed) return; + + // The document is built directly from the fetched data (no hidden-div / + // setTimeout round-trip), so it can be neither unmounted nor stale. + printWindow.document.write( + buildPrintHtml({ + trips: printTrips, + totals: printTotals, + periodName: getPeriodName(), + companyName, + vehicleName: getSelectedVehicleName(), + userName: getSelectedUserName(), + logoUrl: `${window.location.origin}/api/admin/company-settings/logo?variant=light`, + }), + ); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.print(); + }; }; const calculateDistance = (): number => { @@ -430,11 +599,9 @@ export default function TripsAdmin() { }; const totals = { - count: trips.length, - total: trips.reduce((sum, t) => sum + t.distance, 0), - business: trips - .filter((t) => Number(t.is_business)) - .reduce((sum, t) => sum + t.distance, 0), + count: stats?.count ?? 0, + total: stats?.total_km ?? 0, + business: stats?.business_km ?? 0, }; const columns: DataColumn[] = [ @@ -541,7 +708,7 @@ export default function TripsAdmin() { > Správa knihy jízd - {trips.length > 0 && ( + {(pagination?.total ?? 0) > 0 && ( - + { + setFilterPeriod(val); + setPage(1); + }} + /> { + setFilterVehicleId(value); + setPage(1); + }} options={[ { value: "", label: "Všechna vozidla" }, ...vehicles.map((v) => ({ @@ -584,7 +760,10 @@ export default function TripsAdmin() { { + setFilterUserId(value); + setPage(1); + }} options={[ { value: "", label: "Všichni řidiči" }, ...tripUsers.map((u) => ({ @@ -632,14 +811,21 @@ export default function TripsAdmin() { {isPending ? ( ) : ( - - columns={columns} - rows={trips} - rowKey={(trip) => trip.id} - empty={ - - } - /> + <> + + columns={columns} + rows={trips} + rowKey={(trip) => trip.id} + empty={ + + } + /> + + > )} @@ -796,120 +982,6 @@ export default function TripsAdmin() { confirmVariant="danger" loading={deleteMutation.isPending} /> - - {/* Hidden Print Content */} - {trips.length > 0 && ( - - - - - - KNIHA JÍZD - {companyName} - - - - {getPeriodName()} - {getSelectedVehicleName() && ( - - Vozidlo: {getSelectedVehicleName()} - - )} - {getSelectedUserName() && ( - Řidič: {getSelectedUserName()} - )} - - Vygenerováno: {new Date().toLocaleString("cs-CZ")} - - - - - - - {totals.count} - Počet jízd - - - {formatKm(totals.total)} km - Celkem - - - - {formatKm(totals.business)} km - - Služební - - - - {formatKm(totals.total - totals.business)} km - - Soukromé - - - - - - - Datum - Řidič - Vozidlo - Trasa - - Stav km - - - Vzdálenost - - - Typ - - Poznámka - - - - {trips.map((trip) => ( - - {formatDate(trip.trip_date)} - {trip.driver_name} - {trip.spz} - - {trip.route_from} → {trip.route_to} - - - {formatKm(trip.start_km)} - {formatKm(trip.end_km)} - - - {formatKm(trip.distance)} km - - - - {trip.is_business ? "Služební" : "Soukromá"} - - - {trip.notes || ""} - - ))} - - - - - Celkem: - - - {formatKm(totals.total)} km - - - - - - - )} ); } diff --git a/src/admin/pages/TripsHistory.tsx b/src/admin/pages/TripsHistory.tsx index fa0d97a..b85d8d2 100644 --- a/src/admin/pages/TripsHistory.tsx +++ b/src/admin/pages/TripsHistory.tsx @@ -5,12 +5,19 @@ import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import { formatDate } from "../utils/attendanceHelpers"; import { formatKm } from "../utils/formatters"; -import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips"; +import { + tripHistoryOptions, + tripStatsOptions, + tripVehiclesOptions, + type BackendTrip, +} from "../lib/queries/trips"; +import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { Card, DataTable, Select, MonthField, + Pagination, StatusChip, PageHeader, PageEnter, @@ -91,18 +98,35 @@ export default function TripsHistory() { return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; }); const [vehicleId, setVehicleId] = useState(""); + const [page, setPage] = useState(1); const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions()); const vehicles = vehiclesData; - const { data: tripsData, isPending } = useQuery( + const { + items: tripsData, + pagination, + isPending, + } = usePaginatedQuery( tripHistoryOptions({ month, vehicleId: vehicleId ? Number(vehicleId) : undefined, userId: user?.id, + page, + }), + ); + + // Stat cards: month totals over the WHOLE filtered set (server-side + // aggregate over the same filters), not just the visible page. + const { data: stats } = useQuery( + tripStatsOptions({ + month, + vehicleId: vehicleId ? Number(vehicleId) : undefined, + userId: user?.id, }), ); - const trips: Trip[] = (tripsData ?? []).map((t) => ({ + + const trips: Trip[] = tripsData.map((t) => ({ id: t.id, trip_date: t.trip_date, spz: t.vehicles?.spz ?? "", @@ -118,14 +142,11 @@ export default function TripsHistory() { notes: t.notes ?? undefined, })); - const totals = trips.reduce( - (acc, t) => ({ - total: acc.total + (t.distance || 0), - business: acc.business + (t.is_business ? t.distance || 0 : 0), - count: acc.count + 1, - }), - { total: 0, business: 0, count: 0 }, - ); + const totals = { + count: stats?.count ?? 0, + total: stats?.total_km ?? 0, + business: stats?.business_km ?? 0, + }; if (!hasPermission("trips.history")) return ; @@ -221,12 +242,21 @@ export default function TripsHistory() { {/* Filters */} - setMonth(val)} /> + { + setMonth(val); + setPage(1); + }} + /> setVehicleId(value)} + onChange={(value) => { + setVehicleId(value); + setPage(1); + }} options={[ { value: "", label: "Všechna vozidla" }, ...vehicles.map((v) => ({ @@ -275,14 +305,21 @@ export default function TripsHistory() { {isPending ? ( ) : ( - - columns={columns} - rows={trips} - rowKey={(trip) => trip.id} - empty={ - - } - /> + <> + + columns={columns} + rows={trips} + rowKey={(trip) => trip.id} + empty={ + + } + /> + + > )} diff --git a/src/admin/pages/WarehouseItemDetail.tsx b/src/admin/pages/WarehouseItemDetail.tsx index 65becef..1267cd5 100644 --- a/src/admin/pages/WarehouseItemDetail.tsx +++ b/src/admin/pages/WarehouseItemDetail.tsx @@ -59,6 +59,11 @@ interface ItemDetail extends WarehouseItem { const API_BASE = "/api/admin/warehouse/items"; +// Must match UnitEnum in src/schemas/warehouse.schema.ts — the server rejects +// any other value (CreateItemSchema/UpdateItemSchema), so free text here would +// make the save 400. +const UNIT_OPTIONS = ["ks", "m", "kg", "bal", "sada", "m2", "m3", "l"] as const; + interface ItemForm { item_number: string; name: string; @@ -449,15 +454,14 @@ export default function WarehouseItemDetail() { - { - updateForm("unit", e.target.value); + onChange={(v) => { + updateForm("unit", v); setErrors((prev) => ({ ...prev, unit: "" })); }} - placeholder="ks, m, kg..." - fullWidth + options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))} /> cannot send the Authorization header, so attachments are + // fetched via apiFetch and handed to the browser as a temporary blob URL. + const handleDownloadAttachment = async (att: WarehouseReceiptAttachment) => { + if (!receipt) return; + try { + const response = await apiFetch( + `/api/admin/warehouse/receipts/${receipt.id}/attachments/${att.id}`, + ); + if (!response.ok) { + const result = await response.json().catch(() => ({})); + alert.error(result.error || "Nepodařilo se stáhnout přílohu"); + return; + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = att.file_name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 60000); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba připojení"); + } + }; + const confirming = confirmMutation.isPending; const cancelling = cancelMutation.isPending; @@ -249,13 +278,14 @@ export default function WarehouseReceiptDetail() { width: "45%", bold: true, render: (att) => ( - handleDownloadAttachment(att)} + sx={{ textAlign: "left", font: "inherit" }} > {att.file_name} - + ), }, { diff --git a/src/admin/utils/attendanceHelpers.ts b/src/admin/utils/attendanceHelpers.ts index 054e235..bd23082 100644 --- a/src/admin/utils/attendanceHelpers.ts +++ b/src/admin/utils/attendanceHelpers.ts @@ -110,6 +110,16 @@ export const getTimePart = (datetime: string | null | undefined): string => { return extractTime(datetime); }; +/** + * Join a date input ("YYYY-MM-DD") and a time input ("HH:MM") into the + * combined local-datetime wire format the attendance API expects + * ("YYYY-MM-DDTHH:MM:00", validated server-side by `nullableDateTimeString` + * and parsed with `new Date(...)` as local time). Returns null when either + * part is unset — "no value" for the optional datetime fields. + */ +export const combineDatetime = (date: string, time: string): string | null => + date && time ? `${date}T${time}:00` : null; + export const calcProjectMinutesTotal = ( logs: Array<{ project_id?: number; diff --git a/src/admin/utils/formatters.ts b/src/admin/utils/formatters.ts index e8bf00b..55b2723 100644 --- a/src/admin/utils/formatters.ts +++ b/src/admin/utils/formatters.ts @@ -50,6 +50,18 @@ export function todayLocalStr(): string { return `${d.getFullYear()}-${m}-${day}`; } +/** + * Coerce a server-returned numeric (number | decimal-string | null/undefined) + * to a number, falling back ONLY when the value is missing or not numeric — + * NEVER on a legitimate 0 (e.g. a 0% VAT rate / reverse charge). The + * `Number(x) || fallback` idiom silently turns stored zeros into the fallback. + */ +export function numberOr(raw: unknown, fallback: number): number { + if (raw == null || raw === "") return fallback; + const n = Number(raw); + return Number.isFinite(n) ? n : fallback; +} + export function formatKm(km: number | string): string { return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0); } diff --git a/src/routes/admin/attendance.ts b/src/routes/admin/attendance.ts index c7cc19c..dddacf9 100644 --- a/src/routes/admin/attendance.ts +++ b/src/routes/admin/attendance.ts @@ -418,6 +418,8 @@ export default async function attendanceRoutes( departure_lng: body.departure_lng, departure_accuracy: body.departure_accuracy, departure_address: body.departure_address, + break_start: body.break_start, + break_end: body.break_end, notes: body.notes, project_id: body.project_id, leave_type: body.leave_type, diff --git a/src/routes/admin/orders-pdf.ts b/src/routes/admin/orders-pdf.ts index 2d0eb7f..d8a7ea1 100644 --- a/src/routes/admin/orders-pdf.ts +++ b/src/routes/admin/orders-pdf.ts @@ -259,6 +259,9 @@ export default async function ordersPdfRoutes( const order = await prisma.orders.findUnique({ where: { id }, + // The confirmation PDF never renders the PO attachment — don't pull + // the blob just to read the order header/items. + omit: { attachment_data: true }, include: { customers: true, order_items: { orderBy: { position: "asc" } }, diff --git a/src/routes/admin/orders.ts b/src/routes/admin/orders.ts index cce003e..86a3699 100644 --- a/src/routes/admin/orders.ts +++ b/src/routes/admin/orders.ts @@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, error, parseId, paginated } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; +import { contentDisposition } from "../../utils/content-disposition"; import { parseBody } from "../../schemas/common"; import { config } from "../../config/env"; import { @@ -110,10 +111,12 @@ export default async function ordersRoutes( const attachment = await getOrderAttachment(id); if (!attachment) return error(reply, "Příloha nenalezena", 404); - const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, ""); return reply .type("application/pdf") - .header("Content-Disposition", `inline; filename="${safeFilename}"`) + .header( + "Content-Disposition", + contentDisposition("inline", attachment.filename), + ) .send(attachment.data); }, ); diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index d3b0c44..5653db8 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -6,6 +6,7 @@ import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, error, parseId, paginated } from "../../utils/response"; +import { contentDisposition } from "../../utils/content-disposition"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; import { @@ -294,10 +295,14 @@ export default async function receivedInvoicesRoutes( if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404); const mime = invoice.file_mime || "application/pdf"; - const safeFileName = invoice.file_name.replace(/[\r\n"]/g, ""); + // "inline": the FE (ReceivedInvoices.openFile) opens this in a new + // window for viewing, not as a forced download. return reply .type(mime) - .header("Content-Disposition", `inline; filename="${safeFileName}"`) + .header( + "Content-Disposition", + contentDisposition("inline", invoice.file_name), + ) .send(nasFile.data); }, ); diff --git a/src/routes/admin/totp.ts b/src/routes/admin/totp.ts index 6ba7bee..cb0b08e 100644 --- a/src/routes/admin/totp.ts +++ b/src/routes/admin/totp.ts @@ -264,7 +264,8 @@ export default async function totpRoutes( async (request, reply) => { const parsed = parseBody(TotpBackupSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); - const { login_token, backup_code: code } = parsed.data; + const { login_token, backup_code: code, remember_me } = parsed.data; + const rememberMe = remember_me ?? false; const tokenHash = crypto .createHash("sha256") @@ -393,14 +394,18 @@ export default async function totpRoutes( .update(refreshTokenRaw) .digest("hex"); + // Mirror /login/totp: honor remember_me so a backup-code login keeps + // the same session longevity the user asked for at the password step. + const refreshExpiresIn = rememberMe + ? config.jwt.refreshTokenRememberExpiry + : config.jwt.refreshTokenSessionExpiry; + await prisma.refresh_tokens.create({ data: { user_id: user.id, token_hash: refreshTokenHash, - expires_at: new Date( - Date.now() + config.jwt.refreshTokenSessionExpiry * 1000, - ), - remember_me: false, + expires_at: new Date(Date.now() + refreshExpiresIn * 1000), + remember_me: rememberMe, ip_address: request.ip, user_agent: request.headers["user-agent"] ?? null, }, @@ -411,7 +416,7 @@ export default async function totpRoutes( secure: config.isProduction, sameSite: "strict", path: "/api/admin", - maxAge: config.jwt.refreshTokenSessionExpiry, + maxAge: refreshExpiresIn, }); await logAudit({ diff --git a/src/routes/admin/trips.ts b/src/routes/admin/trips.ts index 21aad3e..43857c8 100644 --- a/src/routes/admin/trips.ts +++ b/src/routes/admin/trips.ts @@ -6,6 +6,99 @@ import { success, paginated, error, parseId } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema"; +import { AuthData } from "../../types"; + +/** + * Shared filter/scoping logic for the trip list AND the stats endpoint — the + * stats MUST honor exactly the same filters as the list so the cards always + * describe the rows the list is paging through. + * + * Scoping: admins / trips.manage holders see all trips (optionally filtered + * by user_id); everyone else is hard-scoped to their own trips. + */ +function buildTripsWhere( + query: Record, + authData: AuthData, +): Record { + // Admin role bypasses permission checks entirely (requireAnyPermission + // lets it through with an empty permissions list), so treat the admin + // role as a manager for scoping purposes too. + const isManager = + authData.roleName === "admin" || + authData.permissions.includes("trips.manage"); + + const where: Record = {}; + if (!isManager) where.user_id = authData.userId; + else if (query.user_id) { + // Non-numeric filter values are ignored (like an invalid month below) — + // NaN in a Prisma where clause would throw and surface as a 500. + const uid = Number(query.user_id); + if (Number.isFinite(uid)) where.user_id = uid; + } + if (query.vehicle_id) { + const vid = Number(query.vehicle_id); + if (Number.isFinite(vid)) where.vehicle_id = vid; + } + + // Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats + if (query.month) { + const monthStr = String(query.month); + let yr: number, mo: number; + if (monthStr.includes("-")) { + // Combined YYYY-MM format + const [yStr, mStr] = monthStr.split("-"); + yr = Number(yStr); + mo = Number(mStr); + } else if (query.year) { + yr = Number(query.year); + mo = Number(query.month); + } else { + yr = NaN; + mo = NaN; + } + if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) { + // Use explicit date strings to avoid toJSON timezone shift + const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`; + const nextMonth = + mo === 12 + ? `${yr + 1}-01-01` + : `${yr}-${String(mo + 1).padStart(2, "0")}-01`; + where.trip_date = { + gte: new Date(monthStart), + lt: new Date(nextMonth), + }; + } + } + + return where; +} + +/** + * Recompute a vehicle's actual_km as MAX(end_km) over its trips, falling + * back to initial_km when the vehicle has no trips. Shared by the POST, + * PUT and DELETE handlers so the odometer logic can't drift. + */ +async function recomputeVehicleActualKm(vehicleId: number): Promise { + const [maxTrip, vehicle] = await Promise.all([ + prisma.trips.findFirst({ + where: { vehicle_id: vehicleId }, + orderBy: { end_km: "desc" }, + select: { end_km: true }, + }), + prisma.vehicles.findUnique({ + where: { id: vehicleId }, + select: { initial_km: true }, + }), + ]); + await prisma.vehicles.update({ + where: { id: vehicleId }, + data: { + actual_km: maxTrip + ? Number(maxTrip.end_km) + : Number(vehicle?.initial_km ?? 0), + }, + }); +} export default async function tripsRoutes( fastify: FastifyInstance, @@ -22,55 +115,16 @@ export default async function tripsRoutes( async (request, reply) => { const query = request.query as Record; const { page, limit, skip, order } = parsePagination(query); - const authData = request.authData!; - // Admin role bypasses permission checks entirely (requireAnyPermission - // lets it through with an empty permissions list), so treat the admin - // role as a manager for scoping purposes too. - const isAdmin = - authData.roleName === "admin" || - authData.permissions.includes("trips.manage"); - - const where: Record = {}; - if (!isAdmin) where.user_id = authData.userId; - else if (query.user_id) where.user_id = Number(query.user_id); - if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id); - - // Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats - if (query.month) { - const monthStr = String(query.month); - let yr: number, mo: number; - if (monthStr.includes("-")) { - // Combined YYYY-MM format - const [yStr, mStr] = monthStr.split("-"); - yr = Number(yStr); - mo = Number(mStr); - } else if (query.year) { - yr = Number(query.year); - mo = Number(query.month); - } else { - yr = NaN; - mo = NaN; - } - if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) { - // Use explicit date strings to avoid toJSON timezone shift - const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`; - const nextMonth = - mo === 12 - ? `${yr + 1}-01-01` - : `${yr}-${String(mo + 1).padStart(2, "0")}-01`; - where.trip_date = { - gte: new Date(monthStart), - lt: new Date(nextMonth), - }; - } - } + const where = buildTripsWhere(query, request.authData!); const [trips, total] = await Promise.all([ prisma.trips.findMany({ where, skip, take: limit, - orderBy: { trip_date: order }, + // trip_date is date-only — same-day rows need the id tiebreak for + // deterministic pagination. + orderBy: [{ trip_date: order }, { id: order }], include: { users: { select: { id: true, first_name: true, last_name: true } }, vehicles: { select: { id: true, name: true, spz: true } }, @@ -83,6 +137,60 @@ export default async function tripsRoutes( }, ); + // GET /api/admin/trips/stats — count/km totals over the WHOLE filtered set + // (not one page). Honors exactly the same filters + user scoping as the + // list (month/year, vehicle_id, user_id for managers) so the stat cards + // can't drift from the rows the list is paging through. + fastify.get( + "/stats", + { + preHandler: requireAnyPermission( + "trips.manage", + "trips.record", + "trips.history", + ), + }, + async (request, reply) => { + const query = request.query as Record; + const where = buildTripsWhere(query, request.authData!); + + // Legacy PHP-era rows can carry a stored `distance` that differs from + // end_km - start_km, and every row renderer prefers it + // (`distance ?? end_km - start_km`) — the totals must apply the same + // coalesce or the stat cards would disagree with the visible rows. + // That rules out a plain groupBy aggregate. + const rows = await prisma.trips.findMany({ + where, + select: { + distance: true, + start_km: true, + end_km: true, + is_business: true, + }, + }); + + let count = 0; + let businessKm = 0; + let privateKm = 0; + for (const t of rows) { + const km = + t.distance != null + ? Number(t.distance) + : Number(t.end_km) - Number(t.start_km); + count += 1; + if (t.is_business) businessKm += km; + else privateKm += km; + } + + return success(reply, { + count, + total_km: businessKm + privateKm, + business_km: businessKm, + private_km: privateKm, + }); + }, + ); + // GET /api/admin/trips/users — users with trips.record permission fastify.get( "/users", @@ -121,28 +229,11 @@ export default async function tripsRoutes( { preHandler: requirePermission("trips.manage") }, async (request, reply) => { const query = request.query as Record; - const filterUserId = query.user_id ? Number(query.user_id) : null; - const filterVehicleId = query.vehicle_id - ? Number(query.vehicle_id) - : null; - - const where: Record = {}; - if (filterUserId) where.user_id = filterUserId; - if (filterVehicleId) where.vehicle_id = filterVehicleId; - if (query.month && query.year) { - // Use explicit date strings to avoid toJSON timezone shift - const yr = Number(query.year); - const mo = Number(query.month); - const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`; - const nextMonth = - mo === 12 - ? `${yr + 1}-01-01` - : `${yr}-${String(mo + 1).padStart(2, "0")}-01`; - where.trip_date = { - gte: new Date(monthStart), - lt: new Date(nextMonth), - }; - } + // Same shared filter source as the list + stats endpoints (incl. the + // month 1-12 validation). /print is manager-only (trips.manage guard), + // so buildTripsWhere always takes its manager branch here — user_id / + // vehicle_id / month filters behave exactly as before. + const where = buildTripsWhere(query, request.authData!); const trips = await prisma.trips.findMany({ where, @@ -150,7 +241,8 @@ export default async function tripsRoutes( users: { select: { id: true, first_name: true, last_name: true } }, vehicles: { select: { id: true, name: true, spz: true } }, }, - orderBy: { trip_date: "asc" }, + // trip_date is date-only — id tiebreak keeps same-day rows stable + orderBy: [{ trip_date: "asc" }, { id: "asc" }], }); const vehicles = await prisma.vehicles.findMany({ @@ -166,7 +258,13 @@ export default async function tripsRoutes( let businessKm = 0; let privateKm = 0; for (const t of trips) { - const dist = Number(t.end_km) - Number(t.start_km); + // Same coalesce as the row renderers and /stats: legacy rows may + // store a `distance` differing from end_km - start_km, and the print + // footer must match the sum of the printed rows. + const dist = + t.distance != null + ? Number(t.distance) + : Number(t.end_km) - Number(t.start_km); totalKm += dist; if (t.is_business) businessKm += dist; else privateKm += dist; @@ -251,21 +349,13 @@ export default async function tripsRoutes( }, }); - // Recompute actual_km from MAX(end_km) across all trips (same as - // PUT/DELETE). Setting it unconditionally to this trip's end_km would - // regress the odometer when a back-dated trip with a lower end_km is - // entered after a later trip with a higher reading. - const maxTrip = await prisma.trips.findFirst({ - where: { vehicle_id: vehicleId }, - orderBy: { end_km: "desc" }, - select: { end_km: true }, - }); - if (maxTrip) { - await prisma.vehicles.update({ - where: { id: vehicleId }, - data: { actual_km: Number(maxTrip.end_km) }, - }); - } + // Recompute actual_km from MAX(end_km) across all trips (same helper + // as PUT/DELETE). Setting it unconditionally to this trip's end_km + // would regress the odometer when a back-dated trip with a lower + // end_km is entered after a later trip with a higher reading. (The + // helper's initial_km fallback is unreachable here — the just-created + // trip guarantees at least one row.) + await recomputeVehicleActualKm(vehicleId); await logAudit({ request, @@ -312,6 +402,19 @@ export default async function tripsRoutes( } const data: Record = {}; + if (body.vehicle_id !== undefined) { + const targetVehicleId = Number(body.vehicle_id); + if (targetVehicleId !== existing.vehicle_id) { + // Schema only guarantees a positive integer — without this check a + // nonexistent id would hit the FK as P2003 and surface as a 500. + const vehicleExists = await prisma.vehicles.findUnique({ + where: { id: targetVehicleId }, + select: { id: true }, + }); + if (!vehicleExists) return error(reply, "Vozidlo nenalezeno", 400); + } + data.vehicle_id = targetVehicleId; + } if (body.trip_date !== undefined) data.trip_date = new Date(String(body.trip_date)); if (body.start_km !== undefined) data.start_km = Number(body.start_km); @@ -325,20 +428,19 @@ export default async function tripsRoutes( await prisma.trips.update({ where: { id }, data }); - // Update vehicle actual_km if end_km changed - if (body.end_km !== undefined) { - const vehicleId = existing.vehicle_id; - const maxTrip = await prisma.trips.findFirst({ - where: { vehicle_id: vehicleId }, - orderBy: { end_km: "desc" }, - select: { end_km: true }, - }); - if (maxTrip) { - await prisma.vehicles.update({ - where: { id: vehicleId }, - data: { actual_km: Number(maxTrip.end_km) }, - }); - } + // Recompute odometers when the km reading or the vehicle changed: the + // trip's (possibly new) vehicle, and — when the trip moved between + // vehicles — the old one too, which may have lost its MAX(end_km) row. + const newVehicleId = + body.vehicle_id !== undefined + ? Number(body.vehicle_id) + : existing.vehicle_id; + const vehicleChanged = newVehicleId !== existing.vehicle_id; + if (body.end_km !== undefined || vehicleChanged) { + await recomputeVehicleActualKm(newVehicleId); + } + if (vehicleChanged) { + await recomputeVehicleActualKm(existing.vehicle_id); } await logAudit({ @@ -348,6 +450,8 @@ export default async function tripsRoutes( entityType: "trip", entityId: id, description: `Upravena jízda`, + oldValues: existing, + newValues: data, }); return success(reply, { id }, 200, "Záznam byl aktualizován"); }, @@ -372,24 +476,9 @@ export default async function tripsRoutes( const vehicleId = existing.vehicle_id; await prisma.trips.delete({ where: { id } }); - // Recalculate vehicle actual_km after deletion - const maxTrip = await prisma.trips.findFirst({ - where: { vehicle_id: vehicleId }, - orderBy: { end_km: "desc" }, - select: { end_km: true }, - }); - const vehicle = await prisma.vehicles.findUnique({ - where: { id: vehicleId }, - select: { initial_km: true }, - }); - await prisma.vehicles.update({ - where: { id: vehicleId }, - data: { - actual_km: maxTrip - ? Number(maxTrip.end_km) - : (vehicle?.initial_km ?? 0), - }, - }); + // Recalculate vehicle actual_km after deletion (falls back to + // initial_km when this was the vehicle's last trip). + await recomputeVehicleActualKm(vehicleId); await logAudit({ request, diff --git a/src/routes/admin/warehouse.ts b/src/routes/admin/warehouse.ts index f75d47f..eab436b 100644 --- a/src/routes/admin/warehouse.ts +++ b/src/routes/admin/warehouse.ts @@ -5,6 +5,7 @@ import { config } from "../../config/env"; import { requireAuth, requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, error, parseId, paginated } from "../../utils/response"; +import { contentDisposition } from "../../utils/content-disposition"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; import { nasInvoicesManager } from "../../services/nas-financials-manager"; @@ -1294,6 +1295,41 @@ export default async function warehouseRoutes( }, ); + // GET /receipts/:id/attachments/:attachmentId — download attachment from NAS + fastify.get<{ Params: { id: string; attachmentId: string } }>( + "/receipts/:id/attachments/:attachmentId", + { preHandler: requirePermission("warehouse.view") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + const attachmentId = parseId(request.params.attachmentId, reply); + if (attachmentId === null) return; + + const attachment = await prisma.sklad_receipt_attachments.findUnique({ + where: { id: attachmentId }, + }); + // 404 (not 400) when the attachment belongs to a different receipt: + // a read endpoint should not confirm that the id exists elsewhere. + if (!attachment || attachment.receipt_id !== id) { + return error(reply, "Příloha nenalezena", 404); + } + + const nasFile = nasInvoicesManager.readReceived(attachment.file_path); + if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404); + + const mime = attachment.file_mime || "application/octet-stream"; + // "attachment": the UI always downloads via blob + a.download, never + // renders inline — and attachment is safer with client-controlled mime. + return reply + .type(mime) + .header( + "Content-Disposition", + contentDisposition("attachment", attachment.file_name), + ) + .send(nasFile.data); + }, + ); + // POST /receipts/:id/attachments — upload attachment fastify.post<{ Params: { id: string } }>( "/receipts/:id/attachments", diff --git a/src/schemas/attendance.schema.ts b/src/schemas/attendance.schema.ts index df17b13..f9014ad 100644 --- a/src/schemas/attendance.schema.ts +++ b/src/schemas/attendance.schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { intIdFromForm, + nullableDateTimeString, nullableIntIdFromForm, numberFromForm, numberInRange, @@ -66,19 +67,25 @@ export const AttendancePunchSchema = z.object({ address: z.string().max(500).nullish(), }); +// arrival/departure/break fields travel as COMBINED local datetimes +// ("YYYY-MM-DDTHH:MM:00", built by the admin forms' combineDatetime from a +// date + time input) — the service parses them with `new Date(...)`. They are +// NOT bare HH:MM times. export const CreateAttendanceSchema = z.object({ user_id: intIdFromForm.optional(), shift_date: z.string(), - arrival_time: timeString.nullish(), + arrival_time: nullableDateTimeString.optional(), arrival_lat: numberFromForm.nullish(), arrival_lng: numberFromForm.nullish(), arrival_accuracy: numberFromForm.nullish(), arrival_address: z.string().nullish(), - departure_time: timeString.nullish(), + departure_time: nullableDateTimeString.optional(), departure_lat: numberFromForm.nullish(), departure_lng: numberFromForm.nullish(), departure_accuracy: numberFromForm.nullish(), departure_address: z.string().nullish(), + break_start: nullableDateTimeString.optional(), + break_end: nullableDateTimeString.optional(), notes: z.string().nullish(), project_id: nullableIntIdFromForm.nullish(), leave_type: z @@ -90,10 +97,10 @@ export const CreateAttendanceSchema = z.object({ }); export const UpdateAttendanceSchema = z.object({ - arrival_time: timeString.nullish(), - departure_time: timeString.nullish(), - break_start: timeString.nullish(), - break_end: timeString.nullish(), + arrival_time: nullableDateTimeString.optional(), + departure_time: nullableDateTimeString.optional(), + break_start: nullableDateTimeString.optional(), + break_end: nullableDateTimeString.optional(), notes: z.string().nullish(), project_id: nullableIntIdFromForm.optional(), leave_type: z.enum(["work", "vacation", "sick", "unpaid"]).optional(), diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 195525d..44e83c4 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -23,6 +23,7 @@ export const TotpBackupSchema = z.object({ // Cap length so a request with bodyLimit=10KB can't trigger N×bcrypt on // a multi-KB string. Real backup codes are 8-16 chars; 64 is generous. .max(64, "Záložní kód je příliš dlouhý"), + remember_me: z.boolean().optional().default(false), }); export const TotpEnableSchema = z.object({ diff --git a/src/schemas/common.ts b/src/schemas/common.ts index 625621d..5620192 100644 --- a/src/schemas/common.ts +++ b/src/schemas/common.ts @@ -111,6 +111,40 @@ export const timeString = z.preprocess( .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Čas musí být ve formátu HH:MM"), ); +/** + * Combined local datetime string "YYYY-MM-DDTHH:MM" — the wire format the + * attendance forms produce by joining a date input and a time input + * (`${date}T${time}:00`). Tolerant of a trailing seconds component (":ss" — + * stripped before validating), because the client appends ":00" and an edit + * form round-trips a value the `Date.toJSON` override serialised as + * "YYYY-MM-DDTHH:MM:SS". The service parses the value with `new Date(...)` + * as LOCAL time (Europe/Prague) — no timezone suffix is part of the format. + */ +export const dateTimeString = z.preprocess( + (v) => + typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v) + ? v.slice(0, 16) + : v, + z + .string() + .regex( + /^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d$/, + "Datum a čas musí být ve formátu YYYY-MM-DD HH:MM", + ), +); + +/** + * Optional-datetime variant: ""/null mean "not set" → null (the attendance + * forms historically submitted empty strings for unfilled time fields, and a + * hard reject would 400 the whole form — including leave records that have no + * times at all). Pair with `.optional()` at the call site for fields that may + * be absent entirely (absent stays `undefined`, e.g. "don't change" on update). + */ +export const nullableDateTimeString = z.preprocess( + (v) => (v === "" || v === null ? null : v), + z.union([z.null(), dateTimeString]), +); + /** * An email field that also accepts an empty string (meaning "not set"). Many * settings/supplier forms submit "" for un-filled optional emails; a bare diff --git a/src/schemas/invoices.schema.ts b/src/schemas/invoices.schema.ts index e7d7cca..7c7275d 100644 --- a/src/schemas/invoices.schema.ts +++ b/src/schemas/invoices.schema.ts @@ -54,6 +54,7 @@ export const UpdateInvoiceSchema = z.object({ bank_iban: z.string().max(255).nullish(), bank_account: z.string().max(255).nullish(), issued_by: z.string().max(255).nullish(), + billing_text: z.string().max(8000).nullish(), customer_id: nullableIntIdFromForm.optional(), vat_rate: numberInRange(0, 100).optional(), apply_vat: booleanFromForm.optional(), diff --git a/src/schemas/trips.schema.ts b/src/schemas/trips.schema.ts index 6dc6637..ad321fa 100644 --- a/src/schemas/trips.schema.ts +++ b/src/schemas/trips.schema.ts @@ -21,6 +21,8 @@ export const CreateTripSchema = z.object({ }); export const UpdateTripSchema = z.object({ + // trips.vehicle_id is a non-nullable FK — when present it must be a valid id + vehicle_id: intIdFromForm.optional(), trip_date: isoDateString.optional(), start_km: nonNegativeNumberFromForm.optional(), end_km: nonNegativeNumberFromForm.optional(), diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index 804a708..3ddcd70 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -126,6 +126,8 @@ export interface CreateAttendanceData { departure_lng?: number | null; departure_accuracy?: number | null; departure_address?: string | null; + break_start?: string | null; + break_end?: string | null; notes?: string | null; project_id?: number | null; leave_type: string; @@ -1608,6 +1610,8 @@ export async function createAttendance( departure_lng: data.departure_lng ?? null, departure_accuracy: data.departure_accuracy ?? null, departure_address: data.departure_address ?? null, + break_start: data.break_start ? new Date(data.break_start) : null, + break_end: data.break_end ? new Date(data.break_end) : null, notes: data.notes ?? null, project_id: data.project_id ?? null, leave_type: data.leave_type as attendance_leave_type, diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index b8022f3..a43738f 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -430,6 +430,9 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) { export async function getOrderDataForInvoice(orderId: number) { const order = await prisma.orders.findUnique({ where: { id: orderId }, + // This result is spread into the order-data API response — never include + // the PO attachment blob (served only by GET /orders/:id/attachment). + omit: { attachment_data: true }, include: { customers: true, order_items: { orderBy: { position: "asc" } }, diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts index 231d86e..f73d4bb 100644 --- a/src/services/numbering.service.ts +++ b/src/services/numbering.service.ts @@ -206,8 +206,16 @@ async function releaseSequence( /** Verify a shared number is not already used by an order or project. */ export async function isSharedNumberTaken(number: string): Promise { const [existingOrder, existingProject] = await Promise.all([ - prisma.orders.findFirst({ where: { order_number: number } }), - prisma.projects.findFirst({ where: { project_number: number } }), + // select id only — the orders row carries the PO attachment blob, which an + // existence check must never pull. + prisma.orders.findFirst({ + where: { order_number: number }, + select: { id: true }, + }), + prisma.projects.findFirst({ + where: { project_number: number }, + select: { id: true }, + }), ]); return !!(existingOrder || existingProject); } @@ -238,8 +246,11 @@ export async function isOfferNumberTaken(number: string): Promise { /** Verify an order number is not already used. */ export async function isOrderNumberTaken(number: string): Promise { + // select id only — the orders row carries the PO attachment blob, which an + // existence check must never pull. const existing = await prisma.orders.findFirst({ where: { order_number: number }, + select: { id: true }, }); return !!existing; } diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index b48d2d3..1a42225 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -72,6 +72,10 @@ async function syncProjectStatus( }); } +// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, apply_vat, +// vat_rate, item quantity/unit_price/is_included_in_total). If you read a NEW +// order/item field here, add it to that select — a field missing from the +// select is silently 0 in the per-currency totals. function enrichOrder(o: any) { const subtotal = o.order_items .filter((i: any) => i.is_included_in_total !== false) @@ -158,6 +162,11 @@ export async function listOrders(params: ListOrdersParams) { take: limit, // Tiebreak on id so same-second created_at rows sort deterministically. orderBy: [{ [sortField]: order }, { id: order }], + // The PO attachment blob is served ONLY by GET /:id/attachment. Never + // pull it into list payloads — a single 1MB PDF would serialize into + // megabytes of JSON per row. attachment_name stays as the existence + // signal the frontend uses. + omit: { attachment_data: true }, include: { customers: { select: { id: true, name: true } }, order_items: { orderBy: { position: "asc" } }, @@ -193,17 +202,21 @@ export async function getOrderTotals( ): Promise<{ totals: CurrencyAmount[] }> { const where = buildOrderWhere(params); + // Select ONLY what the math needs (currency + VAT flags + item numbers). + // This runs over the WHOLE filtered set, so pulling attachment blobs, + // sections HTML or relations here would multiply the query size for nothing. const orders = await prisma.orders.findMany({ where, - include: { - customers: { select: { id: true, name: true } }, - order_items: { orderBy: { position: "asc" } }, - order_sections: { orderBy: { position: "asc" } }, - quotations: { select: { quotation_number: true, project_code: true } }, - invoices: { - select: { id: true, invoice_number: true }, - take: 1, - orderBy: { id: "desc" }, + select: { + currency: true, + apply_vat: true, + vat_rate: true, + order_items: { + select: { + quantity: true, + unit_price: true, + is_included_in_total: true, + }, }, }, }); @@ -228,6 +241,9 @@ export async function getOrderTotals( export async function getOrder(id: number) { const order = await prisma.orders.findUnique({ where: { id }, + // The blob belongs only to GET /:id/attachment — the detail payload + // carries attachment_name as the existence signal. + omit: { attachment_data: true }, include: { customers: true, order_items: { orderBy: { position: "asc" } }, @@ -567,7 +583,11 @@ interface UpdateOrderData { } export async function updateOrder(id: number, body: UpdateOrderData) { - const existing = await prisma.orders.findUnique({ where: { id } }); + // Pre-read only the fields the guards below check — never the PDF blob. + const existing = await prisma.orders.findUnique({ + where: { id }, + select: { status: true, order_number: true }, + }); if (!existing) return { error: "Objednávka nenalezena", status: 404 } as const; @@ -680,7 +700,11 @@ export async function updateOrder(id: number, body: UpdateOrderData) { } export async function deleteOrder(id: number, deleteFiles = false) { - const existing = await prisma.orders.findUnique({ where: { id } }); + // Pre-read only what the delete needs (number release + year) — not the blob. + const existing = await prisma.orders.findUnique({ + where: { id }, + select: { order_number: true, created_at: true }, + }); if (!existing) return { error: "Objednávka nenalezena", status: 404 } as const; diff --git a/src/utils/content-disposition.ts b/src/utils/content-disposition.ts new file mode 100644 index 0000000..3d8b2d6 --- /dev/null +++ b/src/utils/content-disposition.ts @@ -0,0 +1,28 @@ +/** + * Build a Content-Disposition header value that is safe for non-ASCII + * filenames (Czech diacritics are the norm here: "příloha č. 1.pdf"). + * + * Node's HTTP layer THROWS on header values containing characters above + * U+00FF, so the raw filename must never be interpolated into the header + * directly — that turns a download of a diacritic-named file into a 500. + * Per RFC 5987 / RFC 6266 the real UTF-8 name is carried in the `filename*` + * parameter (percent-encoded), while `filename` holds an ASCII-only fallback + * for legacy clients. Modern browsers prefer `filename*`. + */ +export function contentDisposition( + type: "inline" | "attachment", + fileName: string, +): string { + const asciiFallback = fileName + .replace(/[^\x20-\x7e]/g, "_") + .replace(/["\\]/g, ""); + // encodeURIComponent leaves ' ( ) * raw, but they are not RFC 5987 + // attr-chars (the apostrophe is even the ext-value delimiter) — strict + // parsers would reject the filename* and fall back to the mangled ASCII + // name. Percent-encode them like the `content-disposition` npm package. + const encoded = encodeURIComponent(fileName).replace( + /['()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); + return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`; +}