440 lines
18 KiB
Markdown
440 lines
18 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 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 |
|
|
| PDF | Puppeteer 24.x |
|
|
| Email | 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
|
|
|
|
```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)
|
|
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: 3000)
|
|
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 test database (`.env.test`).
|
|
|
|
- Test coverage is minimal: only `auth` and `numbering` are tested.
|
|
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
|
- Tests use `vitest.config.ts` with `environment: '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()` in `AdminApp.tsx`.
|
|
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
|
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
|
|
- API calls go through `src/admin/utils/api.ts` which 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_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>
|
|
```
|
|
|
|
---
|
|
|
|
## 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. **Silent error catches** — A few service functions swallow errors in catch blocks. Always log at minimum; never use empty catch blocks.
|
|
|
|
5. **HTML sanitization gap** — Rich text fields in invoices use DOMPurify, but quotation scope and order scope fields may not. If modifying those, add sanitization.
|
|
|
|
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 package.json package-lock.json scripts`
|
|
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 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`
|
|
|
|
### 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.
|