Applied the two-review consolidation: merged the duplicate 2026-06-06/06-09 'Conventions (enforced)' sections into one canonical block (query-invalidation rule stated once instead of three times, Zod-helper 'tracked follow-up' vs 'MANDATORY' contradiction resolved, seed-is-dev-only stated once); removed stale-by-design facts (version pins, test counts, file counts, React 18 typo) and migration-era storytelling; fixed prisma migrate diff flags (--from-url removed in Prisma 7) and documented the non-interactive migration recipe; added the new rules earned this week (@db.Date UTC-truncation, document business rules incl. VAT-only-on-invoices and PO suppliers, document-platform conventions, shared-module reuse list, dashboard invalidation, app_test migrate step). Release/hotfix/drift/baseline runbooks moved to docs/release.md with a pointer (keeps prod details out of every-turn context). Also dropped the db:push npm script - it contradicted the golden rule. CLAUDE.md: 570 -> ~390 lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
21 KiB
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 |
| 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
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)
- Global override:
src/config/env.tssetsTZ=Europe/Pragueand monkey-patchesDate.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. @db.Datecolumns — 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.Datefilter boundaries and "today" writes as UTC-midnight instants of the LOCAL calendar day:new Date(Date.UTC(y, m, d))orutcMidnightOfLocalDay()fromsrc/utils/date.ts; use half-opengte/ltranges; never write a barenew Date()into a@db.Datecolumn (stores yesterday between 00:00–02:00 Prague).- Two deliberate write regimes — don't "fix" either: the plan module
does date-only math in UTC; attendance/leave writes
@db.Dateat local noon (new Date(y, m, d, 12, 0, 0)). Frontend "today" comes fromlocalDateStr(server) /normalizeDateStr/todayLocalStr(client) — nevernew 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 atGET /issued-orders/suppliersunder orders._ perms); offers take customers. Issued orders share theorders._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_TRANSITIONSmaps in the services; detail responses returnvalid_transitionsand 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 shareduseDocumentLockhook +LockBanner. - PDF serving:
GET …/:id/fileserves 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_Nsuffixes. The issued-order PDF gets language from the document'slanguagecolumn and carries a PuppeteerfooterTemplatefooter on every page ("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no CSS margin-box footers; usehtmlToPdf(html, { footerTemplate }). - Shared modules — extend, never fork local copies:
src/admin/components/document/(DocumentItemsEditor, SectionsEditor, LockBanner), hooksuseDocumentLock/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 devapp), configured in.env.test;src/__tests__/setup.tshard-throws unlessDATABASE_URLnames a*test*database. - After any schema change, apply migrations to the test DB too or the
suite fails: set
DATABASE_URLto the app_test URL and runnpx prisma migrate deploy. - To (re)create it:
CREATE DATABASE app_test;→ copy.envto.env.testwith 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 viavi.spyOn). Files run serialized (fileParallelism: false) to avoid sequence deadlocks. Tests are type-checked bytsc -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()inAdminApp.tsx; auth viauseAuth(), toasts viauseAlert(). - React Query for all fetching (query options live in
src/admin/lib/queries/; no fetch-in-useEffect; prefer deriving state over effects). Mutations viauseApiMutation(opt-inenvelopemode passes the server message through).apiFetchrefreshes 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 usesrefetchOnMount: "always"as a backstop. Raw-apiFetchwrites still must invalidate their domains. - Single sources of truth: status chips/labels in
lib/documentStatus.ts; audit entity labels inlib/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 inGlobalStyles.tsx; pages compose the kit insrc/admin/ui/— reach for raw@mui/materialonly 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 viatheme.applyStyles("dark", …). No hardcoded hex. - Don't regress: status row tints are channel-alpha washes (never
.lightfills — invisible text in dark mode); icon badges are solidX.main+ white glyph; the theme persists ONLY under MUI'smui-modelocalStorage key (ThemeContext owns<html data-theme>); dialogs lock<html>viauseDialogScrollLock; Modal/ConfirmDialog freeze content through the close fade; ConfirmDialogs stay open withloadingduring 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.sqlmay contain plainINSERT/UPDATE/DELETE. No raw SQL against production, ever;prisma db seedis dev-only convenience (prisma/seed.tsrefuses to run withAPP_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):
# 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;parseIdfor numeric params (if (id === null) return;);parseBodyfor every body; permission guards on reads AND writes;logAuditwith old/new values on every mutation.- Role-management writes are admin-only and re-checked (no creating or
rewriting the
adminrole).
Schemas (Zod 4)
z.strictObject/z.looseObject(not deprecated.strict()/.passthrough()).- Shared coercion helpers in
src/schemas/common.tsare MANDATORY for form-coerced fields:numberFromForm,numberInRange,nonNegativeNumberFromForm,positiveNumberFromForm,intIdFromForm/nullableIntIdFromForm,nonNegativeIntFromForm,booleanFromForm,isoDateString,dateTimeString/nullableDateTimeString,timeString,emailOrEmpty,DocumentSectionSchema. NEVER the rawz.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.$transactionwithSELECT … FOR UPDATEviatx.$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
txclient to the checker) and catchP2002→ 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
- CJS/ESM: the server compiles to CommonJS; Vitest runs ESM
(
vitest.config.tsbridges it). Beware ESM-only dependencies. - Rich text / PDF security: every rich-text/HTML field gets server-side
DOMPurify + the strict
cleanQuillHtmlfromsrc/utils/pdf-shared.tson the PDF path AND sanitization before anydangerouslySetInnerHTML. Never pass unsanitized user data into Puppeteer templates; string-built HTML (print views, PDF templates) mustescapeHtmlevery interpolation. - NAS paths may be unmounted in dev (
Z:) — NAS features must degrade with logged errors, and tests stub NAS reads. - Prisma client: regenerate after every schema change; it is not committed. (On prod this is a mandatory deploy step — see docs/release.md.)
- No CSRF tokens by design (SameSite=Strict + CORS) — do not weaken CORS.
- Czech locale hardcoded in messages/month names — intentional.
- 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.