# 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). Two repeating-
  footer mechanisms coexist and BOTH work: Puppeteer `footerTemplate`
  (invoices + issued orders — `htmlToPdf(html, { footerTemplate })`) and CSS
  `@page` margin boxes (`@bottom-center` + `counter(page)`, offer PDF —
  supported since Chrome 131, Nov 2024). Don't migrate one to the other.
- **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__/<feature>.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
  `<Forbidden/>` 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 `<html data-theme>`); dialogs lock `<html>` 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"

`ai.use`-gated Claude assistant at `/odin` (granted to admin). **Phase 2a:
read-only agentic assistant** — chat turns run an agentic loop
(`agenticChat` in `src/services/ai.service.ts`, max 6 round-trips, budget
re-checked between iterations) over the read-only tools in
`src/services/ai-tools.ts` (invoices, offers, orders, projects, customers,
warehouse, attendance).

**Security model (do not weaken):** Odin is the **user's delegate** — the
tool list is pre-filtered by the caller's permissions AND every handler
re-checks them (`ctxCan`); there are **NO write tools** (writes come in
Phase 2b as propose→confirm action cards, never autonomous). Tool handlers
call the same service functions the routes use. Assistant turns persist
their tool trace in `ai_chat_messages.content_json` (plain `content` stays
the display text); the UI shows consulted-tool chips. The system prompt
enforces plain-text replies (the chat renders pre-wrap text, no Markdown).

Per-call cost ledger in `ai_usage` with a monthly budget cap (402 when
exceeded). Invoice flow unchanged: PDF → `extract-invoices` (structured
output) → review cards → existing `POST /received-invoices`.
**`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is
back-calculated (`vatFromGross`). Phase-2 design notes live in
`docs/superpowers/specs/`.

---

## 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/<yyyyMMddHHmmss>_<name>
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=<app_test 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.**
