# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands ```bash npm run build # Compile TypeScript → dist/ npm run start # Build and start (tsc && node ./dist/app.js) npm run debug # Start with DEBUG=* environment variable npm run test # Run the vitest suite once with coverage (lcov + testResults/sonar-report.xml) npm run test:watch # vitest in watch mode npm run test:integration # Admin-module tests against a throwaway MariaDB (needs docker or podman) ``` Run a single test file: ```bash npx vitest run test/some.test.ts ``` ## Architecture Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users), `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration), `Tickets` (mounted at `/tickets`), and `Admin` (identity and permissions, mounted at `/admin`, backed by `ADMIN_DB` — see below). `src/app.factory.ts` builds the Express app; `app.ts` only starts it. The split exists so the integration tests drive the real wiring. **Request path:** 1. `src/app.factory.ts` mounts `Calendar.router.ts` at `/calendar` 2. `Calendar.router.ts` delegates to `events.router.ts` and `users.router.ts` 3. Routers call services; services call the MariaDB pool in `Calendar.db.ts` **Key layers:** | Layer | Location | |---|---| | Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` | | Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` | | DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) | | Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) | **Auth model:** Two of them, on purpose. *Admin module (`src/models/admin/`)* — the current one, used by feedback, tickets and the admin app. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to `.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users. Permissions are per app in `user_app_permissions`; `requireAppAccess(app)` in `admin.middleware.ts` is the single authenticator, and it queries the database on every request (no cookie cache) so disabling a user takes effect at once. `ADMIN_BOOTSTRAP_EMAIL` makes sure someone can always get in on a fresh database. *Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation get a session token (30-day window, hash + IP stored in the DB), passed as query parameters. Migration is planned but not started: `docs/calendar-auth-migration.md`. Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`) come from `.env`. **Admin database driver:** the admin pool is the **callback-style** `mysql2`, never `mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`; the promise wrapper ignores that callback, so every Kysely query hangs forever with no error. Only the integration tests catch this. **Admin schema changes:** `sql/admin/NNN_*.sql`, hand-maintained and mirrored in `docker/init/`. The better-auth tables must match what the configured version derives from `admin.auth.ts` — on every better-auth upgrade, re-derive them (`getAuthTables` from `better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not use the published `@better-auth/cli`; it lags the library. **Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both. **Calendar types and IDs:** `public` (1), `members` (2), `management` (3), `choir` (4), `birthdays` (5). `credentials.service.ts` enforces which session/credential can read each calendar. **iCal export:** `icalgenerator.service.ts` converts DB events to RFC 5545 format; reachable via `GET /calendar/events/{calendar}/ical`. **API docs:** Swagger UI served at `/docs`, generated from JSDoc annotations in the router files. ## Environment dotenv 16 parses `.env` stricter than the old dotenv 8: an unquoted `#` starts a comment and backslash escapes inside double quotes are expanded. Wrap any value containing `#`, `"`, `\` or surrounding spaces in single quotes (`DB_PASSWORD='abc#def'`), which are taken literally. A truncated password shows up as MariaDB "Access denied ... (using password: YES)". **`NODE_ENV` is load-bearing for the admin module.** Only the explicit values `development` and `test` relax anything; everything else, *including unset*, is treated as production (strict secrets, cross-subdomain cookies, no localhost CORS). That direction is deliberate: a Plesk vhost does not set `NODE_ENV`, and the inverse arrangement would silently degrade the signing key, the cookie domain and the CORS list at once. Local work needs `NODE_ENV=development`. Copy `.env.example` (or create `.env`) with: ``` NODE_ENV= PORT= DB_HOST= DB_USER= DB_PASSWORD= CALENDAR_DB= ADMIN_DB= BETTER_AUTH_SECRET= API_BASE_URL= ADMIN_APP_URL= APP_ORIGINS= PASSKEY_RP_ID= ADMIN_BOOTSTRAP_EMAIL= CLIENT_IP_HEADERS= TRUSTED_PROXY_IPS= ADMIN_LOG_INVITE_LINKS= FEEDBACK_DB= FEEDBACK_IP_SALT= FEEDBACK_RATE_LIMIT_MAX= FEEDBACK_RATE_LIMIT_WINDOW_MIN= SALESFORCE_ENABLED= SALESFORCE_API_URL= SALESFORCE_CLIENT_ID= SALESFORCE_CLIENT_SECRET= EMAIL_HOST= EMAIL_USERNAME= EMAIL_PASSWORD= MEMBER_CREDENTIAL= CHOIR_CREDENTIAL= MANAGEMENT_CREDENTIAL= ``` ## TypeScript / module system The API runs on Node 26 (`engines` in package.json, `.nvmrc`; Plesk runs 26 too) and is native ESM (`"type": "module"`, `module: nodenext`, target ES2024, strict mode, compiled output in `./dist`, inline source maps). Consequences: - Relative imports carry the `.js` suffix (`import {x} from "./x.js"`) even though the source file is `.ts`. - CommonJS dependencies are consumed via default imports (`import mariadb from "mariadb"`, `import cors from "cors"`, `import winston from "winston"`), never `require()`. - Tests run with vitest directly against `.ts` sources; import `describe`/`it`/`expect`/`vi` from `vitest` explicitly (no globals). Module mocks use `vi.mock(...)` with the same `.js`-suffixed paths as the imports.