11 Commits

Author SHA1 Message Date
Paddy e62b46945a Add a CLIENT_IP_HEADERS escape hatch and stop the auth handler hanging
Two changes, both from working out how to verify the client-IP configuration
on the Plesk vhost before deploying.

CLIENT_IP_HEADERS=none now trusts no header at all. This covers the one case
where the wrong setting is worse than no setting: if the proxy does not
overwrite the header we trust, any client can send it and mint itself an
unlimited brute-force budget against /sign-in. Falling back to the shared
rate-limit bucket is a nuisance - one noisy client can lock everyone out for
ten seconds at a time - but it fails closed, and it can be reverted from the
environment without a deploy. An empty or unset value still means "use the
default": a stray blank line in a .env must not silently change how requests
are bucketed, so only the explicit word does that. An empty array is what
better-auth reads as "no headers"; it falls back to its own default only when
the option is absent, and [] is truthy.

The TRUSTED_PROXY_IPS boot warning was overstating the risk. It now says that
a single-value header needs no trusted proxies, so seeing it on a plain
single-proxy setup is expected rather than a problem to chase.

Separately: a rejected promise from better-auth's handler used to escape as an
unhandled rejection, leaving the request hanging forever with no response while
the process logged an uncaughtException. Express 4 does not await an async
handler, and nearly every better-auth route touches the admin database, so any
database blip would have done this. Found by pointing ADMIN_DB at a database
the user cannot open while testing the hatch. The handler now answers 503, so
the caller learns and the other domains keep serving; verified as a 24ms
response instead of a hang, with / and /feedback/admin/me unaffected.

test/admin/admin.config.test.ts is new: it pins both branches of the hatch and
the rule that an unset NODE_ENV counts as production. It stubs dotenv, because
admin.config would otherwise read the repo's own .env and quietly reintroduce
NODE_ENV=development - the exact value several of those cases exist to remove.

157 unit tests and 41 integration tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:39:33 +02:00
Paddy 27eb301086 Fail CORS closed when NODE_ENV is unset
The CORS origin check used `NODE_ENV !== 'production'` to decide whether to
allow loopback and private-LAN origins. That is not the same question as "is
this a dev machine" when NODE_ENV is unset - which is exactly what a fresh
Plesk vhost gives you. On such a host the check called it dev and answered
`Access-Control-Allow-Origin: http://localhost:<any port>` together with
`Access-Control-Allow-Credentials: true`. With the admin session cookie scoped
to .nachklang.art, that let any page served from localhost on a signed-in
admin's machine read their data cross-origin.

admin.config.ts already resolves this correctly - only an explicit
'development' or 'test' relaxes anything, so unset is treated as production -
so the CORS check now derives from its `isProd` rather than re-deriving its own
answer from NODE_ENV. Two places asking the same question two different ways
was the bug.

Verified against the built app with a vhost-like environment (no NODE_ENV):
before, Origin http://localhost:9999 came back allowed with credentials; after,
it is rejected, while https://tickets.nachklang.art and
https://admin.nachklang.art are still allowed with credentials. With
NODE_ENV=development localhost is still allowed, so local development and the
LAN exception for real-device testing are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:54:42 +02:00
Paddy 33489585a0 Model permissions as (app, role) and name passkeys from their AAGUID
Two changes to the admin module, both made now because it is not deployed yet
and neither is free later.

Permissions were "an app", with a `role` column reserved for a future
fine-grained model. Reviewing whether that reservation was enough found three
problems:

- Every row was written with role = 'admin', hardcoded, and the column
  defaulted to it. On a `tickets` row that reads as "tickets administrator"
  when it only ever meant "has access", and once real roles existed there
  would have been no way to tell an old plain grant from a deliberate one.
- The role never left the database. /admin/me, the user list, the user detail
  and both write endpoints all spoke apps: AppName[]. Adding roles would have
  been a breaking change to /admin/me - and after the cutover that endpoint
  has two more consumers, turning a local edit into a coordinated deploy of
  three apps.
- The key (user_id, app) allowed one role per app, i.e. a tier rather than a
  set of capabilities. Choosing later means an ALTER on a live table.

So: the key is now (user_id, app, role), the role is `access`, and APP_ROLES
in admin.schema.ts is the contract - a role not listed there is rejected with
400 rather than written. permissions: [{app, role}] is on the wire alongside
the derived apps: AppName[], which is kept because the three frontends only
ever ask "may I show this app?". Both write endpoints accept either shape, and
the invitation column (now `permissions`) is parsed leniently: invitations live
seven days, so a deploy that changes the shape has in-flight rows in the old
one. requireAppAccess(app, role?) takes an optional role; nothing passes one
yet.

countActiveAdminsForUpdate now counts DISTINCT users rather than rows. With
several roles per app, counting rows would make a single admin holding two
roles look like two admins and defeat the last-admin guard at exactly the
moment it matters.

Separately, passkey registration now fills `name` from the authenticator's
AAGUID via registration.afterVerification and better-auth's own
getAuthenticatorName, yielding "1Password", "iCloud Keychain", "Windows Hello".
Without it the column stayed NULL and the account page could only label every
passkey "Passkey" - useless when someone has to remove the one on the device
they just lost. A client-supplied name still wins; an unknown AAGUID still
leaves it blank.

148 unit tests (up from 131, including the new admin.schema.test.ts) and 41
integration tests pass. The integration suite applies sql/admin/001_init.sql,
so the new key is exercised rather than trusted.

No production migration is needed - the module is not deployed. An existing dev
database needs three statements: set role = 'access', drop and re-add the
primary key, rename invitations.apps to permissions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:23:08 +02:00
Paddy dbcd5b56f6 Harden the admin module after a fresh-context review
Six defects found by an independent review of 7aac07a.

Environment handling now fails safe. NODE_ENV=production was gating the
signing key, the cookie domain, the CORS origin list and invitation-token
logging all at once, and it was documented nowhere - an unset value, which is
what a fresh Plesk vhost gives you, silently degraded all four. Only
'development' and 'test' relax anything now; everything else, unset included,
is strict. The hardcoded fallback secret is gone (dev gets a random
per-process one, so no committed value can ever sign a production cookie),
and invitation-link logging is an explicit ADMIN_LOG_INVITE_LINKS opt-in that
is refused in strict mode.

Rate limiting no longer collapses into a single global bucket. Without
trustedProxies, better-auth rejects a multi-value x-forwarded-for, resolves no
client IP, and keys every request to "no-trusted-ip" - where /sign-in/*
allows 3 requests per 10 seconds, so one noisy client could lock the whole
organisation out. CLIENT_IP_HEADERS and TRUSTED_PROXY_IPS make this explicit,
the unspecified x-forwarded-for fallback is gone, and strict mode warns at
boot when no trusted proxy is configured.

Invite acceptance is transactional. The user and its credential account go in
one runWithTransaction, as better-auth's own sign-up route does. A transaction
cannot span the permission and invitation writes - those use this module's own
pool - so a failure there is compensated: the user row is deleted and the
invitation un-marked, so the link works again instead of leaving the invitee
with a burnt token and an account no route can repair.

The last-admin guards were check-then-act. Two admins each removing the
other's admin permission could both pass the check and both commit, leaving
nobody able to administer anything. The count now runs inside the write
transaction under SELECT ... FOR UPDATE.

Also: lastSignInAt filtered expired sessions in the detail endpoint but not
the list, so the two disagreed; and the integration suite never reset
rateLimit, leaving it one added sign-in away from 429s that look like auth
bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:28:52 +02:00
Paddy 7aac07a013 Add admin identity module: better-auth, per-app permissions, invitations
Introduces src/models/admin/, a dedicated identity and permissions module on
its own nachklang_admin database, and the shared authenticator that feedback
and tickets will move onto in the cutover step. Nothing swaps over yet:
feedback.auth.ts and tickets.auth.ts still authenticate against the legacy
calendar sessions, so production behaviour is unchanged.

- better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies
  scoped to .nachklang.art so one sign-in covers every *.nachklang.art app.
- Accounts are invite-only: public sign-up is disabled, and the invitations
  plugin is the only code that creates users. Tokens are stored as SHA-256
  hashes and travel in the request body, never in a URL.
- Per-app permissions in user_app_permissions; requireAppAccess(app) queries
  the database on every request (no cookie cache) so disabling a user or
  revoking a session takes effect immediately.
- ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently
  and without crashing the API if the database is unreachable at boot.
- Guards prevent an admin from removing their own admin permission, disabling
  themselves, or stripping the last active admin.

The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's
MysqlDialect drives the pool with callbacks, and the promise wrapper ignores
them, so every query hangs silently. Only the integration tests caught this.

Schema in sql/admin/001_init.sql, derived from getAuthTables() on the
installed better-auth rather than the published CLI, which lags the library
and omits account.issuer.

app.ts is split into src/app.factory.ts so the integration tests drive the
real middleware order rather than a copy of it.

Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB
started by test/integration/setup.ts (docker or podman).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:03:08 +02: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 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
121 changed files with 16022 additions and 6209 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
+82 -7
View File
@@ -8,20 +8,24 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
npm run build # Compile TypeScript → dist/ npm run build # Compile TypeScript → dist/
npm run start # Build and start (tsc && node ./dist/app.js) npm run start # Build and start (tsc && node ./dist/app.js)
npm run debug # Start with DEBUG=* environment variable npm run debug # Start with DEBUG=* environment variable
npm run test # Run Jest tests with coverage (outputs sonar-report.xml) 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: Run a single test file:
```bash ```bash
npx jest test/some.test.ts npx vitest run test/some.test.ts
``` ```
## Architecture ## Architecture
Express.js REST API in TypeScript with a service-oriented layering. The sole domain is `Calendar`, which organises **events** and **users**. 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:** **Request path:**
1. `app.ts` mounts `Calendar.router.ts` at `/calendar` 1. `src/app.factory.ts` mounts `Calendar.router.ts` at `/calendar`
2. `Calendar.router.ts` delegates to `events.router.ts` and `users.router.ts` 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` 3. Routers call services; services call the MariaDB pool in `Calendar.db.ts`
@@ -34,7 +38,43 @@ Express.js REST API in TypeScript with a service-oriented layering. The sole dom
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) | | 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) | | Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
**Auth model:** Users must have a `@nachklang.art` email. After activation they receive a session token (30-day window); the token hash + IP are stored in the DB. Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`) come from `.env`. **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. **Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
@@ -46,13 +86,44 @@ Express.js REST API in TypeScript with a service-oriented layering. The sole dom
## Environment ## 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: Copy `.env.example` (or create `.env`) with:
``` ```
NODE_ENV=
PORT= PORT=
DB_HOST= DB_HOST=
DB_USER= DB_USER=
DB_PASSWORD= DB_PASSWORD=
CALENDAR_DB= 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_HOST=
EMAIL_USERNAME= EMAIL_USERNAME=
EMAIL_PASSWORD= EMAIL_PASSWORD=
@@ -61,6 +132,10 @@ CHOIR_CREDENTIAL=
MANAGEMENT_CREDENTIAL= MANAGEMENT_CREDENTIAL=
``` ```
## TypeScript config ## TypeScript / module system
Strict mode enabled, target ES2016, compiled output in `./dist`, inline source maps. Tests run through `ts-jest` directly against `.ts` sources. 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.
+11
View File
@@ -32,6 +32,13 @@ Currently any active user can edit, move, or delete any event regardless of who
## 3. Activation token has no expiry ## 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` **File:** `src/models/calendar/users/users.service.ts``createUser` / `activateUser`
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase. The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
@@ -45,6 +52,10 @@ The email activation link is valid indefinitely. Acceptable for a small, trusted
## 4. Password reset token has no expiry ## 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` **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. The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
+6 -76
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,74 +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/**/*.interface.ts',
'./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;
+89
View File
@@ -0,0 +1,89 @@
-- Local dev only. Real schema, provided directly by the repo owner
-- (calendars, events, event_versions, sessions, users) - not a guess.
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(),
`created_by_id` int(11) NOT NULL,
PRIMARY KEY (`event_id`),
KEY `events_calendars_calendar_id_fk` (`calendar_id`),
KEY `events_users_user_id_fk` (`created_by_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,
`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`),
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);
INSERT INTO events (calendar_id, uuid, created_by_id) VALUES
(1, UUID(), 1),
(1, UUID(), 1),
(1, UUID(), 1);
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id) 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),
(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),
(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);
+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');
+74
View File
@@ -0,0 +1,74 @@
# Migrating the Calendar domain onto the admin identity module
Status: **not started.** 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.
## 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.
2. **Map the accounts.** For every legacy `users` row that should survive, invite the
person through the admin UI. On acceptance, backfill `events.created_by_user_id` from
`events.created_by_id` via an email-to-new-id mapping. Everyone not re-invited keeps
working on the legacy path until step 4.
3. **Dual-read.** Change `events.service.ts` to prefer `created_by_user_id` and fall back
to `created_by_id`. Writes fill both. This is the only step that is temporary code, and
it should carry a removal note pointing at step 5.
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` against the same origin list. Deploy the
API first; the calendar frontend is broken between the two deploys, so pick a quiet
time. This closes `DEFERRED_SECURITY.md` item 1.
5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions`
table, `created_by_id`, and the dual-read from step 3. Legacy `/calendar/users/*` stays
only if something still calls it - otherwise delete it too. `X-Session-Id` /
`X-Session-Key` can then come out of the CORS `allowedHeaders` list in
`src/app.factory.ts`.
## Open questions to settle before starting
- **The shared calendar credentials.** Do `MEMBER_CREDENTIAL` and friends stay as a
separate mechanism (they serve people with no account at all, and iCal clients that
cannot send headers), or do read-only accounts replace them? This is a product decision,
not a technical one, and it decides how much of `credentials.service.ts` survives.
- **The iCal export.** `GET /calendar/events/{calendar}/ical` takes a password in the query
string on purpose, because iCal clients cannot send headers. Cookie sessions do not help
here; this endpoint likely keeps its own scheme.
- **Which legacy accounts to keep.** Step 2 is the moment to not re-invite people who no
longer need access.
- **`event_versions.version_created_by_id`.** The same INT reference again, joined in
`events.service.ts` for the "last modified by" name. It has to move with `events`, and it
is the reason step 1's bridging column needs a sibling on `event_versions`.
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: [
'test'
]
};
+3839 -5929
View File
File diff suppressed because it is too large Load Diff
+28 -18
View File
@@ -3,55 +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.18.2", "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",
"nodemailer": "^6.9.8", "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.15", "@types/express": "^4.17.15",
"@types/jest": "^28.1.3", "@types/node": "^26.4.1",
"@types/node": "^18.11.17",
"@types/nodemailer": "^6.4.14",
"@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.9.4" "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;
+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;
+166
View File
@@ -0,0 +1,166 @@
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-* stay allowed until the calendar module is migrated off the
// legacy header sessions (see docs/calendar-auth-migration.md).
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;
};
-38
View File
@@ -1,38 +0,0 @@
import * as nodemailer from 'nodemailer';
export namespace MailService {
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
pool: true,
port: 465,
secure: true,
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD
},
tls: {rejectUnauthorized: false}
});
const mailConfigurations = {
// It should be a string of sender email
from: 'noreply@nachklang.art',
// Comma Separated list of mails
to: 'mail@pmueller.me',
// Subject of Email
subject: '',
// This would be the text of email body
text: ''
};
export const sendMail = async (recipientAddress: string, subject: string, body: string) => {
mailConfigurations.to = recipientAddress;
mailConfigurations.subject = subject;
mailConfigurations.text = body;
await transporter.sendMail(mailConfigurations);
};
}
+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);
+162
View File
@@ -0,0 +1,162 @@
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'
];
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});
}
};
+170
View File
@@ -0,0 +1,170 @@
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.
export const APP_ORIGINS = parseList(process.env.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,439 @@
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;
};
+2 -4
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();
@@ -10,8 +9,7 @@ export namespace NachklangCalendarDB {
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.CALENDAR_DB, database: process.env.CALENDAR_DB,
connectionLimit: 5, connectionLimit: 5
autoCommit: false
}); });
export const getConnection = async () => { export const getConnection = async () => {
+3 -3
View File
@@ -3,9 +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'; import {usersRouter} from './users/users.router.js';
/** /**
* Router Definition * Router Definition
@@ -1,5 +1,5 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as UserService from '../users/users.service'; import * as UserService from '../users/users.service.js';
dotenv.config(); dotenv.config();
@@ -70,9 +70,9 @@
* description: The ID of the user who created the event * description: The ID of the user who created the event
* example: 456 * example: 456
* lastModifiedBy: * lastModifiedBy:
* type: string * type: string
* description: The name of the user who last modified the event * description: The name of the user who last modified the event
* example: "John Doe" * example: "John Doe"
* lastModifiedById: * lastModifiedById:
* type: integer * type: integer
* description: The ID of the user who last modified the event * description: The ID of the user who last modified the event
+6 -6
View File
@@ -3,13 +3,13 @@
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {Event} from './event.interface'; import {Event} from './event.interface.js';
import * as EventService from './events.service'; import * as EventService from './events.service.js';
import * as iCalService from './icalgenerator.service'; import * as iCalService from './icalgenerator.service.js';
import * as CredentialService from './credentials.service'; import * as CredentialService from './credentials.service.js';
import * as UserService from '../users/users.service'; import * as UserService from '../users/users.service.js';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger'; import logger from '../../../middleware/logger.js';
/** /**
+60 -2
View File
@@ -1,7 +1,7 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
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';
dotenv.config(); dotenv.config();
@@ -126,6 +126,64 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
} }
}; };
/**
* 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 = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as 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
WHERE e.event_id = ?`;
const eventsRes = await conn.query(eventsQuery, eventId);
if (eventsRes.length === 0) {
return null;
}
const row = eventsRes[0];
return {
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,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency,
status: row.status
} as Event;
} catch (err) {
throw err;
} finally {
// Return connection
await conn.end();
}
};
/** /**
* Create the given event in the database * Create the given event in the database
* @param event The event to create * @param event The event to create
@@ -1,4 +1,4 @@
import {Event} from './event.interface'; import {Event} from './event.interface.js';
/** /**
* Interface to external classes - Turns the given events into an ical string * Interface to external classes - Turns the given events into an ical string
+4 -4
View File
@@ -3,11 +3,11 @@
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import * as UserService from './users.service'; import * as UserService from './users.service.js';
import {Session} from './session.interface'; import {Session} from './session.interface.js';
import {User} from './user.interface'; import {User} from './user.interface.js';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger'; import logger from '../../../middleware/logger.js';
/** /**
* Router Definition * Router Definition
+9 -6
View File
@@ -1,10 +1,10 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import {User} from './user.interface'; import {User} from './user.interface.js';
import {Session} from './session.interface'; import {Session} from './session.interface.js';
import {NachklangCalendarDB} from '../Calendar.db'; import {NachklangCalendarDB} from '../Calendar.db.js';
import {MailService} from "../../../common/common.mail.nodemailer"; import {MailService} from '../../../common/common.mail.js';
dotenv.config(); dotenv.config();
@@ -54,7 +54,10 @@ export const createUser = async (email: string, password: string, fullName: stri
sessionId = row.session_id; sessionId = row.session_id;
} }
// Send email with activation link (after commit so we don't block on email delivery) // 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}`); 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 { return {
+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,136 @@
/**
* @swagger
* components:
* parameters:
* SessionIdHeader:
* in: header
* name: X-Session-Id
* required: true
* schema:
* type: string
* SessionKeyHeader:
* in: header
* name: X-Session-Key
* required: true
* schema:
* type: string
* 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;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* 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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* fullName:
* type: string
* 401:
* description: Unauthorized
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: submissionId
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Deleted
* 404:
* description: Unknown submission
* 401:
* description: Unauthorized
*/
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,408 @@
/**
* 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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/EventAdminSummary'
* 401:
* description: Unauthorized
* 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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
* put:
* summary: Update an event
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Updated
* 404:
* description: Unknown event
* 401:
* description: Unauthorized
* delete:
* summary: Delete an event
* description: Refuses with 409 if submissions exist unless ?force=true is passed.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* post:
* summary: Add a song to an event's setlist
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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,175 @@
/**
* 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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
* post:
* summary: Create a question
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
* 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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: questionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Archived or deleted
* 404:
* description: Unknown question
* 401:
* description: Unauthorized
*/
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,222 @@
/**
* 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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 404:
* description: Unknown event
* 401:
* description: Unauthorized
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: CSV file
* content:
* text/csv: {}
* 401:
* description: Unauthorized
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: CSV file
* content:
* text/csv: {}
* 401:
* description: Unauthorized
*/
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,97 @@
/**
* 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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
* delete:
* summary: Remove a song
* description: Past answers keep their song_title_snapshot even after the song is removed.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: songId
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Removed
* 404:
* description: Unknown song
* 401:
* description: Unauthorized
*/
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();
}
};
+78
View File
@@ -0,0 +1,78 @@
import express from 'express';
import * as UserService from '../calendar/users/users.service.js';
import {sendServerError} from './feedback.errors.js';
/**
* This file is the ONLY place in the feedback module that knows how admin
* authentication works today. No route handler and no service outside this
* file may import users.service, read session headers, or touch bcrypt.
*
* Today: reuses the existing Calendar users/sessions mechanism. Any
* activated @nachklang.art account may administer feedback no roles.
* Migrating to Keycloak later means writing a keycloakJwtAuthenticator
* below and changing the one `activeAuthenticator` binding (plus the
* frontend's login route handler) nothing else in the feedback module
* needs to change.
*
* Explicitly forbidden: accepting sessionId/sessionKey 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.
* Headers only.
*/
// The only thing the rest of the feedback module knows about an admin.
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
// Pluggable strategy: extract + verify credentials from a request.
// Returns the identity, or null if unauthenticated. Throws only on
// infrastructure errors (e.g. the DB being unreachable).
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
// Current implementation: reads X-Session-Id / X-Session-Key headers,
// delegates to the existing calendar UserService.checkSession(...).
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
// Mirrors the Calendar domain's own convention: a valid session on an
// inactive (not yet activated) account is not sufficient.
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
// Swap point: change this one binding to migrate to Keycloak.
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
// Express middleware used by every admin route. On success:
// res.locals.admin = AdminIdentity, calls next(). On failure: 401.
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
try {
const identity = await activeAuthenticator(req);
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
+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);
+35
View File
@@ -0,0 +1,35 @@
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
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,179 @@
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventStats'
* 401:
* description: Unauthorized
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Removed
* 409:
* description: Vouchers already reference this event
* 401:
* description: Unauthorized
*/
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,289 @@
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 404:
* description: Unknown redemption
* 401:
* description: Unauthorized
* 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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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,249 @@
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* 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
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: code
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* 404:
* description: Unknown code
* 401:
* description: Unauthorized
*/
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]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - 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
*/
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};
};
+63
View File
@@ -0,0 +1,63 @@
import express from 'express';
import * as UserService from '../calendar/users/users.service.js';
import {sendServerError} from './tickets.errors.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 import users.service, read
* session headers, or touch bcrypt.
*
* Today: reuses the existing Calendar users/sessions mechanism. Any
* activated @nachklang.art account may administer vouchers - no roles, same
* policy as Feedback (see docs/plan-ticket-shop.md). A dedicated
* roles/permissions model is explicitly out of scope for v1.
*
* Explicitly forbidden: accepting sessionId/sessionKey from query
* parameters - headers only (see DEFERRED_SECURITY.md item 1).
*/
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
try {
const identity = await activeAuthenticator(req);
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
+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();
});
});
+109
View File
@@ -0,0 +1,109 @@
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('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/);
});
});
+71
View File
@@ -0,0 +1,71 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/common/common.mail.js', () => ({
MailService: {sendMail: vi.fn()}
}));
vi.mock('../../src/models/admin/admin.config.js', () => ({
ADMIN_APP_URL: 'https://admin.nachklang.art'
}));
import {MailService} from '../../src/common/common.mail.js';
import {sendInvitationMail, sendPasswordResetMail} from '../../src/models/admin/admin.mail.js';
const sendMail = MailService.sendMail as Mock;
beforeEach(() => {
sendMail.mockReset();
sendMail.mockResolvedValue(true);
});
describe('sendInvitationMail', () => {
it('points at the admin app and carries the token in the query string', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok-en_123', new Date('2026-09-12T10:00:00Z'));
const [to, subject, text, options] = sendMail.mock.calls[0];
expect(to).toBe('a@nachklang.art');
expect(subject).toBeTruthy();
expect(text).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
expect(options.html).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
});
it('url-encodes a token containing url-significant characters', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'a+b/c=d', new Date());
const [, , text] = sendMail.mock.calls[0];
expect(text).toContain('token=a%2Bb%2Fc%3Dd');
});
it('sends both a text and an html part', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date());
const [, , text, options] = sendMail.mock.calls[0];
expect(text.length).toBeGreaterThan(0);
expect(options.html).toContain('<html');
});
it('escapes a name that contains html', async () => {
await sendInvitationMail('a@nachklang.art', '<script>alert(1)</script>', 'tok', new Date());
const [, , , options] = sendMail.mock.calls[0];
expect(options.html).not.toContain('<script>');
expect(options.html).toContain('&lt;script&gt;');
});
it('reports a delivery failure to the caller rather than throwing', async () => {
sendMail.mockResolvedValue(false);
await expect(sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date())).resolves.toBe(false);
});
});
describe('sendPasswordResetMail', () => {
it('uses the url better-auth generated, unchanged', async () => {
const url = 'https://api.nachklang.art/admin/auth/reset-password/abc?callbackURL=x';
await sendPasswordResetMail('a@nachklang.art', 'Anna', url);
const [, , text, options] = sendMail.mock.calls[0];
expect(text).toContain(url);
expect(options.html).toContain('https://api.nachklang.art/admin/auth/reset-password/abc');
});
});
+222
View File
@@ -0,0 +1,222 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import {Request, Response} from 'express';
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models/admin/admin.middleware.js';
const mockGetSession = auth.api.getSession as unknown as Mock;
const mockLoadAccess = UsersService.loadAccess as Mock;
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
const activeUser = {
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
};
describe('resolveAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('returns null without a valid session', async () => {
mockGetSession.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
expect(mockLoadAccess).not.toHaveBeenCalled();
});
it('returns null when the session points at a user row that is gone', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
});
it('resolves identity and permissions in a single permission query', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
expect(await resolveAccess(makeReq())).toEqual({
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
});
// No cookieCache: exactly one lookup per request, never zero.
expect(mockLoadAccess).toHaveBeenCalledTimes(1);
});
});
describe('requireSignedIn', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user that still holds a valid cookie', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('admits a signed-in user with no app permissions at all', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, apps: []});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin.apps).toEqual([]);
});
});
describe('requireAppAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
});
it('403s a signed-in user without that app permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'feedback', role: 'access'}],
apps: ['feedback']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user even when they hold the permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('passes through and exposes the identity the feedback/tickets services expect', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'A'});
});
it('500s (never allows through) when the permission query throws', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockRejectedValue(new Error('db down'));
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(next).not.toHaveBeenCalled();
});
// The seam a finer per-app permission arrives through. Nothing passes a role
// today, so these two pin the behaviour before there is anything to break.
it('403s when a specific role is required and the user only holds another', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'tickets', role: 'access'}],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('passes when the user holds exactly the required role', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [
{app: 'tickets', role: 'access'},
{app: 'tickets', role: 'refund'}
],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
});
});
+99
View File
@@ -0,0 +1,99 @@
import {describe, expect, it} from 'vitest';
import {
ACCESS_ROLE,
appsOf,
isAppPermission,
isAppRole,
toPermissions
} from '../../src/models/admin/admin.schema.js';
/**
* The permission model is (app, role). These tests pin the two properties the
* rest of the module leans on: that the older `['tickets']` shape still means
* "tickets at the access role", and that nothing outside APP_ROLES gets in.
*/
describe('toPermissions', () => {
it('reads the full (app, role) form', () => {
expect(toPermissions([{app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: 'access'}
]);
});
it('reads a plain app list as that app at the access role', () => {
expect(toPermissions(['feedback', 'admin'])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'admin', role: ACCESS_ROLE}
]);
});
it('accepts the two forms mixed, which is what a half-migrated caller sends', () => {
expect(toPermissions(['feedback', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('drops duplicates of the same (app, role)', () => {
expect(toPermissions(['tickets', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('rejects rather than silently dropping an unknown app', () => {
// Silently ignoring it would let "grant calendar + nonsense" look like a
// success while granting less than the caller asked for.
expect(toPermissions(['calendar', 'nonsense'])).toBeNull();
});
it('rejects an unknown role', () => {
expect(toPermissions([{app: 'tickets', role: 'refund'}])).toBeNull();
});
it('rejects anything that is not a list', () => {
expect(toPermissions('admin')).toBeNull();
expect(toPermissions(null)).toBeNull();
expect(toPermissions({app: 'admin', role: 'access'})).toBeNull();
});
it('reads an empty list as "no permissions", not as invalid', () => {
expect(toPermissions([])).toEqual([]);
});
});
describe('isAppRole', () => {
it('accepts the access role for every app', () => {
expect(isAppRole('admin', ACCESS_ROLE)).toBe(true);
expect(isAppRole('calendar', ACCESS_ROLE)).toBe(true);
});
it('rejects a role that does not exist yet', () => {
expect(isAppRole('tickets', 'refund')).toBe(false);
});
});
describe('isAppPermission', () => {
it('needs both halves to be valid', () => {
expect(isAppPermission({app: 'tickets', role: ACCESS_ROLE})).toBe(true);
expect(isAppPermission({app: 'tickets'})).toBe(false);
expect(isAppPermission({role: ACCESS_ROLE})).toBe(false);
expect(isAppPermission(null)).toBe(false);
});
});
describe('appsOf', () => {
it('collapses several roles on one app to a single entry', () => {
// The point of the derived list: a user with two roles on tickets has
// access to tickets once, not twice.
const apps = appsOf([
{app: 'tickets', role: ACCESS_ROLE},
{app: 'tickets', role: 'future-role'},
{app: 'admin', role: ACCESS_ROLE}
]);
expect(apps).toEqual(['tickets', 'admin']);
});
it('is empty for no permissions', () => {
expect(appsOf([])).toEqual([]);
});
});
+199
View File
@@ -0,0 +1,199 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import express from 'express';
import request from 'supertest';
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
listUsers: vi.fn(),
getUserDetail: vi.fn(),
loadAccess: vi.fn(),
setPermissions: vi.fn(),
setPermissionsGuarded: vi.fn(),
disableUser: vi.fn(),
disableUserGuarded: vi.fn(),
enableUser: vi.fn(),
revokeSession: vi.fn(),
countActiveAdmins: vi.fn(),
userExists: vi.fn()
}));
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {usersAdminRouter} from '../../src/models/admin/users/users.admin.router.js';
const service = UsersService as unknown as Record<string, Mock>;
// The router always runs behind requireAppAccess('admin'), which is what puts
// res.locals.admin there; this stands in for it.
const makeApp = (callerId = 'me') => {
const app = express();
app.use(express.json());
app.use((req, res, next) => {
res.locals.admin = {id: callerId, email: 'me@nachklang.art', displayName: 'Me', apps: ['admin']};
next();
});
app.use('/admin/users', usersAdminRouter);
return app;
};
beforeEach(() => {
for (const fn of Object.values(service)) {
if (typeof fn?.mockReset === 'function') {
fn.mockReset();
}
}
service.getUserDetail.mockResolvedValue({id: 'other', apps: []});
service.userExists.mockResolvedValue(true);
service.setPermissionsGuarded.mockResolvedValue('ok');
service.disableUserGuarded.mockResolvedValue('ok');
});
describe('PUT /admin/users/:id/permissions', () => {
it('rejects an unknown app name', async () => {
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: ['calendar', 'nope']});
expect(res.status).toBe(400);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
it('rejects a non-array body', async () => {
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: 'admin'});
expect(res.status).toBe(400);
});
it('404s for an unknown user', async () => {
service.userExists.mockResolvedValue(false);
const res = await request(makeApp()).put('/admin/users/ghost/permissions').send({apps: []});
expect(res.status).toBe(404);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
it('refuses to remove the caller\'s own admin permission', async () => {
service.loadAccess.mockResolvedValue({id: 'me', disabled: false, apps: ['admin']});
service.countActiveAdmins.mockResolvedValue(5);
const res = await request(makeApp('me')).put('/admin/users/me/permissions').send({apps: ['feedback']});
expect(res.status).toBe(409);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
// The last-admin decision is made inside the write transaction (so two
// admins acting at once cannot both pass a check-then-act); the router's
// job is only to turn that verdict into a 409.
it('answers 409 when the service reports the last admin would be removed', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.setPermissionsGuarded.mockResolvedValue('last-admin');
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: []});
expect(res.status).toBe(409);
});
it('allows removing an admin while another active admin remains', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'tickets', role: 'access'}],
'me'
);
});
it('accepts the richer {permissions} body', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me'))
.put('/admin/users/other/permissions')
.send({permissions: [{app: 'tickets', role: 'access'}]});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'tickets', role: 'access'}],
'me'
);
});
it('rejects a role that does not exist', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me'))
.put('/admin/users/other/permissions')
.send({permissions: [{app: 'tickets', role: 'refund'}]});
expect(res.status).toBe(400);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
it('allows granting permissions to someone who has none', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'feedback', role: 'access'}, {app: 'tickets', role: 'access'}],
'me'
);
});
});
describe('POST /admin/users/:id/disable', () => {
it('refuses to disable the caller', async () => {
const res = await request(makeApp('me')).post('/admin/users/me/disable');
expect(res.status).toBe(409);
expect(service.disableUserGuarded).not.toHaveBeenCalled();
});
it('answers 409 when the service reports the last active admin would be disabled', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.disableUserGuarded.mockResolvedValue('last-admin');
const res = await request(makeApp('me')).post('/admin/users/other/disable');
expect(res.status).toBe(409);
});
it('disables a non-admin user', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['feedback']});
const res = await request(makeApp('me')).post('/admin/users/other/disable');
expect(res.status).toBe(200);
expect(service.disableUserGuarded).toHaveBeenCalledWith('other');
});
it('404s for an unknown user', async () => {
service.loadAccess.mockResolvedValue(null);
const res = await request(makeApp('me')).post('/admin/users/ghost/disable');
expect(res.status).toBe(404);
});
});
describe('DELETE /admin/users/:id/sessions/:sid', () => {
it('404s when the session does not belong to that user', async () => {
service.revokeSession.mockResolvedValue(false);
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
expect(res.status).toBe(404);
});
it('204s on a successful revoke', async () => {
service.revokeSession.mockResolvedValue(true);
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
expect(res.status).toBe(204);
expect(service.revokeSession).toHaveBeenCalledWith('other', 's1');
});
});

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