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>
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:
src/app.factory.tsmountsCalendar.router.tsat/calendarCalendar.router.tsdelegates toevents.router.tsandusers.router.ts- 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
publiccalendar answers with no credential of any kind. nachklang.art reads it to show the next upcoming event. Pinned bytest/calendar/credentials.service.test.tsandtest/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
.jssuffix (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"), neverrequire(). - Tests run with vitest directly against
.tssources; importdescribe/it/expect/vifromvitestexplicitly (no globals). Module mocks usevi.mock(...)with the same.js-suffixed paths as the imports.