- Remove ProjectCreate page, POST /projects endpoint, and next-number endpoint - Projects can only be created through orders (shared numbering sequence) - Remove dead CreateProjectSchema and createProject service function - Delete 'order' row from number_sequences (unused; code uses 'shared') - Smart sequence release: decrement last_number only when deleting the highest number - Fix received-invoices stats referencing non-existent is_deleted and amount_czk columns - Update deploy instructions in CLAUDE.md (npm install, prisma migrate deploy) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
12 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 # Apply migrations (dev)
npx prisma migrate deploy # Apply migrations (prod)
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
Do not start the dev server. The user manages it separately.
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:
// 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.
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.
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. -
Silent error catches — A few service functions swallow errors in catch blocks. Always log at minimum; never use empty catch blocks.
-
HTML sanitization gap — Rich text fields in invoices use DOMPurify, but quotation scope and order scope fields may not. If modifying those, add sanitization.
-
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, 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.
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:
Do not push directly to production or restart services without confirmation.