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)} + + + + + +
+
+
${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é
+
+
+ + + + + + + + + + + + + + + ${rows} + + + + + + + +
DatumŘidičVozidloTrasaStav kmVzdálenostTypPoznámka
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 && (