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>
437 lines
22 KiB
Markdown
437 lines
22 KiB
Markdown
# 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.**
|