Files
app/CLAUDE.md
BOHA e11765bf0e fix: resolve all 28 findings from the 2026-06-12 full audit (TDD-pinned)
Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
  failed deficit line rolls back the surplus corrective receipt — retries
  no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
  of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
  (in-transaction, clamped at 0)

High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
  the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
  when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
  (Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
  hours against its own balance (mirrors createLeave)

Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
  500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
  Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
  (recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
  @db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
  /items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
  finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
  settings.company|settings.system; Settings System tab no longer clobbers
  Firma numbering patterns; draft invoices hide the dead PDF button;
  dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
  audit-log + invoice month buckets day-shift fixes

Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.

~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:00:19 +02:00

437 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:0002: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.**