Files
API/CLAUDE.md
T
Paddy e7d76f40de Add a per-event "tickets mailed" flag and mention Abendkasse pickup in the confirmation email
New event_ticket_settings.tickets_mailed column (default false, since
no event mails physical tickets today). When false, the redemption
confirmation email tells the guest their tickets await pickup at the
Abendkasse under their name instead. Read directly by
sendRedemptionConfirmation from event_ticket_settings, so both send
paths (redeem flow, admin resend) pick it up without either caller
changing.

Also fixes docker/init/03-tickets-schema.sql, found missing the
SOURCE line for migration 003 while adding 004 - local tickets dev
databases have been silently missing confirmation_email_status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:46:56 +02:00

8.9 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands

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:

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
Services …/events/events.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: One, since the calendar migration completed.

Admin module (src/models/admin/) — used by every app: calendar, feedback, tickets and the admin app itself. 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. A permission is (app, role) in user_app_permissions, keyed on (user_id, app, role) so one user can hold several roles per app. access is the only role today and means "may use this app at all"; APP_ROLES in admin.schema.ts is the contract, and a role not listed there is rejected rather than written. requireAppAccess(app) in admin.middleware.ts is the single authenticator - it takes an optional second argument to narrow to one role, and queries the database on every request (no cookie cache) so disabling a user takes effect at once. Two things to know before touching this: any count of admins must count distinct users, not permission rows, or a single admin with two roles reads as two and the last-admin guard stops guarding; and both write endpoints accept {permissions: [{app, role}]} as well as the older {apps: ['tickets']}, which means the same at the access role. ADMIN_BOOTSTRAP_EMAIL makes sure someone can always get in on a fresh database.

The calendar used to be the exception - its own users/sessions tables, and a session token passed in query parameters. That is gone: docs/calendar-auth-migration.md records the migration, finished 2026-09-06. Writes sit behind requireAppAccess('calendar'); reads resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal subscription and a signed-in editor.

Two calendar-specific things survive that migration and are easy to break:

  • The public calendar answers with no credential of any kind. nachklang.art reads it to show the next upcoming event. Pinned by test/calendar/credentials.service.test.ts and test/calendar/events.router.test.ts.
  • The shared passwords (MEMBER_CREDENTIAL, CHOIR_CREDENTIAL, MANAGEMENT_CREDENTIAL, from .env) still open the restricted calendars for reading, because an iCal client cannot send a cookie. They can never write.

An event's creator is a display name and nothing else - nothing authorises on it. It resolves from the admin module's user.name when the row carries an admin id, and otherwise from created_by_name, a snapshot taken before the legacy users table was renamed aside.

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.

docker-compose.dev.yml builds a fresh local dev database from docker/init/, not from sql/<domain>/ directly - the two domains use different mechanisms and both need to be kept in sync by hand whenever a migration is added: docker/init/03-tickets-schema.sql and 02-feedback-schema.sql are thin files that SOURCE every sql/<domain>/NNN_*.sql in order (add the new migration's SOURCE line there too); 01-calendar-schema-dev.sql and 04-admin-schema.sql instead fold each migration's effect directly into one reconstructed CREATE-TABLE schema (own header comment: "keep the two in step") - no SOURCE list to extend, edit the reconstructed schema itself. Found 03-tickets-schema.sql missing the SOURCE line for 003_add_confirmation_email_status.sql while adding 004 - every tickets dev DB spun up since that migration was added has been silently missing the column (mail sends still succeed, recordConfirmationEmailResult's UPDATE just fails and logs). Fixed.

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.