e7d76f40de
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>
168 lines
8.9 KiB
Markdown
168 lines
8.9 KiB
Markdown
# 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` |
|
|
| 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.
|