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>
570 lines
35 KiB
Markdown
570 lines
35 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
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 deploy` → `DATABASE_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/sdk` → `claude-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.tsx` → `src/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`. `Fraunces`→`Newsreader` 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
|
||
|
||
```bash
|
||
# 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)
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```bash
|
||
# 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 `0–100`, month `1–12`) → `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 **error** — `npm 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-only** — `prisma/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 generate` — **MANDATORY**.
|
||
`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:
|
||
|
||
```bash
|
||
# 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.
|