From b4f859ea6eb9d0701191cba4861eabc325283668 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 06:45:37 +0200 Subject: [PATCH] docs: codify audit rules in CLAUDE.md + README + audit records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 51 +- README.md | 73 +++ REVIEW.md | 386 ++++++++++++ REVIEW_FINDINGS.md | 1330 ++++++++++++++++++++++++++++++++++++++++++ REVIEW_FIXES.md | 1385 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 3220 insertions(+), 5 deletions(-) create mode 100644 README.md create mode 100644 REVIEW.md create mode 100644 REVIEW_FINDINGS.md create mode 100644 REVIEW_FIXES.md diff --git a/CLAUDE.md b/CLAUDE.md index 7cbe7a4..75efc36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,9 +76,14 @@ npm run build:client # vite build → dist-client/ npm start # node dist/server.js # Tests -npm test # vitest run (single pass) +npm test # vitest run (single pass) — runs against the app_test DB via .env.test npm run test:watch # vitest watch +# Quality gates +npm run typecheck # tsc -b --noEmit (also type-checks the tests via tsconfig.test.json) +npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep at 0 errors +npm run format # prettier --write . + # Database npx prisma migrate dev # Create migration from schema changes + apply to dev npx prisma migrate dev --name # Named migration @@ -239,11 +244,13 @@ if ("error" in body) return error(reply, body.error, 400); ## Testing -Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`). +Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`). -- The suite spans 17 files / 195 tests — auth, numbering, warehouse, plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create, AI/Odin (conversation CRUD, isolation, auto-title, cascade, budget), received-invoices VAT. Server-side only (no component tests yet). +- **Isolated test DB (`app_test`):** the suite MUTATES a real MySQL DB, so it runs against `app_test` (NOT dev `app`), configured in **`.env.test`** (gitignored). `src/__tests__/setup.ts` **hard-throws** if `DATABASE_URL` doesn't name a `*test*` database — a stray URL can never corrupt dev/prod data. To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test` with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → `DATABASE_URL= npx prisma migrate deploy` → `DATABASE_URL= npx tsx prisma/seed.ts`. +- The suite spans 18 files / 247 tests — auth (incl. happy-path login/refresh-rotation), numbering, warehouse (incl. FIFO oldest-first), plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create, AI/Odin, received-invoices VAT. Server-side only (no component tests yet). +- Tests are now **type-checked** by `tsc -b` (via `tsconfig.test.json`) — keep them compiling. - Use `buildApp()` helper to spin up the Fastify instance for tests. -- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout. +- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout; `fileParallelism: false` (serialized) to avoid `number_sequences` deadlocks. - **Do not mock Prisma** — tests hit a real database to catch schema/query bugs. When adding new features, add tests in `src/__tests__/`. Name test files `.test.ts`. @@ -302,7 +309,7 @@ The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 line - **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh. - **Dialogs** lock `` via `useDialogScrollLock` (MUI only locks ``, and `html{overflow-x:hidden}` makes `` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell. -**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`. +**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`, `npm run lint`. The solution `tsconfig.json` now references `tsconfig.test.json` too, so `tsc -b` **type-checks the test files** (previously skipped). ESLint (flat config in `eslint.config.mjs`) enforces `react-hooks/rules-of-hooks` as an **error** — that rule catches the "hook after an early `return`" bug class; keep `npm run lint` at zero errors (warnings are advisory). Prettier config is `.prettierrc.json` (`npm run format`). --- @@ -445,6 +452,40 @@ audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06- - **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`). - **Frontend "today" / date-string round-trips:** use `utils/date.ts` `localDateStr` (server) / `normalizeDateStr` (client). **Never** use `new Date().toISOString().split("T")[0]` for "today" — it's the UTC date and is a day early during the late-evening Prague window. +## Conventions (enforced — added by the 2026-06-09 full audit) + +The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. These are now rules — `npm run lint` + `tsc -b` (which now type-checks tests) catch some automatically. + +**Zod validation — shared coercion helpers are MANDATORY** + +- All numeric/boolean/date/email form fields MUST use the helpers in **`src/schemas/common.ts`**: `numberFromForm`, `numberInRange(min,max)`, `nonNegativeNumberFromForm`, `positiveNumberFromForm`, `intIdFromForm`, `nullableNumberFromForm`/`nullableIntIdFromForm`, `booleanFromForm`, `isoDateString`, `timeString`, `emailOrEmpty`. +- **NEVER** the raw `z.union([z.number(), z.string()]).transform(Number)` idiom — it silently yields `NaN` that flows into Prisma/business math (this was the single biggest bug class). FK ids → `intIdFromForm`/`nullableIntIdFromForm`; quantities/prices → `nonNegative`/`positive`; bounded values (VAT `0–100`, month `1–12`) → `numberInRange`. +- `isoDateString`/`timeString` are **lenient** (they strip a trailing time/seconds component) because an edit form re-submits a `@db.Date` that the `toJSON` override serialised as `"YYYY-MM-DDT00:00:00"`. Use them for date/time fields, not a bare regex. +- An optional email a form may submit as `""` → **`emailOrEmpty.nullish()`** (a bare `.email()` 400s the whole form, blocking unrelated saves). + +**Deterministic ordering — always tiebreak a timestamp sort with `id`** + +- `created_at`/`received_at` are **second-precision** (`@db.Timestamp(0)`/`DateTime(0)`), so `orderBy: { created_at: "desc" }` alone is non-deterministic for same-second rows. ALWAYS add `{ id: "desc" }` (or `asc`) as the secondary sort. (Bit `plan.service.resolveCell`/`resolveGrid` and warehouse FIFO `selectFifoBatches`.) + +**Concurrency — locking discipline for stock / balance / sequence mutations** + +- Any service op that read-modify-writes shared rows (stock, batches, reservations, balances, number sequences) MUST: run inside a `prisma.$transaction`; take the row lock with `SELECT … FOR UPDATE` via **`tx.$queryRaw`** (NOT `$executeRaw` — it does not reliably hold the lock); and lock rows in one global **ascending-id order** (parent → items → batches) so concurrent paths can't deadlock. See `warehouse.service.ts` `lockParentRow`/`lockRowsForUpdate` and `attendance.service.ts` `lockUserRow`. +- **Uniqueness checks** (username/email/document number) go INSIDE the create transaction (re-check immediately before insert) and catch `P2002` → 409. A pre-transaction check is a TOCTOU race that surfaces as a 500. + +**Permissions — guard reads, not just writes** + +- GET list/detail endpoints need a `requirePermission`/`requireAnyPermission` guard, NOT bare `requireAuth` (bare-auth reads leak data to any logged-in user). Role-management writes are admin-only and re-checked (no privilege escalation; no creating/rewriting the `admin` role). + +**Frontend — Rules of Hooks (lint-enforced) + no UTC-today** + +- ALL hooks (`useState`, `useApiMutation`, `useMemo`, …) run BEFORE any early `return` (the ``/`` permission guard goes AFTER every hook). `eslint.config.mjs` sets `react-hooks/rules-of-hooks` to **error** — `npm run lint` must stay at 0 errors. This was a systemic crash bug across ~14 pages. +- Mutations invalidate the **broad domain key**; a mutation that writes via raw `apiFetch` must still `invalidateQueries` the domain it touched (several dashboard widgets were missing this). + +**Safety** + +- `prisma/seed.ts` **refuses to run when `APP_ENV=production`** (it wipes/reseeds permissions). Never seed prod. +- Every catch logs (never silently swallow) — the NAS managers were the worst offenders. + --- ## Known Issues & Gotchas diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd18372 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# boha-app-ts + +Internal business-management system for a Czech company (attendance, invoicing, +leave/trips, projects, vehicles, warehouse, HR) — a TypeScript/Node.js rewrite +of the legacy PHP app. User-facing text is Czech by design. + +> For full architecture, conventions, and gotchas, see **[CLAUDE.md](./CLAUDE.md)**. + +## Stack + +- **Backend:** Fastify 5 · Prisma 6 → MySQL · Zod 4 · JWT (HS256) + TOTP 2FA +- **Frontend:** React 18 · Vite · Material UI v7 (Emotion) · TanStack Query — an + SPA in `src/admin/`, served by the same Fastify process (Vite middleware in + dev, static files in prod) +- **Other:** Puppeteer (PDF) · nodemailer · node-cron · `@anthropic-ai/sdk` (the + admins-only "Odin" assistant) + +## Prerequisites + +- Node.js (LTS) and a MySQL database +- Copy `.env.example` → `.env` and fill in the **required** vars (`DATABASE_URL`, + `JWT_SECRET`, `TOTP_ENCRYPTION_KEY` — generate the keys with `openssl rand -hex 32`) + +## Setup + +```bash +npm install +npx prisma generate +npx prisma migrate dev # apply migrations to your dev DB +npx prisma db seed # optional: dev-only sample data (NEVER on prod) +``` + +## Develop + +```bash +npm run dev:server # API (tsx watch) on http://127.0.0.1:3050 +npm run dev:client # Vite dev server (manage separately) +``` + +## Build & run + +```bash +npm run build # tsc server → dist/ + vite client → dist-client/ +npm start # node dist/server.js +``` + +## Test + +```bash +npm test # vitest run — hits a real test DB (.env.test); no Prisma mocks +``` + +## Quality gates + +Before committing, all of these must pass: + +```bash +npx tsc -b --noEmit # typecheck (NOT `tsc -p tsconfig.json`) +npm run build +npx vitest run +npm run lint # ESLint (incl. react-hooks) — see eslint.config.js +``` + +## Database migrations + +Every schema/data change is a tracked Prisma migration — **never** `prisma db push` +or raw SQL on production. Stop the dev server before running `prisma migrate dev`. +See CLAUDE.md → _Database Migrations_ for the full workflow and the production +deploy/hotfix process. + +## License + +ISC diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..02377d6 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,386 @@ +# Codebase Audit — REVIEW.md + +**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. + +**Total files to review:** 277 + +--- + + +## .claude/hooks/ + +- [x] .claude/hooks/block-env.js +- [x] .claude/hooks/format-on-save.js + +## .claude/ + +- [x] .claude/settings.json +- [x] .claude/settings.local.json + +## ./ + +- [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 + +## scripts/ + +- [x] scripts/migrate-received-invoices-to-nas.ts +- [x] scripts/rotate-totp-key.ts + +## src/ + +- [x] src/App.tsx + +## src/__tests__/ + +- [x] src/__tests__/ai.test.ts +- [x] src/__tests__/auth.service.test.ts +- [x] src/__tests__/auth.test.ts +- [x] src/__tests__/customers.schema.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__/manual-create.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__/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__/warehouse.test.ts + +## src/admin/ + +- [x] src/admin/AdminApp.tsx +- [x] src/admin/GlobalStyles.tsx + +## 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/ErrorBoundary.tsx +- [x] src/admin/components/Forbidden.tsx +- [x] src/admin/components/OrderConfirmationModal.tsx +- [x] src/admin/components/PlanCategoriesModal.tsx +- [x] src/admin/components/PlanCellModal.tsx +- [x] src/admin/components/PlanGrid.tsx +- [x] src/admin/components/PlanRangeChips.tsx +- [x] src/admin/components/ProjectFileManager.tsx +- [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/ + +- [x] src/admin/context/AlertContext.tsx +- [x] src/admin/context/AuthContext.tsx + +## src/admin/hooks/ + +- [x] src/admin/hooks/useAttendanceAdmin.ts +- [x] src/admin/hooks/useDebounce.ts +- [x] src/admin/hooks/useModalLock.ts +- [x] src/admin/hooks/usePaginatedQuery.ts +- [x] src/admin/hooks/usePlanWork.ts +- [x] src/admin/hooks/useReducedMotion.ts +- [x] src/admin/hooks/useTableSort.ts + +## src/admin/lib/ + +- [x] src/admin/lib/apiAdapter.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/leave.ts +- [x] src/admin/lib/queries/mutations.ts +- [x] src/admin/lib/queries/offers.ts +- [x] src/admin/lib/queries/orders.ts +- [x] src/admin/lib/queries/plan.ts +- [x] src/admin/lib/queries/projects.ts +- [x] src/admin/lib/queries/settings.ts +- [x] src/admin/lib/queries/trips.ts +- [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/ + +- [x] src/admin/pages/Attendance.tsx +- [x] src/admin/pages/AttendanceAdmin.tsx +- [x] src/admin/pages/AttendanceBalances.tsx +- [x] src/admin/pages/AttendanceCreate.tsx +- [x] src/admin/pages/AttendanceHistory.tsx +- [x] src/admin/pages/AttendanceLocation.tsx +- [x] src/admin/pages/AuditLog.tsx +- [x] src/admin/pages/CompanySettings.tsx +- [x] src/admin/pages/Dashboard.tsx +- [x] src/admin/pages/InvoiceDetail.tsx +- [x] src/admin/pages/Invoices.tsx +- [x] src/admin/pages/LeaveApproval.tsx +- [x] src/admin/pages/LeaveRequests.tsx +- [x] src/admin/pages/Login.tsx +- [x] src/admin/pages/NotFound.tsx +- [x] src/admin/pages/Odin.tsx +- [x] src/admin/pages/OfferDetail.tsx +- [x] src/admin/pages/Offers.tsx +- [x] src/admin/pages/OffersCustomers.tsx +- [x] src/admin/pages/OffersTemplates.tsx +- [x] src/admin/pages/OrderDetail.tsx +- [x] src/admin/pages/Orders.tsx +- [x] src/admin/pages/PlanWork.tsx +- [x] src/admin/pages/ProjectDetail.tsx +- [x] src/admin/pages/Projects.tsx +- [x] src/admin/pages/ReceivedInvoices.tsx +- [x] src/admin/pages/Settings.tsx +- [x] src/admin/pages/Trips.tsx +- [x] src/admin/pages/TripsAdmin.tsx +- [x] src/admin/pages/TripsHistory.tsx +- [x] src/admin/pages/UiKit.tsx +- [x] src/admin/pages/Users.tsx +- [x] src/admin/pages/Vehicles.tsx +- [x] src/admin/pages/Warehouse.tsx +- [x] src/admin/pages/WarehouseCategories.tsx +- [x] src/admin/pages/WarehouseInventory.tsx +- [x] src/admin/pages/WarehouseInventoryDetail.tsx +- [x] src/admin/pages/WarehouseInventoryForm.tsx +- [x] src/admin/pages/WarehouseIssueDetail.tsx +- [x] src/admin/pages/WarehouseIssueForm.tsx +- [x] src/admin/pages/WarehouseIssues.tsx +- [x] src/admin/pages/WarehouseItemDetail.tsx +- [x] src/admin/pages/WarehouseItems.tsx +- [x] src/admin/pages/WarehouseLocations.tsx +- [x] src/admin/pages/WarehouseReceiptDetail.tsx +- [x] src/admin/pages/WarehouseReceiptForm.tsx +- [x] src/admin/pages/WarehouseReceipts.tsx +- [x] src/admin/pages/WarehouseReports.tsx +- [x] src/admin/pages/WarehouseReservations.tsx +- [x] src/admin/pages/WarehouseSuppliers.tsx + +## src/admin/ + +- [x] src/admin/theme.test.ts +- [x] src/admin/theme.ts + +## 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/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/LoadingState.tsx +- [x] src/admin/ui/Modal.tsx +- [x] src/admin/ui/MonthField.tsx +- [x] src/admin/ui/MuiProvider.tsx +- [x] src/admin/ui/PageEnter.tsx +- [x] src/admin/ui/PageHeader.tsx +- [x] src/admin/ui/Pagination.tsx +- [x] src/admin/ui/ProgressBar.tsx +- [x] src/admin/ui/RichText.tsx +- [x] src/admin/ui/Select.tsx +- [x] src/admin/ui/SidebarNav.tsx +- [x] src/admin/ui/StatCard.tsx +- [x] src/admin/ui/StatusChip.tsx +- [x] src/admin/ui/Tabs.tsx +- [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/ + +- [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/ + +- [x] src/config/database.ts +- [x] src/config/env.ts + +## src/context/ + +- [x] src/context/ThemeContext.tsx + +## src/ + +- [x] src/main.tsx + +## src/middleware/ + +- [x] src/middleware/auth.ts +- [x] src/middleware/security.ts + +## src/routes/admin/ + +- [x] src/routes/admin/ai.ts +- [x] src/routes/admin/attendance.ts +- [x] src/routes/admin/audit-log.ts +- [x] src/routes/admin/auth.ts +- [x] src/routes/admin/bank-accounts.ts +- [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/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/plan.ts +- [x] src/routes/admin/profile.ts +- [x] src/routes/admin/project-files.ts +- [x] src/routes/admin/projects.ts +- [x] src/routes/admin/quotations.ts +- [x] src/routes/admin/received-invoices.ts +- [x] src/routes/admin/roles.ts +- [x] src/routes/admin/scope-templates.ts +- [x] src/routes/admin/sessions.ts +- [x] src/routes/admin/totp.ts +- [x] src/routes/admin/trips.ts +- [x] src/routes/admin/users.ts +- [x] src/routes/admin/vehicles.ts +- [x] src/routes/admin/warehouse.ts + +## src/schemas/ + +- [x] src/schemas/ai.schema.ts +- [x] src/schemas/attendance.schema.ts +- [x] src/schemas/auth.schema.ts +- [x] src/schemas/bank-accounts.schema.ts +- [x] src/schemas/common.ts +- [x] src/schemas/customers.schema.ts +- [x] src/schemas/invoices.schema.ts +- [x] src/schemas/leave-requests.schema.ts +- [x] src/schemas/offers.schema.ts +- [x] src/schemas/orders.schema.ts +- [x] src/schemas/plan.schema.ts +- [x] src/schemas/planCategory.schema.ts +- [x] src/schemas/profile.schema.ts +- [x] src/schemas/projects.schema.ts +- [x] src/schemas/received-invoices.schema.ts +- [x] src/schemas/roles.schema.ts +- [x] src/schemas/scope-templates.schema.ts +- [x] src/schemas/settings.schema.ts +- [x] src/schemas/trips.schema.ts +- [x] src/schemas/users.schema.ts +- [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/ + +- [x] src/types/fastify.d.ts +- [x] src/types/index.ts + +## src/utils/ + +- [x] src/utils/czech-holidays.ts +- [x] src/utils/date.ts +- [x] src/utils/encryption.ts +- [x] src/utils/html-to-pdf.ts +- [x] src/utils/pagination.ts +- [x] src/utils/planAuditDescription.ts +- [x] src/utils/response.ts +- [x] src/utils/totp.ts + +## src/ + +- [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 new file mode 100644 index 0000000..620dc14 --- /dev/null +++ b/REVIEW_FINDINGS.md @@ -0,0 +1,1330 @@ +# 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,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. + +**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 ` (`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 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. + +--- + +## 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 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 + +- 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 + +- 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 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 + +- 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 ` (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 ` (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 ` (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. + +## src/admin/pages/WarehouseIssueForm.tsx + +- [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. + +## src/admin/pages/WarehouseIssues.tsx + +- Clean (kit, FilterBar, PageEnter, debounce, \_count.items, keys). + +## src/admin/pages/WarehouseItemDetail.tsx + +- [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. + +## 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 ` (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 ` (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 ` (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. + +## 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` 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 (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