Files
app/CLAUDE.md
BOHA 674b44d047 feat(odin): Phase 2a read-only agentic assistant + tool-trace UI
- agentic chat loop (max 6 round-trips, budget re-checked between iterations)
  over 10 read-only, permission-delegated tools in src/services/ai-tools.ts
- persist assistant tool trace in ai_chat_messages.content_json (+ migration)
- consulted-tools chips in OdinThread; header now shows month spend only
  (budget cap hidden from the UI, server-side 402 guard unchanged)
- seed: ai.use permission; Settings exposes the ai permission module
- docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md

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

22 KiB
Raw Blame History

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

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) — 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__/<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):

# 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.