Mobile work-plan (Plán prací) responsive fixes — desktop unchanged: - Toolbar stacks into clean full-width rows on ≤640px (nav group, centered range label, full-width segmented view toggle, full-width category button); drops the fixed 230px label min-width and the margin-left:auto that made the toolbar ragged on phones. - Grid scroll container: 100dvh-based max-height, momentum scroll, and overscroll containment so a fling does not bounce the whole page. - ≤480px tightens columns (date col 72px via the colgroup <col>, person cols 132px) so ~2 people are comfortably visible at 360–390px. - Touch (@media hover:none): faint persistent + on empty editable cells, since the hover-reveal add hint never fires on a phone. Dev: `npm run dev` (backend) now listens on 3050 instead of 3000 to avoid colliding with other local apps; CLAUDE.md dev-port note updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
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 6.19.2 → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 |
| Frontend | React 18.3.1 + Vite 8.0.0 |
| Testing | Vitest 4.1.0 + Supertest |
| Puppeteer 24.x | |
| nodemailer 8.x | |
| Cron | node-cron 4.x |
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 frontend (57 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── contexts/ # AuthContext, AlertContext
│ ├── components/ # Layout, modals, tables, editors
│ ├── pages/ # One file per page/feature
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
└── __tests__/ # Vitest tests (auth, numbering)
prisma/
├── schema.prisma # 32 models, MySQL, snake_case columns
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
dist-client/ # Built frontend (Vite, ES2020)
Commands
# Development
npm run dev # Starts server in watch mode (manage frontend separately)
npm run dev:server # tsx watch src/server.ts
npm run dev:client # Vite dev server
# Build
npm run build # Build server + client
npm run build:server # tsc -p tsconfig.server.json → dist/
npm run build:client # vite build → dist-client/
# Run (production)
npm start # node dist/server.js
# Tests
npm test # vitest run (single pass)
npm run test:watch # vitest watch
# Database
npx prisma migrate dev # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name> # Named migration
npx prisma migrate deploy # Apply pending migrations to production
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
npx prisma db seed # (Re)seed the dev database
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
Do not start the dev server. The user manages it separately.
Before running prisma migrate dev or prisma db push, ask the user to stop their dev server and wait for confirmation. Migrations can conflict with an active database connection from the running server.
Environment Variables
Required:
DATABASE_URL=mysql://user:pass@host:3306/dbname
JWT_SECRET=<64-char hex string>
TOTP_ENCRYPTION_KEY=<64-char hex string>
Optional (with defaults):
PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
HOST=127.0.0.1
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
MAX_UPLOAD_SIZE=52428800 # 50MB
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
LEAVE_NOTIFY_EMAIL=
APP_URL= # Used in email links
CORS_ORIGINS= # Comma-separated, production only
Use .env for dev, .env.test for tests.
Architecture & Key Patterns
Request Flow
Request → CORS → Cookie → Rate-limit → Security headers
→ requirePermission() or requireAuth()
→ Zod schema validation (parseBody helper)
→ Route handler
→ Service function
→ Prisma
→ success(reply, data) or error(reply, message, status)
Response Format
All responses use this shape:
// Success
{ success: true, data: T, message?: string, pagination?: {...} }
// Error
{ success: false, error: string }
Use the success() and error() helpers in routes — never write raw reply.send().
Service Pattern
Services are plain exported async functions, no classes:
// src/services/foo.service.ts
export async function getFoo(id: number) {
const result = await prisma.foo.findUnique({ where: { id } });
if (!result) return { error: "Not found", status: 404 };
return { data: result };
}
// src/routes/admin/foo.ts
const result = await getFoo(id);
if ("error" in result) return error(reply, result.error, result.status ?? 400);
return success(reply, result.data);
Error Handling
- Routes map service errors to HTTP responses using the pattern above.
- Global error handler in
server.tscatches all unhandled exceptions; returns 500 with Czech message. - Never silently swallow errors. Even if a failure is non-fatal, log it:
app.log.error(e, 'context'). - Error messages are in Czech (this is intentional — user-facing messages, Czech company).
Permissions
// Route-level guard
fastify.addHook("preHandler", requirePermission("invoices.view"));
// or multiple
fastify.addHook(
"preHandler",
requirePermission("invoices.view", "invoices.edit"),
);
// Admin role bypasses all permission checks
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
Audit Logging
Call logAudit() from src/utils/audit.ts whenever data is created/updated/deleted.
Pass oldData and newData so the diff is stored. Audit failures are non-fatal.
Validation
Use Zod schemas from src/schemas/. All route bodies must be validated:
const body = parseBody(FooSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
Date & Timezone Handling (Critical Gotcha)
src/config/env.ts sets process.env.TZ = 'Europe/Prague' and overrides
Date.prototype.toJSON() to return local time (not UTC). This means:
JSON.stringify(new Date())returns local Czech time, not UTC.- All API responses with Date fields will contain local time strings.
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
- Never assume UTC when working with Date objects in this codebase.
- When writing new date comparisons or DB queries, use
new Date()(already local) — do not manually offset. - The override exists for PHP migration compatibility and Czech date display.
TOTP / 2FA
- Secret stored AES-256-GCM encrypted in
users.totp_secret. - Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
- Backup codes stored as encrypted JSON array in
users.totp_backup_codes. - When
company_settings.require_2fa = true, all users must enroll before accessing the app. - Login flow: password → if 2FA enabled → issue
loginToken(5 min, single-use) → TOTP verify → issue access + refresh tokens.
Testing
Tests live in src/__tests__/. They use Vitest + Supertest against a real test database (.env.test).
- Test coverage is minimal: only
authandnumberingare tested. - Use
buildApp()helper to spin up the Fastify instance for tests. - Tests use
vitest.config.tswithenvironment: 'node'and 15s timeout. - 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()inAdminApp.tsx. - Auth state lives in
AuthContext; useuseAuth()hook to access it. - Alerts/toasts use
AlertContext; useuseAlert()to show them. - API calls go through
src/admin/utils/api.tswhich handles token refresh automatically (deduplicates concurrent refresh calls). - Custom hooks:
useApiCall,useListData,useTableSort,useDebounce,useModalLock. - Styling: CSS files in
src/admin/— no CSS-in-JS, no Tailwind. Use CSS variables.
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)
Database Conventions
- All models use
snake_casecolumn names; Prisma maps to camelCase in TypeScript. - Soft-delete via
is_deletedboolean (not all tables, check schema). - Timestamps:
created_at,updated_at(auto-managed by Prisma). - Number sequences (
number_sequencestable) manage invoice/quotation numbering — never hardcode numbering logic. - All significant tables have audit log entries. Check
audit_logsmodel 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 seedas the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
If you need to insert/update/seed data on production, write a migration. The migration's migration.sql is a normal SQL file — INSERT INTO ..., UPDATE ..., DELETE ... are all valid. Prisma will run it via prisma migrate deploy like any other migration.
Making schema changes
1. Edit prisma/schema.prisma
2. npx prisma migrate dev --name descriptive_name
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
3. npx prisma generate
4. Commit BOTH schema.prisma AND the new migration folder
git add prisma/schema.prisma prisma/migrations/
Verifying before production deploy
# Preview what SQL will run on production (no changes applied)
npx prisma migrate diff \
--from-url "mysql://user:pass@prod:3306/app" \
--to-schema-datamodel prisma/schema.prisma \
--script
# Empty output = no diff. If it shows SQL, review it before deploying.
Deploying migrations to production
The release process runs prisma migrate deploy on production.
This applies only pending migrations — safe, idempotent.
If migrations get out of sync (drift)
# Diff production DB against local schema to find drift
npx prisma migrate diff \
--from-url "mysql://prod" \
--to-schema-datamodel prisma/schema.prisma \
--script
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
# If drift includes DROPs: investigate before touching production
Baselinining a database that has no migrations
If production was synced with db push and has no _prisma_migrations table:
# 1. Create initial migration locally
npx prisma migrate dev --name init
# 2. Copy to production and mark as applied (no SQL runs)
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
ssh user@prod
cd /var/www/app-ts
npx prisma migrate resolve --applied <migration_name>
Conventions (enforced — verified by the 2026-06-06 audit)
These are the unified rules across the codebase. Follow them for new code; the
audit found and fixed the deviations. Full report: docs/codebase-audit-2026-06-06.md.
Routes
- Responses: always
success(reply, data[, status, message])/error(reply, msg, status)/ the paginated helper. Never rawreply.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)thenif (id === null) return;. Do not use rawparseInt(it yieldsNaN, 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 isauthData.roleName === "admin". ⚠️AuthDatahasroleName, notrole(roleonly exists onJwtPayload). Don't typeauthDataasany— it hides exactly this bug. - Audit: call
logAuditon every create/update/delete (and on security-relevant actions like session/token termination) witholdValues/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 thez.union([z.number(), z.string()]).transform(Number)idiom (it's duplicated ~150× today; consolidating is a tracked follow-up). New number coercions should guard againstNaN. - 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 insrc/admin/lib/entityTypeLabels.tsand MUST be keyed to the server'sEntityTypevalues (add the new key there when you add an entity type). Plan categories come from the DB vialib/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.Dateat local noon (new Date(y, m, d, 12, 0, 0)). - Frontend "today" / date-string round-trips: use
utils/date.tslocalDateStr(server) /normalizeDateStr(client). Never usenew Date().toISOString().split("T")[0]for "today" — it's the UTC date and is a day early during the late-evening Prague window.
Known Issues & Gotchas
-
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. -
CJS/ESM mismatch in tests — Server compiles to CommonJS (
tsconfig.server.json), but Vitest runs in ESM by default. Thevitest.config.tsresolves this, but be careful when adding dependencies that only support ESM. -
Mixed error patterns — Some services return
{ error, status }, others return discriminated unions{ type: 'success' | 'error' }. Prefer{ error, status }for consistency with existing routes. -
Never swallow errors — log at minimum; never use empty
catchblocks. The service layer logs viaconsole.*(there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: acatchthat handles an expected condition (e.g.ENOENTused as an existence check) may stay silent only with a comment saying so. -
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
cleanQuillHtmlregex pass at all three PDF routes, and the frontend sanitizes before everydangerouslySetInnerHTML(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. -
Puppeteer PDF generation — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
-
NAS_PATH file access — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using
NAS_PATHwill fail gracefully (or not) if the path is unavailable. -
Prisma client regeneration — After any schema change and migration, run
npx prisma generate. The generated client is not committed to git. -
No CSRF tokens — CSRF protection relies on
SameSite=Strictcookies + CORS. Do not weaken CORS configuration. -
Czech locale hardcoded — Error messages, month names, and some business logic strings are Czech. This is intentional.
-
Seed file is dev-only —
prisma/seed.tsis 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'smigration.sql, not in the seed file.npx prisma db seedmust never be run against production.
Release Process
- Bump version in
package.json npm run build- Commit and tag (
git tag -a vX.Y.Z) - Push to Gitea (
git push origin master && git push origin vX.Y.Z) - Create tarball:
tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts - 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 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 - Apply Prisma migrations:
npx prisma migrate deploy - Restart:
pm2 restart app-ts --update-env
- Path:
Hotfixing a migration that was added after the tarball was built
If you write a new prisma/migrations/<timestamp>_* folder after the
release tarball has already been built and shipped, that migration is
NOT in the tarball and prisma migrate deploy on prod will report
"No pending migrations". The release tarball re-build re-runs the whole
release, but for a one-off hotfix you can ship just the new migration
folder:
# Local
cd prisma/migrations
tar -czf /tmp/warehouse_perms_migration.tar.gz <timestamp>_<name>/
scp /tmp/warehouse_perms_migration.tar.gz boha_admin@192.168.50.100:/tmp/
# On prod
ssh boha_admin@192.168.50.100
cd /var/www/app-ts/prisma/migrations
sudo -u boha_admin tar -xzf /tmp/warehouse_perms_migration.tar.gz
cd /var/www/app-ts
npx prisma migrate deploy
pm2 restart app-ts --update-env
The migration is still tracked in git and applied via prisma migrate deploy — the only thing the tarball is doing is delivering the
migration.sql to prod, which is the same mechanism the main release
uses. This is preferred over running raw SQL on prod (which the policy
in "Database Migrations" forbids).
Do not push directly to production or restart services without confirmation.