Files
app/REVIEW_FINDINGS.md
BOHA b4f859ea6e docs: codify audit rules in CLAUDE.md + README + audit records
CLAUDE.md: new "2026-06-09 audit" enforced-rules section (mandatory coercion
helpers, deterministic id-tiebreak ordering, $queryRaw FOR-UPDATE locking
discipline, uniqueness-in-transaction, guard reads, Rules-of-Hooks, seed prod
guard), lint/typecheck/format commands, and the app_test/.env.test testing
section with the hard-throw guard.

README rewritten from the placeholder stub (setup/run/build/test/gates/migrations).

REVIEW.md / REVIEW_FINDINGS.md / REVIEW_FIXES.md: the full audit record — 277
files reviewed, ~362 findings, every one fixed and verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:37 +02:00

1331 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Codebase Audit — Findings
> 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.
---
## Summary
**Files reviewed:** 277 / 277 (100%) — every tracked source/config file plus `CLAUDE.md`. 8 files came back fully clean.
**Findings by severity:** **2 CRITICAL · 44 HIGH · 115 MEDIUM · 201 LOW** (≈362 total, including 9 project-level findings).
**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,3002,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.
**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.
**Top 5 to fix first:**
1. **Systemic Rules-of-Hooks violation (2 CRITICAL + ~11 HIGH).** ~13 detail/form pages declare `useApiMutation` _after_ an early `return <Forbidden/>` (`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.
**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.
---
## PROJECT-LEVEL
### System core (what this app actually is)
A **single-package full-stack monolith**, and — contrary to the "mess from many AIs" worry — the bones are coherent and conventional:
- **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.
**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.
### [HIGH] No linter anywhere; formatter is unpinned and undeclared
- 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 23 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.
---
## src/server.ts
- [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.
## src/config/env.ts
- [LOW] Global `Date.prototype.toJSON` override (lines 14-22) — documented PHP-compat gotcha; app-wide. Strong boot-time secret/port validation. Clean.
## src/config/database.ts
- Clean — single PrismaClient singleton.
## src/middleware/security.ts
- [LOW] CSP/HSTS prod-only (line 18); `style-src 'unsafe-inline'` required by Emotion; no `report-uri`. Clean.
## src/middleware/auth.ts
- 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<string,any> 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<string,any> 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
- 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<start_km guard is bypassed when either is NaN (NaN compare = false) and Number() then writes NaN to Prisma. Real data-integrity bug.
- [LOW] route_from/route_to unbounded; trip_date no regex; plain objects.
## src/schemas/users.schema.ts
- [MEDIUM] password .min(8) no .max() (6,22-25) — unbounded to bcrypt.
- [LOW] role_id unguarded NaN; username/names no .max(); plain objects.
## src/schemas/vehicles.schema.ts
- [LOW] initial_km/actual_km unguarded NaN + no .min(0) (negative odometer); spz/name no .max()/plate regex; plain objects.
## src/schemas/warehouse.schema.ts
- [HIGH] All movement quantities/prices (ReceiptItem/IssueItem/InventoryItem/Reservation, 124-219) use the unguarded idiom — NO NaN guard, NO .min/positivity; confirmed these feed stock math in warehouse.service -> 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
- 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
- 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
- Clean
## src/types/index.ts
- [LOW] JwtPayload.role vs AuthData.roleName (51,60) — the documented footgun that invites authData.role==="admin"; no bug here but a hazard.
## prisma/seed.ts
- [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.
## scripts/migrate-received-invoices-to-nas.ts
- [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).
## scripts/rotate-totp-key.ts
- [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.
## 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).
## src/admin/ui/Alert.tsx
- [LOW] MUI close button gets default English aria-label "Close" amid otherwise-Czech UI (18). Clean otherwise.
## src/admin/ui/AppShell.tsx
- [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.
## src/admin/ui/Button.tsx
- Clean (sound polymorphic `component` pattern, caller sx wins).
## src/admin/ui/Card.tsx
- [MEDIUM] Always wraps children in CardContent with no opt-out/contentProps — edge-to-edge content (media/table) can't use this primitive. Otherwise clean.
## src/admin/ui/Checkbox.tsx
- [MEDIUM] No disabled/name/id/indeterminate/required passthrough — minimal API forces callers to raw MUI. Otherwise clean.
## 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 <img> won't scale). Clean.
## src/admin/ui/Field.tsx
- [HIGH] `<Typography component="label">` (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
- Clean
## src/admin/ui/Pagination.tsx
- Clean (clamps page, null for single page).
## src/admin/ui/ProgressBar.tsx
- [LOW] Determinate LinearProgress has no accessible name (26).
## src/admin/ui/RichText.tsx
- Clean — style-only `styled("div")`; never uses dangerouslySetInnerHTML. Verified both consumers (InvoiceDetail:1562, OrderDetail:760) DOMPurify-sanitize. No XSS.
## src/admin/ui/Select.tsx
- [LOW] `slotProps as {...}` localized sound widening; spread order correct. Clean.
## src/admin/ui/SidebarNav.tsx
- [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.
## src/admin/ui/StatCard.tsx
- Clean — uses shared iconBadgeSx (solid tile + white glyph).
## src/admin/ui/StatusChip.tsx
- Clean.
## src/admin/ui/Tabs.tsx
- [LOW] TabPanel unmounts inactive content with no role="tabpanel"/aria-labelledby wiring (a11y-incomplete).
## src/admin/ui/TextField.tsx
- Clean.
## src/admin/ui/ThemeToggle.tsx
- Clean — single ThemeContext source; aria-label+title present.
## src/admin/ui/TimeField.tsx
- [LOW] Module-level REF Date used read-only by date-fns (safe); validates via isValid. Clean.
## src/admin/ui/index.ts
- Clean (barrel).
## src/admin/ui/navData.tsx
- [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.
## src/admin/ui/useDialogScrollLock.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).
## src/admin/pages/Warehouse.tsx
- [LOW] Dead/duplicated `MovementRow` type drift (75); redundant per-column `?? 0`; cosmetic isLoading gating. Clean otherwise (read-only).
## src/admin/pages/WarehouseCategories.tsx
- [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.
## src/admin/pages/WarehouseInventory.tsx
- [MEDIUM] `items` column renders `s.items?.length ?? 0` (125) but list endpoint omits items -> always shows 0; should use a \_count. Otherwise clean.
## src/admin/pages/WarehouseInventoryDetail.tsx
- [HIGH] Rules-of-Hooks: `return <Forbidden/>` (70) precedes `useApiMutation` (72) -> hook-count mismatch crash on permission change.
- [LOW] `.../0/confirm` URL fallback is dead (handler guards null). Broad invalidation correct.
## src/admin/pages/WarehouseInventoryForm.tsx
- [HIGH] Rules-of-Hooks: `return <Forbidden/>` (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.
## src/admin/pages/WarehouseIssueDetail.tsx
- [HIGH] Rules-of-Hooks: `return <Forbidden/>` (73) precedes useApiMutation (77,89).
- [MEDIUM] Project link uses raw `<Typography component="a" href>` (316-330) not RouterLink -> full-page reload, breaks SPA nav.
- [LOW] Dead `/0/` URL fallbacks.
## src/admin/pages/WarehouseIssueForm.tsx
- [HIGH] Rules-of-Hooks: `return <Forbidden/>` (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.
## src/admin/pages/WarehouseIssues.tsx
- Clean (kit, FilterBar, PageEnter, debounce, \_count.items, keys).
## src/admin/pages/WarehouseItemDetail.tsx
- [HIGH] Rules-of-Hooks: `return <Forbidden/>` (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.
## src/admin/pages/WarehouseItems.tsx
- Clean (kit, FilterBar, useTableSort, debounce, keys, rowInactive).
## src/admin/pages/WarehouseLocations.tsx
- [LOW] toggleMutation toasts in handler not onSuccess (inconsistent); dead czechPlural ternary. Guard correctly after hooks. Clean otherwise.
## src/admin/pages/WarehouseReceiptDetail.tsx
- [HIGH] Rules-of-Hooks: `return <Forbidden/>` (97) precedes useApiMutation (101,113,126).
- [LOW] Dead `/0/` URL fallbacks; attachment link safe (noopener). Broad invalidation correct.
## src/admin/pages/WarehouseReceiptForm.tsx
- [HIGH] Rules-of-Hooks: `return <Forbidden/>` (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).
## src/admin/pages/WarehouseReceipts.tsx
- Clean (kit, FilterBar, debounce, \_count.items, keys).
## src/admin/pages/WarehouseReports.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).
## src/admin/pages/WarehouseReservations.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 <Forbidden/>` (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 <Forbidden/>` (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 <Forbidden/>` (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 <Forbidden/>` (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 <Forbidden/>` (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 <Navigate/>` (315) — useQuery/useMemo/useState are above, mutations are not.
- [MEDIUM] ~1354-line file — split candidate (roles / system-settings / system-info).
- [LOW] `systemInfo as Record<string,any>` (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 <Forbidden/>` (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 <Forbidden/>` (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 <Forbidden/>` (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 <Forbidden/>` (162).
- [LOW] Raw MUI `<Chip>` instead of kit StatusChip (264); `select: data as unknown as Vehicle[]` double-cast (100); parseInt without radix (404). Broad invalidation (vehicles/trips); rowInactive; keys.
## 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.
## 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).
## src/admin/components/BulkAttendanceModal.tsx
- [LOW] Select-all label shows "Odznacit vse" when users empty (BulkPlanModal guards this — inconsistent). Otherwise clean.
## src/admin/components/BulkPlanModal.tsx
- Clean (kit, derived activeCategories, keys, select-all guarded, no any).
## src/admin/components/ErrorBoundary.tsx
- [LOW] componentDidCatch only console.errors (22) — no telemetry hook. Correct boundary pattern otherwise.
## src/admin/components/Forbidden.tsx
- 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).
## 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.
## 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.
## 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.
## 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.
## 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.
## 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.
## 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.
## src/admin/components/ShortcutsHelp.tsx
- [LOW] Entire component is `return null` — dead stub; remove the file + references, or wire it up.
## src/admin/components/dashboard/DashActivityFeed.tsx
- Clean (escaped React children, single-source ENTITY_TYPE_LABELS).
## src/admin/components/dashboard/DashAttendanceToday.tsx
- [LOW] Key `${u.user_id}-${i}` index-suffixed (75); user_id alone is unique. 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.
## 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.
## 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.
## src/admin/components/dashboard/DashSessions.tsx
- [LOW] Correctly invalidates ["sessions"]; shared `deleting` flag (one dialog at a time); no reduced-motion branch. Clean.
## src/admin/components/dashboard/DashTodayPlan.tsx
- [LOW] key={i} on plans (96, tiny static list); color-mix bgcolor (43, modern-browser only). Clean.
## src/admin/components/odin/InvoiceReviewCard.tsx
- Clean (escaped fields, typed props, no XSS).
## 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.
## src/admin/components/odin/OdinComposer.tsx
- [LOW] `fileRef as RefObject<HTMLInputElement>` unnecessary cast (48); Enter-to-send calls onSubmit even when !canSubmit (parent re-guards — no double-send). Clean.
## src/admin/components/odin/OdinMark.tsx
- Clean — reduced-motion branch correct (opacity glow + documented !important); shouldForwardProp filters thinking/reduce.
## src/admin/components/odin/OdinSidebar.tsx
- [LOW] Conversation row is a clickable <Box onClick> (128) — not a button/no role/tabIndex, so not keyboard-focusable (a11y gap); kebab has aria-label. Clean otherwise.
## 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.
## src/admin/components/odin/types.ts
- Clean
## 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).
## 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.
## src/admin/context/AlertContext.tsx
- Clean (timeouts tracked in a ref Set, cleared on unmount; counter ref avoids key collisions; split state/methods contexts).
## 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 <style> (near-dead as a general helper); calculateNightMinutes mixes timestamp windows with local-midnight math (Prague-TZ-coupled).
## src/admin/utils/dashboardHelpers.ts
- [MEDIUM] STATUS_DOT_CLASS (7-12) is exported but referenced by ZERO files and maps to dash-status-\* CSS classes removed in the MUI migration — dead code.
- [LOW] getCzechDate ISO-week calc (59-62) is the naive formula (off-by-one near year boundaries, display-only). Other exports live/correct.
## src/admin/utils/formatters.ts
- Clean (todayLocalStr avoids the UTC slice trap; formatCurrency locale-correct).
## src/admin/hooks/useAttendanceAdmin.ts
- [HIGH] ~1402-line file mixing the React hook, computeUserTotals payroll math, and ~370 lines of print-HTML templating — the two pure modules have no React dep and should be extracted to utils/ for testability.
- [HIGH] Does data fetching with useEffect+useState+manual setLoading (818-922) against the "React Query for fetching" convention, AND also invalidateQueries(["attendance"]) after mutations — two sources of truth (its own fetchData cache vs the ["attendance"] domain).
- [MEDIUM] fetchData useCallback closes over `alert` (captured first render) with eslint-disable + initialLoadDone ref; pervasive Record<string,any>/any in print/payroll helpers (350,380,597,1319); 300ms sleeps before success toasts as a sync crutch.
- [LOW] handlePrint document.write of escaped data; today/now recomputed every render.
## src/admin/hooks/useDebounce.ts
- Clean.
## src/admin/hooks/useModalLock.ts
- [LOW] Module-level activeLocks counter locks only document.body.style.overflow (3-7) — CLAUDE.md says dialogs must lock <html> (via useDialogScrollLock) because html{overflow-x:hidden} makes <html> the scroller; this body-only lock likely doesn't prevent background scroll for the app layout (superseded/near-dead — confirm callers).
## src/admin/hooks/usePaginatedQuery.ts
- [LOW] `options: any` param (24, eslint-disable) loses generic typing; `as UseQueryOptions<...>` unchecked. Clean otherwise.
## src/admin/hooks/usePlanWork.ts
- [MEDIUM] Optimistic rollback stashed via `(createEntry as any)._rolled = rolled` (264,332,369,403,454,481) — mutating the TanStack mutation object is non-idiomatic and NOT concurrency-safe (two in-flight mutations overwrite each other's \_rolled → wrong-snapshot rollback). Use onMutate context.
- [MEDIUM] Heavy any on mutationFn/onSuccess (237,243,379,385,491); gridQuery.queryFn does r.json() with no res.ok check (92-94) — treats an error body as undefined data.
- [LOW] isoDate UTC slice is correct for Plan, but anchor seeded from a LOCAL `new Date()` then read with getUTC\* (27-31,70,80-86) — verify the late-evening Prague window doesn't shift the week/month anchor. keepPreviousData + broad invalidation correct.
## src/admin/hooks/useReducedMotion.ts
- [LOW] setReduced(false) initial then reconciles in effect — one render with motion for reduced-motion users (documented intentional; a lazy matchMedia initializer would avoid the flash). Listener add/remove correct.
## src/admin/hooks/useTableSort.ts
- Clean (default-vs-user sort distinguished; stable callback).
## src/admin/lib/apiAdapter.ts
- [LOW] paginatedJsonQuery fallback uses per_page=items.length, total_pages=1 when server omits pagination (74-80) — masks a missing-meta bug; `result.data as T`/`items as T[]` unchecked (by design). 401/success/error handling correct.
## src/admin/lib/entityTypeLabels.ts
- Clean — all 29 server EntityType members present and Czech-labeled; single source intact.
## src/admin/lib/queryClient.ts
- Clean — staleTime 30s, gcTime 5min, retry 1, sane defaults.
## src/admin/lib/queries/ai.ts
- [LOW] Singular/plural key split: list ["ai","conversations"] vs messages ["ai","conversation",id,"messages"] — a broad ["ai","conversations"] invalidation won't touch open threads (intended; relies on staleTime Infinity/gcTime 0 to refetch on mount). Correct.
## src/admin/lib/queries/attendance.ts
- [MEDIUM] attendanceLocationOptions (166-185) bypasses jsonQuery: reads `result.data.record||result.data` from untyped `response.json()` with NO response.ok/401 guard — a 500/non-JSON throws an opaque error instead of the adapter's normalized one.
- [LOW] historyOptions hardcodes limit=1000 in the queryFn (not in the key). Keys unique per action/year; enabled:!!id correct.
## src/admin/lib/queries/auditLog.ts
- [LOW] queryFn returns Record<string,unknown>[] rows (23-26) — untyped; consumers cast to AuditLogEntry[] at the page. Key ["audit-log",filters] consistent with broad invalidation.
## src/admin/lib/queries/common.ts
- Clean — ["bank-accounts"]/["suppliers"] distinct broad keys, 2-min staleTime; suppliers invalidated by ReceivedInvoices.
## src/admin/lib/queries/dashboard.ts
- [MEDIUM] sessionsOptions (27-38) hand-rolls fetch with untyped json() and no response.ok/401 guard before reading data.success — inconsistent with jsonQuery.
- [LOW] dashboardOptions returns Record<string,unknown> (untyped payload). Keys broad/unique.
## src/admin/lib/queries/invoices.ts
- [LOW] receivedInvoiceListOptions (157-169) manually lists filter fields into the key instead of spreading filters — a new filter silently won't bust the cache (inconsistent with invoiceListOptions); ["invoices",id] shares prefix with list/stats/received (no real collision). NOTE: received invoices ARE under ["invoices","received"], so a broad ["invoices"] invalidation covers them.
## src/admin/lib/queries/leave.ts
- [LOW] Two key families for one entity: ["leave-requests","mine"/"all"] vs ["leave","pending"/"processed"] — mutations invalidate both domains (consistent) but it's avoidable duplication that's easy to under-invalidate later; rows typed Record<string,unknown> despite a LeaveRequest interface in-file.
## src/admin/lib/queries/mutations.ts
- [LOW] invalidate() loops single top-level string keys (100-104) — can't express a multi-segment broad key (fine for current usage); onSuccess user override force-cast hides signature mismatch. ApiError.status/401 sentinel/broad-invalidation default correct.
## src/admin/lib/queries/offers.ts
- [LOW] offerListOptions (90) omits the <T> on paginatedJsonQuery → rows are `unknown` (the only list option missing its generic); retry:false on detail justified/documented.
## src/admin/lib/queries/orders.ts
- [LOW] orderListOptions (75) also omits <T> → unknown rows; same theoretical id-vs-literal key note.
## src/admin/lib/queries/plan.ts
- Clean — planKeys factory gives structured collision-free keys; helpers pure and used; broad ["plan"] invalidation covers sub-keys.
## src/admin/lib/queries/projects.ts
- [LOW] projectFilesOptions treats HTTP 404 as empty result (97-99, reasonable but undocumented divergence from jsonQuery). Keys safe.
## src/admin/lib/queries/settings.ts
- Clean — distinct keys; 5-min staleTime; require2FAOptions canonical (dashboard duplicate removed).
## src/admin/lib/queries/trips.ts
- [LOW] tripListOptions and tripHistoryOptions hit the SAME endpoint under two key families (redundant cache); tripVehiclesOptions duplicates vehicleListOptions (same /vehicles endpoint cached twice — both invalidated correctly, verified). Redundant footprint only.
## src/admin/lib/queries/users.ts
- [LOW] userListOptions key ["users",{permission}] fans out cache per-permission (a ["users"] mutation invalidates all via prefix — fine). Clean otherwise.
## src/admin/lib/queries/vehicles.ts
- [LOW] Returns Record<string,unknown>[] (untyped; consumers cast). Broad ["vehicles"] key invalidated correctly.
## src/admin/lib/queries/warehouse.ts
- [LOW] Detail keys ["warehouse",entity,id] share prefix with list (no collision); locationListOptions defaults filters in two places that must stay in sync (251-253); undefined filter segments in stock-status/movement-log keys. Every mutation invalidates broad ["warehouse"] (verified).
## src/__tests__/ai.test.ts
- [MEDIUM] Budget-block test inserts cost_usd=budget+1 cleaned only in afterAll — inflates getMonthSpendUsd for other current-month tests in the file (order-safe today, fragile); "sums only current month" asserts spend<6 relying on no other current-month rows.
- [LOW] Configured chat path + the text-only canned-reply guard are untested (avoids API spend — acceptable); duplicated buildApp/token helpers; ai.use grant migration not exercised (relies on admin bypass).
## src/__tests__/auth.service.test.ts
- [HIGH] Only failure/null branches of verifyAccessToken tested — the happy path (valid token → loadAuthData → AuthData) has ZERO coverage.
- [LOW] hashToken tests don't pin a known vector (a silent algo change staying 64-hex deterministic would pass).
## src/__tests__/auth.test.ts
- [HIGH] No successful-login test — the whole happy path (tokens issued, refresh cookie set, 2FA loginToken branch) untested; only 401/400 negatives.
- [MEDIUM] /refresh only tests the no-token 401 — success/rotation and expired/invalid paths untested (security-critical).
- [LOW] Logout asserts only statusCode<500, never that the refresh cookie is cleared (name overstates the assertion).
## src/__tests__/customers.schema.test.ts
- [MEDIUM] Trivial: only name empty/valid + one partial update; no NaN-coercion, email/ICO/DIC format, or nullable-field coverage.
## src/__tests__/env.test.ts
- [LOW] Asserts shape only, never the validation FAILURE path (missing/short secret, bad port) which is the point of env validation.
## src/__tests__/exchange-rates.test.ts
- [HIGH] Module-level rateCache/rateCacheTime never reset between tests (beforeEach resets only mockFetch) — the "throws when API fails and no cache" test passes only because it happens to run before the cache is populated; order-dependent/brittle.
- [MEDIUM] Conversion tests share the uncleared cache — once "today" is cached, later mockFetch resolutions are ignored on hit, asserting stale rates (false confidence).
- [LOW] No test for the amount!=1 path (CNB rates per 100 units, e.g. JPY/HUF) where rate/r.amount matters — the most money-bug-prone branch is untested.
## src/__tests__/helpers.ts
- [LOW] extractCookie splits on first "=" via split("=")[1], truncating base64/JWT values containing "=" (latent; no caller exercises it); `response: any`.
## src/__tests__/invoices.service.test.ts
- [MEDIUM] Inputs use duck-typed {toNumber:()=>N} stand-ins, not real Prisma.Decimal — a regression in real-Decimal handling wouldn't be caught; the "null quantity" test (40) actually passes 0, not null (name lies).
- [MEDIUM] No apply_vat:true with mixed per-line vat_rate, no currency-conversion, no negative/discount line — narrow surface for money logic.
## src/__tests__/manual-create.test.ts
- [MEDIUM] afterEach deleteManys number_sequences type="shared" (16) — destructive shared global state also used by numbering.test.ts (serialized so safe, but resets the live shared counter).
- [LOW] Assertions guard with `if (!("id" in res)) return;` (37,50,116,132) — a failed create silently passes the test; attachment test checks only name+truthiness, not size/content.
## src/__tests__/nas-file-manager.test.ts
- [LOW] Only traversal REJECTIONS tested (moveItem destination path not tested; "rejected" asserts a substring without confirming nothing was deleted/moved); no positive "legitimate path accepted" test (a reject-everything bug would pass).
## src/__tests__/nas-project-folder.test.ts
- Clean
## src/__tests__/numbering.test.ts
- [MEDIUM] Skip-taken preview tests assume a fresh counter, but real projects/quotations rows in the test DB may already hold the first number — second!==first can pass for the wrong reason / the create could collide on the unique column.
- [LOW] "increments" asserts only num1!==num2 (not strictly +1 / year/format preserved); NO concurrency test for the SELECT...FOR UPDATE path the whole fileParallelism:false workaround exists to protect.
## src/__tests__/plan.test.ts
- [MEDIUM] noPermUserId is "second user in DB" reused for two meanings across two beforeAlls; if the DB has only one user it falls back to admin and the scoping assertion toEqual([]) is wrong-for-the-right-reason masked.
- [LOW] Far-future created_at (2037) to dodge the 2038 ceiling (brittle); updateEntry doesn't test inactive/unknown category or date_from>date_to; assertNotPastDate "today" boundary (the bug-prone path) untested.
## src/__tests__/planAuditDescription.test.ts
- Clean
## src/__tests__/planCategory.test.ts
- [MEDIUM] Dedupe test asserts \_2 but cleanup is keyed to in-memory ids (afterAll), so a mid-run crash leaves rows that make the next run assert \_3 and fail (slug-prefix cleanup would be safer).
- [LOW] No duplicate-label-on-update negative test, no deactivate/reactivate test; slugify empty-result collision (slugify("\*\*\*")→"") untested.
## src/__tests__/received-invoices-vat.test.ts
- [LOW] Solid value checks but no half-cent rounding edge, no negative/credit-note amount; rounding pinned via toBeCloseTo rather than the stored 2-dp value.
## src/__tests__/schema-nan.test.ts
- [MEDIUM] Proves "not-a-number" is rejected but never that a valid numeric STRING ("2") is coerced/accepted — the whole point of the form-coercion helper (string→number) is untested.
- [LOW] Only CreateOrder/CreateQuotation covered; the ~150 other duplicated coercion sites (invoices/received-invoices/trips/plan) have no NaN test.
## src/__tests__/setup.ts
- [LOW] Loads .env.test but doesn't assert it exists or that DATABASE_URL targets a TEST db (vitest.config also loads it with override:true — redundant); a missing .env.test silently runs against whatever env is present (non-test-DB risk).
## src/__tests__/warehouse.test.ts
- [MEDIUM] Shared `counter` for location codes + per-describe createdItemId/createdCategoryId in closure → tests within a describe are order-dependent (one early failure cascades).
- [LOW] "deactivates an item (soft delete)" actually asserts a HARD delete (404) — name lies and would mask a future intended soft-delete; FIFO verified only by summed batch quantity, never that the OLDEST batch is consumed first (core stock-valuation logic unverified); no partial-issue-across-batches / negative-qty rejection / reservation+issue-simultaneous-subtraction tests; hardcoded item_number "ITM001" + item_id:1 in a 403 test (fragile).
## Suite-level (tests)
- [MEDIUM] Server-side only — ZERO frontend/component tests: React Query mutations, api.ts token-refresh/dedup, UI VAT/number formatting, and permission-gated rendering are entirely unverified.
- [LOW] buildApp/generateToken/no-perm-user bootstrap copy-pasted across ai/plan/warehouse tests instead of helpers.ts (~150 lines of drift-prone duplication).
## index.html
- Clean — fonts (Newsreader incl.) preconnected; data-theme="dark" initial matches ThemeContext default; cache-control metas are belt-and-suspenders (largely ignored by browsers, harmless).
## .claude/settings.json
- Clean — PreToolUse block-env + PostToolUse prettier hooks. (Prettier-not-a-declared-dep is captured at PROJECT-LEVEL.)
## boneyard.config.json
- Clean — skeleton-generation config (boneyard); hardcoded sample routes (/orders/1, /projects/80) are dev capture targets.
## .claude/settings.local.json
- [LOW] Permission allowlist contains stale entries pointing at the OLD PHP app (find ...boha-app ...\*.php, lines 4-9) — harmless (gitignored, local-only) but dead config from the pre-rewrite project.
## CLAUDE.md
- [LOW] Otherwise excellent and current (counts verified: 52 models, 17 test files/195 tests, 114 admin tsx). One doc-vs-code drift: it states `vatFromGross` (gross VAT) is used "by both the manual form and the AI import," but the JSON single-create path in received-invoices.ts still uses the net `amount*rate/100` formula (see that file's [HIGH]) — the convention is documented as fully applied when one code path was missed. Strength worth preserving: the conventions section + the 2026-06-06 audit reference keep entropy in check.
## Config & build files (assessed at PROJECT-LEVEL)
These were reviewed; their findings are consolidated in the PROJECT-LEVEL section (no linter, stale .env.example, test-typecheck gap, unpinned Prettier). Per-file notes:
## package.json
- See PROJECT-LEVEL: no ESLint/Prettier devDeps; dead deps (react-datepicker, hi-base32, @types/mysql); majors behind. Scripts/structure otherwise clean.
## tsconfig.json
- Clean — solution file referencing the server + app projects.
## tsconfig.server.json
- [HIGH] Excludes `src/__tests__` (with tsconfig.app.json not including it, tests are never type-checked — see PROJECT-LEVEL). strict:true, CJS/ES2022 correct otherwise.
## tsconfig.app.json
- [MEDIUM] `noUnusedLocals:false`/`noUnusedParameters:false` (compiler won't flag dead code) and tests not included (not type-checked). strict:true, bundler resolution correct.
## vite.config.ts
- Clean — react plugin, manualChunks for vendor splitting, es2020 target.
## vitest.config.ts
- Clean — node env, `.env.test` override, `fileParallelism:false` (deliberate, documented), 15s timeouts.
## .env.example
- [MEDIUM] Stale — missing many env vars config/env.ts reads (ANTHROPIC_API_KEY, NAS_FINANCIALS_PATH, NAS_OFFERS_PATH, SMTP_FROM_NAME, INVOICE_ALERT_EMAIL, RATE_LIMIT_*, TRUST_PROXY, TOTP_*). See PROJECT-LEVEL.
## .claude/hooks/block-env.js
- Clean — blocks direct `.env` edits via PreToolUse.
## .claude/hooks/format-on-save.js
- [LOW] Runs `npx prettier --write` but prettier is not a declared devDep and there's no `.prettierrc` (see PROJECT-LEVEL) — formatting is unpinned/non-reproducible.