# CLAUDE.md — boha-app-ts Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js. Handles attendance, invoicing, offers/orders, leave/trips, projects, warehouse, vehicles, and HR operations. --- ## Tech Stack | Layer | Technology | | -------------- | ----------------------------------------------------------------------------------------------------- | | Runtime | Node.js, TypeScript (strict, all tsconfigs) | | HTTP Framework | Fastify 5 | | ORM | Prisma 7 (Rust-free client + @prisma/adapter-mariadb; datasource lives in `prisma.config.ts`) → MySQL | | Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs | | Validation | Zod 4 | | Frontend | React 19 + Vite + Material UI v7 (Emotion) + React Query | | Testing | Vitest + Supertest against a real `app_test` DB | | PDF | Puppeteer (stay on 24.x — v25 is ESM-only, conflicts with the CJS server build) | | Email / Cron | nodemailer / node-cron | | AI | @anthropic-ai/sdk → claude-sonnet-4-6 ("Odin" assistant) | (Exact versions live in `package.json` — don't trust any pinned here.) --- ## Project Structure ``` src/ ├── server.ts # Fastify entry — plugins, routes, error handler ├── routes/admin/ # HTTP route handlers (one file per entity) ├── services/ # Business logic (no classes, exported functions, own Prisma) ├── schemas/ # Zod validation schemas (one file per entity; shared coercers in common.ts) ├── middleware/ # auth.ts (requireAuth/requirePermission), security.ts (CSP, HSTS) ├── utils/ # totp, email, audit, date, pdf-shared, content-disposition, html-to-pdf, … ├── config/ # env.ts (config singleton, TZ + Date.toJSON override) ├── types/ # AuthData, JwtPayload, ApiResponse, Prisma re-exports ├── admin/ # React 19 + MUI v7 frontend │ ├── AdminApp.tsx # Router + lazy-loaded pages │ ├── theme.ts / GlobalStyles.tsx │ ├── ui/ # MUI component kit — pages import from here │ ├── components/ # Non-page components incl. document/ (shared doc editors), odin/, warehouse/ │ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/) │ ├── pages/ # One file per page/feature │ ├── lib/queries/ # React Query options + useApiMutation │ ├── hooks/ # usePaginatedQuery, useDocumentLock, useUnsavedChangesGuard, useDocumentPdf, … │ └── utils/ # api.ts (apiFetch with token refresh), formatters └── __tests__/ # Vitest suites (server-side only; real app_test DB) prisma/ # schema.prisma (snake_case columns) + migrations/ dist/ dist-client/ # Compiled server (CommonJS) / built frontend (Vite) ``` --- ## Commands ```bash npm run dev:server # tsx watch src/server.ts (frontend: npm run dev:client) npm run build # server + client npm test # vitest run — against app_test via .env.test npm run typecheck # tsc -b --noEmit (NOT -p tsconfig.json — that solution file checks nothing) npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep 0 errors npm run format # prettier --write . npx prisma generate # after every schema change (client is not committed) npx prisma studio # DB browser ``` **Do not start the dev server.** The user manages it separately. **Before any migration, ask the user to stop their dev server and wait for confirmation** (the running server holds DB connections). --- ## Environment Variables Required: `DATABASE_URL`, `JWT_SECRET`, `TOTP_ENCRYPTION_KEY` (64-char hex). Optional (defaults in `src/config/env.ts`): `PORT` (prod 3001; dev default 3050 hardcoded), `HOST`, `APP_ENV=local|production` (controls CSP/CORS/HSTS), `ACCESS_TOKEN_EXPIRY`, `REFRESH_TOKEN_SESSION_EXPIRY`, `REFRESH_TOKEN_REMEMBER_EXPIRY`, `NAS_PATH` (project files), `NAS_INVOICES` + `NAS_ORDERS` (document PDF archives — split trees), `MAX_UPLOAD_SIZE`, `CONTACT_EMAIL_TO/FROM`, `SMTP_FROM`, `LEAVE_NOTIFY_EMAIL`, `APP_URL`, `CORS_ORIGINS`, `ANTHROPIC_API_KEY` (Odin self-hides without it). `.env` for dev, `.env.test` for tests (both gitignored — **never edit env files; the user manages them**). --- ## Architecture & Key Patterns ### Request flow ``` Request → CORS → Cookie → Rate-limit → Security headers → requirePermission() / requireAnyPermission() → parseBody(Schema) / parseId(param) → Route handler → Service function → Prisma → success(reply, data) | error(reply, czechMessage, status) ``` ### Response format `{ success: true, data, message?, pagination? }` on success, `{ success: false, error }` on failure. Always the `success()`/`error()`/ paginated helpers — never raw `reply.send()`. ### Services Plain exported async functions; services own Prisma, routes stay thin. Preferred error shape: return **token literals** (`"not_found"`, `"invalid_transition"`, `"supplier_not_found"`, …) and let the ROUTE map tokens to Czech messages + HTTP codes (the offers/issued-orders modules are the reference). Legacy services return `{ error: czech, status }` — fine to keep until touched; never the discriminated-union style. Respect soft-delete (`is_deleted: false`) in reads where the model has it. ### Permissions `requirePermission("entity.action")` / `requireAnyPermission(…)`. The admin role bypasses checks (shortcut: `authData.roleName === "admin"` — ⚠️ `AuthData` has **`roleName`**, not `role`; don't type `authData` as `any`). GET list/detail endpoints need a permission guard too, NOT bare `requireAuth` (bare-auth reads leak data to any logged-in user). Frontend rule: a _view_ permission opens pages read-only; an _edit_ permission unlocks fields and save buttons. ### Audit `logAudit()` on every create/update/delete (and security-relevant actions) with `oldValues`/`newValues`. Use a `"(koncept)"`-style fallback in descriptions for unnumbered drafts. Audit failures are non-fatal. --- ## Dates & Timezones (Critical — three rules) 1. **Global override:** `src/config/env.ts` sets `TZ=Europe/Prague` and monkey-patches `Date.prototype.toJSON()` to emit LOCAL time (PHP-migration parity). All API date strings are local. Never assume UTC; never manually offset. Do not remove the override. 2. **`@db.Date` columns — Prisma truncates filter Dates to the UTC date part.** A local-midnight boundary (`new Date(y, m, d)` = 22:00/23:00 UTC of the _previous_ day) silently shifts the queried window a day back — this was a 14-site production bug class. Build `@db.Date` filter boundaries and "today" writes as UTC-midnight instants of the LOCAL calendar day: `new Date(Date.UTC(y, m, d))` or `utcMidnightOfLocalDay()` from `src/utils/date.ts`; use half-open `gte`/`lt` ranges; never write a bare `new Date()` into a `@db.Date` column (stores yesterday between 00:00–02:00 Prague). 3. **Two deliberate write regimes — don't "fix" either:** the plan module does date-only math in UTC; attendance/leave writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`). Frontend "today" comes from `localDateStr` (server) / `normalizeDateStr`/`todayLocalStr` (client) — **never** `new Date().toISOString().split("T")[0]` (UTC date, a day early in the Prague evening). --- ## Document Modules (offers · orders · issued orders · invoices) **Business rules (user/accountant decisions — do not revert):** - **VAT exists ONLY on invoices and received invoices.** Offers, order confirmations and issued orders are NOT tax documents: net prices only, one total labeled "Celkem bez DPH" / "Total excl. VAT", no VAT columns, no explanatory VAT notice. Their VAT DB columns were dropped. Invoices created from an order prefill the company default VAT rate. - **Issued orders (objednávky vydané) take suppliers** (`sklad_suppliers`, picker lookup at `GET /issued-orders/suppliers` under orders._ perms); offers take customers. Issued orders share the `orders._` permission family (deliberate). - **PDF families:** the offer PDF keeps its own monochrome customer-facing design; invoices + issued orders share the red-accent (#de3a3a) family. - **Free-form PO content lives in the rich-text sections** ("Obsah" on issued orders, "Rozsah projektu" on offers — CZ/EN titles, Quill content). Issued orders deliberately have NO scope-template picker, NO item templates, NO duplicate action. **Platform conventions:** - **Deferred numbering:** drafts carry a NULL document number (nullable-unique column); the sequence number is consumed in-transaction on finalize (draft→active / draft→sent) via `numbering.service.ts`, and released-if-highest on delete. Never hardcode numbering; previews use the collision-advancing helpers. - **Status machines:** `VALID_TRANSITIONS` maps in the services; detail responses return `valid_transitions` and the UI renders transition buttons from it. Field edits outside the editable states (offers: draft/active; issued: draft/sent) are rejected with an explicit Czech 400 — status-only payloads still pass. - **Edit locking:** lock/heartbeat/unlock route trio on both offers and issued orders (30 s server TTL = 3 missed 10 s client heartbeats); detail enriches `locked_by {user_id, username, full_name}` only when fresh and held by another user; client side is the shared `useDocumentLock` hook + `LockBanner`. - **PDF serving:** `GET …/:id/file` serves the archived NAS copy and falls back to a live render (re-archiving it) on a miss; drafts 404 (no number), so the UI hides PDF buttons on drafts. Numbered-document archives write to a deterministic path and **overwrite in place** — never uniquePath `_N` suffixes. The issued-order PDF gets language from the document's `language` column and carries a Puppeteer `footerTemplate` footer on every page ("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`. - **Shared modules — extend, never fork local copies:** `src/admin/components/document/` (DocumentItemsEditor, SectionsEditor, LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` / `useDocumentPdf` (+ list variant), `src/admin/lib/documentStatus.ts`, CustomerPicker/SupplierPicker, `src/utils/pdf-shared.ts` (escapeHtml, strict cleanQuillHtml, formatNum NBSP, formatCurrency, formatDate), `src/utils/content-disposition.ts` (RFC 5987 — raw filenames with diacritics in headers make Node throw 500s). --- ## TOTP / 2FA Secret AES-256-GCM encrypted in `users.totp_secret` (PHP-legacy base64 and TS hex formats both supported); encrypted backup codes with their own `/totp/backup-verify` login endpoint. `company_settings.require_2fa` forces enrollment. Login flow: password → (if enrolled) single-use 5-min `loginToken` → TOTP/backup verify → access + refresh tokens. The enrollment QR is generated locally with the `qrcode` package (never an external QR URL — CSP blocks it and it would leak the secret). --- ## Testing - The suite MUTATES a real MySQL DB → it runs against **`app_test`** (never dev `app`), configured in `.env.test`; `src/__tests__/setup.ts` hard-throws unless `DATABASE_URL` names a `*test*` database. - **After any schema change, apply migrations to the test DB too** or the suite fails: set `DATABASE_URL` to the app_test URL and run `npx prisma migrate deploy`. - To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test` with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → migrate deploy → `npx tsx prisma/seed.ts`. - **Do not mock Prisma** — real DB catches schema/query bugs. Mock only true externals (Puppeteer via `vi.mock("../utils/html-to-pdf")`, NAS reads via `vi.spyOn`). Files run serialized (`fileParallelism: false`) to avoid sequence deadlocks. Tests are type-checked by `tsc -b`. - Fixture hygiene: far-future dates (year 2098) or unique prefixes + full cleanup; FK-safe deletion order. New features get tests in `src/__tests__/.test.ts`. --- ## Frontend Conventions - Pages lazy-load via `React.lazy()` in `AdminApp.tsx`; auth via `useAuth()`, toasts via `useAlert()`. - **React Query for all fetching** (query options live in `src/admin/lib/queries/`; no fetch-in-useEffect; prefer deriving state over effects). Mutations via `useApiMutation` (opt-in `envelope` mode passes the server message through). `apiFetch` refreshes tokens proactively. - **Rules of Hooks:** every hook runs BEFORE any early return — the `` guard goes after all hooks. Lint enforces this as an error. - **Invalidation:** broad domain keys (`["offers"]`, not `["offers","list"]` — prefix matching covers sub-queries), and a mutation invalidates every domain that embeds its data (user CRUD → trips+attendance; vehicle → trips; invoice → orders; attendance/leave → **`["dashboard"]`**). The dashboard also uses `refetchOnMount: "always"` as a backstop. Raw-`apiFetch` writes still must invalidate their domains. - Single sources of truth: status chips/labels in `lib/documentStatus.ts`; audit entity labels in `lib/entityTypeLabels.ts`; plan categories from the DB. Don't duplicate label maps. ### Styling (MUI v7 — no custom .css, no Tailwind) - Theme in `src/admin/theme.ts` (cssVariables, light+dark schemes); global rules in `GlobalStyles.tsx`; pages compose the kit in `src/admin/ui/` — reach for raw `@mui/material` only for one-off layout/infra. - Colours via **`theme.vars!.palette.*`**; alpha via channel tokens (`rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`); per-scheme one-offs via `theme.applyStyles("dark", …)`. No hardcoded hex. - Don't regress: status row tints are channel-alpha washes (never `.light` fills — invisible text in dark mode); icon badges are solid `X.main` + white glyph; the theme persists ONLY under MUI's `mui-mode` localStorage key (ThemeContext owns ``); dialogs lock `` via `useDialogScrollLock`; Modal/ConfirmDialog freeze content through the close fade; ConfirmDialogs stay open with `loading` during their request. - When refactoring pages, **preserve ALL data logic verbatim** (hooks, mutations, invalidate arrays, validation, permissions). --- ## AI Assistant — "Odin" Admin-only (`ai.use` permission) Claude assistant at `/odin`. **Phase-1 scope is invoice import ONLY** — general chat is guarded off in `OdinChat.submit` (text-only messages get a canned reply, no API call). Backend `src/services/ai.service.ts` + `src/routes/admin/ai.ts`; per-call cost ledger in `ai_usage` with a monthly budget cap (402 when exceeded). Invoice flow: PDF → `extract-invoices` (structured output) → review cards → existing `POST /received-invoices`. **`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is back-calculated (`vatFromGross`). Phase-2 design notes live in `docs/superpowers/specs/`. --- ## Database & Migrations Conventions: snake_case columns; `created_at`/`updated_at` timestamps; soft-delete via `is_deleted` where present; `number_sequences` owns all document numbering. **Golden rules:** - **NEVER `prisma db push`** — it bypasses migration history and causes drift. - **Every DB change is a tracked migration** — schema, permission/seed rows, backfills, one-shot fixes. A migration's `migration.sql` may contain plain `INSERT/UPDATE/DELETE`. No raw SQL against production, ever; `prisma db seed` is dev-only convenience (`prisma/seed.ts` refuses to run with `APP_ENV=production`) — prod baselines belong in migrations. **Making a schema change (this shell is non-interactive — `prisma migrate dev` will refuse to run; use this recipe):** ```bash # 1. Edit prisma/schema.prisma (ask the user to stop their dev server first!) # 2. Generate the migration SQL into a new folder: mkdir prisma/migrations/_ npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \ > prisma/migrations/<...>/migration.sql # strip the BOM if written via PowerShell Out-File! # 3. Review the SQL, then apply + regenerate: npx prisma migrate deploy npx prisma generate # 4. Apply to the test DB as well (suite breaks otherwise): # DATABASE_URL= npx prisma migrate deploy # 5. Commit schema.prisma AND the migration folder together. ``` Deployment, drift checks, hotfix-migration shipping and baselining: see **`docs/release.md`**. --- ## Enforced Conventions (single source — merged 2026-06 audits) **Routes** - `success()`/`error()`/paginated helpers only; `parseId` for numeric params (`if (id === null) return;`); `parseBody` for every body; permission guards on reads AND writes; `logAudit` with old/new values on every mutation. - Role-management writes are admin-only and re-checked (no creating or rewriting the `admin` role). **Schemas (Zod 4)** - `z.strictObject`/`z.looseObject` (not deprecated `.strict()`/`.passthrough()`). - **Shared coercion helpers in `src/schemas/common.ts` are MANDATORY** for form-coerced fields: `numberFromForm`, `numberInRange`, `nonNegativeNumberFromForm`, `positiveNumberFromForm`, `intIdFromForm`/`nullableIntIdFromForm`, `nonNegativeIntFromForm`, `booleanFromForm`, `isoDateString`, `dateTimeString`/`nullableDateTimeString`, `timeString`, `emailOrEmpty`, `DocumentSectionSchema`. NEVER the raw `z.union([z.number(), z.string()]).transform(Number)` idiom (silent NaN — the single biggest historical bug class). FK ids → intIdFromForm; quantities/prices → nonNegative/positive; bounded → numberInRange. - The date/time helpers are deliberately **lenient** (strip trailing time components) because edit forms re-submit `toJSON`-serialized values. Optional emails: `emailOrEmpty.nullish()` (a bare `.email()` 400s the form). - Align `max()` caps with the DB column widths (an over-cap string 500s at Prisma instead of 400ing at Zod). Update schemas derive via `.partial()` (+ `.omit()` for immutable fields like document numbers) — and therefore must NOT carry top-level `.default()`s (Zod 4 `.partial()` still injects field defaults into partial payloads). - User-facing messages in **Czech**; identifiers/tokens in English. **Determinism & concurrency** - Timestamp/date-only sorts ALWAYS get an `{ id }` tiebreak (second-precision columns make single-key sorts non-deterministic). - Read-modify-write of shared rows (stock, balances, sequences) runs inside `prisma.$transaction` with `SELECT … FOR UPDATE` via **`tx.$queryRaw`** (not `$executeRaw`), locking in global ascending-id order. Multi-statement writes (header + items + sections) belong in ONE transaction. - Uniqueness checks go INSIDE the create transaction (pass the `tx` client to the checker) and catch `P2002` → 409 as backstop. **Errors** - Never silently swallow — every catch logs (`console.*` in services; there is no request-scoped logger there). The only allowed silent catch is an _expected_ condition (e.g. ENOENT-as-existence-check) **with a comment**. - Prefer explicit Czech 4xx over silently ignoring submitted fields. --- ## Known Gotchas 1. **CJS/ESM:** the server compiles to CommonJS; Vitest runs ESM (`vitest.config.ts` bridges it). Beware ESM-only dependencies. 2. **Rich text / PDF security:** every rich-text/HTML field gets server-side DOMPurify + the strict `cleanQuillHtml` from `src/utils/pdf-shared.ts` on the PDF path AND sanitization before any `dangerouslySetInnerHTML`. Never pass unsanitized user data into Puppeteer templates; string-built HTML (print views, PDF templates) must `escapeHtml` every interpolation. 3. **NAS paths** may be unmounted in dev (`Z:`) — NAS features must degrade with logged errors, and tests stub NAS reads. 4. **Prisma client**: regenerate after every schema change; it is not committed. (On prod this is a mandatory deploy step — see docs/release.md.) 5. **No CSRF tokens** by design (SameSite=Strict + CORS) — do not weaken CORS. 6. **Czech locale hardcoded** in messages/month names — intentional. 7. **Cross-login caches:** React Query keys are user-agnostic; the query cache is cleared on login/logout in AuthContext — keep it that way. --- ## Release Process, deployment commands, hotfix-migration shipping and verification checklist: **`docs/release.md`**. Run the full test suite before tagging; the user picks version numbers. **Never push to production or restart services without explicit confirmation.**