Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e62b46945a | |||
| 27eb301086 | |||
| 33489585a0 | |||
| dbcd5b56f6 | |||
| 7aac07a013 | |||
| bf7f45acce | |||
| 3ea9e630ed | |||
| 449edd6c68 | |||
| 3c4f3331d8 | |||
| b05f6b9da0 | |||
| e7621b8290 | |||
| da85d1487c | |||
|
dc65b49219
|
|||
|
9c45fb11ee
|
|||
|
45dfc22c60
|
|||
|
a38fb20e5a
|
|||
|
cb85e81d67
|
|||
|
59fee19a76
|
|||
|
a79e2186a2
|
|||
|
8f93e1ab7d
|
|||
|
34a4a6664f
|
|||
|
76e6bbdbbf
|
|||
|
5e84eaea70
|
|||
|
b8a68c2480
|
|||
|
95983021ed
|
|||
|
02f7424b56
|
|||
|
93c70b0e1d
|
|||
|
d85f9a992b
|
|||
|
fc071096d8
|
|||
|
a34a5df5a3
|
|||
|
65a5e91ad1
|
|||
|
6cb7f0d59b
|
|||
|
ccfa28877c
|
|||
|
a8f7189cb3
|
|||
|
83c9d090e1
|
|||
|
0348d89121
|
@@ -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
|
||||
@@ -0,0 +1,141 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run build # Compile TypeScript → dist/
|
||||
npm run start # Build and start (tsc && node ./dist/app.js)
|
||||
npm run debug # Start with DEBUG=* environment variable
|
||||
npm run test # Run the vitest suite once with coverage (lcov + testResults/sonar-report.xml)
|
||||
npm run test:watch # vitest in watch mode
|
||||
npm run test:integration # Admin-module tests against a throwaway MariaDB (needs docker or podman)
|
||||
```
|
||||
|
||||
Run a single test file:
|
||||
```bash
|
||||
npx vitest run test/some.test.ts
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users), `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration), `Tickets` (mounted at `/tickets`), and `Admin` (identity and permissions, mounted at `/admin`, backed by `ADMIN_DB` — see below).
|
||||
|
||||
`src/app.factory.ts` builds the Express app; `app.ts` only starts it. The split exists so the integration tests drive the real wiring.
|
||||
|
||||
**Request path:**
|
||||
1. `src/app.factory.ts` mounts `Calendar.router.ts` at `/calendar`
|
||||
2. `Calendar.router.ts` delegates to `events.router.ts` and `users.router.ts`
|
||||
3. Routers call services; services call the MariaDB pool in `Calendar.db.ts`
|
||||
|
||||
**Key layers:**
|
||||
|
||||
| Layer | Location |
|
||||
|---|---|
|
||||
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` |
|
||||
| Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
|
||||
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) |
|
||||
| Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
|
||||
|
||||
**Auth model:** Two of them, on purpose.
|
||||
|
||||
*Admin module (`src/models/admin/`)* — the current one, used by feedback, tickets and the
|
||||
admin app. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every
|
||||
other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler
|
||||
and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
|
||||
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
|
||||
sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users.
|
||||
A permission is **(app, role)** in `user_app_permissions`, keyed on
|
||||
`(user_id, app, role)` so one user can hold several roles per app. `access` is the only role
|
||||
today and means "may use this app at all"; `APP_ROLES` in `admin.schema.ts` is the contract,
|
||||
and a role not listed there is rejected rather than written. `requireAppAccess(app)` in
|
||||
`admin.middleware.ts` is the single authenticator - it takes an optional second argument to
|
||||
narrow to one role, and queries the database on every request (no cookie cache) so disabling
|
||||
a user takes effect at once. Two things to know before touching this: any count of admins
|
||||
must count **distinct users**, not permission rows, or a single admin with two roles reads as
|
||||
two and the last-admin guard stops guarding; and both write endpoints accept
|
||||
`{permissions: [{app, role}]}` as well as the older `{apps: ['tickets']}`, which means the
|
||||
same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL`
|
||||
makes sure someone can always get in on a fresh database.
|
||||
|
||||
*Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation
|
||||
get a session token (30-day window, hash + IP stored in the DB), passed as query
|
||||
parameters. Migration is planned but not started: `docs/calendar-auth-migration.md`.
|
||||
Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`,
|
||||
`MANAGEMENT_CREDENTIAL`) come from `.env`.
|
||||
|
||||
**Admin database driver:** the admin pool is the **callback-style** `mysql2`, never
|
||||
`mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`;
|
||||
the promise wrapper ignores that callback, so every Kysely query hangs forever with no
|
||||
error. Only the integration tests catch this.
|
||||
|
||||
**Admin schema changes:** `sql/admin/NNN_*.sql`, hand-maintained and mirrored in
|
||||
`docker/init/`. The better-auth tables must match what the configured version derives from
|
||||
`admin.auth.ts` — on every better-auth upgrade, re-derive them (`getAuthTables` from
|
||||
`better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not
|
||||
use the published `@better-auth/cli`; it lags the library.
|
||||
|
||||
**Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
|
||||
|
||||
**Calendar types and IDs:** `public` (1), `members` (2), `management` (3), `choir` (4), `birthdays` (5). `credentials.service.ts` enforces which session/credential can read each calendar.
|
||||
|
||||
**iCal export:** `icalgenerator.service.ts` converts DB events to RFC 5545 format; reachable via `GET /calendar/events/{calendar}/ical`.
|
||||
|
||||
**API docs:** Swagger UI served at `/docs`, generated from JSDoc annotations in the router files.
|
||||
|
||||
## Environment
|
||||
|
||||
dotenv 16 parses `.env` stricter than the old dotenv 8: an unquoted `#` starts a comment and
|
||||
backslash escapes inside double quotes are expanded. Wrap any value containing `#`, `"`, `\` or
|
||||
surrounding spaces in single quotes (`DB_PASSWORD='abc#def'`), which are taken literally.
|
||||
A truncated password shows up as MariaDB "Access denied ... (using password: YES)".
|
||||
|
||||
**`NODE_ENV` is load-bearing for the admin module.** Only the explicit values
|
||||
`development` and `test` relax anything; everything else, *including unset*, is treated as
|
||||
production (strict secrets, cross-subdomain cookies, no localhost CORS). That direction is
|
||||
deliberate: a Plesk vhost does not set `NODE_ENV`, and the inverse arrangement would
|
||||
silently degrade the signing key, the cookie domain and the CORS list at once. Local work
|
||||
needs `NODE_ENV=development`.
|
||||
|
||||
Copy `.env.example` (or create `.env`) with:
|
||||
```
|
||||
NODE_ENV=
|
||||
PORT=
|
||||
DB_HOST=
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
CALENDAR_DB=
|
||||
ADMIN_DB=
|
||||
BETTER_AUTH_SECRET=
|
||||
API_BASE_URL=
|
||||
ADMIN_APP_URL=
|
||||
APP_ORIGINS=
|
||||
PASSKEY_RP_ID=
|
||||
ADMIN_BOOTSTRAP_EMAIL=
|
||||
CLIENT_IP_HEADERS=
|
||||
TRUSTED_PROXY_IPS=
|
||||
ADMIN_LOG_INVITE_LINKS=
|
||||
FEEDBACK_DB=
|
||||
FEEDBACK_IP_SALT=
|
||||
FEEDBACK_RATE_LIMIT_MAX=
|
||||
FEEDBACK_RATE_LIMIT_WINDOW_MIN=
|
||||
SALESFORCE_ENABLED=
|
||||
SALESFORCE_API_URL=
|
||||
SALESFORCE_CLIENT_ID=
|
||||
SALESFORCE_CLIENT_SECRET=
|
||||
EMAIL_HOST=
|
||||
EMAIL_USERNAME=
|
||||
EMAIL_PASSWORD=
|
||||
MEMBER_CREDENTIAL=
|
||||
CHOIR_CREDENTIAL=
|
||||
MANAGEMENT_CREDENTIAL=
|
||||
```
|
||||
|
||||
## TypeScript / module system
|
||||
|
||||
The API runs on Node 26 (`engines` in package.json, `.nvmrc`; Plesk runs 26 too) and is native ESM (`"type": "module"`, `module: nodenext`, target ES2024, strict mode, compiled output in `./dist`, inline source maps). Consequences:
|
||||
|
||||
- Relative imports carry the `.js` suffix (`import {x} from "./x.js"`) even though the source file is `.ts`.
|
||||
- CommonJS dependencies are consumed via default imports (`import mariadb from "mariadb"`, `import cors from "cors"`, `import winston from "winston"`), never `require()`.
|
||||
- Tests run with vitest directly against `.ts` sources; import `describe`/`it`/`expect`/`vi` from `vitest` explicitly (no globals). Module mocks use `vi.mock(...)` with the same `.js`-suffixed paths as the imports.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Deferred Security Issues
|
||||
|
||||
These items were identified during a security review on 2026-05-02 and consciously deferred.
|
||||
**Must be addressed before opening the application to a larger or public userbase.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Session credentials in URL query parameters (logged-in users)
|
||||
|
||||
**Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers
|
||||
|
||||
`sessionId` and `sessionKey` are currently read from query parameters, which means they appear in server access logs, browser history, proxy logs, and `Referer` headers.
|
||||
|
||||
**Fix:** Move to request headers (`X-Session-Id` / `X-Session-Key`) or the request body. Requires a corresponding frontend update.
|
||||
|
||||
> Note: the shared calendar `password` parameter in query params is intentional (iCal clients don't support headers) and is acceptable for the current setup.
|
||||
|
||||
---
|
||||
|
||||
## 2. No event ownership check
|
||||
|
||||
**Files:** `src/models/calendar/events/events.router.ts`
|
||||
- `PUT /:eventId` (update)
|
||||
- `PUT /move/:eventId` (move)
|
||||
- `DELETE /:eventId` (delete)
|
||||
|
||||
Currently any active user can edit, move, or delete any event regardless of who created it. This is acceptable while all users are trusted admins.
|
||||
|
||||
**Fix:** When non-admin users are introduced, fetch the event first and verify `event.createdById === user.userId` before allowing the mutation. Add an `isAdmin` flag to the user model to let admins bypass the check.
|
||||
|
||||
---
|
||||
|
||||
## 3. Activation token has no expiry
|
||||
|
||||
> **Superseded for new accounts (2026-09-05).** The admin module
|
||||
> (`src/models/admin/`) replaced account creation for the feedback, tickets and admin
|
||||
> apps: accounts now come from `invitations`, whose tokens expire after 7 days and are
|
||||
> stored only as a SHA-256 hash. The item below still stands for the legacy calendar
|
||||
> `users` table, which the admin module deliberately left alone - see
|
||||
> `docs/calendar-auth-migration.md`.
|
||||
|
||||
**File:** `src/models/calendar/users/users.service.ts` — `createUser` / `activateUser`
|
||||
|
||||
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
|
||||
|
||||
**Fix:**
|
||||
1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`).
|
||||
2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`.
|
||||
3. Check `activation_expires > NOW()` in `activateUser` before accepting the token.
|
||||
|
||||
---
|
||||
|
||||
## 4. Password reset token has no expiry
|
||||
|
||||
> **Superseded for new accounts (2026-09-05).** Password resets for admin-module accounts
|
||||
> go through better-auth, whose reset tokens expire after one hour. As with item 3, the
|
||||
> text below still applies to the legacy calendar `users` table.
|
||||
|
||||
**File:** `src/models/calendar/users/users.service.ts` — `initiatePasswordReset` / `finalizePasswordReset`
|
||||
|
||||
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
|
||||
|
||||
**Fix:**
|
||||
1. Add a `pw_reset_expires` column to the `users` table (e.g. `DATETIME`).
|
||||
2. Set it to `NOW() + INTERVAL 15 MINUTE` in `initiatePasswordReset`.
|
||||
3. Check `pw_reset_expires > NOW()` in `finalizePasswordReset` before accepting the token.
|
||||
@@ -1,15 +1,8 @@
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
import * as dotenv from 'dotenv';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import swaggerJSDoc from 'swagger-jsdoc';
|
||||
import logger from './src/middleware/logger';
|
||||
|
||||
// Router imports
|
||||
import {calendarRouter} from './src/models/calendar/Calendar.router';
|
||||
|
||||
|
||||
let cors = require('cors');
|
||||
import logger from './src/middleware/logger.js';
|
||||
import {createApp} from './src/app.factory.js';
|
||||
import {bootstrapAdmin} from './src/models/admin/admin.bootstrap.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -20,73 +13,11 @@ if (!process.env.PORT) {
|
||||
|
||||
const port: number = parseInt(process.env.PORT, 10);
|
||||
|
||||
const app: express.Application = express();
|
||||
const app = createApp();
|
||||
const server: http.Server = http.createServer(app);
|
||||
|
||||
// here we are adding middleware to parse all incoming requests as JSON
|
||||
app.use(express.json());
|
||||
|
||||
// Configure CORS
|
||||
let allowedHosts = [
|
||||
'https://www.nachklang.art',
|
||||
'https://calendar.nachklang.art'
|
||||
];
|
||||
app.use(cors({
|
||||
origin: function (origin: any, callback: any) {
|
||||
// Allow requests with no origin
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// Block requests with wrong origin
|
||||
if (allowedHosts.indexOf(origin) === -1) {
|
||||
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
|
||||
}
|
||||
|
||||
// Allow all other requests
|
||||
return callback(null, true);
|
||||
}
|
||||
}));
|
||||
|
||||
// Swagger documentation
|
||||
const swaggerDefinition = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Nachklang e.V. REST API',
|
||||
version: '0.1.0',
|
||||
license: {
|
||||
name: 'Licensed Under MIT',
|
||||
url: 'https://spdx.org/licenses/MIT.html'
|
||||
},
|
||||
contact: {
|
||||
name: 'Nachklang e.V.',
|
||||
url: 'https://www.nachklang.art'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const options = {
|
||||
swaggerDefinition,
|
||||
// Paths to files containing OpenAPI definitions
|
||||
apis: [
|
||||
'./src/models/**/*.router.ts'
|
||||
]
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJSDoc(options);
|
||||
|
||||
app.use(
|
||||
'/docs',
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(swaggerSpec)
|
||||
);
|
||||
|
||||
// Add routers
|
||||
app.use('/calendar', calendarRouter);
|
||||
|
||||
// this is a simple route to make sure everything is working properly
|
||||
app.get('/', (req: express.Request, res: express.Response) => {
|
||||
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
logger.info('Server listening on Port ' + port);
|
||||
// Makes sure ADMIN_BOOTSTRAP_EMAIL can always get in. Never throws.
|
||||
void bootstrapAdmin();
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
USE nachklang_feedback;
|
||||
SOURCE /migrations/feedback/001_init.sql;
|
||||
SOURCE /migrations/feedback/002_add_poster_image_url.sql;
|
||||
@@ -0,0 +1,3 @@
|
||||
USE nachklang_tickets;
|
||||
SOURCE /migrations/tickets/001_init.sql;
|
||||
SOURCE /migrations/tickets/002_add_require_address.sql;
|
||||
@@ -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');
|
||||
@@ -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`.
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: [
|
||||
'test'
|
||||
]
|
||||
};
|
||||
Generated
+3842
-5898
File diff suppressed because it is too large
Load Diff
+30
-17
@@ -3,52 +3,65 @@
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=26"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "tsc && node ./dist/app.js",
|
||||
"build": "tsc",
|
||||
"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": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@better-auth/core": "^1.7.2",
|
||||
"@better-auth/passkey": "^1.7.2",
|
||||
"app-root-path": "^3.0.0",
|
||||
"axios": "^0.24.0",
|
||||
"axios": "^1.20.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"better-auth": "^1.7.2",
|
||||
"cors": "^2.8.5",
|
||||
"debug": "^4.3.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.18.2",
|
||||
"guid-typescript": "^1.0.9",
|
||||
"kysely": "^0.29.5",
|
||||
"mariadb": "^3.0.2",
|
||||
"mysql2": "^3.24.3",
|
||||
"random-words": "^1.1.1",
|
||||
"swagger-jsdoc": "^6.1.0",
|
||||
"swagger-ui-express": "^4.3.0",
|
||||
"winston": "^3.3.3"
|
||||
"winston": "^3.3.3",
|
||||
"zod": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/app-root-path": "^1.2.4",
|
||||
"@types/bcrypt": "^3.0.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/debug": "^4.1.5",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/jest": "^28.1.3",
|
||||
"@types/express": "^4.17.15",
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/random-words": "^1.1.2",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/swagger-jsdoc": "^6.0.1",
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"is-number": "^7.0.0",
|
||||
"jest": "^28.1.1",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"source-map-support": "^0.5.19",
|
||||
"ts-jest": "^28.0.5",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "^4.1.5"
|
||||
"supertest": "^7.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^5.0.0",
|
||||
"vitest-sonar-reporter": "^3.0.0"
|
||||
},
|
||||
"jestSonar": {
|
||||
"sonar56x": true,
|
||||
"reportPath": "testResults",
|
||||
"reportFile": "sonar-report.xml",
|
||||
"indent": 4
|
||||
"overrides": {
|
||||
"better-auth": {
|
||||
"vitest": "$vitest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
-- nachklang_admin: identity, sessions and per-app permissions for every
|
||||
-- *.nachklang.art app.
|
||||
--
|
||||
-- The better-auth tables below (user, session, account, verification, passkey,
|
||||
-- rateLimit) mirror what better-auth 1.7.2 derives from the configuration in
|
||||
-- src/models/admin/admin.auth.ts, including the `disabled` additionalField on
|
||||
-- `user` and the `rateLimit` table that rateLimit.storage='database' requires.
|
||||
-- On every better-auth upgrade: re-derive the table list, diff it against this
|
||||
-- file, and add a numbered migration - never edit this one in place.
|
||||
--
|
||||
-- Table and column names are better-auth's own ("camel" casing, so `rateLimit`
|
||||
-- and `userId`). MariaDB on Linux compares table names case-sensitively, so the
|
||||
-- casing here is load-bearing. The two Nachklang-owned tables at the bottom use
|
||||
-- the snake_case convention of the rest of this repo's SQL.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`email` VARCHAR(255) NOT NULL,
|
||||
`emailVerified` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`image` TEXT DEFAULT NULL,
|
||||
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- Nachklang addition, declared through better-auth's additionalFields so
|
||||
-- the adapter knows about it. Disabling also revokes the user's sessions.
|
||||
`disabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `user_email` (`email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `session` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`expiresAt` DATETIME NOT NULL,
|
||||
`token` VARCHAR(255) NOT NULL,
|
||||
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`ipAddress` VARCHAR(255) DEFAULT NULL,
|
||||
`userAgent` TEXT DEFAULT NULL,
|
||||
`userId` VARCHAR(36) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `session_token` (`token`),
|
||||
KEY `session_user` (`userId`),
|
||||
CONSTRAINT `session_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `account` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
-- 1.7 addition: distinguishes a local credential account
|
||||
-- ("local:credential") from an OAuth issuer. Written by better-auth.
|
||||
`issuer` VARCHAR(255) NOT NULL,
|
||||
`accountId` VARCHAR(255) NOT NULL,
|
||||
`providerId` VARCHAR(255) NOT NULL,
|
||||
`userId` VARCHAR(36) NOT NULL,
|
||||
`accessToken` TEXT DEFAULT NULL,
|
||||
`refreshToken` TEXT DEFAULT NULL,
|
||||
`idToken` TEXT DEFAULT NULL,
|
||||
`accessTokenExpiresAt` DATETIME DEFAULT NULL,
|
||||
`refreshTokenExpiresAt` DATETIME DEFAULT NULL,
|
||||
`scope` TEXT DEFAULT NULL,
|
||||
`password` TEXT DEFAULT NULL,
|
||||
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `account_user` (`userId`),
|
||||
KEY `account_provider` (`providerId`, `accountId`),
|
||||
CONSTRAINT `account_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Password-reset and e-mail-verification tokens.
|
||||
CREATE TABLE IF NOT EXISTS `verification` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`identifier` VARCHAR(255) NOT NULL,
|
||||
`value` TEXT NOT NULL,
|
||||
`expiresAt` DATETIME NOT NULL,
|
||||
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `verification_identifier` (`identifier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `passkey` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`name` VARCHAR(255) DEFAULT NULL,
|
||||
`publicKey` TEXT NOT NULL,
|
||||
`userId` VARCHAR(36) NOT NULL,
|
||||
`credentialID` VARCHAR(255) NOT NULL,
|
||||
`counter` INT NOT NULL DEFAULT 0,
|
||||
`deviceType` VARCHAR(255) NOT NULL,
|
||||
`backedUp` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`transports` VARCHAR(255) DEFAULT NULL,
|
||||
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`aaguid` VARCHAR(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `passkey_user` (`userId`),
|
||||
KEY `passkey_credential` (`credentialID`),
|
||||
CONSTRAINT `passkey_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Required by rateLimit.storage = 'database' in admin.auth.ts. Passenger may
|
||||
-- run several API instances, and an in-memory limiter would give each of them
|
||||
-- its own budget.
|
||||
CREATE TABLE IF NOT EXISTS `rateLimit` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`key` VARCHAR(255) NOT NULL,
|
||||
`count` INT NOT NULL DEFAULT 0,
|
||||
-- Epoch milliseconds, not a DATETIME: better-auth stores a number here.
|
||||
`lastRequest` BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `rate_limit_key` (`key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Nachklang-owned tables
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Which apps a user may administer. `admin` is just another app: holding it is
|
||||
-- what lets someone manage users and invitations. A permission is (app, role);
|
||||
-- `access` is the only role today, and the key admits several per app so finer
|
||||
-- ones can be added by inserting rows rather than by migrating this table.
|
||||
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
|
||||
`user_id` VARCHAR(36) NOT NULL,
|
||||
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
|
||||
-- One row per (user, app, role). `access` means "may use this app at all"
|
||||
-- and is the only role today; the key allows several per app so a finer
|
||||
-- permission can be added later by inserting rows, not by migrating.
|
||||
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
|
||||
`granted_by` VARCHAR(36) DEFAULT NULL,
|
||||
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- (user_id, app) is the leftmost prefix of this key, so the per-request
|
||||
-- permission lookup needs no separate index.
|
||||
PRIMARY KEY (`user_id`, `app`, `role`),
|
||||
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- The only route to a new account: there is no public sign-up. Only the
|
||||
-- SHA-256 of the token is stored, so a dump of this table hands out no access.
|
||||
CREATE TABLE IF NOT EXISTS `invitations` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`email` VARCHAR(255) NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`token_hash` CHAR(64) NOT NULL,
|
||||
`permissions` JSON NOT NULL,
|
||||
`invited_by` VARCHAR(36) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`accepted_at` DATETIME DEFAULT NULL,
|
||||
`revoked_at` DATETIME DEFAULT NULL,
|
||||
UNIQUE KEY `inv_token_hash` (`token_hash`),
|
||||
KEY `inv_email` (`email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as appRoot from 'app-root-path';
|
||||
import * as winston from 'winston';
|
||||
import appRoot from 'app-root-path';
|
||||
import winston from 'winston';
|
||||
|
||||
const options = {
|
||||
file_info: {
|
||||
level: 'info',
|
||||
filename: `${appRoot}/logs/app.log`,
|
||||
filename: `${appRoot.path}/logs/app.log`,
|
||||
handleExceptions: true,
|
||||
json: true,
|
||||
maxsize: 5242880, // 5MB
|
||||
@@ -13,7 +13,7 @@ const options = {
|
||||
},
|
||||
file_error: {
|
||||
level: 'error',
|
||||
filename: `${appRoot}/logs/error.log`,
|
||||
filename: `${appRoot.path}/logs/error.log`,
|
||||
handleExceptions: true,
|
||||
json: true,
|
||||
maxsize: 5242880, // 5MB
|
||||
@@ -22,7 +22,7 @@ const options = {
|
||||
},
|
||||
file_debug: {
|
||||
level: 'debug',
|
||||
filename: `${appRoot}/logs/debug.log`,
|
||||
filename: `${appRoot.path}/logs/debug.log`,
|
||||
handleExceptions: true,
|
||||
json: true,
|
||||
maxsize: 5242880, // 5MB
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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 {};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
};
|
||||
|
||||
// `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});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
const mariadb = require('mariadb');
|
||||
import mariadb from 'mariadb';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
import express, {Request, Response} from 'express';
|
||||
import {Guid} from 'guid-typescript';
|
||||
import logger from '../../middleware/logger';
|
||||
import {eventsRouter} from './events/events.router';
|
||||
import logger from '../../middleware/logger.js';
|
||||
import {eventsRouter} from './events/events.router.js';
|
||||
import {usersRouter} from './users/users.router.js';
|
||||
|
||||
/**
|
||||
* Router Definition
|
||||
@@ -12,8 +13,42 @@ import {eventsRouter} from './events/events.router';
|
||||
export const calendarRouter = express.Router();
|
||||
|
||||
calendarRouter.use('/events', eventsRouter);
|
||||
calendarRouter.use('/users', usersRouter);
|
||||
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /calendar:
|
||||
* get:
|
||||
* summary: Calendar API root endpoint
|
||||
* description: Returns a welcome message for the Nachklang e.V. Calendar API.
|
||||
* tags:
|
||||
* - calendar
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* text/plain:
|
||||
* schema:
|
||||
* type: string
|
||||
* example: Nachklang e.V. Calendar API Endpoint
|
||||
* 500:
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: PROCESSING_ERROR
|
||||
* message:
|
||||
* type: string
|
||||
* example: Internal Server Error. Try again later.
|
||||
* reference:
|
||||
* type: string
|
||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||
*/
|
||||
calendarRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.status(200).send('Nachklang e.V. Calendar API Endpoint');
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as UserService from '../users/users.service.js';
|
||||
|
||||
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* Checks if the password gives admin privileges (view / create / edit / delete)
|
||||
* @param password
|
||||
*/
|
||||
export const checkAdminPrivileges = async (sessionId: string, sessionKey: string, ip: string) => {
|
||||
if(sessionId) {
|
||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
||||
return user?.isActive ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the password gives member view privileges
|
||||
* @param password
|
||||
*/
|
||||
export const checkMemberPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
|
||||
if(sessionId) {
|
||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
||||
return user?.isActive ?? false;
|
||||
}
|
||||
|
||||
return password == process.env.MEMBER_CREDENTIAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the password gives choir view privileges
|
||||
* @param password
|
||||
*/
|
||||
export const checkChoirPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
|
||||
if(sessionId) {
|
||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
||||
return user?.isActive ?? false;
|
||||
}
|
||||
|
||||
return password == process.env.CHOIR_CREDENTIAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the password gives management view privileges
|
||||
* @param password
|
||||
*/
|
||||
export const checkManagementPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
|
||||
if(sessionId) {
|
||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
||||
return user?.isActive ?? false;
|
||||
}
|
||||
|
||||
return password == process.env.MANAGEMENT_CREDENTIAL;
|
||||
}
|
||||
|
||||
export const hasAccess = async (calendarName: string, sessionId: string, sessionKey: string, password: string, ip: string) => {
|
||||
switch (calendarName) {
|
||||
case 'public':
|
||||
return true;
|
||||
case 'members':
|
||||
return await checkMemberPrivileges(sessionId, sessionKey, password, ip);
|
||||
case 'choir':
|
||||
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
|
||||
case 'management':
|
||||
return await checkManagementPrivileges(sessionId, sessionKey, password, ip);
|
||||
case 'birthdays':
|
||||
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,113 @@
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Event:
|
||||
* type: object
|
||||
* required:
|
||||
* - eventId
|
||||
* - calendarId
|
||||
* - uuid
|
||||
* - name
|
||||
* - description
|
||||
* - startDateTime
|
||||
* - endDateTime
|
||||
* - createdDate
|
||||
* - location
|
||||
* - createdById
|
||||
* - url
|
||||
* - wholeDay
|
||||
* properties:
|
||||
* eventId:
|
||||
* type: integer
|
||||
* description: The unique identifier for the event
|
||||
* example: 123
|
||||
* calendarId:
|
||||
* type: integer
|
||||
* description: The ID of the calendar this event belongs to
|
||||
* example: 1
|
||||
* uuid:
|
||||
* type: string
|
||||
* description: A unique UUID for the event
|
||||
* example: "550e8400-e29b-41d4-a716-446655440000"
|
||||
* name:
|
||||
* type: string
|
||||
* description: The name/title of the event
|
||||
* example: "Concert at Musikhochschule"
|
||||
* description:
|
||||
* type: string
|
||||
* description: A detailed description of the event
|
||||
* example: "Annual concert at the Musikhochschule"
|
||||
* startDateTime:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: The start date and time of the event
|
||||
* example: "2023-06-15T19:00:00.000Z"
|
||||
* endDateTime:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: The end date and time of the event
|
||||
* example: "2023-06-15T21:00:00.000Z"
|
||||
* createdDate:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: The date and time when the event was created
|
||||
* example: "2023-05-01T10:00:00.000Z"
|
||||
* lastModifiedDate:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* example: "2023-05-01T10:00:00.000Z"
|
||||
* location:
|
||||
* type: string
|
||||
* description: The location of the event
|
||||
* example: "Musikhochschule, Karlsruhe"
|
||||
* createdBy:
|
||||
* type: string
|
||||
* description: The name of the user who created the event
|
||||
* example: "John Doe"
|
||||
* createdById:
|
||||
* type: integer
|
||||
* description: The ID of the user who created the event
|
||||
* example: 456
|
||||
* lastModifiedBy:
|
||||
* type: string
|
||||
* description: The name of the user who last modified the event
|
||||
* example: "John Doe"
|
||||
* lastModifiedById:
|
||||
* type: integer
|
||||
* description: The ID of the user who last modified the event
|
||||
* example: 456
|
||||
* url:
|
||||
* type: string
|
||||
* description: A URL with more information about the event
|
||||
* example: "https://www.nachklang.art/events/concert"
|
||||
* wholeDay:
|
||||
* type: boolean
|
||||
* description: Whether the event lasts the whole day
|
||||
* example: false
|
||||
* status:
|
||||
* type: string
|
||||
* description: The status of the event
|
||||
* enum: [PUBLIC, PRIVATE, DRAFT, DELETED]
|
||||
* example: "PUBLIC"
|
||||
*/
|
||||
export interface Event {
|
||||
event_id: number;
|
||||
calendar_id: number;
|
||||
eventId: number;
|
||||
calendarId: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
start_datetime: Date;
|
||||
end_datetime: Date;
|
||||
created_date: Date;
|
||||
startDateTime: Date;
|
||||
endDateTime: Date;
|
||||
createdDate: Date;
|
||||
lastModifiedDate?: Date;
|
||||
location: string;
|
||||
created_by: string;
|
||||
createdBy?: string;
|
||||
createdById: number;
|
||||
lastModifiedBy?: string;
|
||||
lastModifiedById?: number;
|
||||
url: string;
|
||||
wholeDay: boolean;
|
||||
repeatFrequency: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import {Guid} from 'guid-typescript';
|
||||
import {Event} from './event.interface';
|
||||
import {NachklangCalendarDB} from '../Calendar.db';
|
||||
import {Event} from './event.interface.js';
|
||||
import {NachklangCalendarDB} from '../Calendar.db.js';
|
||||
|
||||
|
||||
dotenv.config();
|
||||
@@ -15,11 +14,50 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
let eventRows: Event[] = [];
|
||||
try {
|
||||
const eventsQuery = 'SELECT * FROM events WHERE calendar_id = ?';
|
||||
const eventsRes = await conn.query(eventsQuery, calendarId);
|
||||
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
|
||||
const calendarRes = await conn.query(calendarQuery, calendarId);
|
||||
let calendarsToFetch: number[] = [calendarId];
|
||||
for(let row of calendarRes) {
|
||||
let includes: number[] = JSON.parse(row.includes_calendars);
|
||||
calendarsToFetch = [...calendarsToFetch, ...includes];
|
||||
}
|
||||
|
||||
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.calendar_id IN (?) AND v.status = 'PUBLIC'
|
||||
ORDER BY e.event_id`;
|
||||
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch]);
|
||||
|
||||
for (let row of eventsRes) {
|
||||
eventRows.push(row);
|
||||
eventRows.push({
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
return eventRows;
|
||||
@@ -30,3 +68,275 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns all events for the given calendar for the admin UI (therefore includes admin relevant information and
|
||||
* ignores the calendar includes
|
||||
* @param calendarId
|
||||
*/
|
||||
export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
let eventRows: Event[] = [];
|
||||
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.calendar_id = ?
|
||||
ORDER BY e.event_id`;
|
||||
const eventsRes = await conn.query(eventsQuery, calendarId);
|
||||
|
||||
for (let row of eventsRes) {
|
||||
eventRows.push({
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
return eventRows;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
// Return connection
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a single event by id (latest version, any status), or null if it
|
||||
* doesn't exist. Unlike getAllEvents/getAllEventsAdmin this isn't scoped to
|
||||
* a calendar - callers that need to enforce calendar/status visibility
|
||||
* should check the returned event's calendarId/status themselves.
|
||||
* @param eventId The event id
|
||||
*/
|
||||
export const getEventById = async (eventId: number): Promise<Event | null> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
const eventsQuery = `
|
||||
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
|
||||
* @param event The event to create
|
||||
*/
|
||||
export const createEvent = async (event: Event): Promise<number> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
let eventUUID = Guid.create().toString();
|
||||
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id';
|
||||
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]);
|
||||
|
||||
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
|
||||
await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]);
|
||||
|
||||
await conn.commit();
|
||||
|
||||
return eventsRes[0].event_id;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the given event in the database
|
||||
* @param event The event to update
|
||||
*/
|
||||
export const updateEvent = async (event: Event): Promise<number> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
|
||||
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]);
|
||||
|
||||
await conn.commit();
|
||||
|
||||
return versionRes.affectedRows;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes the given event from the database
|
||||
* @param event The event to delete
|
||||
*/
|
||||
export const deleteEvent = async (event: Event): Promise<boolean> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_id) VALUES (?,?,?);'
|
||||
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]);
|
||||
|
||||
await conn.commit();
|
||||
|
||||
return versionRes.affectedRows === 1;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves an event to the specified calendar
|
||||
* @param event The event to move. Has to have the target calendar set already.
|
||||
*/
|
||||
export const moveEvent = async (event: Event): Promise<boolean> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const eventQuery = 'UPDATE events SET calendar_id = ? WHERE event_id = ?';
|
||||
const eventRes = await conn.execute(eventQuery, [event.calendarId, event.eventId]);
|
||||
|
||||
await conn.commit();
|
||||
|
||||
return eventRes.affectedRows === 1;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next upcoming event for the given calendar
|
||||
* @param calendarId The calendar Id
|
||||
*/
|
||||
export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
|
||||
const calendarRes = await conn.query(calendarQuery, calendarId);
|
||||
let calendarsToFetch: number[] = [calendarId];
|
||||
for(let row of calendarRes) {
|
||||
let includes: number[] = JSON.parse(row.includes_calendars);
|
||||
calendarsToFetch = [...calendarsToFetch, ...includes];
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
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.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ?
|
||||
ORDER BY v.start_datetime ASC
|
||||
LIMIT 1`;
|
||||
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch, now]);
|
||||
|
||||
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
|
||||
} as Event;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
// Return connection
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {Event} from './event.interface';
|
||||
import {Event} from './event.interface.js';
|
||||
|
||||
/**
|
||||
* Interface to external classes - Turns the given events into an ical string
|
||||
* @param events
|
||||
*/
|
||||
export const convertToIcal = async (events: Event[]): Promise<string> => {
|
||||
try {
|
||||
let ical: iCalFile = {body: []};
|
||||
@@ -15,6 +19,10 @@ export const convertToIcal = async (events: Event[]): Promise<string> => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to serialize an iCalFile object into an ical string
|
||||
* @param ical
|
||||
*/
|
||||
const serializeIcalFile = (ical: iCalFile): string => {
|
||||
let returnString = '';
|
||||
|
||||
@@ -27,6 +35,10 @@ const serializeIcalFile = (ical: iCalFile): string => {
|
||||
return returnString;
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to serialize a single ical event into an ical event string
|
||||
* @param icalevent
|
||||
*/
|
||||
const serializeIcalEvent = (icalevent: iCalEvent): string => {
|
||||
let returnString = '';
|
||||
|
||||
@@ -34,22 +46,31 @@ const serializeIcalEvent = (icalevent: iCalEvent): string => {
|
||||
returnString += 'UID:' + icalevent.uid;
|
||||
returnString += 'DTSTAMP:' + icalevent.created;
|
||||
returnString += 'ORGANIZER:' + icalevent.organizer;
|
||||
if(icalevent.wholeDay) {
|
||||
returnString += 'DTSTART;VALUE=DATE:' + icalevent.start;
|
||||
returnString += 'DTEND;VALUE=DATE:' + icalevent.end;
|
||||
} else {
|
||||
returnString += 'DTSTART;TZID=Europe/Berlin:' + icalevent.start;
|
||||
returnString += 'DTEND;TZID=Europe/Berlin:' + icalevent.end;
|
||||
}
|
||||
if(!isNullOrBlank(icalevent.repeatFrequency)) returnString += 'RRULE:FREQ=' + icalevent.repeatFrequency;
|
||||
returnString += 'SUMMARY:' + icalevent.summary;
|
||||
returnString += 'DESCRIPTION:' + icalevent.description;
|
||||
returnString += 'LOCATION:' + icalevent.location;
|
||||
returnString += 'URL:' + icalevent.url;
|
||||
if(!isNullOrBlank(icalevent.description)) returnString += 'DESCRIPTION:' + icalevent.description;
|
||||
if(!isNullOrBlank(icalevent.location)) returnString += 'LOCATION:' + icalevent.location;
|
||||
if(!isNullOrBlank(icalevent.url)) returnString += 'URL:' + icalevent.url;
|
||||
returnString += icalevent.footer;
|
||||
|
||||
return returnString;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Method to generate the ical header string
|
||||
* @param ical
|
||||
*/
|
||||
const generateHeaderInfo = (ical: iCalFile) => {
|
||||
ical.header = 'BEGIN:VCALENDAR\n' +
|
||||
'VERSION:2.0\n' +
|
||||
'PRODID:-//hacksw/handcal//NONSGML v1.0//EN\n' +
|
||||
'PRODID:-//Nachklang e.V./Nachklang Calendar//NONSGML v1.0//EN\n' +
|
||||
'CALSCALE:GREGORIAN\n' +
|
||||
'BEGIN:VTIMEZONE\n' +
|
||||
'TZID:Europe/Berlin\n' +
|
||||
@@ -73,40 +94,70 @@ const generateHeaderInfo = (ical: iCalFile) => {
|
||||
'END:VTIMEZONE\n';
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to generate the ical footer info
|
||||
* @param ical
|
||||
*/
|
||||
const generateFooterInfo = (ical: iCalFile) => {
|
||||
ical.footer = 'END:VCALENDAR';
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to add events to the iCalFile object
|
||||
* @param ical
|
||||
* @param event
|
||||
*/
|
||||
const addEventToFile = (ical: iCalFile, event: Event) => {
|
||||
ical.body.push(createIcalEvent(event));
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to turn an event object into an iCalEvent object
|
||||
* @param event
|
||||
*/
|
||||
const createIcalEvent = (event: Event): iCalEvent => {
|
||||
let description = event.description ? event.description + '\n' : '';
|
||||
let location = event.location ? event.location + '\n' : '';
|
||||
let url = event.url ? event.url + '\n' : '';
|
||||
|
||||
return {
|
||||
header: 'BEGIN:VEVENT\n',
|
||||
uid: event.uuid + '\n',
|
||||
created: formatDate(event.created_date) + 'Z\n',
|
||||
organizer: event.created_by + '\n',
|
||||
start: formatDate(event.start_datetime) + '\n',
|
||||
end: formatDate(event.end_datetime) + '\n',
|
||||
created: formatDate(event.createdDate) + 'Z\n',
|
||||
organizer: event.createdBy + '\n',
|
||||
start: formatDate(event.startDateTime, event.wholeDay) + '\n',
|
||||
end: formatDate(event.endDateTime, event.wholeDay, true) + '\n',
|
||||
repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '',
|
||||
summary: event.name + '\n',
|
||||
description: event.description + '\n',
|
||||
location: event.location + '\n',
|
||||
url: event.url + '\n',
|
||||
description: description,
|
||||
location: location,
|
||||
url: url,
|
||||
wholeDay: event.wholeDay,
|
||||
footer: 'END:VEVENT\n'
|
||||
};
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
/**
|
||||
* Helper method to format dates in a valid iCal format
|
||||
* @param date
|
||||
* @param wholeDayFormat
|
||||
* @param isEndDate
|
||||
*/
|
||||
const formatDate = (date: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
|
||||
let returnString = '';
|
||||
|
||||
// We need to do this for whole day events as otherwise the event ends one day too early
|
||||
if(wholeDayFormat && isEndDate) date.setDate(date.getDate() + 1)
|
||||
|
||||
returnString += date.getFullYear();
|
||||
returnString += (date.getMonth() + 1).toString().padStart(2, '0'); // +1 Because JS sucks
|
||||
returnString += date.getDate().toString().padStart(2, '0');
|
||||
if(!wholeDayFormat) {
|
||||
returnString += 'T';
|
||||
returnString += date.getHours().toString().padStart(2, '0');
|
||||
returnString += date.getMinutes().toString().padStart(2, '0');
|
||||
returnString += date.getSeconds().toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
return returnString;
|
||||
};
|
||||
@@ -128,5 +179,15 @@ export interface iCalEvent {
|
||||
description: string;
|
||||
location: string;
|
||||
url: string;
|
||||
wholeDay: boolean;
|
||||
repeatFrequency: string;
|
||||
footer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given string is null, undefined or blank
|
||||
* @param str The string to check
|
||||
*/
|
||||
function isNullOrBlank(str: string | null): boolean {
|
||||
return str === null || str === undefined || str.trim() === '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Session:
|
||||
* type: object
|
||||
* required:
|
||||
* - sessionId
|
||||
* - userId
|
||||
* - sessionKey
|
||||
* - sessionKeyHash
|
||||
* - lastIP
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: integer
|
||||
* description: The unique identifier for the session
|
||||
* example: 789
|
||||
* userId:
|
||||
* type: integer
|
||||
* description: The ID of the user this session belongs to
|
||||
* example: 456
|
||||
* sessionKey:
|
||||
* type: string
|
||||
* description: The session key used for authentication
|
||||
* example: "abc123def456"
|
||||
* sessionKeyHash:
|
||||
* type: string
|
||||
* description: The hashed session key (not returned in API responses)
|
||||
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
|
||||
* createdDate:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: The date and time when the session was created
|
||||
* example: "2023-05-01T10:00:00.000Z"
|
||||
* validUntil:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: The date and time until when the session is valid
|
||||
* example: "2023-05-08T10:00:00.000Z"
|
||||
* lastIP:
|
||||
* type: string
|
||||
* description: The last IP address used with this session
|
||||
* example: "192.168.1.1"
|
||||
*/
|
||||
export interface Session {
|
||||
sessionId: number;
|
||||
userId: number;
|
||||
sessionKey: string;
|
||||
sessionKeyHash: string;
|
||||
createdDate?: Date;
|
||||
validUntil?: Date;
|
||||
lastIP: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* User:
|
||||
* type: object
|
||||
* required:
|
||||
* - userId
|
||||
* - fullName
|
||||
* - passwordHash
|
||||
* - email
|
||||
* - isActive
|
||||
* properties:
|
||||
* userId:
|
||||
* type: integer
|
||||
* description: The unique identifier for the user
|
||||
* example: 456
|
||||
* fullName:
|
||||
* type: string
|
||||
* description: The full name of the user
|
||||
* example: "John Doe"
|
||||
* passwordHash:
|
||||
* type: string
|
||||
* description: The hashed password of the user (not returned in API responses)
|
||||
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: The email address of the user
|
||||
* example: "john.doe@nachklang.art"
|
||||
* isActive:
|
||||
* type: boolean
|
||||
* description: Whether the user account is active
|
||||
* example: true
|
||||
*/
|
||||
export interface User {
|
||||
userId: number;
|
||||
fullName: string;
|
||||
passwordHash: string;
|
||||
email: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Required External Modules and Interfaces
|
||||
*/
|
||||
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as UserService from './users.service.js';
|
||||
import {Session} from './session.interface.js';
|
||||
import {User} from './user.interface.js';
|
||||
import {Guid} from 'guid-typescript';
|
||||
import logger from '../../../middleware/logger.js';
|
||||
|
||||
/**
|
||||
* Router Definition
|
||||
*/
|
||||
|
||||
export const usersRouter = express.Router();
|
||||
|
||||
|
||||
/**
|
||||
* Controller Definitions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /calendar/users/register:
|
||||
* post:
|
||||
* summary: Register a new user
|
||||
* description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses.
|
||||
* tags:
|
||||
* - calendar
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - password
|
||||
* - fullName
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: john.doe@nachklang.art
|
||||
* description: Must be an official Nachklang email address
|
||||
* password:
|
||||
* type: string
|
||||
* format: password
|
||||
* example: securePassword123
|
||||
* fullName:
|
||||
* type: string
|
||||
* example: John Doe
|
||||
* responses:
|
||||
* 201:
|
||||
* description: User registered successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: integer
|
||||
* example: 123
|
||||
* sessionKey:
|
||||
* type: string
|
||||
* example: abc123def456
|
||||
* 400:
|
||||
* description: Bad request - missing or invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: Missing parameters
|
||||
* 500:
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: PROCESSING_ERROR
|
||||
* message:
|
||||
* type: string
|
||||
* example: Internal Server Error. Try again later.
|
||||
* reference:
|
||||
* type: string
|
||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||
*/
|
||||
// POST users/register
|
||||
usersRouter.post('/register', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const password: string = req.body.password;
|
||||
const email: string = req.body.email;
|
||||
const fullName: string = req.body.fullName;
|
||||
const ip: string = req.socket.remoteAddress ?? '';
|
||||
|
||||
if (!password || !email || !fullName) {
|
||||
// Missing
|
||||
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/;
|
||||
|
||||
if(!emailRegex.test(email)) {
|
||||
res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the user and a session
|
||||
const session: Session = await UserService.createUser(email, password, fullName, ip);
|
||||
|
||||
// Send the session details back to the user
|
||||
res.status(201).send({
|
||||
sessionId: session.sessionId,
|
||||
sessionKey: session.sessionKey
|
||||
});
|
||||
} catch (e: any) {
|
||||
let errorGuid = Guid.create().toString();
|
||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||
res.status(500).send({
|
||||
'status': 'PROCESSING_ERROR',
|
||||
'message': 'Internal Server Error. Try again later.',
|
||||
'reference': errorGuid
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /calendar/users/activate:
|
||||
* get:
|
||||
* summary: Activate a user account
|
||||
* description: Activates a user account using the provided user ID and activation token.
|
||||
* tags:
|
||||
* - calendar
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The ID of the user to activate
|
||||
* - in: query
|
||||
* name: token
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: The activation token sent to the user's email
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User activated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: OK
|
||||
* message:
|
||||
* type: string
|
||||
* example: User activated
|
||||
* 400:
|
||||
* description: Bad request - missing parameters or activation failed
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: PROCESSING_ERROR
|
||||
* message:
|
||||
* type: string
|
||||
* example: Error activating user. Please contact your administrator.
|
||||
* 500:
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: PROCESSING_ERROR
|
||||
* message:
|
||||
* type: string
|
||||
* example: Internal Server Error. Try again later.
|
||||
* reference:
|
||||
* type: string
|
||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||
*/
|
||||
// GET /users/activate
|
||||
usersRouter.get('/activate', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId: number = parseInt(req.query.id as string ?? '-1', 10);
|
||||
const token: string = req.query.token as string ?? '';
|
||||
|
||||
if (!userId || !token) {
|
||||
// Missing
|
||||
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the user and a session
|
||||
const success: boolean = await UserService.activateUser(userId, token);
|
||||
|
||||
// Send the session details back to the user
|
||||
if(success) {
|
||||
res.status(200).send({
|
||||
'status': 'OK',
|
||||
'message': 'User activated'
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'});
|
||||
} catch (e: any) {
|
||||
let errorGuid = Guid.create().toString();
|
||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||
res.status(500).send({
|
||||
'status': 'PROCESSING_ERROR',
|
||||
'message': 'Internal Server Error. Try again later.',
|
||||
'reference': errorGuid
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /calendar/users/login:
|
||||
* post:
|
||||
* summary: Login a user
|
||||
* description: Authenticates a user with the provided email and password and returns a session.
|
||||
* tags:
|
||||
* - calendar
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: john.doe@nachklang.art
|
||||
* password:
|
||||
* type: string
|
||||
* format: password
|
||||
* example: securePassword123
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: integer
|
||||
* example: 123
|
||||
* sessionKey:
|
||||
* type: string
|
||||
* example: abc123def456
|
||||
* 400:
|
||||
* description: Bad request - missing parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: Missing parameters
|
||||
* 401:
|
||||
* description: Unauthorized - invalid credentials
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: Wrong username and / or password
|
||||
* sessionId:
|
||||
* type: integer
|
||||
* example: -1
|
||||
* sessionKey:
|
||||
* type: string
|
||||
* example: ""
|
||||
* 500:
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: PROCESSING_ERROR
|
||||
* message:
|
||||
* type: string
|
||||
* example: Internal Server Error. Try again later.
|
||||
* reference:
|
||||
* type: string
|
||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||
*/
|
||||
// POST users/login
|
||||
usersRouter.post('/login', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const password: string = req.body.password;
|
||||
const email: string = req.body.email;
|
||||
const ip: string = req.socket.remoteAddress ?? '';
|
||||
|
||||
if (!password || !email) {
|
||||
// Missing
|
||||
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a session
|
||||
const session: Session | null = await UserService.login(email, password, ip);
|
||||
|
||||
if (!session || !session.sessionId) {
|
||||
// Error logging in, probably wrong username / password
|
||||
res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the session details back to the user
|
||||
res.status(200).send({
|
||||
sessionId: session.sessionId,
|
||||
sessionKey: session.sessionKey
|
||||
});
|
||||
} catch (e: any) {
|
||||
let errorGuid = Guid.create().toString();
|
||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||
res.status(500).send({
|
||||
'status': 'PROCESSING_ERROR',
|
||||
'message': 'Internal Server Error. Try again later.',
|
||||
'reference': errorGuid
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /calendar/users/checkSessionValid:
|
||||
* post:
|
||||
* summary: Check if a session is valid
|
||||
* description: Checks if the provided session is valid and returns the user information if it is.
|
||||
* tags:
|
||||
* - calendar
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - sessionId
|
||||
* - sessionKey
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: integer
|
||||
* example: 123
|
||||
* sessionKey:
|
||||
* type: string
|
||||
* example: abc123def456
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session is valid
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* 401:
|
||||
* description: Unauthorized - invalid session
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* example: ["Invalid session"]
|
||||
* 500:
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: PROCESSING_ERROR
|
||||
* message:
|
||||
* type: string
|
||||
* example: Internal Server Error. Try again later.
|
||||
* reference:
|
||||
* type: string
|
||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||
*/
|
||||
// POST users/checkSessionValid
|
||||
usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ip: string = req.socket.remoteAddress ?? '';
|
||||
const session_id = req.body.sessionId;
|
||||
const session_key = req.body.sessionKey;
|
||||
|
||||
if (!session_id || !session_key) {
|
||||
// Error logging in, probably wrong username / password
|
||||
res.status(401).send(JSON.stringify({messages: ['No session detected']}));
|
||||
return;
|
||||
}
|
||||
|
||||
const user: User | null = await UserService.checkSession(session_id, session_key, ip);
|
||||
|
||||
if (!user || !user.userId) {
|
||||
// Error logging in, probably wrong username / password
|
||||
res.status(401).send(JSON.stringify({messages: ['Invalid session']}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).send(user);
|
||||
} catch (e: any) {
|
||||
let errorGuid = Guid.create().toString();
|
||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||
res.status(500).send({
|
||||
'status': 'PROCESSING_ERROR',
|
||||
'message': 'Internal Server Error. Try again later.',
|
||||
'reference': errorGuid
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /calendar/users/initiatePasswordReset:
|
||||
* post:
|
||||
* summary: Initiates a password reset
|
||||
* description: Checks if the user exists and if so, initiates a password reset by sending an email to the user.
|
||||
* tags:
|
||||
* - calendar
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* example: Success
|
||||
* description: A list of status messages
|
||||
* 400:
|
||||
* description: Problem with the request. Please consider the returned detailed error.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* example: Missing parameters
|
||||
* description: A list of error messages
|
||||
* 401:
|
||||
* description: Problem with authorizing the user. Please check the provided credentials.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* example: Invalid session
|
||||
* description: A list of error messages
|
||||
* 500:
|
||||
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* description: The response status
|
||||
* example: PROCESSING_ERROR
|
||||
* message:
|
||||
* type: string
|
||||
* description: The detailed error message
|
||||
* example: Internal Server Error. Try again later.
|
||||
* reference:
|
||||
* type: string
|
||||
* description: An error reference for getting support concerning this error.
|
||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* example: patrick@nachklang.art
|
||||
*/
|
||||
usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => {
|
||||
try {
|
||||
const username = req.body.username;
|
||||
|
||||
if (!username) {
|
||||
// Error logging in, probably wrong username / password
|
||||
res.status(400).send(JSON.stringify({messages: ['No username given']}));
|
||||
return;
|
||||
}
|
||||
|
||||
const success: boolean = await UserService.initiatePasswordReset(username);
|
||||
|
||||
if (!success) {
|
||||
// Error logging in, probably wrong username / password
|
||||
res.status(401).send(JSON.stringify({messages: ['Error']}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).send(JSON.stringify({messages: ['Success']}));
|
||||
} catch (e: any) {
|
||||
let errorGuid = Guid.create().toString();
|
||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||
res.status(500).send({
|
||||
'status': 'PROCESSING_ERROR',
|
||||
'message': 'Internal Server Error. Try again later.',
|
||||
'reference': errorGuid
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /calendar/users/finalizePasswordReset:
|
||||
* post:
|
||||
* summary: Finalizes the password reset
|
||||
* description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password.
|
||||
* tags:
|
||||
* - calendar
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* example: Success
|
||||
* description: A list of status messages
|
||||
* 400:
|
||||
* description: Problem with the request. Please consider the returned detailed error.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* example: Missing parameters
|
||||
* description: A list of error messages
|
||||
* 401:
|
||||
* description: Problem with authorizing the user. Please check the provided credentials.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* example: Invalid session
|
||||
* description: A list of error messages
|
||||
* 500:
|
||||
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* description: The response status
|
||||
* example: PROCESSING_ERROR
|
||||
* message:
|
||||
* type: string
|
||||
* description: The detailed error message
|
||||
* example: Internal Server Error. Try again later.
|
||||
* reference:
|
||||
* type: string
|
||||
* description: An error reference for getting support concerning this error.
|
||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* example: patrick@nachklang.art
|
||||
* token:
|
||||
* type: string
|
||||
* example: 3ccd147f-720b-4e29-a8b7-46b63de31555
|
||||
* password:
|
||||
* type: string
|
||||
* example: ExtremelyBadPassword
|
||||
*/
|
||||
usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => {
|
||||
try {
|
||||
const username = req.body.username;
|
||||
const token = req.body.token;
|
||||
const newPassword = req.body.password;
|
||||
|
||||
if (!username) {
|
||||
// Error logging in, probably wrong username / password
|
||||
res.status(400).send(JSON.stringify({messages: ['No username, token or password given']}));
|
||||
return;
|
||||
}
|
||||
|
||||
const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword);
|
||||
|
||||
if (!success) {
|
||||
// Error logging in, probably wrong username / password
|
||||
res.status(401).send(JSON.stringify({messages: ['Error']}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).send(JSON.stringify({messages: ['Success']}));
|
||||
} catch (e: any) {
|
||||
let errorGuid = Guid.create().toString();
|
||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||
res.status(500).send({
|
||||
'status': 'PROCESSING_ERROR',
|
||||
'message': 'Internal Server Error. Try again later.',
|
||||
'reference': errorGuid
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import bcrypt from 'bcrypt';
|
||||
import {Guid} from 'guid-typescript';
|
||||
import {User} from './user.interface.js';
|
||||
import {Session} from './session.interface.js';
|
||||
import {NachklangCalendarDB} from '../Calendar.db.js';
|
||||
import {MailService} from '../../../common/common.mail.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* Data Model Interfaces
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Service Methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a user record in the database, also creates a session. Returns the session if successful.
|
||||
*/
|
||||
export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// Hash password and generate + hash session key
|
||||
const pwHash = bcrypt.hashSync(password, 10);
|
||||
const sessionKey = Guid.create().toString();
|
||||
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
|
||||
|
||||
const activationToken = Guid.create().toString();
|
||||
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
|
||||
|
||||
// Create user entry in SQL
|
||||
const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
|
||||
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
|
||||
|
||||
// Get user id of the created user
|
||||
let userId: number = -1;
|
||||
for (const row of userIdRes) {
|
||||
userId = row.user_id;
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
|
||||
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
|
||||
await conn.commit();
|
||||
|
||||
// Get session id of the created session
|
||||
let sessionId: number = -1;
|
||||
for (const row of sessionIdRes) {
|
||||
sessionId = row.session_id;
|
||||
}
|
||||
|
||||
// Send email with activation link (after commit so we don't block on email
|
||||
// delivery). sendMail never throws on a delivery failure - it logs and
|
||||
// returns false - so a mail-server problem here can't roll back the
|
||||
// already-committed user and leave registration reporting a false error.
|
||||
await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`);
|
||||
|
||||
return {
|
||||
sessionId: sessionId,
|
||||
userId: userId,
|
||||
sessionKey: sessionKey,
|
||||
sessionKeyHash: 'HIDDEN',
|
||||
lastIP: ip
|
||||
};
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export const activateUser = async (userId: number, token: string): Promise<boolean> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
|
||||
const userNameRes = await conn.query(checkTokenQuery, [userId]);
|
||||
let storedTokenHash = '';
|
||||
for (const row of userNameRes) {
|
||||
storedTokenHash = row.activation_token;
|
||||
}
|
||||
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
|
||||
return false;
|
||||
}
|
||||
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
|
||||
const activateRes = await conn.execute(activateQuery, [userId]);
|
||||
await conn.commit();
|
||||
return activateRes.affectedRows !== 0;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given credentials are valid and creates a new session if they are.
|
||||
* Returns the session information in case of a successful login
|
||||
*/
|
||||
export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// Get saved password hash
|
||||
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
|
||||
const userRows = await conn.query(query, email);
|
||||
let savedHash = '';
|
||||
let userId = -1;
|
||||
for (const row of userRows) {
|
||||
savedHash = row.password_hash;
|
||||
userId = row.user_id;
|
||||
}
|
||||
|
||||
// Check for correct password
|
||||
if (!bcrypt.compareSync(password, savedHash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate + hash session key
|
||||
const sessionKey = Guid.create().toString();
|
||||
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
|
||||
|
||||
// Create session
|
||||
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
|
||||
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
|
||||
await conn.commit();
|
||||
|
||||
// Get session id of the created session
|
||||
let sessionId: number = -1;
|
||||
for (const row of sessionIdRes) {
|
||||
sessionId = row.session_id;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: sessionId,
|
||||
userId: userId,
|
||||
sessionKey: sessionKey,
|
||||
sessionKeyHash: 'HIDDEN',
|
||||
lastIP: ip
|
||||
};
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given session information are valid and returns the user information if they are
|
||||
*/
|
||||
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// Get saved session key hash
|
||||
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
|
||||
const sessionRows = await conn.query(query, sessionId);
|
||||
let savedHash = '';
|
||||
let userId = -1;
|
||||
let validUntil = new Date();
|
||||
for (const row of sessionRows) {
|
||||
savedHash = row.session_key_hash;
|
||||
userId = row.user_id;
|
||||
validUntil = row.valid_until;
|
||||
}
|
||||
|
||||
// Check for correct key
|
||||
if (!bcrypt.compareSync(sessionKey, savedHash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the session is still valid
|
||||
if (validUntil <= new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update session entry in SQL
|
||||
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
|
||||
await conn.query(updateSessionsQuery, [ip, sessionId]);
|
||||
await conn.commit();
|
||||
|
||||
// Get the other required user information
|
||||
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
|
||||
const userRows = await conn.query(userQuery, userId);
|
||||
let email = '';
|
||||
let fullName = '';
|
||||
let is_active = false;
|
||||
for (const row of userRows) {
|
||||
email = row.email;
|
||||
fullName = row.full_name;
|
||||
is_active = row.is_active;
|
||||
}
|
||||
|
||||
// Everything is fine, return user information
|
||||
return {
|
||||
userId: userId,
|
||||
email: email,
|
||||
passwordHash: 'HIDDEN',
|
||||
fullName: fullName,
|
||||
isActive: is_active
|
||||
};
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
|
||||
const userNameRes = await conn.query(checkUsernameQuery, [email]);
|
||||
if (userNameRes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let userId: number = -1;
|
||||
let fullName: string = '';
|
||||
for(let row of userNameRes) {
|
||||
userId = row.user_id;
|
||||
fullName = row.full_Name;
|
||||
}
|
||||
|
||||
let resetToken = Guid.create().toString();
|
||||
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
|
||||
|
||||
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
|
||||
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
|
||||
|
||||
if(updateRes.affectedRows === 0) {
|
||||
return false;
|
||||
}
|
||||
await conn.commit();
|
||||
|
||||
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
|
||||
let conn = await NachklangCalendarDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
|
||||
const userNameRes = await conn.query(checkTokenQuery, [email]);
|
||||
if (userNameRes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let userId: string = '';
|
||||
let tokenHash: string = '';
|
||||
for(let row of userNameRes) {
|
||||
userId = row.user_id;
|
||||
tokenHash = row.pw_reset_token_hash;
|
||||
}
|
||||
|
||||
if(!bcrypt.compareSync(token, tokenHash)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pwHash = bcrypt.hashSync(newPassword, 10);
|
||||
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
|
||||
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
|
||||
|
||||
if(updateRes.affectedRows > 0) {
|
||||
await conn.commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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())}`;
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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};
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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());
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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('<script>');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user