39 Commits

Author SHA1 Message Date
Paddy b4c8c91795 Scope step 5 of the calendar migration, and gate it on step 4 being live
Step 5 removes the legacy path, so it removes the fallback step 4 still leans
on: the join that renders the author of every pre-cutover event, and the
routes an old cached bundle talks to. Building it before step 4 has been
deployed and watched turns a recoverable deploy into an unrecoverable one, so
this records the shape rather than implementing it.

Two decisions worth having in the runbook rather than in someone's memory.
The legacy calendar user module gets deleted outright rather than unmounted -
a survey confirmed nothing outside that directory imports it, and it is the
API's last unauthenticated account-creation and mail-sending endpoint. The
users and sessions tables get renamed aside rather than dropped: the display
names are already snapshotted so nothing visible depends on those rows, but
they still hold e-mail addresses and password hashes, and a rename makes them
unreachable without destroying anything.

Also notes the two consequences worth accepting deliberately: activation links
already in inboxes become 404s, and createdById leaves the wire format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:01:44 +02:00
Paddy d960ac8e24 Fold the pre-deploy review findings into the calendar cutover
A fresh-context review before deploying found two things that would have
broken production, both in the runbook rather than the code.

The deploy order named only migration 003. Production has none of the three -
001 and 002 were only ever applied to the dev database - and the new API reads
the columns they add on every request, so following it literally would have
500'd every calendar call including the anonymous feed the public website
uses. Step 4 now carries a numbered checklist with a verification query.

APP_ORIGINS replaces the code's default list rather than adding to it, so
naming calendar.nachklang.art in DEFAULT_APP_ORIGINS is not enough if that
variable is set on the vhost - and its failure mode is the quiet one the
config already warns about, where everything works except sign-out. Added to
the same checklist.

Also from the review:

The two operands of the read guard on /json/next and /ical were swapped so the
password check short-circuits first. They are side-effect free, so the order
was free - but the old one put an admin-database query in front of the public
feed for any caller holding a .nachklang.art cookie, which is a dependency
that feed has never had. Two tests now assert the admin database is not
consulted at all.

/:calendar/json/next had no route-level test, despite being the endpoint the
public website actually calls and the property named as load-bearing. Covered
now, along with the rest of its credential matrix.

Migrations 001 and 002 gained IF NOT EXISTS. They are applied by hand with no
tracking table, so a partial re-run should be a no-op rather than an error
that aborts the rest of the paste. Verified by applying all three twice to a
throwaway container and diffing against the dev schema.

Swagger: two descriptions still claimed authentication was required where the
public calendar needs none, the calendar enum omitted `birthdays`, and a
`createdBy` request-body field was documented and read but never persisted -
misleading in a way that suggests a client can set authorship. Removed. The
CORS comment describing the calendar's query-parameter sessions is no longer
true and was rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:41:49 +02:00
Paddy b848d6eab9 Move the calendar onto the shared session cookie
Step 4 of docs/calendar-auth-migration.md, and the close of
DEFERRED_SECURITY.md item 1: no calendar route reads sessionId/sessionKey from
the query string any more, so a live credential no longer travels through
access logs, browser history and Referer headers.

The four write routes sit behind requireAppAccess('calendar'), which also
narrows who may edit from "any activated @nachklang.art account" to an
explicit per-user permission. They answer 401 signed out and 403 without the
permission, where they previously answered 403 for both.

The three read routes cannot use the middleware: one URL serves an anonymous
visitor, an iCal subscription holding a shared password, and a signed-in
editor who should see drafts. They resolve the session optionally instead, and
a signed-in user without the calendar permission is treated as anonymous
rather than refused - so they keep the public calendar access anyone has.

That public calendar staying anonymous is load-bearing: nachklang.art reads it
to show the next upcoming event. It is now pinned at both the password-table
and the route level, and so is the rule that a shared password can never be
used to write.

credentials.service.ts loses its session half and becomes the password table
it always wanted to be. The shared passwords survive only for iCal clients,
which cannot send a cookie.

Writes record the author as an admin user id and no longer have a legacy int
to write, which is what migration 003 makes room for.

/calendar/users/* is left in place: nothing calls it and a session it mints
opens nothing, but they are still live password-accepting endpoints, so
removing them belongs with the rest of the legacy path in step 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:23:36 +02:00
Paddy 61d3883479 Read calendar event creators from the admin module, and archive the old ones
Steps 1 and 3 of docs/calendar-auth-migration.md. The calendar is the last
module still authenticating against its own users/sessions tables; this is
the groundwork that lets step 4 swap it for the shared admin identity.

An event now records its creator twice: created_by_id, the legacy INT into
the calendar database's own users table, and created_by_user_id, the admin
module's VARCHAR(36) id. The two live in different databases, so there is no
foreign key and no join - a cross-schema reference would tie the schemas'
lifecycles together, and the name is instead resolved through one lookup per
result set against the admin database.

The creator is only ever rendered as a name; nothing authorises on it. That
is what makes the planned account backfill unnecessary - dropped by decision -
and what makes the read degrade rather than fail: an admin id that no longer
resolves falls back, and an unreachable admin database costs a name rather
than the response. The public calendar is read anonymously by nachklang.art
and has never depended on the admin database being up.

Since there is no backfill, step 5 dropping the legacy users table would have
erased the authorship of every pre-cutover event. Migration 002 brings that
part of step 5 forward and snapshots the names onto the events themselves, so
the data is safe well before the table holding it goes away.

The same SELECT and row mapper existed in four copies; collapsed to one of
each first, so the dual read is written once rather than four times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:09:21 +02:00
Paddy 3c892d02ed Put the feedback and tickets admin areas behind the shared identity (#13)
Jenkins Production Deployment
Reviewed-on: #13
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
2026-09-06 19:06:30 +00:00
Paddy bf7be65b03 Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment
Reviewed-on: #12
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
2026-09-06 10:41:54 +00:00
Paddy ce9b173c71 Merge pull request 'Document dotenv 16 quoting rule for .env values' (#11) from feature/api-esm-prep into master
Reviewed-on: #11
2026-09-05 14:13:29 +00:00
Paddy bf7f45acce Document dotenv 16 quoting rule for .env values
A production password containing '#' was truncated after the dotenv 8 -> 16
bump (unquoted '#' now starts a comment), causing MariaDB access denied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:11:50 +02:00
Paddy 10c459db0f Merge pull request 'Migrate the API to native ESM and vitest; pin Node 26' (#10) from feature/api-esm-prep into master
Jenkins Production Deployment
Reviewed-on: #10
2026-09-05 14:05:13 +00:00
Paddy 3ea9e630ed Migrate the API to native ESM and vitest; pin Node 26
Prep PR for the admin auth module (docs/plan-admin-auth.md step 1).
better-auth 1.7 ships ESM only, so the API moves off CommonJS:

- "type": "module", module nodenext, target ES2024, .js suffixes on all
  relative imports, require('mariadb'|'cors') replaced by imports, and
  export= packages (winston, app-root-path, bcrypt) consumed via default
  imports. The logger now uses appRoot.path explicitly.
- TypeScript 5.9, @types/node 26, tslint removed. Node 26 pinned via
  engines and .nvmrc (Plesk runs 26).
- Jest 28 + ts-jest replaced by vitest 5. Eight test files depend on
  hoisted module mocks with static imports and resetModules + require,
  which Jest's ESM mode does not support; vitest keeps them nearly
  verbatim. Coverage via @vitest/coverage-v8 (lcov), Sonar generic report
  via vitest-sonar-reporter, so sonar-project.properties is unchanged.
  vitest.config.ts sets FEEDBACK_IP_SALT so the suite passes without a
  local .env.
- dotenv 8 -> 16 and axios 0.24 -> 1.x: their old typings are not
  resolvable under nodenext.
- autoCommit: false dropped from the pool configs; it is not a mariadb
  connector option and was silently ignored.

tsc clean, 96/96 tests green, compiled app boots and serves /, /docs and
CORS under Node ESM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:02:26 +02:00
Paddy 449edd6c68 Relay transactional email through Salesforce instead of SMTP (#9)
Jenkins Production Deployment
Reviewed-on: #9
Co-authored-by: Patrick Müller <patrick@mueller-patrick.tech>
Co-committed-by: Patrick Müller <patrick@mueller-patrick.tech>
2026-08-31 16:17:59 +00:00
Paddy 3c4f3331d8 Add Tickets domain for the voucher-based ticket shop (#8)
Jenkins Production Deployment
Reviewed-on: #8
Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech>
Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
2026-08-24 21:18:00 +00:00
Paddy b05f6b9da0 Add Feedback domain module: public submission flow, admin CRUD, reporting (#7)
Jenkins Production Deployment
Co-authored-by: Patrick Müller <mail@pmueller.me>
Reviewed-on: #7
Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech>
Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
2026-08-23 09:39:02 +00:00
Paddy e7621b8290 Merge pull request 'Add claude init file + refactor some security issues' (#6) from feature/aiRefactoring20260502 into master
Jenkins Production Deployment
Reviewed-on: #6
2026-06-28 11:25:23 +00:00
Paddy da85d1487c Add claude init file + refactor some security issues 2026-05-02 12:22:03 +02:00
Paddy dc65b49219 Add possibility to add birthdays + repeating events to the API
Jenkins Production Deployment
2025-09-07 18:15:15 +02:00
Paddy 9c45fb11ee Add last modified information to event GET endpoints
Jenkins Production Deployment
2025-05-29 12:51:51 +02:00
Paddy 45dfc22c60 Add endpoint for getting upcoming event and adding swagger docs to all endpoints
Jenkins Production Deployment
2025-04-18 15:05:32 +02:00
Paddy a38fb20e5a Add endpoint that allows to move an event to a different calendar
Jenkins Production Deployment
2024-06-04 11:55:30 +02:00
Paddy cb85e81d67 Add new "choir" calendar and add cascading functionality for calendars
Jenkins Production Deployment
2024-06-04 11:17:28 +02:00
Paddy 59fee19a76 Fix issue with sending mails
Jenkins Production Deployment
2023-12-30 23:11:50 +01:00
Paddy a79e2186a2 Fix activation endpoint HTTP method
Jenkins Production Deployment
2023-12-30 22:55:40 +01:00
Paddy 8f93e1ab7d Add password reset endpoints and mail service for user activation
Jenkins Production Deployment
2023-12-30 22:50:47 +01:00
Paddy 34a4a6664f Fix bug where some fields were not sent back via the api
Jenkins Production Deployment
2023-05-15 20:50:50 +02:00
Paddy 76e6bbdbbf Add event versioning capabilities
Jenkins Production Deployment
2023-05-15 20:28:42 +02:00
Paddy 5e84eaea70 Future-proof admin interface of the api, make the api fully capable of handling event status 2023-05-15 19:40:44 +02:00
Paddy b8a68c2480 Add status column for events
Jenkins Production Deployment
2023-05-14 21:26:55 +02:00
Paddy 95983021ed Rework user interface field API names
Jenkins Production Deployment
2023-05-14 21:09:34 +02:00
Paddy 02f7424b56 Upgrade to proper user management 2023-05-14 19:17:30 +02:00
Paddy 93c70b0e1d #1: Add possibility to create whole-day events
Jenkins Production Deployment
2022-12-28 12:15:01 +01:00
Paddy d85f9a992b #2: Remove empty fields from generated ical
Jenkins Production Deployment
2022-12-28 11:34:25 +01:00
Paddy fc071096d8 Fix URL null error when no url is given in event
Jenkins Production Deployment
2022-12-28 01:20:06 +01:00
Paddy a34a5df5a3 Interface change to return eventId after POST call
Jenkins Production Deployment
2022-12-26 15:58:24 +01:00
Paddy 65a5e91ad1 Making createdBy field required
Jenkins Production Deployment
2022-12-25 21:50:21 +01:00
Paddy 6cb7f0d59b Interface adjustments
Jenkins Production Deployment
2022-12-25 20:53:09 +01:00
Paddy ccfa28877c Adjust privileges mgmt
Jenkins Production Deployment
2022-12-25 18:24:18 +01:00
Paddy a8f7189cb3 git add . is a difficult command to execute
Jenkins Production Deployment
2022-12-25 15:43:37 +01:00
Paddy 83c9d090e1 Add methods to insert, update and delete events
Jenkins Production Deployment
2022-12-25 15:38:13 +01:00
Paddy 0348d89121 Add credentials check and rework request structure 2022-12-25 13:49:38 +01:00
130 changed files with 19771 additions and 6158 deletions
+71
View File
@@ -0,0 +1,71 @@
# Values containing #, ", \ or surrounding spaces must be single-quoted
# (dotenv 16 treats an unquoted # as a comment): DB_PASSWORD='abc#def'
# REQUIRED. The admin module treats anything other than "development" or "test"
# as production: strict secrets, cross-subdomain cookies, no relaxed CORS.
# Leaving it unset is therefore safe-by-default but will refuse to boot without
# the admin secrets below. Set it to development for local work.
NODE_ENV=development
PORT=3000
DB_HOST=
DB_USER=
DB_PASSWORD=
EMAIL_HOST=
EMAIL_USERNAME=
EMAIL_PASSWORD=
CALENDAR_DB=
FEEDBACK_DB=
FEEDBACK_IP_SALT=
FEEDBACK_RATE_LIMIT_MAX=5
FEEDBACK_RATE_LIMIT_WINDOW_MIN=10
SALESFORCE_ENABLED=false
SALESFORCE_API_URL=
SALESFORCE_CLIENT_ID=
SALESFORCE_CLIENT_SECRET=
TICKETS_DB=
TICKETS_RATE_LIMIT_MAX=10
TICKETS_RATE_LIMIT_WINDOW_MIN=10
ADMIN_DB=
# 32+ random bytes, e.g. `openssl rand -base64 48`. Mandatory outside
# development/test - there is deliberately no fallback, since a hardcoded one
# would be a published signing key. Rotating it signs everyone out and
# invalidates outstanding password-reset links.
BETTER_AUTH_SECRET=
API_BASE_URL=http://localhost:3000
ADMIN_APP_URL=http://localhost:3002
# Comma-separated origins of the apps that may call /admin/* with credentials.
APP_ORIGINS=http://localhost:3001
# nachklang.art in production; passkeys are bound to this value.
PASSKEY_RP_ID=localhost
# On start-up, makes sure this address can get in (invite, or grant admin if the
# user already exists). Idempotent, safe to leave set.
ADMIN_BOOTSTRAP_EMAIL=
# The header the reverse proxy puts the real client IP in, and the proxy hops to
# trust. Get these right or better-auth cannot resolve a client IP and every
# request shares ONE rate-limit bucket (/sign-in/* allows 3 per 10 seconds, so
# one noisy client locks everyone out). Check with:
# SELECT `key` FROM rateLimit; -- a "no-trusted-ip" row means it is happening.
# The header the reverse proxy puts the real client IP in. Must be one the proxy
# actually overwrites - trusting a header it does not set lets any client send its
# own value and bypass the sign-in rate limit entirely.
# Set to "none" to trust no header at all: every request then shares one rate-limit
# bucket, which is the safe fallback if the check below fails. Verify after deploy
# with: SELECT ipAddress FROM session ORDER BY createdAt DESC LIMIT 3;
CLIENT_IP_HEADERS=x-real-ip
TRUSTED_PROXY_IPS=
# Writes invitation links to the log. That link is a live account-creation
# credential, so this is refused outside development. Needed locally, where the
# mail relay is off and only the token's hash is stored.
ADMIN_LOG_INVITE_LINKS=true
MEMBER_CREDENTIAL=123
CHOIR_CREDENTIAL=123
MANAGEMENT_CREDENTIAL=123
+1
View File
@@ -0,0 +1 @@
26
+141
View File
@@ -0,0 +1,141 @@
# 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.
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.
*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.
+79
View File
@@ -0,0 +1,79 @@
# Deferred Security Issues
These items were identified during a security review on 2026-05-02 and consciously deferred.
**Must be addressed before opening the application to a larger or public userbase.**
---
## 1. Session credentials in URL query parameters (logged-in users) — CLOSED 2026-09-06
**Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers
`sessionId` and `sessionKey` were read from query parameters, which meant they appeared in
server access logs, browser history, proxy logs, and `Referer` headers.
**Fixed** by step 4 of `docs/calendar-auth-migration.md`: the calendar's write routes now sit
behind `requireAppAccess('calendar')` against the better-auth session cookie, and the read
routes resolve the same cookie optionally. No route reads `sessionId`/`sessionKey` any more,
and the Angular frontend sends `withCredentials` instead of appending them to every URL. That
closed the item outright rather than moving the credential somewhere safer.
Two things this did *not* change, both deliberate:
- The shared calendar `password` parameter stays. An iCal client cannot send a cookie, so
this is the one caller that genuinely needs a credential in the URL. It grants read access
to one calendar and nothing else - `test/calendar/events.router.test.ts` pins that it can
never be used to write.
- The legacy `/calendar/users/*` routes still exist. Nothing calls them any more, and a
legacy session they mint no longer opens anything, but they are still live
password-accepting endpoints. Step 5 removes them.
---
## 2. No event ownership check
**Files:** `src/models/calendar/events/events.router.ts`
- `PUT /:eventId` (update)
- `PUT /move/:eventId` (move)
- `DELETE /:eventId` (delete)
Currently any account holding the `calendar` permission can edit, move, or delete any event regardless of who created it. This is acceptable while everyone holding it is a trusted admin.
**Fix (updated 2026-09-06):** fetch the event first and verify `event.createdByUserId === res.locals.admin.id` before allowing the mutation — `createdById`, the legacy INT, is no longer written and is gone at step 5. Rather than an `isAdmin` flag, the bypass belongs in the permission model that already exists: `requireAppAccess('calendar', 'manage')` alongside the current `access` role, which needs a row in `APP_ROLES` on both sides and nothing else.
---
## 3. Activation token has no expiry
> **Superseded for new accounts (2026-09-05).** The admin module
> (`src/models/admin/`) replaced account creation for the feedback, tickets and admin
> apps: accounts now come from `invitations`, whose tokens expire after 7 days and are
> stored only as a SHA-256 hash. The item below still stands for the legacy calendar
> `users` table, which the admin module deliberately left alone - see
> `docs/calendar-auth-migration.md`.
**File:** `src/models/calendar/users/users.service.ts``createUser` / `activateUser`
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
**Fix:**
1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`.
3. Check `activation_expires > NOW()` in `activateUser` before accepting the token.
---
## 4. Password reset token has no expiry
> **Superseded for new accounts (2026-09-05).** Password resets for admin-module accounts
> go through better-auth, whose reset tokens expire after one hour. As with item 3, the
> text below still applies to the legacy calendar `users` table.
**File:** `src/models/calendar/users/users.service.ts``initiatePasswordReset` / `finalizePasswordReset`
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
**Fix:**
1. Add a `pw_reset_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 15 MINUTE` in `initiatePasswordReset`.
3. Check `pw_reset_expires > NOW()` in `finalizePasswordReset` before accepting the token.
+6 -75
View File
@@ -1,15 +1,8 @@
import express from 'express';
import * as http from 'http'; import * as http from 'http';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import swaggerUi from 'swagger-ui-express'; import logger from './src/middleware/logger.js';
import swaggerJSDoc from 'swagger-jsdoc'; import {createApp} from './src/app.factory.js';
import logger from './src/middleware/logger'; import {bootstrapAdmin} from './src/models/admin/admin.bootstrap.js';
// Router imports
import {calendarRouter} from './src/models/calendar/Calendar.router';
let cors = require('cors');
dotenv.config(); dotenv.config();
@@ -20,73 +13,11 @@ if (!process.env.PORT) {
const port: number = parseInt(process.env.PORT, 10); const port: number = parseInt(process.env.PORT, 10);
const app: express.Application = express(); const app = createApp();
const server: http.Server = http.createServer(app); const server: http.Server = http.createServer(app);
// here we are adding middleware to parse all incoming requests as JSON
app.use(express.json());
// Configure CORS
let allowedHosts = [
'https://www.nachklang.art',
'https://calendar.nachklang.art'
];
app.use(cors({
origin: function (origin: any, callback: any) {
// Allow requests with no origin
if (!origin) return callback(null, true);
// Block requests with wrong origin
if (allowedHosts.indexOf(origin) === -1) {
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
}
// Allow all other requests
return callback(null, true);
}
}));
// Swagger documentation
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'Nachklang e.V. REST API',
version: '0.1.0',
license: {
name: 'Licensed Under MIT',
url: 'https://spdx.org/licenses/MIT.html'
},
contact: {
name: 'Nachklang e.V.',
url: 'https://www.nachklang.art'
}
}
};
const options = {
swaggerDefinition,
// Paths to files containing OpenAPI definitions
apis: [
'./src/models/**/*.router.ts'
]
};
const swaggerSpec = swaggerJSDoc(options);
app.use(
'/docs',
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
// Add routers
app.use('/calendar', calendarRouter);
// this is a simple route to make sure everything is working properly
app.get('/', (req: express.Request, res: express.Response) => {
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
});
server.listen(port, () => { server.listen(port, () => {
logger.info('Server listening on Port ' + port); logger.info('Server listening on Port ' + port);
// Makes sure ADMIN_BOOTSTRAP_EMAIL can always get in. Never throws.
void bootstrapAdmin();
}); });
+28
View File
@@ -0,0 +1,28 @@
# Local dev only — not used in production/deployment. Spins up a MariaDB
# instance with the calendar (reconstructed dev schema, see docker/init's
# disclaimer), feedback, and tickets databases pre-seeded.
#
# Usage:
# docker compose -f docker-compose.dev.yml up -d
#
# Then point .env at:
# DB_HOST=127.0.0.1
# DB_USER=nachklang
# DB_PASSWORD=devpassword
# CALENDAR_DB=nachklang_calendar
# FEEDBACK_DB=nachklang_feedback
# TICKETS_DB=nachklang_tickets
services:
mariadb:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: rootdevpassword
ports:
- "3306:3306"
volumes:
- nachklang_dev_db:/var/lib/mysql
- ./sql:/migrations:ro
- ./docker/init:/docker-entrypoint-initdb.d:ro
volumes:
nachklang_dev_db:
+31
View File
@@ -0,0 +1,31 @@
# Manual alternative for bringing up the admin tests' database by hand.
#
# `npm run test:integration` does NOT use this file: test/integration/setup.ts
# starts the container directly, because `podman compose` needs a separate
# compose provider that neither podman nor docker ships, and one container needs
# no orchestration. Keep the two in step, or delete this file if nobody uses it.
#
# Port 3307 and a throwaway data directory on purpose: it must never collide
# with, or outlive, the dev database from docker-compose.dev.yml.
services:
mariadb-test:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: roottestpassword
MARIADB_DATABASE: nachklang_admin
MARIADB_USER: nachklang
MARIADB_PASSWORD: testpassword
ports:
- "3307:3306"
tmpfs:
- /var/lib/mysql
volumes:
# Applied by the entrypoint on first boot, against MARIADB_DATABASE.
# This is the very migration production runs, so a mistake in it fails
# the test run rather than the deploy.
- ./sql/admin/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 2s
timeout: 5s
retries: 30
+12
View File
@@ -0,0 +1,12 @@
-- Local dev only. Creates the four databases + a dev user with full access.
CREATE DATABASE IF NOT EXISTS nachklang_calendar CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS nachklang_feedback CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS nachklang_tickets CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS nachklang_admin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'nachklang'@'%' IDENTIFIED BY 'devpassword';
GRANT ALL PRIVILEGES ON nachklang_calendar.* TO 'nachklang'@'%';
GRANT ALL PRIVILEGES ON nachklang_feedback.* TO 'nachklang'@'%';
GRANT ALL PRIVILEGES ON nachklang_tickets.* TO 'nachklang'@'%';
GRANT ALL PRIVILEGES ON nachklang_admin.* TO 'nachklang'@'%';
FLUSH PRIVILEGES;
+107
View File
@@ -0,0 +1,107 @@
-- Local dev only. Real schema, provided directly by the repo owner
-- (calendars, events, event_versions, sessions, users) - not a guess.
-- Columns added by this repo's own migrations under sql/calendar/ are folded
-- in here rather than appended, so a fresh dev container matches production
-- after every migration has been applied. Keep the two in step.
USE nachklang_calendar;
CREATE TABLE `calendars` (
`calendar_id` int(11) NOT NULL AUTO_INCREMENT,
`name` text NOT NULL,
`includes_calendars` text DEFAULT NULL,
PRIMARY KEY (`calendar_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `users` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`full_name` text NOT NULL,
`password_hash` text DEFAULT NULL,
`email` text NOT NULL,
`is_active` tinyint(1) DEFAULT 0,
`pw_reset_token_hash` text DEFAULT NULL,
`activation_token` text DEFAULT NULL,
PRIMARY KEY (`user_id`),
UNIQUE KEY `email` (`email`) USING HASH
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `sessions` (
`session_id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`session_key_hash` text DEFAULT NULL,
`created_date` datetime DEFAULT current_timestamp(),
`valid_until` datetime DEFAULT (current_timestamp() + interval 30 day),
`last_ip` text DEFAULT NULL,
PRIMARY KEY (`session_id`),
KEY `sessions_users_user_id_fk` (`user_id`),
CONSTRAINT `sessions_users_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `events` (
`event_id` int(11) NOT NULL AUTO_INCREMENT,
`calendar_id` int(11) NOT NULL,
`uuid` text NOT NULL,
`created_date` datetime DEFAULT current_timestamp(),
-- Nullable since the cutover; see sql/calendar/003_allow_null_legacy_creator.sql.
`created_by_id` int(11) DEFAULT NULL,
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
`created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Archived creator name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
`created_by_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`event_id`),
KEY `events_calendars_calendar_id_fk` (`calendar_id`),
KEY `events_users_user_id_fk` (`created_by_id`),
KEY `events_created_by_user_idx` (`created_by_user_id`),
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`),
CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `event_versions` (
`event_version_id` int(11) NOT NULL AUTO_INCREMENT,
`event_id` int(11) NOT NULL,
`name` text DEFAULT NULL,
`description` text DEFAULT NULL,
`start_datetime` datetime DEFAULT NULL,
`end_datetime` datetime DEFAULT NULL,
`whole_day` tinyint(1) DEFAULT NULL,
`repeat_frequency` text DEFAULT NULL,
`location` text DEFAULT NULL,
`url` text DEFAULT NULL,
`version_created_by_id` int(11) DEFAULT NULL,
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
`version_created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Archived editor name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
`version_created_by_name` varchar(255) DEFAULT NULL,
`status` text DEFAULT NULL,
`version_created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`event_version_id`),
KEY `event_versions_events_event_id_fk` (`event_id`),
KEY `event_versions_users_user_id_fk` (`version_created_by_id`),
KEY `event_versions_created_by_user_idx` (`version_created_by_user_id`),
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
(1, 'public', '[]'),
(2, 'members', '[]'),
(3, 'management', '[]'),
(4, 'choir', '[]'),
(5, 'birthdays', '[]');
-- Dev admin, password: devpassword
INSERT INTO users (email, password_hash, full_name, is_active) VALUES
('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1);
-- Two rows are left on the legacy path and one carries an admin user id, so
-- dev exercises both branches of the step 3 dual-read rather than only the
-- happy one. It is deliberately a PUBLIC event, so the anonymous listing the
-- website uses covers both. The id is the dev admin from 04-admin-schema.sql.
INSERT INTO events (calendar_id, uuid, created_by_id, created_by_user_id, created_by_name) VALUES
(1, UUID(), 1, NULL, 'Dev Admin'),
(1, UUID(), 1, 'dev-user-0000-0000-0000-000000000001', NULL),
(1, UUID(), 1, NULL, 'Dev Admin');
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id, version_created_by_user_id, version_created_by_name) VALUES
(1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', 1, NULL, 'Dev Admin'),
(2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 1, 'dev-user-0000-0000-0000-000000000001', NULL),
(3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', 1, NULL, 'Dev Admin');
+3
View File
@@ -0,0 +1,3 @@
USE nachklang_feedback;
SOURCE /migrations/feedback/001_init.sql;
SOURCE /migrations/feedback/002_add_poster_image_url.sql;
+3
View File
@@ -0,0 +1,3 @@
USE nachklang_tickets;
SOURCE /migrations/tickets/001_init.sql;
SOURCE /migrations/tickets/002_add_require_address.sql;
+170
View File
@@ -0,0 +1,170 @@
-- Local dev only. Mirrors the table definitions in sql/admin/001_init.sql -
-- keep the two in step - and seeds a ready-to-use dev account on top.
USE nachklang_admin;
CREATE TABLE IF NOT EXISTS `user` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`emailVerified` TINYINT(1) NOT NULL DEFAULT 0,
`image` TEXT DEFAULT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Nachklang addition, declared through better-auth's additionalFields so
-- the adapter knows about it. Disabling also revokes the user's sessions.
`disabled` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `user_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `session` (
`id` VARCHAR(36) NOT NULL,
`expiresAt` DATETIME NOT NULL,
`token` VARCHAR(255) NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ipAddress` VARCHAR(255) DEFAULT NULL,
`userAgent` TEXT DEFAULT NULL,
`userId` VARCHAR(36) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `session_token` (`token`),
KEY `session_user` (`userId`),
CONSTRAINT `session_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `account` (
`id` VARCHAR(36) NOT NULL,
-- 1.7 addition: distinguishes a local credential account
-- ("local:credential") from an OAuth issuer. Written by better-auth.
`issuer` VARCHAR(255) NOT NULL,
`accountId` VARCHAR(255) NOT NULL,
`providerId` VARCHAR(255) NOT NULL,
`userId` VARCHAR(36) NOT NULL,
`accessToken` TEXT DEFAULT NULL,
`refreshToken` TEXT DEFAULT NULL,
`idToken` TEXT DEFAULT NULL,
`accessTokenExpiresAt` DATETIME DEFAULT NULL,
`refreshTokenExpiresAt` DATETIME DEFAULT NULL,
`scope` TEXT DEFAULT NULL,
`password` TEXT DEFAULT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `account_user` (`userId`),
KEY `account_provider` (`providerId`, `accountId`),
CONSTRAINT `account_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Password-reset and e-mail-verification tokens.
CREATE TABLE IF NOT EXISTS `verification` (
`id` VARCHAR(36) NOT NULL,
`identifier` VARCHAR(255) NOT NULL,
`value` TEXT NOT NULL,
`expiresAt` DATETIME NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `verification_identifier` (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `passkey` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(255) DEFAULT NULL,
`publicKey` TEXT NOT NULL,
`userId` VARCHAR(36) NOT NULL,
`credentialID` VARCHAR(255) NOT NULL,
`counter` INT NOT NULL DEFAULT 0,
`deviceType` VARCHAR(255) NOT NULL,
`backedUp` TINYINT(1) NOT NULL DEFAULT 0,
`transports` VARCHAR(255) DEFAULT NULL,
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
`aaguid` VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `passkey_user` (`userId`),
KEY `passkey_credential` (`credentialID`),
CONSTRAINT `passkey_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Required by rateLimit.storage = 'database' in admin.auth.ts. Passenger may
-- run several API instances, and an in-memory limiter would give each of them
-- its own budget.
CREATE TABLE IF NOT EXISTS `rateLimit` (
`id` VARCHAR(36) NOT NULL,
`key` VARCHAR(255) NOT NULL,
`count` INT NOT NULL DEFAULT 0,
-- Epoch milliseconds, not a DATETIME: better-auth stores a number here.
`lastRequest` BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `rate_limit_key` (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Nachklang-owned tables
-- ---------------------------------------------------------------------------
-- Which apps a user may administer. `admin` is just another app: holding it is
-- what lets someone manage users and invitations. A permission is (app, role);
-- `access` is the only role today, and the key admits several per app so finer
-- ones can be added by inserting rows rather than by migrating this table.
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
`user_id` VARCHAR(36) NOT NULL,
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
-- One row per (user, app, role). `access` means "may use this app at all"
-- and is the only role today; the key allows several per app so a finer
-- permission can be added later by inserting rows, not by migrating.
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
`granted_by` VARCHAR(36) DEFAULT NULL,
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- (user_id, app) is the leftmost prefix of this key, so the per-request
-- permission lookup needs no separate index.
PRIMARY KEY (`user_id`, `app`, `role`),
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- The only route to a new account: there is no public sign-up. Only the
-- SHA-256 of the token is stored, so a dump of this table hands out no access.
CREATE TABLE IF NOT EXISTS `invitations` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(255) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`permissions` JSON NOT NULL,
`invited_by` VARCHAR(36) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` DATETIME NOT NULL,
`accepted_at` DATETIME DEFAULT NULL,
`revoked_at` DATETIME DEFAULT NULL,
UNIQUE KEY `inv_token_hash` (`token_hash`),
KEY `inv_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Dev seed: dev@nachklang.art / devpassword, with every app permission.
-- Local only - this account exists nowhere but in this container.
--
-- The password hash is better-auth's own scrypt format (salt:hash), produced
-- with better-auth 1.7.2's hashPassword(). Regenerate it if better-auth ever
-- changes that format; a hash it cannot parse shows up as "invalid password"
-- on an otherwise correct sign-in.
--
-- `issuer` must be exactly 'local:credential' - it is how better-auth 1.7
-- recognises a local password account when signing in.
-- ---------------------------------------------------------------------------
INSERT INTO `user` (`id`, `name`, `email`, `emailVerified`, `disabled`)
VALUES ('dev-user-0000-0000-0000-000000000001', 'Dev Admin', 'dev@nachklang.art', 1, 0);
INSERT INTO `account` (`id`, `issuer`, `accountId`, `providerId`, `userId`, `password`)
VALUES (
'dev-acct-0000-0000-0000-000000000001',
'local:credential',
'dev-user-0000-0000-0000-000000000001',
'credential',
'dev-user-0000-0000-0000-000000000001',
'e6a0485feb04b8fa64453db87badd8e1:85aaffd845e1e44fabc5be97d684c0f533845ed11088bd3f4d533ef277ead71da8372b5c6a72c7eae2d48e3270c50e2d13cffa4fcfb0b5cec456100b8f007ed2'
);
INSERT INTO `user_app_permissions` (`user_id`, `app`, `role`) VALUES
('dev-user-0000-0000-0000-000000000001', 'calendar', 'access'),
('dev-user-0000-0000-0000-000000000001', 'feedback', 'access'),
('dev-user-0000-0000-0000-000000000001', 'tickets', 'access'),
('dev-user-0000-0000-0000-000000000001', 'admin', 'access');
+265
View File
@@ -0,0 +1,265 @@
# Migrating the Calendar domain onto the admin identity module
Status: **steps 1-4 implemented 2026-09-06, not yet merged or deployed.** Step 2 dropped by
decision, part of step 5 brought forward. Only step 5, the removal of the legacy path, is
left to write.
> Read the deploy checklist under step 4 before applying anything. "Done" below means the
> code exists on a branch, **not** that production has it - and in particular production has
> none of the three migrations. Step 5 is scoped but deliberately unstarted: it must not be
> built on top of a step 4 that has not been deployed and watched.
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
are now live, so the calendar is the last module still on the legacy query-parameter
sessions.
## Why the calendar was left out
The admin module replaced authentication for feedback and tickets by swapping one
middleware. The calendar cannot be done that way, because its user identity is woven into
its data:
- `users`/`sessions` live in the **calendar** database and are the same tables the
feedback and tickets admin areas used to authenticate against.
- `events.created_by_id` is an **INT** foreign key into `users.user_id`. The admin module's
user ids are **VARCHAR(36)** strings. Migrating identity means migrating that column and
every query that joins it.
- The Angular frontend passes `sessionId`/`sessionKey` as **query parameters**
(`DEFERRED_SECURITY.md` item 1). Cookie sessions remove the parameters entirely, so
every calendar route signature and the frontend's HTTP layer change together.
- `credentials.service.ts` implements a second, parallel authorisation model: the
`MEMBER_CREDENTIAL` / `CHOIR_CREDENTIAL` / `MANAGEMENT_CREDENTIAL` shared secrets that
let non-users read specific calendars. That has no equivalent in the admin module and is
not a per-user permission at all.
What already exists today: `calendar` is a value in the `user_app_permissions.app` enum, so
permissions can be granted before anything else moves.
## What is in place to build on
- Cookie sessions across `*.nachklang.art`, and `requireAppAccess('calendar')` in
`src/models/admin/admin.middleware.ts` - usable the moment a calendar route wants it.
- `res.locals.admin` is `{id, email, displayName, apps}`; `id` is the string user id.
- Invitations, disable/enable and session revocation already cover calendar users, because
they are properties of the account rather than of an app.
## Suggested sequence
Each step is meant to leave production working on its own.
1. **Add a bridging column.** ~~`ALTER TABLE events ADD COLUMN created_by_user_id
VARCHAR(36) NULL`, indexed. Nothing reads it yet.~~ **Done 2026-09-06**, as
`sql/calendar/001_add_admin_user_bridge.sql` - the first migration this repo owns for the
calendar schema, mirrored into `docker/init/01-calendar-schema-dev.sql`. It covers both
`events.created_by_user_id` and `event_versions.version_created_by_user_id`, and carries
no foreign key (see "What the code actually looks like" below). The dev seed leaves two
events on the legacy path and gives one an admin id, so step 3's dual-read has both cases
to exercise. Verified by applying the pre-migration schema and then the migration to a
throwaway MariaDB 11 container, and diffing `SHOW CREATE TABLE` against a fresh dev
schema: identical. Applied to the running dev database on the same day; a dev container
created before then needs it applied, or recreating.
2. ~~**Map the accounts.**~~ **Dropped 2026-09-06.** There is no backfill: since the
creator is only ever a display name (see below), old events keep resolving through the
legacy join until step 5 and then simply lose the name. Re-inviting the people who
actually still need calendar access remains an operational task, but it is no longer a
migration step and nothing is blocked on it.
3. **Dual-read.** ~~Change `events.service.ts` to prefer `created_by_user_id` and fall back
to `created_by_id`. Writes fill both.~~ **Done 2026-09-06.** `events.service.ts` now reads
both columns and prefers the admin one, resolving the name through a single
`findDisplayNames` lookup against the admin database per result set (added to
`users.admin.service.ts` for this). Four copies of the same SELECT and four copies of the
row mapper were collapsed into one of each first - the dual read would otherwise have had
to be written four times.
A name now has three possible sources, tried weakest first: the legacy join, then the
`created_by_name` snapshot from migration 002, then the live admin lookup - which wins
because it is the only one that follows an account being renamed. An admin id that no
longer resolves falls back rather than blanking, and a failure to reach the admin database
is caught and logged rather than propagated, so an anonymous read of the public calendar
never depends on the admin database being up. Covered by
`test/calendar/events.service.test.ts`.
**Writes are not dual-written**, contrary to the original plan: before the cutover the
request only ever carries a legacy session, so there is no admin id available to write.
Writes start filling `created_by_user_id` (and stop filling `created_by_id`) in step 4.
4. **Switch the routes.** ~~Replace the query-parameter session checks in `events.router.ts`
and `users.router.ts` with `requireAppAccess('calendar')`, and change the Angular frontend
to `withCredentials: true`.~~ **Done 2026-09-06.** `DEFERRED_SECURITY.md` item 1 is closed:
no route reads `sessionId`/`sessionKey` any more.
How it came out, route by route:
- The four write routes sit behind `requireAppAccess('calendar')` as middleware. They
answer 401 when signed out and 403 without the permission, where they used to answer 403
for both.
- The three read routes cannot use middleware - the same URL serves an anonymous visitor,
an iCal subscription holding a shared password, and a signed-in editor who should see
drafts. They call `resolveAccess` optionally instead (`signedInEditor` in the router),
and a signed-in user *without* the calendar permission is treated as anonymous rather
than refused, so they keep their access to the public calendar.
- `credentials.service.ts` lost its session half entirely and is now just the password
table. `hasAccess(calendar, password)`.
- `/calendar/users/*` was left alone. Nothing calls it and a session it mints opens
nothing, but they are live password-accepting endpoints - step 5 removes them.
Also: `calendar.nachklang.art` joined `DEFAULT_APP_ORIGINS` (better-auth `trustedOrigins`,
without which sign-out from the calendar fails while everything else works), and
`localhost:4200` joined the dev origins for the same reason.
Two things this step had to carry that the original sequence put in step 5:
- **`sql/calendar/003_allow_null_legacy_creator.sql` makes `events.created_by_id` nullable**
(`MODIFY created_by_id INT NULL`). It is `NOT NULL` today, so the first event created after the
cutover would otherwise fail to insert - there is no legacy int id to write any more.
`event_versions.version_created_by_id` is already nullable. The foreign key can stay
until step 5; it permits NULL. It also re-runs 002's idempotent name backfill, to catch
anything created between the two migrations. Applying it early is safe - widening a
column to accept NULL cannot break the running pre-cutover build.
- **The public calendar stays anonymous.** `hasAccess('public')` returns true before any
credential check, and nachklang.art reads `/calendar/events/public/json` and
`/public/json/next` with no session at all. Pinned at both levels - the password table in
`test/calendar/credentials.service.test.ts`, the routes themselves in
`test/calendar/events.router.test.ts` - so this cannot regress quietly.
### Deploy checklist
Production has **none** of the three migrations: 001 and 002 were only ever applied to the
dev database. The API build below selects `created_by_user_id` and `created_by_name` on
every read, so deploying it against a database missing them fails every calendar request
including the anonymous public feed the website uses. In order:
1. **Apply `sql/calendar/001`, `002`, `003`, in that order**, against `CALENDAR_DB`. All
three are re-runnable, so applying one that is already applied is a no-op. Verify
before continuing:
`SHOW COLUMNS FROM events LIKE '%by_user%'; SHOW COLUMNS FROM events LIKE '%by_name%';`
- four rows across the two tables, and `created_by_id` nullable.
2. **Check `APP_ORIGINS` on the API vhost.** `calendar.nachklang.art` is in the code's
default list, but the environment variable *replaces* that list rather than adding to
it - so if it is set at all (the tickets/feedback cutover may have set it), append
`https://calendar.nachklang.art` or the calendar's sign-out will 403 while everything
else works. That is the failure mode the comment in `admin.config.ts` warns about.
3. **Deploy the API.**
4. **Deploy the calendar frontend immediately after.** Do not leave a gap - see below.
5. **Re-run 002's two `UPDATE` statements.** Between step 1 and step 3 the old API was
still writing `created_by_id` with no snapshot; those few rows would otherwise lose
their author at step 5.
6. **Rebuild the admin app** if `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS` does not already
contain `https://calendar.nachklang.art`. It is a **build-time** value, so a restart
does nothing.
**The window between steps 3 and 4 does not look broken, which is the danger.** The old
Angular bundle starts by calling `POST /calendar/users/checkSessionValid`, and those
legacy routes are untouched - so it still succeeds and the page renders as signed in. What
the user then sees is an empty event table and saves that silently do nothing. It looks
like the calendar lost its data, not like a deploy in progress. Keep the gap to minutes,
or take the frontend offline for it.
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
than `?password=` stops working permanently. The shared-password URLs are unaffected.
5. **Drop the legacy path.** Not started - and deliberately not started until step 4 has been
deployed and watched, because it removes the fallback step 4 still leans on. Scoped and
decided 2026-09-06; what follows is the agreed shape, not a suggestion.
**Prerequisite: step 4 live in production and behaving.** Until then the legacy join is
what renders the author of every pre-cutover event, and the legacy routes are what an old
cached bundle talks to. Doing this first turns a recoverable deploy into an unrecoverable
one.
Code, in one branch:
- **Delete `src/models/calendar/users/` entirely** - `users.router.ts`, `users.service.ts`,
`session.interface.ts`, `user.interface.ts` - and the `calendarRouter.use('/users', ...)`
line in `Calendar.router.ts`. *(Decided: delete outright rather than unmount.)* This
removes the last unauthenticated account-creation and mail-sending endpoint in the API.
A survey on 2026-09-06 confirmed nothing outside that directory imports it, and nothing
outside it touches the `users`/`sessions` tables except the two joins below.
- **Drop the legacy half of the read** in `events.service.ts`: the two
`LEFT OUTER JOIN users` clauses, the `legacy_*` aliases, and `created_by_id` /
`version_created_by_id` from the SELECT and the row mapper. The snapshot fallback stays -
it is what makes this safe. Remove `createdById` / `lastModifiedById` from
`event.interface.ts` and their (already deprecated) swagger properties.
- **Remove `X-Session-Id` / `X-Session-Key`** from the CORS `allowedHeaders` in
`src/app.factory.ts`. Nothing has sent them since the tickets and feedback frontends were
redeployed.
- **Drop the obsolete test mocks**: `test/feedback/feedback.auth.test.ts`,
`test/tickets/tickets.auth.test.ts` and `test/admin/auth-binding.ts` each mock
`calendar/users/users.service.js` and assert `checkSession` is never called. That
tripwire is meaningless once the module does not exist; remove the mock and the
assertion, keep the rest.
Database, as `sql/calendar/004_*.sql`:
- Drop the foreign keys `events_users_user_id_fk` and `event_versions_users_user_id_fk`,
then the `created_by_id` and `version_created_by_id` columns.
- **`RENAME TABLE users TO users_legacy_archive`**, same for `sessions`. *(Decided: rename
rather than drop.)* The reasoning: the display names are already snapshotted so nothing
visible depends on these rows, but they still hold the old e-mail addresses and password
hashes, and a rename makes the tables unreachable without destroying anything. Dropping
them later is one statement, at a moment when nobody is mid-deploy.
- Mirror all of it in `docker/init/01-calendar-schema-dev.sql` (the archive tables need no
mirror - a fresh dev database has nothing to archive).
Documentation: `DEFERRED_SECURITY.md` items **3** (activation token has no expiry) and
**4** (password reset token has no expiry) close outright - both describe code that ceases
to exist. Item 2 (no event ownership check) stays open.
Two consequences to accept explicitly rather than discover:
- Any activation or password-reset e-mail already sent points at
`api.nachklang.art/calendar/users/activate` and becomes a 404. Those links were only ever
valid for legacy accounts, which no longer open anything.
- `Event.createdById` disappears from the API response. The Angular frontend never read it
(its `Event` model has only `createdBy`, the name), so this is not a breaking change for
the only known consumer - but it is a wire-format removal, so check anything else that
reads `/calendar/events/*/json` first.
## What the code actually looks like (surveyed 2026-09-06)
Four things found while doing step 1 that change how the later steps should be built:
- **`created_by_id` is display-only.** Nothing authorises on it. `events.router.ts` gates
PUT, POST, DELETE and `/move` on `user?.isActive` alone - there is no "only the creator may
edit" rule anywhere - and the column is read back solely to render `created_by_name` and
`last_modified_by_name`. That de-risks steps 2, 3 and 5 considerably: an event whose
creator never gets re-invited loses a name in the UI, it does not become uneditable or
invisible. It also means the step 2 backfill is best-effort, not a precondition.
- **The two schemas are separate databases.** `nachklang_calendar` and `nachklang_admin`
have their own connection pools (`Calendar.db.ts` vs the admin module's Kysely instance).
So the bridging columns get no foreign key, and - the part the original sequence missed -
**the `LEFT OUTER JOIN users` that produces the creator's name cannot simply be repointed**.
It would have to become a cross-schema join, which hardcodes the admin database name into
calendar SQL and ties the two schemas together exactly as an FK would. Recommendation for
step 3: drop the join for the new path and resolve names in the service layer instead -
collect the distinct ids from the result set and do one lookup against the admin users
service. One extra query per listing, no coupling, and it keeps working if the admin
database ever moves.
- **`events.created_by_id` is `NOT NULL`.** Step 5 cannot simply stop writing it; that step
has to drop the column (and its FK to `users`) in the same migration that stops the writes,
or make it nullable first.
- **Every calendar read already hits the session table.** `/:calendar/json` calls
`UserService.checkSession` before falling back to `credentials.service.ts`, so the shared
credentials are the *fallback*, not the primary path. Step 4 replaces the first half of
that with `requireAppAccess('calendar')` and has to decide what happens to the second half
- which is the first open question below.
## Open questions to settle before starting
**Settled 2026-09-06:**
- **The shared calendar credentials keep working, but only for iCal.** The web app goes
cookie-only at step 4; `MEMBER_CREDENTIAL` and friends survive on
`GET /calendar/events/{calendar}/ical`, which is the one case where the client genuinely
cannot send a cookie. Everything else in `credentials.service.ts` goes with step 5.
`public` stays anonymous everywhere - see the note under step 4.
- **The iCal export keeps its own scheme.** Same reasoning; it is the reason the shared
credentials survive at all rather than an exception to their removal.
- **No account backfill.** See step 2 above.
- **Pre-cutover authorship is archived, not discarded.** `events.created_by_name` and
`event_versions.version_created_by_name`, backfilled once by migration 002 and never
written again. This was originally listed as a step 5 question; it was brought forward so
the data is safe well before the table that holds it is dropped.
**Nothing is open.** The last one - ~~`event_versions.version_created_by_id`~~, the same INT
reference on the version rows - was handled in passing: step 1 gave it a sibling bridging
column, step 2 a sibling snapshot, and step 3 reads it exactly like `events`.
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: [
'test'
]
};
+3845 -5901
View File
File diff suppressed because it is too large Load Diff
+30 -17
View File
@@ -3,52 +3,65 @@
"version": "0.1.0", "version": "0.1.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module",
"engines": {
"node": ">=26"
},
"scripts": { "scripts": {
"start": "tsc && node ./dist/app.js", "start": "tsc && node ./dist/app.js",
"build": "tsc", "build": "tsc",
"debug": "export DEBUG=* && npm run start", "debug": "export DEBUG=* && npm run start",
"test": "jest --coverage --testResultsProcessor ./node_modules/jest-sonar-reporter/index.js" "test": "vitest run --coverage",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@better-auth/core": "^1.7.2",
"@better-auth/passkey": "^1.7.2",
"app-root-path": "^3.0.0", "app-root-path": "^3.0.0",
"axios": "^0.24.0", "axios": "^1.20.0",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"better-auth": "^1.7.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"debug": "^4.3.1", "debug": "^4.3.1",
"dotenv": "^8.2.0", "dotenv": "^16.6.1",
"express": "^4.17.1", "express": "^4.18.2",
"guid-typescript": "^1.0.9", "guid-typescript": "^1.0.9",
"kysely": "^0.29.5",
"mariadb": "^3.0.2", "mariadb": "^3.0.2",
"mysql2": "^3.24.3",
"random-words": "^1.1.1", "random-words": "^1.1.1",
"swagger-jsdoc": "^6.1.0", "swagger-jsdoc": "^6.1.0",
"swagger-ui-express": "^4.3.0", "swagger-ui-express": "^4.3.0",
"winston": "^3.3.3" "winston": "^3.3.3",
"zod": "^4.5.4"
}, },
"devDependencies": { "devDependencies": {
"@types/app-root-path": "^1.2.4", "@types/app-root-path": "^1.2.4",
"@types/bcrypt": "^3.0.1", "@types/bcrypt": "^3.0.1",
"@types/cors": "^2.8.19",
"@types/debug": "^4.1.5", "@types/debug": "^4.1.5",
"@types/express": "^4.17.11", "@types/express": "^4.17.15",
"@types/jest": "^28.1.3", "@types/node": "^26.4.1",
"@types/random-words": "^1.1.2", "@types/random-words": "^1.1.2",
"@types/supertest": "^7.2.1",
"@types/swagger-jsdoc": "^6.0.1", "@types/swagger-jsdoc": "^6.0.1",
"@types/swagger-ui-express": "^4.1.3", "@types/swagger-ui-express": "^4.1.3",
"@types/winston": "^2.4.4", "@types/winston": "^2.4.4",
"@vitest/coverage-v8": "^5.0.0",
"is-number": "^7.0.0", "is-number": "^7.0.0",
"jest": "^28.1.1",
"jest-sonar-reporter": "^2.0.0",
"source-map-support": "^0.5.19", "source-map-support": "^0.5.19",
"ts-jest": "^28.0.5", "supertest": "^7.2.2",
"tslint": "^6.1.3", "typescript": "^5.9.3",
"typescript": "^4.1.5" "vitest": "^5.0.0",
"vitest-sonar-reporter": "^3.0.0"
}, },
"jestSonar": { "overrides": {
"sonar56x": true, "better-auth": {
"reportPath": "testResults", "vitest": "$vitest"
"reportFile": "sonar-report.xml", }
"indent": 4
} }
} }
+150
View File
@@ -0,0 +1,150 @@
-- nachklang_admin: identity, sessions and per-app permissions for every
-- *.nachklang.art app.
--
-- The better-auth tables below (user, session, account, verification, passkey,
-- rateLimit) mirror what better-auth 1.7.2 derives from the configuration in
-- src/models/admin/admin.auth.ts, including the `disabled` additionalField on
-- `user` and the `rateLimit` table that rateLimit.storage='database' requires.
-- On every better-auth upgrade: re-derive the table list, diff it against this
-- file, and add a numbered migration - never edit this one in place.
--
-- Table and column names are better-auth's own ("camel" casing, so `rateLimit`
-- and `userId`). MariaDB on Linux compares table names case-sensitively, so the
-- casing here is load-bearing. The two Nachklang-owned tables at the bottom use
-- the snake_case convention of the rest of this repo's SQL.
CREATE TABLE IF NOT EXISTS `user` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`emailVerified` TINYINT(1) NOT NULL DEFAULT 0,
`image` TEXT DEFAULT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Nachklang addition, declared through better-auth's additionalFields so
-- the adapter knows about it. Disabling also revokes the user's sessions.
`disabled` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `user_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `session` (
`id` VARCHAR(36) NOT NULL,
`expiresAt` DATETIME NOT NULL,
`token` VARCHAR(255) NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ipAddress` VARCHAR(255) DEFAULT NULL,
`userAgent` TEXT DEFAULT NULL,
`userId` VARCHAR(36) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `session_token` (`token`),
KEY `session_user` (`userId`),
CONSTRAINT `session_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `account` (
`id` VARCHAR(36) NOT NULL,
-- 1.7 addition: distinguishes a local credential account
-- ("local:credential") from an OAuth issuer. Written by better-auth.
`issuer` VARCHAR(255) NOT NULL,
`accountId` VARCHAR(255) NOT NULL,
`providerId` VARCHAR(255) NOT NULL,
`userId` VARCHAR(36) NOT NULL,
`accessToken` TEXT DEFAULT NULL,
`refreshToken` TEXT DEFAULT NULL,
`idToken` TEXT DEFAULT NULL,
`accessTokenExpiresAt` DATETIME DEFAULT NULL,
`refreshTokenExpiresAt` DATETIME DEFAULT NULL,
`scope` TEXT DEFAULT NULL,
`password` TEXT DEFAULT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `account_user` (`userId`),
KEY `account_provider` (`providerId`, `accountId`),
CONSTRAINT `account_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Password-reset and e-mail-verification tokens.
CREATE TABLE IF NOT EXISTS `verification` (
`id` VARCHAR(36) NOT NULL,
`identifier` VARCHAR(255) NOT NULL,
`value` TEXT NOT NULL,
`expiresAt` DATETIME NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `verification_identifier` (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `passkey` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(255) DEFAULT NULL,
`publicKey` TEXT NOT NULL,
`userId` VARCHAR(36) NOT NULL,
`credentialID` VARCHAR(255) NOT NULL,
`counter` INT NOT NULL DEFAULT 0,
`deviceType` VARCHAR(255) NOT NULL,
`backedUp` TINYINT(1) NOT NULL DEFAULT 0,
`transports` VARCHAR(255) DEFAULT NULL,
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
`aaguid` VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `passkey_user` (`userId`),
KEY `passkey_credential` (`credentialID`),
CONSTRAINT `passkey_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Required by rateLimit.storage = 'database' in admin.auth.ts. Passenger may
-- run several API instances, and an in-memory limiter would give each of them
-- its own budget.
CREATE TABLE IF NOT EXISTS `rateLimit` (
`id` VARCHAR(36) NOT NULL,
`key` VARCHAR(255) NOT NULL,
`count` INT NOT NULL DEFAULT 0,
-- Epoch milliseconds, not a DATETIME: better-auth stores a number here.
`lastRequest` BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `rate_limit_key` (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Nachklang-owned tables
-- ---------------------------------------------------------------------------
-- Which apps a user may administer. `admin` is just another app: holding it is
-- what lets someone manage users and invitations. A permission is (app, role);
-- `access` is the only role today, and the key admits several per app so finer
-- ones can be added by inserting rows rather than by migrating this table.
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
`user_id` VARCHAR(36) NOT NULL,
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
-- One row per (user, app, role). `access` means "may use this app at all"
-- and is the only role today; the key allows several per app so a finer
-- permission can be added later by inserting rows, not by migrating.
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
`granted_by` VARCHAR(36) DEFAULT NULL,
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- (user_id, app) is the leftmost prefix of this key, so the per-request
-- permission lookup needs no separate index.
PRIMARY KEY (`user_id`, `app`, `role`),
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- The only route to a new account: there is no public sign-up. Only the
-- SHA-256 of the token is stored, so a dump of this table hands out no access.
CREATE TABLE IF NOT EXISTS `invitations` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(255) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`permissions` JSON NOT NULL,
`invited_by` VARCHAR(36) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` DATETIME NOT NULL,
`accepted_at` DATETIME DEFAULT NULL,
`revoked_at` DATETIME DEFAULT NULL,
UNIQUE KEY `inv_token_hash` (`token_hash`),
KEY `inv_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,38 @@
-- Nachklang e.V. Calendar module — step 1 of docs/calendar-auth-migration.md.
-- Adds the bridging columns that let an event record who created it as an
-- *admin* user id (VARCHAR(36)) alongside the legacy calendar users.user_id
-- (INT). Apply manually against the CALENDAR_DB database:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 001_add_admin_user_bridge.sql
--
-- Numbered 001 because this is the first migration this repo owns for the
-- calendar schema: the tables themselves predate it and were provided by the
-- repo owner (mirrored for dev in docker/init/01-calendar-schema-dev.sql).
--
-- Nothing reads these columns yet — step 3 introduces the dual-read. Adding
-- them first means the backfill in step 2 has somewhere to write, and this
-- migration can be applied to production on its own without any code change.
--
-- No foreign key, on purpose. The admin `user` table lives in a *different*
-- database (nachklang_admin) behind a different connection pool, and a
-- cross-schema FK would tie the two schemas' lifecycles together: you could no
-- longer dump, restore or move one without the other. The reference is
-- enforced in application code, which is also where the legacy/new fallback
-- lives.
--
-- The collation is pinned to the admin database's (utf8mb4_unicode_ci) rather
-- than inherited from the calendar tables' utf8mb4_general_ci. These columns
-- hold ids that only ever compare against nachklang_admin.user.id, and a
-- mismatched collation makes any such comparison fail at runtime with
-- "Illegal mix of collations" instead of at review time.
ALTER TABLE `events`
ADD COLUMN IF NOT EXISTS `created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `created_by_id`,
ADD KEY IF NOT EXISTS `events_created_by_user_idx` (`created_by_user_id`);
ALTER TABLE `event_versions`
ADD COLUMN IF NOT EXISTS `version_created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `version_created_by_id`,
ADD KEY IF NOT EXISTS `event_versions_created_by_user_idx` (`version_created_by_user_id`);
@@ -0,0 +1,42 @@
-- Nachklang e.V. Calendar module — step 5 preparation, brought forward.
-- Apply manually against the CALENDAR_DB database, after 001:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 002_snapshot_legacy_creator_names.sql
--
-- Snapshots the creator's and last editor's *name* onto the event itself.
--
-- Why: the creator is only ever rendered as a name (nothing authorises on it),
-- and today that name comes from joining the calendar's own `users` table.
-- Step 5 drops that table, which would silently erase the authorship of every
-- event created before the cutover. There is no account backfill to save them
-- either - that was dropped deliberately, see docs/calendar-auth-migration.md.
-- One text column per reference keeps the history at no ongoing cost.
--
-- These columns are an archive, not a source of truth. Nothing writes them
-- after this backfill: events created from the cutover onwards carry an admin
-- user id, whose name is resolved live so that renaming an account updates
-- everywhere. The read path prefers the live admin name, falls back to this
-- snapshot, and falls back again to the join until step 5 removes it.
--
-- The whole file is re-runnable: IF NOT EXISTS on the columns, and the backfill
-- only touches rows with no snapshot yet. Step 4's migration re-runs the
-- backfill, to catch anything created between this migration and the cutover.
--
-- No charset clause: unlike 001's id columns these hold display text that is
-- only ever compared against other calendar data, so they inherit the tables'
-- utf8mb4_general_ci like the columns they are copied from.
ALTER TABLE `events`
ADD COLUMN IF NOT EXISTS `created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `created_by_user_id`;
ALTER TABLE `event_versions`
ADD COLUMN IF NOT EXISTS `version_created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `version_created_by_user_id`;
UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id
SET e.created_by_name = u.full_name
WHERE e.created_by_name IS NULL;
UPDATE `event_versions` v
JOIN `users` u ON u.user_id = v.version_created_by_id
SET v.version_created_by_name = u.full_name
WHERE v.version_created_by_name IS NULL;
@@ -0,0 +1,32 @@
-- Nachklang e.V. Calendar module — step 4 of docs/calendar-auth-migration.md,
-- the cutover. Apply manually against the CALENDAR_DB database, after 002,
-- and BEFORE deploying the API build that goes with it:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 003_allow_null_legacy_creator.sql
--
-- From the cutover on, an event's creator is an admin-module user id. There is
-- no legacy calendar user id to write any more, and `events.created_by_id` is
-- NOT NULL - so without this the very first event created after the deploy
-- fails to insert. `event_versions.version_created_by_id` is already nullable.
--
-- The foreign key to `users` is kept: it permits NULL, so it costs nothing
-- until step 5 drops the column and the table together.
--
-- Applying this early is harmless. Widening a column to accept NULL cannot
-- break the running pre-cutover build, which always supplies a value, so this
-- can go out ahead of the deploy rather than during it.
ALTER TABLE `events`
MODIFY COLUMN `created_by_id` INT(11) NULL DEFAULT NULL;
-- Re-run of 002's backfill, to catch anything created between the two
-- migrations while the legacy path was still writing events. Idempotent by
-- construction: it only touches rows that have no snapshot yet.
UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id
SET e.created_by_name = u.full_name
WHERE e.created_by_name IS NULL;
UPDATE `event_versions` v
JOIN `users` u ON u.user_id = v.version_created_by_id
SET v.version_created_by_name = u.full_name
WHERE v.version_created_by_name IS NULL;
+144
View File
@@ -0,0 +1,144 @@
-- Nachklang e.V. Feedback module — initial schema for FEEDBACK_DB
-- Apply manually against the FEEDBACK_DB database (separate from CALENDAR_DB).
-- See nachklang-feedback/IMPLEMENTATION_PLAN.md §2 for the full rationale
-- behind every design decision below (snapshot columns, denormalisation,
-- absence-over-sentinels, hashed IPs only).
--
-- Apply with e.g.:
-- mysql -h <DB_HOST> -u <DB_USER> -p <FEEDBACK_DB> < 001_init.sql
--
-- Deliberately no USE statement here: the target database is selected via
-- the mysql command line above (whatever FEEDBACK_DB is actually named in
-- .env), not hardcoded to a literal schema name.
-- 1. events -------------------------------------------------------------
CREATE TABLE events (
event_id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(80) NOT NULL,
name VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NULL,
event_date DATE NOT NULL,
feedback_deadline DATETIME NOT NULL,
is_published TINYINT(1) NOT NULL DEFAULT 0,
intro_text TEXT NULL,
created_by_email VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_events_slug (slug),
KEY idx_events_eligibility (is_published, event_date, feedback_deadline)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. songs (per-event setlist) ------------------------------------------
CREATE TABLE songs (
song_id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
composer VARCHAR(255) NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_songs_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
KEY idx_songs_event_position (event_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. questions (global reusable library) ---------------------------------
CREATE TABLE questions (
question_id INT AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(500) NOT NULL,
help_text VARCHAR(500) NULL,
question_type ENUM('SONG_PICK','SONG_RATING','FREE_TEXT') NOT NULL,
is_archived TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_questions_archived_type (is_archived, question_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. event_questions (join + ordering) -----------------------------------
CREATE TABLE event_questions (
event_question_id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
question_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_eq_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
CONSTRAINT fk_eq_question FOREIGN KEY (question_id) REFERENCES questions(question_id) ON DELETE RESTRICT,
UNIQUE KEY uq_eq_event_question (event_id, question_id),
KEY idx_eq_event_position (event_id, position, is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. submissions ----------------------------------------------------------
CREATE TABLE submissions (
submission_id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ip_hash CHAR(64) NULL,
has_guestbook TINYINT(1) NOT NULL DEFAULT 0,
has_newsletter TINYINT(1) NOT NULL DEFAULT 0,
CONSTRAINT fk_sub_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
KEY idx_sub_event_time (event_id, submitted_at),
KEY idx_sub_iphash_time (ip_hash, submitted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6. submission_answers ----------------------------------------------------
-- Deliberate denormalisation: question label/type and song title are
-- snapshotted at submission time so later edits to the question library
-- never retroactively change what a past submission means.
CREATE TABLE submission_answers (
answer_id INT AUTO_INCREMENT PRIMARY KEY,
submission_id INT NOT NULL,
event_id INT NOT NULL,
question_id INT NULL,
event_question_id INT NULL,
question_label_snapshot VARCHAR(500) NOT NULL,
question_type ENUM('SONG_PICK','SONG_RATING','FREE_TEXT') NOT NULL,
position_snapshot INT NOT NULL DEFAULT 0,
song_id INT NULL,
song_title_snapshot VARCHAR(255) NULL,
rating TINYINT NULL,
text_answer TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ans_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE,
CONSTRAINT fk_ans_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
CONSTRAINT fk_ans_question FOREIGN KEY (question_id) REFERENCES questions(question_id) ON DELETE SET NULL,
CONSTRAINT fk_ans_song FOREIGN KEY (song_id) REFERENCES songs(song_id) ON DELETE SET NULL,
CONSTRAINT chk_ans_rating CHECK (rating IS NULL OR (rating BETWEEN 1 AND 5)),
KEY idx_ans_submission (submission_id),
KEY idx_ans_report (event_id, question_id, song_id),
KEY idx_ans_type (event_id, question_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 7. guest_book_entries -----------------------------------------------------
-- Private, admin-only. No public wall in v1.
CREATE TABLE guest_book_entries (
entry_id INT AUTO_INCREMENT PRIMARY KEY,
submission_id INT NOT NULL,
event_id INT NOT NULL,
display_name VARCHAR(255) NULL,
message TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_gb_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE,
CONSTRAINT fk_gb_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
KEY idx_gb_event_time (event_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 8. newsletter_signups -----------------------------------------------------
CREATE TABLE newsletter_signups (
signup_id INT AUTO_INCREMENT PRIMARY KEY,
submission_id INT NOT NULL,
event_id INT NOT NULL,
first_name VARCHAR(120) NOT NULL,
last_name VARCHAR(120) NOT NULL,
email VARCHAR(255) NOT NULL,
consent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
consent_text_version VARCHAR(40) NOT NULL,
sync_status ENUM('PENDING','SENT','FAILED','SKIPPED') NOT NULL DEFAULT 'PENDING',
sync_attempts INT NOT NULL DEFAULT 0,
synced_at DATETIME NULL,
external_id VARCHAR(120) NULL,
last_error TEXT NULL,
CONSTRAINT fk_nl_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE,
CONSTRAINT fk_nl_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
KEY idx_nl_sync_status (sync_status),
KEY idx_nl_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,9 @@
-- Nachklang e.V. Feedback module — adds a per-event poster image URL.
-- Apply manually against the FEEDBACK_DB database, after 001_init.sql:
-- mysql -h <DB_HOST> -u <DB_USER> -p <FEEDBACK_DB> < 002_add_poster_image_url.sql
--
-- Stores a URL only (e.g. an existing nachklang.art poster image) rather
-- than an uploaded file — the concert posters already live on the public
-- website, so there is no need for the feedback app to host its own copy.
ALTER TABLE events
ADD COLUMN poster_image_url VARCHAR(500) NULL AFTER intro_text;
+102
View File
@@ -0,0 +1,102 @@
-- Nachklang e.V. Tickets module — initial schema for TICKETS_DB
-- Apply manually against the TICKETS_DB database (separate from CALENDAR_DB
-- and FEEDBACK_DB). See nachklang-tickets/docs/plan-ticket-shop.md for the
-- full design rationale.
--
-- Apply with e.g.:
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 001_init.sql
--
-- Deliberately no USE statement here: the target database is selected via
-- the mysql command line above (whatever TICKETS_DB is actually named in
-- .env), not hardcoded to a literal schema name.
--
-- `event_id` columns below refer to Calendar's `events.event_id` (a
-- different database). Deliberately no cross-database foreign key —
-- Tickets reads Calendar events via events.service.ts in the same Node
-- process, not via a DB-level join.
-- 1. voucher_codes --------------------------------------------------------
CREATE TABLE voucher_codes (
code VARCHAR(12) NOT NULL PRIMARY KEY,
status ENUM('UNUSED','REDEEMED','VOID') NOT NULL DEFAULT 'UNUSED',
max_guests INT NOT NULL DEFAULT 2,
prefill_name VARCHAR(255) NULL,
prefill_email VARCHAR(255) NULL,
batch_id VARCHAR(36) NULL,
created_by_email VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_vc_batch (batch_id),
KEY idx_vc_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. voucher_code_events (join: which concerts a code may be redeemed for) -
CREATE TABLE voucher_code_events (
voucher_code_event_id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(12) NOT NULL,
event_id INT NOT NULL,
CONSTRAINT fk_vce_code FOREIGN KEY (code) REFERENCES voucher_codes(code) ON DELETE CASCADE,
UNIQUE KEY uq_vce_code_event (code, event_id),
KEY idx_vce_event (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. event_ticket_settings (per-concert voucher config) ---------------------
-- One row per Calendar event_id that has ever had voucher settings
-- configured. Absent row == uncapped, no deadline, address not collected
-- (see docs/plan-ticket-shop.md — "absence over sentinels").
CREATE TABLE event_ticket_settings (
event_id INT NOT NULL PRIMARY KEY,
capacity INT NULL,
redemption_deadline DATETIME NULL,
collect_address TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. redemptions ------------------------------------------------------------
-- `status` is soft-state rather than a hard delete on undo, so guest data
-- and history survive an undo for the audit trail. Capacity/reporting
-- queries filter status = 'ACTIVE'. A code that gets redeemed again after
-- being undone creates a new row here rather than reviving the old one.
CREATE TABLE redemptions (
redemption_id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(12) NOT NULL,
event_id INT NOT NULL,
status ENUM('ACTIVE','UNDONE') NOT NULL DEFAULT 'ACTIVE',
contact_name VARCHAR(255) NOT NULL,
contact_email VARCHAR(255) NOT NULL,
contact_address TEXT NULL,
guest_count INT NOT NULL,
redeemed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_red_code FOREIGN KEY (code) REFERENCES voucher_codes(code),
KEY idx_red_event_status (event_id, status),
KEY idx_red_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. redemption_guests --------------------------------------------------------
-- One row per attendee, including the primary contact (position 0).
CREATE TABLE redemption_guests (
redemption_guest_id INT AUTO_INCREMENT PRIMARY KEY,
redemption_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
position INT NOT NULL DEFAULT 0,
CONSTRAINT fk_rg_redemption FOREIGN KEY (redemption_id) REFERENCES redemptions(redemption_id) ON DELETE CASCADE,
KEY idx_rg_redemption (redemption_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6. voucher_audit_log ----------------------------------------------------
-- Lightweight admin-action history per code (not a full version-history
-- system) — who did what and why. Covers EDIT/VOID/UNDO only; the guest's
-- own redemption isn't an admin action so it isn't logged here (it's
-- already timestamped on `redemptions.redeemed_at`).
CREATE TABLE voucher_audit_log (
audit_id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(12) NOT NULL,
redemption_id INT NULL,
admin_email VARCHAR(255) NOT NULL,
action ENUM('EDIT','VOID','UNDO') NOT NULL,
change_summary JSON NULL,
reason VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_val_code FOREIGN KEY (code) REFERENCES voucher_codes(code) ON DELETE CASCADE,
CONSTRAINT fk_val_redemption FOREIGN KEY (redemption_id) REFERENCES redemptions(redemption_id) ON DELETE SET NULL,
KEY idx_val_code_time (code, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+7
View File
@@ -0,0 +1,7 @@
-- Nachklang e.V. Tickets module — adds a per-event "address required" flag,
-- distinct from collect_address (which only controls whether the field is
-- shown/collected at all). Apply manually against TICKETS_DB, after
-- 001_init.sql:
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 002_add_require_address.sql
ALTER TABLE event_ticket_settings
ADD COLUMN require_address TINYINT(1) NOT NULL DEFAULT 0 AFTER collect_address;
@@ -0,0 +1,7 @@
-- Nachklang e.V. Tickets module — records the outcome of the redemption
-- confirmation email on the redemption itself, so the admin UI can flag a
-- failed send and offer a resend. NULL until the post-commit send resolves.
-- Apply manually against TICKETS_DB, after 002_add_require_address.sql:
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 003_add_confirmation_email_status.sql
ALTER TABLE redemptions
ADD COLUMN confirmation_email_status ENUM('SENT','FAILED') NULL DEFAULT NULL AFTER redeemed_at;
+172
View File
@@ -0,0 +1,172 @@
import express from 'express';
import * as dotenv from 'dotenv';
import swaggerUi from 'swagger-ui-express';
import swaggerJSDoc from 'swagger-jsdoc';
import cors from 'cors';
import {toNodeHandler} from 'better-auth/node';
import logger from './middleware/logger.js';
// Router imports
import {calendarRouter} from './models/calendar/Calendar.router.js';
import {feedbackRouter} from './models/feedback/Feedback.router.js';
import {ticketsRouter} from './models/tickets/Tickets.router.js';
import {adminRouter} from './models/admin/Admin.router.js';
import {auth} from './models/admin/admin.auth.js';
import {ADMIN_ALLOWED_ORIGINS, isProd} from './models/admin/admin.config.js';
dotenv.config();
/**
* Builds the Express app with every router and middleware in place.
*
* Separate from app.ts so the integration tests can drive the *real* wiring
* with supertest instead of a hand-rolled copy of it. The order below is not
* cosmetic - CORS has to precede the better-auth handler so preflights get
* their headers, and the better-auth handler has to precede express.json()
* because it reads the raw body stream itself.
*/
export const createApp = (): express.Application => {
const app: express.Application = express();
// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header.
// Verify the resolved client IP is correct in staging before relying on it
// (used by the feedback rate limiter).
app.set('trust proxy', 1);
// Configure CORS. This has to run before the better-auth handler below, so
// that preflights for /admin/auth/* get their headers, which is why it now
// sits above express.json() instead of after it.
let allowedHosts = [
'https://www.nachklang.art',
'https://calendar.nachklang.art',
'https://feedback.nachklang.art',
'https://tickets.nachklang.art',
'https://admin.nachklang.art',
// The admin app's origin comes from ADMIN_APP_URL, so a rename or a
// staging host does not need a code change here.
...ADMIN_ALLOWED_ORIGINS
];
// `isProd` from admin.config, NOT `NODE_ENV !== 'production'`. The two are not
// the same when NODE_ENV is unset, which is exactly what a fresh Plesk vhost
// gives you: the old test called that "dev" and opened the loopback and
// private-LAN exceptions below. With `credentials: true` on this CORS config
// and a session cookie scoped to .nachklang.art, that let any page served
// from localhost read a signed-in admin's data cross-origin. admin.config
// treats anything but an explicit 'development'/'test' as production, so an
// unset value now fails closed.
const isDev = !isProd;
const localhostRegex = /^http:\/\/localhost:\d+$/;
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
// be reached from a real phone over WiFi during dev (the phone's Origin is
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
app.use(cors({
// X-Session-* are no longer read by anything on this side, and no longer
// sent by anything either: the tickets and feedback cutover took the last
// two readers off them, and the calendar cutover removed the last legacy
// credential path in the API (its session used to travel in query
// parameters - DEFERRED_SECURITY.md item 1, now closed). They stay allowed
// only so a browser still running a pre-cutover tickets or feedback bundle
// gets a clean 401 rather than a CORS preflight failure. Drop them once
// those have aged out - see docs/calendar-auth-migration.md step 5.
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
// The admin session lives in a cookie, so browsers must be allowed to send
// it cross-origin - this is what makes credentials: 'include' work.
credentials: true,
origin: function (origin: any, callback: any) {
// Allow requests with no origin
if (!origin) return callback(null, true);
// Any localhost port, or a private-LAN IP, is fine outside production -
// dev servers pick whatever port is free (Next.js falls back from 3000
// if it's taken), and real-device testing hits the dev machine by IP.
if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) {
return callback(null, true);
}
// Block requests with wrong origin
if (allowedHosts.indexOf(origin) === -1) {
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
}
// Allow all other requests
return callback(null, true);
}
}));
// better-auth's own handler, mounted before express.json(): it reads the raw
// request body stream itself and a parsed body would leave it hanging.
//
// Wrapped, because Express 4 does not await an async handler: a rejected
// promise escapes as an unhandled rejection instead of becoming a response.
// Nearly every better-auth route touches the admin database, so a database
// blip would leave the request hanging with no answer at all while the
// process logged an uncaughtException - observed by pointing ADMIN_DB at a
// database the user cannot open. Answer 503 instead: the caller learns, and
// the other domains keep serving.
const authHandler = toNodeHandler(auth);
app.all('/admin/auth/*', (req, res) => {
Promise.resolve(authHandler(req, res)).catch((e: any) => {
logger.error('Admin auth handler failed', {path: req.path, detail: e?.message});
if (!res.headersSent) {
res.status(503).send({
status: 'SERVICE_UNAVAILABLE',
message: 'Die Anmeldung ist derzeit nicht verfügbar. Bitte versuche es später erneut.'
});
}
});
});
// here we are adding middleware to parse all incoming requests as JSON
app.use(express.json());
// Swagger documentation
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'Nachklang e.V. REST API',
version: '1.0.0',
license: {
name: 'Licensed Under MIT',
url: 'https://spdx.org/licenses/MIT.html'
},
contact: {
name: 'Nachklang e.V.',
url: 'https://www.nachklang.art'
}
}
};
const options = {
swaggerDefinition,
// Paths to files containing OpenAPI definitions
apis: [
'./src/models/**/*.interface.ts',
'./src/models/**/*.router.ts'
]
};
const swaggerSpec = swaggerJSDoc(options);
app.use(
'/docs',
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
// Add routers
app.use('/calendar', calendarRouter);
app.use('/feedback', feedbackRouter);
app.use('/tickets', ticketsRouter);
// JSON routes only; the auth handler above is mounted separately.
app.use('/admin', adminRouter);
// this is a simple route to make sure everything is working properly
app.get('/', (req: express.Request, res: express.Response) => {
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
});
return app;
};
+107
View File
@@ -0,0 +1,107 @@
import logger from '../middleware/logger.js';
import {salesforceApexRestPost, salesforceEnabled} from './salesforce.client.js';
// Transactional email for the ticketing/calendar flows (voucher redemption
// confirmations, account activation links, password-reset tokens) is relayed
// through the Nachklang Salesforce org rather than sent over our own SMTP host:
// that host's IP reputation gets it blocked by allowlist-based receivers
// (notably t-online.de). Salesforce's MTA plus the org's DKIM signature for
// nachklang.art get the mail delivered. The org endpoint is EmailSendResource
// (POST /services/apexrest/email/send); the From address is fixed server-side
// there and is never sent from here.
//
// sendMail never throws on a delivery problem. Every caller has already
// committed its own work (a registration, a password-reset token, a
// redemption) by the time mail goes out, so a mail failure must not surface as
// a user-facing error. It returns whether the mail was accepted so the one
// caller that shows failures to staff (the voucher confirmation) can record it.
export namespace MailService {
export interface MailAttachment {
filename: string;
content: string | Buffer;
contentType?: string;
}
export interface SendMailOptions {
html?: string;
attachments?: MailAttachment[];
}
interface EmailSendResponse {
status: 'SENT';
}
// Practical ceiling, well under Apex REST's 6 MB request-body limit once
// base64 inflation (~33%) is accounted for. The only attachment today is a
// ~1 KB .ics file.
const MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024;
const isRetriable = (err: any): boolean => {
const status = err?.response?.status;
if (status !== undefined) {
return status >= 500;
}
// No response at all - network error or timeout.
return true;
};
/**
* Relays one email through the Salesforce org. Retries once on a transient
* failure (5xx / network / timeout), then logs and returns false rather
* than throwing. Returns false immediately (without a callout) when the
* Salesforce integration is disabled.
*/
export const sendMail = async (
recipientAddress: string,
subject: string,
body: string,
options?: SendMailOptions
): Promise<boolean> => {
if (!salesforceEnabled()) {
logger.info('MailService: SALESFORCE_ENABLED is false, would have sent', {recipientAddress, subject});
return false;
}
let attachments: {filename: string; contentType?: string; contentBase64: string}[];
try {
attachments = (options?.attachments ?? []).map(attachment => {
const buffer = Buffer.isBuffer(attachment.content)
? attachment.content
: Buffer.from(attachment.content, 'utf-8');
if (buffer.byteLength > MAX_ATTACHMENT_BYTES) {
throw new Error(`attachment ${attachment.filename} is ${buffer.byteLength} bytes, over the ${MAX_ATTACHMENT_BYTES} limit`);
}
return {filename: attachment.filename, contentType: attachment.contentType, contentBase64: buffer.toString('base64')};
});
} catch (err: any) {
logger.error('MailService: could not prepare attachments', {recipientAddress, subject, detail: err?.message});
return false;
}
const payload = {
to: recipientAddress,
subject,
textBody: body,
htmlBody: options?.html ?? null,
attachments
};
for (let attempt = 1; attempt <= 2; attempt++) {
try {
await salesforceApexRestPost<EmailSendResponse>('/services/apexrest/email/send', payload);
return true;
} catch (err: any) {
const status = err?.response?.status;
const detail = err?.response?.data?.errorCode || err?.response?.data?.message || err?.message || 'unknown error';
if (attempt === 1 && isRetriable(err)) {
logger.warn('MailService: send failed, retrying once', {recipientAddress, subject, status, detail});
continue;
}
logger.error('MailService: send failed', {recipientAddress, subject, status, detail});
return false;
}
}
return false;
};
}
+66
View File
@@ -0,0 +1,66 @@
import axios from 'axios';
// Shared server-to-server access to the one Nachklang Salesforce org. Both the
// newsletter-signup sync (feedback module) and the transactional-email relay
// (common.mail) authenticate the same way - OAuth 2.0 client credentials
// against the nk_Nachklang_API_Integration external client app - so the token
// cache and the retry-once-on-401 live here rather than being duplicated.
//
// Salesforce's client-credentials token response does not reliably include
// expires_in, so the cache lifetime is a conservative guess rather than a
// value read from the response - a 401 on the next call just triggers a fresh
// fetch (see salesforceApexRestPost).
const TOKEN_CACHE_MS = 15 * 60 * 1000;
let cachedToken: {accessToken: string; fetchedAt: number} | null = null;
export const salesforceEnabled = (): boolean => process.env.SALESFORCE_ENABLED === 'true';
const readConfig = (): {instanceUrl: string; clientId: string; clientSecret: string} => {
const instanceUrl = process.env.SALESFORCE_API_URL;
const clientId = process.env.SALESFORCE_CLIENT_ID;
const clientSecret = process.env.SALESFORCE_CLIENT_SECRET;
if (!instanceUrl || !clientId || !clientSecret) {
throw new Error('SALESFORCE_ENABLED is true but SALESFORCE_API_URL/SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET are not fully configured.');
}
return {instanceUrl, clientId, clientSecret};
};
const getAccessToken = async (forceRefresh: boolean): Promise<string> => {
if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) {
return cachedToken.accessToken;
}
const {instanceUrl, clientId, clientSecret} = readConfig();
const res = await axios.post(
`${instanceUrl}/services/oauth2/token`,
new URLSearchParams({grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret}).toString(),
{headers: {'Content-Type': 'application/x-www-form-urlencoded'}, timeout: 10000}
);
cachedToken = {accessToken: res.data.access_token, fetchedAt: Date.now()};
return cachedToken.accessToken;
};
/**
* POSTs a JSON body to an Apex REST path (e.g. '/services/apexrest/newsletter/signup')
* and returns the parsed response body. Retries once with a forced token
* refresh on a 401 - the server-side token may have expired even though our
* conservative local TTL has not. All other errors propagate to the caller.
*/
export const salesforceApexRestPost = async <T>(path: string, body: unknown): Promise<T> => {
const {instanceUrl} = readConfig();
const url = `${instanceUrl}${path}`;
try {
const token = await getAccessToken(false);
const res = await axios.post<T>(url, body, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
} catch (err: any) {
if (err?.response?.status === 401) {
const token = await getAccessToken(true);
const res = await axios.post<T>(url, body, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
}
throw err;
}
};
+5 -5
View File
@@ -1,10 +1,10 @@
import * as appRoot from 'app-root-path'; import appRoot from 'app-root-path';
import * as winston from 'winston'; import winston from 'winston';
const options = { const options = {
file_info: { file_info: {
level: 'info', level: 'info',
filename: `${appRoot}/logs/app.log`, filename: `${appRoot.path}/logs/app.log`,
handleExceptions: true, handleExceptions: true,
json: true, json: true,
maxsize: 5242880, // 5MB maxsize: 5242880, // 5MB
@@ -13,7 +13,7 @@ const options = {
}, },
file_error: { file_error: {
level: 'error', level: 'error',
filename: `${appRoot}/logs/error.log`, filename: `${appRoot.path}/logs/error.log`,
handleExceptions: true, handleExceptions: true,
json: true, json: true,
maxsize: 5242880, // 5MB maxsize: 5242880, // 5MB
@@ -22,7 +22,7 @@ const options = {
}, },
file_debug: { file_debug: {
level: 'debug', level: 'debug',
filename: `${appRoot}/logs/debug.log`, filename: `${appRoot.path}/logs/debug.log`,
handleExceptions: true, handleExceptions: true,
json: true, json: true,
maxsize: 5242880, // 5MB maxsize: 5242880, // 5MB
+55
View File
@@ -0,0 +1,55 @@
import * as dotenv from 'dotenv';
import mysql from 'mysql2';
import {Kysely, MysqlDialect} from 'kysely';
import {AdminDatabase} from './admin.schema.js';
import logger from '../../middleware/logger.js';
dotenv.config();
/**
* The admin module is the one place in this API that does not use the
* `mariadb` driver: better-auth talks to the database through Kysely, whose
* MySQL dialect expects a mysql2 pool. The other domains keep their own
* `mariadb` pools (see Feedback.db.ts) - this is an addition, not a migration.
*
* The pool is the callback-style `mysql2` one, NOT `mysql2/promise`: Kysely's
* MysqlDialect calls `pool.getConnection((err, conn) => ...)`. The promise
* wrapper ignores that callback and returns a Promise instead, so every query
* through Kysely would hang forever with no error - which is exactly what it
* did until the integration tests caught it.
*
* timezone 'Z' matters: better-auth computes session and token expiry in UTC.
* Without it mysql2 would write and read those DATETIMEs in the process's local
* zone, so sessions would expire an hour early or late depending on DST.
*/
export namespace NachklangAdminDB {
export const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.ADMIN_DB,
// The other modules' pools default to 3306. This one is configurable so
// the integration tests can point at a throwaway container on another
// port without touching a developer's real .env.
port: parseInt(process.env.DB_PORT || '3306', 10),
connectionLimit: 5,
timezone: 'Z'
});
// mysql2 emits connection trouble as an event on the pool, not only as a
// rejected query. Without a listener Node turns that into an
// uncaughtException, so a database restart would take the whole API - and
// with it the calendar, feedback and tickets domains - down with it.
// Individual queries still reject, and their callers still answer 500.
pool.on('error', (err: unknown) => {
logger.error('Admin database pool error', {detail: (err as any)?.message});
});
// Handed to better-auth as `database: {dialect, type: 'mysql'}`.
export const dialect = new MysqlDialect({pool});
// Used by this module's own services for the two custom tables and for
// permission lookups that join better-auth's `user`.
export const db = new Kysely<AdminDatabase>({dialect});
}
+48
View File
@@ -0,0 +1,48 @@
import express, {Request, Response} from 'express';
import {requireAppAccess, requireSignedIn} from './admin.middleware.js';
import {usersAdminRouter} from './users/users.admin.router.js';
import {invitationsRouter} from './invitations/invitations.router.js';
/**
* The admin module's JSON routes. Deliberately *not* the better-auth handler:
* that one is mounted separately in app.ts, ahead of express.json(), because it
* needs the raw request body stream.
*
* Mounted at /admin, so the tree is:
* /admin/me any signed-in account
* /admin/users/* admin permission
* /admin/invitations/* admin permission
*/
export const adminRouter = express.Router();
/**
* @swagger
* /admin/me:
* get:
* summary: The current user's identity and app permissions
* description: Used by every frontend to decide what to show. The API remains the real gate.
* tags: [admin]
* responses:
* 200:
* description: Success
* 401:
* description: Not signed in
* 403:
* description: Account disabled
*/
adminRouter.get('/me', requireSignedIn, (req: Request, res: Response) => {
res.status(200).send({
id: res.locals.admin.id,
email: res.locals.admin.email,
fullName: res.locals.admin.displayName,
// `permissions` is the full (app, role) truth; `apps` is the distinct
// apps within it. Both are sent because the three frontends only ever ask
// "may I show this app?", and keeping `apps` means a finer permission can
// land here without a coordinated deploy of all of them.
permissions: res.locals.admin.permissions,
apps: res.locals.admin.apps
});
});
adminRouter.use('/users', requireAppAccess('admin'), usersAdminRouter);
adminRouter.use('/invitations', requireAppAccess('admin'), invitationsRouter);
+166
View File
@@ -0,0 +1,166 @@
import {betterAuth} from 'better-auth';
import {APIError} from 'better-auth/api';
import {passkey, getAuthenticatorName} from '@better-auth/passkey';
import {NachklangAdminDB} from './Admin.db.js';
import {invitationsPlugin} from './invitations/invitations.plugin.js';
import {sendPasswordResetMail} from './admin.mail.js';
import * as UsersService from './users/users.admin.service.js';
import logger from '../../middleware/logger.js';
import {
ADMIN_ALLOWED_ORIGINS,
API_BASE_URL,
BETTER_AUTH_SECRET,
CLIENT_IP_HEADERS,
PASSKEY_RP_ID,
TRUSTED_PROXY_IPS,
isProd
} from './admin.config.js';
/**
* The single better-auth instance for all *.nachklang.art apps. Mounted in
* app.ts at /admin/auth/* with better-auth's own node handler, ahead of
* express.json() (it needs the raw body stream).
*
* The session cookie is what every app trusts. Everything else in this module -
* permissions, invitations, the admin UI - hangs off it.
*/
const DAY = 60 * 60 * 24;
// Dev runs the apps on plain localhost ports; cookies ignore the port, so
// single-sign-on across them works without fake subdomains or mkcert.
const localhostOrigins = [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:3002',
'http://localhost:3003',
// The Angular calendar frontend; `ng serve` defaults to 4200. Missing from
// this list, sign-out from the calendar answers 403 in dev only, which is a
// confusing thing to debug against a production config that is fine.
'http://localhost:4200'
];
const trustedOrigins = isProd
? ADMIN_ALLOWED_ORIGINS
: Array.from(new Set([...ADMIN_ALLOWED_ORIGINS, ...localhostOrigins]));
export const auth = betterAuth({
appName: 'Nachklang',
database: {
dialect: NachklangAdminDB.dialect,
type: 'mysql'
},
basePath: '/admin/auth',
// Mandatory once crossSubDomainCookies is on: better-auth derives the
// cookie domain and its own absolute URLs from this.
baseURL: API_BASE_URL,
secret: BETTER_AUTH_SECRET,
trustedOrigins,
emailAndPassword: {
enabled: true,
// There is no public sign-up: accounts exist only through an
// invitation (see invitations.plugin.ts). This also makes
// auth.api.signUpEmail throw, which is intended.
disableSignUp: true,
sendResetPassword: async ({user, url}) => {
await sendPasswordResetMail(user.email, user.name, url);
}
},
user: {
additionalFields: {
// Not `returned`, and not settable through the API: disabling is an
// admin action on /admin/users/:id/disable, never something a
// session owner can flip on themselves.
disabled: {
type: 'boolean',
defaultValue: false,
input: false,
returned: false
}
}
},
session: {
expiresIn: 30 * DAY,
updateAge: DAY
// Deliberately no cookieCache: requireAppAccess hits the database on
// every request anyway, and a cached session would keep a disabled
// user or a revoked session alive for the cache's lifetime.
},
advanced: {
// Fixes the cookie name across releases so the frontends' middleware can
// check for it: "nachklang.session_token", or
// "__Secure-nachklang.session_token" over https.
cookiePrefix: 'nachklang',
crossSubDomainCookies: isProd
? {enabled: true, domain: '.nachklang.art'}
: {enabled: false},
ipAddress: {
// better-auth reads the request itself and does not know about
// Express's `trust proxy`, so both the header and the trusted hops
// have to be named here. Getting this wrong does not fail loudly -
// it collapses every client into one rate-limit bucket. See the
// commentary on CLIENT_IP_HEADERS in admin.config.ts.
ipAddressHeaders: CLIENT_IP_HEADERS,
...(TRUSTED_PROXY_IPS.length > 0 ? {trustedProxies: TRUSTED_PROXY_IPS} : {})
}
},
rateLimit: {
enabled: true,
// Passenger may run more than one instance; an in-memory limiter would
// then give each of them its own budget.
storage: 'database'
},
plugins: [
passkey({
rpID: PASSKEY_RP_ID,
rpName: 'Nachklang',
origin: ADMIN_ALLOWED_ORIGINS,
registration: {
// Without this, every passkey is stored with name = NULL and the
// account page can only label them all "Passkey" - useless at the
// one moment that list matters, when someone has to remove the
// passkey on the device they just lost.
//
// The AAGUID identifies the authenticator *model* (not a device
// and not a person), and better-auth ships the lookup table, so
// this yields "1Password", "iCloud Keychain", "Windows Hello".
// It only fills a blank: a name the client sent always wins, and
// an unknown AAGUID leaves the column NULL as before.
afterVerification: async ({verification}) => {
const name = getAuthenticatorName(verification.registrationInfo?.aaguid);
return name ? {name} : undefined;
}
}
}),
invitationsPlugin()
],
databaseHooks: {
session: {
create: {
before: async session => {
const access = await UsersService.loadAccess(session.userId);
if (access?.disabled) {
logger.warn('Admin: sign-in attempt by a disabled account', {userId: session.userId});
// Throwing rather than returning false: `false` aborts
// the session write silently and the caller sees a
// confusing success-shaped response with no cookie.
throw new APIError('FORBIDDEN', {
code: 'ACCOUNT_DISABLED',
message: 'Dieses Konto ist deaktiviert.'
});
}
}
}
}
}
});
export type AdminAuth = typeof auth;
+68
View File
@@ -0,0 +1,68 @@
import * as UsersService from './users/users.admin.service.js';
import * as InvitationsService from './invitations/invitations.service.js';
import {sendInvitationMail} from './admin.mail.js';
import {ACCESS_ROLE} from './admin.schema.js';
import {ADMIN_APP_URL, ADMIN_BOOTSTRAP_EMAIL, LOG_INVITE_LINKS} from './admin.config.js';
import logger from '../../middleware/logger.js';
/**
* Solves the empty-database problem: with invite-only accounts and no public
* sign-up, a fresh nachklang_admin has nobody who can invite anybody. Rather
* than a CLI script somebody has to remember to run against production, the API
* makes sure on every start that ADMIN_BOOTSTRAP_EMAIL can get in.
*
* Idempotent by design - it is safe on every restart:
* - an active admin already exists -> do nothing
* - the address exists as a user -> grant it `admin`
* - an open invitation exists -> do nothing (do not re-mail on restart)
* - otherwise -> invite, and mail the link
*
* Never throws: a database blip at boot must not stop the API from serving the
* calendar, feedback and tickets domains.
*/
export const bootstrapAdmin = async (): Promise<void> => {
try {
if (!ADMIN_BOOTSTRAP_EMAIL) {
return;
}
const email = ADMIN_BOOTSTRAP_EMAIL.trim().toLowerCase();
if ((await UsersService.countActiveAdmins()) > 0) {
return;
}
const existing = await UsersService.findUserByEmail(email);
if (existing) {
await UsersService.grantPermission(existing.id, 'admin', null);
logger.info('Admin bootstrap: granted the admin permission to the existing bootstrap user', {email});
return;
}
// An expired invitation is not "open", so the next restart re-issues
// one - which is the recovery path if the first mail never arrived.
if (await InvitationsService.hasOpenInvitationFor(email)) {
logger.info('Admin bootstrap: an open invitation already exists', {email});
return;
}
const invitation = await InvitationsService.createInvitation(
email,
'Nachklang Admin',
[{app: 'admin', role: ACCESS_ROLE}],
null
);
const mailed = await sendInvitationMail(email, 'Nachklang Admin', invitation.token, invitation.expiresAt);
logger.info('Admin bootstrap: invitation created', {email, mailed});
// With the mail relay off, the logged link is how a local setup gets its
// first admin. Explicit opt-in (see LOG_INVITE_LINKS): the link is a
// live credential, so this must never depend on NODE_ENV alone.
if (LOG_INVITE_LINKS) {
logger.info(`Admin bootstrap: ${ADMIN_APP_URL}/accept-invite?token=${invitation.token}`);
}
} catch (e: any) {
logger.error('Admin bootstrap failed', {detail: e?.message});
}
};
+191
View File
@@ -0,0 +1,191 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
import logger from '../../middleware/logger.js';
dotenv.config();
/**
* One place that reads the admin module's environment. Both admin.auth.ts
* (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the
* same origin list, and a second parser would drift from this one.
*
* Read this before changing the environment handling below: several security
* properties depend on it, and they are deliberately arranged to fail *safe*.
*
* `NODE_ENV` is opt-in to relaxed behaviour, not opt-in to strict behaviour.
* Only the explicit values 'development' and 'test' relax anything; anything
* else - including NODE_ENV being unset, which is exactly what a fresh Plesk
* vhost gives you - is treated as production. The inverse arrangement is a
* trap: it degrades the cookie domain, the CORS origin list and the signing
* key all at once, and every one of those failures is silent.
*
* The signing key is never allowed to be a known constant. In dev, an unset
* BETTER_AUTH_SECRET becomes a random per-process value: sessions do not
* survive a restart, which is mildly annoying and much better than a default
* secret that can be copied out of this file and used against production.
*/
const nodeEnv = process.env.NODE_ENV;
// Explicitly relaxed environments. Everything else, unset included, is strict.
const isRelaxedEnv = nodeEnv === 'development' || nodeEnv === 'test';
export const isProd = !isRelaxedEnv;
const required = (name: string, devDefault: string): string => {
const value = process.env[name];
if (value) {
return value;
}
if (isProd) {
logger.error(
`Admin module: ${name} is not set (NODE_ENV=${nodeEnv ?? 'unset'}, so strict mode applies; ` +
'set NODE_ENV=development for local work)'
);
throw new Error(`${name} must be set unless NODE_ENV is development or test`);
}
return devDefault;
};
export const API_BASE_URL = required('API_BASE_URL', 'http://localhost:3000');
export const ADMIN_APP_URL = required('ADMIN_APP_URL', 'http://localhost:3002');
// 32+ random bytes; better-auth signs cookies and reset tokens with it.
// Rotating it invalidates every session, which is why it is not derived.
// There is no hardcoded fallback on purpose: a constant committed here would
// be a published signing key the moment someone deploys without setting it.
export const BETTER_AUTH_SECRET = required(
'BETTER_AUTH_SECRET',
crypto.randomBytes(48).toString('base64')
);
// Passkeys are bound to this: a credential registered for "nachklang.art"
// works on every *.nachklang.art host, one registered for "localhost" only
// works in dev. Changing it invalidates every registered passkey.
export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost');
const parseList = (value: string | undefined, fallback: string[]): string[] => {
const parsed = (value || '')
.split(',')
.map(entry => entry.trim())
.filter(entry => entry.length > 0);
return parsed.length > 0 ? parsed : fallback;
};
/**
* The apps whose frontends may talk to /admin/* with credentials.
*
* They feed better-auth's `trustedOrigins`, which is what lets the tickets and
* feedback admin areas call /admin/auth/sign-out from their own origin. That
* became load-bearing with the step 4 cutover: before it, the only browser
* origin that ever reached /admin/auth was the admin app itself. The calendar
* joined them with its own cutover (docs/calendar-auth-migration.md step 4).
*
* Hence the production default rather than an empty list. An origin missing
* here fails in a way that is easy to misread - sign-in works, the app works,
* and only sign-out returns an origin error - so the two frontends we know
* about are named here and APP_ORIGINS overrides them for a staging host.
* Dev adds the localhost ports separately (see admin.auth.ts).
*/
const DEFAULT_APP_ORIGINS = [
'https://tickets.nachklang.art',
'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
];
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, DEFAULT_APP_ORIGINS)
.map(origin => origin.replace(/\/$/, ''));
// Kept in sync by construction rather than by three separate lists: the admin
// app itself always counts, and dev adds the local ports.
export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
ADMIN_APP_URL.replace(/\/$/, ''),
...APP_ORIGINS
]));
/**
* The header the reverse proxy puts the real client IP in, and the proxy hops
* to trust when reading it.
*
* This matters more than it looks. better-auth does not know about Express's
* `trust proxy`; it reads the request itself. If it cannot resolve a client IP
* it falls back to a single shared bucket ("no-trusted-ip") for the whole
* process - and /sign-in/* carries a default of 3 requests per 10 seconds, so
* one noisy client would lock every user out of every app.
*
* Without TRUSTED_PROXY_IPS, better-auth rejects a multi-value
* x-forwarded-for outright (it cannot tell which hop is the client), which is
* exactly the case that produces that shared bucket. Set it to the address or
* CIDR of Plesk's nginx. Conversely, listing a header the proxy does not
* overwrite lets a client set its own IP and mint itself an unlimited
* brute-force budget - so the default is the single header nginx sets, not a
* permissive list.
*/
/**
* `CLIENT_IP_HEADERS=none` trusts no header at all.
*
* This is the escape hatch for the one case where the wrong setting is worse
* than no setting: if the proxy turns out NOT to overwrite the header we are
* trusting, any client can send it and mint itself an unlimited brute-force
* budget against /sign-in. Falling back to the shared bucket is bad (one noisy
* client can lock the organisation out for ten seconds at a time) but it is
* bad in a way that fails closed, and it can be reverted from the environment
* without a deploy.
*
* Reach for it only after a check has actually failed - `SELECT ipAddress FROM
* session ORDER BY createdAt DESC` showing 127.0.0.1 or NULL for a real remote
* sign-in - and take it back out once the header is configured.
*
* An empty or unset value still means "use the default", not "trust nothing":
* a stray blank line in a .env must not silently change how requests are
* bucketed. Only the explicit word does that.
*/
const TRUST_NO_HEADER = 'none';
export const TRUST_NO_CLIENT_IP_HEADER =
(process.env.CLIENT_IP_HEADERS || '').trim().toLowerCase() === TRUST_NO_HEADER;
// An empty array is what better-auth reads as "no headers": it only falls back
// to its own default when the option is absent, and `[]` is truthy.
export const CLIENT_IP_HEADERS = TRUST_NO_CLIENT_IP_HEADER
? []
: parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']);
export const TRUSTED_PROXY_IPS = parseList(process.env.TRUSTED_PROXY_IPS, []);
if (isProd && TRUST_NO_CLIENT_IP_HEADER) {
logger.warn(
'Admin module: CLIENT_IP_HEADERS=none - no client-IP header is trusted, so every ' +
'request shares one rate-limit bucket and /sign-in allows 3 attempts per 10 seconds ' +
'for everyone combined. This is the safe fallback, not a destination: configure the ' +
'header the proxy actually sets and remove it.'
);
} else if (isProd && TRUSTED_PROXY_IPS.length === 0) {
logger.warn(
'Admin module: TRUSTED_PROXY_IPS is not set. If the proxy sends a multi-value ' +
`${CLIENT_IP_HEADERS.join('/')}, better-auth cannot resolve a client IP and every ` +
'request shares one rate-limit bucket. Verify with: SELECT `key` FROM rateLimit - ' +
'a "no-trusted-ip" row means this is happening. A single-value header needs no ' +
'trusted proxies, so this warning is expected on a plain single-proxy setup.'
);
}
export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || '';
/**
* Whether to write invitation links to the log. An invitation link is a live
* account-creation credential, so this is an explicit opt-in rather than
* something inferred from NODE_ENV: local work needs it (the mail relay is
* usually off, and only the token's hash is stored, so there is otherwise no
* way to walk the accept flow), and production must never have it.
*
* Refused outright in strict mode, so setting it in a production .env by
* accident fails at boot instead of quietly filling the log with credentials.
*/
export const LOG_INVITE_LINKS = process.env.ADMIN_LOG_INVITE_LINKS === 'true' && !isProd;
if (process.env.ADMIN_LOG_INVITE_LINKS === 'true' && isProd) {
logger.error('Admin module: ADMIN_LOG_INVITE_LINKS is set outside development - refusing to log invitation tokens');
}
+17
View File
@@ -0,0 +1,17 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
/**
* Same catch-block convention as the feedback and tickets modules: log with a
* reference guid, never hand the real error message to the client.
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
+120
View File
@@ -0,0 +1,120 @@
/**
* Swagger component definitions for the admin module. Picked up by
* swagger-jsdoc through the `src/models/**\/*.interface.ts` glob in
* app.factory.ts.
*
* Note what is *not* documented here: the better-auth routes under
* /admin/auth/* (sign-in, sign-out, reset-password, passkey ceremonies, and
* the invitation preview/accept endpoints). better-auth owns those paths and
* their shapes; duplicating them by hand would only drift on the next upgrade.
*/
/**
* @swagger
* components:
* securitySchemes:
* AdminSessionCookie:
* type: apiKey
* in: cookie
* name: nachklang.session_token
* description: >
* Set by /admin/auth/sign-in/email. Over https the name is
* __Secure-nachklang.session_token and the cookie is scoped to
* .nachklang.art, so one sign-in covers every *.nachklang.art app.
* schemas:
* AdminApp:
* type: string
* enum: [calendar, feedback, tickets, admin]
* description: Holding "admin" is what allows managing users and invitations.
* AdminMe:
* type: object
* properties:
* id:
* type: string
* email:
* type: string
* fullName:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* AdminUserSession:
* type: object
* properties:
* id:
* type: string
* createdAt:
* type: string
* format: date-time
* expiresAt:
* type: string
* format: date-time
* ipAddress:
* type: string
* nullable: true
* userAgent:
* type: string
* nullable: true
* AdminUser:
* type: object
* properties:
* id:
* type: string
* email:
* type: string
* name:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* status:
* type: string
* enum: [aktiv, deaktiviert]
* description: Derived - there is no status column.
* createdAt:
* type: string
* format: date-time
* lastSignInAt:
* type: string
* format: date-time
* nullable: true
* description: Newest session's creation time; null once every session has expired.
* AdminUserDetail:
* allOf:
* - $ref: '#/components/schemas/AdminUser'
* - type: object
* properties:
* sessions:
* type: array
* items:
* $ref: '#/components/schemas/AdminUserSession'
* passkeyCount:
* type: integer
* AdminInvitation:
* type: object
* description: An open invitation. The token itself is never returned by any endpoint.
* properties:
* id:
* type: integer
* email:
* type: string
* name:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* invitedBy:
* type: string
* nullable: true
* description: Null for the invitation created by the ADMIN_BOOTSTRAP_EMAIL bootstrap.
* createdAt:
* type: string
* format: date-time
* expiresAt:
* type: string
* format: date-time
*/
export {};
+124
View File
@@ -0,0 +1,124 @@
import {MailService} from '../../common/common.mail.js';
import {ADMIN_APP_URL} from './admin.config.js';
/**
* The two transactional mails the admin module sends. Both go out through the
* shared MailService (Salesforce relay, see common.mail.ts), which never throws
* on a delivery failure - the invitation row and the reset token are already
* committed by the time we get here.
*
* HTML plus a plain-text body: the text part is not a fallback afterthought,
* it is what allowlist-based receivers and text-only clients actually show.
*/
const escapeHtml = (value: string): string => {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
// `heading` is escaped here; `paragraphs` are not, because callers pass markup
// (a <strong> around the expiry date) and escape their own interpolations.
const layout = (heading: string, paragraphs: string[], buttonLabel: string, buttonUrl: string): string => {
const body = paragraphs.map(p => `<p style="margin:0 0 16px;">${p}</p>`).join('');
return `<!doctype html>
<html lang="de">
<body style="margin:0;padding:24px;background:#f5f5f4;font-family:Helvetica,Arial,sans-serif;color:#1c1917;">
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
<h1 style="margin:0 0 24px;font-size:20px;">${escapeHtml(heading)}</h1>
${body}
<p style="margin:24px 0;">
<a href="${escapeHtml(buttonUrl)}" style="display:inline-block;background:#1c1917;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:6px;">${escapeHtml(buttonLabel)}</a>
</p>
<p style="margin:0;font-size:13px;color:#57534e;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<span style="word-break:break-all;">${escapeHtml(buttonUrl)}</span></p>
</div>
</body>
</html>`;
};
/**
* Invitation mail. The link carries the raw token in the query string; the
* admin app strips it from the URL as soon as it has read it (see the plan's
* §3b - the token must never reach an API access log or a Referer header).
*/
export const sendInvitationMail = async (
recipientAddress: string,
name: string,
token: string,
expiresAt: Date
): Promise<boolean> => {
const url = `${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`;
const expiry = expiresAt.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'});
const subject = 'Dein Zugang zu Nachklang';
const text = [
`Hallo ${name},`,
'',
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über diesen Link vergibst du dein Passwort:',
'',
url,
'',
`Der Link ist bis zum ${expiry} gültig.`,
'',
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.',
'',
'Viele Grüße',
'Nachklang e.V.'
].join('\n');
const html = layout(
`Hallo ${name},`,
[
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über den Button vergibst du dein Passwort.',
`Der Link ist bis zum <strong>${escapeHtml(expiry)}</strong> gültig.`,
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.'
],
'Konto einrichten',
url
);
return MailService.sendMail(recipientAddress, subject, text, {html});
};
/**
* Password reset. better-auth builds the URL (it embeds its own token and the
* redirectTo the admin app passed), so this only wraps it in our templates.
*/
export const sendPasswordResetMail = async (
recipientAddress: string,
name: string,
url: string
): Promise<boolean> => {
const subject = 'Passwort zurücksetzen';
const text = [
`Hallo ${name},`,
'',
'über diesen Link kannst du ein neues Passwort vergeben:',
'',
url,
'',
'Der Link ist eine Stunde gültig.',
'',
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.',
'',
'Viele Grüße',
'Nachklang e.V.'
].join('\n');
const html = layout(
`Hallo ${name},`,
[
'über den Button kannst du ein neues Passwort vergeben.',
'Der Link ist eine Stunde gültig.',
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.'
],
'Neues Passwort vergeben',
url
);
return MailService.sendMail(recipientAddress, subject, text, {html});
};
+140
View File
@@ -0,0 +1,140 @@
import express from 'express';
import {fromNodeHeaders} from 'better-auth/node';
import {auth} from './admin.auth.js';
import * as UsersService from './users/users.admin.service.js';
import {AppName, AppPermission, AppRole} from './admin.schema.js';
import {sendServerError} from './admin.errors.js';
/**
* The one authenticator for every admin area in this API. It replaces
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
* header-session check against the calendar users table.
*
* Two things are checked on every request, deliberately without any caching:
* that the session cookie is valid (better-auth), and that the user is still
* enabled and still holds the permission for this app (one database query).
* That is what makes "disable a user" and "revoke a session" take effect
* immediately rather than whenever a cached session happens to expire.
*/
// The shape the feedback and tickets services already expect - unchanged, so
// nothing downstream of the authenticator needs to know this file replaced
// their own.
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
export interface AdminAccess extends AdminIdentity {
disabled: boolean;
/** Every (app, role) grant. */
permissions: AppPermission[];
/** The distinct apps those grants cover. */
apps: AppName[];
}
const unauthorized = (res: express.Response): void => {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
};
const forbidden = (res: express.Response, message: string): void => {
res.status(403).send({status: 'FORBIDDEN', message});
};
/**
* Resolves the session cookie to a user with their permissions, or null.
* One database query, no cache. Throws only on infrastructure errors.
*/
export const resolveAccess = async (req: express.Request): Promise<AdminAccess | null> => {
const session = await auth.api.getSession({headers: fromNodeHeaders(req.headers)});
if (!session?.user) {
return null;
}
const access = await UsersService.loadAccess(session.user.id);
if (!access) {
return null;
}
return {
id: access.id,
email: access.email,
displayName: access.displayName,
disabled: access.disabled,
permissions: access.permissions,
apps: access.apps
};
};
/**
* Any signed-in account, no permission required. Used by /admin/me and the
* account-management routes: a user with no app permissions at all still has
* to be able to see that, and to manage their own password and passkeys.
*
* The disabled check is not redundant with the session-create hook: that hook
* stops a disabled user from signing in, this stops one who was disabled while
* holding a live cookie. Disabling revokes sessions, so the window is small -
* but "small" is not "closed".
*/
export const requireSignedIn: express.RequestHandler = async (req, res, next) => {
try {
const access = await resolveAccess(req);
if (!access) {
unauthorized(res);
return;
}
if (access.disabled) {
forbidden(res, 'Dieses Konto ist deaktiviert.');
return;
}
res.locals.admin = access;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
/**
* The gate every admin area sits behind. `requireAppAccess('feedback')` is
* what feedback.auth.ts's requireAdminAuth used to be, except that it now
* answers 403 for a signed-in user without that app's permission instead of
* letting any activated @nachklang.art account in.
*
* The optional second argument narrows it to one role within the app. Nothing
* passes it today - every app has exactly the `access` role - but it is the
* seam a finer permission arrives through.
*/
export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestHandler => {
return async (req, res, next) => {
try {
const access = await resolveAccess(req);
if (!access) {
unauthorized(res);
return;
}
if (access.disabled) {
forbidden(res, 'Dieses Konto ist deaktiviert.');
return;
}
// Without a role this asks "may they open this app at all?", which is
// any grant on it. With one it asks for that specific grant - the hook
// a finer permission plugs into, without touching existing call sites.
const allowed = role === undefined
? access.apps.includes(app)
: access.permissions.some(permission => permission.app === app && permission.role === role);
if (!allowed) {
forbidden(res, 'Für diesen Bereich fehlt dir die Berechtigung.');
return;
}
res.locals.admin = access;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
};
+179
View File
@@ -0,0 +1,179 @@
import {Generated} from 'kysely';
/**
* Kysely table types for `nachklang_admin`. Only the columns this module
* actually reads or writes are declared - better-auth owns the full shape of
* its own tables and does not use this interface, it is here so the users and
* invitations services get compile-time checking instead of `any`.
*
* Column names follow better-auth's default "camel" casing for its tables
* (`emailVerified`, `userId`, `createdAt`); our own two tables use the
* snake_case convention of the rest of the repo's SQL.
*/
export type AppName = 'calendar' | 'feedback' | 'tickets' | 'admin';
export const APP_NAMES: AppName[] = ['calendar', 'feedback', 'tickets', 'admin'];
export const isAppName = (value: unknown): value is AppName => {
return typeof value === 'string' && (APP_NAMES as string[]).includes(value);
};
/**
* A permission is (app, role), not just an app. Today every app has exactly one
* role - `access`, "may use this app at all" - so the model looks like a plain
* list of apps and the UI renders one checkbox each. It is written this way
* anyway because the alternative gets expensive fast: `user_app_permissions`
* has primary key (user_id, app, role), so a user can hold several roles for
* the same app, and adding one later is a string in APP_ROLES plus rows - never
* a schema migration and never a change to the shape on the wire.
*
* Note the role is deliberately NOT called `admin`, which is what the column
* defaulted to before: on a `tickets` row that reads as "tickets administrator"
* when it only ever meant "has access", and once real roles exist there would
* be no way to tell the two apart.
*/
export const ACCESS_ROLE = 'access';
export type AppRole = string;
/** Every role that exists, per app, in display order. Extend to add one. */
export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
calendar: [ACCESS_ROLE],
feedback: [ACCESS_ROLE],
tickets: [ACCESS_ROLE],
admin: [ACCESS_ROLE]
};
export interface AppPermission {
app: AppName;
role: AppRole;
}
export const isAppRole = (app: AppName, role: unknown): role is AppRole => {
return typeof role === 'string' && APP_ROLES[app].includes(role);
};
export const isAppPermission = (value: unknown): value is AppPermission => {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as {app?: unknown; role?: unknown};
return isAppName(candidate.app) && isAppRole(candidate.app, candidate.role);
};
/**
* Normalises whatever a caller sent into a valid, duplicate-free permission
* list. Accepts the richer `{app, role}` form and the plain `AppName` form,
* because `{apps: ['tickets']}` is still what the older callers send and it
* means exactly "tickets at the access role".
*/
export const toPermissions = (value: unknown): AppPermission[] | null => {
if (!Array.isArray(value)) {
return null;
}
const permissions: AppPermission[] = [];
for (const entry of value) {
if (isAppName(entry)) {
permissions.push({app: entry, role: ACCESS_ROLE});
} else if (isAppPermission(entry)) {
permissions.push({app: entry.app, role: entry.role});
} else {
return null;
}
}
const seen = new Set<string>();
return permissions.filter(permission => {
const key = `${permission.app}:${permission.role}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
/** The distinct apps a permission list grants any access to. */
export const appsOf = (permissions: AppPermission[]): AppName[] => {
return APP_NAMES.filter(app => permissions.some(permission => permission.app === app));
};
export interface UserTable {
id: string;
name: string;
email: string;
emailVerified: boolean;
image: string | null;
createdAt: Date;
updatedAt: Date;
// Added via better-auth `additionalFields` (see admin.auth.ts).
disabled: boolean;
}
export interface SessionTable {
id: string;
token: string;
userId: string;
expiresAt: Date;
createdAt: Date;
updatedAt: Date;
ipAddress: string | null;
userAgent: string | null;
}
export interface PasskeyTable {
id: string;
name: string | null;
userId: string;
createdAt: Date;
}
export interface UserAppPermissionTable {
user_id: string;
app: AppName;
role: AppRole;
granted_by: string | null;
granted_at: Generated<Date>;
}
export interface InvitationTable {
// AUTO_INCREMENT: present on select, never supplied on insert.
id: Generated<number>;
email: string;
name: string;
token_hash: string;
// JSON column holding an AppPermission[]. Older rows may hold a plain
// AppName[]; `parsePermissions` reads both.
permissions: string;
invited_by: string | null;
created_at: Generated<Date>;
expires_at: Date;
accepted_at: Date | null;
revoked_at: Date | null;
}
export interface VerificationTable {
id: string;
identifier: string;
value: string;
expiresAt: Date;
}
export interface RateLimitTable {
id: string;
key: string;
count: number;
lastRequest: number;
}
export interface AdminDatabase {
user: UserTable;
session: SessionTable;
passkey: PasskeyTable;
verification: VerificationTable;
rateLimit: RateLimitTable;
user_app_permissions: UserAppPermissionTable;
invitations: InvitationTable;
}
@@ -0,0 +1,196 @@
import * as z from 'zod';
import {APIError, createAuthEndpoint} from 'better-auth/api';
import {setSessionCookie} from 'better-auth/cookies';
import {createLocalAccountIssuer} from 'better-auth/db';
import {runWithTransaction} from '@better-auth/core/context';
import type {BetterAuthPlugin} from 'better-auth';
import * as InvitationsService from './invitations.service.js';
import * as UsersService from '../users/users.admin.service.js';
import logger from '../../../middleware/logger.js';
/**
* The two public endpoints of the invitation flow, implemented as a better-auth
* plugin rather than as plain Express routes on the admin router.
*
* Why a plugin: `emailAndPassword.disableSignUp` is on, which makes
* `auth.api.signUpEmail` refuse - deliberately, there is no public sign-up.
* Accepting an invitation still has to create a user, hash a password, write a
* credential account and sign the person in. All four are better-auth
* internals reachable only from inside an endpoint's context, so this is where
* account creation lives. Nothing outside this file may create users.
*
* Because they are plugin endpoints they sit under better-auth's basePath:
* POST /admin/auth/invitations/preview
* POST /admin/auth/invitations/accept
*
* The token travels in the request *body*, never in the path or query, so it
* cannot end up in an access log or a Referer header.
*/
// Unknown, expired, revoked and already-accepted tokens must be
// indistinguishable to the caller: one shared error, one shared message.
const invalidToken = (): APIError => {
return new APIError('BAD_REQUEST', {
code: 'INVALID_INVITATION',
message: 'Diese Einladung ist nicht mehr gültig.'
});
};
export const invitationsPlugin = () => {
return {
id: 'nachklang-invitations',
endpoints: {
/**
* Lets the accept-invite page show who the invitation is for before
* asking for a password. Returns only name and email - never the
* granted apps, which is information the invitee has no need for
* and an attacker with a stolen link should not get either.
*/
previewInvitation: createAuthEndpoint(
'/invitations/preview',
{
method: 'POST',
body: z.object({
token: z.string().min(1)
})
},
async ctx => {
const invitation = await InvitationsService.findByToken(ctx.body.token);
if (!invitation) {
throw invalidToken();
}
return ctx.json({email: invitation.email, name: invitation.name});
}
),
/**
* Redeems the invitation: creates the user, its credential account
* and its permissions, then signs the person straight in so they
* land in the app instead of on a login form.
*/
acceptInvitation: createAuthEndpoint(
'/invitations/accept',
{
method: 'POST',
body: z.object({
token: z.string().min(1),
password: z.string().min(8).max(128)
})
},
async ctx => {
const invitation = await InvitationsService.findByToken(ctx.body.token);
if (!invitation) {
throw invalidToken();
}
// An account for this address already exists: the right fix
// is for an admin to grant permissions on the existing user,
// not to create a second one. Reported distinctly because
// the person holds a valid token - this leaks nothing they
// do not already know about their own mailbox.
const existing = await UsersService.findUserByEmail(invitation.email);
if (existing) {
throw new APIError('CONFLICT', {
code: 'USER_ALREADY_EXISTS',
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Melde dich stattdessen an.'
});
}
// Claim the invitation before creating anything. The update
// is conditional on it still being open, so two concurrent
// submissions of the same link cannot both end up creating a
// user.
const claimed = await InvitationsService.markAccepted(invitation.id);
if (!claimed) {
throw invalidToken();
}
// The user and its credential account go in one better-auth
// transaction, the way better-auth's own sign-up route does
// it: a half-created account with no password is not
// recoverable through any route this API exposes.
//
// It cannot cover everything, though. `user_app_permissions`
// and `invitations` are written through this module's own
// Kysely pool, which is a different connection, so no single
// transaction spans both. What follows is therefore
// compensated by hand rather than rolled back.
let user: Awaited<ReturnType<typeof ctx.context.internalAdapter.createUser>> | null = null;
try {
user = await runWithTransaction(ctx.context.adapter, async () => {
const created = await ctx.context.internalAdapter.createUser(
{
email: invitation.email,
name: invitation.name,
// Accepting a link sent to that mailbox *is*
// the proof of address ownership, so there is
// no separate verification mail (plan
// decision 15).
emailVerified: true,
disabled: false
},
{method: 'email-password'}
);
// Same call better-auth's own sign-up route makes,
// down to the synthetic issuer - a credential account
// written any other way is not found on sign-in.
await ctx.context.internalAdapter.linkAccount({
userId: created.id,
providerId: 'credential',
issuer: createLocalAccountIssuer('credential'),
accountId: created.id,
password: await ctx.context.password.hash(ctx.body.password)
});
return created;
});
await UsersService.setPermissions(user.id, invitation.permissions, null);
const session = await ctx.context.internalAdapter.createSession(user.id);
await setSessionCookie(ctx, {session, user});
return ctx.json({
user: {id: user.id, email: user.email, name: user.name}
});
} catch (e: any) {
// Undo what committed, so the invitee can use their link
// again instead of being stranded with a burnt token, an
// account they cannot sign into, and an admin who cannot
// re-invite them (the create route 409s on an existing
// user, and there is no delete route by design).
//
// Deleting the user is safe here: it was created moments
// ago in this request, and acceptance already established
// that no account for this address existed before.
try {
if (user) {
await ctx.context.internalAdapter.deleteUser(user.id);
}
await InvitationsService.unmarkAccepted(invitation.id);
} catch (compensationError: any) {
// Now the state really is inconsistent, and only a
// human can sort it out. Say so loudly and precisely.
logger.error('Admin: invitation acceptance failed AND its rollback failed', {
invitationId: invitation.id,
email: invitation.email,
userId: user?.id,
detail: e?.message,
compensationDetail: compensationError?.message
});
throw e;
}
logger.error('Admin: invitation acceptance failed and was rolled back', {
invitationId: invitation.id,
detail: e?.message
});
throw e;
}
}
)
}
} satisfies BetterAuthPlugin;
};
@@ -0,0 +1,208 @@
import express, {Request, Response} from 'express';
import * as InvitationsService from './invitations.service.js';
import * as UsersService from '../users/users.admin.service.js';
import {toPermissions} from '../admin.schema.js';
import {sendInvitationMail} from '../admin.mail.js';
import {ADMIN_APP_URL, LOG_INVITE_LINKS} from '../admin.config.js';
import {sendServerError} from '../admin.errors.js';
import logger from '../../../middleware/logger.js';
export const invitationsRouter = express.Router();
/**
* The admin-facing half of invitations (create, resend, revoke). The public
* half - preview and accept - lives in invitations.plugin.ts, because
* redeeming an invitation has to create a user through better-auth internals.
*
* Mounted behind requireAppAccess('admin').
*/
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* With the mail relay off (the normal local setup) the invitation mail never
* arrives, and only the token's hash is stored, so there would be no way to
* walk through the accept flow. Logging the link closes that.
*
* Gated on an explicit opt-in rather than on NODE_ENV: the link is a live
* account-creation credential, and "not production" is too weak a condition to
* hang that on. See LOG_INVITE_LINKS in admin.config.ts.
*/
const logInviteLinkInDev = (token: string): void => {
if (LOG_INVITE_LINKS) {
logger.info(`Admin: invitation link ${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`);
}
};
/**
* @swagger
* /admin/invitations:
* get:
* summary: List open (unaccepted, unrevoked, unexpired) invitations
* tags: [admin]
* responses:
* 200:
* description: Success
*/
invitationsRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send(await InvitationsService.listOpenInvitations());
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/invitations:
* post:
* summary: Invite someone and mail them an acceptance link
* tags: [admin]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, name, permissions]
* properties:
* email:
* type: string
* name:
* type: string
* permissions:
* type: array
* description: >
* One entry per (app, role). A plain array of app names is
* accepted too and means the same at the `access` role.
* items:
* type: object
* properties:
* app:
* type: string
* role:
* type: string
* responses:
* 201:
* description: Invitation created and mailed
* 400:
* description: Invalid input
* 409:
* description: A user with this address already exists
*/
invitationsRouter.post('/', async (req: Request, res: Response) => {
try {
const email = String(req.body?.email || '').trim().toLowerCase();
const name = String(req.body?.name || '').trim();
// Same two accepted shapes as PUT /admin/users/:id/permissions.
const permissions = toPermissions(req.body?.permissions ?? req.body?.apps);
if (!EMAIL_PATTERN.test(email) || name.length === 0 || !permissions) {
res.status(400).send({
status: 'BAD_REQUEST',
message: 'E-Mail, Name und Berechtigungen sind erforderlich.'
});
return;
}
// Inviting someone who already has an account would strand them on an
// accept page that can only fail. Granting permissions on the existing
// user is the operation they actually want.
if (await UsersService.findUserByEmail(email)) {
res.status(409).send({
status: 'CONFLICT',
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Vergib dort die Berechtigungen.'
});
return;
}
const invitation = await InvitationsService.createInvitation(
email,
name,
permissions,
res.locals.admin.id
);
const mailed = await sendInvitationMail(email, name, invitation.token, invitation.expiresAt);
if (!mailed) {
logger.warn('Admin: invitation created but the mail was not accepted', {email});
}
logInviteLinkInDev(invitation.token);
res.status(201).send({id: invitation.id, email, name, expiresAt: invitation.expiresAt, mailed});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/invitations/{invitationId}/resend:
* post:
* summary: Issue a new token for an open invitation and mail it again
* description: The previous link stops working.
* tags: [admin]
* responses:
* 200:
* description: Resent
* 404:
* description: No open invitation with this id
*/
invitationsRouter.post('/:invitationId/resend', async (req: Request, res: Response) => {
try {
const invitationId = parseInt(req.params.invitationId, 10);
if (Number.isNaN(invitationId)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
return;
}
const resent = await InvitationsService.resendInvitation(invitationId);
if (!resent) {
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
return;
}
const mailed = await sendInvitationMail(resent.email, resent.name, resent.token, resent.expiresAt);
if (!mailed) {
logger.warn('Admin: invitation resent but the mail was not accepted', {email: resent.email});
}
logInviteLinkInDev(resent.token);
res.status(200).send({id: invitationId, expiresAt: resent.expiresAt, mailed});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/invitations/{invitationId}:
* delete:
* summary: Revoke an open invitation
* tags: [admin]
* responses:
* 204:
* description: Revoked
* 404:
* description: No open invitation with this id
*/
invitationsRouter.delete('/:invitationId', async (req: Request, res: Response) => {
try {
const invitationId = parseInt(req.params.invitationId, 10);
if (Number.isNaN(invitationId)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
return;
}
const revoked = await InvitationsService.revokeInvitation(invitationId);
if (!revoked) {
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,239 @@
import * as crypto from 'crypto';
import {NachklangAdminDB} from '../Admin.db.js';
import {AppPermission, isAppName, isAppPermission, ACCESS_ROLE} from '../admin.schema.js';
const db = NachklangAdminDB.db;
/**
* Invitations are this API's only path to a new account (there is no public
* sign-up). The raw token exists exactly twice: in the mail we send and in the
* request body when it comes back. What we store is its SHA-256 hash, so a
* database dump does not hand out account access - the same reasoning as the
* calendar module's session key hashing, and the reason lookups go through
* `findByToken` rather than any query on a plaintext column.
*/
export const INVITATION_TTL_DAYS = 7;
export interface OpenInvitation {
id: number;
email: string;
name: string;
permissions: AppPermission[];
invitedBy: string | null;
createdAt: Date;
expiresAt: Date;
}
export interface AcceptableInvitation {
id: number;
email: string;
name: string;
permissions: AppPermission[];
}
const hashToken = (token: string): string => {
return crypto.createHash('sha256').update(token).digest('hex');
};
const generateToken = (): string => {
// 32 bytes, url-safe: it travels in a mail link's query string.
return crypto.randomBytes(32).toString('base64url');
};
/**
* Reads the stored permission list. Two shapes are accepted: the current
* `[{app, role}]`, and a bare `['tickets', ...]` from before roles existed,
* which means the same thing at the `access` role. Invitations live for seven
* days, so a deploy that changes the shape has in-flight rows in the old one -
* tolerating both is what stops those invitees from being stranded.
*
* mysql2 hands back a JSON column already parsed; a driver or column-type
* change that turns it into a string must not break the read path either.
*/
const parsePermissions = (value: unknown): AppPermission[] => {
const raw = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(raw)) {
return [];
}
return raw.flatMap((entry): AppPermission[] => {
if (isAppName(entry)) {
return [{app: entry, role: ACCESS_ROLE}];
}
return isAppPermission(entry) ? [{app: entry.app, role: entry.role}] : [];
});
};
const expiryFromNow = (): Date => {
return new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000);
};
/**
* Creates an invitation and returns the raw token for the mail. Any earlier
* open invitation for the same address is revoked first: two valid links for
* one mailbox is a needless second live credential, and "resend" would
* otherwise quietly accumulate them.
*/
export const createInvitation = async (
email: string,
name: string,
permissions: AppPermission[],
invitedBy: string | null
): Promise<{id: number; token: string; expiresAt: Date}> => {
const token = generateToken();
const expiresAt = expiryFromNow();
const valid = permissions.filter(isAppPermission);
const id = await db.transaction().execute(async trx => {
await trx
.updateTable('invitations')
.set({revoked_at: new Date()})
.where('email', '=', email)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.execute();
const result = await trx
.insertInto('invitations')
.values({
email,
name,
token_hash: hashToken(token),
permissions: JSON.stringify(valid),
invited_by: invitedBy,
created_at: new Date(),
expires_at: expiresAt
})
.executeTakeFirst();
return Number(result.insertId);
});
return {id, token, expiresAt};
};
/**
* Looks up a still-usable invitation by raw token. Callers must not
* distinguish "unknown", "expired", "revoked" and "already accepted" to the
* client: all four answer with the same shape, so a stranger cannot probe which
* tokens ever existed.
*/
export const findByToken = async (token: string): Promise<AcceptableInvitation | null> => {
const row = await db
.selectFrom('invitations')
.select(['id', 'email', 'name', 'permissions'])
.where('token_hash', '=', hashToken(token))
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.executeTakeFirst();
if (!row) {
return null;
}
return {id: row.id, email: row.email, name: row.name, permissions: parsePermissions(row.permissions)};
};
/** Marks the invitation accepted. Conditional on it still being open so two
* concurrent accepts of the same link cannot both create an account. */
export const markAccepted = async (invitationId: number): Promise<boolean> => {
const result = await db
.updateTable('invitations')
.set({accepted_at: new Date()})
.where('id', '=', invitationId)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.executeTakeFirst();
return Number(result.numUpdatedRows) > 0;
};
/**
* Reverses markAccepted. Used only to compensate a failed acceptance: the user
* could not be created, so the link must become usable again rather than
* stranding the invitee with a burnt token and no account.
*/
export const unmarkAccepted = async (invitationId: number): Promise<void> => {
await db
.updateTable('invitations')
.set({accepted_at: null})
.where('id', '=', invitationId)
.execute();
};
export const listOpenInvitations = async (): Promise<OpenInvitation[]> => {
const rows = await db
.selectFrom('invitations')
.select(['id', 'email', 'name', 'permissions', 'invited_by', 'created_at', 'expires_at'])
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.orderBy('created_at', 'desc')
.execute();
return rows.map(row => ({
id: row.id,
email: row.email,
name: row.name,
permissions: parsePermissions(row.permissions),
invitedBy: row.invited_by,
createdAt: row.created_at,
expiresAt: row.expires_at
}));
};
export const getOpenInvitation = async (invitationId: number): Promise<OpenInvitation | null> => {
const all = await listOpenInvitations();
return all.find(invitation => invitation.id === invitationId) ?? null;
};
/** Resend issues a *new* token and expiry and invalidates the old one, rather
* than re-mailing the existing link: if the first mail leaked, resending it
* would extend the leak's lifetime. */
export const resendInvitation = async (
invitationId: number
): Promise<{token: string; email: string; name: string; expiresAt: Date} | null> => {
const invitation = await getOpenInvitation(invitationId);
if (!invitation) {
return null;
}
const token = generateToken();
const expiresAt = expiryFromNow();
await db
.updateTable('invitations')
.set({token_hash: hashToken(token), expires_at: expiresAt, created_at: new Date()})
.where('id', '=', invitationId)
.execute();
return {token, email: invitation.email, name: invitation.name, expiresAt};
};
export const revokeInvitation = async (invitationId: number): Promise<boolean> => {
const result = await db
.updateTable('invitations')
.set({revoked_at: new Date()})
.where('id', '=', invitationId)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.executeTakeFirst();
return Number(result.numUpdatedRows) > 0;
};
/** Used by the bootstrap to stay idempotent across restarts. */
export const hasOpenInvitationFor = async (email: string): Promise<boolean> => {
const row = await db
.selectFrom('invitations')
.select('id')
.where('email', '=', email)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.executeTakeFirst();
return Boolean(row);
};
@@ -0,0 +1,247 @@
import express, {Request, Response} from 'express';
import * as UsersService from './users.admin.service.js';
import {toPermissions} from '../admin.schema.js';
import {sendServerError} from '../admin.errors.js';
export const usersAdminRouter = express.Router();
/**
* User administration. Mounted behind requireAppAccess('admin'), so every
* handler here can assume res.locals.admin is an admin.
*
* The guards below exist because this API can lock its own operators out: the
* only way to grant a permission is through these routes, so an admin who
* removes the last `admin` permission leaves nobody who can put it back short
* of a manual SQL statement in production.
*/
const conflict = (res: Response, message: string): void => {
res.status(409).send({status: 'CONFLICT', message});
};
const notFound = (res: Response): void => {
res.status(404).send({status: 'NOT_FOUND', message: 'Benutzer nicht gefunden.'});
};
/**
* @swagger
* /admin/users:
* get:
* summary: List all users with their app permissions and status
* tags: [admin]
* responses:
* 200:
* description: Success
* 401:
* description: Not signed in
* 403:
* description: Missing the admin permission
*/
usersAdminRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send(await UsersService.listUsers());
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}:
* get:
* summary: One user with their active sessions and passkey count
* tags: [admin]
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* 404:
* description: Unknown user
*/
usersAdminRouter.get('/:userId', async (req: Request, res: Response) => {
try {
const detail = await UsersService.getUserDetail(req.params.userId);
if (!detail) {
notFound(res);
return;
}
res.status(200).send(detail);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}/permissions:
* put:
* summary: Replace a user's app permissions
* description: Refuses to remove the caller's own admin permission or the last remaining active admin.
* tags: [admin]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* permissions:
* type: array
* description: >
* One entry per (app, role). `access` is the only role today.
* A plain array of app names is also accepted and means the
* same at the `access` role.
* items:
* type: object
* properties:
* app:
* type: string
* enum: [calendar, feedback, tickets, admin]
* role:
* type: string
* enum: [access]
* responses:
* 200:
* description: Success
* 400:
* description: Invalid app or role
* 409:
* description: Would lock the last admin out
*/
usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response) => {
try {
const userId = req.params.userId;
// `permissions: [{app, role}]` is the real shape; `apps: ['tickets']` is
// accepted as shorthand for the same thing at the `access` role, so a
// caller that predates roles keeps working.
const permissions = toPermissions(req.body?.permissions ?? req.body?.apps);
if (!permissions) {
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Berechtigungsliste.'});
return;
}
if (!(await UsersService.userExists(userId))) {
notFound(res);
return;
}
const target = await UsersService.loadAccess(userId);
const keepsAdmin = permissions.some(permission => permission.app === 'admin');
const losesAdmin = Boolean(target?.apps.includes('admin')) && !keepsAdmin;
// Self-lockout is checked here because it needs the caller's identity,
// which the service has no business knowing. The last-admin check is
// NOT done here: it has to be inside the write transaction to survive
// two admins acting at the same time (see setPermissionsGuarded).
if (losesAdmin && userId === res.locals.admin.id) {
conflict(res, 'Du kannst dir die Admin-Berechtigung nicht selbst entziehen.');
return;
}
const result = await UsersService.setPermissionsGuarded(userId, permissions, res.locals.admin.id);
if (result === 'last-admin') {
conflict(res, 'Die letzte Admin-Berechtigung kann nicht entzogen werden.');
return;
}
res.status(200).send(await UsersService.getUserDetail(userId));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}/disable:
* post:
* summary: Disable a user and revoke all of their sessions
* tags: [admin]
* responses:
* 200:
* description: Success
* 409:
* description: Would disable the caller or the last admin
*/
usersAdminRouter.post('/:userId/disable', async (req: Request, res: Response) => {
try {
const userId = req.params.userId;
if (userId === res.locals.admin.id) {
conflict(res, 'Du kannst dich nicht selbst deaktivieren.');
return;
}
const target = await UsersService.loadAccess(userId);
if (!target) {
notFound(res);
return;
}
const result = await UsersService.disableUserGuarded(userId);
if (result === 'last-admin') {
conflict(res, 'Der letzte aktive Admin kann nicht deaktiviert werden.');
return;
}
res.status(200).send(await UsersService.getUserDetail(userId));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}/enable:
* post:
* summary: Re-enable a disabled user
* description: Does not restore sessions - the user signs in again.
* tags: [admin]
* responses:
* 200:
* description: Success
*/
usersAdminRouter.post('/:userId/enable', async (req: Request, res: Response) => {
try {
if (!(await UsersService.userExists(req.params.userId))) {
notFound(res);
return;
}
await UsersService.enableUser(req.params.userId);
res.status(200).send(await UsersService.getUserDetail(req.params.userId));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}/sessions/{sessionId}:
* delete:
* summary: Revoke one session of a user
* tags: [admin]
* responses:
* 204:
* description: Revoked
* 404:
* description: Unknown session for this user
*/
usersAdminRouter.delete('/:userId/sessions/:sessionId', async (req: Request, res: Response) => {
try {
const revoked = await UsersService.revokeSession(req.params.userId, req.params.sessionId);
if (!revoked) {
res.status(404).send({status: 'NOT_FOUND', message: 'Sitzung nicht gefunden.'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,466 @@
import {Transaction} from 'kysely';
import {NachklangAdminDB} from '../Admin.db.js';
import {
AdminDatabase,
AppName,
AppPermission,
AppRole,
ACCESS_ROLE,
appsOf,
isAppName,
isAppRole
} from '../admin.schema.js';
const db = NachklangAdminDB.db;
/**
* Everything that reads or writes permissions. Two callers with very different
* hot-path requirements share this file: admin.middleware.ts runs
* `loadAccess` on *every* admin-authenticated request (which is why it is one
* query joining `user.disabled` and the permission rows - see the plan's
* decision to run without better-auth's cookieCache), and the /admin/users
* routes run the rest.
*/
export interface UserAccess {
id: string;
email: string;
displayName: string;
disabled: boolean;
/** Every (app, role) grant this user holds. */
permissions: AppPermission[];
/** The distinct apps the above grants any access to. Derived, kept because
* most callers only ever ask "may they open this app at all?". */
apps: AppName[];
}
export type UserStatus = 'aktiv' | 'deaktiviert';
export interface UserListEntry {
id: string;
email: string;
name: string;
permissions: AppPermission[];
apps: AppName[];
status: UserStatus;
createdAt: Date;
lastSignInAt: Date | null;
}
export interface UserSessionEntry {
id: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string | null;
userAgent: string | null;
}
export interface UserDetail extends UserListEntry {
sessions: UserSessionEntry[];
passkeyCount: number;
}
/**
* The single per-request lookup behind requireAppAccess. Returns null when the
* user row is gone; `disabled` is returned rather than filtered so the
* middleware can answer 403 (account deactivated) instead of a misleading 401.
*/
export const loadAccess = async (userId: string): Promise<UserAccess | null> => {
const rows = await db
.selectFrom('user')
.leftJoin('user_app_permissions', 'user_app_permissions.user_id', 'user.id')
.where('user.id', '=', userId)
.select([
'user.id as id',
'user.email as email',
'user.name as name',
'user.disabled as disabled',
'user_app_permissions.app as app',
'user_app_permissions.role as role'
])
.execute();
if (rows.length === 0) {
return null;
}
const permissions = toPermissionRows(rows);
return {
id: rows[0].id,
email: rows[0].email,
displayName: rows[0].name,
// MySQL TINYINT(1) comes back as 0/1 through mysql2.
disabled: Boolean(rows[0].disabled),
permissions,
apps: appsOf(permissions)
};
};
/**
* Turns joined permission rows into AppPermission[]. The left join produces one
* row with a null app for a user who holds nothing, and a role written directly
* into the database that no longer appears in APP_ROLES is dropped rather than
* trusted - the table is the store, APP_ROLES is the contract.
*/
const toPermissionRows = (rows: {app: AppName | null; role: string | null}[]): AppPermission[] => {
return rows
.filter((row): row is {app: AppName; role: string} =>
isAppName(row.app) && isAppRole(row.app, row.role))
.map(row => ({app: row.app, role: row.role}));
};
export const listUsers = async (): Promise<UserListEntry[]> => {
const users = await db
.selectFrom('user')
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
.orderBy('name', 'asc')
.execute();
const permissions = await db
.selectFrom('user_app_permissions')
.select(['user_id', 'app', 'role'])
.execute();
// Last sign-in is derived from the newest session rather than stored: a
// session row is created on every sign-in and we never update its
// createdAt, so max(createdAt) is exactly that, with no extra column to
// keep in sync.
//
// The expiry filter must match getUserDetail's. better-auth only deletes an
// expired session when someone actually presents it, so expired rows linger
// - without this, the list would report a last sign-in for someone the
// detail view shows as never having signed in.
const lastSessions = await db
.selectFrom('session')
.where('expiresAt', '>', new Date())
.select(({fn}) => ['userId', fn.max('createdAt').as('lastSignInAt')])
.groupBy('userId')
.execute();
const permissionsByUser = new Map<string, AppPermission[]>();
for (const row of permissions) {
if (!isAppRole(row.app, row.role)) {
continue;
}
const held = permissionsByUser.get(row.user_id) || [];
held.push({app: row.app, role: row.role});
permissionsByUser.set(row.user_id, held);
}
const lastSignInByUser = new Map<string, Date | null>(
lastSessions.map(row => [row.userId, row.lastSignInAt as Date | null])
);
return users.map(user => {
const held = permissionsByUser.get(user.id) || [];
return {
id: user.id,
email: user.email,
name: user.name,
permissions: held,
apps: appsOf(held),
status: user.disabled ? ('deaktiviert' as const) : ('aktiv' as const),
createdAt: user.createdAt,
lastSignInAt: lastSignInByUser.get(user.id) ?? null
};
});
};
export const getUserDetail = async (userId: string): Promise<UserDetail | null> => {
const user = await db
.selectFrom('user')
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
.where('id', '=', userId)
.executeTakeFirst();
if (!user) {
return null;
}
const [permissions, sessions, passkeys] = await Promise.all([
db
.selectFrom('user_app_permissions')
.select(['app', 'role'])
.where('user_id', '=', userId)
.execute(),
db
.selectFrom('session')
.select(['id', 'createdAt', 'expiresAt', 'ipAddress', 'userAgent'])
.where('userId', '=', userId)
.where('expiresAt', '>', new Date())
.orderBy('createdAt', 'desc')
.execute(),
db
.selectFrom('passkey')
.select(({fn}) => fn.countAll<number>().as('count'))
.where('userId', '=', userId)
.executeTakeFirst()
]);
const held = toPermissionRows(permissions);
return {
id: user.id,
email: user.email,
name: user.name,
permissions: held,
apps: appsOf(held),
status: user.disabled ? 'deaktiviert' : 'aktiv',
createdAt: user.createdAt,
lastSignInAt: sessions.length > 0 ? sessions[0].createdAt : null,
sessions,
passkeyCount: Number(passkeys?.count ?? 0)
};
};
/**
* Replaces a user's permission set. Written as delete-then-insert inside one
* transaction rather than a diff: the set is at most four rows, and a diff
* would only add branches for no measurable gain.
*/
/** The rows a permission list becomes. One row per (app, role). */
const permissionRows = (
userId: string,
permissions: AppPermission[],
grantedBy: string | null
) => {
return permissions.map(permission => ({
user_id: userId,
app: permission.app,
role: permission.role,
granted_by: grantedBy,
granted_at: new Date()
}));
};
/** Drops anything not in APP_ROLES and de-duplicates on (app, role). */
const validPermissions = (permissions: AppPermission[]): AppPermission[] => {
const seen = new Set<string>();
return permissions.filter(permission => {
if (!isAppName(permission.app) || !isAppRole(permission.app, permission.role)) {
return false;
}
const key = `${permission.app}:${permission.role}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
export const setPermissions = async (
userId: string,
permissions: AppPermission[],
grantedBy: string | null
): Promise<void> => {
const valid = validPermissions(permissions);
await db.transaction().execute(async trx => {
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
if (valid.length > 0) {
await trx
.insertInto('user_app_permissions')
.values(permissionRows(userId, valid, grantedBy))
.execute();
}
});
};
/**
* Why the guards live down here rather than in the router: they are
* check-then-act, and the check has to happen inside the same transaction as
* the write, over locked rows. Two admins each removing the other's `admin`
* permission at the same moment would otherwise both read a count of 2, both
* pass, and both commit - leaving nobody who can administer anything, with
* ADMIN_BOOTSTRAP_EMAIL at the next restart as the only way back in.
*
* `SELECT ... FOR UPDATE` makes the second transaction wait and re-read the
* count the first one just changed.
*/
export type LastAdminGuardResult = 'ok' | 'last-admin';
const countActiveAdminsForUpdate = async (trx: Transaction<AdminDatabase>): Promise<number> => {
const row = await trx
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
// countDistinct, not countAll: with (user_id, app, role) as the key one
// user can hold several roles on `admin`, and counting rows would make a
// single admin with two roles look like two admins - defeating the guard
// at exactly the moment it matters.
.select(({fn}) => fn.count<number>('user_app_permissions.user_id').distinct().as('count'))
.forUpdate()
.executeTakeFirst();
return Number(row?.count ?? 0);
};
/**
* Replaces a user's permissions, refusing to remove the last active admin.
* Returns 'last-admin' instead of throwing so the router can answer 409.
*/
export const setPermissionsGuarded = async (
userId: string,
permissions: AppPermission[],
grantedBy: string | null
): Promise<LastAdminGuardResult> => {
const valid = validPermissions(permissions);
const keepsAdmin = valid.some(permission => permission.app === 'admin');
return db.transaction().execute(async trx => {
const target = await trx
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.user_id', '=', userId)
.where('user_app_permissions.app', '=', 'admin')
.select(['user.disabled as disabled'])
.limit(1)
.forUpdate()
.executeTakeFirst();
const losesAdmin = Boolean(target) && !keepsAdmin;
if (losesAdmin && !target?.disabled && (await countActiveAdminsForUpdate(trx)) <= 1) {
return 'last-admin';
}
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
if (valid.length > 0) {
await trx
.insertInto('user_app_permissions')
.values(permissionRows(userId, valid, grantedBy))
.execute();
}
return 'ok';
});
};
/**
* Disables a user and revokes every session, refusing to disable the last
* active admin. Same locking rationale as setPermissionsGuarded.
*/
export const disableUserGuarded = async (userId: string): Promise<LastAdminGuardResult> => {
return db.transaction().execute(async trx => {
const isAdmin = await trx
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.user_id', '=', userId)
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
.select('user_app_permissions.user_id')
.limit(1)
.forUpdate()
.executeTakeFirst();
if (isAdmin && (await countActiveAdminsForUpdate(trx)) <= 1) {
return 'last-admin';
}
await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
await trx.deleteFrom('session').where('userId', '=', userId).execute();
return 'ok';
});
};
export const grantPermission = async (
userId: string,
app: AppName,
grantedBy: string | null,
role: AppRole = ACCESS_ROLE
): Promise<void> => {
await db
.insertInto('user_app_permissions')
.values({user_id: userId, app, role, granted_by: grantedBy, granted_at: new Date()})
// The row already existing is the success case - this is "make sure they
// hold it", not "re-grant it" - so nothing is overwritten and granted_by
// keeps naming whoever granted it first.
.onDuplicateKeyUpdate({role})
.execute();
};
/** Disabling revokes every session: a disabled user must lose access now, not
* when their 30-day cookie happens to expire. */
export const disableUser = async (userId: string): Promise<void> => {
await db.transaction().execute(async trx => {
await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
await trx.deleteFrom('session').where('userId', '=', userId).execute();
});
};
export const enableUser = async (userId: string): Promise<void> => {
await db.updateTable('user').set({disabled: false}).where('id', '=', userId).execute();
};
export const revokeSession = async (userId: string, sessionId: string): Promise<boolean> => {
const result = await db
.deleteFrom('session')
.where('id', '=', sessionId)
.where('userId', '=', userId)
.executeTakeFirst();
return Number(result.numDeletedRows) > 0;
};
/**
* Guard input for the self-lockout rules: how many enabled users still hold the
* `admin` permission. Disabled admins do not count - they cannot sign in, so
* leaving only disabled admins is the same lockout as leaving none.
*/
export const countActiveAdmins = async (): Promise<number> => {
const row = await db
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
.select(({fn}) => fn.countAll<number>().as('count'))
.executeTakeFirst();
return Number(row?.count ?? 0);
};
export const userExists = async (userId: string): Promise<boolean> => {
const row = await db.selectFrom('user').select('id').where('id', '=', userId).executeTakeFirst();
return Boolean(row);
};
export const findUserByEmail = async (email: string): Promise<{id: string; email: string} | null> => {
const row = await db
.selectFrom('user')
.select(['id', 'email'])
.where('email', '=', email)
.executeTakeFirst();
return row ?? null;
};
/**
* Display names for a set of user ids, as an id -> name map. Ids that no
* longer exist are simply absent from the map rather than mapping to a
* placeholder, so callers can distinguish "deleted account" from "never had
* one" and choose their own fallback.
*
* This exists for the calendar migration (docs/calendar-auth-migration.md
* step 3): the calendar lives in a different database, so it cannot join
* against `user` to render "created by". One lookup per result set keeps that
* cheap without coupling the two schemas.
*/
export const findDisplayNames = async (ids: readonly string[]): Promise<Map<string, string>> => {
const distinct = Array.from(new Set(ids.filter(id => id)));
if (distinct.length === 0) {
// Kysely renders `in ()` for an empty list, which MariaDB rejects.
return new Map();
}
const rows = await db
.selectFrom('user')
.select(['id', 'name'])
.where('id', 'in', distinct)
.execute();
return new Map(rows.map(row => [row.id, row.name]));
};
+1 -2
View File
@@ -1,6 +1,5 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
const mariadb = require('mariadb');
dotenv.config(); dotenv.config();
+37 -2
View File
@@ -3,8 +3,9 @@
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger'; import logger from '../../middleware/logger.js';
import {eventsRouter} from './events/events.router'; import {eventsRouter} from './events/events.router.js';
import {usersRouter} from './users/users.router.js';
/** /**
* Router Definition * Router Definition
@@ -12,8 +13,42 @@ import {eventsRouter} from './events/events.router';
export const calendarRouter = express.Router(); export const calendarRouter = express.Router();
calendarRouter.use('/events', eventsRouter); calendarRouter.use('/events', eventsRouter);
calendarRouter.use('/users', usersRouter);
/**
* @swagger
* /calendar:
* get:
* summary: Calendar API root endpoint
* description: Returns a welcome message for the Nachklang e.V. Calendar API.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* text/plain:
* schema:
* type: string
* example: Nachklang e.V. Calendar API Endpoint
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
calendarRouter.get('/', async (req: Request, res: Response) => { calendarRouter.get('/', async (req: Request, res: Response) => {
try { try {
res.status(200).send('Nachklang e.V. Calendar API Endpoint'); res.status(200).send('Nachklang e.V. Calendar API Endpoint');
@@ -0,0 +1,55 @@
import * as dotenv from 'dotenv';
dotenv.config();
/**
* The shared calendar passwords, and nothing else.
*
* Before the step 4 cutover each function here also took a sessionId/sessionKey
* pair and checked it against the calendar's own sessions table, so "is this a
* signed-in user?" and "did they send the right shared password?" were tangled
* together in five places. Signed-in access is now decided by
* requireAppAccess('calendar') before the handler runs; what is left is the
* fallback for people who have no account at all.
*
* That fallback survives on purpose, for one reason: an iCal client subscribing
* to a calendar URL cannot send a cookie. Everything the Angular app does goes
* through the session cookie instead. See docs/calendar-auth-migration.md.
*
* `public` is deliberately open to everyone with no credential of any kind -
* nachklang.art reads it anonymously to show the next upcoming event. Pinned by
* test/calendar/credentials.service.test.ts.
*/
const credentialFor = (calendarName: string): string | undefined => {
switch (calendarName) {
case 'members':
return process.env.MEMBER_CREDENTIAL;
case 'choir':
case 'birthdays':
return process.env.CHOIR_CREDENTIAL;
case 'management':
return process.env.MANAGEMENT_CREDENTIAL;
default:
return undefined;
}
};
/**
* Whether the given shared password opens the given calendar. Answers false
* for an unknown calendar, and - importantly - for a calendar whose credential
* is not configured at all: an unset MEMBER_CREDENTIAL must not turn into
* "everyone with an empty password gets in".
*/
export const hasAccess = async (calendarName: string, password: string): Promise<boolean> => {
if (calendarName === 'public') {
return true;
}
const expected = credentialFor(calendarName);
if (!expected) {
return false;
}
return password === expected;
};
+130 -6
View File
@@ -1,13 +1,137 @@
/**
* @swagger
* components:
* schemas:
* Event:
* type: object
* required:
* - eventId
* - calendarId
* - uuid
* - name
* - description
* - startDateTime
* - endDateTime
* - createdDate
* - location
* - createdById
* - url
* - wholeDay
* properties:
* eventId:
* type: integer
* description: The unique identifier for the event
* example: 123
* calendarId:
* type: integer
* description: The ID of the calendar this event belongs to
* example: 1
* uuid:
* type: string
* description: A unique UUID for the event
* example: "550e8400-e29b-41d4-a716-446655440000"
* name:
* type: string
* description: The name/title of the event
* example: "Concert at Musikhochschule"
* description:
* type: string
* description: A detailed description of the event
* example: "Annual concert at the Musikhochschule"
* startDateTime:
* type: string
* format: date-time
* description: The start date and time of the event
* example: "2023-06-15T19:00:00.000Z"
* endDateTime:
* type: string
* format: date-time
* description: The end date and time of the event
* example: "2023-06-15T21:00:00.000Z"
* createdDate:
* type: string
* format: date-time
* description: The date and time when the event was created
* example: "2023-05-01T10:00:00.000Z"
* lastModifiedDate:
* type: string
* format: date-time
* example: "2023-05-01T10:00:00.000Z"
* location:
* type: string
* description: The location of the event
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* description: The name of the user who created the event
* example: "John Doe"
* createdById:
* type: integer
* deprecated: true
* description: >
* The legacy calendar user id of the creator. Being replaced by
* createdByUserId; see docs/calendar-auth-migration.md. Null on
* events created after the cutover.
* nullable: true
* example: 456
* createdByUserId:
* type: string
* nullable: true
* description: The admin-module user id of the creator, once it has one
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
* lastModifiedBy:
* type: string
* description: The name of the user who last modified the event
* example: "John Doe"
* lastModifiedById:
* type: integer
* deprecated: true
* nullable: true
* description: >
* The legacy calendar user id of the last editor. Being replaced
* by lastModifiedByUserId.
* example: 456
* lastModifiedByUserId:
* type: string
* nullable: true
* description: The admin-module user id of the last editor, once it has one
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
* url:
* type: string
* description: A URL with more information about the event
* example: "https://www.nachklang.art/events/concert"
* wholeDay:
* type: boolean
* description: Whether the event lasts the whole day
* example: false
* status:
* type: string
* description: The status of the event
* enum: [PUBLIC, PRIVATE, DRAFT, DELETED]
* example: "PUBLIC"
*/
export interface Event { export interface Event {
event_id: number; eventId: number;
calendar_id: number; calendarId: number;
uuid: string; uuid: string;
name: string; name: string;
description: string; description: string;
start_datetime: Date; startDateTime: Date;
end_datetime: Date; endDateTime: Date;
created_date: Date; createdDate: Date;
lastModifiedDate?: Date;
location: string; location: string;
created_by: string; /** Display name of the creator, from whichever id below resolved. */
createdBy?: string;
createdById?: number | null;
/** Set once the event's creator exists in the admin module. Preferred over
* createdById when both are present; see docs/calendar-auth-migration.md. */
createdByUserId?: string | null;
lastModifiedBy?: string;
lastModifiedById?: number | null;
lastModifiedByUserId?: string | null;
url: string; url: string;
wholeDay: boolean;
repeatFrequency: string;
status?: string;
} }
File diff suppressed because it is too large Load Diff
+345 -10
View File
@@ -1,28 +1,184 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as bcrypt from 'bcrypt';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import {Event} from './event.interface'; import {Event} from './event.interface.js';
import {NachklangCalendarDB} from '../Calendar.db'; import {NachklangCalendarDB} from '../Calendar.db.js';
import * as AdminUsersService from '../../admin/users/users.admin.service.js';
import logger from '../../../middleware/logger.js';
dotenv.config(); dotenv.config();
/**
* Step 3 of docs/calendar-auth-migration.md: the dual read.
*
* An event records its creator twice - `created_by_id`, the legacy INT into
* the calendar database's own `users` table, and `created_by_user_id`, the
* admin module's VARCHAR(36) id. Old rows have only the first, rows written
* after the step 4 cutover will have only the second, and the two live in
* different databases, so this file has to read both and prefer the new one.
*
* The one thing the creator is used for is a display name. Nothing authorises
* on it - there is no "only the creator may edit" rule anywhere - which is why
* a name that cannot be resolved degrades to blank instead of to an error.
*
* That name has three possible sources, and they are tried weakest first:
*
* 1. LEGACY - joining the calendar's own `users` table on `created_by_id`.
* 2. `created_by_name`, the snapshot migration 002 took of exactly that join,
* so the authorship of pre-cutover events survives step 5 dropping the
* table. An archive: nothing writes it after the backfill.
* 3. The admin module's `user.name`, looked up live for rows that carry an
* admin id. It wins because it is the only one that follows a rename.
*
* Writes only ever set the admin id: since the step 4 cutover there is no
* calendar user id to write, which is why migration 003 made `created_by_id`
* nullable. The reads below still handle rows that predate that.
*
* Removal note: everything marked LEGACY below comes out in step 5, together
* with the `users`/`sessions` tables and the `created_by_id` columns. The
* snapshot stays - it is the reason step 5 can drop them.
*/
/**
* The one SELECT the four read paths share. It was copied out four times
* before, which is precisely why the dual read had to be added in four
* places; callers append their own WHERE and ORDER BY.
*
* `v.*` carries `version_created_by_user_id` and `version_created_by_name`
* along with the rest of the version row, so only the `events` columns need
* naming. The two joined names are aliased `legacy_*` because the unprefixed
* names are now real columns.
*/
const EVENT_SELECT = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, e.created_by_user_id, e.created_by_name,
u.full_name as legacy_created_by_name, u2.full_name as legacy_last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id`;
/**
* Maps a result row to an Event. `status` is included only where it always
* was: the admin views and the by-id lookup return it, the two public listings
* do not.
*/
const toEvent = (row: any, includeStatus: boolean): Event => {
const event: Event = {
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
// Name resolution, weakest first: the LEGACY join against the calendar
// users table, then the snapshot taken in migration 002, then - in
// resolveAdminNames below - the live admin name, which wins because it
// is the only one that follows an account being renamed.
createdBy: row.created_by_name ?? row.legacy_created_by_name,
createdById: row.created_by_id,
createdByUserId: row.created_by_user_id ?? null,
lastModifiedBy: row.version_created_by_name ?? row.legacy_last_modified_by_name,
lastModifiedById: row.version_created_by_id,
lastModifiedByUserId: row.version_created_by_user_id ?? null,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
};
if (includeStatus) {
event.status = row.status;
}
return event;
};
/**
* Fills in creator/editor names for rows that carry an admin user id, by way
* of a single lookup against the admin database. The calendar cannot join
* against `user` - it is a different schema behind a different pool - and
* making it one would tie the two schemas together as tightly as a foreign key
* would.
*
* A failure here is swallowed on purpose. These endpoints include the public
* calendar the website reads anonymously, and a name is decoration: if the
* admin database is unreachable, an event should still render with whatever
* the legacy join produced rather than 500 the whole listing. The alternative
* would widen the public calendar's blast radius to include the admin
* database, which it has never depended on before.
*/
const resolveAdminNames = async (events: Event[]): Promise<void> => {
const ids = events
.flatMap(event => [event.createdByUserId, event.lastModifiedByUserId])
.filter((id): id is string => Boolean(id));
if (ids.length === 0) {
return;
}
let names: Map<string, string>;
try {
names = await AdminUsersService.findDisplayNames(ids);
} catch (e: any) {
logger.warn('Calendar: could not resolve creator names from the admin database: ' + e.message);
return;
}
for (const event of events) {
const createdBy = event.createdByUserId ? names.get(event.createdByUserId) : undefined;
if (createdBy) {
event.createdBy = createdBy;
}
const lastModifiedBy = event.lastModifiedByUserId ? names.get(event.lastModifiedByUserId) : undefined;
if (lastModifiedBy) {
event.lastModifiedBy = lastModifiedBy;
}
}
};
/**
* The calendars a listing has to cover: the requested one plus whatever it
* declares in `includes_calendars`.
*/
const calendarsToFetch = async (conn: any, calendarId: number): Promise<number[]> => {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendars: number[] = [calendarId];
for (let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendars = [...calendars, ...includes];
}
return calendars;
};
/** /**
* Returns all events for the given calendar * Returns all events for the given calendar
* @param calendarId The calendar Id * @param calendarId The calendar Id
*/ */
export const getAllEvents = async (calendarId: number): Promise<Event[]> => { export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
let eventRows: Event[] = [];
try { try {
const eventsQuery = 'SELECT * FROM events WHERE calendar_id = ?'; const calendars = await calendarsToFetch(conn, calendarId);
const eventsRes = await conn.query(eventsQuery, calendarId);
for(let row of eventsRes) { const eventsQuery = `${EVENT_SELECT}
eventRows.push(row); WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
} ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, [calendars]);
return eventRows; const events = eventsRes.map((row: any) => toEvent(row, false));
await resolveAdminNames(events);
return events;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -30,3 +186,182 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
await conn.end(); await conn.end();
} }
}; };
/**
* Returns all events for the given calendar for the admin UI (therefore includes admin relevant information and
* ignores the calendar includes
* @param calendarId
*/
export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection();
try {
const eventsQuery = `${EVENT_SELECT}
WHERE e.calendar_id = ?
ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, calendarId);
const events = eventsRes.map((row: any) => toEvent(row, true));
await resolveAdminNames(events);
return events;
} catch (err) {
throw err;
} finally {
// Return connection
await conn.end();
}
};
/**
* Returns a single event by id (latest version, any status), or null if it
* doesn't exist. Unlike getAllEvents/getAllEventsAdmin this isn't scoped to
* a calendar - callers that need to enforce calendar/status visibility
* should check the returned event's calendarId/status themselves.
* @param eventId The event id
*/
export const getEventById = async (eventId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
const eventsQuery = `${EVENT_SELECT}
WHERE e.event_id = ?`;
const eventsRes = await conn.query(eventsQuery, eventId);
if (eventsRes.length === 0) {
return null;
}
const event = toEvent(eventsRes[0], true);
await resolveAdminNames([event]);
return event;
} catch (err) {
throw err;
} finally {
// Return connection
await conn.end();
}
};
/**
* Create the given event in the database
* @param event The event to create
*/
export const createEvent = async (event: Event): Promise<number> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
let eventUUID = Guid.create().toString();
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_user_id) VALUES (?,?,?) RETURNING event_id';
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdByUserId ?? null]);
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
await conn.commit();
return eventsRes[0].event_id;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Update the given event in the database
* @param event The event to update
*/
export const updateEvent = async (event: Event): Promise<number> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
await conn.commit();
return versionRes.affectedRows;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Deletes the given event from the database
* @param event The event to delete
*/
export const deleteEvent = async (event: Event): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_user_id) VALUES (?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdByUserId ?? null]);
await conn.commit();
return versionRes.affectedRows === 1;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Moves an event to the specified calendar
* @param event The event to move. Has to have the target calendar set already.
*/
export const moveEvent = async (event: Event): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const eventQuery = 'UPDATE events SET calendar_id = ? WHERE event_id = ?';
const eventRes = await conn.execute(eventQuery, [event.calendarId, event.eventId]);
await conn.commit();
return eventRes.affectedRows === 1;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
/**
* Returns the next upcoming event for the given calendar
* @param calendarId The calendar Id
*/
export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
const calendars = await calendarsToFetch(conn, calendarId);
const now = new Date();
const eventsQuery = `${EVENT_SELECT}
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ?
ORDER BY v.start_datetime ASC
LIMIT 1`;
const eventsRes = await conn.query(eventsQuery, [calendars, now]);
if (eventsRes.length === 0) {
return null;
}
const event = toEvent(eventsRes[0], false);
await resolveAdminNames([event]);
return event;
} catch (err) {
throw err;
} finally {
// Return connection
await conn.end();
}
}
@@ -1,5 +1,9 @@
import {Event} from './event.interface'; import {Event} from './event.interface.js';
/**
* Interface to external classes - Turns the given events into an ical string
* @param events
*/
export const convertToIcal = async (events: Event[]): Promise<string> => { export const convertToIcal = async (events: Event[]): Promise<string> => {
try { try {
let ical: iCalFile = {body: []}; let ical: iCalFile = {body: []};
@@ -15,6 +19,10 @@ export const convertToIcal = async (events: Event[]): Promise<string> => {
} }
}; };
/**
* Method to serialize an iCalFile object into an ical string
* @param ical
*/
const serializeIcalFile = (ical: iCalFile): string => { const serializeIcalFile = (ical: iCalFile): string => {
let returnString = ''; let returnString = '';
@@ -27,6 +35,10 @@ const serializeIcalFile = (ical: iCalFile): string => {
return returnString; return returnString;
}; };
/**
* Method to serialize a single ical event into an ical event string
* @param icalevent
*/
const serializeIcalEvent = (icalevent: iCalEvent): string => { const serializeIcalEvent = (icalevent: iCalEvent): string => {
let returnString = ''; let returnString = '';
@@ -34,22 +46,31 @@ const serializeIcalEvent = (icalevent: iCalEvent): string => {
returnString += 'UID:' + icalevent.uid; returnString += 'UID:' + icalevent.uid;
returnString += 'DTSTAMP:' + icalevent.created; returnString += 'DTSTAMP:' + icalevent.created;
returnString += 'ORGANIZER:' + icalevent.organizer; returnString += 'ORGANIZER:' + icalevent.organizer;
returnString += 'DTSTART;TZID=Europe/Berlin:' + icalevent.start; if(icalevent.wholeDay) {
returnString += 'DTEND;TZID=Europe/Berlin:' + icalevent.end; returnString += 'DTSTART;VALUE=DATE:' + icalevent.start;
returnString += 'DTEND;VALUE=DATE:' + icalevent.end;
} else {
returnString += 'DTSTART;TZID=Europe/Berlin:' + icalevent.start;
returnString += 'DTEND;TZID=Europe/Berlin:' + icalevent.end;
}
if(!isNullOrBlank(icalevent.repeatFrequency)) returnString += 'RRULE:FREQ=' + icalevent.repeatFrequency;
returnString += 'SUMMARY:' + icalevent.summary; returnString += 'SUMMARY:' + icalevent.summary;
returnString += 'DESCRIPTION:' + icalevent.description; if(!isNullOrBlank(icalevent.description)) returnString += 'DESCRIPTION:' + icalevent.description;
returnString += 'LOCATION:' + icalevent.location; if(!isNullOrBlank(icalevent.location)) returnString += 'LOCATION:' + icalevent.location;
returnString += 'URL:' + icalevent.url; if(!isNullOrBlank(icalevent.url)) returnString += 'URL:' + icalevent.url;
returnString += icalevent.footer; returnString += icalevent.footer;
return returnString; return returnString;
}; };
/**
* Method to generate the ical header string
* @param ical
*/
const generateHeaderInfo = (ical: iCalFile) => { const generateHeaderInfo = (ical: iCalFile) => {
ical.header = 'BEGIN:VCALENDAR\n' + ical.header = 'BEGIN:VCALENDAR\n' +
'VERSION:2.0\n' + 'VERSION:2.0\n' +
'PRODID:-//hacksw/handcal//NONSGML v1.0//EN\n' + 'PRODID:-//Nachklang e.V./Nachklang Calendar//NONSGML v1.0//EN\n' +
'CALSCALE:GREGORIAN\n' + 'CALSCALE:GREGORIAN\n' +
'BEGIN:VTIMEZONE\n' + 'BEGIN:VTIMEZONE\n' +
'TZID:Europe/Berlin\n' + 'TZID:Europe/Berlin\n' +
@@ -73,40 +94,70 @@ const generateHeaderInfo = (ical: iCalFile) => {
'END:VTIMEZONE\n'; 'END:VTIMEZONE\n';
}; };
/**
* Method to generate the ical footer info
* @param ical
*/
const generateFooterInfo = (ical: iCalFile) => { const generateFooterInfo = (ical: iCalFile) => {
ical.footer = 'END:VCALENDAR'; ical.footer = 'END:VCALENDAR';
}; };
/**
* Method to add events to the iCalFile object
* @param ical
* @param event
*/
const addEventToFile = (ical: iCalFile, event: Event) => { const addEventToFile = (ical: iCalFile, event: Event) => {
ical.body.push(createIcalEvent(event)); ical.body.push(createIcalEvent(event));
}; };
/**
* Method to turn an event object into an iCalEvent object
* @param event
*/
const createIcalEvent = (event: Event): iCalEvent => { const createIcalEvent = (event: Event): iCalEvent => {
let description = event.description ? event.description + '\n' : '';
let location = event.location ? event.location + '\n' : '';
let url = event.url ? event.url + '\n' : '';
return { return {
header: 'BEGIN:VEVENT\n', header: 'BEGIN:VEVENT\n',
uid: event.uuid + '\n', uid: event.uuid + '\n',
created: formatDate(event.created_date) + 'Z\n', created: formatDate(event.createdDate) + 'Z\n',
organizer: event.created_by + '\n', organizer: event.createdBy + '\n',
start: formatDate(event.start_datetime) + '\n', start: formatDate(event.startDateTime, event.wholeDay) + '\n',
end: formatDate(event.end_datetime) + '\n', end: formatDate(event.endDateTime, event.wholeDay, true) + '\n',
repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '',
summary: event.name + '\n', summary: event.name + '\n',
description: event.description + '\n', description: description,
location: event.location + '\n', location: location,
url: event.url + '\n', url: url,
wholeDay: event.wholeDay,
footer: 'END:VEVENT\n' footer: 'END:VEVENT\n'
}; };
}; };
const formatDate = (date: Date): string => { /**
* Helper method to format dates in a valid iCal format
* @param date
* @param wholeDayFormat
* @param isEndDate
*/
const formatDate = (date: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
let returnString = ''; let returnString = '';
// We need to do this for whole day events as otherwise the event ends one day too early
if(wholeDayFormat && isEndDate) date.setDate(date.getDate() + 1)
returnString += date.getFullYear(); returnString += date.getFullYear();
returnString += (date.getMonth() + 1).toString().padStart(2, '0'); // +1 Because JS sucks returnString += (date.getMonth() + 1).toString().padStart(2, '0'); // +1 Because JS sucks
returnString += date.getDate().toString().padStart(2, '0'); returnString += date.getDate().toString().padStart(2, '0');
returnString += 'T'; if(!wholeDayFormat) {
returnString += date.getHours().toString().padStart(2, '0'); returnString += 'T';
returnString += date.getMinutes().toString().padStart(2, '0'); returnString += date.getHours().toString().padStart(2, '0');
returnString += date.getSeconds().toString().padStart(2, '0'); returnString += date.getMinutes().toString().padStart(2, '0');
returnString += date.getSeconds().toString().padStart(2, '0');
}
return returnString; return returnString;
}; };
@@ -128,5 +179,15 @@ export interface iCalEvent {
description: string; description: string;
location: string; location: string;
url: string; url: string;
wholeDay: boolean;
repeatFrequency: string;
footer: string; footer: string;
} }
/**
* Checks if a given string is null, undefined or blank
* @param str The string to check
*/
function isNullOrBlank(str: string | null): boolean {
return str === null || str === undefined || str.trim() === '';
}
@@ -0,0 +1,53 @@
/**
* @swagger
* components:
* schemas:
* Session:
* type: object
* required:
* - sessionId
* - userId
* - sessionKey
* - sessionKeyHash
* - lastIP
* properties:
* sessionId:
* type: integer
* description: The unique identifier for the session
* example: 789
* userId:
* type: integer
* description: The ID of the user this session belongs to
* example: 456
* sessionKey:
* type: string
* description: The session key used for authentication
* example: "abc123def456"
* sessionKeyHash:
* type: string
* description: The hashed session key (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* createdDate:
* type: string
* format: date-time
* description: The date and time when the session was created
* example: "2023-05-01T10:00:00.000Z"
* validUntil:
* type: string
* format: date-time
* description: The date and time until when the session is valid
* example: "2023-05-08T10:00:00.000Z"
* lastIP:
* type: string
* description: The last IP address used with this session
* example: "192.168.1.1"
*/
export interface Session {
sessionId: number;
userId: number;
sessionKey: string;
sessionKeyHash: string;
createdDate?: Date;
validUntil?: Date;
lastIP: string;
}
@@ -0,0 +1,42 @@
/**
* @swagger
* components:
* schemas:
* User:
* type: object
* required:
* - userId
* - fullName
* - passwordHash
* - email
* - isActive
* properties:
* userId:
* type: integer
* description: The unique identifier for the user
* example: 456
* fullName:
* type: string
* description: The full name of the user
* example: "John Doe"
* passwordHash:
* type: string
* description: The hashed password of the user (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* email:
* type: string
* format: email
* description: The email address of the user
* example: "john.doe@nachklang.art"
* isActive:
* type: boolean
* description: Whether the user account is active
* example: true
*/
export interface User {
userId: number;
fullName: string;
passwordHash: string;
email: string;
isActive: boolean;
}
+671
View File
@@ -0,0 +1,671 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as UserService from './users.service.js';
import {Session} from './session.interface.js';
import {User} from './user.interface.js';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
/**
* Router Definition
*/
export const usersRouter = express.Router();
/**
* Controller Definitions
*/
/**
* @swagger
* /calendar/users/register:
* post:
* summary: Register a new user
* description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* - fullName
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* description: Must be an official Nachklang email address
* password:
* type: string
* format: password
* example: securePassword123
* fullName:
* type: string
* example: John Doe
* responses:
* 201:
* description: User registered successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/register
usersRouter.post('/register', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const fullName: string = req.body.fullName;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email || !fullName) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/;
if(!emailRegex.test(email)) {
res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'}));
return;
}
// Create the user and a session
const session: Session = await UserService.createUser(email, password, fullName, ip);
// Send the session details back to the user
res.status(201).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/activate:
* get:
* summary: Activate a user account
* description: Activates a user account using the provided user ID and activation token.
* tags:
* - calendar
* parameters:
* - in: query
* name: id
* required: true
* schema:
* type: integer
* description: The ID of the user to activate
* - in: query
* name: token
* required: true
* schema:
* type: string
* description: The activation token sent to the user's email
* responses:
* 200:
* description: User activated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: OK
* message:
* type: string
* example: User activated
* 400:
* description: Bad request - missing parameters or activation failed
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Error activating user. Please contact your administrator.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// GET /users/activate
usersRouter.get('/activate', async (req: Request, res: Response) => {
try {
const userId: number = parseInt(req.query.id as string ?? '-1', 10);
const token: string = req.query.token as string ?? '';
if (!userId || !token) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create the user and a session
const success: boolean = await UserService.activateUser(userId, token);
// Send the session details back to the user
if(success) {
res.status(200).send({
'status': 'OK',
'message': 'User activated'
});
return;
}
res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/login:
* post:
* summary: Login a user
* description: Authenticates a user with the provided email and password and returns a session.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* password:
* type: string
* format: password
* example: securePassword123
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 401:
* description: Unauthorized - invalid credentials
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Wrong username and / or password
* sessionId:
* type: integer
* example: -1
* sessionKey:
* type: string
* example: ""
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/login
usersRouter.post('/login', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create a session
const session: Session | null = await UserService.login(email, password, ip);
if (!session || !session.sessionId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''}));
return;
}
// Send the session details back to the user
res.status(200).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/checkSessionValid:
* post:
* summary: Check if a session is valid
* description: Checks if the provided session is valid and returns the user information if it is.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* - sessionKey
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* responses:
* 200:
* description: Session is valid
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Unauthorized - invalid session
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: ["Invalid session"]
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/checkSessionValid
usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
try {
const ip: string = req.socket.remoteAddress ?? '';
const session_id = req.body.sessionId;
const session_key = req.body.sessionKey;
if (!session_id || !session_key) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['No session detected']}));
return;
}
const user: User | null = await UserService.checkSession(session_id, session_key, ip);
if (!user || !user.userId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Invalid session']}));
return;
}
res.status(200).send(user);
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/initiatePasswordReset:
* post:
* summary: Initiates a password reset
* description: Checks if the user exists and if so, initiates a password reset by sending an email to the user.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
*/
usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username given']}));
return;
}
const success: boolean = await UserService.initiatePasswordReset(username);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/finalizePasswordReset:
* post:
* summary: Finalizes the password reset
* description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
* token:
* type: string
* example: 3ccd147f-720b-4e29-a8b7-46b63de31555
* password:
* type: string
* example: ExtremelyBadPassword
*/
usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
const token = req.body.token;
const newPassword = req.body.password;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username, token or password given']}));
return;
}
const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
+296
View File
@@ -0,0 +1,296 @@
import * as dotenv from 'dotenv';
import bcrypt from 'bcrypt';
import {Guid} from 'guid-typescript';
import {User} from './user.interface.js';
import {Session} from './session.interface.js';
import {NachklangCalendarDB} from '../Calendar.db.js';
import {MailService} from '../../../common/common.mail.js';
dotenv.config();
/**
* Data Model Interfaces
*/
/**
* Service Methods
*/
/**
* Creates a user record in the database, also creates a session. Returns the session if successful.
*/
export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Hash password and generate + hash session key
const pwHash = bcrypt.hashSync(password, 10);
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
const activationToken = Guid.create().toString();
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
// Create user entry in SQL
const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
// Get user id of the created user
let userId: number = -1;
for (const row of userIdRes) {
userId = row.user_id;
}
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
// Send email with activation link (after commit so we don't block on email
// delivery). sendMail never throws on a delivery failure - it logs and
// returns false - so a mail-server problem here can't roll back the
// already-committed user and leave registration reporting a false error.
await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`);
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const activateUser = async (userId: number, token: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
const userNameRes = await conn.query(checkTokenQuery, [userId]);
let storedTokenHash = '';
for (const row of userNameRes) {
storedTokenHash = row.activation_token;
}
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
return false;
}
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
const activateRes = await conn.execute(activateQuery, [userId]);
await conn.commit();
return activateRes.affectedRows !== 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
/**
* Checks if the given credentials are valid and creates a new session if they are.
* Returns the session information in case of a successful login
*/
export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved password hash
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
const userRows = await conn.query(query, email);
let savedHash = '';
let userId = -1;
for (const row of userRows) {
savedHash = row.password_hash;
userId = row.user_id;
}
// Check for correct password
if (!bcrypt.compareSync(password, savedHash)) {
return null;
}
// Generate + hash session key
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Checks if the given session information are valid and returns the user information if they are
*/
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved session key hash
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
const sessionRows = await conn.query(query, sessionId);
let savedHash = '';
let userId = -1;
let validUntil = new Date();
for (const row of sessionRows) {
savedHash = row.session_key_hash;
userId = row.user_id;
validUntil = row.valid_until;
}
// Check for correct key
if (!bcrypt.compareSync(sessionKey, savedHash)) {
return null;
}
// Check if the session is still valid
if (validUntil <= new Date()) {
return null;
}
// Update session entry in SQL
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
await conn.query(updateSessionsQuery, [ip, sessionId]);
await conn.commit();
// Get the other required user information
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
const userRows = await conn.query(userQuery, userId);
let email = '';
let fullName = '';
let is_active = false;
for (const row of userRows) {
email = row.email;
fullName = row.full_name;
is_active = row.is_active;
}
// Everything is fine, return user information
return {
userId: userId,
email: email,
passwordHash: 'HIDDEN',
fullName: fullName,
isActive: is_active
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
const userNameRes = await conn.query(checkUsernameQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: number = -1;
let fullName: string = '';
for(let row of userNameRes) {
userId = row.user_id;
fullName = row.full_Name;
}
let resetToken = Guid.create().toString();
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
if(updateRes.affectedRows === 0) {
return false;
}
await conn.commit();
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
const userNameRes = await conn.query(checkTokenQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: string = '';
let tokenHash: string = '';
for(let row of userNameRes) {
userId = row.user_id;
tokenHash = row.pw_reset_token_hash;
}
if(!bcrypt.compareSync(token, tokenHash)) {
return false;
}
const pwHash = bcrypt.hashSync(newPassword, 10);
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
if(updateRes.affectedRows > 0) {
await conn.commit();
return true;
}
return false;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
+18
View File
@@ -0,0 +1,18 @@
import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
dotenv.config();
export namespace NachklangFeedbackDB {
const pool = mariadb.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.FEEDBACK_DB,
connectionLimit: 5
});
export const getConnection = async () => {
return pool.getConnection();
};
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {publicRouter} from './public/public.router.js';
import {adminRouter} from './admin/admin.router.js';
import {sendServerError} from './feedback.errors.js';
/**
* Router Definition
*/
export const feedbackRouter = express.Router();
feedbackRouter.use('/admin', adminRouter);
feedbackRouter.use('/', publicRouter);
/**
* @swagger
* /feedback:
* get:
* summary: Feedback API root endpoint
* description: Returns a welcome message for the Nachklang e.V. Feedback API.
* tags:
* - feedback
* responses:
* 200:
* description: Success
* content:
* text/plain:
* schema:
* type: string
* example: Nachklang e.V. Feedback API Endpoint
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
feedbackRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send('Nachklang e.V. Feedback API Endpoint');
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,123 @@
/**
* @swagger
* components:
* schemas:
* EventAdminSummary:
* type: object
* properties:
* eventId:
* type: integer
* slug:
* type: string
* name:
* type: string
* subtitle:
* type: string
* nullable: true
* eventDate:
* type: string
* format: date
* feedbackDeadline:
* type: string
* format: date-time
* posterImageUrl:
* type: string
* nullable: true
* isPublished:
* type: boolean
* submissionCount:
* type: integer
* EventAdminDetail:
* allOf:
* - $ref: '#/components/schemas/EventAdminSummary'
* - type: object
* properties:
* introText:
* type: string
* nullable: true
* songs:
* type: array
* items:
* $ref: '#/components/schemas/Song'
* questions:
* type: array
* items:
* type: object
* properties:
* eventQuestionId:
* type: integer
* questionId:
* type: integer
* position:
* type: integer
* isActive:
* type: boolean
* AdminQuestion:
* type: object
* properties:
* questionId:
* type: integer
* label:
* type: string
* helpText:
* type: string
* nullable: true
* questionType:
* $ref: '#/components/schemas/QuestionType'
* isArchived:
* type: boolean
*/
import {QuestionType, Song} from '../feedback.interface.js';
export interface EventAdminSummary {
eventId: number;
slug: string;
name: string;
subtitle: string | null;
eventDate: string;
feedbackDeadline: string;
posterImageUrl: string | null;
isPublished: boolean;
submissionCount: number;
}
export interface EventAdminQuestionAssignment {
eventQuestionId: number;
questionId: number;
position: number;
isActive: boolean;
}
export interface EventAdminDetail extends EventAdminSummary {
introText: string | null;
songs: Song[];
questions: EventAdminQuestionAssignment[];
}
export interface CreateEventInput {
name: string;
subtitle?: string;
eventDate: string;
feedbackDeadline?: string;
introText?: string;
posterImageUrl?: string;
}
export interface UpdateEventInput {
name?: string;
subtitle?: string;
eventDate?: string;
feedbackDeadline?: string;
isPublished?: boolean;
introText?: string;
posterImageUrl?: string;
}
export interface AdminQuestion {
questionId: number;
label: string;
helpText: string | null;
questionType: QuestionType;
isArchived: boolean;
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../feedback.auth.js';
import {sendServerError} from '../feedback.errors.js';
import {eventsAdminRouter} from './events.admin.router.js';
import {songsAdminRouter} from './songs.admin.router.js';
import {questionsAdminRouter} from './questions.admin.router.js';
import {reportsAdminRouter} from './reports.admin.router.js';
import * as ReportsAdminService from './reports.admin.service.js';
/**
* Router Definition
*/
export const adminRouter = express.Router();
// Applied once at the top of the admin router tree - every route below
// requires a valid admin session.
adminRouter.use(requireAdminAuth);
/**
* @swagger
* /feedback/admin/me:
* get:
* summary: Validate the current admin session
* description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* fullName:
* type: string
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
});
/**
* @swagger
* /feedback/admin/submissions/{submissionId}:
* delete:
* summary: Delete a single submission
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: submissionId
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Deleted
* 404:
* description: Unknown submission
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
try {
const deleted = await ReportsAdminService.deleteSubmission(Number(req.params.submissionId));
if (!deleted) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
adminRouter.use('/events', eventsAdminRouter);
adminRouter.use('/events', reportsAdminRouter);
adminRouter.use('/songs', songsAdminRouter);
adminRouter.use('/questions', questionsAdminRouter);
+70
View File
@@ -0,0 +1,70 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {formatDatetime} from '../feedback.dates.js';
const CSV_SEPARATOR = ';';
const UTF8_BOM = '';
/**
* RFC 4180 field escaping for a `;`-separated CSV, plus a formula-injection
* guard: a field starting with = + - @ gets a leading apostrophe so
* German-locale Excel never evaluates it as a formula.
*/
export const escapeCsvField = (value: string | number | null | undefined): string => {
let str = value === null || value === undefined ? '' : String(value);
str = str.replace(/\r\n|\r|\n/g, ' ');
if (/^[=+\-@]/.test(str)) {
str = `'${str}`;
}
if (str.includes(CSV_SEPARATOR) || str.includes('"')) {
str = `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const buildCsv = (headers: string[], rows: (string | number | null | undefined)[][]): string => {
const lines = [headers.map(escapeCsvField).join(CSV_SEPARATOR)];
for (const row of rows) {
lines.push(row.map(escapeCsvField).join(CSV_SEPARATOR));
}
return UTF8_BOM + lines.join('\r\n');
};
export const buildResponsesCsv = async (eventId: number): Promise<string> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const rows = await conn.query(
`SELECT sa.submission_id, s.submitted_at, sa.question_label_snapshot, sa.question_type,
sa.song_title_snapshot, sa.rating, sa.text_answer
FROM submission_answers sa
INNER JOIN submissions s ON s.submission_id = sa.submission_id
WHERE sa.event_id = ?
ORDER BY sa.submission_id ASC`,
[eventId]
);
return buildCsv(
['submission_id', 'submitted_at', 'question_label', 'question_type', 'song_title', 'rating', 'text_answer'],
rows.map((r: any) => [r.submission_id, formatDatetime(r.submitted_at), r.question_label_snapshot, r.question_type, r.song_title_snapshot, r.rating, r.text_answer])
);
} finally {
await conn.end();
}
};
export const buildGuestBookCsv = async (eventId: number): Promise<string> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const rows = await conn.query(
'SELECT entry_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at ASC',
[eventId]
);
return buildCsv(
['entry_id', 'submitted_at', 'display_name', 'message'],
rows.map((r: any) => [r.entry_id, formatDatetime(r.created_at), r.display_name, r.message])
);
} finally {
await conn.end();
}
};
@@ -0,0 +1,426 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as EventsAdminService from './events.admin.service.js';
import * as SongsAdminService from './songs.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const eventsAdminRouter = express.Router();
/**
* @swagger
* /feedback/admin/events:
* get:
* summary: List all events (admin)
* description: All events, published or not, past or future, with submission counts.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/EventAdminSummary'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post:
* summary: Create an event
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name, eventDate]
* properties:
* name:
* type: string
* subtitle:
* type: string
* eventDate:
* type: string
* format: date
* feedbackDeadline:
* type: string
* format: date-time
* introText:
* type: string
* posterImageUrl:
* type: string
* responses:
* 201:
* description: Created
* 400:
* description: Missing required fields
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send(await EventsAdminService.listEventsAdmin());
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.post('/', async (req: Request, res: Response) => {
try {
const {name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl} = req.body || {};
if (!name || !eventDate) {
res.status(400).send({status: 'BAD_REQUEST', message: 'name and eventDate are required'});
return;
}
const eventId = await EventsAdminService.createEvent(
{name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl},
res.locals.admin.email
);
res.status(201).send({eventId});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}:
* get:
* summary: Get one event (admin)
* description: Full event detail including setlist and assigned questions.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventAdminDetail'
* 404:
* description: Unknown event
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* put:
* summary: Update an event
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Updated
* 404:
* description: Unknown event
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete:
* summary: Delete an event
* description: Refuses with 409 if submissions exist unless ?force=true is passed.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* - in: query
* name: force
* schema:
* type: boolean
* responses:
* 204:
* description: Deleted
* 404:
* description: Unknown event
* 409:
* description: Submissions exist and force was not set
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
try {
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(event);
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.put('/:eventId', async (req: Request, res: Response) => {
try {
const updated = await EventsAdminService.updateEvent(Number(req.params.eventId), req.body || {});
if (!updated) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
try {
const force = req.query.force === 'true';
const result = await EventsAdminService.deleteEvent(Number(req.params.eventId), force);
if (result === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
if (result === 'HAS_SUBMISSIONS') {
res.status(409).send({status: 'HAS_SUBMISSIONS', message: 'This event has submissions. Pass ?force=true to delete anyway.'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/songs:
* get:
* summary: Get an event's setlist
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post:
* summary: Add a song to an event's setlist
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [title]
* properties:
* title:
* type: string
* composer:
* type: string
* responses:
* 201:
* description: Created
* 400:
* description: Missing title
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
try {
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(event.songs);
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) => {
try {
const {title, composer} = req.body || {};
if (!title) {
res.status(400).send({status: 'BAD_REQUEST', message: 'title is required'});
return;
}
const songId = await SongsAdminService.addSong(Number(req.params.eventId), title, composer || null);
res.status(201).send({songId});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/songs/order:
* put:
* summary: Bulk reorder an event's setlist
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [songIds]
* properties:
* songIds:
* type: array
* items:
* type: integer
* responses:
* 200:
* description: Reordered
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
try {
const songIds: number[] = req.body?.songIds || [];
await EventsAdminService.reorderSongs(Number(req.params.eventId), songIds);
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/questions:
* get:
* summary: Get an event's assigned questions
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* put:
* summary: Bulk-set an event's assigned questions
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [items]
* properties:
* items:
* type: array
* items:
* type: object
* properties:
* questionId:
* type: integer
* position:
* type: integer
* isActive:
* type: boolean
* responses:
* 200:
* description: Saved
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
try {
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(event.questions);
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.put('/:eventId/questions', async (req: Request, res: Response) => {
try {
const items = req.body?.items || [];
await EventsAdminService.setEventQuestions(Number(req.params.eventId), items);
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,286 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {Song} from '../feedback.interface.js';
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface.js';
import {formatDatetime} from '../feedback.dates.js';
const UMLAUT_MAP: Record<string, string> = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue'
};
/**
* Slug base from a name: lowercase, umlaut-transliterated, hyphenated.
* The caller appends the concert year and resolves collisions.
*/
export const slugifyName = (name: string): string => {
const transliterated = name.replace(/[äöüßÄÖÜ]/g, (ch) => UMLAUT_MAP[ch] || ch);
return transliterated
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
};
/**
* Default feedback deadline: event day + 14 days, end of day. Computed
* here (not by the DB) so the admin UI can pre-fill and override it.
*/
export const computeDefaultDeadline = (eventDateIso: string): Date => {
const [year, month, day] = eventDateIso.split('-').map(Number);
return new Date(year, month - 1, day + 14, 23, 59, 59);
};
const mapSummaryRow = (row: any): EventAdminSummary => ({
eventId: row.event_id,
slug: row.slug,
name: row.name,
subtitle: row.subtitle,
eventDate: row.event_date,
feedbackDeadline: row.feedback_deadline,
posterImageUrl: row.poster_image_url,
isPublished: !!row.is_published,
submissionCount: Number(row.submission_count)
});
export const listEventsAdmin = async (): Promise<EventAdminSummary[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = `
SELECT e.event_id, e.slug, e.name, e.subtitle, e.event_date, e.feedback_deadline, e.is_published,
COUNT(s.submission_id) as submission_count
FROM events e
LEFT JOIN submissions s ON s.event_id = e.event_id
GROUP BY e.event_id
ORDER BY e.event_date DESC`;
const rows = await conn.query(query);
return rows.map(mapSummaryRow);
} finally {
await conn.end();
}
};
/**
* Slug base with the concert year appended as a disambiguator - unless the
* name already ends with it (e.g. "Adventskonzert 2026"), which would
* otherwise double up as "adventskonzert-2026-2026".
*/
export const slugBase = (name: string, eventDateIso: string): string => {
const year = eventDateIso.split('-')[0];
const nameSlug = slugifyName(name);
return nameSlug.endsWith(`-${year}`) ? nameSlug : `${nameSlug}-${year}`;
};
const generateUniqueSlug = async (conn: any, name: string, eventDate: string): Promise<string> => {
const base = slugBase(name, eventDate);
let candidate = base;
let suffix = 2;
// Small table, small admin audience - a loop is simpler and safer than
// a clever single query, and collisions will be rare in practice.
while (true) {
const rows = await conn.query('SELECT 1 FROM events WHERE slug = ?', [candidate]);
if (rows.length === 0) return candidate;
candidate = `${base}-${suffix}`;
suffix++;
}
};
export const createEvent = async (input: CreateEventInput, createdByEmail: string): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const slug = await generateUniqueSlug(conn, input.name, input.eventDate);
const deadline = input.feedbackDeadline
? new Date(input.feedbackDeadline)
: computeDefaultDeadline(input.eventDate);
const query = `
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, created_by_email)
VALUES (?,?,?,?,?,?,?,?) RETURNING event_id`;
const res = await conn.query(query, [
slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline),
input.introText || null, input.posterImageUrl || null, createdByEmail
]);
await conn.commit();
return res[0].event_id;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const getEventAdmin = async (eventId: number): Promise<EventAdminDetail | null> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventRows = await conn.query(`
SELECT e.*, COUNT(s.submission_id) as submission_count
FROM events e
LEFT JOIN submissions s ON s.event_id = e.event_id
WHERE e.event_id = ?
GROUP BY e.event_id`, [eventId]);
if (eventRows.length === 0) return null;
const row = eventRows[0];
const songRows = await conn.query('SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC', [eventId]);
const songs: Song[] = songRows.map((r: any) => ({songId: r.song_id, title: r.title, composer: r.composer, position: r.position}));
const questionRows = await conn.query(
'SELECT event_question_id, question_id, position, is_active FROM event_questions WHERE event_id = ? ORDER BY position ASC',
[eventId]
);
const questions: EventAdminQuestionAssignment[] = questionRows.map((r: any) => ({
eventQuestionId: r.event_question_id, questionId: r.question_id, position: r.position, isActive: !!r.is_active
}));
return {
...mapSummaryRow(row),
introText: row.intro_text,
songs,
questions
};
} finally {
await conn.end();
}
};
export const updateEvent = async (eventId: number, input: UpdateEventInput): Promise<boolean> => {
const fields: string[] = [];
const values: any[] = [];
if (input.name !== undefined) { fields.push('name = ?'); values.push(input.name); }
if (input.subtitle !== undefined) { fields.push('subtitle = ?'); values.push(input.subtitle); }
if (input.eventDate !== undefined) { fields.push('event_date = ?'); values.push(input.eventDate); }
if (input.feedbackDeadline !== undefined) { fields.push('feedback_deadline = ?'); values.push(input.feedbackDeadline); }
if (input.isPublished !== undefined) { fields.push('is_published = ?'); values.push(input.isPublished ? 1 : 0); }
if (input.introText !== undefined) { fields.push('intro_text = ?'); values.push(input.introText); }
if (input.posterImageUrl !== undefined) { fields.push('poster_image_url = ?'); values.push(input.posterImageUrl || null); }
if (fields.length === 0) return true;
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
values.push(eventId);
const res = await conn.query(`UPDATE events SET ${fields.join(', ')} WHERE event_id = ?`, values);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type DeleteEventResult = 'DELETED' | 'NOT_FOUND' | 'HAS_SUBMISSIONS';
/**
* Deletes an event and everything under it. Children are deleted in
* explicit dependency order rather than left to the DB's ON DELETE CASCADE
* chain: submission_answers and guest_book_entries are reachable from
* `events` via two different cascade paths (direct event_id FK, and via
* `submissions`/`songs`), and MariaDB can reject that as an ambiguous
* multi-path cascade. See IMPLEMENTATION_PLAN.md Phase 1 notes.
*/
export const deleteEvent = async (eventId: number, force: boolean): Promise<DeleteEventResult> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const eventRows = await conn.query('SELECT event_id FROM events WHERE event_id = ?', [eventId]);
if (eventRows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
const countRows = await conn.query('SELECT COUNT(*) as cnt FROM submissions WHERE event_id = ?', [eventId]);
const submissionCount = Number(countRows[0].cnt);
if (submissionCount > 0 && !force) {
await conn.rollback();
return 'HAS_SUBMISSIONS';
}
await conn.query('DELETE FROM guest_book_entries WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM newsletter_signups WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM submission_answers WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM submissions WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM event_questions WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM songs WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM events WHERE event_id = ?', [eventId]);
await conn.commit();
return 'DELETED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const reorderSongs = async (eventId: number, songIds: number[]): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
for (let i = 0; i < songIds.length; i++) {
await conn.query('UPDATE songs SET position = ? WHERE song_id = ? AND event_id = ?', [i, songIds[i], eventId]);
}
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export interface QuestionAssignmentItem {
questionId: number;
position: number;
isActive: boolean;
}
/**
* Bulk-sets an event's assigned questions in one transaction: inserts new
* assignments, updates existing ones' position/active state, and removes
* ones no longer present in `items`.
*/
export const setEventQuestions = async (eventId: number, items: QuestionAssignmentItem[]): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const existingRows = await conn.query('SELECT question_id FROM event_questions WHERE event_id = ?', [eventId]);
const existingIds = new Set<number>(existingRows.map((r: any) => r.question_id));
const nextIds = new Set<number>(items.map((i) => i.questionId));
for (const existingId of existingIds) {
if (!nextIds.has(existingId)) {
await conn.query('DELETE FROM event_questions WHERE event_id = ? AND question_id = ?', [eventId, existingId]);
}
}
for (const item of items) {
if (existingIds.has(item.questionId)) {
await conn.query(
'UPDATE event_questions SET position = ?, is_active = ? WHERE event_id = ? AND question_id = ?',
[item.position, item.isActive ? 1 : 0, eventId, item.questionId]
);
} else {
await conn.query(
'INSERT INTO event_questions (event_id, question_id, position, is_active) VALUES (?,?,?,?)',
[eventId, item.questionId, item.position, item.isActive ? 1 : 0]
);
}
}
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
@@ -0,0 +1,182 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as QuestionsAdminService from './questions.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const questionsAdminRouter = express.Router();
/**
* @swagger
* /feedback/admin/questions:
* get:
* summary: List the question library
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: query
* name: includeArchived
* schema:
* type: boolean
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/AdminQuestion'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post:
* summary: Create a question
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [label, questionType]
* properties:
* label:
* type: string
* helpText:
* type: string
* questionType:
* $ref: '#/components/schemas/QuestionType'
* responses:
* 201:
* description: Created
* 400:
* description: Missing or invalid fields
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
questionsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
const includeArchived = req.query.includeArchived === 'true';
res.status(200).send(await QuestionsAdminService.listQuestions(includeArchived));
} catch (e: any) {
sendServerError(res, e);
}
});
const VALID_TYPES = ['SONG_PICK', 'SONG_RATING', 'FREE_TEXT'];
questionsAdminRouter.post('/', async (req: Request, res: Response) => {
try {
const {label, helpText, questionType} = req.body || {};
if (!label || !VALID_TYPES.includes(questionType)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'label and a valid questionType are required'});
return;
}
const questionId = await QuestionsAdminService.createQuestion(label, helpText || null, questionType);
res.status(201).send({questionId});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/questions/{questionId}:
* put:
* summary: Edit a question's label/help text
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: questionId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [label]
* properties:
* label:
* type: string
* helpText:
* type: string
* responses:
* 200:
* description: Updated
* 400:
* description: Missing label
* 404:
* description: Unknown question
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete:
* summary: Archive (or hard-delete) a question
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: questionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Archived or deleted
* 404:
* description: Unknown question
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
try {
const {label, helpText} = req.body || {};
if (!label) {
res.status(400).send({status: 'BAD_REQUEST', message: 'label is required'});
return;
}
const updated = await QuestionsAdminService.updateQuestion(Number(req.params.questionId), label, helpText || null);
if (!updated) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
questionsAdminRouter.delete('/:questionId', async (req: Request, res: Response) => {
try {
const result = await QuestionsAdminService.removeQuestion(Number(req.params.questionId));
if (result === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: result});
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,98 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {QuestionType} from '../feedback.interface.js';
import {AdminQuestion} from './admin.interface.js';
const mapRow = (row: any): AdminQuestion => ({
questionId: row.question_id,
label: row.label,
helpText: row.help_text,
questionType: row.question_type,
isArchived: !!row.is_archived
});
export const listQuestions = async (includeArchived: boolean): Promise<AdminQuestion[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = includeArchived
? 'SELECT * FROM questions ORDER BY created_at DESC'
: 'SELECT * FROM questions WHERE is_archived = 0 ORDER BY created_at DESC';
const rows = await conn.query(query);
return rows.map(mapRow);
} finally {
await conn.end();
}
};
export const createQuestion = async (label: string, helpText: string | null, questionType: QuestionType): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query(
'INSERT INTO questions (label, help_text, question_type) VALUES (?,?,?) RETURNING question_id',
[label, helpText, questionType]
);
await conn.commit();
return res[0].question_id;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Edits label/help text only. question_type is immutable after creation -
* changing it would invalidate existing answers' question_type_snapshot
* semantics. The admin UI offers "archive and create new" instead.
*/
export const updateQuestion = async (questionId: number, label: string, helpText: string | null): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query('UPDATE questions SET label = ?, help_text = ? WHERE question_id = ?', [label, helpText, questionId]);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type RemoveQuestionResult = 'ARCHIVED' | 'DELETED' | 'NOT_FOUND';
/**
* Archives (soft delete) a question. Hard-deletes it instead if it has
* never been assigned to any event, so an admin's typo doesn't have to
* live forever in the library.
*/
export const removeQuestion = async (questionId: number): Promise<RemoveQuestionResult> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const existsRows = await conn.query('SELECT 1 FROM questions WHERE question_id = ?', [questionId]);
if (existsRows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
const usageRows = await conn.query('SELECT 1 FROM event_questions WHERE question_id = ? LIMIT 1', [questionId]);
if (usageRows.length === 0) {
await conn.query('DELETE FROM questions WHERE question_id = ?', [questionId]);
await conn.commit();
return 'DELETED';
}
await conn.query('UPDATE questions SET is_archived = 1 WHERE question_id = ?', [questionId]);
await conn.commit();
return 'ARCHIVED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
@@ -0,0 +1,76 @@
import {QuestionType} from '../feedback.interface.js';
export interface SongPickResult {
songId: number;
title: string;
votes: number;
}
export interface SongPickReport {
questionId: number | null;
label: string;
totalVotes: number;
results: SongPickResult[];
}
export interface SongRatingResult {
songId: number;
title: string;
average: number;
count: number;
}
export interface SongRatingReport {
questionId: number | null;
label: string;
results: SongRatingResult[];
}
export interface FreeTextResponse {
submissionId: number;
submittedAt: string;
text: string;
}
export interface FreeTextReport {
questionId: number | null;
label: string;
responses: FreeTextResponse[];
hasMore: boolean;
}
export interface EventReport {
event: {
eventId: number;
name: string;
eventDate: string;
feedbackDeadline: string;
};
totalSubmissions: number;
firstSubmissionAt: string | null;
lastSubmissionAt: string | null;
songPicks: SongPickReport[];
songRatings: SongRatingReport[];
freeText: FreeTextReport[];
guestBookCount: number;
newsletter: {
total: number;
sent: number;
pending: number;
failed: number;
skipped: number;
};
}
/** Raw answer row as read from submission_answers, joined with submissions.submitted_at. */
export interface AnswerRow {
submissionId: number;
submittedAt: string;
questionId: number | null;
questionLabel: string;
questionType: QuestionType;
songId: number | null;
songTitle: string | null;
rating: number | null;
textAnswer: string | null;
}
@@ -0,0 +1,232 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as ReportsAdminService from './reports.admin.service.js';
import * as CsvService from './csv.service.js';
import * as EventsAdminService from './events.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const reportsAdminRouter = express.Router();
/**
* @swagger
* /feedback/admin/events/{eventId}/report:
* get:
* summary: Aggregated feedback report for one event
* description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 404:
* description: Unknown event
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => {
try {
const report = await ReportsAdminService.getReport(Number(req.params.eventId));
if (!report) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(report);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/guestbook:
* get:
* summary: Guest Book entries for one event
* description: Newest first, paginated.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: pageSize
* schema:
* type: integer
* - in: query
* name: search
* description: Filters entries whose name or message contains this text (case-insensitive).
* schema:
* type: string
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50));
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await ReportsAdminService.getGuestBookEntries(Number(req.params.eventId), page, pageSize, search);
res.status(200).send(result);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/newsletter:
* get:
* summary: Newsletter signups for one event
* description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: pageSize
* schema:
* type: integer
* - in: query
* name: search
* description: Filters entries whose first name, last name, or email contains this text (case-insensitive).
* schema:
* type: string
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50));
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await ReportsAdminService.getNewsletterSignups(Number(req.params.eventId), page, pageSize, search);
res.status(200).send(result);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/export/responses.csv:
* get:
* summary: CSV export of all answers for one event
* description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: CSV file
* content:
* text/csv: {}
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => {
try {
const eventId = Number(req.params.eventId);
const event = await EventsAdminService.getEventAdmin(eventId);
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
const csv = await CsvService.buildResponsesCsv(eventId);
res.status(200)
.set('Content-Type', 'text/csv; charset=utf-8')
.set('Content-Disposition', `attachment; filename="nachklang-feedback-${event.slug}.csv"`)
.send(csv);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/export/guestbook.csv:
* get:
* summary: CSV export of Guest Book entries for one event
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: CSV file
* content:
* text/csv: {}
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => {
try {
const eventId = Number(req.params.eventId);
const event = await EventsAdminService.getEventAdmin(eventId);
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
const csv = await CsvService.buildGuestBookCsv(eventId);
res.status(200)
.set('Content-Type', 'text/csv; charset=utf-8')
.set('Content-Disposition', `attachment; filename="nachklang-feedback-guestbook-${event.slug}.csv"`)
.send(csv);
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,282 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {
AnswerRow, EventReport, FreeTextReport, SongPickReport, SongRatingReport
} from './reports.admin.interface.js';
const FREE_TEXT_CAP = 500;
/**
* Pure aggregation over one event's answer rows - no DB access, so it's
* directly unit-testable against fixture data. group key is questionId
* when present, falling back to the label snapshot for answers whose
* question was hard-deleted (question_id IS NULL).
*/
export const aggregateReport = (
eventMeta: {eventId: number; name: string; eventDate: string; feedbackDeadline: string},
submissionStats: {totalSubmissions: number; firstSubmissionAt: string | null; lastSubmissionAt: string | null},
answerRows: AnswerRow[],
guestBookCount: number,
newsletterCounts: {total: number; sent: number; pending: number; failed: number; skipped: number}
): EventReport => {
const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`;
const songPickGroups = new Map<string, AnswerRow[]>();
const songRatingGroups = new Map<string, AnswerRow[]>();
const freeTextGroups = new Map<string, AnswerRow[]>();
for (const row of answerRows) {
const key = groupKey(row);
const target = row.questionType === 'SONG_PICK' ? songPickGroups
: row.questionType === 'SONG_RATING' ? songRatingGroups
: freeTextGroups;
if (!target.has(key)) target.set(key, []);
target.get(key)!.push(row);
}
const songPicks: SongPickReport[] = [...songPickGroups.values()].map((rows) => {
const votesBySong = new Map<number, {title: string; votes: number}>();
for (const row of rows) {
if (row.songId === null || row.songTitle === null) continue;
const entry = votesBySong.get(row.songId) || {title: row.songTitle, votes: 0};
entry.votes += 1;
votesBySong.set(row.songId, entry);
}
const results = [...votesBySong.entries()]
.map(([songId, v]) => ({songId, title: v.title, votes: v.votes}))
.sort((a, b) => b.votes - a.votes);
return {
questionId: rows[0].questionId,
label: rows[0].questionLabel,
totalVotes: results.reduce((sum, r) => sum + r.votes, 0),
results
};
});
const songRatings: SongRatingReport[] = [...songRatingGroups.values()].map((rows) => {
const sumsBySong = new Map<number, {title: string; sum: number; count: number}>();
for (const row of rows) {
if (row.songId === null || row.songTitle === null || row.rating === null) continue;
const entry = sumsBySong.get(row.songId) || {title: row.songTitle, sum: 0, count: 0};
entry.sum += row.rating;
entry.count += 1;
sumsBySong.set(row.songId, entry);
}
const results = [...sumsBySong.entries()]
.map(([songId, v]) => ({songId, title: v.title, average: Math.round((v.sum / v.count) * 10) / 10, count: v.count}))
.sort((a, b) => b.average - a.average);
return {questionId: rows[0].questionId, label: rows[0].questionLabel, results};
});
const freeText: FreeTextReport[] = [...freeTextGroups.values()].map((rows) => {
const sorted = rows
.filter((row) => row.textAnswer !== null)
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime());
const responses = sorted.slice(0, FREE_TEXT_CAP).map((row) => ({
submissionId: row.submissionId,
submittedAt: row.submittedAt,
text: row.textAnswer!
}));
return {
questionId: rows[0].questionId,
label: rows[0].questionLabel,
responses,
hasMore: sorted.length > FREE_TEXT_CAP
};
});
return {
event: eventMeta,
totalSubmissions: submissionStats.totalSubmissions,
firstSubmissionAt: submissionStats.firstSubmissionAt,
lastSubmissionAt: submissionStats.lastSubmissionAt,
songPicks,
songRatings,
freeText,
guestBookCount,
newsletter: newsletterCounts
};
};
export const getReport = async (eventId: number): Promise<EventReport | null> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventRows = await conn.query('SELECT event_id, name, event_date, feedback_deadline FROM events WHERE event_id = ?', [eventId]);
if (eventRows.length === 0) return null;
const eventRow = eventRows[0];
const statsRows = await conn.query(
'SELECT COUNT(*) as cnt, MIN(submitted_at) as first_at, MAX(submitted_at) as last_at FROM submissions WHERE event_id = ?',
[eventId]
);
const stats = statsRows[0];
const answerRows = await conn.query(
`SELECT sa.submission_id, s.submitted_at, sa.question_id, sa.question_label_snapshot,
sa.question_type, sa.song_id, sa.song_title_snapshot, sa.rating, sa.text_answer
FROM submission_answers sa
INNER JOIN submissions s ON s.submission_id = sa.submission_id
WHERE sa.event_id = ?`,
[eventId]
);
const answers: AnswerRow[] = answerRows.map((r: any) => ({
submissionId: r.submission_id,
submittedAt: r.submitted_at,
questionId: r.question_id,
questionLabel: r.question_label_snapshot,
questionType: r.question_type,
songId: r.song_id,
songTitle: r.song_title_snapshot,
rating: r.rating,
textAnswer: r.text_answer
}));
const guestBookRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]);
const guestBookCount = Number(guestBookRows[0].cnt);
const newsletterRows = await conn.query(
`SELECT sync_status, COUNT(*) as cnt FROM newsletter_signups WHERE event_id = ? GROUP BY sync_status`,
[eventId]
);
const newsletterCounts = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 0};
for (const row of newsletterRows) {
const cnt = Number(row.cnt);
newsletterCounts.total += cnt;
if (row.sync_status === 'SENT') newsletterCounts.sent = cnt;
else if (row.sync_status === 'PENDING') newsletterCounts.pending = cnt;
else if (row.sync_status === 'FAILED') newsletterCounts.failed = cnt;
else if (row.sync_status === 'SKIPPED') newsletterCounts.skipped = cnt;
}
return aggregateReport(
{eventId: eventRow.event_id, name: eventRow.name, eventDate: eventRow.event_date, feedbackDeadline: eventRow.feedback_deadline},
{totalSubmissions: Number(stats.cnt), firstSubmissionAt: stats.first_at, lastSubmissionAt: stats.last_at},
answers,
guestBookCount,
newsletterCounts
);
} finally {
await conn.end();
}
};
export interface GuestBookEntry {
entryId: number;
submissionId: number;
submittedAt: string;
displayName: string | null;
message: string | null;
}
// Escapes LIKE wildcards (% and _) so a search term is matched literally,
// not interpreted as a pattern - a search for "50%" must not match everything.
const escapeLikeTerm = (term: string) => term.replace(/[\\%_]/g, (c) => `\\${c}`);
export const getGuestBookEntries = async (
eventId: number,
page: number,
pageSize: number,
search?: string
): Promise<{entries: GuestBookEntry[]; total: number}> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const trimmedSearch = search?.trim();
const whereClause = trimmedSearch
? 'WHERE event_id = ? AND (display_name LIKE ? ESCAPE \'\\\\\' OR message LIKE ? ESCAPE \'\\\\\')'
: 'WHERE event_id = ?';
const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined;
const whereParams = trimmedSearch ? [eventId, likeParam, likeParam] : [eventId];
const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM guest_book_entries ${whereClause}`, whereParams);
const rows = await conn.query(
`SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...whereParams, pageSize, (page - 1) * pageSize]
);
return {
total: Number(totalRows[0].cnt),
entries: rows.map((r: any) => ({
entryId: r.entry_id,
submissionId: r.submission_id,
submittedAt: r.created_at,
displayName: r.display_name,
message: r.message
}))
};
} finally {
await conn.end();
}
};
export interface NewsletterSignupRow {
signupId: number;
firstName: string;
lastName: string;
email: string;
consentAt: string;
syncStatus: string;
lastError: string | null;
}
/**
* Deletes one submission and everything under it (its answers, guest book
* entry, newsletter signup). Single-path deletes by submission_id - unlike
* deleteEvent's multi-path cascade issue, there's only one way to reach each
* child table here, so explicit ordering is for consistency with that
* function's style, not to work around an ambiguous-cascade error.
*/
export const deleteSubmission = async (submissionId: number): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const rows = await conn.query('SELECT submission_id FROM submissions WHERE submission_id = ?', [submissionId]);
if (rows.length === 0) {
await conn.rollback();
return false;
}
await conn.query('DELETE FROM guest_book_entries WHERE submission_id = ?', [submissionId]);
await conn.query('DELETE FROM newsletter_signups WHERE submission_id = ?', [submissionId]);
await conn.query('DELETE FROM submission_answers WHERE submission_id = ?', [submissionId]);
await conn.query('DELETE FROM submissions WHERE submission_id = ?', [submissionId]);
await conn.commit();
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const getNewsletterSignups = async (
eventId: number,
page: number,
pageSize: number,
search?: string
): Promise<{entries: NewsletterSignupRow[]; total: number}> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const trimmedSearch = search?.trim();
const whereClause = trimmedSearch
? 'WHERE event_id = ? AND (first_name LIKE ? ESCAPE \'\\\\\' OR last_name LIKE ? ESCAPE \'\\\\\' OR email LIKE ? ESCAPE \'\\\\\')'
: 'WHERE event_id = ?';
const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined;
const whereParams = trimmedSearch ? [eventId, likeParam, likeParam, likeParam] : [eventId];
const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM newsletter_signups ${whereClause}`, whereParams);
const rows = await conn.query(
`SELECT signup_id, first_name, last_name, email, consent_at, sync_status, last_error FROM newsletter_signups ${whereClause} ORDER BY consent_at DESC LIMIT ? OFFSET ?`,
[...whereParams, pageSize, (page - 1) * pageSize]
);
return {
total: Number(totalRows[0].cnt),
entries: rows.map((r: any) => ({
signupId: r.signup_id, firstName: r.first_name, lastName: r.last_name, email: r.email,
consentAt: r.consent_at, syncStatus: r.sync_status, lastError: r.last_error
}))
};
} finally {
await conn.end();
}
};
@@ -0,0 +1,101 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as SongsAdminService from './songs.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const songsAdminRouter = express.Router();
/**
* @swagger
* /feedback/admin/songs/{songId}:
* put:
* summary: Edit a song's title/composer
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: songId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [title]
* properties:
* title:
* type: string
* composer:
* type: string
* responses:
* 200:
* description: Updated
* 400:
* description: Missing title
* 404:
* description: Unknown song
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete:
* summary: Remove a song
* description: Past answers keep their song_title_snapshot even after the song is removed.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: songId
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Removed
* 404:
* description: Unknown song
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
try {
const {title, composer} = req.body || {};
if (!title) {
res.status(400).send({status: 'BAD_REQUEST', message: 'title is required'});
return;
}
const updated = await SongsAdminService.updateSong(Number(req.params.songId), title, composer || null);
if (!updated) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
songsAdminRouter.delete('/:songId', async (req: Request, res: Response) => {
try {
const deleted = await SongsAdminService.deleteSong(Number(req.params.songId));
if (!deleted) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,56 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
export const addSong = async (eventId: number, title: string, composer: string | null): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const posRows = await conn.query('SELECT COALESCE(MAX(position), -1) + 1 as next_position FROM songs WHERE event_id = ?', [eventId]);
const position = posRows[0].next_position;
const res = await conn.query(
'INSERT INTO songs (event_id, title, composer, position) VALUES (?,?,?,?) RETURNING song_id',
[eventId, title, composer, position]
);
await conn.commit();
return res[0].song_id;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const updateSong = async (songId: number, title: string, composer: string | null): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query('UPDATE songs SET title = ?, composer = ? WHERE song_id = ?', [title, composer, songId]);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Removes a song. submission_answers rows referencing it keep their
* song_title_snapshot (song_id is set to NULL via ON DELETE SET NULL) -
* past answers still say what song was rated, even after the song is gone.
*/
export const deleteSong = async (songId: number): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query('DELETE FROM songs WHERE song_id = ?', [songId]);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
+38
View File
@@ -0,0 +1,38 @@
import {requireAppAccess} from '../admin/admin.middleware.js';
/**
* This file is the ONLY place in the feedback module that knows how admin
* authentication works. No route handler and no service outside this file may
* read session headers or resolve a user itself.
*
* Today: the shared admin identity in `src/models/admin/`. A session cookie
* set by /admin/auth on admin.nachklang.art, plus a `feedback` permission on
* the account. Both are re-checked on every request, so disabling a user or
* taking their feedback permission away takes effect immediately.
*
* Before 2026-09-06 this was a header session against the calendar users
* table, and any activated @nachklang.art account could administer feedback.
* That is why the swap is a one-line binding: everything downstream only ever
* saw `requireAdminAuth` and `res.locals.admin`, and both still mean what
* they meant. What changed is that access is now granted per user rather than
* implied by having an account.
*
* Explicitly forbidden: accepting session credentials from query parameters,
* even "temporarily". That is the exact mistake documented in
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials end
* up in access logs, browser history, proxy logs, and Referer headers.
*/
// The only thing the rest of the feedback module knows about an admin. The
// shared middleware puts a superset of this on res.locals.admin.
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
// Express middleware used by every admin route. On success:
// res.locals.admin = AdminAccess (an AdminIdentity plus permissions), calls
// next(). On failure: 401 when not signed in, 403 when signed in without the
// feedback permission.
export const requireAdminAuth = requireAppAccess('feedback');
+13
View File
@@ -0,0 +1,13 @@
/**
* Formats a Date using its local getters (not toISOString/UTC), so the
* wall-clock time the server is running in is what gets stored/displayed -
* never silently shifted by a UTC conversion. Used both for MySQL DATETIME
* literals (events.admin.service.ts) and CSV export (csv.service.ts): same
* requirement, same format, in either context.
*/
export const formatDatetime = (value: Date | string | null): string => {
if (!value) return '';
const d = value instanceof Date ? value : new Date(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
+18
View File
@@ -0,0 +1,18 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
/**
* The feedback module's standard catch-block response: log with a
* reference guid, never leak the real error message to the client. Every
* router in this module follows this exact convention (see CLAUDE.md).
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
+130
View File
@@ -0,0 +1,130 @@
/**
* @swagger
* components:
* schemas:
* QuestionType:
* type: string
* enum: [SONG_PICK, SONG_RATING, FREE_TEXT]
* Song:
* type: object
* required: [songId, title, position]
* properties:
* songId:
* type: integer
* example: 44
* title:
* type: string
* example: "Abendlied"
* composer:
* type: string
* nullable: true
* example: "Josef Rheinberger"
* position:
* type: integer
* example: 0
* Question:
* type: object
* required: [eventQuestionId, questionId, type, label, position]
* properties:
* eventQuestionId:
* type: integer
* example: 12
* questionId:
* type: integer
* example: 5
* type:
* $ref: '#/components/schemas/QuestionType'
* label:
* type: string
* example: "Welches Stück hat Sie am meisten berührt?"
* helpText:
* type: string
* nullable: true
* position:
* type: integer
* example: 0
* EventSummary:
* type: object
* required: [slug, name, eventDate, feedbackDeadline]
* properties:
* slug:
* type: string
* example: "sommerkonzert-2026"
* name:
* type: string
* example: "Sommerkonzert 2026"
* subtitle:
* type: string
* nullable: true
* eventDate:
* type: string
* format: date
* feedbackDeadline:
* type: string
* format: date-time
* posterImageUrl:
* type: string
* nullable: true
* example: "https://www.nachklang.art/img/nk/image-20260727-214855-851.jpeg"
* EventConfig:
* allOf:
* - $ref: '#/components/schemas/EventSummary'
* - type: object
* properties:
* introText:
* type: string
* nullable: true
* songs:
* type: array
* items:
* $ref: '#/components/schemas/Song'
* questions:
* type: array
* items:
* $ref: '#/components/schemas/Question'
* ProcessingError:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
export type QuestionType = 'SONG_PICK' | 'SONG_RATING' | 'FREE_TEXT';
export interface Song {
songId: number;
title: string;
composer: string | null;
position: number;
}
export interface Question {
eventQuestionId: number;
questionId: number;
type: QuestionType;
label: string;
helpText: string | null;
position: number;
}
export interface EventSummary {
slug: string;
name: string;
subtitle: string | null;
eventDate: string;
feedbackDeadline: string;
posterImageUrl: string | null;
}
export interface EventConfig extends EventSummary {
introText: string | null;
songs: Song[];
questions: Question[];
}
+100
View File
@@ -0,0 +1,100 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
import {NachklangFeedbackDB} from './Feedback.db.js';
dotenv.config();
const RATE_LIMIT_MAX = parseInt(process.env.FEEDBACK_RATE_LIMIT_MAX || '5', 10);
const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.FEEDBACK_RATE_LIMIT_WINDOW_MIN || '10', 10);
const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MIN * 60 * 1000;
if (!process.env.FEEDBACK_IP_SALT) {
// A missing salt would silently degrade hashIp() to unsalted SHA-256,
// which is reversible for the whole IPv4 space in minutes - fail loudly
// instead of persisting deanonymizable data.
throw new Error('FEEDBACK_IP_SALT is required (see .env / CLAUDE.md environment block)');
}
const IP_SALT = process.env.FEEDBACK_IP_SALT;
/**
* Salted hash of the client IP. Never store or log the raw address.
*/
export const hashIp = (ip: string): string => {
return crypto.createHash('sha256').update(IP_SALT + ip).digest('hex');
};
// In-memory sliding window, keyed by ip hash. Resets on process restart —
// acceptable, the DB backstop below covers that gap.
const recentSubmissions = new Map<string, number[]>();
const pruneOld = (timestamps: number[], now: number): number[] => {
return timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
};
// Without this, isRateLimited() would store a Map entry for every distinct
// ip hash it has ever seen - including empty arrays for one-off visitors -
// and nothing would ever remove it, growing unbounded for the process
// lifetime. Sweep periodically so hashes that stop submitting eventually
// drop out even if isRateLimited() is never called for them again.
const sweepInterval = setInterval(() => {
const now = Date.now();
for (const [ipHash, timestamps] of recentSubmissions) {
if (pruneOld(timestamps, now).length === 0) {
recentSubmissions.delete(ipHash);
}
}
}, RATE_LIMIT_WINDOW_MS);
sweepInterval.unref();
/**
* DB backstop for the case where the in-memory counter was reset by a
* process restart. Only queried when the in-memory counter is already
* near the limit, so the common path stays DB-free.
*/
const checkDbBackstop = async (ipHash: string): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = 'SELECT COUNT(*) as cnt FROM submissions WHERE ip_hash = ? AND submitted_at > NOW() - INTERVAL ? MINUTE';
const rows = await conn.query(query, [ipHash, RATE_LIMIT_WINDOW_MIN]);
return Number(rows[0].cnt);
} finally {
await conn.end();
}
};
/**
* Returns true if the given ip hash is currently allowed to submit.
* Does not itself record the submission — call recordSubmission after a
* successful insert.
*/
export const isRateLimited = async (ipHash: string): Promise<boolean> => {
const now = Date.now();
const timestamps = pruneOld(recentSubmissions.get(ipHash) || [], now);
if (timestamps.length > 0) {
recentSubmissions.set(ipHash, timestamps);
} else {
recentSubmissions.delete(ipHash);
}
if (timestamps.length >= RATE_LIMIT_MAX) {
return true;
}
// Close to the limit in memory — fall back to the DB in case the
// process restarted and lost earlier counts.
if (timestamps.length >= RATE_LIMIT_MAX - 1) {
const dbCount = await checkDbBackstop(ipHash);
if (dbCount >= RATE_LIMIT_MAX) {
return true;
}
}
return false;
};
export const recordSubmission = (ipHash: string): void => {
const now = Date.now();
const timestamps = pruneOld(recentSubmissions.get(ipHash) || [], now);
timestamps.push(now);
recentSubmissions.set(ipHash, timestamps);
};
@@ -0,0 +1,100 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import logger from '../../../middleware/logger.js';
import {salesforceApexRestPost} from '../../../common/salesforce.client.js';
// Newsletter opt-ins sync to Salesforce, which already runs a full
// double-opt-in subscription flow (Person Account for existing constituents,
// Lead for everyone else - see the Salesforce repo's
// feature/newsletter-signup-integration branch for the full design notes).
// This is the one file that knows that contract exists; submissions.service.ts
// only ever calls syncNewsletterSignup(signupId) after its own transaction
// commits, fire-and-forget, so a Salesforce outage can never delay or fail a
// visitor's feedback submission. The OAuth token cache and retry-once-on-401
// live in common/salesforce.client.ts, shared with the transactional-email relay.
interface SalesforceSuccessResponse {
status: 'PENDING_CONFIRMATION' | 'ALREADY_SUBSCRIBED';
salesforceObject: 'Lead' | 'Account';
salesforceRecordId: string;
created: boolean;
}
interface NewsletterSignupRow {
signup_id: number;
first_name: string;
last_name: string;
email: string;
event_name: string;
}
const postSignup = (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise<SalesforceSuccessResponse> =>
salesforceApexRestPost<SalesforceSuccessResponse>('/services/apexrest/newsletter/signup', payload);
const markSynced = async (signupId: number, externalId: string): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.query(
`UPDATE newsletter_signups SET sync_status = 'SENT', synced_at = NOW(), external_id = ?, sync_attempts = sync_attempts + 1, last_error = NULL WHERE signup_id = ?`,
[externalId, signupId]
);
} finally {
await conn.end();
}
};
const markFailed = async (signupId: number, errorMessage: string): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.query(
`UPDATE newsletter_signups SET sync_status = 'FAILED', last_error = ?, sync_attempts = sync_attempts + 1 WHERE signup_id = ?`,
[errorMessage.slice(0, 2000), signupId]
);
} finally {
await conn.end();
}
};
/**
* Reads one newsletter_signups row and syncs it to Salesforce. Always
* called after the owning submission's transaction has committed, never
* awaited by the request handler. When SALESFORCE_ENABLED is false, this
* only logs the payload it would have sent - the row's sync_status is
* already 'SKIPPED' from the insert in submissions.service.ts, so there's
* nothing to update.
*/
export const syncNewsletterSignup = async (signupId: number): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
let row: NewsletterSignupRow | undefined;
try {
const rows = await conn.query(
`SELECT ns.signup_id, ns.first_name, ns.last_name, ns.email, e.name AS event_name
FROM newsletter_signups ns JOIN events e ON e.event_id = ns.event_id
WHERE ns.signup_id = ?`,
[signupId]
);
row = rows[0];
} finally {
await conn.end();
}
if (!row) {
logger.error('syncNewsletterSignup: signup not found', {signupId});
return;
}
const payload = {firstName: row.first_name, lastName: row.last_name, email: row.email, eventName: row.event_name};
if (process.env.SALESFORCE_ENABLED !== 'true') {
logger.info('syncNewsletterSignup: SALESFORCE_ENABLED is false, would have sent', {signupId, payload});
return;
}
try {
const result = await postSignup(payload);
await markSynced(signupId, result.salesforceRecordId);
} catch (err: any) {
const message = err?.response?.data?.message || err?.message || 'Unknown error';
logger.error('syncNewsletterSignup failed', {signupId, message});
await markFailed(signupId, message);
}
};
@@ -0,0 +1,104 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface.js';
/**
* Returns all events currently eligible to receive feedback:
* published, on or after their concert day, and before the deadline.
*/
export const getEligibleEvents = async (): Promise<EventSummary[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = `
SELECT slug, name, subtitle, event_date, feedback_deadline, poster_image_url
FROM events
WHERE is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()
ORDER BY event_date DESC`;
const rows = await conn.query(query);
return rows.map((row: any) => ({
slug: row.slug,
name: row.name,
subtitle: row.subtitle,
eventDate: row.event_date,
feedbackDeadline: row.feedback_deadline,
posterImageUrl: row.poster_image_url
}));
} finally {
await conn.end();
}
};
export type EventLookupResult =
| { status: 'OK'; eventId: number; event: EventConfig }
| { status: 'NOT_FOUND' }
| { status: 'CLOSED' };
/**
* Resolves a slug to its full public config: meta, ordered setlist, ordered
* active questions. Distinguishes "unknown slug" from "known but outside
* its feedback window" so callers can respond 404 vs 410. Also used
* internally by the submission flow, which additionally needs `eventId`.
*/
export const getEventConfigBySlug = async (slug: string): Promise<EventLookupResult> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventQuery = `
SELECT event_id, slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, is_published
FROM events WHERE slug = ?`;
const eventRows = await conn.query(eventQuery, [slug]);
if (eventRows.length === 0) {
return {status: 'NOT_FOUND'};
}
const eventRow = eventRows[0];
const eligibleQuery = `
SELECT 1 FROM events
WHERE event_id = ? AND is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()`;
const eligibleRows = await conn.query(eligibleQuery, [eventRow.event_id]);
if (eligibleRows.length === 0) {
return {status: 'CLOSED'};
}
const songsQuery = 'SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC';
const songRows = await conn.query(songsQuery, [eventRow.event_id]);
const songs: Song[] = songRows.map((row: any) => ({
songId: row.song_id,
title: row.title,
composer: row.composer,
position: row.position
}));
const questionsQuery = `
SELECT eq.event_question_id, eq.position, q.question_id, q.question_type, q.label, q.help_text
FROM event_questions eq
INNER JOIN questions q ON q.question_id = eq.question_id
WHERE eq.event_id = ? AND eq.is_active = 1
ORDER BY eq.position ASC`;
const questionRows = await conn.query(questionsQuery, [eventRow.event_id]);
const questions: Question[] = questionRows.map((row: any) => ({
eventQuestionId: row.event_question_id,
questionId: row.question_id,
type: row.question_type,
label: row.label,
helpText: row.help_text,
position: row.position
}));
return {
status: 'OK',
eventId: eventRow.event_id,
event: {
slug: eventRow.slug,
name: eventRow.name,
subtitle: eventRow.subtitle,
eventDate: eventRow.event_date,
feedbackDeadline: eventRow.feedback_deadline,
posterImageUrl: eventRow.poster_image_url,
introText: eventRow.intro_text,
songs,
questions
}
};
} finally {
await conn.end();
}
};
+193
View File
@@ -0,0 +1,193 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import logger from '../../../middleware/logger.js';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service.js';
import {submitFeedback} from './submissions.service.js';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const publicRouter = express.Router();
/**
* True if the honeypot field was filled in — a real visitor never types
* into it, since it's hidden with CSS only. Pulled out as a pure function
* so the short-circuit behaviour is unit-testable without a live DB.
*/
export const isHoneypotTriggered = (body: any): boolean => {
return typeof body?.website === 'string' && body.website.trim().length > 0;
};
/**
* @swagger
* /feedback/events:
* get:
* summary: List currently eligible events
* description: Returns events that are published, on or after their concert day, and before their feedback deadline. An empty array is a valid, expected response.
* tags:
* - feedback
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/EventSummary'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ProcessingError'
*/
publicRouter.get('/events', async (req: Request, res: Response) => {
try {
const events = await getEligibleEvents();
res.status(200).send(events);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/events/{slug}:
* get:
* summary: Get the full public config for one event
* description: Returns event meta, ordered setlist, and ordered active questions. 404 if the slug is unknown, 410 if the event exists but is outside its feedback window.
* tags:
* - feedback
* parameters:
* - in: path
* name: slug
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventConfig'
* 404:
* description: Unknown slug
* 410:
* description: Event exists but feedback is closed
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ProcessingError'
*/
publicRouter.get('/events/:slug', async (req: Request, res: Response) => {
try {
const result = await getEventConfigBySlug(req.params.slug);
if (result.status === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
if (result.status === 'CLOSED') {
res.status(410).send({status: 'FEEDBACK_CLOSED'});
return;
}
res.status(200).send(result.event);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/events/{slug}/submissions:
* post:
* summary: Submit feedback for an event
* description: Every field is optional; the only validation error the public form can produce is EMPTY_SUBMISSION (nothing was filled in). Rate-limited per IP hash and honeypot-checked.
* tags:
* - feedback
* parameters:
* - in: path
* name: slug
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SubmissionRequest'
* responses:
* 201:
* description: Submitted
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SubmissionResponse'
* 400:
* description: Nothing was filled in
* 404:
* description: Unknown slug
* 410:
* description: Event exists but feedback is closed
* 429:
* description: Rate limited
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ProcessingError'
*/
publicRouter.post('/events/:slug/submissions', async (req: Request, res: Response) => {
try {
const body = req.body || {};
// Honeypot: a real visitor never fills this in. Fake success, persist
// nothing, stay silent about it having failed.
if (isHoneypotTriggered(body)) {
logger.info('Feedback honeypot triggered', {slug: req.params.slug});
res.status(201).send({submissionId: -1, newsletterDropped: false});
return;
}
const ipHash = hashIp(req.ip || '');
if (await isRateLimited(ipHash)) {
res.status(429).send({status: 'RATE_LIMITED'});
return;
}
// Count every request that reaches this point against the limit,
// regardless of outcome - an attacker sending EMPTY/NOT_FOUND/CLOSED
// requests still costs DB round-trips per attempt and must not get an
// unlimited number of free ones.
recordSubmission(ipHash);
const result = await submitFeedback(req.params.slug, body, ipHash);
switch (result.status) {
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'CLOSED':
res.status(410).send({status: 'FEEDBACK_CLOSED'});
return;
case 'EMPTY':
res.status(400).send({status: 'EMPTY_SUBMISSION'});
return;
case 'OK':
res.status(201).send({submissionId: result.submissionId, newsletterDropped: result.newsletterDropped});
return;
}
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,98 @@
/**
* @swagger
* components:
* schemas:
* SubmissionRequest:
* type: object
* properties:
* answers:
* type: array
* items:
* type: object
* properties:
* eventQuestionId:
* type: integer
* example: 12
* songId:
* type: integer
* nullable: true
* description: SONG_PICK only
* ratings:
* type: array
* description: SONG_RATING only
* items:
* type: object
* properties:
* songId:
* type: integer
* rating:
* type: integer
* minimum: 1
* maximum: 5
* text:
* type: string
* nullable: true
* description: FREE_TEXT only
* guestBook:
* type: object
* nullable: true
* properties:
* displayName:
* type: string
* nullable: true
* message:
* type: string
* nullable: true
* newsletter:
* type: object
* nullable: true
* properties:
* firstName:
* type: string
* lastName:
* type: string
* email:
* type: string
* website:
* type: string
* description: Honeypot field. Must stay empty; a real visitor never fills it in.
* SubmissionResponse:
* type: object
* properties:
* submissionId:
* type: integer
* example: 91
* newsletterDropped:
* type: boolean
* description: True if the newsletter opt-in was present but failed validation (e.g. a malformed email) - the rest of the submission still saved.
*/
export interface RatingInput {
songId: number;
rating: number;
}
export interface AnswerInput {
eventQuestionId: number;
songId?: number;
ratings?: RatingInput[];
text?: string;
}
export interface GuestBookInput {
displayName?: string;
message?: string;
}
export interface NewsletterInput {
firstName: string;
lastName: string;
email: string;
}
export interface SubmissionRequestBody {
answers?: AnswerInput[];
guestBook?: GuestBookInput;
newsletter?: NewsletterInput;
website?: string;
}
@@ -0,0 +1,229 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {QuestionType} from '../feedback.interface.js';
import {getEventConfigBySlug} from './events.public.service.js';
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface.js';
import {syncNewsletterSignup} from '../integrations/salesforce.service.js';
import logger from '../../../middleware/logger.js';
// Bump when the privacy/consent copy shown next to the newsletter opt-in
// changes; recorded per-signup so a past consent's exact wording is provable.
const CONSENT_TEXT_VERSION = '2026-08-02';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// A real setlist tops out around a few dozen songs and a handful of
// questions, so a legitimate submission never comes close to this. Caps
// total generated rows regardless of how large the client's answers/ratings
// arrays are, bounding the number of INSERTs one request can trigger.
export const MAX_ANSWER_ROWS = 200;
export interface ValidatedAnswerRow {
eventQuestionId: number;
questionId: number;
label: string;
type: QuestionType;
position: number;
songId: number | null;
songTitle: string | null;
rating: number | null;
text: string | null;
}
export interface ValidatedGuestBook {
displayName: string | null;
message: string | null;
}
export interface ValidatedNewsletter {
firstName: string;
lastName: string;
email: string;
}
/**
* Validates raw answers against the event's *actual* active questions and
* songs. Unknown eventQuestionId/songId are ignored rather than erroring —
* a stale tab must not lose someone's comment. Empty answers are dropped
* entirely; "no row" is the canonical representation of "skipped".
*/
export const validateAnswers = (
answers: AnswerInput[],
questionsById: Map<number, {eventQuestionId: number; questionId: number; type: QuestionType; label: string; position: number}>,
songTitleById: Map<number, string>
): ValidatedAnswerRow[] => {
const rows: ValidatedAnswerRow[] = [];
const pushRow = (row: ValidatedAnswerRow): boolean => {
if (rows.length >= MAX_ANSWER_ROWS) return false;
rows.push(row);
return true;
};
outer: for (const answer of answers) {
const question = questionsById.get(answer.eventQuestionId);
if (!question) continue;
if (question.type === 'SONG_PICK') {
if (answer.songId != null && songTitleById.has(answer.songId)) {
if (!pushRow({
eventQuestionId: question.eventQuestionId,
questionId: question.questionId,
label: question.label,
type: 'SONG_PICK',
position: question.position,
songId: answer.songId,
songTitle: songTitleById.get(answer.songId)!,
rating: null,
text: null
})) break outer;
}
} else if (question.type === 'SONG_RATING') {
// De-duplicate by songId (last value wins) before generating rows,
// so a client can't force one row per repeated entry for the same
// song by simply repeating it in the ratings array.
const ratingBySong = new Map<number, number>();
for (const r of answer.ratings || []) {
if (!songTitleById.has(r.songId)) continue;
ratingBySong.set(r.songId, Math.min(5, Math.max(1, Math.round(r.rating))));
}
for (const [songId, clamped] of ratingBySong) {
if (!pushRow({
eventQuestionId: question.eventQuestionId,
questionId: question.questionId,
label: question.label,
type: 'SONG_RATING',
position: question.position,
songId,
songTitle: songTitleById.get(songId)!,
rating: clamped,
text: null
})) break outer;
}
} else if (question.type === 'FREE_TEXT') {
const trimmed = (answer.text || '').trim();
if (trimmed.length > 0) {
if (!pushRow({
eventQuestionId: question.eventQuestionId,
questionId: question.questionId,
label: question.label,
type: 'FREE_TEXT',
position: question.position,
songId: null,
songTitle: null,
rating: null,
text: trimmed.slice(0, 5000)
})) break outer;
}
}
}
return rows;
};
export const validateGuestBook = (input?: GuestBookInput): ValidatedGuestBook | null => {
if (!input) return null;
const displayName = (input.displayName || '').trim().slice(0, 255) || null;
const message = (input.message || '').trim().slice(0, 2000) || null;
if (!displayName && !message) return null;
return {displayName, message};
};
export const validateNewsletter = (input?: NewsletterInput): ValidatedNewsletter | null => {
if (!input) return null;
const firstName = (input.firstName || '').trim().slice(0, 120);
const lastName = (input.lastName || '').trim().slice(0, 120);
const email = (input.email || '').trim().slice(0, 255);
if (!firstName || !lastName || !EMAIL_RE.test(email)) return null;
return {firstName, lastName, email};
};
export type SubmitResult =
| { status: 'OK'; submissionId: number; newsletterDropped: boolean }
| { status: 'NOT_FOUND' }
| { status: 'CLOSED' }
| { status: 'EMPTY' };
/**
* Validates and persists one feedback submission. Re-checks event
* eligibility (the window may have closed between page load and submit),
* validates every answer against the event's live questions/songs, then
* inserts everything in a single transaction.
*/
export const submitFeedback = async (slug: string, body: SubmissionRequestBody, ipHash: string | null): Promise<SubmitResult> => {
const lookup = await getEventConfigBySlug(slug);
if (lookup.status === 'NOT_FOUND') return {status: 'NOT_FOUND'};
if (lookup.status === 'CLOSED') return {status: 'CLOSED'};
const {eventId, event} = lookup;
const questionsById = new Map(event.questions.map(q => [q.eventQuestionId, q]));
const songTitleById = new Map(event.songs.map(s => [s.songId, s.title]));
const answerRows = validateAnswers(body.answers || [], questionsById, songTitleById);
const guestBook = validateGuestBook(body.guestBook);
const newsletter = validateNewsletter(body.newsletter);
// body.newsletter is only sent at all when the visitor had the opt-in
// checkbox on (see FeedbackForm.tsx), so a present-but-invalid block
// (e.g. a mistyped email) is distinguishable from "didn't opt in" - the
// rest of the submission still saves, but the client can tell the
// visitor their newsletter signup specifically didn't go through.
const newsletterDropped = !!body.newsletter && !newsletter;
if (answerRows.length === 0 && !guestBook && !newsletter) {
return {status: 'EMPTY'};
}
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const subQuery = 'INSERT INTO submissions (event_id, ip_hash, has_guestbook, has_newsletter) VALUES (?,?,?,?) RETURNING submission_id';
const subRes = await conn.query(subQuery, [eventId, ipHash, guestBook ? 1 : 0, newsletter ? 1 : 0]);
const submissionId = subRes[0].submission_id;
for (const row of answerRows) {
const ansQuery = `INSERT INTO submission_answers
(submission_id, event_id, question_id, event_question_id, question_label_snapshot, question_type, position_snapshot, song_id, song_title_snapshot, rating, text_answer)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`;
await conn.query(ansQuery, [
submissionId, eventId, row.questionId, row.eventQuestionId, row.label, row.type, row.position,
row.songId, row.songTitle, row.rating, row.text
]);
}
if (guestBook) {
const gbQuery = 'INSERT INTO guest_book_entries (submission_id, event_id, display_name, message) VALUES (?,?,?,?)';
await conn.query(gbQuery, [submissionId, eventId, guestBook.displayName, guestBook.message]);
}
let newsletterSignupId: number | null = null;
if (newsletter) {
// The signup is always persisted locally first, regardless of sync
// outcome - syncNewsletterSignup (fired after commit, below) is what
// actually talks to Salesforce and moves PENDING to SENT/FAILED.
const salesforceEnabled = process.env.SALESFORCE_ENABLED === 'true';
const nlQuery = `INSERT INTO newsletter_signups
(submission_id, event_id, first_name, last_name, email, consent_text_version, sync_status)
VALUES (?,?,?,?,?,?,?) RETURNING signup_id`;
const nlRes = await conn.query(nlQuery, [
submissionId, eventId, newsletter.firstName, newsletter.lastName, newsletter.email,
CONSENT_TEXT_VERSION, salesforceEnabled ? 'PENDING' : 'SKIPPED'
]);
newsletterSignupId = nlRes[0].signup_id;
}
await conn.commit();
if (newsletterSignupId !== null) {
const signupId = newsletterSignupId;
void syncNewsletterSignup(signupId).catch((err) => {
logger.error('syncNewsletterSignup threw outside its own error handling', {signupId, error: String(err)});
});
}
return {status: 'OK', submissionId, newsletterDropped};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
+18
View File
@@ -0,0 +1,18 @@
import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
dotenv.config();
export namespace NachklangTicketsDB {
const pool = mariadb.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.TICKETS_DB,
connectionLimit: 5
});
export const getConnection = async () => {
return pool.getConnection();
};
}
+8
View File
@@ -0,0 +1,8 @@
import express from 'express';
import {adminRouter} from './admin/admin.router.js';
import {publicRouter} from './public/public.router.js';
export const ticketsRouter = express.Router();
ticketsRouter.use('/admin', adminRouter);
ticketsRouter.use('/', publicRouter);
+36
View File
@@ -0,0 +1,36 @@
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../tickets.auth.js';
import {vouchersAdminRouter} from './vouchers.admin.router.js';
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
import {eventsAdminRouter} from './events.admin.router.js';
export const adminRouter = express.Router();
// Applied once at the top of the admin router tree - every route below
// requires a valid admin session (mirrors Feedback's admin.router.ts).
adminRouter.use(requireAdminAuth);
/**
* @swagger
* /tickets/admin/me:
* get:
* summary: Validate the current admin session
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
});
adminRouter.use('/vouchers', vouchersAdminRouter);
adminRouter.use('/vouchers', voucherHistoryRouter);
adminRouter.use('/redemptions', redemptionsAdminRouter);
adminRouter.use('/events', eventsAdminRouter);
@@ -0,0 +1,187 @@
import express, {Request, Response} from 'express';
import * as EventsAdminService from './events.admin.service.js';
import {sendServerError} from '../tickets.errors.js';
export const eventsAdminRouter = express.Router();
/**
* @swagger
* /tickets/admin/events:
* get:
* summary: List concerts for the admin event picker
* description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events).
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send(await EventsAdminService.listEventsForPicker());
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/events/available:
* get:
* summary: List public-calendar events not yet added to the ticket shop
* description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
try {
res.status(200).send(await EventsAdminService.listAvailableEventsToAdd());
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/events/{eventId}/stats:
* get:
* summary: Get a concert's voucher/capacity stats
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventStats'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => {
try {
res.status(200).send(await EventsAdminService.getEventStats(Number(req.params.eventId)));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/events/{eventId}/settings:
* put:
* summary: Set a concert's voucher settings
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* capacity:
* type: integer
* nullable: true
* redemptionDeadline:
* type: string
* format: date-time
* nullable: true
* collectAddress:
* type: boolean
* requireAddress:
* type: boolean
* description: Only meaningful when collectAddress is true.
* responses:
* 200:
* description: Saved
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
try {
const {capacity, redemptionDeadline, collectAddress, requireAddress} = req.body || {};
await EventsAdminService.setEventSettings(Number(req.params.eventId), {
capacity: capacity ?? null,
// The mariadb driver needs an actual Date to serialize a DATETIME
// column correctly - a raw ISO string (as arrives over JSON) gets
// rejected with "Incorrect datetime value".
redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null,
collectAddress: !!collectAddress,
requireAddress: !!collectAddress && !!requireAddress
});
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/events/{eventId}/settings:
* delete:
* summary: Remove an event from the ticket shop
* description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Removed
* 409:
* description: Vouchers already reference this event
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => {
try {
const result = await EventsAdminService.removeEvent(Number(req.params.eventId));
if (result === 'HAS_VOUCHERS') {
res.status(409).send({status: 'HAS_VOUCHERS', message: 'Für dieses Konzert existieren bereits Gutscheine.'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,155 @@
import * as CalendarEventsService from '../../calendar/events/events.service.js';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {EventStats, EventTicketSettings} from '../tickets.interface.js';
// Concerts are managed on the public calendar (calendarId 1) - see
// docs/plan-ticket-shop.md. getAllEventsAdmin includes DRAFT events so
// organizers can generate vouchers for a concert before it's announced.
const PUBLIC_CALENDAR_ID = 1;
export interface EventPickerEntry {
eventId: number;
name: string;
startDateTime: Date;
location: string;
status: string | undefined;
}
/**
* The public calendar holds more than concerts (rehearsal announcements,
* general notices, etc.), and Calendar's own Event has no category field to
* tell them apart. `event_ticket_settings` doubles as the ticket shop's
* allow-list: a Calendar event only appears here once an admin has
* explicitly added it (see addEvent/removeEvent below) - even with every
* setting left at its default (uncapped, no deadline, no address).
*/
export const listEventsForPicker = async (): Promise<EventPickerEntry[]> => {
let conn = await NachklangTicketsDB.getConnection();
let enabledEventIds: number[];
try {
const rows = await conn.query('SELECT event_id FROM event_ticket_settings');
enabledEventIds = rows.map((r: any) => r.event_id);
} finally {
await conn.end();
}
if (enabledEventIds.length === 0) return [];
const events = await Promise.all(enabledEventIds.map(id => CalendarEventsService.getEventById(id)));
return events
.filter((e): e is NonNullable<typeof e> => e !== null && e.status !== 'DELETED')
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status}))
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
};
/**
* Public-calendar events that could be added to the ticket shop but
* haven't been yet - source list for the "add a concert" picker.
*/
export const listAvailableEventsToAdd = async (): Promise<EventPickerEntry[]> => {
let conn = await NachklangTicketsDB.getConnection();
let enabledEventIds: Set<number>;
try {
const rows = await conn.query('SELECT event_id FROM event_ticket_settings');
enabledEventIds = new Set(rows.map((r: any) => r.event_id));
} finally {
await conn.end();
}
const events = await CalendarEventsService.getAllEventsAdmin(PUBLIC_CALENDAR_ID);
return events
.filter(e => e.status !== 'DELETED' && !enabledEventIds.has(e.eventId))
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status}))
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
};
export const getEventStats = async (eventId: number): Promise<EventStats> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const ticketState = await getEventTicketState(conn, eventId);
const countRows = await conn.query(
`SELECT vc.status, COUNT(DISTINCT vc.code) as cnt
FROM voucher_codes vc
INNER JOIN voucher_code_events vce ON vce.code = vc.code
WHERE vce.event_id = ?
GROUP BY vc.status`,
[eventId]
);
let unusedCodes = 0, redeemedCodes = 0, voidCodes = 0;
for (const row of countRows) {
if (row.status === 'UNUSED') unusedCodes = Number(row.cnt);
if (row.status === 'REDEEMED') redeemedCodes = Number(row.cnt);
if (row.status === 'VOID') voidCodes = Number(row.cnt);
}
return {
eventId,
capacity: ticketState.capacity,
redemptionDeadline: ticketState.redemptionDeadline,
collectAddress: ticketState.collectAddress,
requireAddress: ticketState.requireAddress,
guestsUsed: ticketState.guestsUsed,
spotsRemaining: ticketState.spotsRemaining,
unusedCodes,
redeemedCodes,
voidCodes
};
} finally {
await conn.end();
}
};
/**
* Upsert - also doubles as "add this event to the ticket shop" when called
* with all-default values (see listEventsForPicker).
*/
export const setEventSettings = async (eventId: number, settings: Omit<EventTicketSettings, 'eventId'>): Promise<void> => {
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address)`,
[eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0]
);
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type RemoveEventResult = 'REMOVED' | 'HAS_VOUCHERS';
/**
* Removes an event from the ticket shop (deletes its settings row, so it
* drops out of listEventsForPicker and reappears in the "add" list).
* Refuses if vouchers already reference it - existing vouchers/redemptions
* stay valid and keep working even for an event no longer offered for new
* voucher generation, so this only blocks removing one that's still in use.
*/
export const removeEvent = async (eventId: number): Promise<RemoveEventResult> => {
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
const voucherRows = await conn.query('SELECT 1 FROM voucher_code_events WHERE event_id = ? LIMIT 1', [eventId]);
if (voucherRows.length > 0) {
await conn.rollback();
return 'HAS_VOUCHERS';
}
await conn.query('DELETE FROM event_ticket_settings WHERE event_id = ?', [eventId]);
await conn.commit();
return 'REMOVED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
@@ -0,0 +1,301 @@
import express, {Request, Response} from 'express';
import * as RedemptionsAdminService from './redemptions.admin.service.js';
import {sendServerError} from '../tickets.errors.js';
export const redemptionsAdminRouter = express.Router();
/**
* @swagger
* /tickets/admin/redemptions:
* get:
* summary: List redemptions (admin)
* description: Filterable by event and status (ACTIVE/UNDONE).
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: query
* name: eventId
* schema:
* type: integer
* - in: query
* name: status
* schema:
* $ref: '#/components/schemas/RedemptionStatus'
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/RedemptionSummary'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
const eventId = req.query.eventId !== undefined ? Number(req.query.eventId) : undefined;
const status = req.query.status as any;
res.status(200).send(await RedemptionsAdminService.listRedemptions({eventId, status}));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/redemptions/{redemptionId}:
* get:
* summary: Get a single redemption (admin)
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 404:
* description: Unknown redemption
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* patch:
* summary: Edit a redemption's contact info and/or guest list
* description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* contactName:
* type: string
* contactEmail:
* type: string
* contactAddress:
* type: string
* nullable: true
* guestNames:
* type: array
* items:
* type: string
* reason:
* type: string
* responses:
* 200:
* description: Edited
* 404:
* description: Unknown redemption
* 409:
* description: Not active, exceeds max guests, or exceeds remaining capacity
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => {
try {
const redemption = await RedemptionsAdminService.getRedemption(Number(req.params.redemptionId));
if (!redemption) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(redemption);
} catch (e: any) {
sendServerError(res, e);
}
});
redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Response) => {
try {
const {contactName, contactEmail, contactAddress, guestNames, reason} = req.body || {};
const result = await RedemptionsAdminService.editRedemption(
Number(req.params.redemptionId),
{contactName, contactEmail, contactAddress, guestNames},
res.locals.admin.email,
reason || null
);
switch (result.status) {
case 'EDITED':
res.status(200).send({status: 'OK'});
return;
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'NOT_ACTIVE':
res.status(409).send({status: 'NOT_ACTIVE', message: 'This redemption is not active.'});
return;
case 'INVALID_EMAIL':
res.status(400).send({status: 'INVALID_EMAIL', message: 'Die E-Mail-Adresse sieht nicht gültig aus.'});
return;
case 'EXCEEDS_MAX_GUESTS':
res.status(409).send({status: 'EXCEEDS_MAX_GUESTS', maxGuests: result.maxGuests});
return;
case 'CAPACITY_EXCEEDED':
res.status(409).send({status: 'CAPACITY_EXCEEDED', spotsRemaining: result.spotsRemaining});
return;
}
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/redemptions/{redemptionId}/undo:
* post:
* summary: Undo a redemption
* description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* reason:
* type: string
* responses:
* 200:
* description: Undone
* 404:
* description: Unknown redemption
* 409:
* description: Redemption is not active
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => {
try {
const result = await RedemptionsAdminService.undoRedemption(Number(req.params.redemptionId), res.locals.admin.email, req.body?.reason || null);
if (result === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
if (result === 'NOT_ACTIVE') {
res.status(409).send({status: 'NOT_ACTIVE', message: 'This redemption is not active.'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/redemptions/{redemptionId}/resend-confirmation:
* post:
* summary: Resend the redemption confirmation email
* description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The email was accepted for delivery
* 404:
* description: Unknown redemption
* 409:
* description: Redemption is not active
* 502:
* description: The email relay rejected the send
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => {
try {
const result = await RedemptionsAdminService.resendRedemptionConfirmation(Number(req.params.redemptionId));
switch (result) {
case 'SENT':
res.status(200).send({status: 'OK'});
return;
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'NOT_ACTIVE':
res.status(409).send({status: 'NOT_ACTIVE', message: 'This redemption is not active.'});
return;
case 'FAILED':
res.status(502).send({status: 'SEND_FAILED', message: 'Die E-Mail konnte nicht versendet werden. Bitte später erneut versuchen.'});
return;
}
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/vouchers/{code}/history:
* get:
* summary: Get a voucher's admin-action audit trail
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: code
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/AuditLogEntry'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
export const voucherHistoryRouter = express.Router();
voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => {
try {
res.status(200).send(await RedemptionsAdminService.getAuditHistory(req.params.code));
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,274 @@
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email.js';
import {AuditLogEntry, RedemptionSummary} from '../tickets.interface.js';
import {isValidEmail} from '../tickets.validation.js';
const mapRedemptionRow = (row: any, guests: string[]): RedemptionSummary => ({
redemptionId: row.redemption_id,
code: row.code,
eventId: row.event_id,
status: row.status,
contactName: row.contact_name,
contactEmail: row.contact_email,
contactAddress: row.contact_address,
guestCount: row.guest_count,
guests,
redeemedAt: row.redeemed_at,
confirmationEmailStatus: row.confirmation_email_status ?? null
});
export interface ListRedemptionsFilter {
eventId?: number;
status?: 'ACTIVE' | 'UNDONE';
}
export const listRedemptions = async (filter: ListRedemptionsFilter): Promise<RedemptionSummary[]> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const where: string[] = [];
const params: any[] = [];
if (filter.eventId !== undefined) {
where.push('event_id = ?');
params.push(filter.eventId);
}
if (filter.status) {
where.push('status = ?');
params.push(filter.status);
}
let query = 'SELECT * FROM redemptions';
if (where.length > 0) query += ' WHERE ' + where.join(' AND ');
query += ' ORDER BY redeemed_at DESC';
const rows = await conn.query(query, params);
if (rows.length === 0) return [];
const redemptionIds = rows.map((r: any) => r.redemption_id);
const guestRows = await conn.query(
'SELECT redemption_id, name FROM redemption_guests WHERE redemption_id IN (?) ORDER BY redemption_id, position',
[redemptionIds]
);
const guestsByRedemption = new Map<number, string[]>();
for (const g of guestRows) {
const list = guestsByRedemption.get(g.redemption_id) || [];
list.push(g.name);
guestsByRedemption.set(g.redemption_id, list);
}
return rows.map((row: any) => mapRedemptionRow(row, guestsByRedemption.get(row.redemption_id) || []));
} finally {
await conn.end();
}
};
export const getRedemption = async (redemptionId: number): Promise<RedemptionSummary | null> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const rows = await conn.query('SELECT * FROM redemptions WHERE redemption_id = ?', [redemptionId]);
if (rows.length === 0) return null;
const guestRows = await conn.query('SELECT name FROM redemption_guests WHERE redemption_id = ? ORDER BY position', [redemptionId]);
return mapRedemptionRow(rows[0], guestRows.map((g: any) => g.name));
} finally {
await conn.end();
}
};
export type UndoRedemptionResult = 'UNDONE' | 'NOT_FOUND' | 'NOT_ACTIVE';
/**
* Reopens the code (back to UNUSED) and marks the redemption UNDONE
* (soft-state, not deleted - guest names/contact info stay for the audit
* trail). A later re-redemption of the same code creates a new
* redemptions row rather than reviving this one.
*/
export const undoRedemption = async (redemptionId: number, adminEmail: string, reason: string | null): Promise<UndoRedemptionResult> => {
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
const rows = await conn.query('SELECT * FROM redemptions WHERE redemption_id = ? FOR UPDATE', [redemptionId]);
if (rows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
const redemption = rows[0];
if (redemption.status !== 'ACTIVE') {
await conn.rollback();
return 'NOT_ACTIVE';
}
await conn.query('UPDATE redemptions SET status = ? WHERE redemption_id = ?', ['UNDONE', redemptionId]);
await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['UNUSED', redemption.code]);
await conn.query(
'INSERT INTO voucher_audit_log (code, redemption_id, admin_email, action, reason) VALUES (?,?,?,?,?)',
[redemption.code, redemptionId, adminEmail, 'UNDO', reason]
);
await conn.commit();
return 'UNDONE';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export interface EditRedemptionInput {
contactName?: string;
contactEmail?: string;
contactAddress?: string | null;
guestNames?: string[];
}
export type EditRedemptionResult =
| {status: 'EDITED'}
| {status: 'NOT_FOUND'}
| {status: 'NOT_ACTIVE'}
| {status: 'INVALID_EMAIL'}
| {status: 'EXCEEDS_MAX_GUESTS'; maxGuests: number}
| {status: 'CAPACITY_EXCEEDED'; spotsRemaining: number};
/**
* Direct admin correction of a redemption's contact info and/or guest
* list. Only the fields present in `input` are changed. Growing the guest
* count is re-checked against both the voucher's own max_guests and the
* event's remaining capacity (forUpdate=true, same race-safety approach as
* the public redeem path).
*/
export const editRedemption = async (redemptionId: number, input: EditRedemptionInput, adminEmail: string, reason: string | null): Promise<EditRedemptionResult> => {
if (input.contactEmail !== undefined && !isValidEmail(input.contactEmail)) {
return {status: 'INVALID_EMAIL'};
}
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
const rows = await conn.query('SELECT * FROM redemptions WHERE redemption_id = ? FOR UPDATE', [redemptionId]);
if (rows.length === 0) {
await conn.rollback();
return {status: 'NOT_FOUND'};
}
const before = rows[0];
if (before.status !== 'ACTIVE') {
await conn.rollback();
return {status: 'NOT_ACTIVE'};
}
const changeSummary: Record<string, {before: any; after: any}> = {};
const fields: string[] = [];
const values: any[] = [];
if (input.contactName !== undefined && input.contactName !== before.contact_name) {
changeSummary.contactName = {before: before.contact_name, after: input.contactName};
fields.push('contact_name = ?');
values.push(input.contactName);
}
if (input.contactEmail !== undefined && input.contactEmail !== before.contact_email) {
changeSummary.contactEmail = {before: before.contact_email, after: input.contactEmail};
fields.push('contact_email = ?');
values.push(input.contactEmail);
}
if (input.contactAddress !== undefined && input.contactAddress !== before.contact_address) {
changeSummary.contactAddress = {before: before.contact_address, after: input.contactAddress};
fields.push('contact_address = ?');
values.push(input.contactAddress);
}
if (input.guestNames !== undefined) {
const newCount = input.guestNames.length;
const delta = newCount - before.guest_count;
if (delta > 0) {
const voucherRows = await conn.query('SELECT max_guests FROM voucher_codes WHERE code = ?', [before.code]);
const maxGuests = voucherRows[0].max_guests;
if (newCount > maxGuests) {
await conn.rollback();
return {status: 'EXCEEDS_MAX_GUESTS', maxGuests};
}
const ticketState = await getEventTicketState(conn, before.event_id, true);
if (ticketState.spotsRemaining !== null && delta > ticketState.spotsRemaining) {
await conn.rollback();
return {status: 'CAPACITY_EXCEEDED', spotsRemaining: ticketState.spotsRemaining};
}
}
const oldGuestRows = await conn.query('SELECT name FROM redemption_guests WHERE redemption_id = ? ORDER BY position', [redemptionId]);
changeSummary.guests = {before: oldGuestRows.map((g: any) => g.name), after: input.guestNames};
fields.push('guest_count = ?');
values.push(newCount);
await conn.query('DELETE FROM redemption_guests WHERE redemption_id = ?', [redemptionId]);
for (let i = 0; i < input.guestNames.length; i++) {
await conn.query('INSERT INTO redemption_guests (redemption_id, name, position) VALUES (?,?,?)', [redemptionId, input.guestNames[i], i]);
}
}
if (fields.length > 0) {
values.push(redemptionId);
await conn.query(`UPDATE redemptions SET ${fields.join(', ')} WHERE redemption_id = ?`, values);
}
if (Object.keys(changeSummary).length > 0) {
await conn.query(
'INSERT INTO voucher_audit_log (code, redemption_id, admin_email, action, change_summary, reason) VALUES (?,?,?,?,?,?)',
[before.code, redemptionId, adminEmail, 'EDIT', JSON.stringify(changeSummary), reason]
);
}
await conn.commit();
return {status: 'EDITED'};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type ResendConfirmationResult = 'SENT' | 'FAILED' | 'NOT_FOUND' | 'NOT_ACTIVE';
/**
* Rebuilds and re-sends the redemption confirmation email from the stored
* redemption data, then records the new outcome on the row. Used by the admin
* UI's "resend" action on a redemption whose confirmation email failed. Only
* active redemptions can be resent.
*/
export const resendRedemptionConfirmation = async (redemptionId: number): Promise<ResendConfirmationResult> => {
const redemption = await getRedemption(redemptionId);
if (!redemption) return 'NOT_FOUND';
if (redemption.status !== 'ACTIVE') return 'NOT_ACTIVE';
const sent = await sendRedemptionConfirmation({
eventId: redemption.eventId,
contactName: redemption.contactName,
contactEmail: redemption.contactEmail,
guestNames: redemption.guests
});
await recordConfirmationEmailResult(redemptionId, sent);
return sent ? 'SENT' : 'FAILED';
};
export const getAuditHistory = async (code: string): Promise<AuditLogEntry[]> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const rows = await conn.query('SELECT * FROM voucher_audit_log WHERE code = ? ORDER BY created_at DESC', [code]);
return rows.map((row: any) => ({
auditId: row.audit_id,
code: row.code,
redemptionId: row.redemption_id,
adminEmail: row.admin_email,
action: row.action,
// The mariadb driver already deserializes JSON-typed columns into
// objects - only parse if we somehow got a raw string back.
changeSummary: typeof row.change_summary === 'string' ? JSON.parse(row.change_summary) : (row.change_summary ?? null),
reason: row.reason,
createdAt: row.created_at
}));
} finally {
await conn.end();
}
};
@@ -0,0 +1,257 @@
import express, {Request, Response} from 'express';
import * as VouchersAdminService from './vouchers.admin.service.js';
import {sendServerError} from '../tickets.errors.js';
export const vouchersAdminRouter = express.Router();
/**
* @swagger
* /tickets/admin/vouchers:
* get:
* summary: List vouchers (admin)
* description: Filterable by event and status.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: query
* name: eventId
* schema:
* type: integer
* - in: query
* name: status
* schema:
* $ref: '#/components/schemas/VoucherStatus'
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/VoucherCode'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
try {
const eventId = req.query.eventId !== undefined ? Number(req.query.eventId) : undefined;
const status = req.query.status as any;
res.status(200).send(await VouchersAdminService.listVouchers({eventId, status}));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/vouchers/wildcard:
* post:
* summary: Batch-generate wildcard codes
* description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [eventIds, quantity]
* properties:
* eventIds:
* type: array
* items:
* type: integer
* maxGuests:
* type: integer
* default: 2
* quantity:
* type: integer
* responses:
* 201:
* description: Created
* content:
* application/json:
* schema:
* type: object
* properties:
* codes:
* type: array
* items:
* type: string
* 400:
* description: Invalid input
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
try {
const {eventIds, maxGuests, quantity} = req.body || {};
if (!Array.isArray(eventIds) || eventIds.length === 0 || !quantity) {
res.status(400).send({status: 'BAD_REQUEST', message: 'eventIds and quantity are required'});
return;
}
const codes = await VouchersAdminService.generateWildcardBatch(
{eventIds, maxGuests: maxGuests || 2, quantity},
res.locals.admin.email
);
res.status(201).send({codes});
} catch (e: any) {
res.status(400).send({status: 'BAD_REQUEST', message: e.message});
}
});
/**
* @swagger
* /tickets/admin/vouchers/personalized:
* post:
* summary: Bulk-create personalized codes
* description: One code per row (name, email, eligible events, max guests), grouped under one batchId.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [rows]
* properties:
* rows:
* type: array
* items:
* type: object
* required: [name, email, eventIds]
* properties:
* name:
* type: string
* email:
* type: string
* eventIds:
* type: array
* items:
* type: integer
* maxGuests:
* type: integer
* default: 2
* responses:
* 201:
* description: Created
* 400:
* description: Invalid input
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => {
try {
const rows = (req.body?.rows || []).map((r: any) => ({
name: r.name,
email: r.email,
eventIds: r.eventIds || [],
maxGuests: r.maxGuests || 2
}));
const codes = await VouchersAdminService.generatePersonalizedBatch(rows, res.locals.admin.email);
res.status(201).send({codes});
} catch (e: any) {
res.status(400).send({status: 'BAD_REQUEST', message: e.message});
}
});
/**
* @swagger
* /tickets/admin/vouchers/{code}:
* get:
* summary: Get a single voucher (admin)
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: code
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* 404:
* description: Unknown code
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
try {
const voucher = await VouchersAdminService.getVoucher(req.params.code);
if (!voucher) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(voucher);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/vouchers/{code}/void:
* post:
* summary: Void an unredeemed code
* description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: code
* required: true
* schema:
* type: string
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* reason:
* type: string
* responses:
* 200:
* description: Voided
* 404:
* description: Unknown code
* 409:
* description: Code is not in UNUSED status
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => {
try {
const result = await VouchersAdminService.voidCode(req.params.code, res.locals.admin.email, req.body?.reason || null);
if (result === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
if (result === 'NOT_UNUSED') {
res.status(409).send({status: 'NOT_UNUSED', message: 'Only unused codes can be voided.'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,244 @@
import {Guid} from 'guid-typescript';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {generateUniqueCode} from '../tickets.codes.js';
import {VoucherCode, VoucherStatus} from '../tickets.interface.js';
import {isValidEmail} from '../tickets.validation.js';
export interface WildcardGenerateInput {
eventIds: number[];
maxGuests: number;
quantity: number;
}
export interface PersonalizedRowInput {
name: string;
email: string;
eventIds: number[];
maxGuests: number;
}
const mapVoucherRow = (row: any): VoucherCode => ({
code: row.code,
status: row.status,
maxGuests: row.max_guests,
prefillName: row.prefill_name,
prefillEmail: row.prefill_email,
batchId: row.batch_id,
createdByEmail: row.created_by_email,
createdAt: row.created_at,
eligibleEventIds: []
});
/**
* Guards the event allow-list (event_ticket_settings) at the one place
* codes actually get minted - the admin event picker already filters to
* allow-listed events, but that's cosmetic unless generation enforces the
* same rule server-side. Without this, any event_id could be passed
* directly (bypassing the picker) and get a fully-uncapped, no-deadline
* redeemable code minted for a non-concert Calendar event.
*/
const assertEventsAllowListed = async (conn: any, eventIds: number[]): Promise<void> => {
const uniqueIds = [...new Set(eventIds)];
const rows = await conn.query('SELECT event_id FROM event_ticket_settings WHERE event_id IN (?)', [uniqueIds]);
const allowListed = new Set<number>(rows.map((r: any) => r.event_id));
const missing = uniqueIds.filter(id => !allowListed.has(id));
if (missing.length > 0) {
throw new Error(`event(s) not added to the ticket shop yet: ${missing.join(', ')}`);
}
};
/**
* Attaches eligibleEventIds to a list of voucher rows in one extra query,
* rather than N+1 per code.
*/
const attachEligibleEvents = async (conn: any, vouchers: VoucherCode[]): Promise<VoucherCode[]> => {
if (vouchers.length === 0) return vouchers;
const codes = vouchers.map(v => v.code);
const rows = await conn.query('SELECT code, event_id FROM voucher_code_events WHERE code IN (?)', [codes]);
const byCode = new Map<string, number[]>();
for (const row of rows) {
const list = byCode.get(row.code) || [];
list.push(row.event_id);
byCode.set(row.code, list);
}
for (const voucher of vouchers) {
voucher.eligibleEventIds = byCode.get(voucher.code) || [];
}
return vouchers;
};
/**
* Batch-generates N wildcard codes sharing the same eligible events and
* max-guest count. All codes get the same batchId so the admin UI can group
* "codes generated together" (e.g. for printing a sheet for the conductor).
*/
export const generateWildcardBatch = async (input: WildcardGenerateInput, createdByEmail: string): Promise<string[]> => {
if (input.quantity < 1 || input.quantity > 500) {
throw new Error('quantity must be between 1 and 500');
}
if (input.eventIds.length === 0) {
throw new Error('at least one eligible event is required');
}
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
await assertEventsAllowListed(conn, input.eventIds);
const existingRows = await conn.query('SELECT code FROM voucher_codes');
const existingCodes = new Set<string>(existingRows.map((r: any) => r.code));
const batchId = Guid.create().toString();
const codes: string[] = [];
for (let i = 0; i < input.quantity; i++) {
const code = generateUniqueCode(existingCodes);
codes.push(code);
await conn.query(
'INSERT INTO voucher_codes (code, status, max_guests, batch_id, created_by_email) VALUES (?,?,?,?,?)',
[code, 'UNUSED', input.maxGuests, batchId, createdByEmail]
);
for (const eventId of input.eventIds) {
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
}
}
await conn.commit();
return codes;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Bulk-creates personalized codes from a list of rows (repeating-row admin
* UI - see docs/plan-ticket-shop.md). One code per row, all sharing a
* batchId for the submission.
*/
export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], createdByEmail: string): Promise<string[]> => {
if (rows.length === 0) {
throw new Error('at least one row is required');
}
for (const row of rows) {
if (!row.name || !row.email || row.eventIds.length === 0) {
throw new Error('each row requires a name, email, and at least one eligible event');
}
if (!isValidEmail(row.email)) {
throw new Error(`"${row.email}" does not look like a valid email address`);
}
}
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
await assertEventsAllowListed(conn, rows.flatMap(r => r.eventIds));
const existingRows = await conn.query('SELECT code FROM voucher_codes');
const existingCodes = new Set<string>(existingRows.map((r: any) => r.code));
const batchId = Guid.create().toString();
const codes: string[] = [];
for (const row of rows) {
const code = generateUniqueCode(existingCodes);
codes.push(code);
await conn.query(
'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)',
[code, 'UNUSED', row.maxGuests, row.name, row.email, batchId, createdByEmail]
);
for (const eventId of row.eventIds) {
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
}
}
await conn.commit();
return codes;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export interface ListVouchersFilter {
eventId?: number;
status?: VoucherStatus;
}
export const listVouchers = async (filter: ListVouchersFilter): Promise<VoucherCode[]> => {
let conn = await NachklangTicketsDB.getConnection();
try {
let query = 'SELECT vc.* FROM voucher_codes vc';
const params: any[] = [];
const where: string[] = [];
if (filter.eventId !== undefined) {
query += ' INNER JOIN voucher_code_events vce ON vce.code = vc.code';
where.push('vce.event_id = ?');
params.push(filter.eventId);
}
if (filter.status) {
where.push('vc.status = ?');
params.push(filter.status);
}
if (where.length > 0) {
query += ' WHERE ' + where.join(' AND ');
}
query += ' GROUP BY vc.code ORDER BY vc.created_at DESC';
const rows = await conn.query(query, params);
const vouchers = rows.map(mapVoucherRow);
return await attachEligibleEvents(conn, vouchers);
} finally {
await conn.end();
}
};
export const getVoucher = async (code: string): Promise<VoucherCode | null> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const rows = await conn.query('SELECT * FROM voucher_codes WHERE code = ?', [code]);
if (rows.length === 0) return null;
const [voucher] = await attachEligibleEvents(conn, [mapVoucherRow(rows[0])]);
return voucher;
} finally {
await conn.end();
}
};
export type VoidCodeResult = 'VOIDED' | 'NOT_FOUND' | 'NOT_UNUSED';
export const voidCode = async (code: string, adminEmail: string, reason: string | null): Promise<VoidCodeResult> => {
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
const rows = await conn.query('SELECT status FROM voucher_codes WHERE code = ?', [code]);
if (rows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
if (rows[0].status !== 'UNUSED') {
await conn.rollback();
return 'NOT_UNUSED';
}
await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['VOID', code]);
await conn.query(
'INSERT INTO voucher_audit_log (code, redemption_id, admin_email, action, reason) VALUES (?,NULL,?,?,?)',
[code, adminEmail, 'VOID', reason]
);
await conn.commit();
return 'VOIDED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
+135
View File
@@ -0,0 +1,135 @@
import express, {Request, Response} from 'express';
import * as VoucherPublicService from './voucher.public.service.js';
import {sendServerError} from '../tickets.errors.js';
import {hashIp, redeemLimiter, validateLimiter} from '../tickets.ratelimit.js';
export const publicRouter = express.Router();
publicRouter.get('/', async (req: Request, res: Response) => {
res.status(200).send('Nachklang e.V. Tickets API Endpoint');
});
const rateLimitGuard = (req: Request, res: Response, limiter: typeof validateLimiter): string | null => {
const ipHash = hashIp(req.ip || '');
if (limiter.isRateLimited(ipHash)) {
res.status(429).send({status: 'RATE_LIMITED', message: 'Zu viele Anfragen. Bitte versuche es später erneut.'});
return null;
}
return ipHash;
};
/**
* @swagger
* /tickets/voucher/{code}:
* get:
* summary: Validate a voucher code
* description: Returns status, prefill data, and eligible events (with deadline/capacity state) for a code. Rate-limited per IP.
* tags: [tickets]
* parameters:
* - in: path
* name: code
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/VoucherValidation'
* 404:
* description: Unknown code
* 429:
* description: Rate limited
*/
publicRouter.get('/voucher/:code', async (req: Request, res: Response) => {
try {
const ipHash = rateLimitGuard(req, res, validateLimiter);
if (!ipHash) return;
validateLimiter.recordRequest(ipHash);
const voucher = await VoucherPublicService.validateVoucher(req.params.code.toUpperCase());
if (!voucher) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(voucher);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/voucher/{code}/redeem:
* post:
* summary: Redeem a voucher code
* description: Marks the code redeemed, records the redemption, and sends a confirmation email with an .ics attachment. Rate-limited per IP.
* tags: [tickets]
* parameters:
* - in: path
* name: code
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/RedeemRequest'
* responses:
* 200:
* description: Redeemed
* 400:
* description: Invalid request
* 404:
* description: Unknown code
* 409:
* description: Code already used, event no longer eligible, deadline passed, or capacity exceeded
* 429:
* description: Rate limited
*/
publicRouter.post('/voucher/:code/redeem', async (req: Request, res: Response) => {
try {
const ipHash = rateLimitGuard(req, res, redeemLimiter);
if (!ipHash) return;
redeemLimiter.recordRequest(ipHash);
const code = req.params.code.toUpperCase();
const {eventId, contactName, contactEmail, contactAddress, guests} = req.body || {};
if (!eventId || !contactName || !contactEmail || !Array.isArray(guests)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'eventId, contactName, contactEmail, and guests are required'});
return;
}
const result = await VoucherPublicService.redeemVoucher(code, {eventId, contactName, contactEmail, contactAddress, guests});
switch (result.status) {
case 'OK':
res.status(200).send({status: 'OK', redemptionId: result.redemptionId});
return;
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'ALREADY_USED':
res.status(409).send({status: 'ALREADY_USED', message: 'Dieser Code wurde bereits eingelöst.'});
return;
case 'INVALID_EVENT':
res.status(409).send({status: 'INVALID_EVENT', message: 'Dieses Konzert ist für diesen Code nicht verfügbar.'});
return;
case 'DEADLINE_PASSED':
res.status(409).send({status: 'DEADLINE_PASSED', message: 'Die Anmeldefrist für dieses Konzert ist abgelaufen.'});
return;
case 'CAPACITY_EXCEEDED':
res.status(409).send({status: 'CAPACITY_EXCEEDED', message: 'Nicht genügend freie Plätze für dieses Konzert.', spotsRemaining: result.spotsRemaining});
return;
case 'ADDRESS_REQUIRED':
res.status(409).send({status: 'ADDRESS_REQUIRED', message: 'Für dieses Konzert ist eine Adresse erforderlich.'});
return;
}
} catch (e: any) {
res.status(400).send({status: 'BAD_REQUEST', message: e.message});
}
});
@@ -0,0 +1,184 @@
import * as EventsService from '../../calendar/events/events.service.js';
import logger from '../../../middleware/logger.js';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email.js';
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface.js';
import {isValidEmail} from '../tickets.validation.js';
/**
* Builds the eligible-events list for a code: for each event it's linked
* to, merges live Calendar event details with the Tickets module's own
* capacity/deadline state. Events deleted from the calendar since the code
* was generated are silently skipped rather than erroring. DRAFT events are
* deliberately still eligible - vouchers are sometimes sent out before a
* concert is publicly announced (see docs/plan-ticket-shop.md), so only
* DELETED is excluded here, not draft/unpublished status.
*/
export const validateVoucher = async (code: string): Promise<VoucherValidation | null> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const voucherRows = await conn.query('SELECT * FROM voucher_codes WHERE code = ?', [code]);
if (voucherRows.length === 0) {
return null;
}
const voucher = voucherRows[0];
const eventIdRows = await conn.query('SELECT event_id FROM voucher_code_events WHERE code = ?', [code]);
const eligibleEvents: EligibleEvent[] = [];
const now = new Date();
for (const row of eventIdRows) {
const eventId = row.event_id;
const event = await EventsService.getEventById(eventId);
if (!event || event.status === 'DELETED') continue;
const ticketState = await getEventTicketState(conn, eventId);
eligibleEvents.push({
eventId,
name: event.name,
startDateTime: event.startDateTime,
location: event.location,
deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline),
isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0,
spotsRemaining: ticketState.spotsRemaining,
collectAddress: ticketState.collectAddress,
requireAddress: ticketState.requireAddress
});
}
eligibleEvents.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
return {
code: voucher.code,
status: voucher.status,
maxGuests: voucher.max_guests,
prefillName: voucher.prefill_name,
prefillEmail: voucher.prefill_email,
eligibleEvents
};
} finally {
await conn.end();
}
};
export type RedeemResult =
| {status: 'OK'; redemptionId: number}
| {status: 'NOT_FOUND'}
| {status: 'ALREADY_USED'}
| {status: 'INVALID_EVENT'}
| {status: 'DEADLINE_PASSED'}
| {status: 'CAPACITY_EXCEEDED'; spotsRemaining: number}
| {status: 'ADDRESS_REQUIRED'};
export const redeemVoucher = async (code: string, request: RedeemRequest): Promise<RedeemResult> => {
if (!request.contactName || !request.contactEmail || !Array.isArray(request.guests) || request.guests.length === 0) {
throw new Error('contactName, contactEmail, and at least one guest are required');
}
if (!isValidEmail(request.contactEmail)) {
throw new Error('contactEmail does not look like a valid email address');
}
for (const guest of request.guests) {
if (!guest.name) {
throw new Error('every guest requires a name');
}
}
let conn = await NachklangTicketsDB.getConnection();
let redemptionId: number;
let eventId: number;
try {
await conn.beginTransaction();
// Locks the code row so two concurrent requests for the same code
// can't both pass the "still UNUSED" check.
const voucherRows = await conn.query('SELECT * FROM voucher_codes WHERE code = ? FOR UPDATE', [code]);
if (voucherRows.length === 0) {
await conn.rollback();
return {status: 'NOT_FOUND'};
}
const voucher = voucherRows[0];
if (voucher.status !== 'UNUSED') {
await conn.rollback();
return {status: 'ALREADY_USED'};
}
const eligibleRows = await conn.query('SELECT 1 FROM voucher_code_events WHERE code = ? AND event_id = ?', [code, request.eventId]);
if (eligibleRows.length === 0) {
await conn.rollback();
return {status: 'INVALID_EVENT'};
}
// A concert can be canceled/deleted from the calendar after codes were
// issued - without this, such a code would stay silently redeemable.
// DRAFT is deliberately still allowed (see validateVoucher's comment).
const event = await EventsService.getEventById(request.eventId);
if (!event || event.status === 'DELETED') {
await conn.rollback();
return {status: 'INVALID_EVENT'};
}
if (request.guests.length > voucher.max_guests) {
await conn.rollback();
throw new Error(`this code allows at most ${voucher.max_guests} guests`);
}
// forUpdate=true serializes concurrent redemptions against this event
// so the capacity check below can't race past a hard cap.
const ticketState = await getEventTicketState(conn, request.eventId, true);
const now = new Date();
if (ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline)) {
await conn.rollback();
return {status: 'DEADLINE_PASSED'};
}
if (ticketState.spotsRemaining !== null && request.guests.length > ticketState.spotsRemaining) {
await conn.rollback();
return {status: 'CAPACITY_EXCEEDED', spotsRemaining: ticketState.spotsRemaining};
}
if (ticketState.requireAddress && !request.contactAddress?.trim()) {
await conn.rollback();
return {status: 'ADDRESS_REQUIRED'};
}
const contactAddress = ticketState.collectAddress ? (request.contactAddress || null) : null;
const redemptionRes = await conn.query(
'INSERT INTO redemptions (code, event_id, contact_name, contact_email, contact_address, guest_count) VALUES (?,?,?,?,?,?) RETURNING redemption_id',
[code, request.eventId, request.contactName, request.contactEmail, contactAddress, request.guests.length]
);
redemptionId = redemptionRes[0].redemption_id;
eventId = request.eventId;
for (let i = 0; i < request.guests.length; i++) {
await conn.query('INSERT INTO redemption_guests (redemption_id, name, position) VALUES (?,?,?)', [redemptionId, request.guests[i].name, i]);
}
await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['REDEEMED', code]);
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
// Sent after commit, mirroring the Calendar/Feedback convention: a mail
// delivery failure shouldn't roll back a successful redemption, and the
// guest has in fact already secured their spot. The send itself no longer
// throws on a delivery problem; its result is recorded on the redemption
// so staff can spot and resend a failed confirmation from the admin UI.
try {
const sent = await sendRedemptionConfirmation({
eventId,
contactName: request.contactName,
contactEmail: request.contactEmail,
guestNames: request.guests.map(g => g.name)
});
await recordConfirmationEmailResult(redemptionId, sent);
} catch (e: any) {
logger.error('Redemption ' + redemptionId + ' committed but the confirmation email step failed: ' + e.message);
}
return {status: 'OK', redemptionId};
};
+31
View File
@@ -0,0 +1,31 @@
import {requireAppAccess} from '../admin/admin.middleware.js';
/**
* Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in
* the tickets module that knows how admin authentication works. No route
* handler and no service outside this file may read session headers or
* resolve a user itself.
*
* Today: the shared admin identity in `src/models/admin/`. A session cookie
* set by /admin/auth on admin.nachklang.art, plus a `tickets` permission on
* the account. Both are re-checked on every request, so disabling a user or
* taking their tickets permission away takes effect immediately.
*
* Before 2026-09-06 this was a header session against the calendar users
* table, and any activated @nachklang.art account could administer vouchers
* (see docs/plan-ticket-shop.md, which called a roles model out of scope for
* v1). It is in scope now, and lives in the admin module rather than here.
*
* Explicitly forbidden: accepting session credentials from query parameters -
* see DEFERRED_SECURITY.md item 1.
*/
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
// On failure: 401 when not signed in, 403 when signed in without the tickets
// permission.
export const requireAdminAuth = requireAppAccess('tickets');
+41
View File
@@ -0,0 +1,41 @@
export interface EventTicketState {
eventId: number;
capacity: number | null;
redemptionDeadline: Date | null;
collectAddress: boolean;
requireAddress: boolean;
guestsUsed: number;
spotsRemaining: number | null;
}
/**
* Reads an event's voucher settings + live guest count within the caller's
* connection/transaction. Absence of a settings row means uncapped/no
* deadline/no address collection - the "absence over sentinels" convention
* also used by the Feedback module.
*
* Pass forUpdate=true from inside the redeem transaction to lock the
* settings row for the duration of that transaction, serializing concurrent
* redemptions against the same event so the guestsUsed sum computed here
* stays correct even under a last-spot race. Events with no settings row
* (uncapped) don't need this - there's no cap to race against.
*/
export const getEventTicketState = async (conn: any, eventId: number, forUpdate = false): Promise<EventTicketState> => {
const settingsQuery = `SELECT capacity, redemption_deadline, collect_address, require_address FROM event_ticket_settings WHERE event_id = ?${forUpdate ? ' FOR UPDATE' : ''}`;
const settingsRows = await conn.query(settingsQuery, [eventId]);
const capacity = settingsRows.length > 0 ? settingsRows[0].capacity : null;
const redemptionDeadline = settingsRows.length > 0 ? settingsRows[0].redemption_deadline : null;
const collectAddress = settingsRows.length > 0 ? !!settingsRows[0].collect_address : false;
// Only meaningful when collectAddress is also true - the field isn't
// shown/collected at all otherwise, so "required" is moot.
const requireAddress = collectAddress && settingsRows.length > 0 ? !!settingsRows[0].require_address : false;
const usedRows = await conn.query(
"SELECT COALESCE(SUM(guest_count), 0) as used FROM redemptions WHERE event_id = ? AND status = 'ACTIVE'",
[eventId]
);
const guestsUsed = Number(usedRows[0].used);
const spotsRemaining = capacity === null ? null : Math.max(0, capacity - guestsUsed);
return {eventId, capacity, redemptionDeadline, collectAddress, requireAddress, guestsUsed, spotsRemaining};
};
+38
View File
@@ -0,0 +1,38 @@
import * as crypto from 'crypto';
// Excludes 0/O, 1/I/L to avoid look-alike confusion when a code is
// hand-written, read aloud, or typed from a printed fallback under a QR
// code. 31 symbols * 8 chars ≈ 39.6 bits of entropy - effectively
// unguessable combined with rate-limiting on the redeem endpoint.
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 8;
/**
* Generates a single random code. Uses crypto.randomInt (uniform, no
* modulo bias) rather than Math.random() since these gate a real-world
* concert invitation.
*/
export const generateCode = (): string => {
let code = '';
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)];
}
return code;
};
/**
* Generates a code guaranteed not to collide with any row `existingCodes`
* already contains, tries up to `maxAttempts` times before giving up.
* Collisions are astronomically unlikely at this entropy - this exists as
* a correctness backstop, not because collisions are expected.
*/
export const generateUniqueCode = (existingCodes: Set<string>, maxAttempts = 20): string => {
for (let i = 0; i < maxAttempts; i++) {
const candidate = generateCode();
if (!existingCodes.has(candidate)) {
existingCodes.add(candidate);
return candidate;
}
}
throw new Error('Could not generate a unique voucher code after ' + maxAttempts + ' attempts');
};
@@ -0,0 +1,76 @@
import * as EventsService from '../calendar/events/events.service.js';
import * as IcalService from '../calendar/events/icalgenerator.service.js';
import {MailService} from '../../common/common.mail.js';
import logger from '../../middleware/logger.js';
import {NachklangTicketsDB} from './Tickets.db.js';
export type ConfirmationEmailStatus = 'SENT' | 'FAILED';
// The redemption confirmation email is built and sent from here so the public
// redeem path and the admin "resend" action share one copy of the German text
// and the .ics attachment logic.
const formatGermanDateTime = (date: Date): string =>
new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'Europe/Berlin'
}).format(date);
export interface ConfirmationRecipient {
eventId: number;
contactName: string;
contactEmail: string;
guestNames: string[];
}
/**
* Sends the redemption confirmation email for one redemption. Returns whether
* the mail was accepted by the relay. Never throws: a missing event is treated
* as "not sent", and MailService.sendMail already swallows delivery failures.
*/
export const sendRedemptionConfirmation = async (recipient: ConfirmationRecipient): Promise<boolean> => {
const event = await EventsService.getEventById(recipient.eventId);
if (!event) {
logger.error('Confirmation email skipped: event ' + recipient.eventId + ' no longer exists');
return false;
}
const guestList = recipient.guestNames.map(name => `- ${name}`).join('\n');
const body =
`Hallo ${recipient.contactName},\n\n` +
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
`Ort: ${event.location}\n\n` +
`Angemeldete Gäste:\n${guestList}\n\n` +
`Wir freuen uns auf dich!\n\nDein Nachklang-Team`;
let icsAttachment;
try {
const ics = await IcalService.convertToIcal([event]);
icsAttachment = [{filename: 'konzert.ics', content: ics, contentType: 'text/calendar'}];
} catch (e: any) {
// Non-fatal: the confirmation still goes out, just without the calendar file.
logger.warn('Confirmation email for event ' + recipient.eventId + ' sent without .ics attachment: ' + e?.message);
icsAttachment = undefined;
}
return MailService.sendMail(recipient.contactEmail, `Bestätigung: ${event.name}`, body, {attachments: icsAttachment});
};
/**
* Records the outcome of a confirmation-email send on the redemption row so the
* admin UI can flag failures. Best-effort: a failure to write the flag is
* logged, never thrown - the redemption itself already succeeded.
*/
export const recordConfirmationEmailResult = async (redemptionId: number, sent: boolean): Promise<void> => {
const status: ConfirmationEmailStatus = sent ? 'SENT' : 'FAILED';
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.query('UPDATE redemptions SET confirmation_email_status = ? WHERE redemption_id = ?', [status, redemptionId]);
} catch (err: any) {
logger.error('Could not record confirmation email status for redemption ' + redemptionId + ': ' + err?.message);
} finally {
await conn.end();
}
};
+18
View File
@@ -0,0 +1,18 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
/**
* The tickets module's standard catch-block response: log with a reference
* guid, never leak the real error message to the client. Mirrors the
* Feedback module's feedback.errors.ts convention.
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
+307
View File
@@ -0,0 +1,307 @@
/**
* @swagger
* components:
* schemas:
* VoucherStatus:
* type: string
* enum: [UNUSED, REDEEMED, VOID]
* RedemptionStatus:
* type: string
* enum: [ACTIVE, UNDONE]
* EligibleEvent:
* type: object
* required: [eventId, name, startDateTime, location, deadlinePassed, isFull, collectAddress, requireAddress]
* properties:
* eventId:
* type: integer
* example: 42
* name:
* type: string
* example: "Adventskonzert 2026"
* startDateTime:
* type: string
* format: date-time
* location:
* type: string
* deadlinePassed:
* type: boolean
* isFull:
* type: boolean
* spotsRemaining:
* type: integer
* nullable: true
* description: null when the event has no capacity cap set (uncapped)
* collectAddress:
* type: boolean
* requireAddress:
* type: boolean
* VoucherValidation:
* type: object
* required: [code, status, maxGuests, eligibleEvents]
* properties:
* code:
* type: string
* example: "K7F3M9QX"
* status:
* $ref: '#/components/schemas/VoucherStatus'
* maxGuests:
* type: integer
* example: 2
* prefillName:
* type: string
* nullable: true
* prefillEmail:
* type: string
* nullable: true
* eligibleEvents:
* type: array
* items:
* $ref: '#/components/schemas/EligibleEvent'
* RedeemGuest:
* type: object
* required: [name]
* properties:
* name:
* type: string
* example: "Erika Mustermann"
* RedeemRequest:
* type: object
* required: [eventId, contactName, contactEmail, guests]
* properties:
* eventId:
* type: integer
* contactName:
* type: string
* contactEmail:
* type: string
* contactAddress:
* type: string
* nullable: true
* guests:
* type: array
* items:
* $ref: '#/components/schemas/RedeemGuest'
* RedemptionSummary:
* type: object
* required: [redemptionId, code, eventId, status, contactName, contactEmail, guestCount, guests, redeemedAt]
* properties:
* redemptionId:
* type: integer
* code:
* type: string
* eventId:
* type: integer
* status:
* $ref: '#/components/schemas/RedemptionStatus'
* contactName:
* type: string
* contactEmail:
* type: string
* contactAddress:
* type: string
* nullable: true
* guestCount:
* type: integer
* guests:
* type: array
* items:
* type: string
* redeemedAt:
* type: string
* format: date-time
* confirmationEmailStatus:
* type: string
* enum: [SENT, FAILED]
* nullable: true
* description: Outcome of the redemption confirmation email. null until the send resolves.
* VoucherCode:
* type: object
* required: [code, status, maxGuests, createdByEmail, createdAt, eligibleEventIds]
* properties:
* code:
* type: string
* status:
* $ref: '#/components/schemas/VoucherStatus'
* maxGuests:
* type: integer
* prefillName:
* type: string
* nullable: true
* prefillEmail:
* type: string
* nullable: true
* batchId:
* type: string
* nullable: true
* createdByEmail:
* type: string
* createdAt:
* type: string
* format: date-time
* eligibleEventIds:
* type: array
* items:
* type: integer
* EventTicketSettings:
* type: object
* required: [eventId, collectAddress, requireAddress]
* properties:
* eventId:
* type: integer
* capacity:
* type: integer
* nullable: true
* redemptionDeadline:
* type: string
* format: date-time
* nullable: true
* collectAddress:
* type: boolean
* requireAddress:
* type: boolean
* description: Only meaningful when collectAddress is true.
* EventStats:
* type: object
* required: [eventId, collectAddress, requireAddress, guestsUsed, unusedCodes, redeemedCodes, voidCodes]
* properties:
* eventId:
* type: integer
* capacity:
* type: integer
* nullable: true
* redemptionDeadline:
* type: string
* format: date-time
* nullable: true
* collectAddress:
* type: boolean
* requireAddress:
* type: boolean
* guestsUsed:
* type: integer
* spotsRemaining:
* type: integer
* nullable: true
* unusedCodes:
* type: integer
* redeemedCodes:
* type: integer
* voidCodes:
* type: integer
* AuditLogEntry:
* type: object
* required: [auditId, code, adminEmail, action, createdAt]
* properties:
* auditId:
* type: integer
* code:
* type: string
* redemptionId:
* type: integer
* nullable: true
* adminEmail:
* type: string
* action:
* type: string
* enum: [EDIT, VOID, UNDO]
* changeSummary:
* type: object
* nullable: true
* reason:
* type: string
* nullable: true
* createdAt:
* type: string
* format: date-time
*/
export type VoucherStatus = 'UNUSED' | 'REDEEMED' | 'VOID';
export type RedemptionStatus = 'ACTIVE' | 'UNDONE';
export type AuditAction = 'EDIT' | 'VOID' | 'UNDO';
export interface EligibleEvent {
eventId: number;
name: string;
startDateTime: Date;
location: string;
deadlinePassed: boolean;
isFull: boolean;
spotsRemaining: number | null;
collectAddress: boolean;
requireAddress: boolean;
}
export interface VoucherValidation {
code: string;
status: VoucherStatus;
maxGuests: number;
prefillName: string | null;
prefillEmail: string | null;
eligibleEvents: EligibleEvent[];
}
export interface RedeemGuest {
name: string;
}
export interface RedeemRequest {
eventId: number;
contactName: string;
contactEmail: string;
contactAddress?: string | null;
guests: RedeemGuest[];
}
export interface RedemptionSummary {
redemptionId: number;
code: string;
eventId: number;
status: RedemptionStatus;
contactName: string;
contactEmail: string;
contactAddress: string | null;
guestCount: number;
guests: string[];
redeemedAt: Date;
// null until the post-redemption confirmation email send resolves.
confirmationEmailStatus: 'SENT' | 'FAILED' | null;
}
export interface VoucherCode {
code: string;
status: VoucherStatus;
maxGuests: number;
prefillName: string | null;
prefillEmail: string | null;
batchId: string | null;
createdByEmail: string;
createdAt: Date;
eligibleEventIds: number[];
}
export interface EventTicketSettings {
eventId: number;
capacity: number | null;
redemptionDeadline: Date | null;
collectAddress: boolean;
requireAddress: boolean;
}
export interface EventStats extends EventTicketSettings {
guestsUsed: number;
spotsRemaining: number | null;
unusedCodes: number;
redeemedCodes: number;
voidCodes: number;
}
export interface AuditLogEntry {
auditId: number;
code: string;
redemptionId: number | null;
adminEmail: string;
action: AuditAction;
changeSummary: Record<string, unknown> | null;
reason: string | null;
createdAt: Date;
}
+68
View File
@@ -0,0 +1,68 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
dotenv.config();
const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.TICKETS_RATE_LIMIT_WINDOW_MIN || '10', 10);
const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MIN * 60 * 1000;
// Salted per-process (not persisted/configured) - these limiters are
// in-memory-only with no DB backstop, so the salt only needs to survive
// for the current process's lifetime, unlike Feedback's FEEDBACK_IP_SALT
// which also salts a persisted ip_hash column.
const IP_SALT = crypto.randomBytes(32).toString('hex');
export const hashIp = (ip: string): string => {
return crypto.createHash('sha256').update(IP_SALT + ip).digest('hex');
};
/**
* Two independent budgets, not one shared counter: validating a code (GET)
* is a cheap, repeatable lookup a guest's own browser triggers on every
* page load/reload/back-navigation of their redemption link - a shared
* budget with redeem meant a guest could exhaust it just by reloading the
* page a few times before ever submitting. Redeeming (POST) is the
* sensitive, code-consuming action and stays tightly limited; validating
* is limited too (it's still the enumeration vector for guessing codes),
* just with a much larger allowance headroomed for normal page-reload
* behaviour.
*/
const createLimiter = (max: number) => {
const recentRequests = new Map<string, number[]>();
const pruneOld = (timestamps: number[], now: number): number[] => {
return timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
};
const sweepInterval = setInterval(() => {
const now = Date.now();
for (const [ipHash, timestamps] of recentRequests) {
if (pruneOld(timestamps, now).length === 0) {
recentRequests.delete(ipHash);
}
}
}, RATE_LIMIT_WINDOW_MS);
sweepInterval.unref();
return {
isRateLimited: (ipHash: string): boolean => {
const now = Date.now();
const timestamps = pruneOld(recentRequests.get(ipHash) || [], now);
if (timestamps.length > 0) {
recentRequests.set(ipHash, timestamps);
} else {
recentRequests.delete(ipHash);
}
return timestamps.length >= max;
},
recordRequest: (ipHash: string): void => {
const now = Date.now();
const timestamps = pruneOld(recentRequests.get(ipHash) || [], now);
timestamps.push(now);
recentRequests.set(ipHash, timestamps);
}
};
};
export const validateLimiter = createLimiter(parseInt(process.env.TICKETS_VALIDATE_RATE_LIMIT_MAX || '30', 10));
export const redeemLimiter = createLimiter(parseInt(process.env.TICKETS_REDEEM_RATE_LIMIT_MAX || '10', 10));
+6
View File
@@ -0,0 +1,6 @@
// Intentionally permissive - "looks like an email" (something@something.tld),
// not full RFC 5322 validation. Good enough to catch typos without rejecting
// real addresses RFC 5322 edge cases would.
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const isValidEmail = (email: string): boolean => EMAIL_REGEX.test(email.trim());
+96
View File
@@ -0,0 +1,96 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
countActiveAdmins: vi.fn(),
findUserByEmail: vi.fn(),
grantPermission: vi.fn()
}));
vi.mock('../../src/models/admin/invitations/invitations.service.js', () => ({
hasOpenInvitationFor: vi.fn(),
createInvitation: vi.fn()
}));
vi.mock('../../src/models/admin/admin.mail.js', () => ({
sendInvitationMail: vi.fn()
}));
vi.mock('../../src/models/admin/admin.config.js', () => ({
ADMIN_BOOTSTRAP_EMAIL: 'boss@nachklang.art',
ADMIN_APP_URL: 'http://localhost:3002',
isProd: false
}));
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import {sendInvitationMail} from '../../src/models/admin/admin.mail.js';
import {bootstrapAdmin} from '../../src/models/admin/admin.bootstrap.js';
const countActiveAdmins = UsersService.countActiveAdmins as Mock;
const findUserByEmail = UsersService.findUserByEmail as Mock;
const grantPermission = UsersService.grantPermission as Mock;
const hasOpenInvitationFor = InvitationsService.hasOpenInvitationFor as Mock;
const createInvitation = InvitationsService.createInvitation as Mock;
const mockMail = sendInvitationMail as Mock;
beforeEach(() => {
countActiveAdmins.mockReset();
findUserByEmail.mockReset();
grantPermission.mockReset();
hasOpenInvitationFor.mockReset();
createInvitation.mockReset();
mockMail.mockReset();
mockMail.mockResolvedValue(true);
createInvitation.mockResolvedValue({id: 1, token: 'raw-token', expiresAt: new Date()});
});
describe('bootstrapAdmin', () => {
it('does nothing when an active admin already exists', async () => {
countActiveAdmins.mockResolvedValue(1);
await bootstrapAdmin();
expect(createInvitation).not.toHaveBeenCalled();
expect(grantPermission).not.toHaveBeenCalled();
});
it('grants admin directly when the bootstrap address is already a user', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue({id: 'u9', email: 'boss@nachklang.art'});
await bootstrapAdmin();
expect(grantPermission).toHaveBeenCalledWith('u9', 'admin', null);
expect(createInvitation).not.toHaveBeenCalled();
});
it('does not re-invite (or re-mail) while an open invitation exists', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue(null);
hasOpenInvitationFor.mockResolvedValue(true);
await bootstrapAdmin();
expect(createInvitation).not.toHaveBeenCalled();
expect(mockMail).not.toHaveBeenCalled();
});
it('invites with the admin permission when there is nothing to work with', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue(null);
hasOpenInvitationFor.mockResolvedValue(false);
await bootstrapAdmin();
expect(createInvitation).toHaveBeenCalledWith(
'boss@nachklang.art',
'Nachklang Admin',
[{app: 'admin', role: 'access'}],
null
);
expect(mockMail).toHaveBeenCalled();
});
it('never throws when the database is unreachable at boot', async () => {
countActiveAdmins.mockRejectedValue(new Error('connect ECONNREFUSED'));
await expect(bootstrapAdmin()).resolves.toBeUndefined();
});
});
+144
View File
@@ -0,0 +1,144 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
// admin.config calls dotenv.config(), which would read the repo's own .env and
// quietly reintroduce NODE_ENV=development - the exact value several of these
// cases exist to remove. Stub it so the tests see only what they set.
vi.mock('dotenv', () => ({config: vi.fn()}));
/**
* admin.config reads the environment once at import, so every case here has to
* reset the module registry and re-import it. The two things worth pinning are
* the ones that are silent when wrong: which client-IP header is trusted, and
* whether an unset NODE_ENV counts as production.
*/
const ORIGINAL_ENV = {...process.env};
const loadConfig = async () => {
vi.resetModules();
return import('../../src/models/admin/admin.config.js');
};
beforeEach(() => {
process.env = {...ORIGINAL_ENV};
// dotenv.config() in admin.config does not overwrite what is already set,
// so setting these here is enough to keep the local .env out of the test.
process.env.NODE_ENV = 'test';
delete process.env.CLIENT_IP_HEADERS;
delete process.env.TRUSTED_PROXY_IPS;
});
afterEach(() => {
process.env = {...ORIGINAL_ENV};
});
describe('CLIENT_IP_HEADERS', () => {
it('defaults to the single header Plesk nginx sets', async () => {
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('reads a comma-separated list', async () => {
process.env.CLIENT_IP_HEADERS = 'x-real-ip, cf-connecting-ip';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip', 'cf-connecting-ip']);
});
it('trusts nothing when set to "none"', async () => {
// The escape hatch. An empty list is what better-auth reads as "no
// headers" - it only falls back to its own default when the option is
// absent - so this really does stop any header being believed.
process.env.CLIENT_IP_HEADERS = 'none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(true);
});
it('accepts the hatch case-insensitively and with stray whitespace', async () => {
process.env.CLIENT_IP_HEADERS = ' NONE ';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
});
it('treats an empty value as "use the default", not as the hatch', async () => {
// A blank line in a .env must not silently change how requests are
// bucketed - only the explicit word does that.
process.env.CLIENT_IP_HEADERS = '';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('does not mistake a header actually named none-ish for the hatch', async () => {
process.env.CLIENT_IP_HEADERS = 'x-none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-none']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
});
describe('APP_ORIGINS', () => {
beforeEach(() => {
delete process.env.APP_ORIGINS;
});
// These reach better-auth's trustedOrigins, and the step 4 cutover made the
// tickets and feedback origins load-bearing: without them their sign-out
// call is rejected while everything else still works.
it('defaults to the three production frontends', async () => {
const config = await loadConfig();
expect(config.APP_ORIGINS).toEqual([
'https://tickets.nachklang.art',
'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
]);
});
it('is overridden wholesale by the environment, for a staging host', async () => {
process.env.APP_ORIGINS = 'https://tickets.staging.example, https://feedback.staging.example/';
const config = await loadConfig();
expect(config.APP_ORIGINS).toEqual([
'https://tickets.staging.example',
// Trailing slash stripped: an origin with one never matches.
'https://feedback.staging.example'
]);
});
it('always includes the admin app itself in ADMIN_ALLOWED_ORIGINS', async () => {
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
const config = await loadConfig();
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://admin.nachklang.art');
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://tickets.nachklang.art');
});
});
describe('isProd', () => {
it('is false only for the explicit relaxed environments', async () => {
process.env.NODE_ENV = 'development';
expect((await loadConfig()).isProd).toBe(false);
process.env.NODE_ENV = 'test';
expect((await loadConfig()).isProd).toBe(false);
});
it('treats an unset NODE_ENV as production, which is what a bare vhost gives', async () => {
delete process.env.NODE_ENV;
// Strict mode refuses to boot without these; supply them so the import
// gets far enough to answer the question being asked.
process.env.BETTER_AUTH_SECRET = 'x'.repeat(48);
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
expect((await loadConfig()).isProd).toBe(true);
});
it('refuses to start without a signing key outside development', async () => {
delete process.env.NODE_ENV;
delete process.env.BETTER_AUTH_SECRET;
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
await expect(loadConfig()).rejects.toThrow(/BETTER_AUTH_SECRET/);
});
});

Some files were not shown because too many files have changed in this diff Show More