From 5c9ebddf5076cc3ee778253fae7f095dd45c58aa Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 10 Jun 2026 18:07:39 +0200 Subject: [PATCH] docs: consolidate CLAUDE.md (merge audit sections, dedupe, fix stale facts); runbooks to docs/release.md 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 --- CLAUDE.md | 761 +++++++++++++++++++----------------------------- docs/release.md | 96 ++++++ package.json | 1 - 3 files changed, 402 insertions(+), 456 deletions(-) create mode 100644 docs/release.md diff --git a/CLAUDE.md b/CLAUDE.md index 0db0a3e..b3a8e25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,25 +1,26 @@ # 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. +Handles attendance, invoicing, offers/orders, leave/trips, projects, warehouse, 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) | +| 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.) --- @@ -27,34 +28,28 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation ``` src/ -├── server.ts # Fastify server entry point — plugins, routes, error handler +├── server.ts # Fastify entry — 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) +├── 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 # MUI theme — light/dark color schemes, tokens, component defaults -│ ├── GlobalStyles.tsx # App-wide global styles via MUI (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) +│ ├── 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/ # 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, …) +│ ├── 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 # 52 models, MySQL, snake_case columns -└── migrations/ # Applied migrations - -dist/ # Compiled server (CommonJS, ES2022) -dist-client/ # Built frontend (Vite, ES2020) +prisma/ # schema.prisma (snake_case columns) + migrations/ +dist/ dist-client/ # Compiled server (CommonJS) / built frontend (Vite) ``` --- @@ -62,508 +57,364 @@ 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 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 . - -# Database -npx prisma migrate dev # Create migration from schema changes + apply to dev -npx prisma migrate dev --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 --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy -npx prisma migrate resolve --applied # Mark migration as applied without running SQL +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 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. +**Before any migration, ask the user to stop their dev server and wait for +confirmation** (the running server holds DB connections). --- ## Environment Variables -Required: +Required: `DATABASE_URL`, `JWT_SECRET`, `TOTP_ENCRYPTION_KEY` (64-char hex). -``` -DATABASE_URL=mysql://user:pass@host:3306/dbname -JWT_SECRET=<64-char hex string> -TOTP_ENCRYPTION_KEY=<64-char hex string> -``` +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). -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. +`.env` for dev, `.env.test` for tests (both gitignored — **never edit env +files; the user manages them**). --- ## Architecture & Key Patterns -### Request Flow +### 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) + → requirePermission() / requireAnyPermission() + → parseBody(Schema) / parseId(param) + → Route handler → Service function → Prisma + → success(reply, data) | error(reply, czechMessage, status) ``` -### Response Format +### Response format -All responses use this shape: +`{ success: true, data, message?, pagination? }` on success, +`{ success: false, error }` on failure. Always the `success()`/`error()`/ +paginated helpers — never raw `reply.send()`. -```typescript -// Success -{ success: true, data: T, message?: string, pagination?: {...} } +### Services -// 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). +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 -```typescript -// Route-level guard -fastify.addHook("preHandler", requirePermission("invoices.view")); -// or multiple -fastify.addHook( - "preHandler", - requirePermission("invoices.view", "invoices.edit"), -); +`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. -// Admin role bypasses all permission checks -// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete") -``` +### Audit -### 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); -``` +`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. --- -## Date & Timezone Handling (Critical Gotcha) +## Dates & Timezones (Critical — three rules) -`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides -`Date.prototype.toJSON()` to return local time (not UTC). This means: +1. **Global override:** `src/config/env.ts` sets `TZ=Europe/Prague` and + monkey-patches `Date.prototype.toJSON()` to emit LOCAL time (PHP-migration + parity). All API date strings are local. Never assume UTC; never manually + offset. Do not remove the override. +2. **`@db.Date` columns — Prisma truncates filter Dates to the UTC date + part.** A local-midnight boundary (`new Date(y, m, d)` = 22:00/23:00 UTC of + the _previous_ day) silently shifts the queried window a day back — this was + a 14-site production bug class. Build `@db.Date` filter boundaries and + "today" writes as UTC-midnight instants of the LOCAL calendar day: + `new Date(Date.UTC(y, m, d))` or `utcMidnightOfLocalDay()` from + `src/utils/date.ts`; use half-open `gte`/`lt` ranges; never write a bare + `new Date()` into a `@db.Date` column (stores yesterday between 00:00–02:00 + Prague). +3. **Two deliberate write regimes — don't "fix" either:** the plan module + does date-only math in UTC; attendance/leave writes `@db.Date` at **local + noon** (`new Date(y, m, d, 12, 0, 0)`). Frontend "today" comes from + `localDateStr` (server) / `normalizeDateStr`/`todayLocalStr` (client) — + **never** `new Date().toISOString().split("T")[0]` (UTC date, a day early + in the Prague evening). -- `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. +--- + +## 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 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. +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 -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= npx prisma migrate deploy` → `DATABASE_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 `.test.ts`. +- 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__/.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. +- 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 + `` 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. -### Query Invalidation Convention +### Styling (MUI v7 — no custom .css, no Tailwind) -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) +- 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 ``); dialogs lock `` 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). --- -## Frontend — Styling & MUI (migration complete, shipped in v2.0.0) +## AI Assistant — "Odin" -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 ``. Filters go in `` with **bare** controls (no `` 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 `` 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 `` via `useDialogScrollLock` (MUI only locks ``, and `html{overflow-x:hidden}` makes `` 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`). +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/`. --- -## AI Assistant — "Odin" (shipped v2.1.5) +## Database & Migrations -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. +Conventions: snake_case columns; `created_at`/`updated_at` timestamps; +soft-delete via `is_deleted` where present; `number_sequences` owns all +document numbering. -- **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/`. +**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. -## 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 +**Making a schema change (this shell is non-interactive — `prisma migrate +dev` will refuse to run; use this recipe):** ```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. +# 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/_ +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= npx prisma migrate deploy +# 5. Commit schema.prisma AND the migration folder together. ``` -### 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 -``` +Deployment, drift checks, hotfix-migration shipping and baselining: +see **`docs/release.md`**. --- -## 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`. +## Enforced Conventions (single source — merged 2026-06 audits) **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. +- `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)** -- 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. +- `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. -**Frontend** +**Determinism & concurrency** -- **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. +- 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. -**Dates (two deliberate regimes — don't "fix" either)** +**Errors** -- **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 ``/`` 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. +- 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 Issues & Gotchas +## Known 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. +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 +## Release -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/_*` 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 _/ -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. +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.** diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..321eb7b --- /dev/null +++ b/docs/release.md @@ -0,0 +1,96 @@ +# Release & deployment runbook — boha-app-ts + +Referenced from CLAUDE.md. **Never deploy to production or restart services +without explicit user confirmation.** + +## Standard release + +1. Run the full gates locally: `npx vitest run` (all green), `npx tsc -b --noEmit`, + `npm run lint` (0 errors). +2. Bump version: `npm version X.Y.Z --no-git-tag-version` (the user picks the number). +3. `npm run build` +4. Commit and tag: `git tag -a vX.Y.Z` +5. Push to Gitea: `git push origin master && git push origin vX.Y.Z` +6. Create tarball: + + ```bash + 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. + +7. Deploy via SSH to production (`boha_admin@192.168.50.100`, path `/var/www/app-ts`): + + ```bash + scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/ + ssh boha_admin@192.168.50.100 + cd /var/www/app-ts + rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json + tar -xzf /tmp/app-ts-X.Y.Z.tar.gz + npm install --omit=dev + npx prisma generate # MANDATORY — see below + npx prisma migrate deploy + pm2 restart app-ts --update-env + ``` + + **`npx prisma generate` is mandatory.** `npm install` skips client + regeneration when dependencies didn't change, leaving a stale client that + still selects dropped/renamed columns → P2022 500s in prod (this caused + the v2.4.0 incident: every issued-orders query failed until generate ran). + +8. Verify: `pm2 list` (status online, correct version, restart counter not + climbing), `npx prisma migrate status` ("up to date"), + `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/` → 200, + and grep logs for errors from the CURRENT pid only (the out log keeps old + errors from prior pids). +9. Clean up tarballs locally and in `/tmp/` on the server. + +Risky releases (framework jumps, FK/constraint-altering migrations) get a +read-only pre-flight first: `prisma migrate status` on prod, verify FK +constraint names the migration DROPs, check no data violates new +UNIQUE/RESTRICT constraints — and confirm with the user before the +irreversible steps. The prod DB is backed up by a cron job (no manual +mysqldump needed). + +## Hotfixing a migration added after the tarball was built + +A migration folder created after the release tarball shipped is not on prod, +so `prisma migrate deploy` reports "No pending migrations". Ship just the +migration folder (still tracked in git — this is NOT raw SQL on prod): + +```bash +# Local +cd prisma/migrations +tar -czf /tmp/_migration.tar.gz _/ +scp /tmp/_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 +tar -xzf /tmp/_migration.tar.gz +cd /var/www/app-ts +npx prisma migrate deploy +pm2 restart app-ts --update-env +``` + +## Drift check / preview before deploy + +```bash +# Preview what SQL would bring the dev DB (per prisma.config.ts) to the local schema +npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script +``` + +Empty output = no diff. (Prisma 7 removed `--from-url`; diffs against another +DB need its URL in a config/env override.) If drift includes DROPs, +investigate before touching production. + +## Baselining a database that has no migrations + +If a database was synced without migration history (no `_prisma_migrations` +table): create the initial migration locally, copy `prisma/migrations` to the +server, then mark it applied without running SQL: + +```bash +npx prisma migrate resolve --applied +``` diff --git a/package.json b/package.json index bc9aa3c..eb0babc 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "preview": "vite preview", "db:generate": "prisma generate", "db:pull": "prisma db pull", - "db:push": "prisma db push", "db:studio": "prisma studio", "test": "vitest run", "test:watch": "vitest",