diff --git a/CLAUDE.md b/CLAUDE.md index b3a8e25..d515ec1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -291,15 +291,28 @@ CSP blocks it and it would leak the secret). ## AI Assistant — "Odin" -Admin-only (`ai.use` permission) Claude assistant at `/odin`. **Phase-1 scope -is invoice import ONLY** — general chat is guarded off in `OdinChat.submit` -(text-only messages get a canned reply, no API call). Backend -`src/services/ai.service.ts` + `src/routes/admin/ai.ts`; per-call cost ledger -in `ai_usage` with a monthly budget cap (402 when exceeded). Invoice flow: -PDF → `extract-invoices` (structured output) → review cards → existing -`POST /received-invoices`. **`received_invoices.amount` is GROSS -(VAT-inclusive)** — VAT is back-calculated (`vatFromGross`). Phase-2 design -notes live in `docs/superpowers/specs/`. +`ai.use`-gated Claude assistant at `/odin` (granted to admin). **Phase 2a: +read-only agentic assistant** — chat turns run an agentic loop +(`agenticChat` in `src/services/ai.service.ts`, max 6 round-trips, budget +re-checked between iterations) over the read-only tools in +`src/services/ai-tools.ts` (invoices, offers, orders, projects, customers, +warehouse, attendance). + +**Security model (do not weaken):** Odin is the **user's delegate** — the +tool list is pre-filtered by the caller's permissions AND every handler +re-checks them (`ctxCan`); there are **NO write tools** (writes come in +Phase 2b as propose→confirm action cards, never autonomous). Tool handlers +call the same service functions the routes use. Assistant turns persist +their tool trace in `ai_chat_messages.content_json` (plain `content` stays +the display text); the UI shows consulted-tool chips. The system prompt +enforces plain-text replies (the chat renders pre-wrap text, no Markdown). + +Per-call cost ledger in `ai_usage` with a monthly budget cap (402 when +exceeded). Invoice flow unchanged: PDF → `extract-invoices` (structured +output) → review cards → existing `POST /received-invoices`. +**`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is +back-calculated (`vatFromGross`). Phase-2 design notes live in +`docs/superpowers/specs/`. --- diff --git a/REVIEW.md b/REVIEW.md index 02377d6..95e1652 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -1,81 +1,102 @@ -# Codebase Audit — REVIEW.md +# Codebase Audit Checklist -**Date:** 2026-06-08 -**Scope:** entire repository (tracked source/config files via `git ls-files`) -**Method:** file-by-file review. Source of truth for progress — re-read before continuing after any compaction. +**Date:** 2026-06-09 | **Total files:** 304 | Scope: full project (source + config). Excluded: docs/*.md, prisma/migrations/*.sql (generated SQL), package-lock.json, binaries. -**Total files to review:** 277 +## (root) ---- +- [x] .env.example +- [x] .gitignore +- [x] .prettierignore +- [x] .prettierrc.json +- [x] boneyard.config.json +- [x] CLAUDE.md +- [x] eslint.config.mjs +- [x] index.html +- [x] package.json +- [x] prisma.config.ts +- [x] tsconfig.app.json +- [x] tsconfig.json +- [x] tsconfig.server.json +- [x] tsconfig.test.json +- [x] vite.config.ts +- [x] vitest.config.ts - -## .claude/hooks/ +## .claude - [x] .claude/hooks/block-env.js - [x] .claude/hooks/format-on-save.js - -## .claude/ - - [x] .claude/settings.json - [x] .claude/settings.local.json -## ./ +## prisma -- [x] .env.example -- [x] CLAUDE.md -- [x] boneyard.config.json -- [x] index.html -- [x] package.json - -## prisma/ - -- [x] prisma/schema.prisma - [x] prisma/seed.ts +- [x] prisma/schema.prisma -## scripts/ +## scripts +- [x] scripts/check-roles.mjs - [x] scripts/migrate-received-invoices-to-nas.ts - [x] scripts/rotate-totp-key.ts +- [x] scripts/seed-test-users.mjs -## src/ - -- [x] src/App.tsx - -## src/__tests__/ +## src/__tests__ - [x] src/__tests__/ai.test.ts - [x] src/__tests__/auth.service.test.ts - [x] src/__tests__/auth.test.ts +- [x] src/__tests__/bank-accounts.test.ts +- [x] src/__tests__/company-settings-numbering.test.ts - [x] src/__tests__/customers.schema.test.ts +- [x] src/__tests__/drafts-aggregation.test.ts +- [x] src/__tests__/drafts-deferred-numbering.test.ts - [x] src/__tests__/env.test.ts - [x] src/__tests__/exchange-rates.test.ts - [x] src/__tests__/helpers.ts - [x] src/__tests__/invoices.service.test.ts +- [x] src/__tests__/issued-orders.test.ts - [x] src/__tests__/manual-create.test.ts +- [x] src/__tests__/nas-document-manager.test.ts - [x] src/__tests__/nas-file-manager.test.ts - [x] src/__tests__/nas-project-folder.test.ts - [x] src/__tests__/numbering.test.ts +- [x] src/__tests__/offer-invoice-totals.test.ts +- [x] src/__tests__/orders-list.test.ts +- [x] src/__tests__/order-totals.test.ts - [x] src/__tests__/plan.test.ts - [x] src/__tests__/planAuditDescription.test.ts - [x] src/__tests__/planCategory.test.ts - [x] src/__tests__/received-invoices-vat.test.ts -- [x] src/__tests__/schema-nan.test.ts - [x] src/__tests__/setup.ts +- [x] src/__tests__/schema-nan.test.ts - [x] src/__tests__/warehouse.test.ts -## src/admin/ +## src/admin (AdminApp.tsx) - [x] src/admin/AdminApp.tsx -- [x] src/admin/GlobalStyles.tsx -## src/admin/components/ +## src/admin (components) - [x] src/admin/components/AlertContainer.tsx - [x] src/admin/components/AttendanceShiftTable.tsx - [x] src/admin/components/BulkAttendanceModal.tsx - [x] src/admin/components/BulkPlanModal.tsx +- [x] src/admin/components/dashboard/DashActivityFeed.tsx +- [x] src/admin/components/dashboard/DashAttendanceToday.tsx +- [x] src/admin/components/dashboard/DashKpiCards.tsx +- [x] src/admin/components/dashboard/DashProfile.tsx +- [x] src/admin/components/dashboard/DashQuickActions.tsx +- [x] src/admin/components/dashboard/DashSessions.tsx +- [x] src/admin/components/dashboard/DashTodayPlan.tsx - [x] src/admin/components/ErrorBoundary.tsx - [x] src/admin/components/Forbidden.tsx +- [x] src/admin/components/odin/InvoiceReviewCard.tsx +- [x] src/admin/components/odin/OdinComposer.tsx +- [x] src/admin/components/odin/OdinChat.tsx +- [x] src/admin/components/odin/OdinMark.tsx +- [x] src/admin/components/odin/OdinSidebar.tsx +- [x] src/admin/components/odin/OdinThread.tsx +- [x] src/admin/components/odin/types.ts - [x] src/admin/components/OrderConfirmationModal.tsx - [x] src/admin/components/PlanCategoriesModal.tsx - [x] src/admin/components/PlanCellModal.tsx @@ -85,38 +106,19 @@ - [x] src/admin/components/RichEditor.tsx - [x] src/admin/components/ShiftFormModal.tsx - [x] src/admin/components/ShortcutsHelp.tsx - -## src/admin/components/dashboard/ - -- [x] src/admin/components/dashboard/DashActivityFeed.tsx -- [x] src/admin/components/dashboard/DashAttendanceToday.tsx -- [x] src/admin/components/dashboard/DashKpiCards.tsx -- [x] src/admin/components/dashboard/DashProfile.tsx -- [x] src/admin/components/dashboard/DashQuickActions.tsx -- [x] src/admin/components/dashboard/DashSessions.tsx -- [x] src/admin/components/dashboard/DashTodayPlan.tsx - -## src/admin/components/odin/ - -- [x] src/admin/components/odin/InvoiceReviewCard.tsx -- [x] src/admin/components/odin/OdinChat.tsx -- [x] src/admin/components/odin/OdinComposer.tsx -- [x] src/admin/components/odin/OdinMark.tsx -- [x] src/admin/components/odin/OdinSidebar.tsx -- [x] src/admin/components/odin/OdinThread.tsx -- [x] src/admin/components/odin/types.ts - -## src/admin/components/warehouse/ - - [x] src/admin/components/warehouse/ItemPicker.tsx - [x] src/admin/components/warehouse/ReservationPicker.tsx -## src/admin/context/ +## src/admin (context) - [x] src/admin/context/AlertContext.tsx - [x] src/admin/context/AuthContext.tsx -## src/admin/hooks/ +## src/admin (GlobalStyles.tsx) + +- [x] src/admin/GlobalStyles.tsx + +## src/admin (hooks) - [x] src/admin/hooks/useAttendanceAdmin.ts - [x] src/admin/hooks/useDebounce.ts @@ -126,19 +128,18 @@ - [x] src/admin/hooks/useReducedMotion.ts - [x] src/admin/hooks/useTableSort.ts -## src/admin/lib/ +## src/admin (lib) - [x] src/admin/lib/apiAdapter.ts +- [x] src/admin/lib/documentStatus.ts - [x] src/admin/lib/entityTypeLabels.ts - -## src/admin/lib/queries/ - - [x] src/admin/lib/queries/ai.ts - [x] src/admin/lib/queries/attendance.ts - [x] src/admin/lib/queries/auditLog.ts - [x] src/admin/lib/queries/common.ts - [x] src/admin/lib/queries/dashboard.ts - [x] src/admin/lib/queries/invoices.ts +- [x] src/admin/lib/queries/issued-orders.ts - [x] src/admin/lib/queries/leave.ts - [x] src/admin/lib/queries/mutations.ts - [x] src/admin/lib/queries/offers.ts @@ -150,12 +151,9 @@ - [x] src/admin/lib/queries/users.ts - [x] src/admin/lib/queries/vehicles.ts - [x] src/admin/lib/queries/warehouse.ts - -## src/admin/lib/ - - [x] src/admin/lib/queryClient.ts -## src/admin/pages/ +## src/admin (pages) - [x] src/admin/pages/Attendance.tsx - [x] src/admin/pages/AttendanceAdmin.tsx @@ -168,6 +166,8 @@ - [x] src/admin/pages/Dashboard.tsx - [x] src/admin/pages/InvoiceDetail.tsx - [x] src/admin/pages/Invoices.tsx +- [x] src/admin/pages/IssuedOrderDetail.tsx +- [x] src/admin/pages/IssuedOrders.tsx - [x] src/admin/pages/LeaveApproval.tsx - [x] src/admin/pages/LeaveRequests.tsx - [x] src/admin/pages/Login.tsx @@ -183,6 +183,7 @@ - [x] src/admin/pages/ProjectDetail.tsx - [x] src/admin/pages/Projects.tsx - [x] src/admin/pages/ReceivedInvoices.tsx +- [x] src/admin/pages/ReceivedOrders.tsx - [x] src/admin/pages/Settings.tsx - [x] src/admin/pages/Trips.tsx - [x] src/admin/pages/TripsAdmin.tsx @@ -208,29 +209,35 @@ - [x] src/admin/pages/WarehouseReservations.tsx - [x] src/admin/pages/WarehouseSuppliers.tsx -## src/admin/ +## src/admin (theme.test.ts) - [x] src/admin/theme.test.ts + +## src/admin (theme.ts) + - [x] src/admin/theme.ts -## src/admin/ui/ +## src/admin (ui) - [x] src/admin/ui/Alert.tsx - [x] src/admin/ui/AppShell.tsx - [x] src/admin/ui/Button.tsx - [x] src/admin/ui/Card.tsx -- [x] src/admin/ui/Checkbox.tsx - [x] src/admin/ui/ConfirmDialog.tsx +- [x] src/admin/ui/CustomerPicker.tsx - [x] src/admin/ui/DataTable.tsx - [x] src/admin/ui/DateField.tsx - [x] src/admin/ui/EmptyState.tsx - [x] src/admin/ui/Field.tsx - [x] src/admin/ui/FileUpload.tsx - [x] src/admin/ui/FilterBar.tsx +- [x] src/admin/ui/Checkbox.tsx +- [x] src/admin/ui/index.ts - [x] src/admin/ui/LoadingState.tsx - [x] src/admin/ui/Modal.tsx - [x] src/admin/ui/MonthField.tsx - [x] src/admin/ui/MuiProvider.tsx +- [x] src/admin/ui/navData.tsx - [x] src/admin/ui/PageEnter.tsx - [x] src/admin/ui/PageHeader.tsx - [x] src/admin/ui/Pagination.tsx @@ -244,36 +251,38 @@ - [x] src/admin/ui/TextField.tsx - [x] src/admin/ui/ThemeToggle.tsx - [x] src/admin/ui/TimeField.tsx -- [x] src/admin/ui/index.ts -- [x] src/admin/ui/navData.tsx - [x] src/admin/ui/useDialogScrollLock.ts -## src/admin/utils/ +## src/admin (utils) - [x] src/admin/utils/api.ts - [x] src/admin/utils/attendanceHelpers.ts - [x] src/admin/utils/dashboardHelpers.ts - [x] src/admin/utils/formatters.ts -## src/config/ +## src/App.tsx + +- [x] src/App.tsx + +## src/config - [x] src/config/database.ts - [x] src/config/env.ts -## src/context/ +## src/context - [x] src/context/ThemeContext.tsx -## src/ +## src/main.tsx - [x] src/main.tsx -## src/middleware/ +## src/middleware - [x] src/middleware/auth.ts - [x] src/middleware/security.ts -## src/routes/admin/ +## src/routes - [x] src/routes/admin/ai.ts - [x] src/routes/admin/attendance.ts @@ -283,12 +292,14 @@ - [x] src/routes/admin/company-settings.ts - [x] src/routes/admin/customers.ts - [x] src/routes/admin/dashboard.ts -- [x] src/routes/admin/invoices-pdf.ts - [x] src/routes/admin/invoices.ts +- [x] src/routes/admin/invoices-pdf.ts +- [x] src/routes/admin/issued-orders.ts +- [x] src/routes/admin/issued-orders-pdf.ts - [x] src/routes/admin/leave-requests.ts - [x] src/routes/admin/offers-pdf.ts -- [x] src/routes/admin/orders-pdf.ts - [x] src/routes/admin/orders.ts +- [x] src/routes/admin/orders-pdf.ts - [x] src/routes/admin/plan.ts - [x] src/routes/admin/profile.ts - [x] src/routes/admin/project-files.ts @@ -304,7 +315,36 @@ - [x] src/routes/admin/vehicles.ts - [x] src/routes/admin/warehouse.ts -## src/schemas/ +## src/server.ts + +- [x] src/server.ts + +## src/services + +- [x] src/services/ai.service.ts +- [x] src/services/attendance.service.ts +- [x] src/services/audit.ts +- [x] src/services/auth.ts +- [x] src/services/exchange-rates.ts +- [x] src/services/invoice-alerts.ts +- [x] src/services/invoices.service.ts +- [x] src/services/issued-orders.service.ts +- [x] src/services/leave-notification.ts +- [x] src/services/mailer.ts +- [x] src/services/nas-file-manager.ts +- [x] src/services/nas-financials-manager.ts +- [x] src/services/nas-offers-manager.ts +- [x] src/services/numbering.service.ts +- [x] src/services/offers.service.ts +- [x] src/services/orders.service.ts +- [x] src/services/plan.service.ts +- [x] src/services/planCategory.service.ts +- [x] src/services/projects.service.ts +- [x] src/services/system-settings.ts +- [x] src/services/users.service.ts +- [x] src/services/warehouse.service.ts + +## src/schemas - [x] src/schemas/ai.schema.ts - [x] src/schemas/attendance.schema.ts @@ -313,6 +353,7 @@ - [x] src/schemas/common.ts - [x] src/schemas/customers.schema.ts - [x] src/schemas/invoices.schema.ts +- [x] src/schemas/issued-orders.schema.ts - [x] src/schemas/leave-requests.schema.ts - [x] src/schemas/offers.schema.ts - [x] src/schemas/orders.schema.ts @@ -329,40 +370,12 @@ - [x] src/schemas/vehicles.schema.ts - [x] src/schemas/warehouse.schema.ts -## src/ - -- [x] src/server.ts - -## src/services/ - -- [x] src/services/ai.service.ts -- [x] src/services/attendance.service.ts -- [x] src/services/audit.ts -- [x] src/services/auth.ts -- [x] src/services/exchange-rates.ts -- [x] src/services/invoice-alerts.ts -- [x] src/services/invoices.service.ts -- [x] src/services/leave-notification.ts -- [x] src/services/mailer.ts -- [x] src/services/nas-file-manager.ts -- [x] src/services/nas-financials-manager.ts -- [x] src/services/nas-offers-manager.ts -- [x] src/services/numbering.service.ts -- [x] src/services/offers.service.ts -- [x] src/services/orders.service.ts -- [x] src/services/plan.service.ts -- [x] src/services/planCategory.service.ts -- [x] src/services/projects.service.ts -- [x] src/services/system-settings.ts -- [x] src/services/users.service.ts -- [x] src/services/warehouse.service.ts - -## src/types/ +## src/types - [x] src/types/fastify.d.ts - [x] src/types/index.ts -## src/utils/ +## src/utils - [x] src/utils/czech-holidays.ts - [x] src/utils/date.ts @@ -373,14 +386,8 @@ - [x] src/utils/response.ts - [x] src/utils/totp.ts -## src/ +## src/vite-env.d.ts - [x] src/vite-env.d.ts -## ./ -- [x] tsconfig.app.json -- [x] tsconfig.json -- [x] tsconfig.server.json -- [x] vite.config.ts -- [x] vitest.config.ts diff --git a/REVIEW_FINDINGS.md b/REVIEW_FINDINGS.md index 620dc14..d4b85a3 100644 --- a/REVIEW_FINDINGS.md +++ b/REVIEW_FINDINGS.md @@ -1,1000 +1,449 @@ -# Codebase Audit — Findings +# Audit Findings — boha-app-ts -> Severity tags: `[CRITICAL]` (exploitable / data-loss / breaks prod) · `[HIGH]` (real bug or security weakness) · `[MEDIUM]` (correctness/maintainability risk) · `[LOW]` (polish / nit). The `## Summary` section is written last, at the top, once every file is reviewed. - ---- +Full file-by-file audit, 2026-06-09/10. Method: 45 independent review agents (every file read in full), plus a project-level architecture review and a dependency review; every CRITICAL/HIGH per-file finding was then re-examined by an independent adversarial verifier before being accepted into this report. ## Summary -**Files reviewed:** 277 / 277 (100%) — every tracked source/config file plus `CLAUDE.md`. 8 files came back fully clean. +**Coverage:** 304/304 in-scope files reviewed (see `REVIEW.md`); 130 files clean, 174 with findings. -**Findings by severity:** **2 CRITICAL · 44 HIGH · 115 MEDIUM · 201 LOW** (≈362 total, including 9 project-level findings). +**Per-file findings:** 0 CRITICAL, **19 HIGH**, 161 MEDIUM, 247 LOW. All 19 HIGH findings were independently verified as real (5 further candidates were downgraded during verification; none of the verified findings were refuted). -**Project structure.** Contrary to the "mess from many AIs" worry, the architecture is _coherent and conventional_: a single-package Fastify-5 + Prisma/MySQL backend (clean routes → services → Prisma layering, followed consistently) serving a React-18 + MUI-v7 + TanStack-Query SPA from the same process. The layering, response-envelope, permission, audit, and React-Query conventions are applied uniformly across all ~277 files — that consistency is the codebase's real strength. The "mess" is not architectural; it is **(a) accumulated cleanup debt** — a handful of 1,300–2,400-line God-files (`warehouse.ts`, `InvoiceDetail.tsx`, `OfferDetail.tsx`, `attendance.service.ts`, `useAttendanceAdmin.ts`), ~77 `any` escape hatches, several dead modules (`STATUS_DOT_CLASS`, `ShortcutsHelp`, `useModalLock`, dead deps) and working-tree cruft — and **(b) missing guard-rails**: no ESLint, an unpinned Prettier, and tests excluded from type-checking, which is precisely why a _systemic_ Rules-of-Hooks bug and a wall of NaN-coercion gaps slipped in unnoticed. **Recommendation: keep the current core** (do not rewrite to Next.js or split repos) and spend the effort on linting + the targeted fixes below. +**Project-level findings:** 1 CRITICAL, 2 HIGH, 13 MEDIUM, 17 LOW (details in PROJECT-LEVEL below). -**Dependencies.** Healthy but drifting. `npm audit` reports **8 known vulns (6 moderate, 2 low)** — `quill` XSS (mitigated by the DOMPurify-everywhere sanitization, verified), `file-type` DoS, `react-router` open-redirect, plus transitive `ws`/`qs`/`brace-expansion`; most are non-breaking `npm audit fix`. Three **dead dependencies** (`react-datepicker`, `hi-base32`, `@types/mysql`) should be removed. Several **majors are behind** (React 18→19, MUI 7→9, Prisma 6→7, react-router 6→7, TS 5.9→6) — none urgent for an internal tool, but Prisma 6→7 and React 18→19 are worth planning. No secrets are committed (good); `.env.example` is stale and there is no committed README. +**Project structure** — Structurally healthy: clean Fastify request pipeline, strict TypeScript across all tsconfig projects, solid ESLint/Prettier setup, exemplary `.env.example` and secret hygiene, and a real-DB Supertest suite. The main weaknesses are an incompletely applied service layer (trips/customers/received-invoices/leave-requests/quotations/warehouse keep Prisma + business logic in route files; `warehouse.ts` is 2,565 lines), ~3,700 lines of copy-pasted PDF template code across four `*-pdf.ts` route files, a fragile tsconfig-carved frontend/backend split inside one `src/` tree, naming drift (camelCase vs kebab-case, quotations-vs-offers), zero component tests for a 114-file React frontend, and documentation drift in README.md/CLAUDE.md. Consolidation work, not rescue work. -**Top 5 to fix first:** +**Dependencies** — Healthy and modern (Fastify 5, Prisma 7, React 19, Vite 8, Zod 4). The 2 critical npm-audit vulnerabilities come entirely from `concurrently`, an **unused** devDependency — deleting it clears them. The 3 moderates are one Prisma-CLI-tooling chain best fixed with an npm `overrides` pin of `@hono/node-server@^1.19.13` (do NOT take npm's suggested Prisma 6 downgrade). The quill XSS advisory is already mitigated by the existing DOMPurify/cleanQuillHtml discipline. Two deprecated `@types` stubs (`@types/dompurify`, `@types/bcryptjs`) should be removed; ~19 packages need only safe minor/patch bumps. Strategic majors to plan deliberately: MUI 7→9, react-router 6→7, puppeteer 25 (ESM-only — blocked on a CJS→ESM server move), TS 6.0 (wait for toolchain). -1. **Systemic Rules-of-Hooks violation (2 CRITICAL + ~11 HIGH).** ~13 detail/form pages declare `useApiMutation` _after_ an early `return ` (`OrderDetail`, `ProjectDetail` = CRITICAL; `Settings`, `Trips`, `TripsAdmin`, `Users`, `Vehicles`, `AttendanceBalances`, `AuditLog`, `LeaveApproval`, and 5 warehouse detail/form pages). React crashes ("rendered fewer hooks") when a user hits the page without the permission or permission state flips. Mechanical fix (move every hook above the guard); `eslint-plugin-react-hooks` would have caught all of them. -2. **`roles.ts` privilege escalation (HIGH).** A non-admin `settings.roles` holder can create a role — even one named `admin` — with arbitrary `permission_ids`, and rewrite the existing admin role's permissions via PUT (no admin gate on POST/PUT, only DELETE). Real escalation/lockout path. (Pair with `trips.ts /users` and `vehicles.ts GET /` missing-permission disclosure.) -3. **Warehouse stock + money correctness (HIGH).** `confirmIssue`/`confirmInventorySession` lock only the issue row (not item/batch), `cancelReservation` has no lock/transaction at all, and FIFO selection is a wide TOCTOU → lost updates, negative stock, double-issue. Compounded by the **schema NaN-coercion gap**: warehouse/trips/received-invoices/settings quantities, FKs, and security numbers (`max_login_attempts`, `lockout_minutes`) accept `NaN`/negatives — root cause is the missing shared coercion helper in `schemas/common.ts`. -4. **VAT + concurrency data-integrity bugs (HIGH).** `received-invoices` JSON-create computes VAT as net-from-gross, inconsistent with the other two paths (same invoice → different stored VAT); `attendance.service.lockUserRow` uses `$executeRaw` for a `SELECT … FOR UPDATE` (the punch lock may not hold); `exchange-rates` `fetch` has no timeout (a hung ČNB endpoint blocks the event loop); `numbering`/`users`/`offers` have first-of-year / uniqueness TOCTOU races (500 instead of 409/retry). -5. **Missing engineering guard-rails + the `nas-offers-manager` silent catches (HIGH).** Add ESLint + `typescript-eslint` + `eslint-plugin-react-hooks`, a committed Prettier as a real devDep, and type-check the tests (`tsconfig.test.json`) — this single investment would have caught #1 and a large share of the MEDIUM findings automatically. Separately, `nas-offers-manager.ts` has five fully-silent `catch {}` blocks (a failed offer-PDF save/delete is invisible), violating the project's own never-swallow rule. +### Top 5 issues to address first -**Note on test confidence:** the suite is server-side only (no component tests) and has real gaps on the _happy paths_ — no successful-login / refresh-rotation test, no happy-path `verifyAccessToken` test, and the `exchange-rates` module cache is never reset between tests (order-dependent). Warehouse FIFO is asserted only by total-quantity conservation, not oldest-batch-first. Green tests therefore give less assurance on auth and stock valuation than the count (195) suggests. - ---- +1. **Attendance creation/editing is broken since commit 519edce (2026-06-09)** — `src/admin/pages/AttendanceCreate.tsx` (80-99): every submit 400s because always-sent empty time strings fail the new `timeString` validation, breaks/overnight fields are silently stripped, and validated "HH:MM" values crash the service (`new Date('08:00')` → Invalid Date → 500). Same schema/client/service contradiction breaks the AttendanceAdmin create/edit modals (407-442). A core daily workflow is non-functional. +2. **Backup-code 2FA login is broken — lockout risk** — `src/admin/context/AuthContext.tsx` (263-272): backup codes are posted to `/login/totp` as `totp_code`, which the 6-digit schema always rejects; the real `POST /totp/backup-verify` endpoint is never called from the frontend. Any user who loses their authenticator is locked out. Related: the 2FA enrollment QR (`DashProfile.tsx:556`, api.qrserver.com) is blocked by the production CSP, and `Dashboard.tsx` (89-91) shows the company-wide 2FA _policy_ flag as the user's personal enrollment status. +3. **Silent invoice data corruption on re-save** — `src/admin/pages/InvoiceDetail.tsx` (728): edit-mode hydration overwrites per-line VAT rates with the invoice-level rate (and `|| 21` turns legitimate 0% into 21%) — re-saving a mixed-rate invoice corrupts stored VAT data; `billing_text` edits are silently dropped because `UpdateInvoiceSchema` lacks the field. Same falsy-zero hydration bug in `IssuedOrderDetail.tsx` (606-611). +4. **Trips lists/totals silently truncated at 25 rows** — `src/admin/lib/queries/trips.ts` (52-63, 95-103): the paginated response's meta is discarded, so Trips/TripsAdmin/TripsHistory compute km and business-trip totals from at most 25/100 rows with no pagination UI — wrong business numbers once data grows. Same class of bug: `orders.service.ts` ships the full PDF `attachment_data` blob inside every list/detail/totals JSON response (severe payload bloat). +5. **`concurrently` devDependency (unused) carries both CRITICAL npm-audit vulnerabilities** (shell-quote command injection, CVSS 8.1) — remove the package, zero migration risk; then pin `@hono/node-server@^1.19.13` via `overrides` to clear the moderates. ## PROJECT-LEVEL -### System core (what this app actually is) +### Architecture & structure -A **single-package full-stack monolith**, and — contrary to the "mess from many AIs" worry — the bones are coherent and conventional: +Overall, boha-app-ts is a structurally healthy, above-hobbyist-grade codebase that mostly lives up to its own documented conventions: a clean Fastify request pipeline (CORS → cookies → rate-limit → security headers → permission guard → Zod → service → response helpers), strict TypeScript across all three tsconfig projects, a solid flat ESLint config with rules-of-hooks as an error, Prettier + format-on-save hooks, an exemplary .env.example, genuinely good secret hygiene (no env file ever committed, placeholder-only template, hard-throw on missing required vars), and a 25-file Supertest suite against a real isolated test DB. The main architectural weaknesses are (1) an incompletely-applied service layer — roughly half the entities (trips, customers, leave-requests, received-invoices, quotations, most warehouse CRUD) keep Prisma queries and business logic directly in route files, with warehouse.ts at 2,565 lines/46 endpoints and four ~800–1,100-line PDF route files containing copy-pasted inline HTML templates and helpers; (2) a single src/ tree shared by server and SPA that is carved up by fragile tsconfig include/exclude lists, leaving stragglers like src/context/ThemeContext.tsx, App.tsx and main.tsx outside the frontend's logical home; and (3) naming drift (camelCase vs kebab-case, .service suffix vs none, quotations-vs-offers domain split) plus documentation rot in README.md and a few spots in CLAUDE.md. Nothing found is critical; the recommendations below are consolidation work, not rescue work. -- **Backend:** Fastify 5 JSON API. Entry `src/server.ts` registers ~29 route plugins under `/api/admin/*`. Clean 3-layer split: **routes** (HTTP, Zod validation, auth guards, `success/error` helpers) → **services** (plain exported async functions, own Prisma) → **Prisma/MySQL** (52 models, snake_case). Cross-cutting infra: JWT(HS256)+TOTP auth with hashed refresh-token rotation, custom security headers + prod CSP, `node-cron` (invoice alerts), Puppeteer (PDF), nodemailer (email), Anthropic SDK (Odin AI). -- **Frontend:** React 18 SPA in `src/admin/`, built by Vite, **served by the same Fastify process** (Vite middleware in dev, `@fastify/static` in prod). MUI v7 + Emotion, TanStack Query for all data, React Router 6. Component kit in `ui/`, pages lazy-loaded. -- **Build topology:** one `package.json`, three tsconfigs via a solution file — `tsconfig.server.json` (CJS → `dist/`) and `tsconfig.app.json` (ESM, `noEmit`, Vite owns emit). Tests (Vitest) hit a real MySQL test DB. +- [HIGH] **Separation of concerns — service layer** — The documented 'services own Prisma, routes stay thin' pattern is applied to only ~half the entities: trips.ts (20 prisma calls), customers.ts (12), received-invoices.ts (15), leave-requests.ts, quotations.ts and warehouse.ts (79 prisma calls, 46 endpoints, 2,565 lines) all run Prisma queries and business decisions directly in route handlers, while attendance/plan/orders/invoices have proper \*.service.ts files. + - Recommendation: Incrementally extract services for the route-resident entities (start with warehouse CRUD and received-invoices since they carry the most logic), and split src/routes/admin/warehouse.ts into a folder of sub-route files (items, receipts, issues, reservations, inventory) registered under the same prefix. Do it opportunistically per the existing 'convert when you touch them' rule, but track it so it converges. +- [HIGH] **Code duplication — PDF routes** — invoices-pdf.ts (1,097), orders-pdf.ts (917), issued-orders-pdf.ts (880) and offers-pdf.ts (784 lines) each embed a full inline HTML template plus four copy-pasted helper sets (formatNum, formatDate, cleanQuillHtml, a per-file JSDOM+DOMPurify instance) — ~3,700 lines of near-parallel presentation code living in the routes layer, where a sanitization or layout fix must be applied four times. + - Recommendation: Extract a shared src/pdf/ (or src/services/pdf/) module: one sanitize+cleanQuillHtml pipeline, one number/date formatter set, one base document template with per-document sections. The PDF routes then shrink to data-fetch + template-call, matching the thin-route convention. +- [MEDIUM] **Project layout — frontend/backend split** — Server and SPA share one src/ tree carved up by tsconfig include/exclude lists: tsconfig.server.json includes src/\*_/_ then excludes src/admin, src/context, App.tsx, main.tsx, **tests** — so any new frontend folder created outside src/admin silently lands in the server build. Symptoms already exist: src/context/ThemeContext.tsx lives outside src/admin/context (where AuthContext/AlertContext live), and the SPA entry files App.tsx/main.tsx sit at src/ root next to server.ts. + - Recommendation: Move ThemeContext.tsx into src/admin/context (it is only excluded from the server build, nothing server-side imports it), relocate App.tsx/main.tsx under src/admin (adjust index.html entry), and flip tsconfig.server.json from an exclude-list to an explicit include of server directories (config, routes, services, schemas, middleware, utils, types, server.ts). That makes the boundary structural instead of memorized. +- [MEDIUM] **Consistency — file naming** — Mixed naming conventions in the same directories: planCategory.service.ts / planCategory.schema.ts (camelCase) vs every sibling in kebab-case; services split between suffixed (warehouse.service.ts) and unsuffixed (auth.ts, audit.ts, mailer.ts, exchange-rates.ts, nas-\*.ts, system-settings.ts); src/admin/lib/queries has auditLog.ts beside issued-orders.ts; src/utils has planAuditDescription.ts beside czech-holidays.ts; and the offers domain is named 'quotations' only in routes/admin/quotations.ts (registered at /api/admin/offers, with offers.service.ts, offers.schema.ts, Offers.tsx everywhere else). + - Recommendation: Standardize on kebab-case files with .service.ts/.schema.ts suffixes for entity modules (rename planCategory._ → plan-category._, auditLog.ts → audit-log.ts, planAuditDescription.ts → plan-audit-description.ts, quotations.ts → offers.ts), and either suffix the bare services or document that unsuffixed files are infrastructure helpers, not entity services. Pure renames — git tracks them, low risk, one PR. +- [MEDIUM] **Tooling — package.json contradicts migration policy** — package.json ships db:push (prisma db push) and db:pull scripts even though CLAUDE.md's golden rule is 'NEVER use prisma db push' — a one-keystroke footgun that bypasses migration history, and there are no db:migrate/db:deploy scripts for the blessed path. + - Recommendation: Remove db:push (and db:pull, or rename it db:pull:DANGEROUS), and add scripts for the sanctioned workflow instead: db:migrate → prisma migrate dev, db:deploy → prisma migrate deploy, keeping db:generate/db:studio. +- [MEDIUM] **Testing — zero component tests for a 114-file React frontend** — All 25 test files in src/**tests** are server-side API tests (good breadth there); the only frontend test is a theme-token unit test (src/admin/theme.test.ts). Pages of 800–2,300 lines (InvoiceDetail.tsx 2,278; OfferDetail.tsx 2,012; CompanySettings.tsx 1,542) carry untested form/validation/permission logic, and the single node-environment vitest.config.ts cannot host jsdom component tests. + - Recommendation: Adopt Vitest projects (workspace) config: the existing node+real-DB project unchanged, plus a jsdom project with @testing-library/react for src/admin/\*_/_.test.tsx. Start with the shared kit (DataTable, Select, ConfirmDialog) and the most logic-heavy pages; even a handful of tests around the detail-page save flows would cover the riskiest untested surface. +- [MEDIUM] **Frontend structure — monolithic page components** — Detail/settings pages routinely exceed 1,000 lines (InvoiceDetail 2,278; OfferDetail 2,012; CompanySettings 1,542; IssuedOrderDetail 1,465; Settings 1,356; ReceivedInvoices 1,316) because each page owns its forms, modals, line-item editors and mutations inline, while src/admin/components only holds a small set of shared pieces. + - Recommendation: When touching these pages, split per-page subcomponents into feature folders (e.g. src/admin/pages/invoices/{InvoiceDetail.tsx, InvoiceItemsEditor.tsx, InvoicePaymentsPanel.tsx}) keeping data logic in the parent. This is the natural prerequisite for the component-testing recommendation and reduces merge conflicts in the busiest files. +- [MEDIUM] **Documentation accuracy — README.md and CLAUDE.md drift** — README.md claims 'Prisma 6' and 'React 18' (actual: Prisma 7.8.0, React 19.2.7) and points to 'eslint.config.js' (actual: eslint.config.mjs); CLAUDE.md says logAudit lives in src/utils/audit.ts (actual: src/services/audit.ts), lists utils as 'totp.ts, pdf.ts, email.ts, audit.ts' (actual: html-to-pdf.ts in utils, mailer.ts in services, no email.ts/pdf.ts/audit.ts), says 'admin/ # React 18' in the structure tree while its own tech-stack table says React 19, and cites '17 files'/'18 files / 247 tests' (actual: 25 files in src/**tests** plus theme.test.ts). + - Recommendation: One doc-sync pass: fix README versions and the eslint filename, correct CLAUDE.md's audit.ts path, utils listing, React-18 remnant and test counts. These docs are actively used as ground truth by tooling and agents, so drift here propagates into wrong code. +- [LOW] **Repo hygiene — tracked .claude/settings.local.json** — .claude/settings.local.json is committed (git ls-files confirms) even though .gitignore lists it — it was tracked before the ignore rule was added, so ignore no longer applies and future machine-local permission edits will keep landing in commits. Current content is harmless (bash permission allowlists, no secrets); .claude/settings.json (hooks only) is fine to track. + - Recommendation: Run git rm --cached .claude/settings.local.json and commit; the existing .gitignore entry then takes effect. Keep settings.json tracked since the format-on-save and block-env hooks are useful shared config. +- [LOW] **Repo hygiene — root directory clutter** — The repo root carries six release tarballs (app-ts-2.0.0…2.2.0.tar.gz, ~1 MB each), scratch outputs (npm-audit.json, npm-outdated.json, review_scope.txt, review_batches.json, frontend_Design_flaws.md, simon/plan_boha.pdf), and historical review reports (REVIEW\*.md, currently staged for deletion). All are gitignored or being removed, but they obscure the real project files and risk accidental tarball-of-a-tarball in releases. + - Recommendation: Move release archives to a gitignored releases/ folder (and update the release-process docs), delete or relocate one-off scan outputs to docs/ or a scratch dir, and finish committing the REVIEW\*.md deletions. +- [LOW] **Dead configuration — unused path alias and stale ESLint ignore** — All three tsconfigs define the '@/_' → 'src/_' path alias but zero source files use it, and Vite has no matching resolve.alias so it could never work in the frontend anyway; eslint.config.mjs also ignores 'src/admin/bones/\*_' which no longer exists, and the blanket '_.config.ts' ignore leaves vite.config.ts/vitest.config.ts/prisma.config.ts unlinted. + - Recommendation: Either wire the alias properly (add resolve.alias to vite.config.ts and start using it for deep imports) or delete the paths blocks from all three tsconfigs; drop the bones ignore; consider linting the config files via the typescript-eslint node block. +- [LOW] **Server module system — CJS forces an eval-import hack** — The server compiles to CommonJS, so src/server.ts loads ESM-only Vite via Function('return import("vite")')() — an eval-style escape hatch that defeats type checking and bundler analysis, and the same CJS choice drives 'as any' casting around the Vite middleware and the documented CJS/ESM test friction. + - Recommendation: Plan a server migration to ESM (module: nodenext, "type": "module" or .mts), which removes the eval hack, aligns with Fastify 5/Vitest defaults, and future-proofs against the growing set of ESM-only dependencies. Not urgent — it only bites in dev — but worth scheduling before more dynamic-import workarounds accumulate. +- [LOW] **Environment validation — manual parsing instead of schema** — src/config/env.ts hand-rolls required()/parseInt with per-field fallbacks; the project already depends on Zod 4 but does not use it for env validation, so a typo like ACCESS_TOKEN_EXPIRY=15m silently becomes NaN→fallback rather than a boot error (a few fields like MAX_UPLOAD_SIZE guard this individually, most do not). The .env.example itself is excellent and complete vs env.ts. + - Recommendation: Define a Zod schema for the whole env (z.coerce.number() with bounds, enums for APP_ENV) and parse once at boot — same fail-fast behavior the codebase already mandates for request bodies, and it keeps .env.example verifiably in sync with one source of truth. +- [LOW] **Test placement — theme.test.ts outside the test tree** — src/admin/theme.test.ts is the only test colocated with source; every other test lives in src/**tests** per the documented convention, and it is type-checked by tsconfig.app.json rather than tsconfig.test.json, so it follows different compiler settings than the rest of the suite. + - Recommendation: Either move it to src/**tests**/theme.test.ts for consistency, or — if adopting the component-testing recommendation — keep colocation as the new convention for frontend tests and document that split (server tests in **tests**, frontend tests colocated). -**Verdict on structure:** This is a textbook layered monolith and the layering is followed consistently across the whole tree. The separation of concerns is _good_. The "mess" is not architectural — it's **(a) a handful of God-files, (b) missing tooling/hygiene scaffolding, (c) accumulated dead weight (deps + working-tree cruft), and (d) ~77 `any` escape hatches.** None of that requires re-architecting; it requires a cleanup pass. Recommendation: **keep the current core**; do not migrate to Next.js/separate repos — the unified Fastify-serves-SPA model is appropriate for an internal tool and a rewrite would cost far more than it returns. +### Dependencies -### [HIGH] No linter anywhere; formatter is unpinned and undeclared +Overall dependency health is good: the stack is modern (Fastify 5, Prisma 7, React 19, Vite 8, Zod 4, Vitest 4) and ~19 of the 26 outdated packages are trivial minor/patch bumps clearable in one npm update pass. The npm audit total of 7 vulnerabilities (2 critical, 3 moderate, 2 low) is far less scary than it looks: both criticals come from `concurrently`, an UNUSED devDependency (no npm script references it — delete it and they vanish); the 3 moderates are one chain (prisma 7.x → @prisma/dev pinning vulnerable @hono/node-server) whose npm-suggested "fix" is a Prisma 6 downgrade you must NOT take — use an npm `overrides` entry for `@hono/node-server@^1.19.13` instead; the lows are an unpatched-upstream quill XSS already mitigated by the codebase's DOMPurify/cleanQuillHtml sanitization. Verified maintenance status (registry + web, June 2026): bcryptjs, node-cron, otpauth, nodemailer, puppeteer, react-quill-new and dnd-kit are all actively maintained — the only genuinely dormant package is the transitive quill core editor (last publish Jan 2025, open CVE), worth watching under its maintained react-quill-new wrapper. Two officially deprecated `@types` stubs (@types/dompurify, @types/bcryptjs) should be removed. Strategic upgrades to triage, none urgent: MUI 7→9 (double-major, codemods available), react-router-dom 6→7, TypeScript 5.9→6.0 (wait for toolchain support), and note puppeteer 25 is ESM-only + Node ≥22, which conflicts with the CommonJS server build — stay on the still-supported 24.x line until an ESM migration is planned. -- There is **no ESLint config** (`eslint.config.*`/`.eslintrc` absent) and **no ESLint dependency**. A 77k-LOC TS/React codebase with no static lint is the single biggest hygiene gap — it's why 77 `any`s, unused vars, and hook-dep mistakes go uncaught. The app tsconfig even sets `noUnusedLocals: false` / `noUnusedParameters: false`, so the compiler won't flag dead code either. -- `.claude/hooks/format-on-save.js` runs `npx prettier --write`, but **prettier is not in `devDependencies`** and there is **no `.prettierrc`**. Formatting is therefore whatever an ad-hoc `npx`-fetched Prettier defaults to — not reproducible, not enforced in CI. -- **Recommend:** add `eslint` + `typescript-eslint` + `eslint-plugin-react-hooks` (the hooks plugin alone would catch a class of frontend bugs), add `prettier` as a real devDep with a committed config, and wire both into an npm script / pre-commit. - -### [HIGH] Test sources are never type-checked - -`tsconfig.server.json` excludes `src/__tests__`, and `tsconfig.app.json` does not include it — so **no project type-checks the test files**. `npx tsc -b` (the documented gate) silently skips 19 test files. Type errors in tests only surface at `vitest run`. Add a `tsconfig.test.json` (or include tests in a checked project) so the gate actually covers them. - -### [MEDIUM] God-files — a few modules carry far too much - -Largest files by LOC: `routes/admin/warehouse.ts` **2399**, `pages/InvoiceDetail.tsx` **2243**, `pages/OfferDetail.tsx` **2059**, `services/attendance.service.ts` **1700**, `pages/CompanySettings.tsx` **1503**, `hooks/useAttendanceAdmin.ts` **1402**, `pages/Settings.tsx` **1354**, `pages/Attendance.tsx` **1266**. These are hard to hold in context, hard to review, and the most likely homes for latent bugs. `routes/admin/warehouse.ts` in particular should be split per sub-entity (items/receipts/issues/reservations/inventory) the way the _pages_ already are. (Per-file findings below will flag specific seams.) - -### [MEDIUM] ~77 `any` escape hatches erode the strict-mode guarantee - -`grep` finds 77 `as any` / `: any` in `src`. CLAUDE.md explicitly warns that typing `authData as any` hides the `roleName` vs `role` bug — yet route files (`plan.ts`, `projects.ts`, `quotations.ts`, `sessions.ts`, `trips.ts`) and `services/audit.ts` use `any`. Each is a hole where the type system stops helping. Most are `(request.params as any).id` / `(request.body as any)` which a small set of typed helpers would remove. Flagged per-file where they hide real risk. - -### [MEDIUM] Dependency drift + 8 known vulnerabilities (`npm audit`) - -`npm audit`: **8 vulns (6 moderate, 2 low)**. Notable: `quill` 2.0.3 XSS via HTML-export (pulled by `react-quill-new`; partly mitigated here because rich-text is DOMPurify-sanitized on both render and PDF paths), `file-type` ASF infinite-loop DoS, `react-router` open-redirect via protocol-relative `//` path, plus `ws`/`qs`/`brace-expansion` transitive DoS/disclosure. Most are `npm audit fix` (non-breaking); `quill` and `file-type` need a major bump. -Major versions behind (review before jumping — all breaking): **React 18→19**, **MUI 7→9**, **@mui/x-date-pickers 8→9**, **Prisma 6→7**, **react-router 6→7**, **TypeScript 5.9→6**, **file-type 16→22 (ESM-only)**, **@fastify/multipart 9→10**. None are urgent for an internal app, but Prisma 6→7 and React 18→19 are the strategically important ones to plan. - -### [MEDIUM] Dead dependencies (classic multi-AI residue) - -Verified zero imports in `src`: **`react-datepicker`** (replaced by `@mui/x-date-pickers` — remove), **`hi-base32`** (TOTP now via `otpauth` — remove), **`@types/mysql`** (no `mysql` package used; Prisma is the driver — remove). Removing these shrinks the install and the audit surface. (`qrcode`, `leaflet`, `file-type`, `dompurify`, `nodemailer`, `@dnd-kit` are all genuinely used.) - -### [MEDIUM] No committed README; `.env.example` is stale - -- **No README is tracked** (`README.md` exists only as an untracked working-tree file). A production app should have a committed README covering setup/run/deploy. (CLAUDE.md is excellent and partly compensates, but it's agent-facing, not a project README.) -- **`.env.example` is missing many real env vars** that `config/env.ts` reads: `ANTHROPIC_API_KEY`, `NAS_FINANCIALS_PATH`, `NAS_OFFERS_PATH`, `SMTP_FROM_NAME`, `INVOICE_ALERT_EMAIL`, `RATE_LIMIT_*`, `TRUST_PROXY`, `TOTP_ALGORITHM/DIGITS/PERIOD`, `LOGIN_TOKEN_EXPIRY_MINUTES`. A fresh checkout can't discover these without reading source. - -### [LOW] `.gitignore` lets build/junk leak into the working tree - -`.gitignore` covers `node_modules/dist/dist-client/.env*` but **not** `*.tsbuildinfo`, `*.bak`, or stray artifacts. The working tree currently holds untracked cruft: `tsconfig.app.tsbuildinfo`, `tsconfig.server.tsbuildinfo`, `prisma/schema.prisma.bak`, `simon/plan_boha.pdf`, `.playwright-mcp/`, `frontend_Design_flaws.md`, `plan-*.png` screenshots. None are committed (good), but add `*.tsbuildinfo` and `*.bak` to `.gitignore` and clear the loose files so the tree reads clean. - -### [LOW] Per-request auth does several uncached DB round-trips - -`verifyAccessToken` → `loadAuthData` runs on **every** authenticated request and issues: user+roles+permissions join, a `company_settings` lookup for `require_2fa`, and — for admins — a **full `permissions` table scan** (`findMany`) every call. Correct, but it's 2–3 queries per request with zero caching. Fine at current scale; a short-TTL in-memory cache of permissions-by-role would cut DB load materially if traffic grows. (Noting once here rather than per-route.) - -### Positives worth preserving - -Timing-safe login (dummy-bcrypt on every failure path), refresh-token **rotation** with `replaced_by_hash` reuse-detection and `FOR UPDATE` locking, hashed (not plaintext) refresh tokens, strict secret validation at boot (64-hex JWT/TOTP keys, port range), health check before rate-limit, consistent `{success,error}` envelope, DOMPurify on both render and PDF paths, prod-only HSTS+CSP, and a genuinely thorough CLAUDE.md. The conventions doc + the 2026-06-06 audit show the team already fights entropy deliberately. +- [CRITICAL] **concurrently (+ shell-quote)** — Both critical audit findings (GHSA-w7jw-789q-3m8p, shell-quote command injection CVSS 8.1, via concurrently 9.2.1) sit in a devDependency that is completely unused — no npm script in package.json references concurrently (the dev scripts run tsx/vite separately); the only 'concurrently' matches in src/ are the English word in comments. + - Recommendation: Remove concurrently from devDependencies entirely (zero migration risk — it is dead weight). If it is ever wanted again, install concurrently@10.0.3 which has the patched shell-quote. This single deletion clears both critical audit entries. +- [MEDIUM] **prisma / @prisma/dev / @hono/node-server** — All 3 moderate audit findings are one chain: prisma 7.x ships @prisma/dev (<=0.24.8) which pins vulnerable @hono/node-server <1.19.13 (GHSA-92pp-h63x-v22m, middleware bypass via repeated slashes in serveStatic). Exposure is the Prisma CLI dev tooling, not the production runtime query path. npm audit's suggested fix is a DOWNGRADE to prisma 6.19.3 (semver-major backwards) — unacceptable, the app is built on the Prisma 7 Rust-free client + @prisma/adapter-mariadb. + - Recommendation: Do NOT take npm's downgrade fix. Add an npm overrides entry in package.json: "overrides": { "@hono/node-server": "^1.19.13" }, and track prisma/prisma#29605 for an upstream @prisma/dev fix. Migration risk: trivial (one package.json field + reinstall). +- [MEDIUM] **@types/dompurify** — Officially deprecated on npm: "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed." dompurify >=3.2 bundles its own types; the stub is dead weight and can shadow/conflict with the real types. + - Recommendation: Remove @types/dompurify from devDependencies. Migration risk: none — run npm run typecheck after removal to confirm. +- [MEDIUM] **@types/bcryptjs** — Officially deprecated on npm: "This is a stub types definition. bcryptjs provides its own type definitions." Worse, the installed stub is ^2.4.6 (2.x API) while bcryptjs 3.0.3 is the runtime — the stale stub can mask the bundled 3.x typings. + - Recommendation: Remove @types/bcryptjs from devDependencies. Migration risk: none — bcryptjs 3.x ships correct types; verify with npm run typecheck. +- [MEDIUM] **quill (via react-quill-new)** — Two-part finding. (1) Audit low: quill 2.0.3 has an unpatched XSS in the HTML export feature (GHSA-v3m3-f69x-jf25 / CVE-2025-15056); NO fixed quill release exists, and npm's 'fix' is a downgrade of react-quill-new to 3.7.0 — not a real fix. The codebase already mitigates this (server-side DOMPurify via jsdom, frontend sanitization before dangerouslySetInnerHTML, cleanQuillHtml on all PDF routes). (2) Maintenance: quill core has had no release since Jan 2025 and Snyk flags it as possibly discontinued; the react-quill-new wrapper itself IS actively maintained (3.8.3, Feb 2026). + - Recommendation: Keep react-quill-new 3.8.3 and the existing sanitization discipline (do not take the downgrade 'fix'). Watch quill core: if it stays dormant through an open CVE for another cycle, plan a medium-term migration to TipTap or Lexical — that is a LARGE refactor (RichEditor/RichEditorRoot/RichTextView components, Quill Delta/HTML content model, existing stored rich-text in invoices/quotations/orders), so it should be a deliberate project, not a casual swap. +- [MEDIUM] **puppeteer** — Behind on two levels: 24.40.0 → 24.43.1 (patch, safe) and major 25.1.0 available. Puppeteer is very actively maintained (verified, 25.1.0 published May 2026), but v25 is ESM-ONLY, requires Node >=22, and makes executablePath/defaultArgs async — this directly conflicts with the server build, which compiles to CommonJS (tsconfig.server.json, "type": "commonjs"). + - Recommendation: Bump within 24.x to 24.43.1 now (safe). Defer v25 until/unless the server build migrates to ESM (or use dynamic import() shims); treat the v25 jump as HIGH migration risk and bundle it with any future CJS→ESM move. The 24.x line is still supported. +- [MEDIUM] **@mui/material + @mui/x-date-pickers** — @mui/material 7.3.11 → 9.1.0 and @mui/x-date-pickers 8.29.0 → 9.4.0. MUI skipped v8 for core to re-align majors with MUI X. v9 removes previously-deprecated APIs and system props (sx only), changes Grid (no direction="column"), tweaks ListItemIcon min-width, ~3% smaller bundle; official codemods and an Upgrade-to-v9 guide exist. The app's entire admin UI kit (src/admin/ui/, theme.ts, GlobalStyles.tsx) sits on MUI v7, so the surface is wide. + - Recommendation: Plan a dedicated upgrade task: run the MUI v9 codemods, upgrade core and x-date-pickers together (one shared major), then visually regression-test the ui-kit showcase route and dense tables. Moderate migration risk, not urgent — v7 still works fine; do it before v7 drops out of support. +- [MEDIUM] **react-router-dom** — 6.30.4 → 7.17.0 (major). v6 is in maintenance mode; v7 is the active line (package consolidation into react-router, react-router-dom kept as a thin re-export). The app uses classic /lazy pages in AdminApp.tsx, and already satisfies v7's React 18+ requirement (React 19). + - Recommendation: Upgrade path: enable the v6 future flags first (v7_startTransition, v7_relativeSplatPath, etc.) on 6.30.x, confirm no warnings, then bump to v7 and optionally switch imports to "react-router". Moderate, mostly-mechanical migration risk for a non-data-router app like this one. +- [LOW] **typescript** — 5.9.3 → 6.0.3 (major). TS 6.0 changes nine compiler defaults (strict by default — this repo is already strict; rootDir inference now '.', types:[] no longer auto-crawls @types, moduleResolution classic removed, ES5 target deprecated, esModuleInterop always on). Toolchain (typescript-eslint 8.61, vite, vitest) must also declare TS 6 support. + - Recommendation: Hold on 5.9.x until typescript-eslint and the Vite/Vitest toolchain officially support TS 6, then migrate using the ts5to6 codemod (npx @andrewbranch/ts5to6) and re-verify the three tsconfig projects (server/test/solution). Moderate config-level risk, low urgency. +- [LOW] **@fastify/multipart, @fastify/rate-limit** — @fastify/multipart 9.4.0 → 10.0.0 and @fastify/rate-limit 10.3.0 → 11.0.0 — both new majors; Fastify plugin majors historically bump minimum Fastify/Node versions with small API changes (e.g. multipart v10 changes saveRequestFiles return values). Detailed breaking-change lists were not in release summaries found. + - Recommendation: Read the GitHub release notes for both before bumping; expected low migration risk on Fastify 5, but verify the multipart upload route and rate-limit config against the changelogs, and run the NAS file-manager tests after. +- [LOW] **minor/patch batch (19 packages)** — Safe minor/patch updates available: @anthropic-ai/sdk 0.102.0→0.104.0, @tanstack/react-query 5.100.5→5.101.0, @types/jsdom 28.0.1→28.0.3, @types/node 25.5.0→25.9.2, @vitejs/plugin-react 6.0.1→6.0.2, date-fns 4.1.0→4.4.0, dompurify 3.4.5→3.4.8, dotenv 17.3.1→17.4.2, framer-motion 12.38.0→12.40.0, jsdom 29.0.2→29.1.1, mariadb 3.5.2→3.5.3, nodemailer 8.0.7→8.0.10, otpauth 9.5.0→9.5.1, prettier 3.8.3→3.8.4, puppeteer 24.40.0→24.43.1 (within-range), tsx 4.21.0→4.22.4, vite 8.0.13→8.0.16, vitest 4.1.0→4.1.8, zod 4.3.6→4.4.3. + - Recommendation: One npm update pass, then the standard gates (tsc -b --noEmit, npm run build, npx vitest run, npm run lint). @anthropic-ai/sdk is 0.x so skim its 0.103/0.104 changelog for the Odin extract-invoices structured-output path, but risk is low. +- [LOW] **@types/nodemailer** — Types package (7.0.11) is a full major behind the installed runtime (nodemailer 8.0.x); @types/nodemailer 8.0.0 exists. Mismatched type majors can hide real API differences in src/utils/email.ts. + - Recommendation: Bump @types/nodemailer to ^8.0.0 alongside the nodemailer patch bump. Trivial risk; typecheck confirms. +- [LOW] **bcryptjs** — Verified actively maintained (3.0.3, 2026 activity) and NOT deprecated — no forced action. Context: it is pure-JS (~3-4x slower than native bcrypt), and OWASP's current guidance puts Argon2id first with bcrypt 'acceptable'. Existing user hashes are bcrypt (including PHP-legacy $2y$), so any swap needs a rehash-on-login transition. + - Recommendation: Keep bcryptjs for now (zero native-build hassle on the Windows dev + Linux prod mix). Optional long-term hardening: migrate to argon2 (npm 'argon2', current 0.44.0) with verify-old/rehash-new on login. Migration risk: moderate (dual-verify logic, native dependency in prod deploy). +- [LOW] **node-cron** — Verified maintained (4.2.1, 2026 activity) — not abandoned. However croner (current 10.0.1) is the more robust 2026 choice: TypeScript-native, zero deps, Intl-based timezone/DST handling, and a catch option so job errors aren't silently swallowed. Relevant here because the app pins TZ=Europe/Prague where DST transitions can shift naive cron schedules, and the codebase has a 'never swallow errors' rule. + - Recommendation: No urgent action. If cron-time DST correctness or job error handling ever bites, swap to croner@10 — cron expression syntax is compatible and the scheduler surface in this app is small. Low migration risk. +- [LOW] **otpauth, nodemailer, puppeteer (maintenance verification)** — All three flagged-for-checking packages verified healthy via registry + web (June 2026): otpauth 9.5.1 (hectorm, active releases, RFC 6238 — matches the TOTP module), nodemailer 8.0.10 (published May 2026, active), puppeteer 25.1.0 (published May 2026, very active). None deprecated or abandoned. + - Recommendation: No replacements needed; just take the patch/minor bumps listed in the batch finding (puppeteer stays on 24.x per its separate finding). +- [LOW] **@dnd-kit/\* and leaflet (staleness watch)** — @dnd-kit/core last npm publish Dec 2024, but 2026 ecosystem sources consistently describe dnd-kit as actively maintained and still the React drag-and-drop default (used in 3 detail pages here). leaflet is on 1.9.4 (last stable era 2023) with v2 in progress upstream; used on a single page (AttendanceLocation.tsx). Neither is deprecated. + - Recommendation: No action now; re-check both at the next dependency review. If dnd-kit goes truly dormant, Atlassian's pragmatic-drag-and-drop is the credible replacement (headless, more DIY). Leaflet 2.0 will be a deliberate migration when it ships stable. +- [LOW] **framer-motion (weight, not redundancy)** — Used in 15 files, but mostly for simple entrance/fade/stagger animations (PageEnter, PageHeader, dashboard widgets, Odin thread) — the full framer-motion bundle (~30-40 kB gz) is heavy for that usage profile. It is genuinely used, so removal is not recommended; no competing animation lib is installed (no duplication). + - Recommendation: Optional optimization: adopt framer-motion's built-in LazyMotion + m components (domAnimation feature set) to cut the animation bundle to ~6 kB, or convert the simplest fades to CSS transitions. Low risk, purely a bundle-size win; respects the existing useReducedMotion handling. +- [LOW] **dependency placement hygiene (@types/jsdom, dotenv)** — (1) @types/jsdom sits in dependencies instead of devDependencies, so the prod install (npm install --omit=dev) still pulls a types-only package. (2) dotenv 17 is fine and maintained, but Node >=20 supports --env-file natively; dotenv is now optional weight (kept useful here by the .env/.env.test switching workflow). + - Recommendation: Move @types/jsdom to devDependencies (trivial, no risk). Keep dotenv unless someone wants to slim prod deps — switching to node --env-file would touch the start script and test setup, so only do it deliberately. +- [LOW] **date-fns (redundancy check — clean)** — Date-library redundancy check passed: date-fns v4 is the only date library (no moment/dayjs/luxon duplication) and it is REQUIRED as the @mui/x-date-pickers adapter (AdapterDateFns with cs locale in MuiProvider/DateField/MonthField/TimeField). Not replaceable by native code while MUI pickers are in use. + - Recommendation: No action — this is the correct setup. Take the 4.1.0 → 4.4.0 minor bump with the batch. --- -## src/server.ts +# Per-file findings -- [LOW] `Function('return import("vite")')()` indirect-eval hack (line 160) to load ESM Vite from the CJS bundle — intentional, brittle. Otherwise clean: plugin order, health check before rate-limit, graceful shutdown. +## .claude/hooks/block-env.js -## src/config/env.ts +- [HIGH] (line 10-11) Block decision never takes effect: hook 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 read from stderr), so exit 1 is treated as a non-blocking error, stdout is ignored, and the .env edit proceeds silently; fix by exiting 0 with the JSON or exiting 2 with the reason on stderr. _(verified)_ +- [LOW] (line 13) Empty `catch {}` swallows malformed-hook-input errors with no log and fails open (edit allowed), violating the repo rule that silent expected-condition catches require an explanatory comment. -- [LOW] Global `Date.prototype.toJSON` override (lines 14-22) — documented PHP-compat gotcha; app-wide. Strong boot-time secret/port validation. Clean. +## .claude/hooks/format-on-save.js -## src/config/database.ts +- [MEDIUM] (line 10) Shell-injection risk: filePath from hook JSON input is interpolated unescaped into an execSync shell string (`npx prettier --write "${filePath}"`); filenames containing shell metacharacters (POSIX: quotes/backticks/$( ); cmd.exe: %VAR% expansion) execute arbitrary commands with no permission prompt — use execFileSync with an argument array. +- [LOW] (line 12) Empty `catch {}` plus stdio:'ignore' silently swallows every hook failure (prettier errors, timeouts) with no comment marking it as an expected-condition catch. -- Clean — single PrismaClient singleton. +## .claude/settings.json -## src/middleware/security.ts +- [LOW] (line 9, 22) Hook commands use relative paths (`node .claude/hooks/...`) instead of "$CLAUDE_PROJECT_DIR"; hooks run in the session's current directory, so if cwd leaves the repo root both hooks fail non-blockingly and the .env guard + auto-format silently stop working. -- [LOW] CSP/HSTS prod-only (line 18); `style-src 'unsafe-inline'` required by Emotion; no `report-uri`. Clean. +## .claude/settings.local.json -## src/middleware/auth.ts +- [LOW] (line 4-9) Dead permission allowlist: lines 4-5 have backslashes escaped away (`D:cortexboha-appresources`) so the rules can never match a real command, and lines 4-5/8-9 all target the legacy boha-app PHP repo rather than this project — stale entries that should be pruned. -- Clean — correct `reply.sent` short-circuit, admin bypass via `roleName`. - -## src/utils/response.ts - -- Clean — envelope helpers + `parseId` (NaN/≤0 → 400). - -## src/services/auth.ts - -- [MEDIUM] Refresh-token rotation rejects replaced/expired tokens but does NOT revoke the token family on detected reuse (lines 273-280) — no breach containment. -- [LOW] `loadAuthData` runs on every request (2-3 queries; admin full `permissions.findMany` scan). Strong otherwise (timing-safe, hashed tokens, FOR UPDATE, lockout). - -## src/services/ai.service.ts - -- [LOW] `extractInvoice` `JSON.parse` of model output unguarded (line 351) — low risk with json_schema. Budget/cost ledger correct. - -## src/routes/admin/ai.ts - -- [LOW] `extract-invoices` swallows per-file failure / budget-exhausted-mid-batch into partial success, no truncation signal (lines 130-131). Otherwise clean (guard, parseId/parseBody, per-user ownership, audit). - -## src/routes/admin/attendance.ts - -- [MEDIUM] Many handlers use raw `reply.send` envelope instead of `success()`/`paginated()` (lines 32,108,118,128,142,165-239). -- [LOW] `action=project_logs` (line 189) — no ownership check (location path does enforce it); `Number(query.year)` NaN fallbacks. - -## src/routes/admin/audit-log.ts - -- [MEDIUM] `Number(query.user_id)` (line 33) → NaN → Prisma 500 instead of 400. -- [LOW] `date_to`+`"T23:59:59"` (line 40) — malformed date → 500. - -## src/routes/admin/auth.ts - -- Clean — TOTP replay protection, single-use login token, httpOnly/secure/sameSite=strict cookie, rate limits, audit. - -## src/routes/admin/bank-accounts.ts - -- Clean — `settings.banking` guard, parseId/parseBody, existence checks, audit. - -## src/routes/admin/company-settings.ts - -- [LOW] Extra `findFirst` for `has_logo` (302-307, deliberate); request-time `require(package.json)`; PUT `Number()` without NaN re-guard (481). Clean (magic-byte MIME sniff, race-safe create). - -## src/routes/admin/customers.ts - -- [MEDIUM] GET `/` raw `reply.send` envelope (100-104) instead of `paginated()`. Otherwise clean (guards, FK-check before delete, allow-listed sort, parameterized search). - -## src/routes/admin/dashboard.ts - -- [MEDIUM] N+1: `today_plan` per-cell `plan_categories.findUnique` (55-67); bounded but should be one `findMany`. -- [LOW] `created_at ?? ""` dead fallback (314). Otherwise clean (gated, Promise.all, parameterized $queryRaw). - -## src/routes/admin/leave-requests.ts - -- [MEDIUM] GET `/` raw `reply.send` envelope (57-61). -- [LOW] Approval path drops `reviewer_note` (302-309) though rejection saves it. Otherwise clean (transactional approval, owner+pending cancel, audit). - -## src/routes/admin/profile.ts - -- Clean — current-password verify, email-uniqueness in tx (409), tokens invalidated on pwd change, self-scoped, audit. - -## src/routes/admin/invoices.ts - -- [LOW] GET `/` raw `reply.send` envelope (62-66); module-scoped `markOverdueInvoices` throttle can double-run under concurrency (30-39, self-correcting). Otherwise clean. - -## src/routes/admin/invoices-pdf.ts - -- [LOW] `JSON.parse(settings.custom_fields)` (451) has no try/catch → malformed JSON makes whole PDF 500; `save=1` raw envelope (1073); loose typing. Note: `notes` IS DOMPurify+cleanQuillHtml sanitized (521). - -## src/routes/admin/offers-pdf.ts - -- [LOW] `save=1` raw envelope (761); local `cleanQuillHtml` strips only `javascript:` (not data:/vbscript:) — defense-in-depth only (DOMPurify first, 357). Clean. - -## src/routes/admin/orders.ts - -- [MEDIUM] From-quotation multipart parses `fields.quotationId` with raw `parseInt` (123) not schema/parseId. -- [LOW] Raw `request.body` casts (203,313); unbounded multipart field/file count (only fileSize limited). Otherwise solid. - -## src/routes/admin/orders-pdf.ts - -- [HIGH] POST renders an official "Order Confirmation" PDF entirely from client-supplied `body.items` (304-312) — `orders.view` holder can fabricate descriptions/prices not reflecting stored data (downloadable, not persisted → limited impact; trust/authz concern). -- [LOW] Deprecated `.passthrough()` (30); loose untyped `body.lang`/`applyVat`; `(order as Record).payment_method` cast (387). `notes` sanitized (409). - -## src/routes/admin/quotations.ts - -- [MEDIUM] GET `/:id` second `quotations.findUnique` for lock info right after `getOffer(id)` (123-129) — redundant query per detail view. -- [LOW] `(result as any).status` cast (286); empty `catch {}` on NAS PDF delete commented "Non-fatal" but does NOT log (321-323). Lock/heartbeat/unlock scoped to `locked_by=userId` (correct). - -## src/routes/admin/received-invoices.ts - -- [HIGH] JSON single-create computes `vat_amount=(amount*vat_rate)/100` (394-397) — treats GROSS as net, violating gross-VAT convention and INCONSISTENT with multipart (297) + update (467) paths using `vatFromGross`. Same invoice → different VAT by entry path. -- [LOW] GET `/` raw envelope (89-93); `stats` `sumCzk` sequential + throws on unknown currency → 500 (135); multipart create loops per-file with no transaction (partial-success on failure). - -## src/routes/admin/scope-templates.ts - -- [MEDIUM] No `logAudit` on ANY mutation (create/update/delete of scope + item templates) — audit-convention violation. -- [MEDIUM] DELETE `?action=item&id=X` uses `Number(query.id)` no NaN/existence guard (134-139) → 500; no row-exists/`is_deleted` check. -- [LOW] `?action=` dispatch mixes two entities in one handler; `Number(body.id)`/`Number(query.id)` instead of validated ids. - -## src/routes/admin/plan.ts - -- [LOW] `(result.data as any).id`/`(result.oldData as any)` audit casts (256,310,334,361,392,416). Otherwise clean — guards on every route, parseId/parseBody, audit on every mutation, `isAdminLike` reads `roleName`, 92-day range cap. - -## src/routes/admin/project-files.ts - -- [LOW] Folder-create (120) and move (236) read raw `request.body` bypassing parseBody (manually String-coerced). Path traversal delegated to `resolveProjectPath` (sound). Clean. - -## src/routes/admin/projects.ts - -- [MEDIUM] GET `/` raw envelope (44-48). -- [LOW] DELETE reads unvalidated body for `delete_files` (182-183); `(result as any).status` casts; POST `/:id/notes` does NOT `logAudit` though note-delete does (126-148) — inconsistent audit. - -## src/routes/admin/roles.ts - -- [HIGH] POST `/` no uniqueness guard on `roles.name` and no admin-only gate — a non-admin `settings.roles` holder can create a role (even named `admin`) and attach ANY permission_ids → privilege escalation (59-98). PUT `/:id` can rewrite the `admin` role's permissions (no `name==="admin"` guard like DELETE) → escalation/lockout (102-138). -- [MEDIUM] Role insert + permission assignment in separate transactions (67-73 vs 76-86) — partial failure leaves orphan role. - -## src/routes/admin/sessions.ts - -- Clean — ownership-scoped queries, audit on terminate/terminate-all, current-session via `hashToken`. No IDOR. - -## src/routes/admin/totp.ts - -- [HIGH] `/enable` verifies the new secret with hardcoded SHA1/6/30 (87-94) not `config.totp.*` — config divergence → enrolled secrets verify under different params than login → lockout. -- [MEDIUM] `/backup-verify` bcrypt-compares every code, no early-exit on match (307-312) — N bcrypt ops/attempt. -- [LOW] `/enable` change-flow (2FA on) needs only a live code, not password (65-76); redundant dynamic re-imports shadow `config` (363-370). - -## src/routes/admin/trips.ts - -- [MEDIUM] GET `/users` (83) guarded only by `requireAuth` — any authenticated user enumerates all active users. Information disclosure. -- [MEDIUM] GET `/` raw envelope (73-77). -- [LOW] In-handler permission checks run after parseBody; POST create unconditionally sets `vehicle.actual_km=end_km` (244-247) — can regress odometer (PUT/DELETE recompute via MAX). PUT/DELETE ownership checks correct. - -## src/routes/admin/users.ts - -- [LOW] Non-admin escalation guard strips `role_id`/`is_active` (109-114, good); bounded `as Parameters<...>` cast (121); forced logout on pwd change not separately audited. Clean (parseId/parseBody/guard/audit, paginated used). - -## src/routes/admin/vehicles.ts - -- [MEDIUM] GET `/` guarded only by `requireAuth` (15) — any authenticated user lists all vehicles + stats; mutations need `vehicles.manage`. Read should require a view permission. Otherwise clean (single groupBy, delete blocks linked trips). - -## src/routes/admin/warehouse.ts - -- [HIGH] `confirmIssue` locks only the `sklad_issues` row, not item/batch (service:523) — two different draft issues selecting the same FIFO batch confirm concurrently, both read-modify-write `batch.quantity` -> lost update, negative stock, double-issue. Same gap in `confirmInventorySession`. -- [HIGH] FIFO batch selection at issue create/update (1496-1499,1622-1625) runs with no lock, persists chosen batch on draft; availability re-checked only at confirm -> wide TOCTOU/oversell. -- [HIGH] Reports stock-status/project-consumption/movement-log/below-minimum have NO pagination + per-item N+1 (3 sequential aggregates/item; 629-646,2174-2306). -- [MEDIUM] PUT receipts/issues/inventory-sessions do deleteMany+createMany on lines with NO $transaction (1150-1164,1660-1673,2090-2098) -> failure leaves draft with no lines. -- [MEDIUM] Attachment save: NAS write then DB insert, no compensating delete (orphan on DB failure); confirm does not verify batch.item_id===line.item_id (wrong item decremented); received_by overwritten at confirm (service:350). -- [MEDIUM] 2399-line file bundles 8 sub-entities + inline mid-file multipart register (963) — split per sub-entity. -- [LOW] Unvalidated query.status / Number(query.x) filters; pervasive Record-cast querystrings. Every handler guarded, tiers consistent, attachment DELETE ownership-checked — no missing-guard/IDOR. - -## src/services/attendance.service.ts - -- [HIGH] `lockUserRow` uses `tx.$executeRaw` for a bare SELECT ... FOR UPDATE (85) — $executeRaw is for write statements; the punch/switch concurrency lock may not reliably hold. Should be `tx.$queryRaw`. -- [HIGH] `getBalances` N+1: one `leave_balances.findFirst` per user in a loop (491), unbounded. -- [MEDIUM] `createLeave` books a Dec->Jan range entirely against the start year (getFullYear, 1261); `getStatus` runs 5 independent queries serially. -- [LOW] `deleteAttendance` (1694) has no ownership param (unlike updateAttendance); hardcoded 160 vacation default x3; ~1700-line file should be split. - -## src/services/audit.ts - -- [LOW] `safeJsonReplacer` duck-types Prisma Decimal via method presence (14-19) — instanceof would be precise. Failure caught+logged. Clean. - -## src/services/exchange-rates.ts - -- [HIGH] `fetch(url)` (33) has NO timeout/AbortController — a hung CNB endpoint blocks every awaiting caller indefinitely. -- [MEDIUM] Global single `rateCacheTime` (14,22-24) — a hit on one key refreshes TTL for ALL keys; date-specific entries can outlive TTL; stale-fallback only checks today. -- [LOW] No validation data.rates is an array before iterating (39). - -## src/services/invoice-alerts.ts - -- [MEDIUM] Created-invoice alert amount uses only subtotal (net, 92-95) while received invoices show gross — inconsistent; per-invoice `invoice_alert_log.findUnique` N+1 (81,142). -- [LOW] Dead/identical ternary branches (232-239); sequential unguarded create loop can re-fire alerts; escapeHtml omits the apostrophe char. - -## src/services/invoices.service.ts - -- [MEDIUM] Three divergent money/VAT implementations: computeInvoiceTotals (47-74), invoiceTotalWithVat (164-196), stats VAT loop (264-270, no per-line rounding) — inconsistent totals risk. -- [MEDIUM] `getInvoiceStats` serializes many toCzk awaits (247-295) — should Promise.all; createInvoice/updateInvoice take body: Record with unchecked `as InvoiceItemInput[]` casts. -- [LOW] `deleteInvoice` derives year from invoice_number.split("/")[1] (497) -> wrong sequence released for non-standard numbers; getInvoice returns null not {error,status}; paid_date settable on non-paid invoice (463). - -## src/services/leave-notification.ts - -- [LOW] `formatDate` try/catch ineffective — new Date(invalid) returns Invalid Date, never throws (13-18). Header injection mitigated via sanitizeHeaderValue; send failure logged. Clean. - -## src/services/mailer.ts - -- [MEDIUM] Transport hardcoded to local sendmail /usr/sbin/sendmail (5-9) — Linux-only, no SMTP fallback; on hosts without the binary every send silently fails (console.error only). -- [LOW] `to` not validated/sanitized for CRLF (11) — nodemailer guards most. - -## src/services/system-settings.ts - -- [LOW] JSON.parse of vat-rates/currencies (56,61) no shape validation; process-local cache won't propagate cross-instance within 60s TTL. Two empty catches are commented expected-condition guards (OK). Clean. - -## src/services/nas-file-manager.ts - -- [MEDIUM] TOCTOU between resolveProjectPath validation (701-713) and the later fs op on the returned path — a symlink swapped into a path component could bypass the guard (low risk on a trusted share); ".." matched as literal substring (679, contained by prefix check). -- [LOW] createProjectFolder sync/non-atomic (idempotent); MIME gate passes type-less files (SVG served as attachment w/ nosniff -> mitigated); collision suffix loop can yield name_1_2. - -## src/services/nas-financials-manager.ts - -- [MEDIUM] Fully synchronous fs over a network share on the request path (39-73,81+) — slow/hung NAS blocks the event loop. -- [LOW] isConfigured() does mkdirSync as a predicate side effect (24); saveIssuedInvoicePdf skips isConfigured() (asymmetry). resolveSafePath guard correct. - -## src/services/nas-offers-manager.ts - -- [HIGH] Five fully-silent empty-catch blocks, no log/comment (24,46,59,76,111) — violates never-swallow rule; sibling financials manager logs all of them -> failed offer-PDF save/delete is invisible. -- [LOW] deleteOfferPdf readdir/rmdir swallowed (covered above); isConfigured() mkdir side effect. resolveSafePath correct. - -## src/services/numbering.service.ts - -- [MEDIUM] getNextSequence first-of-year INSERT race (61-75) -> @@unique([type,year]) P2002 as 500 instead of retry. -- [MEDIUM] releaseSequence (116-158) SELECT-then-UPDATE with no transaction/lock/tx param — concurrent delete+create can lose a decrement or decrement an in-use number (gap/dup). -- [LOW] releaseSequence reconstructs highest number from current settings (144-149); changed pattern -> no-op -> gap. - -## src/services/offers.service.ts - -- [MEDIUM] createOffer TOCTOU: isOfferNumberTaken runs BEFORE the tx (162-167), create inside does not re-check -> concurrent dup numbers (orders.service fixed this). -- [LOW] deleteOffer never calls deleteOfferPdf -> orphaned PDF on NAS; pervasive any in enrich/list money math. - -## src/services/orders.service.ts - -- [LOW] enrichOrder/getOrder treat projects as array projects?.[0] (60,151) — confirm cardinality; status->project-status sync duplicated verbatim in both update branches (525-538 & 572-585); deleteOrder FK guard runs outside the tx (599,617-634) -> narrow re-intro window. Tx boundaries otherwise correct. - -## src/services/plan.service.ts - -- [MEDIUM] N+1 per mutation: resolvePlanLabels(2q)+resolveCategoryLabel(1q) after each write for the audit description (483-489); bulkCreateEntries multiplies it. -- [LOW] createEntry cap check outside any tx (464) — concurrent creates can exceed cap (intentional per comment). UTC date math deliberate. - -## src/services/planCategory.service.ts - -- [LOW] slugifyCategory strips a literal combining-marks range (file-byte fragile); uniqueKey unbounded collision loop + race; sort_order=max+1 outside tx (dup possible). Soft-delete-aware checks correct. - -## src/services/projects.service.ts - -- [LOW] updateProject renames NAS folder after DB commit outside any tx (DB/NAS divergence, failure unlogged at call site, 143); getProject does a synchronous full-base readdirSync on every GET (95); deleteProject removes folder BEFORE the DB delete (218) — inconsistent with orders.service DB-first ordering; Record typing. - -## src/services/users.service.ts - -- [MEDIUM] createUser username/email uniqueness via separate findFirst BEFORE a non-transactional create (116-142) — TOCTOU; duplicate -> unhandled 500 not 409 (updateUser already fixed this in a tx). -- [LOW] createUser no in-service min password length (updateUser requires >=8); where: any. Last-admin + self-delete guards correct. - -## src/services/warehouse.service.ts - -- [HIGH] cancelReservation (959-988) has NO row lock and NO transaction — a concurrent confirmIssue fulfilling the same reservation interleaves -> lost fulfillment, double-counted stock. Every other warehouse op locks the parent row; this one was missed. -- [MEDIUM] selectFifoBatches orders only by received_at no id tie-break (170) + second-truncated precision -> nondeterministic FIFO; getBelowMinimumItems N+1 (232-261). -- [LOW] validateIssueReservationRules N+1; confirmInventorySession negative-diff decrements item_locations independent of consumed batches (drift); surplus enters at unit_price:0 (valuation); dead itemQty/linePrice locals. Lock/FIFO/storno guards otherwise correct. - -## src/schemas/common.ts - -- [HIGH] Contains only parseBody — NO shared coercion helpers despite the convention; the z.union number/string transform idiom is re-implemented ~150x across schemas with no shared home. Root cause of the NaN findings below. -- [LOW] Deprecated ZodSchema import (1); Zod 4 prefers z.ZodType. - -## src/schemas/ai.schema.ts - -- [LOW] Message objects plain z.object (not strict); AiBudgetSchema.budget_usd no upper bound (39, but Number.isFinite-guarded). - -## src/schemas/attendance.schema.ts - -- [HIGH] Pervasive unguarded Number(v) transform with NO NaN check on user_id, year, vacation/sick totals, project_id, leave_hours, and all lat/lng/accuracy fields (13-145) — NaN flows to service/Prisma; project_id NaN -> bogus FK. -- [MEDIUM] Time fields (arrival/departure/break) unbounded z.string() with no HH:MM regex (45-48 etc.). -- [LOW] Plain z.object not strict; minutes not capped at 59. - -## src/schemas/auth.schema.ts - -- [MEDIUM] username/password and TOTP password fields have no .max() (4-5) — unbounded string to bcrypt (file caps backup codes for this exact DoS reason but not password). -- [LOW] totp_code .length(6) but not digits-only; plain objects. - -## src/schemas/bank-accounts.schema.ts - -- [MEDIUM] iban/bic/account_number/currency/names all unbounded nullish strings (4-9) — no length caps, no IBAN/BIC/ISO format checks. -- [LOW] position unguarded number-from-form (NaN); plain objects. - -## src/schemas/customers.schema.ts - -- [MEDIUM] custom_fields: z.array(z.any()) (11,23) — fully over-permissive, arbitrary nested payload persisted as-is. -- [LOW] All address/id strings unbounded, no format checks; plain objects. - -## src/schemas/invoices.schema.ts - -- [LOW] quantity/unit_price throw on NaN (good) but vat_rate/position/order_id/customer_id use the UNGUARDED idiom; status/currency/language free strings (no enum); unbounded text; plain objects. - -## src/schemas/leave-requests.schema.ts - -- [LOW] date_from/date_to min(1) no YYYY-MM-DD regex, no date_to>=date_from cross-check; unbounded notes; plain objects. - -## src/schemas/offers.schema.ts - -- [LOW] Number coercions correctly refine(!isNaN) but re-implement the idiom ~7x; currency/language free strings; unbounded text; plain objects. - -## src/schemas/orders.schema.ts - -- [LOW] Same as offers — refined but duplicated idiom; free currency/language; unbounded text; plain objects. - -## src/schemas/plan.schema.ts - -- [LOW] intFromForm (7-10) is a proper guarded helper but defined locally (belongs in common.ts); project_id coercions (21-102) use the UNGUARDED idiom not intFromForm -> NaN FK; plain objects. - -## src/schemas/planCategory.schema.ts +## .env.example - Clean -## src/schemas/profile.schema.ts - -- [MEDIUM] new_password .min(8) no .max() (8); current_password unbounded — unbounded to bcrypt. -- [LOW] first_name/last_name no min/max; plain objects. - -## src/schemas/projects.schema.ts - -- [LOW] Create FK fields refine-guard NaN, but Update + quotation_id/order_id use the UNGUARDED idiom (29-44) -> NaN FK on update; status free string (no enum); date strings no regex; plain objects. - -## src/schemas/received-invoices.schema.ts - -- [HIGH] month/year coerce via Number(v) with NO NaN guard AND no range bounds (5-6,53-60) — month not 1-12, year unbounded; flows into the ledger + date logic. -- [MEDIUM] amount/vat_rate/vat_amount unguarded idiom (NaN); vat_rate no range bound (negative/>100 accepted) — load-bearing for gross VAT back-calc. -- [LOW] Unbounded text; plain objects. - -## src/schemas/roles.schema.ts - -- [LOW] permission_ids: z.array(z.number()) not .int()/positive, no .max() length; name no slug/format; unbounded text; plain objects. - -## src/schemas/scope-templates.schema.ts - -- [LOW] position/id/default_price unguarded number-from-form (NaN); name/title nullish/optional with no .min(1) (nameless template passes); unbounded text; plain objects. - -## src/schemas/settings.schema.ts - -- [MEDIUM] Every numeric setting — incl. security-relevant max_login_attempts, lockout_minutes, max_requests_per_minute (15-62) — uses the unguarded idiom with NO NaN guard and NO range bounds; NaN/0/negative can disable lockout/rate-limit. -- [MEDIUM] custom_fields/supplier_field_order: z.array(z.any()); available_vat_rates no int/range; available_currencies no enum. -- [LOW] Email-ish fields no .email(); plain objects. - -## src/schemas/trips.schema.ts - -- [HIGH] start_km/end_km/vehicle_id/user_id use the unguarded idiom — NO NaN check; confirmed routes/admin/trips.ts:226 end_km NaN/negative corrupt inventory totals. -- [MEDIUM] All FK coercions (item_id/category_id/supplier_id/project_id/location_id/batch_id/reservation_id) unguarded -> NaN FK to Prisma lookups. -- [LOW] Supplier email/phone no format check; date strings no regex; unbounded text; plain objects; idiom duplicated dozens of times. - -## src/utils/czech-holidays.ts - -- [LOW] Easter calc parses YYYY-MM-DD as UTC midnight then reads with local getters (50-57) — correct only because Prague is east of UTC; a negative-offset TZ would be a day early (safe today, fragile). Minor formatting duplication. holidayCache bounded by year. - -## src/utils/date.ts +## .gitignore - Clean -## src/utils/encryption.ts - -- [MEDIUM] Format detection via split(":").length===3 (27-28) — brittle (a malformed TS value falls through to base64 branch). Crypto sound: random IV per encrypt (no reuse), GCM tag verified, raw 32-byte key. A version/prefix tag would be more robust. - -## src/utils/html-to-pdf.ts - -- [MEDIUM] process.env.CHROMIUM_PATH || "/usr/bin/chromium-browser" || "/usr/bin/chromium" (21-24) — third path is dead (second operand always truthy); if only chromium exists, launch fails. Browser lifecycle/finally sound (no leak). Injection safety handled upstream. - -## src/utils/pagination.ts - -- [LOW] sort/search passed through unvalidated (19,22) — trust-boundary note for callers (orderBy field needs an allowlist); page/limit clamping correct. - -## src/utils/planAuditDescription.ts +## .prettierignore - Clean -## src/utils/totp.ts - -- [LOW] console.error logs raw error on decrypt/verify failure (27) — no secret leak; swallow-to-{valid:false} intentional. Correct RFC-6238 (window:1, params from config); replay protection in auth.ts. - -## src/types/fastify.d.ts +## .prettierrc.json - Clean -## src/types/index.ts +## CLAUDE.md -- [LOW] JwtPayload.role vs AuthData.roleName (51,60) — the documented footgun that invites authData.role==="admin"; no bug here but a hazard. +- [LOW] (line 39) Stale doc: project-structure comment says "React 18 + Material UI frontend" while the tech-stack table (line 17) says React 19.2.7. +- [LOW] (line 50) Inconsistent test counts: line 50 says the suite is "17 files" while the Testing section (line 250) says "18 files / 247 tests". -## prisma/seed.ts +## boneyard.config.json -- [MEDIUM] main() unconditionally deleteManys ALL role_permissions + permissions (321-322) before re-inserting — prod-destructive if ever misused (custom roles lose all permissions; stale-reconciliation at 351-368 is dead after the wipe). Dev-only by policy. -- [LOW] Seeds an admin/admin user hashing the literal "admin" (389) — dev convenience, risk if run on a reachable env. +- Clean -## scripts/migrate-received-invoices-to-nas.ts +## eslint.config.mjs -- [LOW] Treats saveReceivedInvoice as sync (41) — verify; sets file_data: null after a claimed-success save with no read-back verification (56) — a 0-byte/corrupt write would lose the only DB copy (one-shot migration). +- [LOW] (line 54-61) eslint-plugin-react-refresh is imported and registered in the frontend block but no react-refresh rule is ever enabled — dead plugin registration (e.g. only-export-components is never turned on). -## scripts/rotate-totp-key.ts +## index.html -- [HIGH] Hand-rolled decrypt (6-14) only handles the TS iv:enc:tag format; PHP-legacy base64 secrets (no colon) -> Buffer.from(undefined,'hex') throws inside $transaction with no per-user try/catch (58-65) -> the FIRST legacy secret aborts/rolls back the ENTIRE batch. Should reuse src/utils/encryption.ts dual-format decrypt. -- [LOW] Dry-run shows [FAIL] rows for legacy secrets the real run cannot process — operator must not ignore them. +- [LOW] (line 2-6) data-theme="dark" and theme-color #12121a are hardcoded with no inline pre-React script syncing the stored 'mui-mode' localStorage key, so light-mode users get a dark flash on every page load until React/ThemeContext mounts. + +## package.json + +- [LOW] (line 17) "db:push": "prisma db push" provides a first-class npm script for the command CLAUDE.md's golden rule forbids (NEVER use prisma db push — it causes migration drift); the script should be removed. +- [LOW] (line 50) @types/jsdom is listed in production dependencies (installed on prod via npm install --omit=dev) — type packages belong in devDependencies. +- [LOW] (line 88) concurrently is a devDependency but no npm script uses it (dev script is plain `tsx watch`) — unused dependency. + +## prisma.config.ts + +- Clean ## prisma/schema.prisma -- [HIGH] Entire sklad\_\* (warehouse) module declares relations with NO explicit onDelete (780-958) — header->line deletes and master-data deletes get engine-default behavior, inconsistent with the explicit cascade/setNull/restrict used everywhere else; define explicit onDelete (Cascade line->header, Restrict line->master-data). -- [MEDIUM] Warehouse document numbers receipt_number/issue_number/session_number (825,877,935) are nullable and NOT unique — unlike every other document-number column (invoice/order/quotation are @unique); two receipts can share a number. -- [MEDIUM] invoices.order_id/customer_id, orders.quotation_id/customer_id, quotations.customer_id, project FKs have no explicit onDelete (default Restrict — likely intended but implicit). Several \*\_id are bare Int with no relation/FK -> orphan risk (received_invoices.uploaded_by, quotations.locked_by can point at deleted users). -- [MEDIUM] sklad_issues.project_id/issued_by, sklad_receipts.supplier_id/received_by, bank_accounts declare no @@index (MySQL auto-indexes the FK constraint, but non-FK hot filters are uncovered and the schema is inconsistent vs the rest). -- [LOW] Most modified_at/updated_at lack @updatedAt (rely on app code -> stale-timestamp footgun); duplicate identical audit_logs created_at indexes (71,73); is_deleted absent on financial/document models (audit_logs compensates); invoice/order/project/quotation status are free strings not Prisma enums; config lists stored as LongText JSON not Json columns. Money is correctly Decimal throughout (good). +- [MEDIUM] (line 302-309) number_sequences has fully nullable type (String?), year (Int?) and last_number (Int?) on the concurrency-critical numbering table — the [type, year] unique index does not prevent duplicate rows when either column is NULL (MySQL allows multiple NULLs in unique indexes), and a nullable counter forces null-handling in the locking/sequence code; all three should be required. +- [LOW] (line 26, 36) attendance.project_id is a bare Int? with only an index (idx_project_id) and no relation/FK to projects (unlike attendance_project_logs.project_id) — deleting a project leaves dangling references with no referential integrity. +- [LOW] (line 545-560, 624-634) Auth token tables have no FK to users: refresh_tokens.user_id is @db.UnsignedInt (type-mismatched against signed users.id, which blocks adding one) and totp_login_tokens.user_id has no relation — orphaned token rows persist after user deletion; same FK-less pattern on ai_usage.user_id, ai_chat_messages.user_id, received_invoices.uploaded_by and quotations.locked_by. +- [LOW] (line 30, 293, 650, 673, 1043) Most updated_at columns are DateTime? @default(now()) WITHOUT @updatedAt (and the generated DDL has no ON UPDATE CURRENT_TIMESTAMP), so they only change when service code remembers to set them manually — inconsistent with ai_conversations/plan_categories which do use @updatedAt; updates that forget leave stale timestamps. -## src/admin/ui/Alert.tsx +## prisma/seed.ts -- [LOW] MUI close button gets default English aria-label "Close" amid otherwise-Czech UI (18). Clean otherwise. +- [MEDIUM] (line 320-327) Seed deleteMany-wipes ALL permissions/role_permissions then re-inserts only the hardcoded PERMISSIONS list, which omits the migration-seeded 'ai.use' (20260608120000_add_ai_assistant) — re-seeding a migrated dev/test DB silently deletes the AI permission row and any non-admin grants, desyncing the DB from the migration baseline (Odin page is gated on hasPermission("ai.use")). +- [LOW] (line 306-315) The destructive-wipe production guard keys solely off APP*ENV === "production"; a DATABASE_URL pointing at prod with APP_ENV unset/local (e.g. after a migrate-diff session against the prod URL) still wipes prod permissions — no DB-target check like the test setup's hard-throw on non-\_test* database names. -## src/admin/ui/AppShell.tsx +## scripts/check-roles.mjs -- [MEDIUM] `setTimeout(()=>logout(),400)` (27) is fire-and-forget, never cleared — fires on an unmounted tree if unmounted within 400ms. -- [LOW] Decorative inline SVGs lack aria-hidden. Otherwise clean. +- [MEDIUM] (line 2) Bare `new PrismaClient()` throws PrismaClientInitializationError at construction under Prisma 7 (driver adapter is mandatory — verified by running the script), and the script never loads dotenv so DATABASE_URL would be undefined anyway; it crashes on every invocation and should import the shared adapter-wired client from src/config/database like seed.ts does. -## src/admin/ui/Button.tsx +## scripts/migrate-received-invoices-to-nas.ts -- Clean (sound polymorphic `component` pattern, caller sx wins). +- [MEDIUM] (line 9, 21-30, 41, 61, 87-90) Script is fully dead/broken against the current codebase: it imports a nonexistent `nasFinancialsManager` export (the module now exports nasInvoicesManager/nasOrdersManager after the NAS split), calls removed methods saveReceivedInvoice/readReceivedInvoice (now saveReceived/readReceived), and queries/updates received_invoices.file_data/file_path fields that exist in neither schema.prisma nor any migration; the NAS_FINANCIALS_PATH usage header is stale too. scripts/ is excluded from every tsconfig so typecheck never catches it, and nas-financials-manager.ts (line 19) still references this script as the canonical safe-migration mechanism. +- [LOW] (line 69-73) Reaches into the manager's private basePath via `as unknown as { basePath: string }` cast and concatenates the path manually with "/" — a type-safety hole that bypasses the manager's own path resolution/guards. -## src/admin/ui/Card.tsx +## scripts/rotate-totp-key.ts -- [MEDIUM] Always wraps children in CardContent with no opt-out/contentProps — edge-to-edge content (media/table) can't use this primitive. Otherwise clean. +- [LOW] (line 37-52) The --dry-run path returns from main() without prisma.$disconnect() (only the non-dry-run path disconnects at line 67), so the open mariadb pool keeps the event loop alive and the script hangs after printing "Dry run complete." +- [LOW] (line 19-25) Old/new AES-256 encryption keys are passed as plain command-line arguments — they end up in shell history and are visible in the process list (ps) for the duration of the run; reading from env vars or prompting would avoid the leak. -## src/admin/ui/Checkbox.tsx +## scripts/seed-test-users.mjs -- [MEDIUM] No disabled/name/id/indeterminate/required passthrough — minimal API forces callers to raw MUI. Otherwise clean. +- [MEDIUM] (line 10) Bare `new PrismaClient()` throws PrismaClientInitializationError at construction under Prisma 7 (driver adapter is mandatory — empirically verified for the identical pattern in check-roles.mjs), and the script never loads dotenv — it crashes on every invocation, so neither documented invocation in the header (node / npx tsx) works. +- [LOW] (line 57-107) No production guard (unlike prisma/seed.ts's APP_ENV hard-throw): once the client construction is fixed, running it with a production DATABASE_URL would create 20 active accounts with the known shared password "test1234". -## src/admin/ui/ConfirmDialog.tsx - -- [MEDIUM] `cancelText` (and `confirmVariant`) not frozen in `shown` while title/message/confirmText/loading are — cancel label can flicker during the close fade. message is plain string (no XSS). Otherwise clean. - -## src/admin/ui/DataTable.tsx - -- [MEDIUM] Mobile card branch (78-173) ignores sortBy/sortDir/onSort — sorting controls vanish on phones with no alternative. -- [LOW] Desktop action cells do NOT stopPropagation (237,254-275) — an action button in an `onRowClick` row fires BOTH (potential double-action; HIGH if any table pairs them). `"actions"` column key magically special-cased (undocumented). Status tints correctly channel-alpha. - -## src/admin/ui/DateField.tsx - -- [LOW] Dual-label coexistence (picker label + outer Field); parseISO guarded by isValid. Clean. - -## src/admin/ui/EmptyState.tsx - -- [LOW] icon sized via fontSize:48 — assumes font/SVG-inheriting glyph (an won't scale). Clean. - -## src/admin/ui/Field.tsx - -- [HIGH] `` (24) has NO `htmlFor` and injects no id into children — label not associated with the input (no click-to-focus, screen readers don't announce). System-wide since this is the shared form-label primitive. -- [MEDIUM] error/hint not linked via aria-describedby and input gets no aria-invalid (41-49). SwitchField lacks disabled passthrough. - -## src/admin/ui/FileUpload.tsx - -- [LOW] Keys include array index; remove button uses entity glyph with proper aria-label. Clean. - -## src/admin/ui/FilterBar.tsx - -- Clean. - -## src/admin/ui/LoadingState.tsx - -- [LOW] CircularProgress has no accessible label when `label` omitted (the full-screen AppShell case). Clean otherwise. - -## src/admin/ui/Modal.tsx - -- [MEDIUM] `cancelText` not frozen in `shown` (cancel label flickers on close); DialogContent renders LIVE children (77) so the form body still flashes its cleared state during the fade — the exact issue the freeze targets, unsolved for the body. Otherwise clean. - -## src/admin/ui/MonthField.tsx - -- [LOW] ~90% duplicate of DateField.tsx (low-value to consolidate); parseISO guarded. Clean. - -## src/admin/ui/MuiProvider.tsx - -- [LOW] `defaultMode="dark"` hardcoded (11); verify it agrees with ThemeContext's `data-theme`/`mui-mode` source of truth to avoid a one-frame mismatch. Clean otherwise. - -## src/admin/ui/PageEnter.tsx - -- [LOW] Children keyed by index (animation only); useReducedMotion called before early return (hook order safe). Clean. - -## src/admin/ui/PageHeader.tsx +## src/App.tsx - Clean -## src/admin/ui/Pagination.tsx +## src/**tests**/ai.test.ts -- Clean (clamps page, null for single page). +- [LOW] (line 323-336) API key is blanked then restored on the line after the assertion — if the 503 expectation fails, the restore is skipped and the rest of the file runs with apiKey="" until afterAll; should use try/finally like the budget test at lines 119-129 does. -## src/admin/ui/ProgressBar.tsx +## src/**tests**/auth.service.test.ts -- [LOW] Determinate LinearProgress has no accessible name (26). +- Clean -## src/admin/ui/RichText.tsx +## src/**tests**/auth.test.ts -- Clean — style-only `styled("div")`; never uses dangerouslySetInnerHTML. Verified both consumers (InvoiceDetail:1562, OrderDetail:760) DOMPurify-sanitize. No XSS. +- Clean -## src/admin/ui/Select.tsx +## src/**tests**/bank-accounts.test.ts -- [LOW] `slotProps as {...}` localized sound widening; spread order correct. Clean. +- Clean -## src/admin/ui/SidebarNav.tsx +## src/**tests**/company-settings-numbering.test.ts -- [LOW] Logo onError fallback relies on the fallback existing (no infinite loop). `& svg {width:18}` constraint documented; OdinMark overrides via wrapper + 70% !important (verified). Clean. +- Clean -## src/admin/ui/StatCard.tsx +## src/**tests**/customers.schema.test.ts -- Clean — uses shared iconBadgeSx (solid tile + white glyph). +- Clean -## src/admin/ui/StatusChip.tsx +## src/**tests**/drafts-aggregation.test.ts -- Clean. +- Clean -## src/admin/ui/Tabs.tsx +## src/**tests**/drafts-deferred-numbering.test.ts -- [LOW] TabPanel unmounts inactive content with no role="tabpanel"/aria-labelledby wiring (a11y-incomplete). +- [LOW] (line 112-118) "Deleting a draft" tests (also 183-189 and 267-274) never push the draft id into the cleanup array and never assert the delete service result — if deleteIssuedOrder/deleteInvoice/deleteOffer returns an error the test still passes (preview unchanged either way) and the row leaks into the shared app_test DB. -## src/admin/ui/TextField.tsx +## src/**tests**/env.test.ts -- Clean. +- [LOW] (line 36-68) The "failure path" describe asserts locally re-declared copies of the validation predicates (HEX64_RE, portValid, expiryValid) rather than exercising src/config/env.ts — it can never catch a regression in the real validation and drifts silently if env.ts changes (the comment acknowledges this, but only the happy-path assertions touch real code). -## src/admin/ui/ThemeToggle.tsx +## src/**tests**/exchange-rates.test.ts -- Clean — single ThemeContext source; aria-label+title present. +- Clean -## src/admin/ui/TimeField.tsx +## src/**tests**/helpers.ts -- [LOW] Module-level REF Date used read-only by date-fns (safe); validates via isValid. Clean. +- Clean -## src/admin/ui/index.ts +## src/**tests**/invoices.service.test.ts -- Clean (barrel). +- Clean -## src/admin/ui/navData.tsx +## src/**tests**/issued-orders.test.ts -- [LOW] Non-matchPrefix branch uses bare `pathname.startsWith(item.path)` (no boundary) — fragile if a `/x-foo` route is ever added (items needing exactness set end:true). Verbose inline SVG set. +- [LOW] (line 24-29) Vacuous-pass risk: expect(Number(b.number)).toBe(Number(a.number) + 1) — issued_order_number_pattern is settings-configurable, and for any non-numeric pattern both sides become NaN and vitest's toBe (Object.is) treats NaN===NaN as a pass; numbering.test.ts's assertStrictIncrement (which asserts the parsed suffixes are not NaN) exists precisely to avoid this. +- [LOW] (line 191-211) Cleanup gap: the order created in 'deletes, cascades items, frees the latest number' is never pushed to createdIds (unlike every other test in the file), so if any assertion before deleteIssuedOrder throws (e.g. expect(before).toBeTruthy() at line 198), the row and its consumed number leak into app_test and survive afterEach. -## src/admin/ui/useDialogScrollLock.ts +## src/**tests**/manual-create.test.ts -- [MEDIUM] Module-level lock state — production-safe (balanced [open] dep + cleanup), but a StrictMode/hot-reload double-invoke can transiently skew lockCount. -- [LOW] Stale comment references removed base.css (now GlobalStyles.tsx). +- [LOW] (line 157-183) Cleanup gap: in the deleteOrder regression test, orderId is never pushed to createdOrderIds and the auto-created project is never pushed to createdProjectIds (other tests push immediately after create), so a mid-test assertion failure before deleteOrder leaks both rows past afterEach into app_test. -## src/admin/pages/Warehouse.tsx +## src/**tests**/nas-document-manager.test.ts -- [LOW] Dead/duplicated `MovementRow` type drift (75); redundant per-column `?? 0`; cosmetic isLoading gating. Clean otherwise (read-only). +- Clean -## src/admin/pages/WarehouseCategories.tsx +## src/**tests**/nas-file-manager.test.ts -- [MEDIUM] `sort_order` via `parseInt(...)||0` no radix (308) — drops a legit 0 and truncates "3.9"->3; convention wants Number/NaN-guard. -- [LOW] Redundant `saving` state duplicates `submitMutation.isPending`. Broad ["warehouse"] invalidation correct. +- [LOW] (line 7-57) The first describe instantiates new NasFileManager() bound to the real env-configured NAS_PATH, and its two 'rejects path traversal' tests (lines 31-34, 53-56) pass vacuously: project 'PRJ-001' never exists, so resolveProjectPath returns null via findProjectFolder not-found — yielding the same 'Neplatná cesta' message whether or not the traversal guard fired. Only the tmp-dir tests at lines 105-127 genuinely exercise the guard; the first-describe traversal cases are redundant false confidence and should use the constructor seam like the rest. -## src/admin/pages/WarehouseInventory.tsx +## src/**tests**/nas-project-folder.test.ts -- [MEDIUM] `items` column renders `s.items?.length ?? 0` (125) but list endpoint omits items -> always shows 0; should use a \_count. Otherwise clean. +- Clean -## src/admin/pages/WarehouseInventoryDetail.tsx +## src/**tests**/numbering.test.ts -- [HIGH] Rules-of-Hooks: `return ` (70) precedes `useApiMutation` (72) -> hook-count mismatch crash on permission change. -- [LOW] `.../0/confirm` URL fallback is dead (handler guards null). Broad invalidation correct. +- Clean -## src/admin/pages/WarehouseInventoryForm.tsx +## src/**tests**/offer-invoice-totals.test.ts -- [HIGH] Rules-of-Hooks: `return ` (83) precedes `useApiMutation` (94). -- [MEDIUM] `actual_qty` via `Number(e.target.value)` no NaN guard (249) — clearing field -> NaN in payload (receipt/issue forms use parseDecimal). validate() doesn't check qty finite. +- Clean -## src/admin/pages/WarehouseIssueDetail.tsx +## src/**tests**/order-totals.test.ts -- [HIGH] Rules-of-Hooks: `return ` (73) precedes useApiMutation (77,89). -- [MEDIUM] Project link uses raw `` (316-330) not RouterLink -> full-page reload, breaks SPA nav. -- [LOW] Dead `/0/` URL fallbacks. +- [LOW] (line 22) Dead code: createdCustomerIds is declared and cleaned up in afterEach (lines 29-30) but never populated — verified createOrder/createIssuedOrder never create customer rows in these tests. +- [LOW] (line 157) Month zero-padding via template literal `0${STATS_MONTH}` (also lines 185-186 with STATS_MONTH+1) only works for single-digit months — changing STATS_MONTH to 9+ silently produces an invalid date string like "2097-010-15" instead of failing loudly. -## src/admin/pages/WarehouseIssueForm.tsx +## src/**tests**/orders-list.test.ts -- [HIGH] Rules-of-Hooks: `return ` (267) precedes useApiMutation/useState (335,347). -- [MEDIUM] `confirmIssue` setState(id) then immediately `await mutateAsync`; the url: closure reads pre-update id -> first confirm hits `/0/confirm`. Pass id as mutation variable. -- [LOW] Form-seeding effects (238,242) are effect-as-derived-state. +- Clean -## src/admin/pages/WarehouseIssues.tsx +## src/**tests**/plan.test.ts -- Clean (kit, FilterBar, PageEnter, debounce, \_count.items, keys). +- [LOW] (line 1161-1170) Silent `.catch(() => {})` on the noPerm user/role cleanup lacks the justifying comment the repo convention requires for intentionally-silent catches (the matching scope-user cleanup at lines 85-92 has one). -## src/admin/pages/WarehouseItemDetail.tsx +## src/**tests**/planAuditDescription.test.ts -- [HIGH] Rules-of-Hooks: `return ` (157) precedes useApiMutation (164,180). -- [MEDIUM] Error-handling effect navigates away (148-153) — effect-as-control-flow (could be a render branch). -- [LOW] Form-seed logic duplicated 3x (128/132/248-261); `useModalLock(editing)` (155) locks page scroll for an in-page (non-modal) edit form — likely unintended. +- Clean -## src/admin/pages/WarehouseItems.tsx +## src/**tests**/planCategory.test.ts -- Clean (kit, FilterBar, useTableSort, debounce, keys, rowInactive). +- [LOW] (line 37) Only test file that calls prisma.$disconnect() on the shared client in afterAll — harmless today because vitest isolates each file in its own worker, but inconsistent with every other suite and would break sibling files if isolation were ever turned off. -## src/admin/pages/WarehouseLocations.tsx +## src/**tests**/received-invoices-vat.test.ts -- [LOW] toggleMutation toasts in handler not onSuccess (inconsistent); dead czechPlural ternary. Guard correctly after hooks. Clean otherwise. +- [LOW] (line 49) Duplicated assertion with a stale comment: line 48 says the exact-equality pin is "replacing the toBeCloseTo above", but the original toBeCloseTo assertion at line 9 was never removed. -## src/admin/pages/WarehouseReceiptDetail.tsx +## src/**tests**/schema-nan.test.ts -- [HIGH] Rules-of-Hooks: `return ` (97) precedes useApiMutation (101,113,126). -- [LOW] Dead `/0/` URL fallbacks; attachment link safe (noopener). Broad invalidation correct. +- Clean -## src/admin/pages/WarehouseReceiptForm.tsx +## src/**tests**/setup.ts -- [HIGH] Rules-of-Hooks: `return ` (203) precedes useApiMutation/useState (269,281). -- [MEDIUM] Same stale-closure confirm bug as IssueForm (307-309) -> `/0/confirm` on first confirm. -- [LOW] Uses imperative document.createElement input + hand-rolled dropzone instead of kit FileUpload (620-633). +- Clean -## src/admin/pages/WarehouseReceipts.tsx +## src/**tests**/warehouse.test.ts -- Clean (kit, FilterBar, debounce, \_count.items, keys). +- [LOW] (line 91-105) Cleanup order bug: sklad_items.deleteMany runs before sklad_inventory_lines.deleteMany (same in afterAll, 209-223), but sklad_inventory_lines.item_id FK-references sklad_items with onDelete: Restrict — leftover inventory-line test data would make the whole suite crash in beforeAll; latent today only because this file never creates inventory lines. +- [LOW] (line 110-118) Silently swallowed errors: .catch(() => {}) on users/roles/projects cleanup without the explanatory comment the repo's 'never silently swallow' convention requires (the afterAll equivalents at 229-238 do have one); a masked FK-constraint failure here leaves a stale wh*test*\* user that makes the next run fail on the unique-username create with a misleading P2002 instead of the real cause. -## src/admin/pages/WarehouseReports.tsx +## src/admin/AdminApp.tsx -- [MEDIUM] Project-consumption & movement-log tabs fetch via raw apiFetch + useState (263-290,386-413) not React Query — results NOT in ["warehouse"] cache, won't refresh on mutations; existing query options unused. -- [LOW] Duplicated MovementLogRow type (46); empty `catch {}` -> setData([]) swallows fetch errors (285,408); raw `row.date` not formatDate'd (426). +- Clean -## src/admin/pages/WarehouseReservations.tsx +## src/admin/GlobalStyles.tsx -- [MEDIUM] Create & cancel use raw apiFetch + manual invalidate (170-217) not useApiMutation (broad key correct, but plumbing duplicated). -- [LOW] Empty catches don't log; `quantity` via Number() no NaN guard (457) and `NaN<=0` is false so NaN can be submitted. - -## src/admin/pages/WarehouseSuppliers.tsx - -- [MEDIUM] `toggleActive` setState(id) then immediate `await mutateAsync` — url: closure reads null id -> PUT hits the collection root (wrong endpoint) on first toggle. Pass id as mutation variable. -- [LOW] Redundant `saving` state. Guard correctly after hooks. Clean otherwise. - -## src/admin/pages/Attendance.tsx - -- [MEDIUM] `calculateBusinessDays` parses YYYY-MM-DD via `new Date()` (UTC midnight) + local `getDay()` (480-488) — can mis-count business days in the evening Prague window. -- [LOW] leaveRequestMutation invalidates attendance/leave-requests/users but NOT ["leave"] (303) — approver's pending queue (["leave","pending"]) won't auto-refresh; live clock (676) never ticks; notes synced via effect can clobber unsaved edits (336-340). todayLocalStr used (good). - -## src/admin/pages/AttendanceAdmin.tsx - -- Clean (thin presenter over useAttendanceAdmin). - -## src/admin/pages/AttendanceBalances.tsx - -- [HIGH] Rules-of-Hooks: `return ` (171) precedes useApiMutation (173,189). -- [LOW] parseFloat on vacation/sick fields -> NaN on empty (770,781,794) into payload. - -## src/admin/pages/AttendanceCreate.tsx - -- [LOW] `leave_hours: parseFloat()` -> NaN on cleared field (190). todayLocalStr used; broad invalidation. Clean otherwise. - -## src/admin/pages/AttendanceHistory.tsx - -- [MEDIUM] ~865-line file with inline print-HTML template + hidden print JSX — split candidate. -- [LOW] Duplicates holiday/business-day logic that also exists in attendanceHelpers (84-149). normalizeDateStr used; hooks before guard. Clean otherwise. - -## src/admin/pages/AttendanceLocation.tsx - -- [LOW] Dead LoadingState branch (199 unreachable, `!record` returns null at 187 while pending). Popup via createElement/textContent (no XSS); Leaflet cleanup correct. Clean. - -## src/admin/pages/AuditLog.tsx - -- [HIGH] Rules-of-Hooks: `return ` (185-187) precedes useApiMutation (189). -- keepPreviousData, FilterBar, broad invalidation, shared ENTITY_TYPE_LABELS. Otherwise clean. - -## src/admin/pages/CompanySettings.tsx - -- [MEDIUM] ~1503-line file — split candidate (info/banking/numbering/currency-VAT/logo cards). -- [MEDIUM] Uses deprecated MUI `inputProps` (1312) — silently ignored in v7 so min/step not applied (rest of file uses slotProps.htmlInput). -- [LOW] Doesn't wrap in PageEnter (raw motion.div per card — intentional embedded mode); fieldOrder index logic fragile. Blob cleanup correct. - -## src/admin/pages/Dashboard.tsx - -- [LOW] DashData hand-rolled with `[key]:unknown` index sig + `as DashData` cast (76,88) bypasses query typing; 2FA handlers use raw apiFetch + manual invalidate (179-211) not useApiMutation. getCzechDate used (good); gated sections. Clean otherwise. - -## src/admin/pages/InvoiceDetail.tsx - -- [MEDIUM] ~2243-line file — split candidate (SortableInvoiceRow / paid read-only view / create-edit form). -- [MEDIUM] UTC anti-pattern `new Date(...).toISOString().split("T")[0]` for due_date default (494), issue/due/tax dates (645-651) and computedDueDate (818-823) — the latter reaches the saved payload (977), persisting a day-early due date in evening Prague. -- [LOW] Editable `notes` state never wired to an input in non-paid mode (only form.notes saved) — confirm intended. DOMPurify correctly applied before the one dangerouslySetInnerHTML (1562) — no XSS. Money math guarded; blob cleanup correct. - -## src/admin/pages/Invoices.tsx - -- [LOW] handleDelete/toggleStatus/handlePdf use raw apiFetch + manual invalidate (broad keys, correct behavior, off-convention); overdue rowSx UTC-vs-local compare off by a day near midnight (506-507, visual only). Clean otherwise. - -## src/admin/pages/LeaveApproval.tsx - -- [HIGH] Rules-of-Hooks: `return ` (158) precedes useApiMutation (160,173). -- Broad invalidation on leave-requests/leave/attendance/users; reject requires note. Otherwise clean. - -## src/admin/pages/LeaveRequests.tsx - -- Clean — all hooks (useApiMutation:84) precede the guard (97); broad invalidation; truncation via title attr. - -## src/admin/pages/Login.tsx - -- [LOW] `verify2FA(loginToken!, ...)` non-null assertion (105) — a null token (server returns requires2FA w/o token) would pass `null!`. Otherwise secure: tokens via useAuth, no persisted token state, TOTP input strips non-digits, autoComplete=one-time-code, renders outside AppShell. - -## src/admin/pages/NotFound.tsx - -- Clean. - -## src/admin/pages/Odin.tsx - -- Clean (permission gate + OdinChat; PageEnter wrap). - -## src/admin/pages/OfferDetail.tsx - -- [HIGH] Heartbeat effect (730) deps omit `isCompleted` though the guard reads it (731) — lock heartbeat keeps running after transition to dokoncena (stale closure). -- [MEDIUM] ~2059-line file — split candidate (SortableItemRow / scope-sections / draft logic). -- [LOW] `payload: any` (884) and item-update casts; parseInt/parseFloat on qty/price yield NaN on cleared field (rendered until totals guard). Broad invalidation correct; draft parse guarded; blob revoked. - -## src/admin/pages/Offers.tsx - -- [LOW] Expired check uses idiosyncratic `new Date(new Date().toDateString())` (658, functional). Otherwise clean (broad keys, kit, blob revoke, keys). - -## src/admin/pages/OffersCustomers.tsx - -- [LOW] parseInt without radix on `key.split("_")[1]` (239,259,675, harmless decimal). Otherwise clean (custom-field re-key correct, broad invalidation, a11y). - -## src/admin/pages/OffersTemplates.tsx - -- [LOW] openEdit fetches via raw apiFetch (bypasses RQ cache) for on-demand modal. Guard is before a hooks-free body (no violation). Otherwise clean. - -## src/admin/pages/OrderDetail.tsx - -- [CRITICAL] Rules-of-Hooks: 3 useApiMutation (181,187,193) AFTER `return ` (179) — crash when orders.view absent / permission flips. -- [LOW] `valid_transitions?.filter(...).length! > 0` (466) misleading non-null assertion. Section rich text DOMPurify-sanitized before dangerouslySetInnerHTML (760) — no XSS. Broad invalidation; blob cleanup. - -## src/admin/pages/Orders.tsx - -- [LOW] vat_rate sent as raw string in JSON create (272, relies on Zod coercion). Otherwise clean — Forbidden return (312) after all hooks; broad invalidation; keys. - -## src/admin/pages/PlanWork.tsx - -- [LOW] Several mutation callbacks use `body: any`/`data: any` (185,395,412,476,491). `isoDate` toISOString().slice(0,10) is correct (Plan UTC convention, not a finding). Forbidden return after hooks; pulse timer cleaned; todayIsoLocal used. Clean otherwise. - -## src/admin/pages/ProjectDetail.tsx - -- [CRITICAL] Rules-of-Hooks: 4 useApiMutation (179,194,203,209) AFTER `return ` (177). -- [LOW] formatNoteDate duplicates utils/formatters date logic. Broad invalidation (projects/warehouse); keys. - -## src/admin/pages/Projects.tsx - -- [LOW] List query uses raw `search` not debounced (219) — every keystroke refetches (Offers/Orders debounce). Hooks (useApiMutation 124,173) precede the guard (222) — correct ordering. Broad invalidation; keys. - -## src/admin/pages/ReceivedInvoices.tsx - -- [MEDIUM] `toDateInput` uses `toISOString().split("T")[0]` (453) to seed edit-form dates — forbidden UTC-split (day-early evening Prague). Use normalizeDateStr. -- [MEDIUM] ~1237-line file — split candidate (upload modal + edit modal). -- [LOW] Render-phase ref mutation `hasLoadedOnce.current=true` (279); loose parseFloat typing (557). VAT-from-gross preserved; broad invalidation; blob cleanup. - -## src/admin/pages/Settings.tsx - -- [HIGH] Rules-of-Hooks: 5 useApiMutation (319,339,354,372,390) AFTER `return ` (315) — useQuery/useMemo/useState are above, mutations are not. -- [MEDIUM] ~1354-line file — split candidate (roles / system-settings / system-info). -- [LOW] `systemInfo as Record` (679); O(n^2 log n) permission-group sort (1243, negligible). Tab state derived from URL (good); broad invalidation. - -## src/admin/pages/Trips.tsx - -- [HIGH] Rules-of-Hooks: 2 useApiMutation (190,204) AFTER `return ` (188). -- [LOW] Dead `[,setLastKm]` state (186, only setter used); parseInt without radix (287,315-316). todayLocalStr used; broad invalidation (trips/vehicles); keys. - -## src/admin/pages/TripsAdmin.tsx - -- [HIGH] Rules-of-Hooks: 2 useApiMutation (236,255) AFTER `return ` (231). -- [LOW] handlePrint uses raw printWindow.document.write of app data (344-417) — low XSS surface (React-escaped innerHTML) but fragile; iframe/template safer. Broad invalidation; keys. - -## src/admin/pages/TripsHistory.tsx - -- [LOW] `totals` computed before the Forbidden return (wasteful when unauthorized) — but all hooks precede the return (no violation). Read-only; kit; keys. Clean. - -## src/admin/pages/UiKit.tsx - -- Clean (dev-only showcase). - -## src/admin/pages/Users.tsx - -- [HIGH] Rules-of-Hooks: 3 useApiMutation (126,152,162) AFTER `return ` (173). -- [LOW] `roles[0]?.id ? ... : ""` default would skip a role id of 0 (never 0 in practice). Shared broad USER_INVALIDATE array (good); self-deactivation guarded; keys. - -## src/admin/pages/Vehicles.tsx - -- [HIGH] Rules-of-Hooks: 3 useApiMutation (121,136,146) AFTER `return ` (162). -- [LOW] Raw MUI `` instead of kit StatusChip (264); `select: data as unknown as Vehicle[]` double-cast (100); parseInt without radix (404). Broad invalidation (vehicles/trips); rowInactive; keys. +- Clean ## src/admin/components/AlertContainer.tsx -- [LOW] Stacking offset keyed off array index (15) — removing a middle alert reflows/can overlap with 3+ alerts. Keys use alert.id. Otherwise clean. +- Clean ## src/admin/components/AttendanceShiftTable.tsx -- [LOW] Project-cell key falls back to index when log.id nullish (119); duration math not memoized. Otherwise clean (no any, keys, no effects). +- Clean ## src/admin/components/BulkAttendanceModal.tsx -- [LOW] Select-all label shows "Odznacit vse" when users empty (BulkPlanModal guards this — inconsistent). Otherwise clean. +- Clean ## src/admin/components/BulkPlanModal.tsx -- Clean (kit, derived activeCategories, keys, select-all guarded, no any). +- Clean ## src/admin/components/ErrorBoundary.tsx -- [LOW] componentDidCatch only console.errors (22) — no telemetry hook. Correct boundary pattern otherwise. +- Clean ## src/admin/components/Forbidden.tsx -- Clean. +- Clean ## src/admin/components/OrderConfirmationModal.tsx -- [MEDIUM] Local state (step/items/applyVat/lang) never reset on reopen — component is permanently mounted, toggled by isOpen; reopening shows prior step/edits and items captured once via useState(initialItems) go stale. -- [MEDIUM] `finally` closes the modal even when onGenerate throws (58-77) — a generation error drops edited items with only a transient toast. -- [LOW] Editable rows key={i} — deleting a non-last row shifts indices (focus jumps; data stays correct since controlled). +- Clean ## src/admin/components/PlanCategoriesModal.tsx -- [LOW] Label TextField uncontrolled defaultValue with no key={c.label} (148) — won't re-seed on external rename (color input does add key); inline delete-confirm instead of kit ConfirmDialog. Broad ["plan","dashboard","audit-log"] invalidation correct; onBlur-commit. +- [LOW] (line 147-158) Rename TextField is uncontrolled and keyed on c.label, so when the PATCH fails (onError only alerts) the input keeps showing the user's unsaved text with no remount/reset — the row silently displays a value that differs from the server's label. ## src/admin/components/PlanCellModal.tsx -- [MEDIUM] Props onSaveEntry/onUpdateEntry/onSaveOverride/onUpdateOverride typed `any` (77-82) — untyped save payloads on the most bug-prone path. -- [LOW] EditForm derives state from `initial` via useState (201-206) relying on parent remount per record (no key reset — fragile if parent swaps entryId same-kind); odd Czech phrasing (415). Discriminated-union mode + kit otherwise strong. +- [MEDIUM] (line 276-333) handleSubmit/handleDelete are try/finally with no catch: PlanWork's handlers (PlanWork.tsx:394-584) alert then rethrow, and Modal.tsx:93 / ConfirmDialog.tsx:88 invoke onSubmit/onConfirm as bare onClick handlers, so every failed save/delete surfaces as an unhandled promise rejection. +- [MEDIUM] (line 476-479) DayInRangeModal's 'Vytvořit přepsání' async onClick has no submitting/disabled state — a double-click fires onCreateOverrideFromRange twice and the server is additive up to 3 records per day (plan.service.ts:690-713, no unique constraint on user_id+shift_date), creating duplicate identical overrides; its rejection is also unhandled (parent rethrows after alerting). ## src/admin/components/PlanGrid.tsx -- [LOW] `projects.find(...)` inside per-cell render loop (612-616) O(days*users*cells\*projects) — build a useMemo Map (rarely hit since server embeds label); eachDay/days recomputed every render (not memoized). todayIso uses local Y/M/D (correct); thorough aria-labels. +- [LOW] (line 427-433) todayIso() is a third hand-rolled copy of the canonical local-today helper (todayLocalStr in src/admin/utils/formatters.ts; PlanWork.tsx:103 has another, todayIsoLocal) — implementation is correct (local components, not toISOString slicing), but 'today' should have a single source per the conventions. +- [LOW] (line 486-493, 583) The pulse 'nonce' re-trigger mechanism doesn't work as the comments claim: for back-to-back mutations on the same cell the button's className is unchanged and no CSS selector references the root's data-pulse attribute, so changing it cannot restart the descendant keyframe animation — repeat pulses on the same cell never re-animate. ## src/admin/components/PlanRangeChips.tsx -- [LOW] `readonly` prop accepted then discarded via `void readonly` (17) — dead prop (PlanGrid still passes it). className styling intentional (classes defined in PlanGrid root). Otherwise clean. +- Clean ## src/admin/components/ProjectFileManager.tsx -- [MEDIUM] Drag-over has no drag-depth counter — handleDragLeave (377-380) fires on every child boundary -> drop overlay flickers during drag. -- [MEDIUM] File ops use raw apiFetch + manual invalidate of sub-key `["projects",id,"files"]` (349-351) not broad ["projects"] (deliberate scope, documented deviation). -- [LOW] `catch {}` substitutes a generic toast but never logs the real error (336,408,438,471,508); sequential per-file upload; breadcrumb join breaks if a folder name contains "/". Escape/Enter handling + canManage gating correct. +- [MEDIUM] (line 355-357) All four mutations (upload, create-folder, delete, rename at 355-357, 419-421, 484-486, 522-524) invalidate only the targeted ["projects", id, "files"] key instead of the broad ["projects"] domain mandated by the convention; the project detail query ["projects", id] is never invalidated, so has_nas_folder (the hasNasFolder prop driving the empty-state text) stays stale after the first upload auto-creates the NAS folder. +- [LOW] (line 50-135) File-type icon colors are hardcoded hex (#e6a817, #e74c3c, #3498db, rgba(230,168,23,0.15), ...) instead of theme tokens; only the fallback at 138 correctly uses var(--mui-palette-text-secondary) — violates the theme.vars color convention. +- [LOW] (line 594-614) Rename has no in-flight guard: pressing Enter starts the async handleRename while renamingItem stays set, so a subsequent blur (or second Enter) fires handleRename again against the already-renamed path, producing a spurious 'Nepodařilo se přejmenovat' error alert after a successful rename. ## src/admin/components/RichEditor.tsx -- [LOW] `_delta: any` on the Quill onChange param (91, react-quill-new loose types — could be unknown); useLayoutEffect font-set no-ops if ref not ready (mounts sync in practice). Never injects HTML — consumers DOMPurify-sanitize. No XSS. +- [MEDIUM] (line 78, 93-97) lastValueRef is initialized once from the initial value prop and only updated on user edits; external prop changes (form reset / switching records arrive as source 'api' and are ignored), so a later user change whose content exactly equals the stale ref (e.g. re-pasting the same text after a reset) is silently dropped — onChange never fires and parent state desyncs from what the editor displays, losing the content on save. ## src/admin/components/ShiftFormModal.tsx -- [MEDIUM] `leave_hours` via parseFloat (330-331) writes NaN to form state on empty input — serializes to invalid; should fall back to 0 like the Number(...)||0 pattern. -- [LOW] Module-level mutable `_logKeyCounter` (20) shared across instances (fragile under StrictMode/concurrent instances) — use a per-instance ref/randomUUID; server-loaded logs may lack \_key -> index-key risk. Derived isWorkType/isCreate + kit otherwise correct. +- Clean ## src/admin/components/ShortcutsHelp.tsx -- [LOW] Entire component is `return null` — dead stub; remove the file + references, or wire it up. +- Clean ## src/admin/components/dashboard/DashActivityFeed.tsx -- Clean (escaped React children, single-source ENTITY_TYPE_LABELS). +- Clean ## src/admin/components/dashboard/DashAttendanceToday.tsx -- [LOW] Key `${u.user_id}-${i}` index-suffixed (75); user_id alone is unique. Clean. +- Clean ## src/admin/components/dashboard/DashKpiCards.tsx -- [LOW] buildKpiCards recomputed every render (124, cheap); framer-motion entrance has no reduced-motion branch (JS-driven animate not stopped by the GlobalStyles CSS reset) — applies to DashQuickActions/DashProfile/DashSessions too. Clean otherwise. +- Clean ## src/admin/components/dashboard/DashProfile.tsx -- [MEDIUM] handleSubmit (175-197) uses raw apiFetch and invalidates NO React Query domain after a profile PUT — anything keyed on user data goes stale. -- [LOW] `delete (dataToSave as any).current_password` casts (172-173); magic 300ms sleep before success toast (189); no reduced-motion branch. +- [HIGH] (line 556) The QR from api.qrserver.com is blocked by the production CSP (src/middleware/security.ts:30 img-src only allows 'self' data: blob: and \*.tile.openstreetmap.org), so every production 2FA enrollment shows a broken image and users must fall back to typing the secret manually. _(verified)_ +- [MEDIUM] (line 556) 2FA setup sends the full otpauth:// URI — including the plaintext TOTP secret and the account label — as a GET query param to the third-party service api.qrserver.com; the live 2FA secret leaves the system (and lands in that service's logs). Generate the QR locally (client-side QR lib) instead. _(severity adjusted by verification: The code at DashProfile.tsx:556 really does embed the full otpauth:// URI (secret included, from totp.ts:50) in an api.qrserver.com GET URL, but in production the global CSP (security.ts:30, img-src 'self' data: blob: https://\*.tile.openstreetmap.org, applied via server.ts:99 to the served index.html) blocks the request entirely — the secret never egresses in prod; instead the QR renders broken and users fall back to the manual-key entry (DashProfile.tsx:568). The flaw is real (secret leaks in non-CSP local/dev runs, and prod QR enrollment is silently broken) and should be fixed with a local QR render (the `qrcode` dep already exists), but the headline "live secret leaves the system" does not hold in production, so HIGH is overstated — MEDIUM gap/risk.)_ ## src/admin/components/dashboard/DashQuickActions.tsx -- [HIGH] handleTripSubmit (157-176) POSTs a trip via raw apiFetch and NEVER invalidates ["trips"]/["dashboard"] (no useQueryClient imported) — list/dashboard stay stale until manual refetch. -- [MEDIUM] `result` from .json() untyped any (96,116,164); last_km assigned into a string field with no String() coercion. -- [LOW] Empty catches don't log (104-106,120-122); parseInt without radix. tripDistance derived (good); todayLocalStr used. +- [MEDIUM] (line 105-139) openTripModal (109-115) and handleTripVehicleChange (130-135) silently ignore success:false API responses — no log, no alert, and the vehicle list/start-km just stays empty or stale, violating the never-silently-swallow rule (only the network-error catch logs). +- [LOW] (line 121-139) Stale-response race: rapidly switching vehicles fires concurrent /trips/last-km/:id fetches with no cancellation or selected-id check, so a late response for the previous vehicle can overwrite start_km for the currently selected one. ## src/admin/components/dashboard/DashSessions.tsx -- [LOW] Correctly invalidates ["sessions"]; shared `deleting` flag (one dialog at a time); no reduced-motion branch. Clean. +- [MEDIUM] (line 101, 122) Both delete handlers use a bare `catch { alert.error(...) }` that discards the error object without logging it (console.error), violating the repo's "every catch logs" convention — the generic 'Chyba připojení' toast loses all diagnostics. ## src/admin/components/dashboard/DashTodayPlan.tsx -- [LOW] key={i} on plans (96, tiny static list); color-mix bgcolor (43, modern-browser only). Clean. +- Clean ## src/admin/components/odin/InvoiceReviewCard.tsx -- Clean (escaped fields, typed props, no XSS). +- Clean ## src/admin/components/odin/OdinChat.tsx -- [LOW] saveInvoice invalidates ["invoices"] (353) — broad key DOES cover received invoices (keyed ["invoices","received"]), so correct; persist() is fire-and-forget (failure only console.error, user not told history unsaved). Seed/auto-scroll effects are legitimate. Invoice-only guard correct. +- [MEDIUM] (line 254-282) Extraction response handling silently drops per-file `error` entries (filtered out at 261-279 with no per-file notice) and never reads the server's `truncated` flag (routes/admin/ai.ts:141, set when the budget halts the batch mid-way), so failed or unprocessed files vanish without feedback; an all-failed batch reads as the misleading 'V přílohách jsem nenašel žádnou fakturu'. +- [MEDIUM] (line 185-200) ensureActive returns null on a non-ok POST /conversations response (193) without logging or alerting, and submit then skips persist() — the just-sent turn is silently never saved to history (silent error swallow). +- [MEDIUM] (line 287, 315-318) If ensureActive's apiFetch throws (network) AFTER a successful, billed extraction, the catch rolls back the thread to prevTurns (losing the assistant summary), shows the wrong error 'Chyba čtení faktur', yet leaves the review cards and already-cleared attachments — inconsistent UI state for a non-extraction failure. +- [MEDIUM] (line 337-339) saveInvoice coerces user-edited free-text with raw Number(): a Czech-format amount like "1 234,56" becomes NaN, which JSON.stringify serializes as null, and the server's non-nullable nonNegativeNumberFromForm (received-invoices.schema.ts:10) rejects it with a 400 — no client-side number validation or comma handling before submit. +- [MEDIUM] (line 374-400) onRename/onDelete handle !res.ok but have no try/catch around apiFetch, and OdinSidebar invokes them un-awaited (OdinSidebar.tsx:53,61) — a network failure becomes an unhandled promise rejection with no user feedback and no log. ## src/admin/components/odin/OdinComposer.tsx -- [LOW] `fileRef as RefObject` unnecessary cast (48); Enter-to-send calls onSubmit even when !canSubmit (parent re-guards — no double-send). Clean. +- [MEDIUM] (line 50) File input accept="application/pdf,image/_" invites image attachments, but the backend hardcodes media_type "application/pdf" for every uploaded file (src/services/ai.service.ts:317-321), so any attached image is sent to the Anthropic API as a malformed PDF and always fails with 'Nepodařilo se přečíst fakturu' — drop image/_ or support images server-side. ## src/admin/components/odin/OdinMark.tsx -- Clean — reduced-motion branch correct (opacity glow + documented !important); shouldForwardProp filters thinking/reduce. +- Clean ## src/admin/components/odin/OdinSidebar.tsx -- [LOW] Conversation row is a clickable (128) — not a button/no role/tabIndex, so not keyboard-focusable (a11y gap); kebab has aria-label. Clean otherwise. +- [MEDIUM] (line 132-140) Row onKeyDown doesn't check e.target, so Enter/Space on the focused kebab IconButton bubbles to the row, preventDefault() cancels the button's native click activation, and the row gets selected instead — rename/delete menu is unreachable by keyboard; the comment claiming the nested button 'handles its own keys' is wrong (stopPropagation is only on the click event, which never fires). ## src/admin/components/odin/OdinThread.tsx -- [LOW] Bubble key={i} (174, append-only so stable); content via Typography pre-wrap (no XSS); reduced-motion gated. Clean. +- Clean ## src/admin/components/odin/types.ts @@ -1002,329 +451,1130 @@ Timing-safe login (dummy-bcrypt on every failure path), refresh-token **rotation ## src/admin/components/warehouse/ItemPicker.tsx -- [MEDIUM] useQuery(warehouseItemListOptions({search: inputValue})) fires on EVERY keystroke with no debounce (30-32) — request spam; useDebounce hook exists. renderOption correctly extracts key (good). +- [LOW] (line 53-55) The Autocomplete is clearable (no disableClearable) but onChange ignores null options, so the clear (X) button is a silent no-op — the parent keeps the old itemId and the selected name snaps back on blur; either propagate the clear or set disableClearable. ## src/admin/components/warehouse/ReservationPicker.tsx -- [MEDIUM] On item/project change, if the selected `value` is no longer in `reservations`, the component doesn't reset/notify the parent — parent can hold a stale reservationId. Redundant Number() casts (41,49). Typed (no any). - -## src/admin/AdminApp.tsx - -- [LOW] No RequireAuth wrapper visible in the router — auth gating delegated to AppShell/pages (a new top-level route outside AppShell would be public by default); Dashboard eagerly imported (not lazy) (13). Clean otherwise. - -## src/admin/GlobalStyles.tsx - -- Clean. - -## src/admin/theme.ts - -- [LOW] containedPrimary hover/active transform lacks a prefers-reduced-motion guard present on MuiButton.root/MuiOutlinedInput (89-95); iconBadgeSx key cast loses type-safety (248, behavior fine). Clean otherwise. - -## src/admin/theme.test.ts - -- [LOW] Heavy `as any`/`as unknown as ...` to reach theme internals (deliberate test shortcut). Clean. - -## src/App.tsx - -- [LOW] AdminLoader reads data-theme once at render — dark fallback on cold load before ThemeContext sets it (acceptable). Clean otherwise. - -## src/main.tsx - -- Clean. - -## src/vite-env.d.ts - -- Clean. - -## src/context/ThemeContext.tsx - -- [MEDIUM] Theme single-source is correct (mui-mode only; legacy boha-theme read-once-then-deleted) BUT the "dark" default is hardcoded twice (here:30 and MuiProvider.tsx:11) — change one without the other and page (MUI) vs toggle desync on first paint; reconcile to one constant. -- [LOW] No `storage` event listener — two tabs won't sync theme until reload. View-Transition/flushSync + reduced-motion fallback correct. +- [LOW] (line 1-72) Dead code: ReservationPicker is not imported anywhere in src/ (verified by grep — only its own file matches); the warehouse issue form does not use it. ## src/admin/context/AlertContext.tsx -- Clean (timeouts tracked in a ref Set, cleared on unmount; counter ref avoids key collisions; split state/methods contexts). +- [LOW] (line 19, 53-57) addAlert accepts type?: string and force-casts it (type as Alert["type"]) — a typo'd severity string flows unchecked into the Alert union; the parameter should be typed as the "success" | "error" | "warning" | "info" union. ## src/admin/context/AuthContext.tsx -- [MEDIUM] `apiRequest` (318-350) duplicates the refresh+retry logic of utils/api.ts apiFetch — two parallel 401-retry paths with DIVERGENT behavior (apiRequest ignores abort signals and doesn't set the session-expired flag). Consolidate onto apiFetch. -- [MEDIUM] Proactive-refresh timer fires a closure-captured `silentRefresh` (memoized [] + eslint-disable, 117-125); works only because everything is read through refs — fragile cyclic setup. -- [LOW] In-flight refresh not cancelled on logout (a resolving refresh could re-setUser); checkSession maps user twice (181-182). Token held in-memory only (secure). Dedup + expires_in proactive refresh otherwise correct. - -## src/admin/utils/api.ts - -- [LOW] Module-singleton `state` with injected getTokenFn/refreshFn couples a module global to React lifecycle (resetApiState for tests); on a 401 with aborted signal returns the 401 response not the 499 sentinel used elsewhere (inconsistent); its refreshPromise dedup is a second single-flight layer alongside AuthContext's. Up-front refresh/abort/FormData handling correct. - -## src/admin/utils/attendanceHelpers.ts - -- [MEDIUM] getTimePart (105-109) uses `new Date(datetime).getHours()` (TZ-dependent) while sibling extractTime parses the string to avoid TZ conversion — can yield a different hour than displayed at DST/offset edges, violating the file's own stated ethos. -- [LOW] getLeaveTypeBadgeClass returns badge-\* classes that exist only in AttendanceHistory's print