Files
app/CLAUDE.md
BOHA 700fb47bbc docs(release): prisma generate + prisma.config.ts are mandatory deploy steps
npm install skips client regeneration when deps are unchanged - a schema-changing release then serves a stale client (P2022 500s; bit v2.4.0). prisma.config.ts carries the Prisma 7 datasource and must ship in the tarball.

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

35 KiB
Raw Permalink Blame History

CLAUDE.md — boha-app-ts

Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js. Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.


Tech Stack

Layer Technology
Runtime Node.js, TypeScript 5.9.3 (strict)
HTTP Framework Fastify 5.8.2
ORM Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → MySQL
Auth JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs
Validation Zod 4.3.6
Frontend React 19.2.7 + Vite 8.0.0 + Material UI v7 (Emotion)
Testing Vitest 4.1.0 + Supertest
PDF Puppeteer 24.x
Email nodemailer 8.x
Cron node-cron 4.x
AI @anthropic-ai/sdk 0.102 — Claude Sonnet 4.6 ("Odin" assistant)

Project Structure

src/
├── server.ts           # Fastify server entry point — plugins, routes, error handler
├── routes/admin/       # HTTP route handlers (one file per entity)
├── services/           # Business logic (no classes, exported functions, uses Prisma directly)
├── schemas/            # Zod validation schemas (one file per entity)
├── middleware/         # auth.ts (requireAuth, requirePermission, optionalAuth)
│                       # security.ts (CSP, HSTS, security headers)
├── utils/              # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
├── config/             # env.ts (config singleton, Date.toJSON override)
├── types/              # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
├── admin/              # React 18 + Material UI frontend (~114 .tsx files)
│   ├── AdminApp.tsx    # Router + lazy-loaded pages
│   ├── theme.ts        # MUI theme — light/dark color schemes, tokens, component defaults
│   ├── GlobalStyles.tsx  # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
│   ├── ui/             # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
│   ├── context/        # AuthContext, AlertContext  (ThemeContext lives in src/context/)
│   ├── components/     # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets, odin/ (AI chat)
│   ├── pages/          # One file per page/feature
│   ├── lib/            # React Query options & mutations (queries/) + shared label maps
│   ├── hooks/          # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
│   └── utils/          # api.ts (fetch wrapper with token refresh), formatters, helpers
└── __tests__/          # Vitest tests (17 files: auth, numbering, warehouse, plan, invoices, ai/Odin, received-invoices VAT, …)

prisma/
├── schema.prisma       # 52 models, MySQL, snake_case columns
└── migrations/         # Applied migrations

dist/                   # Compiled server (CommonJS, ES2022)
dist-client/            # Built frontend (Vite, ES2020)

Commands

# Development
npm run dev             # Starts server in watch mode (manage frontend separately)
npm run dev:server      # tsx watch src/server.ts
npm run dev:client      # Vite dev server

# Build
npm run build           # Build server + client
npm run build:server    # tsc -p tsconfig.server.json → dist/
npm run build:client    # vite build → dist-client/

# Run (production)
npm start               # node dist/server.js

# Tests
npm test                # vitest run (single pass) — runs against the app_test DB via .env.test
npm run test:watch      # vitest watch

# Quality gates
npm run typecheck       # tsc -b --noEmit (also type-checks the tests via tsconfig.test.json)
npm run lint            # eslint . — react-hooks/rules-of-hooks is an ERROR; keep at 0 errors
npm run format          # prettier --write .

# Database
npx prisma migrate dev                              # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name>    # Named migration
npx prisma migrate deploy                           # Apply pending migrations to production
npx prisma generate                                 # Regenerate Prisma client after schema changes
npx prisma studio                                   # DB browser GUI
npx prisma db seed                                  # (Re)seed the dev database
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script  # Preview SQL before prod deploy
npx prisma migrate resolve --applied <migration>    # Mark migration as applied without running SQL

Do not start the dev server. The user manages it separately.

Before running prisma migrate dev or prisma db push, ask the user to stop their dev server and wait for confirmation. Migrations can conflict with an active database connection from the running server.


Environment Variables

Required:

DATABASE_URL=mysql://user:pass@host:3306/dbname
JWT_SECRET=<64-char hex string>
TOTP_ENCRYPTION_KEY=<64-char hex string>

Optional (with defaults):

PORT=3001                          # Production port (dev default: 3050, hardcoded in server.ts)
HOST=127.0.0.1
APP_ENV=local|production           # Default: local. Controls CSP, CORS, HSTS
ACCESS_TOKEN_EXPIRY=900            # 15 minutes
REFRESH_TOKEN_SESSION_EXPIRY=3600  # 1 hour
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000  # 30 days
NAS_PATH=Z:/02_PROJEKTY            # Network share for project files
MAX_UPLOAD_SIZE=52428800           # 50MB
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
LEAVE_NOTIFY_EMAIL=
APP_URL=                           # Used in email links
CORS_ORIGINS=                      # Comma-separated, production only

Use .env for dev, .env.test for tests.


Architecture & Key Patterns

Request Flow

Request → CORS → Cookie → Rate-limit → Security headers
       → requirePermission() or requireAuth()
       → Zod schema validation (parseBody helper)
       → Route handler
       → Service function
       → Prisma
       → success(reply, data) or error(reply, message, status)

Response Format

All responses use this shape:

// Success
{ success: true, data: T, message?: string, pagination?: {...} }

// Error
{ success: false, error: string }

Use the success() and error() helpers in routes — never write raw reply.send().

Service Pattern

Services are plain exported async functions, no classes:

// src/services/foo.service.ts
export async function getFoo(id: number) {
  const result = await prisma.foo.findUnique({ where: { id } });
  if (!result) return { error: "Not found", status: 404 };
  return { data: result };
}

// src/routes/admin/foo.ts
const result = await getFoo(id);
if ("error" in result) return error(reply, result.error, result.status ?? 400);
return success(reply, result.data);

Error Handling

  • Routes map service errors to HTTP responses using the pattern above.
  • Global error handler in server.ts catches all unhandled exceptions; returns 500 with Czech message.
  • Never silently swallow errors. Even if a failure is non-fatal, log it: app.log.error(e, 'context').
  • Error messages are in Czech (this is intentional — user-facing messages, Czech company).

Permissions

// Route-level guard
fastify.addHook("preHandler", requirePermission("invoices.view"));
// or multiple
fastify.addHook(
  "preHandler",
  requirePermission("invoices.view", "invoices.edit"),
);

// Admin role bypasses all permission checks
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")

Audit Logging

Call logAudit() from src/utils/audit.ts whenever data is created/updated/deleted. Pass oldData and newData so the diff is stored. Audit failures are non-fatal.

Validation

Use Zod schemas from src/schemas/. All route bodies must be validated:

const body = parseBody(FooSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);

Date & Timezone Handling (Critical Gotcha)

src/config/env.ts sets process.env.TZ = 'Europe/Prague' and overrides Date.prototype.toJSON() to return local time (not UTC). This means:

  • JSON.stringify(new Date()) returns local Czech time, not UTC.
  • All API responses with Date fields will contain local time strings.
  • Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
  • Never assume UTC when working with Date objects in this codebase.
  • When writing new date comparisons or DB queries, use new Date() (already local) — do not manually offset.
  • The override exists for PHP migration compatibility and Czech date display.

TOTP / 2FA

  • Secret stored AES-256-GCM encrypted in users.totp_secret.
  • Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
  • Backup codes stored as encrypted JSON array in users.totp_backup_codes.
  • When company_settings.require_2fa = true, all users must enroll before accessing the app.
  • Login flow: password → if 2FA enabled → issue loginToken (5 min, single-use) → TOTP verify → issue access + refresh tokens.

Testing

Tests live in src/__tests__/. They use Vitest + Supertest against a real, throwaway test database (app_test).

  • Isolated test DB (app_test): the suite MUTATES a real MySQL DB, so it runs against app_test (NOT dev app), configured in .env.test (gitignored). src/__tests__/setup.ts hard-throws if DATABASE_URL doesn't name a *test* database — a stray URL can never corrupt dev/prod data. To (re)create it: CREATE DATABASE app_test; → copy .env to .env.test with the DB name swapped + ANTHROPIC_API_KEY= blanked → DATABASE_URL=<app_test-url> npx prisma migrate deployDATABASE_URL=<app_test-url> npx tsx prisma/seed.ts.
  • The suite spans 18 files / 247 tests — auth (incl. happy-path login/refresh-rotation), numbering, warehouse (incl. FIFO oldest-first), plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create, AI/Odin, received-invoices VAT. Server-side only (no component tests yet).
  • Tests are now type-checked by tsc -b (via tsconfig.test.json) — keep them compiling.
  • Use buildApp() helper to spin up the Fastify instance for tests.
  • Tests use vitest.config.ts with environment: 'node' and 15s timeout; fileParallelism: false (serialized) to avoid number_sequences deadlocks.
  • Do not mock Prisma — tests hit a real database to catch schema/query bugs.

When adding new features, add tests in src/__tests__/. Name test files <feature>.test.ts.


Frontend Conventions

  • Pages are lazy-loaded via React.lazy() in AdminApp.tsx.
  • Auth state lives in AuthContext; use useAuth() hook to access it.
  • Alerts/toasts use AlertContext; use useAlert() to show them.
  • Data fetching uses React Query (query options + mutations in src/admin/lib/queries/); src/admin/utils/api.ts (apiFetch) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry expires_in so the client refreshes BEFORE expiry (no reactive 401 churn).
  • Custom hooks: usePaginatedQuery, useTableSort, useDebounce, useModalLock, useReducedMotion.
  • Styling: Material UI v7 (Emotion) — theme in src/admin/theme.ts, sx/styled() in components, app-wide rules in GlobalStyles.tsx. No hand-written .css files, no Tailwind. Use theme.vars for colours so light/dark resolve automatically.

Query Invalidation Convention

Mutations must invalidate the full domain of any entity they touch. Prefer broad invalidation:

  • ["users"] over ["users", "list"]
  • ["trips"] over ["trips", "vehicles"]

Reason: React Query's invalidateQueries uses prefix matching, so ["trips"] matches all ["trips", ...] sub-queries. This means new queries are automatically invalidated without updating every mutation handler. Inactive queries are only marked stale, not refetched, so the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.

When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:

  • User CRUD invalidates ["trips"] and ["attendance"] (both embed user data)
  • Vehicle CRUD invalidates ["trips"] (trips reference vehicles)
  • Invoice CRUD invalidates ["orders"] (orders reference invoices)

Frontend — Styling & MUI (migration complete, shipped in v2.0.0)

The admin frontend is fully on Material UI v7 (Emotion). The old ~7,100 lines of hand-written CSS are gone — there are no custom .css files in src/admin; the only stylesheets imported anywhere are the two third-party libs (react-quill-new/dist/quill.snow.css, leaflet/dist/leaflet.css). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (project_mui_migration.md); the original spec/plans are under docs/superpowers/.

Where styling lives

  • Theme — src/admin/theme.ts: cssVariables with colorSchemeSelector: "[data-theme='%s']", light + dark colorSchemes, tokens, component defaults. Use theme.vars!.palette.* (the vars field is typed optional → !) so colours resolve per scheme; for alpha use the channel tokens — rgba(${theme.vars!.palette.primary.mainChannel} / 0.12); for per-scheme one-offs use theme.applyStyles("dark", { … }).
  • Global rules — src/admin/GlobalStyles.tsx: reset, typography, scrollbar, ::selection, view-transition timing, and the utility classes (.text-*, .flex-*, .mb-*, …), all theme-aware. (The pre-React bootstrap spinner is inlined in src/App.tsx because it mounts before MUI.)
  • Component kit — src/admin/ui/: AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional children + content freeze), DataTable (sortable + mobile card layout + rowSx/rowDanger/rowInactive), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only /ui-kit showcase route.

Building / editing pages

  • Pages import from the kit (src/admin/ui/); reach for @mui/material (styled/sx) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
  • Wrap a page's top-level sections in <PageEnter>. Filters go in <FilterBar> with bare controls (no <Field> label) at the standard widths.
  • When refactoring, preserve ALL data logic verbatim (hooks, mutations, invalidate arrays, validation, permissions); change only presentation.

Conventions learned — don't regress

  • Status row tints = subtle channel-alpha washes (rgba(var(--mui-palette-X-mainChannel) / 0.12)), NEVER a solid .light fill: .light is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = opacity + muted text.
  • Icon badges = solid X.main tile + white glyph (not an X.light tile + X.main glyph — that's low-contrast).
  • Theme is single-source: src/context/ThemeContext.tsx owns the <html data-theme> attribute + the View-Transitions cross-fade, and persists under MUI's mui-mode localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
  • Dialogs lock <html> via useDialogScrollLock (MUI only locks <body>, and html{overflow-x:hidden} makes <html> the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.

Gates every change: npx tsc -b --noEmit (NOT -p tsconfig.json — a vacuous solution file that checks nothing), npm run build, npx vitest run, npm run lint. The solution tsconfig.json now references tsconfig.test.json too, so tsc -b type-checks the test files (previously skipped). ESLint (flat config in eslint.config.mjs) enforces react-hooks/rules-of-hooks as an error — that rule catches the "hook after an early return" bug class; keep npm run lint at zero errors (warnings are advisory). Prettier config is .prettierrc.json (npm run format).


AI Assistant — "Odin" (shipped v2.1.5)

Admins-only Claude assistant on the /odin page (sidebar nav item "Odin"). Phase-1 scope is invoice import ONLY — general chat is guarded off in OdinChat.submit to avoid spending API credits: a text-only message gets a canned reply and makes NO AI call. Removing that one guard re-enables the /chat path for a later "general assistant" phase.

  • SDK / model: @anthropic-ai/sdkclaude-sonnet-4-6. Server-side only — ANTHROPIC_API_KEY in .env (already set on prod; don't touch env, the user manages it). The whole feature self-hides when the key is absent (/ai/usage returns configured:false).
  • Backend: src/services/ai.service.ts (chat, extractInvoice [PDF→structured-output JSON], cost/budget tracking, conversation CRUD with server-side auto-title), src/routes/admin/ai.ts (/api/admin/ai/*: usage, budget, chat, extract-invoices, conversations[/:id/messages]), src/schemas/ai.schema.ts. Every route requirePermission("ai.use") (granted to admin only via migration).
  • Data: ai_usage (per-call token-cost ledger), ai_conversations + ai_chat_messages (per-user, FK cascade, auto-title from first message), company_settings.ai_monthly_budget_usd (default $50; assertBudgetAvailable → 402 at the cap).
  • Frontend: src/admin/pages/Odin.tsxsrc/admin/components/odin/: OdinChat (orchestrator + state + the proven submit/extract/save logic), OdinSidebar (conversation list; inline ≥md, slide-in Drawer below md), OdinThread (messages, framer-motion entrance, Newsreader serif greeting hero), OdinComposer (claude-style rounded multiline pill, attach/send icons), InvoiceReviewCard, OdinMark (animated brand mark — CSS keyframes via styled, not inline style; opacity-glow fallback under reduced-motion), types.ts. Queries in src/admin/lib/queries/ai.ts. FrauncesNewsreader font added to index.html.
  • Invoice flow: attach PDF(s) → extract-invoices → editable review cards → save to the EXISTING POST /received-invoices. received_invoices.amount is GROSS (VAT-inclusive) — VAT is back-calculated via vatFromGross in received-invoices.ts (amount * rate/(100+rate)), used by both the manual form and the AI import. The save button is gated on invoices.create.
  • Phase 2 (general assistant — NOT built): forward-looking design notes in docs/superpowers/specs/2026-06-08-odin-phase2-general-assistant-notes.md (tool-use over services, structured message-block storage, per-action authorization). Phase-1 spec/plan also under docs/superpowers/.

Database Conventions

  • All models use snake_case column names; Prisma maps to camelCase in TypeScript.
  • Soft-delete via is_deleted boolean (not all tables, check schema).
  • Timestamps: created_at, updated_at (auto-managed by Prisma).
  • Number sequences (number_sequences table) manage invoice/quotation numbering — never hardcode numbering logic.
  • All significant tables have audit log entries. Check audit_logs model for the schema.

Database Migrations (Critical Workflow)

Golden rule: NEVER use prisma db push. It bypasses migration history and causes drift between the schema and migration tracking. Always use prisma migrate dev for every schema change.

Every database change or manipulation must be a tracked Prisma migration. This includes:

  • Schema changes (tables, columns, indexes, constraints) — via prisma migrate dev
  • Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
  • Lookup data, default settings, or any other row-level baseline that production needs
  • Backfills, data fixes, and one-shot corrections that should be in sync across environments

What is NOT acceptable:

  • Running raw SQL against production (mysql -e "...", npx prisma db execute, pm2 exec)
  • npx prisma db seed as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
  • "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it

The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.

If you need to insert/update/seed data on production, write a migration. The migration's migration.sql is a normal SQL file — INSERT INTO ..., UPDATE ..., DELETE ... are all valid. Prisma will run it via prisma migrate deploy like any other migration.

Making schema changes

1. Edit prisma/schema.prisma
2. npx prisma migrate dev --name descriptive_name
   → This creates a migration in prisma/migrations/ AND applies it to dev DB
3. npx prisma generate
4. Commit BOTH schema.prisma AND the new migration folder
   git add prisma/schema.prisma prisma/migrations/

Verifying before production deploy

# Preview what SQL will run on production (no changes applied)
npx prisma migrate diff \
  --from-url "mysql://user:pass@prod:3306/app" \
  --to-schema-datamodel prisma/schema.prisma \
  --script

# Empty output = no diff. If it shows SQL, review it before deploying.

Deploying migrations to production

The release process runs prisma migrate deploy on production. This applies only pending migrations — safe, idempotent.

If migrations get out of sync (drift)

# Diff production DB against local schema to find drift
npx prisma migrate diff \
  --from-url "mysql://prod" \
  --to-schema-datamodel prisma/schema.prisma \
  --script

# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
# If drift includes DROPs: investigate before touching production

Baselinining a database that has no migrations

If production was synced with db push and has no _prisma_migrations table:

# 1. Create initial migration locally
npx prisma migrate dev --name init

# 2. Copy to production and mark as applied (no SQL runs)
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
ssh user@prod
cd /var/www/app-ts
npx prisma migrate resolve --applied <migration_name>

Conventions (enforced — verified by the 2026-06-06 audit)

These are the unified rules across the codebase. Follow them for new code; the audit found and fixed the deviations. Full report: docs/codebase-audit-2026-06-06.md.

Routes

  • Responses: always success(reply, data[, status, message]) / error(reply, msg, status) / the paginated helper. Never raw reply.send({ success: true, ... }). (Some legacy files still do — convert when you touch them.)
  • Route ids: parse numeric params with parseId((request.params as any).id, reply) then if (id === null) return;. Do not use raw parseInt (it yields NaN, not a 400).
  • Bodies: validate every body with parseBody(Schema, request.body)if ("error" in body) return error(reply, body.error, 400).
  • Permissions: guard with requirePermission / requireAnyPermission. The admin shortcut is authData.roleName === "admin". ⚠️ AuthData has roleName, not role (role only exists on JwtPayload). Don't type authData as any — it hides exactly this bug.
  • Audit: call logAudit on every create/update/delete (and on security-relevant actions like session/token termination) with oldValues/newValues.

Services

  • Plain exported async functions; return { data } or { error, status } (preferred over discriminated unions). Services own Prisma; routes stay thin.
  • Respect soft-delete (is_deleted: false) in reads where the model has it.

Schemas (Zod 4)

  • Use Zod 4 idioms: z.strictObject({...}) / z.looseObject({...}) (not deprecated .strict() / .passthrough()).
  • Shared coercion helpers (number-from-form, nullable-number, boolean-from-form) belong in src/schemas/common.ts — don't copy-paste the z.union([z.number(), z.string()]).transform(Number) idiom (it's duplicated ~150× today; consolidating is a tracked follow-up). New number coercions should guard against NaN.
  • User-facing messages in Czech; identifiers/keys in English.

Frontend

  • Query invalidation: invalidate the broad domain key (["offers"], not ["offers","list"]). Prefix-matching covers sub-queries.
  • Single source of truth for shared maps: audit entity_type → Czech label lives in src/admin/lib/entityTypeLabels.ts and MUST be keyed to the server's EntityType values (add the new key there when you add an entity type). Plan categories come from the DB via lib/queries/plan.ts. Don't duplicate label maps across files or across server/client.
  • Prefer deriving state over useEffect; use React Query for fetching, not effects.

Dates (two deliberate regimes — don't "fix" either)

  • Plan module does all date-only math in UTC (setUTCDate, toISOString().slice(0,10)) because its columns are @db.Date (UTC-midnight). Correct and stable.
  • Attendance/leave writes @db.Date at local noon (new Date(y, m, d, 12, 0, 0)).
  • Frontend "today" / date-string round-trips: use utils/date.ts localDateStr (server) / normalizeDateStr (client). Never use new Date().toISOString().split("T")[0] for "today" — it's the UTC date and is a day early during the late-evening Prague window.

Conventions (enforced — added by the 2026-06-09 full audit)

The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. These are now rules — npm run lint + tsc -b (which now type-checks tests) catch some automatically.

Zod validation — shared coercion helpers are MANDATORY

  • All numeric/boolean/date/email form fields MUST use the helpers in src/schemas/common.ts: numberFromForm, numberInRange(min,max), nonNegativeNumberFromForm, positiveNumberFromForm, intIdFromForm, nullableNumberFromForm/nullableIntIdFromForm, booleanFromForm, isoDateString, timeString, emailOrEmpty.
  • NEVER the raw z.union([z.number(), z.string()]).transform(Number) idiom — it silently yields NaN that flows into Prisma/business math (this was the single biggest bug class). FK ids → intIdFromForm/nullableIntIdFromForm; quantities/prices → nonNegative/positive; bounded values (VAT 0100, month 112) → numberInRange.
  • isoDateString/timeString are lenient (they strip a trailing time/seconds component) because an edit form re-submits a @db.Date that the toJSON override serialised as "YYYY-MM-DDT00:00:00". Use them for date/time fields, not a bare regex.
  • An optional email a form may submit as ""emailOrEmpty.nullish() (a bare .email() 400s the whole form, blocking unrelated saves).

Deterministic ordering — always tiebreak a timestamp sort with id

  • created_at/received_at are second-precision (@db.Timestamp(0)/DateTime(0)), so orderBy: { created_at: "desc" } alone is non-deterministic for same-second rows. ALWAYS add { id: "desc" } (or asc) as the secondary sort. (Bit plan.service.resolveCell/resolveGrid and warehouse FIFO selectFifoBatches.)

Concurrency — locking discipline for stock / balance / sequence mutations

  • Any service op that read-modify-writes shared rows (stock, batches, reservations, balances, number sequences) MUST: run inside a prisma.$transaction; take the row lock with SELECT … FOR UPDATE via tx.$queryRaw (NOT $executeRaw — it does not reliably hold the lock); and lock rows in one global ascending-id order (parent → items → batches) so concurrent paths can't deadlock. See warehouse.service.ts lockParentRow/lockRowsForUpdate and attendance.service.ts lockUserRow.
  • Uniqueness checks (username/email/document number) go INSIDE the create transaction (re-check immediately before insert) and catch P2002 → 409. A pre-transaction check is a TOCTOU race that surfaces as a 500.

Permissions — guard reads, not just writes

  • GET list/detail endpoints need a requirePermission/requireAnyPermission guard, NOT bare requireAuth (bare-auth reads leak data to any logged-in user). Role-management writes are admin-only and re-checked (no privilege escalation; no creating/rewriting the admin role).

Frontend — Rules of Hooks (lint-enforced) + no UTC-today

  • ALL hooks (useState, useApiMutation, useMemo, …) run BEFORE any early return (the <Forbidden/>/<Navigate/> permission guard goes AFTER every hook). eslint.config.mjs sets react-hooks/rules-of-hooks to errornpm run lint must stay at 0 errors. This was a systemic crash bug across ~14 pages.
  • Mutations invalidate the broad domain key; a mutation that writes via raw apiFetch must still invalidateQueries the domain it touched (several dashboard widgets were missing this).

Safety

  • prisma/seed.ts refuses to run when APP_ENV=production (it wipes/reseeds permissions). Never seed prod.
  • Every catch logs (never silently swallow) — the NAS managers were the worst offenders.

Known Issues & Gotchas

  1. Date.prototype.toJSON override — global monkey-patch in src/config/env.ts. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.

  2. CJS/ESM mismatch in tests — Server compiles to CommonJS (tsconfig.server.json), but Vitest runs in ESM by default. The vitest.config.ts resolves this, but be careful when adding dependencies that only support ESM.

  3. Mixed error patterns — Some services return { error, status }, others return discriminated unions { type: 'success' | 'error' }. Prefer { error, status } for consistency with existing routes.

  4. Never swallow errors — log at minimum; never use empty catch blocks. The service layer logs via console.* (there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: a catch that handles an expected condition (e.g. ENOENT used as an existence check) may stay silent only with a comment saying so.

  5. HTML sanitization is in place — keep applying it — Rich-text fields (invoice notes, quotation scope, order notes) ARE sanitized: server-side DOMPurify (jsdom) plus a cleanQuillHtml regex pass at all three PDF routes, and the frontend sanitizes before every dangerouslySetInnerHTML (InvoiceDetail, OrderDetail). (The old "sanitization gap" note was stale — verified by the 2026-06-06 audit.) When you add ANY new rich-text/HTML field, apply the same sanitization on BOTH the PDF path and the render path.

  6. Puppeteer PDF generation — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.

  7. NAS_PATH file access — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using NAS_PATH will fail gracefully (or not) if the path is unavailable.

  8. Prisma client regeneration — After any schema change and migration, run npx prisma generate. The generated client is not committed to git.

  9. No CSRF tokens — CSRF protection relies on SameSite=Strict cookies + CORS. Do not weaken CORS configuration.

  10. Czech locale hardcoded — Error messages, month names, and some business logic strings are Czech. This is intentional.

  11. Seed file is dev-onlyprisma/seed.ts is for local dev convenience. It is not the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's migration.sql, not in the seed file. npx prisma db seed must never be run against production.


Release Process

  1. Bump version in package.json
  2. npm run build
  3. Commit and tag (git tag -a vX.Y.Z)
  4. Push to Gitea (git push origin master && git push origin vX.Y.Z)
  5. Create tarball: tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts (⚠️ prisma.config.ts is REQUIRED — Prisma 7 keeps the datasource URL there; without it, prisma generate/migrate deploy on prod have no datasource)
  6. Deploy via SSH to production server (boha_admin@192.168.50.100):
    • Path: /var/www/app-ts
    • Remove old files: rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json
    • Copy tarball to server: scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/
    • Extract tarball: tar -xzf /tmp/app-ts-X.Y.Z.tar.gz
    • Install dependencies: npm install --omit=dev
    • Regenerate the Prisma client: npx prisma generateMANDATORY. npm install skips regeneration when dependencies didn't change, leaving a stale client that still selects dropped/renamed columns → P2022 500s in prod (bit the v2.4.0 supplier release).
    • Apply Prisma migrations: npx prisma migrate deploy
    • Restart: pm2 restart app-ts --update-env

Hotfixing a migration that was added after the tarball was built

If you write a new prisma/migrations/<timestamp>_* folder after the release tarball has already been built and shipped, that migration is NOT in the tarball and prisma migrate deploy on prod will report "No pending migrations". The release tarball re-build re-runs the whole release, but for a one-off hotfix you can ship just the new migration folder:

# Local
cd prisma/migrations
tar -czf /tmp/warehouse_perms_migration.tar.gz <timestamp>_<name>/
scp /tmp/warehouse_perms_migration.tar.gz boha_admin@192.168.50.100:/tmp/

# On prod
ssh boha_admin@192.168.50.100
cd /var/www/app-ts/prisma/migrations
sudo -u boha_admin tar -xzf /tmp/warehouse_perms_migration.tar.gz
cd /var/www/app-ts
npx prisma migrate deploy
pm2 restart app-ts --update-env

The migration is still tracked in git and applied via prisma migrate deploy — the only thing the tarball is doing is delivering the migration.sql to prod, which is the same mechanism the main release uses. This is preferred over running raw SQL on prod (which the policy in "Database Migrations" forbids).

Do not push directly to production or restart services without confirmation.