31 Commits

Author SHA1 Message Date
Paddy bf7f45acce Document dotenv 16 quoting rule for .env values
A production password containing '#' was truncated after the dotenv 8 -> 16
bump (unquoted '#' now starts a comment), causing MariaDB access denied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:11:50 +02:00
Paddy 3ea9e630ed Migrate the API to native ESM and vitest; pin Node 26
Prep PR for the admin auth module (docs/plan-admin-auth.md step 1).
better-auth 1.7 ships ESM only, so the API moves off CommonJS:

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:02:26 +02:00
Paddy 449edd6c68 Relay transactional email through Salesforce instead of SMTP (#9)
Jenkins Production Deployment
Reviewed-on: #9
Co-authored-by: Patrick Müller <patrick@mueller-patrick.tech>
Co-committed-by: Patrick Müller <patrick@mueller-patrick.tech>
2026-08-31 16:17:59 +00:00
Paddy 3c4f3331d8 Add Tickets domain for the voucher-based ticket shop (#8)
Jenkins Production Deployment
Reviewed-on: #8
Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech>
Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
2026-08-24 21:18:00 +00:00
Paddy b05f6b9da0 Add Feedback domain module: public submission flow, admin CRUD, reporting (#7)
Jenkins Production Deployment
Co-authored-by: Patrick Müller <mail@pmueller.me>
Reviewed-on: #7
Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech>
Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
2026-08-23 09:39:02 +00:00
Paddy e7621b8290 Merge pull request 'Add claude init file + refactor some security issues' (#6) from feature/aiRefactoring20260502 into master
Jenkins Production Deployment
Reviewed-on: #6
2026-06-28 11:25:23 +00:00
Paddy da85d1487c Add claude init file + refactor some security issues 2026-05-02 12:22:03 +02:00
Paddy dc65b49219 Add possibility to add birthdays + repeating events to the API
Jenkins Production Deployment
2025-09-07 18:15:15 +02:00
Paddy 9c45fb11ee Add last modified information to event GET endpoints
Jenkins Production Deployment
2025-05-29 12:51:51 +02:00
Paddy 45dfc22c60 Add endpoint for getting upcoming event and adding swagger docs to all endpoints
Jenkins Production Deployment
2025-04-18 15:05:32 +02:00
Paddy a38fb20e5a Add endpoint that allows to move an event to a different calendar
Jenkins Production Deployment
2024-06-04 11:55:30 +02:00
Paddy cb85e81d67 Add new "choir" calendar and add cascading functionality for calendars
Jenkins Production Deployment
2024-06-04 11:17:28 +02:00
Paddy 59fee19a76 Fix issue with sending mails
Jenkins Production Deployment
2023-12-30 23:11:50 +01:00
Paddy a79e2186a2 Fix activation endpoint HTTP method
Jenkins Production Deployment
2023-12-30 22:55:40 +01:00
Paddy 8f93e1ab7d Add password reset endpoints and mail service for user activation
Jenkins Production Deployment
2023-12-30 22:50:47 +01:00
Paddy 34a4a6664f Fix bug where some fields were not sent back via the api
Jenkins Production Deployment
2023-05-15 20:50:50 +02:00
Paddy 76e6bbdbbf Add event versioning capabilities
Jenkins Production Deployment
2023-05-15 20:28:42 +02:00
Paddy 5e84eaea70 Future-proof admin interface of the api, make the api fully capable of handling event status 2023-05-15 19:40:44 +02:00
Paddy b8a68c2480 Add status column for events
Jenkins Production Deployment
2023-05-14 21:26:55 +02:00
Paddy 95983021ed Rework user interface field API names
Jenkins Production Deployment
2023-05-14 21:09:34 +02:00
Paddy 02f7424b56 Upgrade to proper user management 2023-05-14 19:17:30 +02:00
Paddy 93c70b0e1d #1: Add possibility to create whole-day events
Jenkins Production Deployment
2022-12-28 12:15:01 +01:00
Paddy d85f9a992b #2: Remove empty fields from generated ical
Jenkins Production Deployment
2022-12-28 11:34:25 +01:00
Paddy fc071096d8 Fix URL null error when no url is given in event
Jenkins Production Deployment
2022-12-28 01:20:06 +01:00
Paddy a34a5df5a3 Interface change to return eventId after POST call
Jenkins Production Deployment
2022-12-26 15:58:24 +01:00
Paddy 65a5e91ad1 Making createdBy field required
Jenkins Production Deployment
2022-12-25 21:50:21 +01:00
Paddy 6cb7f0d59b Interface adjustments
Jenkins Production Deployment
2022-12-25 20:53:09 +01:00
Paddy ccfa28877c Adjust privileges mgmt
Jenkins Production Deployment
2022-12-25 18:24:18 +01:00
Paddy a8f7189cb3 git add . is a difficult command to execute
Jenkins Production Deployment
2022-12-25 15:43:37 +01:00
Paddy 83c9d090e1 Add methods to insert, update and delete events
Jenkins Production Deployment
2022-12-25 15:38:13 +01:00
Paddy 0348d89121 Add credentials check and rework request structure 2022-12-25 13:49:38 +01:00
91 changed files with 12433 additions and 6062 deletions
+30
View File
@@ -0,0 +1,30 @@
# Values containing #, ", \ or surrounding spaces must be single-quoted
# (dotenv 16 treats an unquoted # as a comment): DB_PASSWORD='abc#def'
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
MEMBER_CREDENTIAL=123
CHOIR_CREDENTIAL=123
MANAGEMENT_CREDENTIAL=123
+1
View File
@@ -0,0 +1 @@
26
+84
View File
@@ -0,0 +1,84 @@
# 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
```
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) and `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).
**Request path:**
1. `app.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:** Users must have a `@nachklang.art` email. After activation they receive a session token (30-day window); the token hash + IP are stored in the DB. Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`) come from `.env`.
**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)".
Copy `.env.example` (or create `.env`) with:
```
PORT=
DB_HOST=
DB_USER=
DB_PASSWORD=
CALENDAR_DB=
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.
+55
View File
@@ -0,0 +1,55 @@
# 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
**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
**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.
+30 -5
View File
@@ -3,14 +3,15 @@ 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';
import cors from 'cors';
import logger from './src/middleware/logger.js';
// Router imports
import {calendarRouter} from './src/models/calendar/Calendar.router';
import {calendarRouter} from './src/models/calendar/Calendar.router.js';
import {feedbackRouter} from './src/models/feedback/Feedback.router.js';
import {ticketsRouter} from './src/models/tickets/Tickets.router.js';
let cors = require('cors');
dotenv.config();
if (!process.env.PORT) {
@@ -23,19 +24,40 @@ const port: number = parseInt(process.env.PORT, 10);
const app: express.Application = express();
const server: http.Server = http.createServer(app);
// 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);
// 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'
'https://calendar.nachklang.art',
'https://feedback.nachklang.art',
'https://tickets.nachklang.art'
];
const isDev = process.env.NODE_ENV !== 'production';
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({
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
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);
@@ -67,6 +89,7 @@ const options = {
swaggerDefinition,
// Paths to files containing OpenAPI definitions
apis: [
'./src/models/**/*.interface.ts',
'./src/models/**/*.router.ts'
]
};
@@ -81,6 +104,8 @@ app.use(
// Add routers
app.use('/calendar', calendarRouter);
app.use('/feedback', feedbackRouter);
app.use('/tickets', ticketsRouter);
// this is a simple route to make sure everything is working properly
app.get('/', (req: express.Request, res: express.Response) => {
+28
View File
@@ -0,0 +1,28 @@
# Local dev only — not used in production/deployment. Spins up a MariaDB
# instance with the calendar (reconstructed dev schema, see docker/init's
# disclaimer), feedback, and tickets databases pre-seeded.
#
# Usage:
# docker compose -f docker-compose.dev.yml up -d
#
# Then point .env at:
# DB_HOST=127.0.0.1
# DB_USER=nachklang
# DB_PASSWORD=devpassword
# CALENDAR_DB=nachklang_calendar
# FEEDBACK_DB=nachklang_feedback
# TICKETS_DB=nachklang_tickets
services:
mariadb:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: rootdevpassword
ports:
- "3306:3306"
volumes:
- nachklang_dev_db:/var/lib/mysql
- ./sql:/migrations:ro
- ./docker/init:/docker-entrypoint-initdb.d:ro
volumes:
nachklang_dev_db:
+10
View File
@@ -0,0 +1,10 @@
-- Local dev only. Creates the three 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 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'@'%';
FLUSH PRIVILEGES;
+89
View File
@@ -0,0 +1,89 @@
-- Local dev only. Real schema, provided directly by the repo owner
-- (calendars, events, event_versions, sessions, users) - not a guess.
USE nachklang_calendar;
CREATE TABLE `calendars` (
`calendar_id` int(11) NOT NULL AUTO_INCREMENT,
`name` text NOT NULL,
`includes_calendars` text DEFAULT NULL,
PRIMARY KEY (`calendar_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `users` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`full_name` text NOT NULL,
`password_hash` text DEFAULT NULL,
`email` text NOT NULL,
`is_active` tinyint(1) DEFAULT 0,
`pw_reset_token_hash` text DEFAULT NULL,
`activation_token` text DEFAULT NULL,
PRIMARY KEY (`user_id`),
UNIQUE KEY `email` (`email`) USING HASH
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `sessions` (
`session_id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`session_key_hash` text DEFAULT NULL,
`created_date` datetime DEFAULT current_timestamp(),
`valid_until` datetime DEFAULT (current_timestamp() + interval 30 day),
`last_ip` text DEFAULT NULL,
PRIMARY KEY (`session_id`),
KEY `sessions_users_user_id_fk` (`user_id`),
CONSTRAINT `sessions_users_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `events` (
`event_id` int(11) NOT NULL AUTO_INCREMENT,
`calendar_id` int(11) NOT NULL,
`uuid` text NOT NULL,
`created_date` datetime DEFAULT current_timestamp(),
`created_by_id` int(11) NOT NULL,
PRIMARY KEY (`event_id`),
KEY `events_calendars_calendar_id_fk` (`calendar_id`),
KEY `events_users_user_id_fk` (`created_by_id`),
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`),
CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `event_versions` (
`event_version_id` int(11) NOT NULL AUTO_INCREMENT,
`event_id` int(11) NOT NULL,
`name` text DEFAULT NULL,
`description` text DEFAULT NULL,
`start_datetime` datetime DEFAULT NULL,
`end_datetime` datetime DEFAULT NULL,
`whole_day` tinyint(1) DEFAULT NULL,
`repeat_frequency` text DEFAULT NULL,
`location` text DEFAULT NULL,
`url` text DEFAULT NULL,
`version_created_by_id` int(11) DEFAULT NULL,
`status` text DEFAULT NULL,
`version_created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`event_version_id`),
KEY `event_versions_events_event_id_fk` (`event_id`),
KEY `event_versions_users_user_id_fk` (`version_created_by_id`),
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
(1, 'public', '[]'),
(2, 'members', '[]'),
(3, 'management', '[]'),
(4, 'choir', '[]'),
(5, 'birthdays', '[]');
-- Dev admin, password: devpassword
INSERT INTO users (email, password_hash, full_name, is_active) VALUES
('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1);
INSERT INTO events (calendar_id, uuid, created_by_id) VALUES
(1, UUID(), 1),
(1, UUID(), 1),
(1, UUID(), 1);
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id) VALUES
(1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', 1),
(2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 1),
(3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', 1);
+3
View File
@@ -0,0 +1,3 @@
USE nachklang_feedback;
SOURCE /migrations/feedback/001_init.sql;
SOURCE /migrations/feedback/002_add_poster_image_url.sql;
+3
View File
@@ -0,0 +1,3 @@
USE nachklang_tickets;
SOURCE /migrations/tickets/001_init.sql;
SOURCE /migrations/tickets/002_add_require_address.sql;
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: [
'test'
]
};
+2169 -5873
View File
File diff suppressed because it is too large Load Diff
+16 -17
View File
@@ -3,23 +3,28 @@
"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"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"app-root-path": "^3.0.0",
"axios": "^0.24.0",
"axios": "^1.20.0",
"bcrypt": "^5.0.1",
"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",
"mariadb": "^3.0.2",
"random-words": "^1.1.1",
@@ -30,25 +35,19 @@
"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/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"
},
"jestSonar": {
"sonar56x": true,
"reportPath": "testResults",
"reportFile": "sonar-report.xml",
"indent": 4
"typescript": "^5.9.3",
"vitest": "^5.0.0",
"vitest-sonar-reporter": "^3.0.0"
}
}
+144
View File
@@ -0,0 +1,144 @@
-- Nachklang e.V. Feedback module — initial schema for FEEDBACK_DB
-- Apply manually against the FEEDBACK_DB database (separate from CALENDAR_DB).
-- See nachklang-feedback/IMPLEMENTATION_PLAN.md §2 for the full rationale
-- behind every design decision below (snapshot columns, denormalisation,
-- absence-over-sentinels, hashed IPs only).
--
-- Apply with e.g.:
-- mysql -h <DB_HOST> -u <DB_USER> -p <FEEDBACK_DB> < 001_init.sql
--
-- Deliberately no USE statement here: the target database is selected via
-- the mysql command line above (whatever FEEDBACK_DB is actually named in
-- .env), not hardcoded to a literal schema name.
-- 1. events -------------------------------------------------------------
CREATE TABLE events (
event_id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(80) NOT NULL,
name VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NULL,
event_date DATE NOT NULL,
feedback_deadline DATETIME NOT NULL,
is_published TINYINT(1) NOT NULL DEFAULT 0,
intro_text TEXT NULL,
created_by_email VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_events_slug (slug),
KEY idx_events_eligibility (is_published, event_date, feedback_deadline)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. songs (per-event setlist) ------------------------------------------
CREATE TABLE songs (
song_id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
composer VARCHAR(255) NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_songs_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
KEY idx_songs_event_position (event_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. questions (global reusable library) ---------------------------------
CREATE TABLE questions (
question_id INT AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(500) NOT NULL,
help_text VARCHAR(500) NULL,
question_type ENUM('SONG_PICK','SONG_RATING','FREE_TEXT') NOT NULL,
is_archived TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_questions_archived_type (is_archived, question_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. event_questions (join + ordering) -----------------------------------
CREATE TABLE event_questions (
event_question_id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
question_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_eq_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
CONSTRAINT fk_eq_question FOREIGN KEY (question_id) REFERENCES questions(question_id) ON DELETE RESTRICT,
UNIQUE KEY uq_eq_event_question (event_id, question_id),
KEY idx_eq_event_position (event_id, position, is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. submissions ----------------------------------------------------------
CREATE TABLE submissions (
submission_id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ip_hash CHAR(64) NULL,
has_guestbook TINYINT(1) NOT NULL DEFAULT 0,
has_newsletter TINYINT(1) NOT NULL DEFAULT 0,
CONSTRAINT fk_sub_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
KEY idx_sub_event_time (event_id, submitted_at),
KEY idx_sub_iphash_time (ip_hash, submitted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6. submission_answers ----------------------------------------------------
-- Deliberate denormalisation: question label/type and song title are
-- snapshotted at submission time so later edits to the question library
-- never retroactively change what a past submission means.
CREATE TABLE submission_answers (
answer_id INT AUTO_INCREMENT PRIMARY KEY,
submission_id INT NOT NULL,
event_id INT NOT NULL,
question_id INT NULL,
event_question_id INT NULL,
question_label_snapshot VARCHAR(500) NOT NULL,
question_type ENUM('SONG_PICK','SONG_RATING','FREE_TEXT') NOT NULL,
position_snapshot INT NOT NULL DEFAULT 0,
song_id INT NULL,
song_title_snapshot VARCHAR(255) NULL,
rating TINYINT NULL,
text_answer TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ans_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE,
CONSTRAINT fk_ans_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
CONSTRAINT fk_ans_question FOREIGN KEY (question_id) REFERENCES questions(question_id) ON DELETE SET NULL,
CONSTRAINT fk_ans_song FOREIGN KEY (song_id) REFERENCES songs(song_id) ON DELETE SET NULL,
CONSTRAINT chk_ans_rating CHECK (rating IS NULL OR (rating BETWEEN 1 AND 5)),
KEY idx_ans_submission (submission_id),
KEY idx_ans_report (event_id, question_id, song_id),
KEY idx_ans_type (event_id, question_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 7. guest_book_entries -----------------------------------------------------
-- Private, admin-only. No public wall in v1.
CREATE TABLE guest_book_entries (
entry_id INT AUTO_INCREMENT PRIMARY KEY,
submission_id INT NOT NULL,
event_id INT NOT NULL,
display_name VARCHAR(255) NULL,
message TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_gb_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE,
CONSTRAINT fk_gb_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
KEY idx_gb_event_time (event_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 8. newsletter_signups -----------------------------------------------------
CREATE TABLE newsletter_signups (
signup_id INT AUTO_INCREMENT PRIMARY KEY,
submission_id INT NOT NULL,
event_id INT NOT NULL,
first_name VARCHAR(120) NOT NULL,
last_name VARCHAR(120) NOT NULL,
email VARCHAR(255) NOT NULL,
consent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
consent_text_version VARCHAR(40) NOT NULL,
sync_status ENUM('PENDING','SENT','FAILED','SKIPPED') NOT NULL DEFAULT 'PENDING',
sync_attempts INT NOT NULL DEFAULT 0,
synced_at DATETIME NULL,
external_id VARCHAR(120) NULL,
last_error TEXT NULL,
CONSTRAINT fk_nl_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE,
CONSTRAINT fk_nl_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
KEY idx_nl_sync_status (sync_status),
KEY idx_nl_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,9 @@
-- Nachklang e.V. Feedback module — adds a per-event poster image URL.
-- Apply manually against the FEEDBACK_DB database, after 001_init.sql:
-- mysql -h <DB_HOST> -u <DB_USER> -p <FEEDBACK_DB> < 002_add_poster_image_url.sql
--
-- Stores a URL only (e.g. an existing nachklang.art poster image) rather
-- than an uploaded file — the concert posters already live on the public
-- website, so there is no need for the feedback app to host its own copy.
ALTER TABLE events
ADD COLUMN poster_image_url VARCHAR(500) NULL AFTER intro_text;
+102
View File
@@ -0,0 +1,102 @@
-- Nachklang e.V. Tickets module — initial schema for TICKETS_DB
-- Apply manually against the TICKETS_DB database (separate from CALENDAR_DB
-- and FEEDBACK_DB). See nachklang-tickets/docs/plan-ticket-shop.md for the
-- full design rationale.
--
-- Apply with e.g.:
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 001_init.sql
--
-- Deliberately no USE statement here: the target database is selected via
-- the mysql command line above (whatever TICKETS_DB is actually named in
-- .env), not hardcoded to a literal schema name.
--
-- `event_id` columns below refer to Calendar's `events.event_id` (a
-- different database). Deliberately no cross-database foreign key —
-- Tickets reads Calendar events via events.service.ts in the same Node
-- process, not via a DB-level join.
-- 1. voucher_codes --------------------------------------------------------
CREATE TABLE voucher_codes (
code VARCHAR(12) NOT NULL PRIMARY KEY,
status ENUM('UNUSED','REDEEMED','VOID') NOT NULL DEFAULT 'UNUSED',
max_guests INT NOT NULL DEFAULT 2,
prefill_name VARCHAR(255) NULL,
prefill_email VARCHAR(255) NULL,
batch_id VARCHAR(36) NULL,
created_by_email VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_vc_batch (batch_id),
KEY idx_vc_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. voucher_code_events (join: which concerts a code may be redeemed for) -
CREATE TABLE voucher_code_events (
voucher_code_event_id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(12) NOT NULL,
event_id INT NOT NULL,
CONSTRAINT fk_vce_code FOREIGN KEY (code) REFERENCES voucher_codes(code) ON DELETE CASCADE,
UNIQUE KEY uq_vce_code_event (code, event_id),
KEY idx_vce_event (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. event_ticket_settings (per-concert voucher config) ---------------------
-- One row per Calendar event_id that has ever had voucher settings
-- configured. Absent row == uncapped, no deadline, address not collected
-- (see docs/plan-ticket-shop.md — "absence over sentinels").
CREATE TABLE event_ticket_settings (
event_id INT NOT NULL PRIMARY KEY,
capacity INT NULL,
redemption_deadline DATETIME NULL,
collect_address TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. redemptions ------------------------------------------------------------
-- `status` is soft-state rather than a hard delete on undo, so guest data
-- and history survive an undo for the audit trail. Capacity/reporting
-- queries filter status = 'ACTIVE'. A code that gets redeemed again after
-- being undone creates a new row here rather than reviving the old one.
CREATE TABLE redemptions (
redemption_id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(12) NOT NULL,
event_id INT NOT NULL,
status ENUM('ACTIVE','UNDONE') NOT NULL DEFAULT 'ACTIVE',
contact_name VARCHAR(255) NOT NULL,
contact_email VARCHAR(255) NOT NULL,
contact_address TEXT NULL,
guest_count INT NOT NULL,
redeemed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_red_code FOREIGN KEY (code) REFERENCES voucher_codes(code),
KEY idx_red_event_status (event_id, status),
KEY idx_red_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. redemption_guests --------------------------------------------------------
-- One row per attendee, including the primary contact (position 0).
CREATE TABLE redemption_guests (
redemption_guest_id INT AUTO_INCREMENT PRIMARY KEY,
redemption_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
position INT NOT NULL DEFAULT 0,
CONSTRAINT fk_rg_redemption FOREIGN KEY (redemption_id) REFERENCES redemptions(redemption_id) ON DELETE CASCADE,
KEY idx_rg_redemption (redemption_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6. voucher_audit_log ----------------------------------------------------
-- Lightweight admin-action history per code (not a full version-history
-- system) — who did what and why. Covers EDIT/VOID/UNDO only; the guest's
-- own redemption isn't an admin action so it isn't logged here (it's
-- already timestamped on `redemptions.redeemed_at`).
CREATE TABLE voucher_audit_log (
audit_id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(12) NOT NULL,
redemption_id INT NULL,
admin_email VARCHAR(255) NOT NULL,
action ENUM('EDIT','VOID','UNDO') NOT NULL,
change_summary JSON NULL,
reason VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_val_code FOREIGN KEY (code) REFERENCES voucher_codes(code) ON DELETE CASCADE,
CONSTRAINT fk_val_redemption FOREIGN KEY (redemption_id) REFERENCES redemptions(redemption_id) ON DELETE SET NULL,
KEY idx_val_code_time (code, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+7
View File
@@ -0,0 +1,7 @@
-- Nachklang e.V. Tickets module — adds a per-event "address required" flag,
-- distinct from collect_address (which only controls whether the field is
-- shown/collected at all). Apply manually against TICKETS_DB, after
-- 001_init.sql:
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 002_add_require_address.sql
ALTER TABLE event_ticket_settings
ADD COLUMN require_address TINYINT(1) NOT NULL DEFAULT 0 AFTER collect_address;
@@ -0,0 +1,7 @@
-- Nachklang e.V. Tickets module — records the outcome of the redemption
-- confirmation email on the redemption itself, so the admin UI can flag a
-- failed send and offer a resend. NULL until the post-commit send resolves.
-- Apply manually against TICKETS_DB, after 002_add_require_address.sql:
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 003_add_confirmation_email_status.sql
ALTER TABLE redemptions
ADD COLUMN confirmation_email_status ENUM('SENT','FAILED') NULL DEFAULT NULL AFTER redeemed_at;
+107
View File
@@ -0,0 +1,107 @@
import logger from '../middleware/logger.js';
import {salesforceApexRestPost, salesforceEnabled} from './salesforce.client.js';
// Transactional email for the ticketing/calendar flows (voucher redemption
// confirmations, account activation links, password-reset tokens) is relayed
// through the Nachklang Salesforce org rather than sent over our own SMTP host:
// that host's IP reputation gets it blocked by allowlist-based receivers
// (notably t-online.de). Salesforce's MTA plus the org's DKIM signature for
// nachklang.art get the mail delivered. The org endpoint is EmailSendResource
// (POST /services/apexrest/email/send); the From address is fixed server-side
// there and is never sent from here.
//
// sendMail never throws on a delivery problem. Every caller has already
// committed its own work (a registration, a password-reset token, a
// redemption) by the time mail goes out, so a mail failure must not surface as
// a user-facing error. It returns whether the mail was accepted so the one
// caller that shows failures to staff (the voucher confirmation) can record it.
export namespace MailService {
export interface MailAttachment {
filename: string;
content: string | Buffer;
contentType?: string;
}
export interface SendMailOptions {
html?: string;
attachments?: MailAttachment[];
}
interface EmailSendResponse {
status: 'SENT';
}
// Practical ceiling, well under Apex REST's 6 MB request-body limit once
// base64 inflation (~33%) is accounted for. The only attachment today is a
// ~1 KB .ics file.
const MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024;
const isRetriable = (err: any): boolean => {
const status = err?.response?.status;
if (status !== undefined) {
return status >= 500;
}
// No response at all - network error or timeout.
return true;
};
/**
* Relays one email through the Salesforce org. Retries once on a transient
* failure (5xx / network / timeout), then logs and returns false rather
* than throwing. Returns false immediately (without a callout) when the
* Salesforce integration is disabled.
*/
export const sendMail = async (
recipientAddress: string,
subject: string,
body: string,
options?: SendMailOptions
): Promise<boolean> => {
if (!salesforceEnabled()) {
logger.info('MailService: SALESFORCE_ENABLED is false, would have sent', {recipientAddress, subject});
return false;
}
let attachments: {filename: string; contentType?: string; contentBase64: string}[];
try {
attachments = (options?.attachments ?? []).map(attachment => {
const buffer = Buffer.isBuffer(attachment.content)
? attachment.content
: Buffer.from(attachment.content, 'utf-8');
if (buffer.byteLength > MAX_ATTACHMENT_BYTES) {
throw new Error(`attachment ${attachment.filename} is ${buffer.byteLength} bytes, over the ${MAX_ATTACHMENT_BYTES} limit`);
}
return {filename: attachment.filename, contentType: attachment.contentType, contentBase64: buffer.toString('base64')};
});
} catch (err: any) {
logger.error('MailService: could not prepare attachments', {recipientAddress, subject, detail: err?.message});
return false;
}
const payload = {
to: recipientAddress,
subject,
textBody: body,
htmlBody: options?.html ?? null,
attachments
};
for (let attempt = 1; attempt <= 2; attempt++) {
try {
await salesforceApexRestPost<EmailSendResponse>('/services/apexrest/email/send', payload);
return true;
} catch (err: any) {
const status = err?.response?.status;
const detail = err?.response?.data?.errorCode || err?.response?.data?.message || err?.message || 'unknown error';
if (attempt === 1 && isRetriable(err)) {
logger.warn('MailService: send failed, retrying once', {recipientAddress, subject, status, detail});
continue;
}
logger.error('MailService: send failed', {recipientAddress, subject, status, detail});
return false;
}
}
return false;
};
}
+66
View File
@@ -0,0 +1,66 @@
import axios from 'axios';
// Shared server-to-server access to the one Nachklang Salesforce org. Both the
// newsletter-signup sync (feedback module) and the transactional-email relay
// (common.mail) authenticate the same way - OAuth 2.0 client credentials
// against the nk_Nachklang_API_Integration external client app - so the token
// cache and the retry-once-on-401 live here rather than being duplicated.
//
// Salesforce's client-credentials token response does not reliably include
// expires_in, so the cache lifetime is a conservative guess rather than a
// value read from the response - a 401 on the next call just triggers a fresh
// fetch (see salesforceApexRestPost).
const TOKEN_CACHE_MS = 15 * 60 * 1000;
let cachedToken: {accessToken: string; fetchedAt: number} | null = null;
export const salesforceEnabled = (): boolean => process.env.SALESFORCE_ENABLED === 'true';
const readConfig = (): {instanceUrl: string; clientId: string; clientSecret: string} => {
const instanceUrl = process.env.SALESFORCE_API_URL;
const clientId = process.env.SALESFORCE_CLIENT_ID;
const clientSecret = process.env.SALESFORCE_CLIENT_SECRET;
if (!instanceUrl || !clientId || !clientSecret) {
throw new Error('SALESFORCE_ENABLED is true but SALESFORCE_API_URL/SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET are not fully configured.');
}
return {instanceUrl, clientId, clientSecret};
};
const getAccessToken = async (forceRefresh: boolean): Promise<string> => {
if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) {
return cachedToken.accessToken;
}
const {instanceUrl, clientId, clientSecret} = readConfig();
const res = await axios.post(
`${instanceUrl}/services/oauth2/token`,
new URLSearchParams({grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret}).toString(),
{headers: {'Content-Type': 'application/x-www-form-urlencoded'}, timeout: 10000}
);
cachedToken = {accessToken: res.data.access_token, fetchedAt: Date.now()};
return cachedToken.accessToken;
};
/**
* POSTs a JSON body to an Apex REST path (e.g. '/services/apexrest/newsletter/signup')
* and returns the parsed response body. Retries once with a forced token
* refresh on a 401 - the server-side token may have expired even though our
* conservative local TTL has not. All other errors propagate to the caller.
*/
export const salesforceApexRestPost = async <T>(path: string, body: unknown): Promise<T> => {
const {instanceUrl} = readConfig();
const url = `${instanceUrl}${path}`;
try {
const token = await getAccessToken(false);
const res = await axios.post<T>(url, body, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
} catch (err: any) {
if (err?.response?.status === 401) {
const token = await getAccessToken(true);
const res = await axios.post<T>(url, body, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
}
throw err;
}
};
+5 -5
View File
@@ -1,10 +1,10 @@
import * as appRoot from 'app-root-path';
import * 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
+1 -2
View File
@@ -1,6 +1,5 @@
import * as dotenv from 'dotenv';
const mariadb = require('mariadb');
import mariadb from 'mariadb';
dotenv.config();
+37 -2
View File
@@ -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;
}
}
+106 -6
View File
@@ -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
+317 -7
View File
@@ -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];
}
for(let row of eventsRes) {
eventRows.push(row);
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({
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;
}
+671
View File
@@ -0,0 +1,671 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as UserService from './users.service.js';
import {Session} from './session.interface.js';
import {User} from './user.interface.js';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
/**
* Router Definition
*/
export const usersRouter = express.Router();
/**
* Controller Definitions
*/
/**
* @swagger
* /calendar/users/register:
* post:
* summary: Register a new user
* description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* - fullName
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* description: Must be an official Nachklang email address
* password:
* type: string
* format: password
* example: securePassword123
* fullName:
* type: string
* example: John Doe
* responses:
* 201:
* description: User registered successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/register
usersRouter.post('/register', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const fullName: string = req.body.fullName;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email || !fullName) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/;
if(!emailRegex.test(email)) {
res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'}));
return;
}
// Create the user and a session
const session: Session = await UserService.createUser(email, password, fullName, ip);
// Send the session details back to the user
res.status(201).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/activate:
* get:
* summary: Activate a user account
* description: Activates a user account using the provided user ID and activation token.
* tags:
* - calendar
* parameters:
* - in: query
* name: id
* required: true
* schema:
* type: integer
* description: The ID of the user to activate
* - in: query
* name: token
* required: true
* schema:
* type: string
* description: The activation token sent to the user's email
* responses:
* 200:
* description: User activated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: OK
* message:
* type: string
* example: User activated
* 400:
* description: Bad request - missing parameters or activation failed
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Error activating user. Please contact your administrator.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// GET /users/activate
usersRouter.get('/activate', async (req: Request, res: Response) => {
try {
const userId: number = parseInt(req.query.id as string ?? '-1', 10);
const token: string = req.query.token as string ?? '';
if (!userId || !token) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create the user and a session
const success: boolean = await UserService.activateUser(userId, token);
// Send the session details back to the user
if(success) {
res.status(200).send({
'status': 'OK',
'message': 'User activated'
});
return;
}
res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/login:
* post:
* summary: Login a user
* description: Authenticates a user with the provided email and password and returns a session.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* password:
* type: string
* format: password
* example: securePassword123
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 401:
* description: Unauthorized - invalid credentials
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Wrong username and / or password
* sessionId:
* type: integer
* example: -1
* sessionKey:
* type: string
* example: ""
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/login
usersRouter.post('/login', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create a session
const session: Session | null = await UserService.login(email, password, ip);
if (!session || !session.sessionId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''}));
return;
}
// Send the session details back to the user
res.status(200).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/checkSessionValid:
* post:
* summary: Check if a session is valid
* description: Checks if the provided session is valid and returns the user information if it is.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* - sessionKey
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* responses:
* 200:
* description: Session is valid
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Unauthorized - invalid session
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: ["Invalid session"]
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/checkSessionValid
usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
try {
const ip: string = req.socket.remoteAddress ?? '';
const session_id = req.body.sessionId;
const session_key = req.body.sessionKey;
if (!session_id || !session_key) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['No session detected']}));
return;
}
const user: User | null = await UserService.checkSession(session_id, session_key, ip);
if (!user || !user.userId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Invalid session']}));
return;
}
res.status(200).send(user);
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/initiatePasswordReset:
* post:
* summary: Initiates a password reset
* description: Checks if the user exists and if so, initiates a password reset by sending an email to the user.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
*/
usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username given']}));
return;
}
const success: boolean = await UserService.initiatePasswordReset(username);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/finalizePasswordReset:
* post:
* summary: Finalizes the password reset
* description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
* token:
* type: string
* example: 3ccd147f-720b-4e29-a8b7-46b63de31555
* password:
* type: string
* example: ExtremelyBadPassword
*/
usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
const token = req.body.token;
const newPassword = req.body.password;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username, token or password given']}));
return;
}
const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
+296
View File
@@ -0,0 +1,296 @@
import * as dotenv from 'dotenv';
import bcrypt from 'bcrypt';
import {Guid} from 'guid-typescript';
import {User} from './user.interface.js';
import {Session} from './session.interface.js';
import {NachklangCalendarDB} from '../Calendar.db.js';
import {MailService} from '../../../common/common.mail.js';
dotenv.config();
/**
* Data Model Interfaces
*/
/**
* Service Methods
*/
/**
* Creates a user record in the database, also creates a session. Returns the session if successful.
*/
export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Hash password and generate + hash session key
const pwHash = bcrypt.hashSync(password, 10);
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
const activationToken = Guid.create().toString();
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
// Create user entry in SQL
const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
// Get user id of the created user
let userId: number = -1;
for (const row of userIdRes) {
userId = row.user_id;
}
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
// Send email with activation link (after commit so we don't block on email
// delivery). sendMail never throws on a delivery failure - it logs and
// returns false - so a mail-server problem here can't roll back the
// already-committed user and leave registration reporting a false error.
await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`);
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const activateUser = async (userId: number, token: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
const userNameRes = await conn.query(checkTokenQuery, [userId]);
let storedTokenHash = '';
for (const row of userNameRes) {
storedTokenHash = row.activation_token;
}
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
return false;
}
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
const activateRes = await conn.execute(activateQuery, [userId]);
await conn.commit();
return activateRes.affectedRows !== 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
/**
* Checks if the given credentials are valid and creates a new session if they are.
* Returns the session information in case of a successful login
*/
export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved password hash
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
const userRows = await conn.query(query, email);
let savedHash = '';
let userId = -1;
for (const row of userRows) {
savedHash = row.password_hash;
userId = row.user_id;
}
// Check for correct password
if (!bcrypt.compareSync(password, savedHash)) {
return null;
}
// Generate + hash session key
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Checks if the given session information are valid and returns the user information if they are
*/
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved session key hash
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
const sessionRows = await conn.query(query, sessionId);
let savedHash = '';
let userId = -1;
let validUntil = new Date();
for (const row of sessionRows) {
savedHash = row.session_key_hash;
userId = row.user_id;
validUntil = row.valid_until;
}
// Check for correct key
if (!bcrypt.compareSync(sessionKey, savedHash)) {
return null;
}
// Check if the session is still valid
if (validUntil <= new Date()) {
return null;
}
// Update session entry in SQL
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
await conn.query(updateSessionsQuery, [ip, sessionId]);
await conn.commit();
// Get the other required user information
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
const userRows = await conn.query(userQuery, userId);
let email = '';
let fullName = '';
let is_active = false;
for (const row of userRows) {
email = row.email;
fullName = row.full_name;
is_active = row.is_active;
}
// Everything is fine, return user information
return {
userId: userId,
email: email,
passwordHash: 'HIDDEN',
fullName: fullName,
isActive: is_active
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
const userNameRes = await conn.query(checkUsernameQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: number = -1;
let fullName: string = '';
for(let row of userNameRes) {
userId = row.user_id;
fullName = row.full_Name;
}
let resetToken = Guid.create().toString();
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
if(updateRes.affectedRows === 0) {
return false;
}
await conn.commit();
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
const userNameRes = await conn.query(checkTokenQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: string = '';
let tokenHash: string = '';
for(let row of userNameRes) {
userId = row.user_id;
tokenHash = row.pw_reset_token_hash;
}
if(!bcrypt.compareSync(token, tokenHash)) {
return false;
}
const pwHash = bcrypt.hashSync(newPassword, 10);
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
if(updateRes.affectedRows > 0) {
await conn.commit();
return true;
}
return false;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
+18
View File
@@ -0,0 +1,18 @@
import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
dotenv.config();
export namespace NachklangFeedbackDB {
const pool = mariadb.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.FEEDBACK_DB,
connectionLimit: 5
});
export const getConnection = async () => {
return pool.getConnection();
};
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {publicRouter} from './public/public.router.js';
import {adminRouter} from './admin/admin.router.js';
import {sendServerError} from './feedback.errors.js';
/**
* Router Definition
*/
export const feedbackRouter = express.Router();
feedbackRouter.use('/admin', adminRouter);
feedbackRouter.use('/', publicRouter);
/**
* @swagger
* /feedback:
* get:
* summary: Feedback API root endpoint
* description: Returns a welcome message for the Nachklang e.V. Feedback API.
* tags:
* - feedback
* responses:
* 200:
* description: Success
* content:
* text/plain:
* schema:
* type: string
* example: Nachklang e.V. Feedback API Endpoint
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
feedbackRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send('Nachklang e.V. Feedback API Endpoint');
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,136 @@
/**
* @swagger
* components:
* parameters:
* SessionIdHeader:
* in: header
* name: X-Session-Id
* required: true
* schema:
* type: string
* SessionKeyHeader:
* in: header
* name: X-Session-Key
* required: true
* schema:
* type: string
* schemas:
* EventAdminSummary:
* type: object
* properties:
* eventId:
* type: integer
* slug:
* type: string
* name:
* type: string
* subtitle:
* type: string
* nullable: true
* eventDate:
* type: string
* format: date
* feedbackDeadline:
* type: string
* format: date-time
* posterImageUrl:
* type: string
* nullable: true
* isPublished:
* type: boolean
* submissionCount:
* type: integer
* EventAdminDetail:
* allOf:
* - $ref: '#/components/schemas/EventAdminSummary'
* - type: object
* properties:
* introText:
* type: string
* nullable: true
* songs:
* type: array
* items:
* $ref: '#/components/schemas/Song'
* questions:
* type: array
* items:
* type: object
* properties:
* eventQuestionId:
* type: integer
* questionId:
* type: integer
* position:
* type: integer
* isActive:
* type: boolean
* AdminQuestion:
* type: object
* properties:
* questionId:
* type: integer
* label:
* type: string
* helpText:
* type: string
* nullable: true
* questionType:
* $ref: '#/components/schemas/QuestionType'
* isArchived:
* type: boolean
*/
import {QuestionType, Song} from '../feedback.interface.js';
export interface EventAdminSummary {
eventId: number;
slug: string;
name: string;
subtitle: string | null;
eventDate: string;
feedbackDeadline: string;
posterImageUrl: string | null;
isPublished: boolean;
submissionCount: number;
}
export interface EventAdminQuestionAssignment {
eventQuestionId: number;
questionId: number;
position: number;
isActive: boolean;
}
export interface EventAdminDetail extends EventAdminSummary {
introText: string | null;
songs: Song[];
questions: EventAdminQuestionAssignment[];
}
export interface CreateEventInput {
name: string;
subtitle?: string;
eventDate: string;
feedbackDeadline?: string;
introText?: string;
posterImageUrl?: string;
}
export interface UpdateEventInput {
name?: string;
subtitle?: string;
eventDate?: string;
feedbackDeadline?: string;
isPublished?: boolean;
introText?: string;
posterImageUrl?: string;
}
export interface AdminQuestion {
questionId: number;
label: string;
helpText: string | null;
questionType: QuestionType;
isArchived: boolean;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../feedback.auth.js';
import {sendServerError} from '../feedback.errors.js';
import {eventsAdminRouter} from './events.admin.router.js';
import {songsAdminRouter} from './songs.admin.router.js';
import {questionsAdminRouter} from './questions.admin.router.js';
import {reportsAdminRouter} from './reports.admin.router.js';
import * as ReportsAdminService from './reports.admin.service.js';
/**
* Router Definition
*/
export const adminRouter = express.Router();
// Applied once at the top of the admin router tree - every route below
// requires a valid admin session.
adminRouter.use(requireAdminAuth);
/**
* @swagger
* /feedback/admin/me:
* get:
* summary: Validate the current admin session
* description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* fullName:
* type: string
* 401:
* description: Unauthorized
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
});
/**
* @swagger
* /feedback/admin/submissions/{submissionId}:
* delete:
* summary: Delete a single submission
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: submissionId
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Deleted
* 404:
* description: Unknown submission
* 401:
* description: Unauthorized
*/
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
try {
const deleted = await ReportsAdminService.deleteSubmission(Number(req.params.submissionId));
if (!deleted) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
adminRouter.use('/events', eventsAdminRouter);
adminRouter.use('/events', reportsAdminRouter);
adminRouter.use('/songs', songsAdminRouter);
adminRouter.use('/questions', questionsAdminRouter);
+70
View File
@@ -0,0 +1,70 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {formatDatetime} from '../feedback.dates.js';
const CSV_SEPARATOR = ';';
const UTF8_BOM = '';
/**
* RFC 4180 field escaping for a `;`-separated CSV, plus a formula-injection
* guard: a field starting with = + - @ gets a leading apostrophe so
* German-locale Excel never evaluates it as a formula.
*/
export const escapeCsvField = (value: string | number | null | undefined): string => {
let str = value === null || value === undefined ? '' : String(value);
str = str.replace(/\r\n|\r|\n/g, ' ');
if (/^[=+\-@]/.test(str)) {
str = `'${str}`;
}
if (str.includes(CSV_SEPARATOR) || str.includes('"')) {
str = `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const buildCsv = (headers: string[], rows: (string | number | null | undefined)[][]): string => {
const lines = [headers.map(escapeCsvField).join(CSV_SEPARATOR)];
for (const row of rows) {
lines.push(row.map(escapeCsvField).join(CSV_SEPARATOR));
}
return UTF8_BOM + lines.join('\r\n');
};
export const buildResponsesCsv = async (eventId: number): Promise<string> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const rows = await conn.query(
`SELECT sa.submission_id, s.submitted_at, sa.question_label_snapshot, sa.question_type,
sa.song_title_snapshot, sa.rating, sa.text_answer
FROM submission_answers sa
INNER JOIN submissions s ON s.submission_id = sa.submission_id
WHERE sa.event_id = ?
ORDER BY sa.submission_id ASC`,
[eventId]
);
return buildCsv(
['submission_id', 'submitted_at', 'question_label', 'question_type', 'song_title', 'rating', 'text_answer'],
rows.map((r: any) => [r.submission_id, formatDatetime(r.submitted_at), r.question_label_snapshot, r.question_type, r.song_title_snapshot, r.rating, r.text_answer])
);
} finally {
await conn.end();
}
};
export const buildGuestBookCsv = async (eventId: number): Promise<string> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const rows = await conn.query(
'SELECT entry_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at ASC',
[eventId]
);
return buildCsv(
['entry_id', 'submitted_at', 'display_name', 'message'],
rows.map((r: any) => [r.entry_id, formatDatetime(r.created_at), r.display_name, r.message])
);
} finally {
await conn.end();
}
};
@@ -0,0 +1,408 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as EventsAdminService from './events.admin.service.js';
import * as SongsAdminService from './songs.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const eventsAdminRouter = express.Router();
/**
* @swagger
* /feedback/admin/events:
* get:
* summary: List all events (admin)
* description: All events, published or not, past or future, with submission counts.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/EventAdminSummary'
* 401:
* description: Unauthorized
* post:
* summary: Create an event
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name, eventDate]
* properties:
* name:
* type: string
* subtitle:
* type: string
* eventDate:
* type: string
* format: date
* feedbackDeadline:
* type: string
* format: date-time
* introText:
* type: string
* posterImageUrl:
* type: string
* responses:
* 201:
* description: Created
* 400:
* description: Missing required fields
* 401:
* description: Unauthorized
*/
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send(await EventsAdminService.listEventsAdmin());
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.post('/', async (req: Request, res: Response) => {
try {
const {name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl} = req.body || {};
if (!name || !eventDate) {
res.status(400).send({status: 'BAD_REQUEST', message: 'name and eventDate are required'});
return;
}
const eventId = await EventsAdminService.createEvent(
{name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl},
res.locals.admin.email
);
res.status(201).send({eventId});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}:
* get:
* summary: Get one event (admin)
* description: Full event detail including setlist and assigned questions.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventAdminDetail'
* 404:
* description: Unknown event
* 401:
* description: Unauthorized
* put:
* summary: Update an event
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Updated
* 404:
* description: Unknown event
* 401:
* description: Unauthorized
* delete:
* summary: Delete an event
* description: Refuses with 409 if submissions exist unless ?force=true is passed.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* - in: query
* name: force
* schema:
* type: boolean
* responses:
* 204:
* description: Deleted
* 404:
* description: Unknown event
* 409:
* description: Submissions exist and force was not set
* 401:
* description: Unauthorized
*/
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
try {
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(event);
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.put('/:eventId', async (req: Request, res: Response) => {
try {
const updated = await EventsAdminService.updateEvent(Number(req.params.eventId), req.body || {});
if (!updated) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
try {
const force = req.query.force === 'true';
const result = await EventsAdminService.deleteEvent(Number(req.params.eventId), force);
if (result === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
if (result === 'HAS_SUBMISSIONS') {
res.status(409).send({status: 'HAS_SUBMISSIONS', message: 'This event has submissions. Pass ?force=true to delete anyway.'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/songs:
* get:
* summary: Get an event's setlist
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* post:
* summary: Add a song to an event's setlist
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [title]
* properties:
* title:
* type: string
* composer:
* type: string
* responses:
* 201:
* description: Created
* 400:
* description: Missing title
* 401:
* description: Unauthorized
*/
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
try {
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(event.songs);
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) => {
try {
const {title, composer} = req.body || {};
if (!title) {
res.status(400).send({status: 'BAD_REQUEST', message: 'title is required'});
return;
}
const songId = await SongsAdminService.addSong(Number(req.params.eventId), title, composer || null);
res.status(201).send({songId});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/songs/order:
* put:
* summary: Bulk reorder an event's setlist
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [songIds]
* properties:
* songIds:
* type: array
* items:
* type: integer
* responses:
* 200:
* description: Reordered
* 401:
* description: Unauthorized
*/
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
try {
const songIds: number[] = req.body?.songIds || [];
await EventsAdminService.reorderSongs(Number(req.params.eventId), songIds);
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/questions:
* get:
* summary: Get an event's assigned questions
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* put:
* summary: Bulk-set an event's assigned questions
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [items]
* properties:
* items:
* type: array
* items:
* type: object
* properties:
* questionId:
* type: integer
* position:
* type: integer
* isActive:
* type: boolean
* responses:
* 200:
* description: Saved
* 401:
* description: Unauthorized
*/
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
try {
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(event.questions);
} catch (e: any) {
sendServerError(res, e);
}
});
eventsAdminRouter.put('/:eventId/questions', async (req: Request, res: Response) => {
try {
const items = req.body?.items || [];
await EventsAdminService.setEventQuestions(Number(req.params.eventId), items);
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,286 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {Song} from '../feedback.interface.js';
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface.js';
import {formatDatetime} from '../feedback.dates.js';
const UMLAUT_MAP: Record<string, string> = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue'
};
/**
* Slug base from a name: lowercase, umlaut-transliterated, hyphenated.
* The caller appends the concert year and resolves collisions.
*/
export const slugifyName = (name: string): string => {
const transliterated = name.replace(/[äöüßÄÖÜ]/g, (ch) => UMLAUT_MAP[ch] || ch);
return transliterated
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
};
/**
* Default feedback deadline: event day + 14 days, end of day. Computed
* here (not by the DB) so the admin UI can pre-fill and override it.
*/
export const computeDefaultDeadline = (eventDateIso: string): Date => {
const [year, month, day] = eventDateIso.split('-').map(Number);
return new Date(year, month - 1, day + 14, 23, 59, 59);
};
const mapSummaryRow = (row: any): EventAdminSummary => ({
eventId: row.event_id,
slug: row.slug,
name: row.name,
subtitle: row.subtitle,
eventDate: row.event_date,
feedbackDeadline: row.feedback_deadline,
posterImageUrl: row.poster_image_url,
isPublished: !!row.is_published,
submissionCount: Number(row.submission_count)
});
export const listEventsAdmin = async (): Promise<EventAdminSummary[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = `
SELECT e.event_id, e.slug, e.name, e.subtitle, e.event_date, e.feedback_deadline, e.is_published,
COUNT(s.submission_id) as submission_count
FROM events e
LEFT JOIN submissions s ON s.event_id = e.event_id
GROUP BY e.event_id
ORDER BY e.event_date DESC`;
const rows = await conn.query(query);
return rows.map(mapSummaryRow);
} finally {
await conn.end();
}
};
/**
* Slug base with the concert year appended as a disambiguator - unless the
* name already ends with it (e.g. "Adventskonzert 2026"), which would
* otherwise double up as "adventskonzert-2026-2026".
*/
export const slugBase = (name: string, eventDateIso: string): string => {
const year = eventDateIso.split('-')[0];
const nameSlug = slugifyName(name);
return nameSlug.endsWith(`-${year}`) ? nameSlug : `${nameSlug}-${year}`;
};
const generateUniqueSlug = async (conn: any, name: string, eventDate: string): Promise<string> => {
const base = slugBase(name, eventDate);
let candidate = base;
let suffix = 2;
// Small table, small admin audience - a loop is simpler and safer than
// a clever single query, and collisions will be rare in practice.
while (true) {
const rows = await conn.query('SELECT 1 FROM events WHERE slug = ?', [candidate]);
if (rows.length === 0) return candidate;
candidate = `${base}-${suffix}`;
suffix++;
}
};
export const createEvent = async (input: CreateEventInput, createdByEmail: string): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const slug = await generateUniqueSlug(conn, input.name, input.eventDate);
const deadline = input.feedbackDeadline
? new Date(input.feedbackDeadline)
: computeDefaultDeadline(input.eventDate);
const query = `
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, created_by_email)
VALUES (?,?,?,?,?,?,?,?) RETURNING event_id`;
const res = await conn.query(query, [
slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline),
input.introText || null, input.posterImageUrl || null, createdByEmail
]);
await conn.commit();
return res[0].event_id;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const getEventAdmin = async (eventId: number): Promise<EventAdminDetail | null> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventRows = await conn.query(`
SELECT e.*, COUNT(s.submission_id) as submission_count
FROM events e
LEFT JOIN submissions s ON s.event_id = e.event_id
WHERE e.event_id = ?
GROUP BY e.event_id`, [eventId]);
if (eventRows.length === 0) return null;
const row = eventRows[0];
const songRows = await conn.query('SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC', [eventId]);
const songs: Song[] = songRows.map((r: any) => ({songId: r.song_id, title: r.title, composer: r.composer, position: r.position}));
const questionRows = await conn.query(
'SELECT event_question_id, question_id, position, is_active FROM event_questions WHERE event_id = ? ORDER BY position ASC',
[eventId]
);
const questions: EventAdminQuestionAssignment[] = questionRows.map((r: any) => ({
eventQuestionId: r.event_question_id, questionId: r.question_id, position: r.position, isActive: !!r.is_active
}));
return {
...mapSummaryRow(row),
introText: row.intro_text,
songs,
questions
};
} finally {
await conn.end();
}
};
export const updateEvent = async (eventId: number, input: UpdateEventInput): Promise<boolean> => {
const fields: string[] = [];
const values: any[] = [];
if (input.name !== undefined) { fields.push('name = ?'); values.push(input.name); }
if (input.subtitle !== undefined) { fields.push('subtitle = ?'); values.push(input.subtitle); }
if (input.eventDate !== undefined) { fields.push('event_date = ?'); values.push(input.eventDate); }
if (input.feedbackDeadline !== undefined) { fields.push('feedback_deadline = ?'); values.push(input.feedbackDeadline); }
if (input.isPublished !== undefined) { fields.push('is_published = ?'); values.push(input.isPublished ? 1 : 0); }
if (input.introText !== undefined) { fields.push('intro_text = ?'); values.push(input.introText); }
if (input.posterImageUrl !== undefined) { fields.push('poster_image_url = ?'); values.push(input.posterImageUrl || null); }
if (fields.length === 0) return true;
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
values.push(eventId);
const res = await conn.query(`UPDATE events SET ${fields.join(', ')} WHERE event_id = ?`, values);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type DeleteEventResult = 'DELETED' | 'NOT_FOUND' | 'HAS_SUBMISSIONS';
/**
* Deletes an event and everything under it. Children are deleted in
* explicit dependency order rather than left to the DB's ON DELETE CASCADE
* chain: submission_answers and guest_book_entries are reachable from
* `events` via two different cascade paths (direct event_id FK, and via
* `submissions`/`songs`), and MariaDB can reject that as an ambiguous
* multi-path cascade. See IMPLEMENTATION_PLAN.md Phase 1 notes.
*/
export const deleteEvent = async (eventId: number, force: boolean): Promise<DeleteEventResult> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const eventRows = await conn.query('SELECT event_id FROM events WHERE event_id = ?', [eventId]);
if (eventRows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
const countRows = await conn.query('SELECT COUNT(*) as cnt FROM submissions WHERE event_id = ?', [eventId]);
const submissionCount = Number(countRows[0].cnt);
if (submissionCount > 0 && !force) {
await conn.rollback();
return 'HAS_SUBMISSIONS';
}
await conn.query('DELETE FROM guest_book_entries WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM newsletter_signups WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM submission_answers WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM submissions WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM event_questions WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM songs WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM events WHERE event_id = ?', [eventId]);
await conn.commit();
return 'DELETED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const reorderSongs = async (eventId: number, songIds: number[]): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
for (let i = 0; i < songIds.length; i++) {
await conn.query('UPDATE songs SET position = ? WHERE song_id = ? AND event_id = ?', [i, songIds[i], eventId]);
}
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export interface QuestionAssignmentItem {
questionId: number;
position: number;
isActive: boolean;
}
/**
* Bulk-sets an event's assigned questions in one transaction: inserts new
* assignments, updates existing ones' position/active state, and removes
* ones no longer present in `items`.
*/
export const setEventQuestions = async (eventId: number, items: QuestionAssignmentItem[]): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const existingRows = await conn.query('SELECT question_id FROM event_questions WHERE event_id = ?', [eventId]);
const existingIds = new Set<number>(existingRows.map((r: any) => r.question_id));
const nextIds = new Set<number>(items.map((i) => i.questionId));
for (const existingId of existingIds) {
if (!nextIds.has(existingId)) {
await conn.query('DELETE FROM event_questions WHERE event_id = ? AND question_id = ?', [eventId, existingId]);
}
}
for (const item of items) {
if (existingIds.has(item.questionId)) {
await conn.query(
'UPDATE event_questions SET position = ?, is_active = ? WHERE event_id = ? AND question_id = ?',
[item.position, item.isActive ? 1 : 0, eventId, item.questionId]
);
} else {
await conn.query(
'INSERT INTO event_questions (event_id, question_id, position, is_active) VALUES (?,?,?,?)',
[eventId, item.questionId, item.position, item.isActive ? 1 : 0]
);
}
}
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
@@ -0,0 +1,175 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as QuestionsAdminService from './questions.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const questionsAdminRouter = express.Router();
/**
* @swagger
* /feedback/admin/questions:
* get:
* summary: List the question library
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query
* name: includeArchived
* schema:
* type: boolean
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/AdminQuestion'
* 401:
* description: Unauthorized
* post:
* summary: Create a question
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [label, questionType]
* properties:
* label:
* type: string
* helpText:
* type: string
* questionType:
* $ref: '#/components/schemas/QuestionType'
* responses:
* 201:
* description: Created
* 400:
* description: Missing or invalid fields
* 401:
* description: Unauthorized
*/
questionsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
const includeArchived = req.query.includeArchived === 'true';
res.status(200).send(await QuestionsAdminService.listQuestions(includeArchived));
} catch (e: any) {
sendServerError(res, e);
}
});
const VALID_TYPES = ['SONG_PICK', 'SONG_RATING', 'FREE_TEXT'];
questionsAdminRouter.post('/', async (req: Request, res: Response) => {
try {
const {label, helpText, questionType} = req.body || {};
if (!label || !VALID_TYPES.includes(questionType)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'label and a valid questionType are required'});
return;
}
const questionId = await QuestionsAdminService.createQuestion(label, helpText || null, questionType);
res.status(201).send({questionId});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/questions/{questionId}:
* put:
* summary: Edit a question's label/help text
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: questionId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [label]
* properties:
* label:
* type: string
* helpText:
* type: string
* responses:
* 200:
* description: Updated
* 400:
* description: Missing label
* 404:
* description: Unknown question
* 401:
* description: Unauthorized
* delete:
* summary: Archive (or hard-delete) a question
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: questionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Archived or deleted
* 404:
* description: Unknown question
* 401:
* description: Unauthorized
*/
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
try {
const {label, helpText} = req.body || {};
if (!label) {
res.status(400).send({status: 'BAD_REQUEST', message: 'label is required'});
return;
}
const updated = await QuestionsAdminService.updateQuestion(Number(req.params.questionId), label, helpText || null);
if (!updated) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
questionsAdminRouter.delete('/:questionId', async (req: Request, res: Response) => {
try {
const result = await QuestionsAdminService.removeQuestion(Number(req.params.questionId));
if (result === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: result});
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,98 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {QuestionType} from '../feedback.interface.js';
import {AdminQuestion} from './admin.interface.js';
const mapRow = (row: any): AdminQuestion => ({
questionId: row.question_id,
label: row.label,
helpText: row.help_text,
questionType: row.question_type,
isArchived: !!row.is_archived
});
export const listQuestions = async (includeArchived: boolean): Promise<AdminQuestion[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = includeArchived
? 'SELECT * FROM questions ORDER BY created_at DESC'
: 'SELECT * FROM questions WHERE is_archived = 0 ORDER BY created_at DESC';
const rows = await conn.query(query);
return rows.map(mapRow);
} finally {
await conn.end();
}
};
export const createQuestion = async (label: string, helpText: string | null, questionType: QuestionType): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query(
'INSERT INTO questions (label, help_text, question_type) VALUES (?,?,?) RETURNING question_id',
[label, helpText, questionType]
);
await conn.commit();
return res[0].question_id;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Edits label/help text only. question_type is immutable after creation -
* changing it would invalidate existing answers' question_type_snapshot
* semantics. The admin UI offers "archive and create new" instead.
*/
export const updateQuestion = async (questionId: number, label: string, helpText: string | null): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query('UPDATE questions SET label = ?, help_text = ? WHERE question_id = ?', [label, helpText, questionId]);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type RemoveQuestionResult = 'ARCHIVED' | 'DELETED' | 'NOT_FOUND';
/**
* Archives (soft delete) a question. Hard-deletes it instead if it has
* never been assigned to any event, so an admin's typo doesn't have to
* live forever in the library.
*/
export const removeQuestion = async (questionId: number): Promise<RemoveQuestionResult> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const existsRows = await conn.query('SELECT 1 FROM questions WHERE question_id = ?', [questionId]);
if (existsRows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
const usageRows = await conn.query('SELECT 1 FROM event_questions WHERE question_id = ? LIMIT 1', [questionId]);
if (usageRows.length === 0) {
await conn.query('DELETE FROM questions WHERE question_id = ?', [questionId]);
await conn.commit();
return 'DELETED';
}
await conn.query('UPDATE questions SET is_archived = 1 WHERE question_id = ?', [questionId]);
await conn.commit();
return 'ARCHIVED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
@@ -0,0 +1,76 @@
import {QuestionType} from '../feedback.interface.js';
export interface SongPickResult {
songId: number;
title: string;
votes: number;
}
export interface SongPickReport {
questionId: number | null;
label: string;
totalVotes: number;
results: SongPickResult[];
}
export interface SongRatingResult {
songId: number;
title: string;
average: number;
count: number;
}
export interface SongRatingReport {
questionId: number | null;
label: string;
results: SongRatingResult[];
}
export interface FreeTextResponse {
submissionId: number;
submittedAt: string;
text: string;
}
export interface FreeTextReport {
questionId: number | null;
label: string;
responses: FreeTextResponse[];
hasMore: boolean;
}
export interface EventReport {
event: {
eventId: number;
name: string;
eventDate: string;
feedbackDeadline: string;
};
totalSubmissions: number;
firstSubmissionAt: string | null;
lastSubmissionAt: string | null;
songPicks: SongPickReport[];
songRatings: SongRatingReport[];
freeText: FreeTextReport[];
guestBookCount: number;
newsletter: {
total: number;
sent: number;
pending: number;
failed: number;
skipped: number;
};
}
/** Raw answer row as read from submission_answers, joined with submissions.submitted_at. */
export interface AnswerRow {
submissionId: number;
submittedAt: string;
questionId: number | null;
questionLabel: string;
questionType: QuestionType;
songId: number | null;
songTitle: string | null;
rating: number | null;
textAnswer: string | null;
}
@@ -0,0 +1,222 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as ReportsAdminService from './reports.admin.service.js';
import * as CsvService from './csv.service.js';
import * as EventsAdminService from './events.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const reportsAdminRouter = express.Router();
/**
* @swagger
* /feedback/admin/events/{eventId}/report:
* get:
* summary: Aggregated feedback report for one event
* description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 404:
* description: Unknown event
* 401:
* description: Unauthorized
*/
reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => {
try {
const report = await ReportsAdminService.getReport(Number(req.params.eventId));
if (!report) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(report);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/guestbook:
* get:
* summary: Guest Book entries for one event
* description: Newest first, paginated.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: pageSize
* schema:
* type: integer
* - in: query
* name: search
* description: Filters entries whose name or message contains this text (case-insensitive).
* schema:
* type: string
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50));
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await ReportsAdminService.getGuestBookEntries(Number(req.params.eventId), page, pageSize, search);
res.status(200).send(result);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/newsletter:
* get:
* summary: Newsletter signups for one event
* description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: pageSize
* schema:
* type: integer
* - in: query
* name: search
* description: Filters entries whose first name, last name, or email contains this text (case-insensitive).
* schema:
* type: string
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50));
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await ReportsAdminService.getNewsletterSignups(Number(req.params.eventId), page, pageSize, search);
res.status(200).send(result);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/export/responses.csv:
* get:
* summary: CSV export of all answers for one event
* description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: CSV file
* content:
* text/csv: {}
* 401:
* description: Unauthorized
*/
reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => {
try {
const eventId = Number(req.params.eventId);
const event = await EventsAdminService.getEventAdmin(eventId);
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
const csv = await CsvService.buildResponsesCsv(eventId);
res.status(200)
.set('Content-Type', 'text/csv; charset=utf-8')
.set('Content-Disposition', `attachment; filename="nachklang-feedback-${event.slug}.csv"`)
.send(csv);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/export/guestbook.csv:
* get:
* summary: CSV export of Guest Book entries for one event
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: CSV file
* content:
* text/csv: {}
* 401:
* description: Unauthorized
*/
reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => {
try {
const eventId = Number(req.params.eventId);
const event = await EventsAdminService.getEventAdmin(eventId);
if (!event) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
const csv = await CsvService.buildGuestBookCsv(eventId);
res.status(200)
.set('Content-Type', 'text/csv; charset=utf-8')
.set('Content-Disposition', `attachment; filename="nachklang-feedback-guestbook-${event.slug}.csv"`)
.send(csv);
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,282 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {
AnswerRow, EventReport, FreeTextReport, SongPickReport, SongRatingReport
} from './reports.admin.interface.js';
const FREE_TEXT_CAP = 500;
/**
* Pure aggregation over one event's answer rows - no DB access, so it's
* directly unit-testable against fixture data. group key is questionId
* when present, falling back to the label snapshot for answers whose
* question was hard-deleted (question_id IS NULL).
*/
export const aggregateReport = (
eventMeta: {eventId: number; name: string; eventDate: string; feedbackDeadline: string},
submissionStats: {totalSubmissions: number; firstSubmissionAt: string | null; lastSubmissionAt: string | null},
answerRows: AnswerRow[],
guestBookCount: number,
newsletterCounts: {total: number; sent: number; pending: number; failed: number; skipped: number}
): EventReport => {
const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`;
const songPickGroups = new Map<string, AnswerRow[]>();
const songRatingGroups = new Map<string, AnswerRow[]>();
const freeTextGroups = new Map<string, AnswerRow[]>();
for (const row of answerRows) {
const key = groupKey(row);
const target = row.questionType === 'SONG_PICK' ? songPickGroups
: row.questionType === 'SONG_RATING' ? songRatingGroups
: freeTextGroups;
if (!target.has(key)) target.set(key, []);
target.get(key)!.push(row);
}
const songPicks: SongPickReport[] = [...songPickGroups.values()].map((rows) => {
const votesBySong = new Map<number, {title: string; votes: number}>();
for (const row of rows) {
if (row.songId === null || row.songTitle === null) continue;
const entry = votesBySong.get(row.songId) || {title: row.songTitle, votes: 0};
entry.votes += 1;
votesBySong.set(row.songId, entry);
}
const results = [...votesBySong.entries()]
.map(([songId, v]) => ({songId, title: v.title, votes: v.votes}))
.sort((a, b) => b.votes - a.votes);
return {
questionId: rows[0].questionId,
label: rows[0].questionLabel,
totalVotes: results.reduce((sum, r) => sum + r.votes, 0),
results
};
});
const songRatings: SongRatingReport[] = [...songRatingGroups.values()].map((rows) => {
const sumsBySong = new Map<number, {title: string; sum: number; count: number}>();
for (const row of rows) {
if (row.songId === null || row.songTitle === null || row.rating === null) continue;
const entry = sumsBySong.get(row.songId) || {title: row.songTitle, sum: 0, count: 0};
entry.sum += row.rating;
entry.count += 1;
sumsBySong.set(row.songId, entry);
}
const results = [...sumsBySong.entries()]
.map(([songId, v]) => ({songId, title: v.title, average: Math.round((v.sum / v.count) * 10) / 10, count: v.count}))
.sort((a, b) => b.average - a.average);
return {questionId: rows[0].questionId, label: rows[0].questionLabel, results};
});
const freeText: FreeTextReport[] = [...freeTextGroups.values()].map((rows) => {
const sorted = rows
.filter((row) => row.textAnswer !== null)
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime());
const responses = sorted.slice(0, FREE_TEXT_CAP).map((row) => ({
submissionId: row.submissionId,
submittedAt: row.submittedAt,
text: row.textAnswer!
}));
return {
questionId: rows[0].questionId,
label: rows[0].questionLabel,
responses,
hasMore: sorted.length > FREE_TEXT_CAP
};
});
return {
event: eventMeta,
totalSubmissions: submissionStats.totalSubmissions,
firstSubmissionAt: submissionStats.firstSubmissionAt,
lastSubmissionAt: submissionStats.lastSubmissionAt,
songPicks,
songRatings,
freeText,
guestBookCount,
newsletter: newsletterCounts
};
};
export const getReport = async (eventId: number): Promise<EventReport | null> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventRows = await conn.query('SELECT event_id, name, event_date, feedback_deadline FROM events WHERE event_id = ?', [eventId]);
if (eventRows.length === 0) return null;
const eventRow = eventRows[0];
const statsRows = await conn.query(
'SELECT COUNT(*) as cnt, MIN(submitted_at) as first_at, MAX(submitted_at) as last_at FROM submissions WHERE event_id = ?',
[eventId]
);
const stats = statsRows[0];
const answerRows = await conn.query(
`SELECT sa.submission_id, s.submitted_at, sa.question_id, sa.question_label_snapshot,
sa.question_type, sa.song_id, sa.song_title_snapshot, sa.rating, sa.text_answer
FROM submission_answers sa
INNER JOIN submissions s ON s.submission_id = sa.submission_id
WHERE sa.event_id = ?`,
[eventId]
);
const answers: AnswerRow[] = answerRows.map((r: any) => ({
submissionId: r.submission_id,
submittedAt: r.submitted_at,
questionId: r.question_id,
questionLabel: r.question_label_snapshot,
questionType: r.question_type,
songId: r.song_id,
songTitle: r.song_title_snapshot,
rating: r.rating,
textAnswer: r.text_answer
}));
const guestBookRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]);
const guestBookCount = Number(guestBookRows[0].cnt);
const newsletterRows = await conn.query(
`SELECT sync_status, COUNT(*) as cnt FROM newsletter_signups WHERE event_id = ? GROUP BY sync_status`,
[eventId]
);
const newsletterCounts = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 0};
for (const row of newsletterRows) {
const cnt = Number(row.cnt);
newsletterCounts.total += cnt;
if (row.sync_status === 'SENT') newsletterCounts.sent = cnt;
else if (row.sync_status === 'PENDING') newsletterCounts.pending = cnt;
else if (row.sync_status === 'FAILED') newsletterCounts.failed = cnt;
else if (row.sync_status === 'SKIPPED') newsletterCounts.skipped = cnt;
}
return aggregateReport(
{eventId: eventRow.event_id, name: eventRow.name, eventDate: eventRow.event_date, feedbackDeadline: eventRow.feedback_deadline},
{totalSubmissions: Number(stats.cnt), firstSubmissionAt: stats.first_at, lastSubmissionAt: stats.last_at},
answers,
guestBookCount,
newsletterCounts
);
} finally {
await conn.end();
}
};
export interface GuestBookEntry {
entryId: number;
submissionId: number;
submittedAt: string;
displayName: string | null;
message: string | null;
}
// Escapes LIKE wildcards (% and _) so a search term is matched literally,
// not interpreted as a pattern - a search for "50%" must not match everything.
const escapeLikeTerm = (term: string) => term.replace(/[\\%_]/g, (c) => `\\${c}`);
export const getGuestBookEntries = async (
eventId: number,
page: number,
pageSize: number,
search?: string
): Promise<{entries: GuestBookEntry[]; total: number}> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const trimmedSearch = search?.trim();
const whereClause = trimmedSearch
? 'WHERE event_id = ? AND (display_name LIKE ? ESCAPE \'\\\\\' OR message LIKE ? ESCAPE \'\\\\\')'
: 'WHERE event_id = ?';
const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined;
const whereParams = trimmedSearch ? [eventId, likeParam, likeParam] : [eventId];
const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM guest_book_entries ${whereClause}`, whereParams);
const rows = await conn.query(
`SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...whereParams, pageSize, (page - 1) * pageSize]
);
return {
total: Number(totalRows[0].cnt),
entries: rows.map((r: any) => ({
entryId: r.entry_id,
submissionId: r.submission_id,
submittedAt: r.created_at,
displayName: r.display_name,
message: r.message
}))
};
} finally {
await conn.end();
}
};
export interface NewsletterSignupRow {
signupId: number;
firstName: string;
lastName: string;
email: string;
consentAt: string;
syncStatus: string;
lastError: string | null;
}
/**
* Deletes one submission and everything under it (its answers, guest book
* entry, newsletter signup). Single-path deletes by submission_id - unlike
* deleteEvent's multi-path cascade issue, there's only one way to reach each
* child table here, so explicit ordering is for consistency with that
* function's style, not to work around an ambiguous-cascade error.
*/
export const deleteSubmission = async (submissionId: number): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const rows = await conn.query('SELECT submission_id FROM submissions WHERE submission_id = ?', [submissionId]);
if (rows.length === 0) {
await conn.rollback();
return false;
}
await conn.query('DELETE FROM guest_book_entries WHERE submission_id = ?', [submissionId]);
await conn.query('DELETE FROM newsletter_signups WHERE submission_id = ?', [submissionId]);
await conn.query('DELETE FROM submission_answers WHERE submission_id = ?', [submissionId]);
await conn.query('DELETE FROM submissions WHERE submission_id = ?', [submissionId]);
await conn.commit();
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const getNewsletterSignups = async (
eventId: number,
page: number,
pageSize: number,
search?: string
): Promise<{entries: NewsletterSignupRow[]; total: number}> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const trimmedSearch = search?.trim();
const whereClause = trimmedSearch
? 'WHERE event_id = ? AND (first_name LIKE ? ESCAPE \'\\\\\' OR last_name LIKE ? ESCAPE \'\\\\\' OR email LIKE ? ESCAPE \'\\\\\')'
: 'WHERE event_id = ?';
const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined;
const whereParams = trimmedSearch ? [eventId, likeParam, likeParam, likeParam] : [eventId];
const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM newsletter_signups ${whereClause}`, whereParams);
const rows = await conn.query(
`SELECT signup_id, first_name, last_name, email, consent_at, sync_status, last_error FROM newsletter_signups ${whereClause} ORDER BY consent_at DESC LIMIT ? OFFSET ?`,
[...whereParams, pageSize, (page - 1) * pageSize]
);
return {
total: Number(totalRows[0].cnt),
entries: rows.map((r: any) => ({
signupId: r.signup_id, firstName: r.first_name, lastName: r.last_name, email: r.email,
consentAt: r.consent_at, syncStatus: r.sync_status, lastError: r.last_error
}))
};
} finally {
await conn.end();
}
};
@@ -0,0 +1,97 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as SongsAdminService from './songs.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const songsAdminRouter = express.Router();
/**
* @swagger
* /feedback/admin/songs/{songId}:
* put:
* summary: Edit a song's title/composer
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: songId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [title]
* properties:
* title:
* type: string
* composer:
* type: string
* responses:
* 200:
* description: Updated
* 400:
* description: Missing title
* 404:
* description: Unknown song
* 401:
* description: Unauthorized
* delete:
* summary: Remove a song
* description: Past answers keep their song_title_snapshot even after the song is removed.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: songId
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Removed
* 404:
* description: Unknown song
* 401:
* description: Unauthorized
*/
songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
try {
const {title, composer} = req.body || {};
if (!title) {
res.status(400).send({status: 'BAD_REQUEST', message: 'title is required'});
return;
}
const updated = await SongsAdminService.updateSong(Number(req.params.songId), title, composer || null);
if (!updated) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
songsAdminRouter.delete('/:songId', async (req: Request, res: Response) => {
try {
const deleted = await SongsAdminService.deleteSong(Number(req.params.songId));
if (!deleted) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,56 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
export const addSong = async (eventId: number, title: string, composer: string | null): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const posRows = await conn.query('SELECT COALESCE(MAX(position), -1) + 1 as next_position FROM songs WHERE event_id = ?', [eventId]);
const position = posRows[0].next_position;
const res = await conn.query(
'INSERT INTO songs (event_id, title, composer, position) VALUES (?,?,?,?) RETURNING song_id',
[eventId, title, composer, position]
);
await conn.commit();
return res[0].song_id;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const updateSong = async (songId: number, title: string, composer: string | null): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query('UPDATE songs SET title = ?, composer = ? WHERE song_id = ?', [title, composer, songId]);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Removes a song. submission_answers rows referencing it keep their
* song_title_snapshot (song_id is set to NULL via ON DELETE SET NULL) -
* past answers still say what song was rated, even after the song is gone.
*/
export const deleteSong = async (songId: number): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const res = await conn.query('DELETE FROM songs WHERE song_id = ?', [songId]);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
+78
View File
@@ -0,0 +1,78 @@
import express from 'express';
import * as UserService from '../calendar/users/users.service.js';
import {sendServerError} from './feedback.errors.js';
/**
* This file is the ONLY place in the feedback module that knows how admin
* authentication works today. No route handler and no service outside this
* file may import users.service, read session headers, or touch bcrypt.
*
* Today: reuses the existing Calendar users/sessions mechanism. Any
* activated @nachklang.art account may administer feedback — no roles.
* Migrating to Keycloak later means writing a keycloakJwtAuthenticator
* below and changing the one `activeAuthenticator` binding (plus the
* frontend's login route handler) — nothing else in the feedback module
* needs to change.
*
* Explicitly forbidden: accepting sessionId/sessionKey from query
* parameters, even "temporarily". That is the exact mistake documented in
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials
* end up in access logs, browser history, proxy logs, and Referer headers.
* Headers only.
*/
// The only thing the rest of the feedback module knows about an admin.
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
// Pluggable strategy: extract + verify credentials from a request.
// Returns the identity, or null if unauthenticated. Throws only on
// infrastructure errors (e.g. the DB being unreachable).
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
// Current implementation: reads X-Session-Id / X-Session-Key headers,
// delegates to the existing calendar UserService.checkSession(...).
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
// Mirrors the Calendar domain's own convention: a valid session on an
// inactive (not yet activated) account is not sufficient.
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
// Swap point: change this one binding to migrate to Keycloak.
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
// Express middleware used by every admin route. On success:
// res.locals.admin = AdminIdentity, calls next(). On failure: 401.
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
try {
const identity = await activeAuthenticator(req);
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
+13
View File
@@ -0,0 +1,13 @@
/**
* Formats a Date using its local getters (not toISOString/UTC), so the
* wall-clock time the server is running in is what gets stored/displayed -
* never silently shifted by a UTC conversion. Used both for MySQL DATETIME
* literals (events.admin.service.ts) and CSV export (csv.service.ts): same
* requirement, same format, in either context.
*/
export const formatDatetime = (value: Date | string | null): string => {
if (!value) return '';
const d = value instanceof Date ? value : new Date(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
+18
View File
@@ -0,0 +1,18 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
/**
* The feedback module's standard catch-block response: log with a
* reference guid, never leak the real error message to the client. Every
* router in this module follows this exact convention (see CLAUDE.md).
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
+130
View File
@@ -0,0 +1,130 @@
/**
* @swagger
* components:
* schemas:
* QuestionType:
* type: string
* enum: [SONG_PICK, SONG_RATING, FREE_TEXT]
* Song:
* type: object
* required: [songId, title, position]
* properties:
* songId:
* type: integer
* example: 44
* title:
* type: string
* example: "Abendlied"
* composer:
* type: string
* nullable: true
* example: "Josef Rheinberger"
* position:
* type: integer
* example: 0
* Question:
* type: object
* required: [eventQuestionId, questionId, type, label, position]
* properties:
* eventQuestionId:
* type: integer
* example: 12
* questionId:
* type: integer
* example: 5
* type:
* $ref: '#/components/schemas/QuestionType'
* label:
* type: string
* example: "Welches Stück hat Sie am meisten berührt?"
* helpText:
* type: string
* nullable: true
* position:
* type: integer
* example: 0
* EventSummary:
* type: object
* required: [slug, name, eventDate, feedbackDeadline]
* properties:
* slug:
* type: string
* example: "sommerkonzert-2026"
* name:
* type: string
* example: "Sommerkonzert 2026"
* subtitle:
* type: string
* nullable: true
* eventDate:
* type: string
* format: date
* feedbackDeadline:
* type: string
* format: date-time
* posterImageUrl:
* type: string
* nullable: true
* example: "https://www.nachklang.art/img/nk/image-20260727-214855-851.jpeg"
* EventConfig:
* allOf:
* - $ref: '#/components/schemas/EventSummary'
* - type: object
* properties:
* introText:
* type: string
* nullable: true
* songs:
* type: array
* items:
* $ref: '#/components/schemas/Song'
* questions:
* type: array
* items:
* $ref: '#/components/schemas/Question'
* ProcessingError:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
export type QuestionType = 'SONG_PICK' | 'SONG_RATING' | 'FREE_TEXT';
export interface Song {
songId: number;
title: string;
composer: string | null;
position: number;
}
export interface Question {
eventQuestionId: number;
questionId: number;
type: QuestionType;
label: string;
helpText: string | null;
position: number;
}
export interface EventSummary {
slug: string;
name: string;
subtitle: string | null;
eventDate: string;
feedbackDeadline: string;
posterImageUrl: string | null;
}
export interface EventConfig extends EventSummary {
introText: string | null;
songs: Song[];
questions: Question[];
}
+100
View File
@@ -0,0 +1,100 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
import {NachklangFeedbackDB} from './Feedback.db.js';
dotenv.config();
const RATE_LIMIT_MAX = parseInt(process.env.FEEDBACK_RATE_LIMIT_MAX || '5', 10);
const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.FEEDBACK_RATE_LIMIT_WINDOW_MIN || '10', 10);
const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MIN * 60 * 1000;
if (!process.env.FEEDBACK_IP_SALT) {
// A missing salt would silently degrade hashIp() to unsalted SHA-256,
// which is reversible for the whole IPv4 space in minutes - fail loudly
// instead of persisting deanonymizable data.
throw new Error('FEEDBACK_IP_SALT is required (see .env / CLAUDE.md environment block)');
}
const IP_SALT = process.env.FEEDBACK_IP_SALT;
/**
* Salted hash of the client IP. Never store or log the raw address.
*/
export const hashIp = (ip: string): string => {
return crypto.createHash('sha256').update(IP_SALT + ip).digest('hex');
};
// In-memory sliding window, keyed by ip hash. Resets on process restart —
// acceptable, the DB backstop below covers that gap.
const recentSubmissions = new Map<string, number[]>();
const pruneOld = (timestamps: number[], now: number): number[] => {
return timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
};
// Without this, isRateLimited() would store a Map entry for every distinct
// ip hash it has ever seen - including empty arrays for one-off visitors -
// and nothing would ever remove it, growing unbounded for the process
// lifetime. Sweep periodically so hashes that stop submitting eventually
// drop out even if isRateLimited() is never called for them again.
const sweepInterval = setInterval(() => {
const now = Date.now();
for (const [ipHash, timestamps] of recentSubmissions) {
if (pruneOld(timestamps, now).length === 0) {
recentSubmissions.delete(ipHash);
}
}
}, RATE_LIMIT_WINDOW_MS);
sweepInterval.unref();
/**
* DB backstop for the case where the in-memory counter was reset by a
* process restart. Only queried when the in-memory counter is already
* near the limit, so the common path stays DB-free.
*/
const checkDbBackstop = async (ipHash: string): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = 'SELECT COUNT(*) as cnt FROM submissions WHERE ip_hash = ? AND submitted_at > NOW() - INTERVAL ? MINUTE';
const rows = await conn.query(query, [ipHash, RATE_LIMIT_WINDOW_MIN]);
return Number(rows[0].cnt);
} finally {
await conn.end();
}
};
/**
* Returns true if the given ip hash is currently allowed to submit.
* Does not itself record the submission — call recordSubmission after a
* successful insert.
*/
export const isRateLimited = async (ipHash: string): Promise<boolean> => {
const now = Date.now();
const timestamps = pruneOld(recentSubmissions.get(ipHash) || [], now);
if (timestamps.length > 0) {
recentSubmissions.set(ipHash, timestamps);
} else {
recentSubmissions.delete(ipHash);
}
if (timestamps.length >= RATE_LIMIT_MAX) {
return true;
}
// Close to the limit in memory — fall back to the DB in case the
// process restarted and lost earlier counts.
if (timestamps.length >= RATE_LIMIT_MAX - 1) {
const dbCount = await checkDbBackstop(ipHash);
if (dbCount >= RATE_LIMIT_MAX) {
return true;
}
}
return false;
};
export const recordSubmission = (ipHash: string): void => {
const now = Date.now();
const timestamps = pruneOld(recentSubmissions.get(ipHash) || [], now);
timestamps.push(now);
recentSubmissions.set(ipHash, timestamps);
};
@@ -0,0 +1,100 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import logger from '../../../middleware/logger.js';
import {salesforceApexRestPost} from '../../../common/salesforce.client.js';
// Newsletter opt-ins sync to Salesforce, which already runs a full
// double-opt-in subscription flow (Person Account for existing constituents,
// Lead for everyone else - see the Salesforce repo's
// feature/newsletter-signup-integration branch for the full design notes).
// This is the one file that knows that contract exists; submissions.service.ts
// only ever calls syncNewsletterSignup(signupId) after its own transaction
// commits, fire-and-forget, so a Salesforce outage can never delay or fail a
// visitor's feedback submission. The OAuth token cache and retry-once-on-401
// live in common/salesforce.client.ts, shared with the transactional-email relay.
interface SalesforceSuccessResponse {
status: 'PENDING_CONFIRMATION' | 'ALREADY_SUBSCRIBED';
salesforceObject: 'Lead' | 'Account';
salesforceRecordId: string;
created: boolean;
}
interface NewsletterSignupRow {
signup_id: number;
first_name: string;
last_name: string;
email: string;
event_name: string;
}
const postSignup = (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise<SalesforceSuccessResponse> =>
salesforceApexRestPost<SalesforceSuccessResponse>('/services/apexrest/newsletter/signup', payload);
const markSynced = async (signupId: number, externalId: string): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.query(
`UPDATE newsletter_signups SET sync_status = 'SENT', synced_at = NOW(), external_id = ?, sync_attempts = sync_attempts + 1, last_error = NULL WHERE signup_id = ?`,
[externalId, signupId]
);
} finally {
await conn.end();
}
};
const markFailed = async (signupId: number, errorMessage: string): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.query(
`UPDATE newsletter_signups SET sync_status = 'FAILED', last_error = ?, sync_attempts = sync_attempts + 1 WHERE signup_id = ?`,
[errorMessage.slice(0, 2000), signupId]
);
} finally {
await conn.end();
}
};
/**
* Reads one newsletter_signups row and syncs it to Salesforce. Always
* called after the owning submission's transaction has committed, never
* awaited by the request handler. When SALESFORCE_ENABLED is false, this
* only logs the payload it would have sent - the row's sync_status is
* already 'SKIPPED' from the insert in submissions.service.ts, so there's
* nothing to update.
*/
export const syncNewsletterSignup = async (signupId: number): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
let row: NewsletterSignupRow | undefined;
try {
const rows = await conn.query(
`SELECT ns.signup_id, ns.first_name, ns.last_name, ns.email, e.name AS event_name
FROM newsletter_signups ns JOIN events e ON e.event_id = ns.event_id
WHERE ns.signup_id = ?`,
[signupId]
);
row = rows[0];
} finally {
await conn.end();
}
if (!row) {
logger.error('syncNewsletterSignup: signup not found', {signupId});
return;
}
const payload = {firstName: row.first_name, lastName: row.last_name, email: row.email, eventName: row.event_name};
if (process.env.SALESFORCE_ENABLED !== 'true') {
logger.info('syncNewsletterSignup: SALESFORCE_ENABLED is false, would have sent', {signupId, payload});
return;
}
try {
const result = await postSignup(payload);
await markSynced(signupId, result.salesforceRecordId);
} catch (err: any) {
const message = err?.response?.data?.message || err?.message || 'Unknown error';
logger.error('syncNewsletterSignup failed', {signupId, message});
await markFailed(signupId, message);
}
};
@@ -0,0 +1,104 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface.js';
/**
* Returns all events currently eligible to receive feedback:
* published, on or after their concert day, and before the deadline.
*/
export const getEligibleEvents = async (): Promise<EventSummary[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = `
SELECT slug, name, subtitle, event_date, feedback_deadline, poster_image_url
FROM events
WHERE is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()
ORDER BY event_date DESC`;
const rows = await conn.query(query);
return rows.map((row: any) => ({
slug: row.slug,
name: row.name,
subtitle: row.subtitle,
eventDate: row.event_date,
feedbackDeadline: row.feedback_deadline,
posterImageUrl: row.poster_image_url
}));
} finally {
await conn.end();
}
};
export type EventLookupResult =
| { status: 'OK'; eventId: number; event: EventConfig }
| { status: 'NOT_FOUND' }
| { status: 'CLOSED' };
/**
* Resolves a slug to its full public config: meta, ordered setlist, ordered
* active questions. Distinguishes "unknown slug" from "known but outside
* its feedback window" so callers can respond 404 vs 410. Also used
* internally by the submission flow, which additionally needs `eventId`.
*/
export const getEventConfigBySlug = async (slug: string): Promise<EventLookupResult> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventQuery = `
SELECT event_id, slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, is_published
FROM events WHERE slug = ?`;
const eventRows = await conn.query(eventQuery, [slug]);
if (eventRows.length === 0) {
return {status: 'NOT_FOUND'};
}
const eventRow = eventRows[0];
const eligibleQuery = `
SELECT 1 FROM events
WHERE event_id = ? AND is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()`;
const eligibleRows = await conn.query(eligibleQuery, [eventRow.event_id]);
if (eligibleRows.length === 0) {
return {status: 'CLOSED'};
}
const songsQuery = 'SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC';
const songRows = await conn.query(songsQuery, [eventRow.event_id]);
const songs: Song[] = songRows.map((row: any) => ({
songId: row.song_id,
title: row.title,
composer: row.composer,
position: row.position
}));
const questionsQuery = `
SELECT eq.event_question_id, eq.position, q.question_id, q.question_type, q.label, q.help_text
FROM event_questions eq
INNER JOIN questions q ON q.question_id = eq.question_id
WHERE eq.event_id = ? AND eq.is_active = 1
ORDER BY eq.position ASC`;
const questionRows = await conn.query(questionsQuery, [eventRow.event_id]);
const questions: Question[] = questionRows.map((row: any) => ({
eventQuestionId: row.event_question_id,
questionId: row.question_id,
type: row.question_type,
label: row.label,
helpText: row.help_text,
position: row.position
}));
return {
status: 'OK',
eventId: eventRow.event_id,
event: {
slug: eventRow.slug,
name: eventRow.name,
subtitle: eventRow.subtitle,
eventDate: eventRow.event_date,
feedbackDeadline: eventRow.feedback_deadline,
posterImageUrl: eventRow.poster_image_url,
introText: eventRow.intro_text,
songs,
questions
}
};
} finally {
await conn.end();
}
};
+193
View File
@@ -0,0 +1,193 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import logger from '../../../middleware/logger.js';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service.js';
import {submitFeedback} from './submissions.service.js';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit.js';
import {sendServerError} from '../feedback.errors.js';
/**
* Router Definition
*/
export const publicRouter = express.Router();
/**
* True if the honeypot field was filled in — a real visitor never types
* into it, since it's hidden with CSS only. Pulled out as a pure function
* so the short-circuit behaviour is unit-testable without a live DB.
*/
export const isHoneypotTriggered = (body: any): boolean => {
return typeof body?.website === 'string' && body.website.trim().length > 0;
};
/**
* @swagger
* /feedback/events:
* get:
* summary: List currently eligible events
* description: Returns events that are published, on or after their concert day, and before their feedback deadline. An empty array is a valid, expected response.
* tags:
* - feedback
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/EventSummary'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ProcessingError'
*/
publicRouter.get('/events', async (req: Request, res: Response) => {
try {
const events = await getEligibleEvents();
res.status(200).send(events);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/events/{slug}:
* get:
* summary: Get the full public config for one event
* description: Returns event meta, ordered setlist, and ordered active questions. 404 if the slug is unknown, 410 if the event exists but is outside its feedback window.
* tags:
* - feedback
* parameters:
* - in: path
* name: slug
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventConfig'
* 404:
* description: Unknown slug
* 410:
* description: Event exists but feedback is closed
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ProcessingError'
*/
publicRouter.get('/events/:slug', async (req: Request, res: Response) => {
try {
const result = await getEventConfigBySlug(req.params.slug);
if (result.status === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
if (result.status === 'CLOSED') {
res.status(410).send({status: 'FEEDBACK_CLOSED'});
return;
}
res.status(200).send(result.event);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/events/{slug}/submissions:
* post:
* summary: Submit feedback for an event
* description: Every field is optional; the only validation error the public form can produce is EMPTY_SUBMISSION (nothing was filled in). Rate-limited per IP hash and honeypot-checked.
* tags:
* - feedback
* parameters:
* - in: path
* name: slug
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SubmissionRequest'
* responses:
* 201:
* description: Submitted
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SubmissionResponse'
* 400:
* description: Nothing was filled in
* 404:
* description: Unknown slug
* 410:
* description: Event exists but feedback is closed
* 429:
* description: Rate limited
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ProcessingError'
*/
publicRouter.post('/events/:slug/submissions', async (req: Request, res: Response) => {
try {
const body = req.body || {};
// Honeypot: a real visitor never fills this in. Fake success, persist
// nothing, stay silent about it having failed.
if (isHoneypotTriggered(body)) {
logger.info('Feedback honeypot triggered', {slug: req.params.slug});
res.status(201).send({submissionId: -1, newsletterDropped: false});
return;
}
const ipHash = hashIp(req.ip || '');
if (await isRateLimited(ipHash)) {
res.status(429).send({status: 'RATE_LIMITED'});
return;
}
// Count every request that reaches this point against the limit,
// regardless of outcome - an attacker sending EMPTY/NOT_FOUND/CLOSED
// requests still costs DB round-trips per attempt and must not get an
// unlimited number of free ones.
recordSubmission(ipHash);
const result = await submitFeedback(req.params.slug, body, ipHash);
switch (result.status) {
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'CLOSED':
res.status(410).send({status: 'FEEDBACK_CLOSED'});
return;
case 'EMPTY':
res.status(400).send({status: 'EMPTY_SUBMISSION'});
return;
case 'OK':
res.status(201).send({submissionId: result.submissionId, newsletterDropped: result.newsletterDropped});
return;
}
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,98 @@
/**
* @swagger
* components:
* schemas:
* SubmissionRequest:
* type: object
* properties:
* answers:
* type: array
* items:
* type: object
* properties:
* eventQuestionId:
* type: integer
* example: 12
* songId:
* type: integer
* nullable: true
* description: SONG_PICK only
* ratings:
* type: array
* description: SONG_RATING only
* items:
* type: object
* properties:
* songId:
* type: integer
* rating:
* type: integer
* minimum: 1
* maximum: 5
* text:
* type: string
* nullable: true
* description: FREE_TEXT only
* guestBook:
* type: object
* nullable: true
* properties:
* displayName:
* type: string
* nullable: true
* message:
* type: string
* nullable: true
* newsletter:
* type: object
* nullable: true
* properties:
* firstName:
* type: string
* lastName:
* type: string
* email:
* type: string
* website:
* type: string
* description: Honeypot field. Must stay empty; a real visitor never fills it in.
* SubmissionResponse:
* type: object
* properties:
* submissionId:
* type: integer
* example: 91
* newsletterDropped:
* type: boolean
* description: True if the newsletter opt-in was present but failed validation (e.g. a malformed email) - the rest of the submission still saved.
*/
export interface RatingInput {
songId: number;
rating: number;
}
export interface AnswerInput {
eventQuestionId: number;
songId?: number;
ratings?: RatingInput[];
text?: string;
}
export interface GuestBookInput {
displayName?: string;
message?: string;
}
export interface NewsletterInput {
firstName: string;
lastName: string;
email: string;
}
export interface SubmissionRequestBody {
answers?: AnswerInput[];
guestBook?: GuestBookInput;
newsletter?: NewsletterInput;
website?: string;
}
@@ -0,0 +1,229 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {QuestionType} from '../feedback.interface.js';
import {getEventConfigBySlug} from './events.public.service.js';
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface.js';
import {syncNewsletterSignup} from '../integrations/salesforce.service.js';
import logger from '../../../middleware/logger.js';
// Bump when the privacy/consent copy shown next to the newsletter opt-in
// changes; recorded per-signup so a past consent's exact wording is provable.
const CONSENT_TEXT_VERSION = '2026-08-02';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// A real setlist tops out around a few dozen songs and a handful of
// questions, so a legitimate submission never comes close to this. Caps
// total generated rows regardless of how large the client's answers/ratings
// arrays are, bounding the number of INSERTs one request can trigger.
export const MAX_ANSWER_ROWS = 200;
export interface ValidatedAnswerRow {
eventQuestionId: number;
questionId: number;
label: string;
type: QuestionType;
position: number;
songId: number | null;
songTitle: string | null;
rating: number | null;
text: string | null;
}
export interface ValidatedGuestBook {
displayName: string | null;
message: string | null;
}
export interface ValidatedNewsletter {
firstName: string;
lastName: string;
email: string;
}
/**
* Validates raw answers against the event's *actual* active questions and
* songs. Unknown eventQuestionId/songId are ignored rather than erroring —
* a stale tab must not lose someone's comment. Empty answers are dropped
* entirely; "no row" is the canonical representation of "skipped".
*/
export const validateAnswers = (
answers: AnswerInput[],
questionsById: Map<number, {eventQuestionId: number; questionId: number; type: QuestionType; label: string; position: number}>,
songTitleById: Map<number, string>
): ValidatedAnswerRow[] => {
const rows: ValidatedAnswerRow[] = [];
const pushRow = (row: ValidatedAnswerRow): boolean => {
if (rows.length >= MAX_ANSWER_ROWS) return false;
rows.push(row);
return true;
};
outer: for (const answer of answers) {
const question = questionsById.get(answer.eventQuestionId);
if (!question) continue;
if (question.type === 'SONG_PICK') {
if (answer.songId != null && songTitleById.has(answer.songId)) {
if (!pushRow({
eventQuestionId: question.eventQuestionId,
questionId: question.questionId,
label: question.label,
type: 'SONG_PICK',
position: question.position,
songId: answer.songId,
songTitle: songTitleById.get(answer.songId)!,
rating: null,
text: null
})) break outer;
}
} else if (question.type === 'SONG_RATING') {
// De-duplicate by songId (last value wins) before generating rows,
// so a client can't force one row per repeated entry for the same
// song by simply repeating it in the ratings array.
const ratingBySong = new Map<number, number>();
for (const r of answer.ratings || []) {
if (!songTitleById.has(r.songId)) continue;
ratingBySong.set(r.songId, Math.min(5, Math.max(1, Math.round(r.rating))));
}
for (const [songId, clamped] of ratingBySong) {
if (!pushRow({
eventQuestionId: question.eventQuestionId,
questionId: question.questionId,
label: question.label,
type: 'SONG_RATING',
position: question.position,
songId,
songTitle: songTitleById.get(songId)!,
rating: clamped,
text: null
})) break outer;
}
} else if (question.type === 'FREE_TEXT') {
const trimmed = (answer.text || '').trim();
if (trimmed.length > 0) {
if (!pushRow({
eventQuestionId: question.eventQuestionId,
questionId: question.questionId,
label: question.label,
type: 'FREE_TEXT',
position: question.position,
songId: null,
songTitle: null,
rating: null,
text: trimmed.slice(0, 5000)
})) break outer;
}
}
}
return rows;
};
export const validateGuestBook = (input?: GuestBookInput): ValidatedGuestBook | null => {
if (!input) return null;
const displayName = (input.displayName || '').trim().slice(0, 255) || null;
const message = (input.message || '').trim().slice(0, 2000) || null;
if (!displayName && !message) return null;
return {displayName, message};
};
export const validateNewsletter = (input?: NewsletterInput): ValidatedNewsletter | null => {
if (!input) return null;
const firstName = (input.firstName || '').trim().slice(0, 120);
const lastName = (input.lastName || '').trim().slice(0, 120);
const email = (input.email || '').trim().slice(0, 255);
if (!firstName || !lastName || !EMAIL_RE.test(email)) return null;
return {firstName, lastName, email};
};
export type SubmitResult =
| { status: 'OK'; submissionId: number; newsletterDropped: boolean }
| { status: 'NOT_FOUND' }
| { status: 'CLOSED' }
| { status: 'EMPTY' };
/**
* Validates and persists one feedback submission. Re-checks event
* eligibility (the window may have closed between page load and submit),
* validates every answer against the event's live questions/songs, then
* inserts everything in a single transaction.
*/
export const submitFeedback = async (slug: string, body: SubmissionRequestBody, ipHash: string | null): Promise<SubmitResult> => {
const lookup = await getEventConfigBySlug(slug);
if (lookup.status === 'NOT_FOUND') return {status: 'NOT_FOUND'};
if (lookup.status === 'CLOSED') return {status: 'CLOSED'};
const {eventId, event} = lookup;
const questionsById = new Map(event.questions.map(q => [q.eventQuestionId, q]));
const songTitleById = new Map(event.songs.map(s => [s.songId, s.title]));
const answerRows = validateAnswers(body.answers || [], questionsById, songTitleById);
const guestBook = validateGuestBook(body.guestBook);
const newsletter = validateNewsletter(body.newsletter);
// body.newsletter is only sent at all when the visitor had the opt-in
// checkbox on (see FeedbackForm.tsx), so a present-but-invalid block
// (e.g. a mistyped email) is distinguishable from "didn't opt in" - the
// rest of the submission still saves, but the client can tell the
// visitor their newsletter signup specifically didn't go through.
const newsletterDropped = !!body.newsletter && !newsletter;
if (answerRows.length === 0 && !guestBook && !newsletter) {
return {status: 'EMPTY'};
}
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const subQuery = 'INSERT INTO submissions (event_id, ip_hash, has_guestbook, has_newsletter) VALUES (?,?,?,?) RETURNING submission_id';
const subRes = await conn.query(subQuery, [eventId, ipHash, guestBook ? 1 : 0, newsletter ? 1 : 0]);
const submissionId = subRes[0].submission_id;
for (const row of answerRows) {
const ansQuery = `INSERT INTO submission_answers
(submission_id, event_id, question_id, event_question_id, question_label_snapshot, question_type, position_snapshot, song_id, song_title_snapshot, rating, text_answer)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`;
await conn.query(ansQuery, [
submissionId, eventId, row.questionId, row.eventQuestionId, row.label, row.type, row.position,
row.songId, row.songTitle, row.rating, row.text
]);
}
if (guestBook) {
const gbQuery = 'INSERT INTO guest_book_entries (submission_id, event_id, display_name, message) VALUES (?,?,?,?)';
await conn.query(gbQuery, [submissionId, eventId, guestBook.displayName, guestBook.message]);
}
let newsletterSignupId: number | null = null;
if (newsletter) {
// The signup is always persisted locally first, regardless of sync
// outcome - syncNewsletterSignup (fired after commit, below) is what
// actually talks to Salesforce and moves PENDING to SENT/FAILED.
const salesforceEnabled = process.env.SALESFORCE_ENABLED === 'true';
const nlQuery = `INSERT INTO newsletter_signups
(submission_id, event_id, first_name, last_name, email, consent_text_version, sync_status)
VALUES (?,?,?,?,?,?,?) RETURNING signup_id`;
const nlRes = await conn.query(nlQuery, [
submissionId, eventId, newsletter.firstName, newsletter.lastName, newsletter.email,
CONSENT_TEXT_VERSION, salesforceEnabled ? 'PENDING' : 'SKIPPED'
]);
newsletterSignupId = nlRes[0].signup_id;
}
await conn.commit();
if (newsletterSignupId !== null) {
const signupId = newsletterSignupId;
void syncNewsletterSignup(signupId).catch((err) => {
logger.error('syncNewsletterSignup threw outside its own error handling', {signupId, error: String(err)});
});
}
return {status: 'OK', submissionId, newsletterDropped};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
+18
View File
@@ -0,0 +1,18 @@
import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
dotenv.config();
export namespace NachklangTicketsDB {
const pool = mariadb.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.TICKETS_DB,
connectionLimit: 5
});
export const getConnection = async () => {
return pool.getConnection();
};
}
+8
View File
@@ -0,0 +1,8 @@
import express from 'express';
import {adminRouter} from './admin/admin.router.js';
import {publicRouter} from './public/public.router.js';
export const ticketsRouter = express.Router();
ticketsRouter.use('/admin', adminRouter);
ticketsRouter.use('/', publicRouter);
+35
View File
@@ -0,0 +1,35 @@
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../tickets.auth.js';
import {vouchersAdminRouter} from './vouchers.admin.router.js';
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
import {eventsAdminRouter} from './events.admin.router.js';
export const adminRouter = express.Router();
// Applied once at the top of the admin router tree - every route below
// requires a valid admin session (mirrors Feedback's admin.router.ts).
adminRouter.use(requireAdminAuth);
/**
* @swagger
* /tickets/admin/me:
* get:
* summary: Validate the current admin session
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
});
adminRouter.use('/vouchers', vouchersAdminRouter);
adminRouter.use('/vouchers', voucherHistoryRouter);
adminRouter.use('/redemptions', redemptionsAdminRouter);
adminRouter.use('/events', eventsAdminRouter);
@@ -0,0 +1,179 @@
import express, {Request, Response} from 'express';
import * as EventsAdminService from './events.admin.service.js';
import {sendServerError} from '../tickets.errors.js';
export const eventsAdminRouter = express.Router();
/**
* @swagger
* /tickets/admin/events:
* get:
* summary: List concerts for the admin event picker
* description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events).
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send(await EventsAdminService.listEventsForPicker());
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/events/available:
* get:
* summary: List public-calendar events not yet added to the ticket shop
* description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
try {
res.status(200).send(await EventsAdminService.listAvailableEventsToAdd());
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/events/{eventId}/stats:
* get:
* summary: Get a concert's voucher/capacity stats
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventStats'
* 401:
* description: Unauthorized
*/
eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => {
try {
res.status(200).send(await EventsAdminService.getEventStats(Number(req.params.eventId)));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/events/{eventId}/settings:
* put:
* summary: Set a concert's voucher settings
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* capacity:
* type: integer
* nullable: true
* redemptionDeadline:
* type: string
* format: date-time
* nullable: true
* collectAddress:
* type: boolean
* requireAddress:
* type: boolean
* description: Only meaningful when collectAddress is true.
* responses:
* 200:
* description: Saved
* 401:
* description: Unauthorized
*/
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
try {
const {capacity, redemptionDeadline, collectAddress, requireAddress} = req.body || {};
await EventsAdminService.setEventSettings(Number(req.params.eventId), {
capacity: capacity ?? null,
// The mariadb driver needs an actual Date to serialize a DATETIME
// column correctly - a raw ISO string (as arrives over JSON) gets
// rejected with "Incorrect datetime value".
redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null,
collectAddress: !!collectAddress,
requireAddress: !!collectAddress && !!requireAddress
});
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/events/{eventId}/settings:
* delete:
* summary: Remove an event from the ticket shop
* description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Removed
* 409:
* description: Vouchers already reference this event
* 401:
* description: Unauthorized
*/
eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => {
try {
const result = await EventsAdminService.removeEvent(Number(req.params.eventId));
if (result === 'HAS_VOUCHERS') {
res.status(409).send({status: 'HAS_VOUCHERS', message: 'Für dieses Konzert existieren bereits Gutscheine.'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,155 @@
import * as CalendarEventsService from '../../calendar/events/events.service.js';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {EventStats, EventTicketSettings} from '../tickets.interface.js';
// Concerts are managed on the public calendar (calendarId 1) - see
// docs/plan-ticket-shop.md. getAllEventsAdmin includes DRAFT events so
// organizers can generate vouchers for a concert before it's announced.
const PUBLIC_CALENDAR_ID = 1;
export interface EventPickerEntry {
eventId: number;
name: string;
startDateTime: Date;
location: string;
status: string | undefined;
}
/**
* The public calendar holds more than concerts (rehearsal announcements,
* general notices, etc.), and Calendar's own Event has no category field to
* tell them apart. `event_ticket_settings` doubles as the ticket shop's
* allow-list: a Calendar event only appears here once an admin has
* explicitly added it (see addEvent/removeEvent below) - even with every
* setting left at its default (uncapped, no deadline, no address).
*/
export const listEventsForPicker = async (): Promise<EventPickerEntry[]> => {
let conn = await NachklangTicketsDB.getConnection();
let enabledEventIds: number[];
try {
const rows = await conn.query('SELECT event_id FROM event_ticket_settings');
enabledEventIds = rows.map((r: any) => r.event_id);
} finally {
await conn.end();
}
if (enabledEventIds.length === 0) return [];
const events = await Promise.all(enabledEventIds.map(id => CalendarEventsService.getEventById(id)));
return events
.filter((e): e is NonNullable<typeof e> => e !== null && e.status !== 'DELETED')
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status}))
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
};
/**
* Public-calendar events that could be added to the ticket shop but
* haven't been yet - source list for the "add a concert" picker.
*/
export const listAvailableEventsToAdd = async (): Promise<EventPickerEntry[]> => {
let conn = await NachklangTicketsDB.getConnection();
let enabledEventIds: Set<number>;
try {
const rows = await conn.query('SELECT event_id FROM event_ticket_settings');
enabledEventIds = new Set(rows.map((r: any) => r.event_id));
} finally {
await conn.end();
}
const events = await CalendarEventsService.getAllEventsAdmin(PUBLIC_CALENDAR_ID);
return events
.filter(e => e.status !== 'DELETED' && !enabledEventIds.has(e.eventId))
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status}))
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
};
export const getEventStats = async (eventId: number): Promise<EventStats> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const ticketState = await getEventTicketState(conn, eventId);
const countRows = await conn.query(
`SELECT vc.status, COUNT(DISTINCT vc.code) as cnt
FROM voucher_codes vc
INNER JOIN voucher_code_events vce ON vce.code = vc.code
WHERE vce.event_id = ?
GROUP BY vc.status`,
[eventId]
);
let unusedCodes = 0, redeemedCodes = 0, voidCodes = 0;
for (const row of countRows) {
if (row.status === 'UNUSED') unusedCodes = Number(row.cnt);
if (row.status === 'REDEEMED') redeemedCodes = Number(row.cnt);
if (row.status === 'VOID') voidCodes = Number(row.cnt);
}
return {
eventId,
capacity: ticketState.capacity,
redemptionDeadline: ticketState.redemptionDeadline,
collectAddress: ticketState.collectAddress,
requireAddress: ticketState.requireAddress,
guestsUsed: ticketState.guestsUsed,
spotsRemaining: ticketState.spotsRemaining,
unusedCodes,
redeemedCodes,
voidCodes
};
} finally {
await conn.end();
}
};
/**
* Upsert - also doubles as "add this event to the ticket shop" when called
* with all-default values (see listEventsForPicker).
*/
export const setEventSettings = async (eventId: number, settings: Omit<EventTicketSettings, 'eventId'>): Promise<void> => {
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address)`,
[eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0]
);
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type RemoveEventResult = 'REMOVED' | 'HAS_VOUCHERS';
/**
* Removes an event from the ticket shop (deletes its settings row, so it
* drops out of listEventsForPicker and reappears in the "add" list).
* Refuses if vouchers already reference it - existing vouchers/redemptions
* stay valid and keep working even for an event no longer offered for new
* voucher generation, so this only blocks removing one that's still in use.
*/
export const removeEvent = async (eventId: number): Promise<RemoveEventResult> => {
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
const voucherRows = await conn.query('SELECT 1 FROM voucher_code_events WHERE event_id = ? LIMIT 1', [eventId]);
if (voucherRows.length > 0) {
await conn.rollback();
return 'HAS_VOUCHERS';
}
await conn.query('DELETE FROM event_ticket_settings WHERE event_id = ?', [eventId]);
await conn.commit();
return 'REMOVED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
@@ -0,0 +1,289 @@
import express, {Request, Response} from 'express';
import * as RedemptionsAdminService from './redemptions.admin.service.js';
import {sendServerError} from '../tickets.errors.js';
export const redemptionsAdminRouter = express.Router();
/**
* @swagger
* /tickets/admin/redemptions:
* get:
* summary: List redemptions (admin)
* description: Filterable by event and status (ACTIVE/UNDONE).
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query
* name: eventId
* schema:
* type: integer
* - in: query
* name: status
* schema:
* $ref: '#/components/schemas/RedemptionStatus'
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/RedemptionSummary'
* 401:
* description: Unauthorized
*/
redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
const eventId = req.query.eventId !== undefined ? Number(req.query.eventId) : undefined;
const status = req.query.status as any;
res.status(200).send(await RedemptionsAdminService.listRedemptions({eventId, status}));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/redemptions/{redemptionId}:
* get:
* summary: Get a single redemption (admin)
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Success
* 404:
* description: Unknown redemption
* 401:
* description: Unauthorized
* patch:
* summary: Edit a redemption's contact info and/or guest list
* description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* contactName:
* type: string
* contactEmail:
* type: string
* contactAddress:
* type: string
* nullable: true
* guestNames:
* type: array
* items:
* type: string
* reason:
* type: string
* responses:
* 200:
* description: Edited
* 404:
* description: Unknown redemption
* 409:
* description: Not active, exceeds max guests, or exceeds remaining capacity
* 401:
* description: Unauthorized
*/
redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => {
try {
const redemption = await RedemptionsAdminService.getRedemption(Number(req.params.redemptionId));
if (!redemption) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(redemption);
} catch (e: any) {
sendServerError(res, e);
}
});
redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Response) => {
try {
const {contactName, contactEmail, contactAddress, guestNames, reason} = req.body || {};
const result = await RedemptionsAdminService.editRedemption(
Number(req.params.redemptionId),
{contactName, contactEmail, contactAddress, guestNames},
res.locals.admin.email,
reason || null
);
switch (result.status) {
case 'EDITED':
res.status(200).send({status: 'OK'});
return;
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'NOT_ACTIVE':
res.status(409).send({status: 'NOT_ACTIVE', message: 'This redemption is not active.'});
return;
case 'INVALID_EMAIL':
res.status(400).send({status: 'INVALID_EMAIL', message: 'Die E-Mail-Adresse sieht nicht gültig aus.'});
return;
case 'EXCEEDS_MAX_GUESTS':
res.status(409).send({status: 'EXCEEDS_MAX_GUESTS', maxGuests: result.maxGuests});
return;
case 'CAPACITY_EXCEEDED':
res.status(409).send({status: 'CAPACITY_EXCEEDED', spotsRemaining: result.spotsRemaining});
return;
}
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/redemptions/{redemptionId}/undo:
* post:
* summary: Undo a redemption
* description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* reason:
* type: string
* responses:
* 200:
* description: Undone
* 404:
* description: Unknown redemption
* 409:
* description: Redemption is not active
* 401:
* description: Unauthorized
*/
redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => {
try {
const result = await RedemptionsAdminService.undoRedemption(Number(req.params.redemptionId), res.locals.admin.email, req.body?.reason || null);
if (result === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
if (result === 'NOT_ACTIVE') {
res.status(409).send({status: 'NOT_ACTIVE', message: 'This redemption is not active.'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/redemptions/{redemptionId}/resend-confirmation:
* post:
* summary: Resend the redemption confirmation email
* description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The email was accepted for delivery
* 404:
* description: Unknown redemption
* 409:
* description: Redemption is not active
* 502:
* description: The email relay rejected the send
* 401:
* description: Unauthorized
*/
redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => {
try {
const result = await RedemptionsAdminService.resendRedemptionConfirmation(Number(req.params.redemptionId));
switch (result) {
case 'SENT':
res.status(200).send({status: 'OK'});
return;
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'NOT_ACTIVE':
res.status(409).send({status: 'NOT_ACTIVE', message: 'This redemption is not active.'});
return;
case 'FAILED':
res.status(502).send({status: 'SEND_FAILED', message: 'Die E-Mail konnte nicht versendet werden. Bitte später erneut versuchen.'});
return;
}
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/vouchers/{code}/history:
* get:
* summary: Get a voucher's admin-action audit trail
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: code
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/AuditLogEntry'
* 401:
* description: Unauthorized
*/
export const voucherHistoryRouter = express.Router();
voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => {
try {
res.status(200).send(await RedemptionsAdminService.getAuditHistory(req.params.code));
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,274 @@
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email.js';
import {AuditLogEntry, RedemptionSummary} from '../tickets.interface.js';
import {isValidEmail} from '../tickets.validation.js';
const mapRedemptionRow = (row: any, guests: string[]): RedemptionSummary => ({
redemptionId: row.redemption_id,
code: row.code,
eventId: row.event_id,
status: row.status,
contactName: row.contact_name,
contactEmail: row.contact_email,
contactAddress: row.contact_address,
guestCount: row.guest_count,
guests,
redeemedAt: row.redeemed_at,
confirmationEmailStatus: row.confirmation_email_status ?? null
});
export interface ListRedemptionsFilter {
eventId?: number;
status?: 'ACTIVE' | 'UNDONE';
}
export const listRedemptions = async (filter: ListRedemptionsFilter): Promise<RedemptionSummary[]> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const where: string[] = [];
const params: any[] = [];
if (filter.eventId !== undefined) {
where.push('event_id = ?');
params.push(filter.eventId);
}
if (filter.status) {
where.push('status = ?');
params.push(filter.status);
}
let query = 'SELECT * FROM redemptions';
if (where.length > 0) query += ' WHERE ' + where.join(' AND ');
query += ' ORDER BY redeemed_at DESC';
const rows = await conn.query(query, params);
if (rows.length === 0) return [];
const redemptionIds = rows.map((r: any) => r.redemption_id);
const guestRows = await conn.query(
'SELECT redemption_id, name FROM redemption_guests WHERE redemption_id IN (?) ORDER BY redemption_id, position',
[redemptionIds]
);
const guestsByRedemption = new Map<number, string[]>();
for (const g of guestRows) {
const list = guestsByRedemption.get(g.redemption_id) || [];
list.push(g.name);
guestsByRedemption.set(g.redemption_id, list);
}
return rows.map((row: any) => mapRedemptionRow(row, guestsByRedemption.get(row.redemption_id) || []));
} finally {
await conn.end();
}
};
export const getRedemption = async (redemptionId: number): Promise<RedemptionSummary | null> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const rows = await conn.query('SELECT * FROM redemptions WHERE redemption_id = ?', [redemptionId]);
if (rows.length === 0) return null;
const guestRows = await conn.query('SELECT name FROM redemption_guests WHERE redemption_id = ? ORDER BY position', [redemptionId]);
return mapRedemptionRow(rows[0], guestRows.map((g: any) => g.name));
} finally {
await conn.end();
}
};
export type UndoRedemptionResult = 'UNDONE' | 'NOT_FOUND' | 'NOT_ACTIVE';
/**
* Reopens the code (back to UNUSED) and marks the redemption UNDONE
* (soft-state, not deleted - guest names/contact info stay for the audit
* trail). A later re-redemption of the same code creates a new
* redemptions row rather than reviving this one.
*/
export const undoRedemption = async (redemptionId: number, adminEmail: string, reason: string | null): Promise<UndoRedemptionResult> => {
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
const rows = await conn.query('SELECT * FROM redemptions WHERE redemption_id = ? FOR UPDATE', [redemptionId]);
if (rows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
const redemption = rows[0];
if (redemption.status !== 'ACTIVE') {
await conn.rollback();
return 'NOT_ACTIVE';
}
await conn.query('UPDATE redemptions SET status = ? WHERE redemption_id = ?', ['UNDONE', redemptionId]);
await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['UNUSED', redemption.code]);
await conn.query(
'INSERT INTO voucher_audit_log (code, redemption_id, admin_email, action, reason) VALUES (?,?,?,?,?)',
[redemption.code, redemptionId, adminEmail, 'UNDO', reason]
);
await conn.commit();
return 'UNDONE';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export interface EditRedemptionInput {
contactName?: string;
contactEmail?: string;
contactAddress?: string | null;
guestNames?: string[];
}
export type EditRedemptionResult =
| {status: 'EDITED'}
| {status: 'NOT_FOUND'}
| {status: 'NOT_ACTIVE'}
| {status: 'INVALID_EMAIL'}
| {status: 'EXCEEDS_MAX_GUESTS'; maxGuests: number}
| {status: 'CAPACITY_EXCEEDED'; spotsRemaining: number};
/**
* Direct admin correction of a redemption's contact info and/or guest
* list. Only the fields present in `input` are changed. Growing the guest
* count is re-checked against both the voucher's own max_guests and the
* event's remaining capacity (forUpdate=true, same race-safety approach as
* the public redeem path).
*/
export const editRedemption = async (redemptionId: number, input: EditRedemptionInput, adminEmail: string, reason: string | null): Promise<EditRedemptionResult> => {
if (input.contactEmail !== undefined && !isValidEmail(input.contactEmail)) {
return {status: 'INVALID_EMAIL'};
}
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
const rows = await conn.query('SELECT * FROM redemptions WHERE redemption_id = ? FOR UPDATE', [redemptionId]);
if (rows.length === 0) {
await conn.rollback();
return {status: 'NOT_FOUND'};
}
const before = rows[0];
if (before.status !== 'ACTIVE') {
await conn.rollback();
return {status: 'NOT_ACTIVE'};
}
const changeSummary: Record<string, {before: any; after: any}> = {};
const fields: string[] = [];
const values: any[] = [];
if (input.contactName !== undefined && input.contactName !== before.contact_name) {
changeSummary.contactName = {before: before.contact_name, after: input.contactName};
fields.push('contact_name = ?');
values.push(input.contactName);
}
if (input.contactEmail !== undefined && input.contactEmail !== before.contact_email) {
changeSummary.contactEmail = {before: before.contact_email, after: input.contactEmail};
fields.push('contact_email = ?');
values.push(input.contactEmail);
}
if (input.contactAddress !== undefined && input.contactAddress !== before.contact_address) {
changeSummary.contactAddress = {before: before.contact_address, after: input.contactAddress};
fields.push('contact_address = ?');
values.push(input.contactAddress);
}
if (input.guestNames !== undefined) {
const newCount = input.guestNames.length;
const delta = newCount - before.guest_count;
if (delta > 0) {
const voucherRows = await conn.query('SELECT max_guests FROM voucher_codes WHERE code = ?', [before.code]);
const maxGuests = voucherRows[0].max_guests;
if (newCount > maxGuests) {
await conn.rollback();
return {status: 'EXCEEDS_MAX_GUESTS', maxGuests};
}
const ticketState = await getEventTicketState(conn, before.event_id, true);
if (ticketState.spotsRemaining !== null && delta > ticketState.spotsRemaining) {
await conn.rollback();
return {status: 'CAPACITY_EXCEEDED', spotsRemaining: ticketState.spotsRemaining};
}
}
const oldGuestRows = await conn.query('SELECT name FROM redemption_guests WHERE redemption_id = ? ORDER BY position', [redemptionId]);
changeSummary.guests = {before: oldGuestRows.map((g: any) => g.name), after: input.guestNames};
fields.push('guest_count = ?');
values.push(newCount);
await conn.query('DELETE FROM redemption_guests WHERE redemption_id = ?', [redemptionId]);
for (let i = 0; i < input.guestNames.length; i++) {
await conn.query('INSERT INTO redemption_guests (redemption_id, name, position) VALUES (?,?,?)', [redemptionId, input.guestNames[i], i]);
}
}
if (fields.length > 0) {
values.push(redemptionId);
await conn.query(`UPDATE redemptions SET ${fields.join(', ')} WHERE redemption_id = ?`, values);
}
if (Object.keys(changeSummary).length > 0) {
await conn.query(
'INSERT INTO voucher_audit_log (code, redemption_id, admin_email, action, change_summary, reason) VALUES (?,?,?,?,?,?)',
[before.code, redemptionId, adminEmail, 'EDIT', JSON.stringify(changeSummary), reason]
);
}
await conn.commit();
return {status: 'EDITED'};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type ResendConfirmationResult = 'SENT' | 'FAILED' | 'NOT_FOUND' | 'NOT_ACTIVE';
/**
* Rebuilds and re-sends the redemption confirmation email from the stored
* redemption data, then records the new outcome on the row. Used by the admin
* UI's "resend" action on a redemption whose confirmation email failed. Only
* active redemptions can be resent.
*/
export const resendRedemptionConfirmation = async (redemptionId: number): Promise<ResendConfirmationResult> => {
const redemption = await getRedemption(redemptionId);
if (!redemption) return 'NOT_FOUND';
if (redemption.status !== 'ACTIVE') return 'NOT_ACTIVE';
const sent = await sendRedemptionConfirmation({
eventId: redemption.eventId,
contactName: redemption.contactName,
contactEmail: redemption.contactEmail,
guestNames: redemption.guests
});
await recordConfirmationEmailResult(redemptionId, sent);
return sent ? 'SENT' : 'FAILED';
};
export const getAuditHistory = async (code: string): Promise<AuditLogEntry[]> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const rows = await conn.query('SELECT * FROM voucher_audit_log WHERE code = ? ORDER BY created_at DESC', [code]);
return rows.map((row: any) => ({
auditId: row.audit_id,
code: row.code,
redemptionId: row.redemption_id,
adminEmail: row.admin_email,
action: row.action,
// The mariadb driver already deserializes JSON-typed columns into
// objects - only parse if we somehow got a raw string back.
changeSummary: typeof row.change_summary === 'string' ? JSON.parse(row.change_summary) : (row.change_summary ?? null),
reason: row.reason,
createdAt: row.created_at
}));
} finally {
await conn.end();
}
};
@@ -0,0 +1,249 @@
import express, {Request, Response} from 'express';
import * as VouchersAdminService from './vouchers.admin.service.js';
import {sendServerError} from '../tickets.errors.js';
export const vouchersAdminRouter = express.Router();
/**
* @swagger
* /tickets/admin/vouchers:
* get:
* summary: List vouchers (admin)
* description: Filterable by event and status.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query
* name: eventId
* schema:
* type: integer
* - in: query
* name: status
* schema:
* $ref: '#/components/schemas/VoucherStatus'
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/VoucherCode'
* 401:
* description: Unauthorized
*/
vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
try {
const eventId = req.query.eventId !== undefined ? Number(req.query.eventId) : undefined;
const status = req.query.status as any;
res.status(200).send(await VouchersAdminService.listVouchers({eventId, status}));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/vouchers/wildcard:
* post:
* summary: Batch-generate wildcard codes
* description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [eventIds, quantity]
* properties:
* eventIds:
* type: array
* items:
* type: integer
* maxGuests:
* type: integer
* default: 2
* quantity:
* type: integer
* responses:
* 201:
* description: Created
* content:
* application/json:
* schema:
* type: object
* properties:
* codes:
* type: array
* items:
* type: string
* 400:
* description: Invalid input
* 401:
* description: Unauthorized
*/
vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
try {
const {eventIds, maxGuests, quantity} = req.body || {};
if (!Array.isArray(eventIds) || eventIds.length === 0 || !quantity) {
res.status(400).send({status: 'BAD_REQUEST', message: 'eventIds and quantity are required'});
return;
}
const codes = await VouchersAdminService.generateWildcardBatch(
{eventIds, maxGuests: maxGuests || 2, quantity},
res.locals.admin.email
);
res.status(201).send({codes});
} catch (e: any) {
res.status(400).send({status: 'BAD_REQUEST', message: e.message});
}
});
/**
* @swagger
* /tickets/admin/vouchers/personalized:
* post:
* summary: Bulk-create personalized codes
* description: One code per row (name, email, eligible events, max guests), grouped under one batchId.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [rows]
* properties:
* rows:
* type: array
* items:
* type: object
* required: [name, email, eventIds]
* properties:
* name:
* type: string
* email:
* type: string
* eventIds:
* type: array
* items:
* type: integer
* maxGuests:
* type: integer
* default: 2
* responses:
* 201:
* description: Created
* 400:
* description: Invalid input
* 401:
* description: Unauthorized
*/
vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => {
try {
const rows = (req.body?.rows || []).map((r: any) => ({
name: r.name,
email: r.email,
eventIds: r.eventIds || [],
maxGuests: r.maxGuests || 2
}));
const codes = await VouchersAdminService.generatePersonalizedBatch(rows, res.locals.admin.email);
res.status(201).send({codes});
} catch (e: any) {
res.status(400).send({status: 'BAD_REQUEST', message: e.message});
}
});
/**
* @swagger
* /tickets/admin/vouchers/{code}:
* get:
* summary: Get a single voucher (admin)
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: code
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* 404:
* description: Unknown code
* 401:
* description: Unauthorized
*/
vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
try {
const voucher = await VouchersAdminService.getVoucher(req.params.code);
if (!voucher) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(voucher);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/vouchers/{code}/void:
* post:
* summary: Void an unredeemed code
* description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail.
* tags: [tickets-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: code
* required: true
* schema:
* type: string
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* reason:
* type: string
* responses:
* 200:
* description: Voided
* 404:
* description: Unknown code
* 409:
* description: Code is not in UNUSED status
* 401:
* description: Unauthorized
*/
vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => {
try {
const result = await VouchersAdminService.voidCode(req.params.code, res.locals.admin.email, req.body?.reason || null);
if (result === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
if (result === 'NOT_UNUSED') {
res.status(409).send({status: 'NOT_UNUSED', message: 'Only unused codes can be voided.'});
return;
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,244 @@
import {Guid} from 'guid-typescript';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {generateUniqueCode} from '../tickets.codes.js';
import {VoucherCode, VoucherStatus} from '../tickets.interface.js';
import {isValidEmail} from '../tickets.validation.js';
export interface WildcardGenerateInput {
eventIds: number[];
maxGuests: number;
quantity: number;
}
export interface PersonalizedRowInput {
name: string;
email: string;
eventIds: number[];
maxGuests: number;
}
const mapVoucherRow = (row: any): VoucherCode => ({
code: row.code,
status: row.status,
maxGuests: row.max_guests,
prefillName: row.prefill_name,
prefillEmail: row.prefill_email,
batchId: row.batch_id,
createdByEmail: row.created_by_email,
createdAt: row.created_at,
eligibleEventIds: []
});
/**
* Guards the event allow-list (event_ticket_settings) at the one place
* codes actually get minted - the admin event picker already filters to
* allow-listed events, but that's cosmetic unless generation enforces the
* same rule server-side. Without this, any event_id could be passed
* directly (bypassing the picker) and get a fully-uncapped, no-deadline
* redeemable code minted for a non-concert Calendar event.
*/
const assertEventsAllowListed = async (conn: any, eventIds: number[]): Promise<void> => {
const uniqueIds = [...new Set(eventIds)];
const rows = await conn.query('SELECT event_id FROM event_ticket_settings WHERE event_id IN (?)', [uniqueIds]);
const allowListed = new Set<number>(rows.map((r: any) => r.event_id));
const missing = uniqueIds.filter(id => !allowListed.has(id));
if (missing.length > 0) {
throw new Error(`event(s) not added to the ticket shop yet: ${missing.join(', ')}`);
}
};
/**
* Attaches eligibleEventIds to a list of voucher rows in one extra query,
* rather than N+1 per code.
*/
const attachEligibleEvents = async (conn: any, vouchers: VoucherCode[]): Promise<VoucherCode[]> => {
if (vouchers.length === 0) return vouchers;
const codes = vouchers.map(v => v.code);
const rows = await conn.query('SELECT code, event_id FROM voucher_code_events WHERE code IN (?)', [codes]);
const byCode = new Map<string, number[]>();
for (const row of rows) {
const list = byCode.get(row.code) || [];
list.push(row.event_id);
byCode.set(row.code, list);
}
for (const voucher of vouchers) {
voucher.eligibleEventIds = byCode.get(voucher.code) || [];
}
return vouchers;
};
/**
* Batch-generates N wildcard codes sharing the same eligible events and
* max-guest count. All codes get the same batchId so the admin UI can group
* "codes generated together" (e.g. for printing a sheet for the conductor).
*/
export const generateWildcardBatch = async (input: WildcardGenerateInput, createdByEmail: string): Promise<string[]> => {
if (input.quantity < 1 || input.quantity > 500) {
throw new Error('quantity must be between 1 and 500');
}
if (input.eventIds.length === 0) {
throw new Error('at least one eligible event is required');
}
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
await assertEventsAllowListed(conn, input.eventIds);
const existingRows = await conn.query('SELECT code FROM voucher_codes');
const existingCodes = new Set<string>(existingRows.map((r: any) => r.code));
const batchId = Guid.create().toString();
const codes: string[] = [];
for (let i = 0; i < input.quantity; i++) {
const code = generateUniqueCode(existingCodes);
codes.push(code);
await conn.query(
'INSERT INTO voucher_codes (code, status, max_guests, batch_id, created_by_email) VALUES (?,?,?,?,?)',
[code, 'UNUSED', input.maxGuests, batchId, createdByEmail]
);
for (const eventId of input.eventIds) {
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
}
}
await conn.commit();
return codes;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Bulk-creates personalized codes from a list of rows (repeating-row admin
* UI - see docs/plan-ticket-shop.md). One code per row, all sharing a
* batchId for the submission.
*/
export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], createdByEmail: string): Promise<string[]> => {
if (rows.length === 0) {
throw new Error('at least one row is required');
}
for (const row of rows) {
if (!row.name || !row.email || row.eventIds.length === 0) {
throw new Error('each row requires a name, email, and at least one eligible event');
}
if (!isValidEmail(row.email)) {
throw new Error(`"${row.email}" does not look like a valid email address`);
}
}
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
await assertEventsAllowListed(conn, rows.flatMap(r => r.eventIds));
const existingRows = await conn.query('SELECT code FROM voucher_codes');
const existingCodes = new Set<string>(existingRows.map((r: any) => r.code));
const batchId = Guid.create().toString();
const codes: string[] = [];
for (const row of rows) {
const code = generateUniqueCode(existingCodes);
codes.push(code);
await conn.query(
'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)',
[code, 'UNUSED', row.maxGuests, row.name, row.email, batchId, createdByEmail]
);
for (const eventId of row.eventIds) {
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
}
}
await conn.commit();
return codes;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export interface ListVouchersFilter {
eventId?: number;
status?: VoucherStatus;
}
export const listVouchers = async (filter: ListVouchersFilter): Promise<VoucherCode[]> => {
let conn = await NachklangTicketsDB.getConnection();
try {
let query = 'SELECT vc.* FROM voucher_codes vc';
const params: any[] = [];
const where: string[] = [];
if (filter.eventId !== undefined) {
query += ' INNER JOIN voucher_code_events vce ON vce.code = vc.code';
where.push('vce.event_id = ?');
params.push(filter.eventId);
}
if (filter.status) {
where.push('vc.status = ?');
params.push(filter.status);
}
if (where.length > 0) {
query += ' WHERE ' + where.join(' AND ');
}
query += ' GROUP BY vc.code ORDER BY vc.created_at DESC';
const rows = await conn.query(query, params);
const vouchers = rows.map(mapVoucherRow);
return await attachEligibleEvents(conn, vouchers);
} finally {
await conn.end();
}
};
export const getVoucher = async (code: string): Promise<VoucherCode | null> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const rows = await conn.query('SELECT * FROM voucher_codes WHERE code = ?', [code]);
if (rows.length === 0) return null;
const [voucher] = await attachEligibleEvents(conn, [mapVoucherRow(rows[0])]);
return voucher;
} finally {
await conn.end();
}
};
export type VoidCodeResult = 'VOIDED' | 'NOT_FOUND' | 'NOT_UNUSED';
export const voidCode = async (code: string, adminEmail: string, reason: string | null): Promise<VoidCodeResult> => {
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.beginTransaction();
const rows = await conn.query('SELECT status FROM voucher_codes WHERE code = ?', [code]);
if (rows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
if (rows[0].status !== 'UNUSED') {
await conn.rollback();
return 'NOT_UNUSED';
}
await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['VOID', code]);
await conn.query(
'INSERT INTO voucher_audit_log (code, redemption_id, admin_email, action, reason) VALUES (?,NULL,?,?,?)',
[code, adminEmail, 'VOID', reason]
);
await conn.commit();
return 'VOIDED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
+135
View File
@@ -0,0 +1,135 @@
import express, {Request, Response} from 'express';
import * as VoucherPublicService from './voucher.public.service.js';
import {sendServerError} from '../tickets.errors.js';
import {hashIp, redeemLimiter, validateLimiter} from '../tickets.ratelimit.js';
export const publicRouter = express.Router();
publicRouter.get('/', async (req: Request, res: Response) => {
res.status(200).send('Nachklang e.V. Tickets API Endpoint');
});
const rateLimitGuard = (req: Request, res: Response, limiter: typeof validateLimiter): string | null => {
const ipHash = hashIp(req.ip || '');
if (limiter.isRateLimited(ipHash)) {
res.status(429).send({status: 'RATE_LIMITED', message: 'Zu viele Anfragen. Bitte versuche es später erneut.'});
return null;
}
return ipHash;
};
/**
* @swagger
* /tickets/voucher/{code}:
* get:
* summary: Validate a voucher code
* description: Returns status, prefill data, and eligible events (with deadline/capacity state) for a code. Rate-limited per IP.
* tags: [tickets]
* parameters:
* - in: path
* name: code
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/VoucherValidation'
* 404:
* description: Unknown code
* 429:
* description: Rate limited
*/
publicRouter.get('/voucher/:code', async (req: Request, res: Response) => {
try {
const ipHash = rateLimitGuard(req, res, validateLimiter);
if (!ipHash) return;
validateLimiter.recordRequest(ipHash);
const voucher = await VoucherPublicService.validateVoucher(req.params.code.toUpperCase());
if (!voucher) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(200).send(voucher);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/voucher/{code}/redeem:
* post:
* summary: Redeem a voucher code
* description: Marks the code redeemed, records the redemption, and sends a confirmation email with an .ics attachment. Rate-limited per IP.
* tags: [tickets]
* parameters:
* - in: path
* name: code
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/RedeemRequest'
* responses:
* 200:
* description: Redeemed
* 400:
* description: Invalid request
* 404:
* description: Unknown code
* 409:
* description: Code already used, event no longer eligible, deadline passed, or capacity exceeded
* 429:
* description: Rate limited
*/
publicRouter.post('/voucher/:code/redeem', async (req: Request, res: Response) => {
try {
const ipHash = rateLimitGuard(req, res, redeemLimiter);
if (!ipHash) return;
redeemLimiter.recordRequest(ipHash);
const code = req.params.code.toUpperCase();
const {eventId, contactName, contactEmail, contactAddress, guests} = req.body || {};
if (!eventId || !contactName || !contactEmail || !Array.isArray(guests)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'eventId, contactName, contactEmail, and guests are required'});
return;
}
const result = await VoucherPublicService.redeemVoucher(code, {eventId, contactName, contactEmail, contactAddress, guests});
switch (result.status) {
case 'OK':
res.status(200).send({status: 'OK', redemptionId: result.redemptionId});
return;
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'ALREADY_USED':
res.status(409).send({status: 'ALREADY_USED', message: 'Dieser Code wurde bereits eingelöst.'});
return;
case 'INVALID_EVENT':
res.status(409).send({status: 'INVALID_EVENT', message: 'Dieses Konzert ist für diesen Code nicht verfügbar.'});
return;
case 'DEADLINE_PASSED':
res.status(409).send({status: 'DEADLINE_PASSED', message: 'Die Anmeldefrist für dieses Konzert ist abgelaufen.'});
return;
case 'CAPACITY_EXCEEDED':
res.status(409).send({status: 'CAPACITY_EXCEEDED', message: 'Nicht genügend freie Plätze für dieses Konzert.', spotsRemaining: result.spotsRemaining});
return;
case 'ADDRESS_REQUIRED':
res.status(409).send({status: 'ADDRESS_REQUIRED', message: 'Für dieses Konzert ist eine Adresse erforderlich.'});
return;
}
} catch (e: any) {
res.status(400).send({status: 'BAD_REQUEST', message: e.message});
}
});
@@ -0,0 +1,184 @@
import * as EventsService from '../../calendar/events/events.service.js';
import logger from '../../../middleware/logger.js';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email.js';
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface.js';
import {isValidEmail} from '../tickets.validation.js';
/**
* Builds the eligible-events list for a code: for each event it's linked
* to, merges live Calendar event details with the Tickets module's own
* capacity/deadline state. Events deleted from the calendar since the code
* was generated are silently skipped rather than erroring. DRAFT events are
* deliberately still eligible - vouchers are sometimes sent out before a
* concert is publicly announced (see docs/plan-ticket-shop.md), so only
* DELETED is excluded here, not draft/unpublished status.
*/
export const validateVoucher = async (code: string): Promise<VoucherValidation | null> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const voucherRows = await conn.query('SELECT * FROM voucher_codes WHERE code = ?', [code]);
if (voucherRows.length === 0) {
return null;
}
const voucher = voucherRows[0];
const eventIdRows = await conn.query('SELECT event_id FROM voucher_code_events WHERE code = ?', [code]);
const eligibleEvents: EligibleEvent[] = [];
const now = new Date();
for (const row of eventIdRows) {
const eventId = row.event_id;
const event = await EventsService.getEventById(eventId);
if (!event || event.status === 'DELETED') continue;
const ticketState = await getEventTicketState(conn, eventId);
eligibleEvents.push({
eventId,
name: event.name,
startDateTime: event.startDateTime,
location: event.location,
deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline),
isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0,
spotsRemaining: ticketState.spotsRemaining,
collectAddress: ticketState.collectAddress,
requireAddress: ticketState.requireAddress
});
}
eligibleEvents.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
return {
code: voucher.code,
status: voucher.status,
maxGuests: voucher.max_guests,
prefillName: voucher.prefill_name,
prefillEmail: voucher.prefill_email,
eligibleEvents
};
} finally {
await conn.end();
}
};
export type RedeemResult =
| {status: 'OK'; redemptionId: number}
| {status: 'NOT_FOUND'}
| {status: 'ALREADY_USED'}
| {status: 'INVALID_EVENT'}
| {status: 'DEADLINE_PASSED'}
| {status: 'CAPACITY_EXCEEDED'; spotsRemaining: number}
| {status: 'ADDRESS_REQUIRED'};
export const redeemVoucher = async (code: string, request: RedeemRequest): Promise<RedeemResult> => {
if (!request.contactName || !request.contactEmail || !Array.isArray(request.guests) || request.guests.length === 0) {
throw new Error('contactName, contactEmail, and at least one guest are required');
}
if (!isValidEmail(request.contactEmail)) {
throw new Error('contactEmail does not look like a valid email address');
}
for (const guest of request.guests) {
if (!guest.name) {
throw new Error('every guest requires a name');
}
}
let conn = await NachklangTicketsDB.getConnection();
let redemptionId: number;
let eventId: number;
try {
await conn.beginTransaction();
// Locks the code row so two concurrent requests for the same code
// can't both pass the "still UNUSED" check.
const voucherRows = await conn.query('SELECT * FROM voucher_codes WHERE code = ? FOR UPDATE', [code]);
if (voucherRows.length === 0) {
await conn.rollback();
return {status: 'NOT_FOUND'};
}
const voucher = voucherRows[0];
if (voucher.status !== 'UNUSED') {
await conn.rollback();
return {status: 'ALREADY_USED'};
}
const eligibleRows = await conn.query('SELECT 1 FROM voucher_code_events WHERE code = ? AND event_id = ?', [code, request.eventId]);
if (eligibleRows.length === 0) {
await conn.rollback();
return {status: 'INVALID_EVENT'};
}
// A concert can be canceled/deleted from the calendar after codes were
// issued - without this, such a code would stay silently redeemable.
// DRAFT is deliberately still allowed (see validateVoucher's comment).
const event = await EventsService.getEventById(request.eventId);
if (!event || event.status === 'DELETED') {
await conn.rollback();
return {status: 'INVALID_EVENT'};
}
if (request.guests.length > voucher.max_guests) {
await conn.rollback();
throw new Error(`this code allows at most ${voucher.max_guests} guests`);
}
// forUpdate=true serializes concurrent redemptions against this event
// so the capacity check below can't race past a hard cap.
const ticketState = await getEventTicketState(conn, request.eventId, true);
const now = new Date();
if (ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline)) {
await conn.rollback();
return {status: 'DEADLINE_PASSED'};
}
if (ticketState.spotsRemaining !== null && request.guests.length > ticketState.spotsRemaining) {
await conn.rollback();
return {status: 'CAPACITY_EXCEEDED', spotsRemaining: ticketState.spotsRemaining};
}
if (ticketState.requireAddress && !request.contactAddress?.trim()) {
await conn.rollback();
return {status: 'ADDRESS_REQUIRED'};
}
const contactAddress = ticketState.collectAddress ? (request.contactAddress || null) : null;
const redemptionRes = await conn.query(
'INSERT INTO redemptions (code, event_id, contact_name, contact_email, contact_address, guest_count) VALUES (?,?,?,?,?,?) RETURNING redemption_id',
[code, request.eventId, request.contactName, request.contactEmail, contactAddress, request.guests.length]
);
redemptionId = redemptionRes[0].redemption_id;
eventId = request.eventId;
for (let i = 0; i < request.guests.length; i++) {
await conn.query('INSERT INTO redemption_guests (redemption_id, name, position) VALUES (?,?,?)', [redemptionId, request.guests[i].name, i]);
}
await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['REDEEMED', code]);
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
// Sent after commit, mirroring the Calendar/Feedback convention: a mail
// delivery failure shouldn't roll back a successful redemption, and the
// guest has in fact already secured their spot. The send itself no longer
// throws on a delivery problem; its result is recorded on the redemption
// so staff can spot and resend a failed confirmation from the admin UI.
try {
const sent = await sendRedemptionConfirmation({
eventId,
contactName: request.contactName,
contactEmail: request.contactEmail,
guestNames: request.guests.map(g => g.name)
});
await recordConfirmationEmailResult(redemptionId, sent);
} catch (e: any) {
logger.error('Redemption ' + redemptionId + ' committed but the confirmation email step failed: ' + e.message);
}
return {status: 'OK', redemptionId};
};
+63
View File
@@ -0,0 +1,63 @@
import express from 'express';
import * as UserService from '../calendar/users/users.service.js';
import {sendServerError} from './tickets.errors.js';
/**
* Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in
* the tickets module that knows how admin authentication works. No route
* handler and no service outside this file may import users.service, read
* session headers, or touch bcrypt.
*
* Today: reuses the existing Calendar users/sessions mechanism. Any
* activated @nachklang.art account may administer vouchers - no roles, same
* policy as Feedback (see docs/plan-ticket-shop.md). A dedicated
* roles/permissions model is explicitly out of scope for v1.
*
* Explicitly forbidden: accepting sessionId/sessionKey from query
* parameters - headers only (see DEFERRED_SECURITY.md item 1).
*/
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
try {
const identity = await activeAuthenticator(req);
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
+41
View File
@@ -0,0 +1,41 @@
export interface EventTicketState {
eventId: number;
capacity: number | null;
redemptionDeadline: Date | null;
collectAddress: boolean;
requireAddress: boolean;
guestsUsed: number;
spotsRemaining: number | null;
}
/**
* Reads an event's voucher settings + live guest count within the caller's
* connection/transaction. Absence of a settings row means uncapped/no
* deadline/no address collection - the "absence over sentinels" convention
* also used by the Feedback module.
*
* Pass forUpdate=true from inside the redeem transaction to lock the
* settings row for the duration of that transaction, serializing concurrent
* redemptions against the same event so the guestsUsed sum computed here
* stays correct even under a last-spot race. Events with no settings row
* (uncapped) don't need this - there's no cap to race against.
*/
export const getEventTicketState = async (conn: any, eventId: number, forUpdate = false): Promise<EventTicketState> => {
const settingsQuery = `SELECT capacity, redemption_deadline, collect_address, require_address FROM event_ticket_settings WHERE event_id = ?${forUpdate ? ' FOR UPDATE' : ''}`;
const settingsRows = await conn.query(settingsQuery, [eventId]);
const capacity = settingsRows.length > 0 ? settingsRows[0].capacity : null;
const redemptionDeadline = settingsRows.length > 0 ? settingsRows[0].redemption_deadline : null;
const collectAddress = settingsRows.length > 0 ? !!settingsRows[0].collect_address : false;
// Only meaningful when collectAddress is also true - the field isn't
// shown/collected at all otherwise, so "required" is moot.
const requireAddress = collectAddress && settingsRows.length > 0 ? !!settingsRows[0].require_address : false;
const usedRows = await conn.query(
"SELECT COALESCE(SUM(guest_count), 0) as used FROM redemptions WHERE event_id = ? AND status = 'ACTIVE'",
[eventId]
);
const guestsUsed = Number(usedRows[0].used);
const spotsRemaining = capacity === null ? null : Math.max(0, capacity - guestsUsed);
return {eventId, capacity, redemptionDeadline, collectAddress, requireAddress, guestsUsed, spotsRemaining};
};
+38
View File
@@ -0,0 +1,38 @@
import * as crypto from 'crypto';
// Excludes 0/O, 1/I/L to avoid look-alike confusion when a code is
// hand-written, read aloud, or typed from a printed fallback under a QR
// code. 31 symbols * 8 chars ≈ 39.6 bits of entropy - effectively
// unguessable combined with rate-limiting on the redeem endpoint.
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 8;
/**
* Generates a single random code. Uses crypto.randomInt (uniform, no
* modulo bias) rather than Math.random() since these gate a real-world
* concert invitation.
*/
export const generateCode = (): string => {
let code = '';
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)];
}
return code;
};
/**
* Generates a code guaranteed not to collide with any row `existingCodes`
* already contains, tries up to `maxAttempts` times before giving up.
* Collisions are astronomically unlikely at this entropy - this exists as
* a correctness backstop, not because collisions are expected.
*/
export const generateUniqueCode = (existingCodes: Set<string>, maxAttempts = 20): string => {
for (let i = 0; i < maxAttempts; i++) {
const candidate = generateCode();
if (!existingCodes.has(candidate)) {
existingCodes.add(candidate);
return candidate;
}
}
throw new Error('Could not generate a unique voucher code after ' + maxAttempts + ' attempts');
};
@@ -0,0 +1,76 @@
import * as EventsService from '../calendar/events/events.service.js';
import * as IcalService from '../calendar/events/icalgenerator.service.js';
import {MailService} from '../../common/common.mail.js';
import logger from '../../middleware/logger.js';
import {NachklangTicketsDB} from './Tickets.db.js';
export type ConfirmationEmailStatus = 'SENT' | 'FAILED';
// The redemption confirmation email is built and sent from here so the public
// redeem path and the admin "resend" action share one copy of the German text
// and the .ics attachment logic.
const formatGermanDateTime = (date: Date): string =>
new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'Europe/Berlin'
}).format(date);
export interface ConfirmationRecipient {
eventId: number;
contactName: string;
contactEmail: string;
guestNames: string[];
}
/**
* Sends the redemption confirmation email for one redemption. Returns whether
* the mail was accepted by the relay. Never throws: a missing event is treated
* as "not sent", and MailService.sendMail already swallows delivery failures.
*/
export const sendRedemptionConfirmation = async (recipient: ConfirmationRecipient): Promise<boolean> => {
const event = await EventsService.getEventById(recipient.eventId);
if (!event) {
logger.error('Confirmation email skipped: event ' + recipient.eventId + ' no longer exists');
return false;
}
const guestList = recipient.guestNames.map(name => `- ${name}`).join('\n');
const body =
`Hallo ${recipient.contactName},\n\n` +
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
`Ort: ${event.location}\n\n` +
`Angemeldete Gäste:\n${guestList}\n\n` +
`Wir freuen uns auf dich!\n\nDein Nachklang-Team`;
let icsAttachment;
try {
const ics = await IcalService.convertToIcal([event]);
icsAttachment = [{filename: 'konzert.ics', content: ics, contentType: 'text/calendar'}];
} catch (e: any) {
// Non-fatal: the confirmation still goes out, just without the calendar file.
logger.warn('Confirmation email for event ' + recipient.eventId + ' sent without .ics attachment: ' + e?.message);
icsAttachment = undefined;
}
return MailService.sendMail(recipient.contactEmail, `Bestätigung: ${event.name}`, body, {attachments: icsAttachment});
};
/**
* Records the outcome of a confirmation-email send on the redemption row so the
* admin UI can flag failures. Best-effort: a failure to write the flag is
* logged, never thrown - the redemption itself already succeeded.
*/
export const recordConfirmationEmailResult = async (redemptionId: number, sent: boolean): Promise<void> => {
const status: ConfirmationEmailStatus = sent ? 'SENT' : 'FAILED';
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.query('UPDATE redemptions SET confirmation_email_status = ? WHERE redemption_id = ?', [status, redemptionId]);
} catch (err: any) {
logger.error('Could not record confirmation email status for redemption ' + redemptionId + ': ' + err?.message);
} finally {
await conn.end();
}
};
+18
View File
@@ -0,0 +1,18 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
/**
* The tickets module's standard catch-block response: log with a reference
* guid, never leak the real error message to the client. Mirrors the
* Feedback module's feedback.errors.ts convention.
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
+307
View File
@@ -0,0 +1,307 @@
/**
* @swagger
* components:
* schemas:
* VoucherStatus:
* type: string
* enum: [UNUSED, REDEEMED, VOID]
* RedemptionStatus:
* type: string
* enum: [ACTIVE, UNDONE]
* EligibleEvent:
* type: object
* required: [eventId, name, startDateTime, location, deadlinePassed, isFull, collectAddress, requireAddress]
* properties:
* eventId:
* type: integer
* example: 42
* name:
* type: string
* example: "Adventskonzert 2026"
* startDateTime:
* type: string
* format: date-time
* location:
* type: string
* deadlinePassed:
* type: boolean
* isFull:
* type: boolean
* spotsRemaining:
* type: integer
* nullable: true
* description: null when the event has no capacity cap set (uncapped)
* collectAddress:
* type: boolean
* requireAddress:
* type: boolean
* VoucherValidation:
* type: object
* required: [code, status, maxGuests, eligibleEvents]
* properties:
* code:
* type: string
* example: "K7F3M9QX"
* status:
* $ref: '#/components/schemas/VoucherStatus'
* maxGuests:
* type: integer
* example: 2
* prefillName:
* type: string
* nullable: true
* prefillEmail:
* type: string
* nullable: true
* eligibleEvents:
* type: array
* items:
* $ref: '#/components/schemas/EligibleEvent'
* RedeemGuest:
* type: object
* required: [name]
* properties:
* name:
* type: string
* example: "Erika Mustermann"
* RedeemRequest:
* type: object
* required: [eventId, contactName, contactEmail, guests]
* properties:
* eventId:
* type: integer
* contactName:
* type: string
* contactEmail:
* type: string
* contactAddress:
* type: string
* nullable: true
* guests:
* type: array
* items:
* $ref: '#/components/schemas/RedeemGuest'
* RedemptionSummary:
* type: object
* required: [redemptionId, code, eventId, status, contactName, contactEmail, guestCount, guests, redeemedAt]
* properties:
* redemptionId:
* type: integer
* code:
* type: string
* eventId:
* type: integer
* status:
* $ref: '#/components/schemas/RedemptionStatus'
* contactName:
* type: string
* contactEmail:
* type: string
* contactAddress:
* type: string
* nullable: true
* guestCount:
* type: integer
* guests:
* type: array
* items:
* type: string
* redeemedAt:
* type: string
* format: date-time
* confirmationEmailStatus:
* type: string
* enum: [SENT, FAILED]
* nullable: true
* description: Outcome of the redemption confirmation email. null until the send resolves.
* VoucherCode:
* type: object
* required: [code, status, maxGuests, createdByEmail, createdAt, eligibleEventIds]
* properties:
* code:
* type: string
* status:
* $ref: '#/components/schemas/VoucherStatus'
* maxGuests:
* type: integer
* prefillName:
* type: string
* nullable: true
* prefillEmail:
* type: string
* nullable: true
* batchId:
* type: string
* nullable: true
* createdByEmail:
* type: string
* createdAt:
* type: string
* format: date-time
* eligibleEventIds:
* type: array
* items:
* type: integer
* EventTicketSettings:
* type: object
* required: [eventId, collectAddress, requireAddress]
* properties:
* eventId:
* type: integer
* capacity:
* type: integer
* nullable: true
* redemptionDeadline:
* type: string
* format: date-time
* nullable: true
* collectAddress:
* type: boolean
* requireAddress:
* type: boolean
* description: Only meaningful when collectAddress is true.
* EventStats:
* type: object
* required: [eventId, collectAddress, requireAddress, guestsUsed, unusedCodes, redeemedCodes, voidCodes]
* properties:
* eventId:
* type: integer
* capacity:
* type: integer
* nullable: true
* redemptionDeadline:
* type: string
* format: date-time
* nullable: true
* collectAddress:
* type: boolean
* requireAddress:
* type: boolean
* guestsUsed:
* type: integer
* spotsRemaining:
* type: integer
* nullable: true
* unusedCodes:
* type: integer
* redeemedCodes:
* type: integer
* voidCodes:
* type: integer
* AuditLogEntry:
* type: object
* required: [auditId, code, adminEmail, action, createdAt]
* properties:
* auditId:
* type: integer
* code:
* type: string
* redemptionId:
* type: integer
* nullable: true
* adminEmail:
* type: string
* action:
* type: string
* enum: [EDIT, VOID, UNDO]
* changeSummary:
* type: object
* nullable: true
* reason:
* type: string
* nullable: true
* createdAt:
* type: string
* format: date-time
*/
export type VoucherStatus = 'UNUSED' | 'REDEEMED' | 'VOID';
export type RedemptionStatus = 'ACTIVE' | 'UNDONE';
export type AuditAction = 'EDIT' | 'VOID' | 'UNDO';
export interface EligibleEvent {
eventId: number;
name: string;
startDateTime: Date;
location: string;
deadlinePassed: boolean;
isFull: boolean;
spotsRemaining: number | null;
collectAddress: boolean;
requireAddress: boolean;
}
export interface VoucherValidation {
code: string;
status: VoucherStatus;
maxGuests: number;
prefillName: string | null;
prefillEmail: string | null;
eligibleEvents: EligibleEvent[];
}
export interface RedeemGuest {
name: string;
}
export interface RedeemRequest {
eventId: number;
contactName: string;
contactEmail: string;
contactAddress?: string | null;
guests: RedeemGuest[];
}
export interface RedemptionSummary {
redemptionId: number;
code: string;
eventId: number;
status: RedemptionStatus;
contactName: string;
contactEmail: string;
contactAddress: string | null;
guestCount: number;
guests: string[];
redeemedAt: Date;
// null until the post-redemption confirmation email send resolves.
confirmationEmailStatus: 'SENT' | 'FAILED' | null;
}
export interface VoucherCode {
code: string;
status: VoucherStatus;
maxGuests: number;
prefillName: string | null;
prefillEmail: string | null;
batchId: string | null;
createdByEmail: string;
createdAt: Date;
eligibleEventIds: number[];
}
export interface EventTicketSettings {
eventId: number;
capacity: number | null;
redemptionDeadline: Date | null;
collectAddress: boolean;
requireAddress: boolean;
}
export interface EventStats extends EventTicketSettings {
guestsUsed: number;
spotsRemaining: number | null;
unusedCodes: number;
redeemedCodes: number;
voidCodes: number;
}
export interface AuditLogEntry {
auditId: number;
code: string;
redemptionId: number | null;
adminEmail: string;
action: AuditAction;
changeSummary: Record<string, unknown> | null;
reason: string | null;
createdAt: Date;
}
+68
View File
@@ -0,0 +1,68 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
dotenv.config();
const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.TICKETS_RATE_LIMIT_WINDOW_MIN || '10', 10);
const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MIN * 60 * 1000;
// Salted per-process (not persisted/configured) - these limiters are
// in-memory-only with no DB backstop, so the salt only needs to survive
// for the current process's lifetime, unlike Feedback's FEEDBACK_IP_SALT
// which also salts a persisted ip_hash column.
const IP_SALT = crypto.randomBytes(32).toString('hex');
export const hashIp = (ip: string): string => {
return crypto.createHash('sha256').update(IP_SALT + ip).digest('hex');
};
/**
* Two independent budgets, not one shared counter: validating a code (GET)
* is a cheap, repeatable lookup a guest's own browser triggers on every
* page load/reload/back-navigation of their redemption link - a shared
* budget with redeem meant a guest could exhaust it just by reloading the
* page a few times before ever submitting. Redeeming (POST) is the
* sensitive, code-consuming action and stays tightly limited; validating
* is limited too (it's still the enumeration vector for guessing codes),
* just with a much larger allowance headroomed for normal page-reload
* behaviour.
*/
const createLimiter = (max: number) => {
const recentRequests = new Map<string, number[]>();
const pruneOld = (timestamps: number[], now: number): number[] => {
return timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
};
const sweepInterval = setInterval(() => {
const now = Date.now();
for (const [ipHash, timestamps] of recentRequests) {
if (pruneOld(timestamps, now).length === 0) {
recentRequests.delete(ipHash);
}
}
}, RATE_LIMIT_WINDOW_MS);
sweepInterval.unref();
return {
isRateLimited: (ipHash: string): boolean => {
const now = Date.now();
const timestamps = pruneOld(recentRequests.get(ipHash) || [], now);
if (timestamps.length > 0) {
recentRequests.set(ipHash, timestamps);
} else {
recentRequests.delete(ipHash);
}
return timestamps.length >= max;
},
recordRequest: (ipHash: string): void => {
const now = Date.now();
const timestamps = pruneOld(recentRequests.get(ipHash) || [], now);
timestamps.push(now);
recentRequests.set(ipHash, timestamps);
}
};
};
export const validateLimiter = createLimiter(parseInt(process.env.TICKETS_VALIDATE_RATE_LIMIT_MAX || '30', 10));
export const redeemLimiter = createLimiter(parseInt(process.env.TICKETS_REDEEM_RATE_LIMIT_MAX || '10', 10));
+6
View File
@@ -0,0 +1,6 @@
// Intentionally permissive - "looks like an email" (something@something.tld),
// not full RFC 5322 validation. Good enough to catch typos without rejecting
// real addresses RFC 5322 edge cases would.
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const isValidEmail = (email: string): boolean => EMAIL_REGEX.test(email.trim());
+116
View File
@@ -0,0 +1,116 @@
// common.mail relays one email through the Salesforce org (see
// src/common/common.mail.ts). These tests mock the shared Salesforce client so
// no network is touched, and check: the payload shape, base64 attachment
// encoding, the attachment size cap, the retry-once-on-transient-failure
// behaviour, and that a delivery failure is swallowed (returns false, never
// throws).
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/common/salesforce.client.js');
vi.mock('../../src/middleware/logger.js', () => ({
__esModule: true,
default: {info: vi.fn(), warn: vi.fn(), error: vi.fn()}
}));
import {MailService} from '../../src/common/common.mail.js';
import {salesforceApexRestPost, salesforceEnabled} from '../../src/common/salesforce.client.js';
const mockPost = salesforceApexRestPost as Mock;
const mockEnabled = salesforceEnabled as Mock;
const httpError = (status: number, body?: any): any => {
const err: any = new Error('request failed with ' + status);
err.response = {status, data: body};
return err;
};
beforeEach(() => {
vi.clearAllMocks();
mockEnabled.mockReturnValue(true);
mockPost.mockResolvedValue({status: 'SENT'});
});
describe('MailService.sendMail', () => {
it('returns false without a callout when Salesforce is disabled', async () => {
mockEnabled.mockReturnValue(false);
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
});
it('posts the email to the Apex REST endpoint and returns true on success', async () => {
const result = await MailService.sendMail('guest@example.com', 'Bestätigung', 'Hallo', {html: '<p>Hallo</p>'});
expect(result).toBe(true);
expect(mockPost).toHaveBeenCalledWith('/services/apexrest/email/send', {
to: 'guest@example.com',
subject: 'Bestätigung',
textBody: 'Hallo',
htmlBody: '<p>Hallo</p>',
attachments: []
});
});
it('sends htmlBody as null when no HTML is given', async () => {
await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(mockPost).toHaveBeenCalledWith('/services/apexrest/email/send', expect.objectContaining({htmlBody: null}));
});
it('base64-encodes attachments', async () => {
await MailService.sendMail('guest@example.com', 'Hi', 'Hallo', {
attachments: [{filename: 'konzert.ics', content: 'BEGIN:VCALENDAR', contentType: 'text/calendar'}]
});
expect(mockPost).toHaveBeenCalledWith(
'/services/apexrest/email/send',
expect.objectContaining({
attachments: [{
filename: 'konzert.ics',
contentType: 'text/calendar',
contentBase64: Buffer.from('BEGIN:VCALENDAR', 'utf-8').toString('base64')
}]
})
);
});
it('rejects an attachment over the size cap without sending', async () => {
const huge = Buffer.alloc(3 * 1024 * 1024 + 1);
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo', {
attachments: [{filename: 'big.pdf', content: huge}]
});
expect(result).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
});
it('retries once on a 5xx and returns false when the retry also fails', async () => {
mockPost.mockRejectedValue(httpError(503));
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(false);
expect(mockPost).toHaveBeenCalledTimes(2);
});
it('retries once on a network error (no response) then succeeds', async () => {
mockPost.mockRejectedValueOnce(new Error('socket hang up')).mockResolvedValueOnce({status: 'SENT'});
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(true);
expect(mockPost).toHaveBeenCalledTimes(2);
});
it('does not retry on a 4xx (e.g. the 429 limit response) and returns false', async () => {
mockPost.mockRejectedValue(httpError(429, {errorCode: 'LIMIT_REACHED'}));
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(false);
expect(mockPost).toHaveBeenCalledTimes(1);
});
});
+125
View File
@@ -0,0 +1,125 @@
// salesforce.client caches the OAuth token at module scope, so every test
// resets the module registry for a clean cache and re-imports axios + the
// module under test after the reset (same approach as
// test/feedback/salesforce.service.test.ts). Mocked modules survive
// vi.resetModules(), so mock state is reset explicitly in beforeEach.
import {vi, describe, it, expect, beforeEach, afterAll} from 'vitest';
vi.mock('axios');
const freshImports = async () => {
const axios: any = (await import('axios')).default;
const {salesforceApexRestPost, salesforceEnabled} = await import('../../src/common/salesforce.client.js');
return {axios, salesforceApexRestPost, salesforceEnabled};
};
const ORIGINAL_ENV = {...process.env};
const ENABLED_ENV = {
...ORIGINAL_ENV,
SALESFORCE_ENABLED: 'true',
SALESFORCE_API_URL: 'https://example.my.salesforce.com',
SALESFORCE_CLIENT_ID: 'client-id',
SALESFORCE_CLIENT_SECRET: 'client-secret'
};
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
process.env = {...ENABLED_ENV};
});
afterAll(() => {
process.env = {...ORIGINAL_ENV};
});
describe('salesforceEnabled', () => {
it('is true only when SALESFORCE_ENABLED === "true"', async () => {
process.env.SALESFORCE_ENABLED = 'true';
expect((await freshImports()).salesforceEnabled()).toBe(true);
vi.resetModules();
process.env.SALESFORCE_ENABLED = 'false';
expect((await freshImports()).salesforceEnabled()).toBe(false);
});
});
describe('salesforceApexRestPost', () => {
it('fetches a token, posts to the given Apex REST path, and returns the response body', async () => {
const {axios, salesforceApexRestPost} = await freshImports();
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
return Promise.resolve({data: {ok: true}});
});
const result = await salesforceApexRestPost('/services/apexrest/email/send', {to: 'x@example.com'});
expect(result).toEqual({ok: true});
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/oauth2/token',
expect.any(String),
expect.objectContaining({headers: {'Content-Type': 'application/x-www-form-urlencoded'}})
);
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/apexrest/email/send',
{to: 'x@example.com'},
expect.objectContaining({headers: {Authorization: 'Bearer tok-1'}})
);
});
it('reuses the cached token across calls instead of fetching twice', async () => {
const {axios, salesforceApexRestPost} = await freshImports();
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
return Promise.resolve({data: {}});
});
await salesforceApexRestPost('/services/apexrest/email/send', {});
await salesforceApexRestPost('/services/apexrest/email/send', {});
const tokenCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/services/oauth2/token'));
expect(tokenCalls).toHaveLength(1);
});
it('retries once with a fresh token on a 401, then succeeds', async () => {
const {axios, salesforceApexRestPost} = await freshImports();
let tokenFetches = 0;
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) {
tokenFetches += 1;
return Promise.resolve({data: {access_token: `tok-${tokenFetches}`}});
}
if (tokenFetches === 1) {
const err: any = new Error('Unauthorized');
err.response = {status: 401};
return Promise.reject(err);
}
return Promise.resolve({data: {ok: true}});
});
const result = await salesforceApexRestPost('/services/apexrest/email/send', {});
expect(result).toEqual({ok: true});
expect(tokenFetches).toBe(2);
});
it('does not retry on a non-401 error and rethrows it', async () => {
const {axios, salesforceApexRestPost} = await freshImports();
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
const err: any = new Error('Server error');
err.response = {status: 500};
return Promise.reject(err);
});
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('Server error');
const endpointCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/email/send'));
expect(endpointCalls).toHaveLength(1);
});
it('throws a clear error when client credentials are not configured', async () => {
process.env.SALESFORCE_CLIENT_ID = '';
const {salesforceApexRestPost} = await freshImports();
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('SALESFORCE_CLIENT_ID');
});
});
+59
View File
@@ -0,0 +1,59 @@
import {describe, it, expect} from 'vitest';
import {escapeCsvField} from '../../src/models/feedback/admin/csv.service.js';
import {formatDatetime} from '../../src/models/feedback/feedback.dates.js';
describe('escapeCsvField', () => {
it('passes plain text through unchanged', () => {
expect(escapeCsvField('Abendlied')).toBe('Abendlied');
});
it('converts null/undefined to an empty string', () => {
expect(escapeCsvField(null)).toBe('');
expect(escapeCsvField(undefined)).toBe('');
});
it('converts numbers to strings', () => {
expect(escapeCsvField(5)).toBe('5');
});
it('quotes and doubles internal quotes (RFC 4180)', () => {
expect(escapeCsvField('Sie sagte "Danke"')).toBe('"Sie sagte ""Danke"""');
});
it('quotes a field containing the ; separator', () => {
expect(escapeCsvField('Rheinberger; Bach')).toBe('"Rheinberger; Bach"');
});
it('strips embedded newlines instead of breaking the row', () => {
expect(escapeCsvField('Zeile 1\r\nZeile 2')).toBe('Zeile 1 Zeile 2');
expect(escapeCsvField('Zeile 1\nZeile 2')).toBe('Zeile 1 Zeile 2');
});
it('prefixes formula-injection characters with an apostrophe', () => {
expect(escapeCsvField('=1+1')).toBe("'=1+1");
expect(escapeCsvField('+SUM(A1)')).toBe("'+SUM(A1)");
expect(escapeCsvField('-2')).toBe("'-2");
expect(escapeCsvField('@example')).toBe("'@example");
});
it('does not treat a mid-string = as formula injection', () => {
expect(escapeCsvField('x = y')).toBe('x = y');
});
});
describe('formatDatetime', () => {
it('formats a Date as YYYY-MM-DD HH:mm:ss, not the verbose Date.toString()', () => {
const d = new Date(2026, 7, 2, 21, 59, 21); // month is 0-indexed: August
expect(formatDatetime(d)).toBe('2026-08-02 21:59:21');
expect(formatDatetime(d)).not.toContain('GMT');
});
it('pads single-digit components', () => {
const d = new Date(2026, 0, 5, 3, 4, 5);
expect(formatDatetime(d)).toBe('2026-01-05 03:04:05');
});
it('returns an empty string for null', () => {
expect(formatDatetime(null)).toBe('');
});
});
@@ -0,0 +1,60 @@
import {describe, it, expect} from 'vitest';
import {computeDefaultDeadline, slugBase, slugifyName} from '../../src/models/feedback/admin/events.admin.service.js';
describe('slugifyName', () => {
it('lowercases and hyphenates', () => {
expect(slugifyName('Sommerkonzert 2026')).toBe('sommerkonzert-2026');
});
it('transliterates umlauts', () => {
expect(slugifyName('Frühlingskonzert')).toBe('fruehlingskonzert');
expect(slugifyName('Weihnachtsgrüße')).toBe('weihnachtsgruesse');
});
it('strips punctuation and collapses separators', () => {
expect(slugifyName('Konzert: "Klänge & Farben"!')).toBe('konzert-klaenge-farben');
});
it('trims leading and trailing hyphens', () => {
expect(slugifyName(' -- Herbstkonzert -- ')).toBe('herbstkonzert');
});
});
describe('slugBase', () => {
it('appends the concert year when the name does not already carry it', () => {
expect(slugBase('Sommerkonzert', '2026-08-01')).toBe('sommerkonzert-2026');
});
it('does not double up the year when the name already ends with it', () => {
expect(slugBase('Adventskonzert 2026', '2026-12-06')).toBe('adventskonzert-2026');
});
it('still appends the year when the name contains a different year', () => {
expect(slugBase('Jubiläum 2020', '2026-08-01')).toBe('jubilaeum-2020-2026');
});
});
describe('computeDefaultDeadline', () => {
it('is 14 days after the event date, at 23:59:59', () => {
const deadline = computeDefaultDeadline('2026-08-01');
expect(deadline.getFullYear()).toBe(2026);
expect(deadline.getMonth()).toBe(7); // August = index 7
expect(deadline.getDate()).toBe(15);
expect(deadline.getHours()).toBe(23);
expect(deadline.getMinutes()).toBe(59);
expect(deadline.getSeconds()).toBe(59);
});
it('rolls over the month correctly', () => {
const deadline = computeDefaultDeadline('2026-08-25');
expect(deadline.getMonth()).toBe(8); // September
expect(deadline.getDate()).toBe(8);
});
it('rolls over the year correctly', () => {
const deadline = computeDefaultDeadline('2026-12-25');
expect(deadline.getFullYear()).toBe(2027);
expect(deadline.getMonth()).toBe(0); // January
expect(deadline.getDate()).toBe(8);
});
});
+88
View File
@@ -0,0 +1,88 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import {Request, Response} from 'express';
vi.mock('../../src/models/calendar/users/users.service.js', () => ({
checkSession: vi.fn()
}));
import * as UserService from '../../src/models/calendar/users/users.service.js';
import {requireAdminAuth, sessionHeaderAuthenticator} from '../../src/models/feedback/feedback.auth.js';
const mockCheckSession = UserService.checkSession as Mock;
const makeReq = (headers: Record<string, string>): Request => {
return {
header: (name: string) => headers[name],
ip: '203.0.113.42'
} 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;
};
describe('sessionHeaderAuthenticator', () => {
beforeEach(() => mockCheckSession.mockReset());
it('returns null when headers are missing', async () => {
const identity = await sessionHeaderAuthenticator(makeReq({}));
expect(identity).toBeNull();
expect(mockCheckSession).not.toHaveBeenCalled();
});
it('returns null when checkSession finds no user', async () => {
mockCheckSession.mockResolvedValue(null);
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
expect(identity).toBeNull();
});
it('returns null for a valid session on an inactive account', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: false});
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
expect(identity).toBeNull();
});
it('returns the identity for a valid session on an active account', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
expect(identity).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
it('passes the session id and key from headers through to checkSession, never from query params', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: true});
await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '42', 'X-Session-Key': 'sekret'}));
expect(mockCheckSession).toHaveBeenCalledWith('42', 'sekret', '203.0.113.42');
});
});
describe('requireAdminAuth', () => {
beforeEach(() => mockCheckSession.mockReset());
it('responds 401 and does not call next() when unauthenticated', async () => {
mockCheckSession.mockResolvedValue(null);
const req = makeReq({});
const res = makeRes();
const next = vi.fn();
await requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('sets res.locals.admin and calls next() when authenticated', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
const req = makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'});
const res = makeRes();
const next = vi.fn();
await requireAdminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
});
+17
View File
@@ -0,0 +1,17 @@
import {describe, it, expect} from 'vitest';
import {isHoneypotTriggered} from '../../src/models/feedback/public/public.router.js';
describe('isHoneypotTriggered', () => {
it('is false when the field is absent', () => {
expect(isHoneypotTriggered({})).toBe(false);
});
it('is false when the field is empty', () => {
expect(isHoneypotTriggered({website: ''})).toBe(false);
expect(isHoneypotTriggered({website: ' '})).toBe(false);
});
it('is true when a bot filled it in', () => {
expect(isHoneypotTriggered({website: 'https://spam.example'})).toBe(true);
});
});
@@ -0,0 +1,29 @@
// Isolated from ratelimit.test.ts because it needs to control whether
// FEEDBACK_IP_SALT is present at module-load time, which a real dotenv.config()
// call would silently repopulate from the repo's .env file.
import {vi, describe, it, expect, afterEach} from 'vitest';
vi.mock('dotenv', () => ({config: vi.fn()}));
vi.mock('../../src/models/feedback/Feedback.db.js', () => ({
NachklangFeedbackDB: {getConnection: vi.fn()}
}));
describe('FEEDBACK_IP_SALT enforcement', () => {
const originalSalt = process.env.FEEDBACK_IP_SALT;
afterEach(() => {
process.env.FEEDBACK_IP_SALT = originalSalt;
vi.resetModules();
});
it('throws at import time when FEEDBACK_IP_SALT is unset, rather than silently hashing unsalted', async () => {
vi.resetModules();
delete process.env.FEEDBACK_IP_SALT;
await expect(import('../../src/models/feedback/feedback.ratelimit.js')).rejects.toThrow(/FEEDBACK_IP_SALT/);
});
it('does not throw when FEEDBACK_IP_SALT is set', async () => {
vi.resetModules();
process.env.FEEDBACK_IP_SALT = 'a-real-salt';
await expect(import('../../src/models/feedback/feedback.ratelimit.js')).resolves.toBeDefined();
});
});
+21
View File
@@ -0,0 +1,21 @@
import {describe, it, expect} from 'vitest';
import {hashIp} from '../../src/models/feedback/feedback.ratelimit.js';
describe('hashIp', () => {
it('never returns the raw IP', () => {
const hash = hashIp('203.0.113.42');
expect(hash).not.toContain('203.0.113.42');
});
it('is deterministic for the same input', () => {
expect(hashIp('203.0.113.42')).toBe(hashIp('203.0.113.42'));
});
it('differs for different inputs', () => {
expect(hashIp('203.0.113.42')).not.toBe(hashIp('203.0.113.43'));
});
it('is a 64-char hex SHA-256 digest', () => {
expect(hashIp('203.0.113.42')).toMatch(/^[0-9a-f]{64}$/);
});
});
+110
View File
@@ -0,0 +1,110 @@
import {describe, it, expect} from 'vitest';
import {aggregateReport} from '../../src/models/feedback/admin/reports.admin.service.js';
import {AnswerRow} from '../../src/models/feedback/admin/reports.admin.interface.js';
const eventMeta = {eventId: 1, name: 'Sommerkonzert', eventDate: '2026-08-01', feedbackDeadline: '2026-08-15T23:59:59'};
const emptyNewsletter = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 0};
const row = (overrides: Partial<AnswerRow>): AnswerRow => ({
submissionId: 1,
submittedAt: '2026-08-02T10:00:00.000Z',
questionId: 1,
questionLabel: 'Q',
questionType: 'FREE_TEXT',
songId: null,
songTitle: null,
rating: null,
textAnswer: null,
...overrides
});
describe('aggregateReport - song picks', () => {
it('counts votes per song and sorts by votes descending', () => {
const answers: AnswerRow[] = [
row({submissionId: 1, questionId: 5, questionLabel: 'Lieblingsstück?', questionType: 'SONG_PICK', songId: 10, songTitle: 'Abendlied'}),
row({submissionId: 2, questionId: 5, questionLabel: 'Lieblingsstück?', questionType: 'SONG_PICK', songId: 10, songTitle: 'Abendlied'}),
row({submissionId: 3, questionId: 5, questionLabel: 'Lieblingsstück?', questionType: 'SONG_PICK', songId: 11, songTitle: 'Morgenlied'})
];
const report = aggregateReport(eventMeta, {totalSubmissions: 3, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
expect(report.songPicks).toHaveLength(1);
expect(report.songPicks[0].totalVotes).toBe(3);
expect(report.songPicks[0].results).toEqual([
{songId: 10, title: 'Abendlied', votes: 2},
{songId: 11, title: 'Morgenlied', votes: 1}
]);
});
it('keeps separate SONG_PICK questions in separate groups', () => {
const answers: AnswerRow[] = [
row({questionId: 5, questionLabel: 'Frage A', questionType: 'SONG_PICK', songId: 10, songTitle: 'Abendlied'}),
row({questionId: 6, questionLabel: 'Frage B', questionType: 'SONG_PICK', songId: 11, songTitle: 'Morgenlied'})
];
const report = aggregateReport(eventMeta, {totalSubmissions: 2, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
expect(report.songPicks).toHaveLength(2);
});
});
describe('aggregateReport - song ratings', () => {
it('averages ratings per song, rounded to one decimal, sorted descending', () => {
const answers: AnswerRow[] = [
row({questionId: 7, questionLabel: 'Bewertung', questionType: 'SONG_RATING', songId: 10, songTitle: 'Abendlied', rating: 5}),
row({questionId: 7, questionLabel: 'Bewertung', questionType: 'SONG_RATING', songId: 10, songTitle: 'Abendlied', rating: 4}),
row({questionId: 7, questionLabel: 'Bewertung', questionType: 'SONG_RATING', songId: 11, songTitle: 'Morgenlied', rating: 3})
];
const report = aggregateReport(eventMeta, {totalSubmissions: 2, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
expect(report.songRatings[0].results).toEqual([
{songId: 10, title: 'Abendlied', average: 4.5, count: 2},
{songId: 11, title: 'Morgenlied', average: 3, count: 1}
]);
});
});
describe('aggregateReport - free text', () => {
it('sorts newest first and caps at 500 with hasMore', () => {
const answers: AnswerRow[] = Array.from({length: 501}, (_, i) =>
row({
submissionId: i,
questionId: 9,
questionLabel: 'Sonstiges',
questionType: 'FREE_TEXT',
textAnswer: `Antwort ${i}`,
submittedAt: new Date(2026, 0, 1, 0, 0, i).toISOString()
})
);
const report = aggregateReport(eventMeta, {totalSubmissions: 501, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
expect(report.freeText[0].responses).toHaveLength(500);
expect(report.freeText[0].hasMore).toBe(true);
expect(report.freeText[0].responses[0].text).toBe('Antwort 500');
});
it('does not set hasMore when at or under the cap', () => {
const answers: AnswerRow[] = [row({questionId: 9, questionLabel: 'Sonstiges', questionType: 'FREE_TEXT', textAnswer: 'Danke!'})];
const report = aggregateReport(eventMeta, {totalSubmissions: 1, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
expect(report.freeText[0].hasMore).toBe(false);
});
});
describe('aggregateReport - top-level fields', () => {
it('passes through submission stats, guest book count, and newsletter counts unchanged', () => {
const report = aggregateReport(
eventMeta,
{totalSubmissions: 42, firstSubmissionAt: '2026-08-02T10:00:00.000Z', lastSubmissionAt: '2026-08-10T18:00:00.000Z'},
[],
7,
{total: 10, sent: 6, pending: 2, failed: 1, skipped: 1}
);
expect(report.totalSubmissions).toBe(42);
expect(report.firstSubmissionAt).toBe('2026-08-02T10:00:00.000Z');
expect(report.lastSubmissionAt).toBe('2026-08-10T18:00:00.000Z');
expect(report.guestBookCount).toBe(7);
expect(report.newsletter).toEqual({total: 10, sent: 6, pending: 2, failed: 1, skipped: 1});
expect(report.event).toEqual(eventMeta);
});
it('produces empty arrays for an event with no submissions', () => {
const report = aggregateReport(eventMeta, {totalSubmissions: 0, firstSubmissionAt: null, lastSubmissionAt: null}, [], 0, emptyNewsletter);
expect(report.songPicks).toEqual([]);
expect(report.songRatings).toEqual([]);
expect(report.freeText).toEqual([]);
});
});
+202
View File
@@ -0,0 +1,202 @@
// The module under test caches its OAuth token at module scope (see
// salesforce.service.ts's `cachedToken`), so every test resets the module
// registry for a clean cache. Every mocked dependency (axios, Feedback.db,
// the logger) is re-imported after the reset rather than referenced from a
// top-level import, so the test always holds the same instance the service
// under test resolves. Mocked modules survive vi.resetModules(), so their
// mock state is reset explicitly in beforeEach.
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('axios');
vi.mock('../../src/models/feedback/Feedback.db.js', () => ({
NachklangFeedbackDB: {getConnection: vi.fn()}
}));
vi.mock('../../src/middleware/logger.js', () => ({
__esModule: true,
default: {info: vi.fn(), error: vi.fn()}
}));
const SIGNUP_ROW = {
signup_id: 7,
first_name: 'Erika',
last_name: 'Mustermann',
email: 'erika@example.com',
event_name: 'Sommerkonzert 2026'
};
const makeConn = (rows: any[]) => ({
query: vi.fn().mockResolvedValue(rows),
end: vi.fn().mockResolvedValue(undefined)
});
// Re-imports every mocked dependency (see the note above) and returns the
// live references plus the service under test.
const freshImports = async () => {
const axios: any = (await import('axios')).default;
const {NachklangFeedbackDB} = await import('../../src/models/feedback/Feedback.db.js');
const logger = (await import('../../src/middleware/logger.js')).default;
const {syncNewsletterSignup} = await import('../../src/models/feedback/integrations/salesforce.service.js');
return {axios, mockGetConnection: NachklangFeedbackDB.getConnection as Mock, logger, syncNewsletterSignup};
};
const ORIGINAL_ENV = {...process.env};
describe('syncNewsletterSignup - disabled mode', () => {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
process.env = {...ORIGINAL_ENV, SALESFORCE_ENABLED: 'false'};
});
it('logs the payload it would send and does not touch the network or write to the DB', async () => {
const {axios, mockGetConnection, logger, syncNewsletterSignup} = await freshImports();
const conn = makeConn([SIGNUP_ROW]);
mockGetConnection.mockResolvedValue(conn);
await syncNewsletterSignup(7);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('would have sent'),
expect.objectContaining({
signupId: 7,
payload: {firstName: 'Erika', lastName: 'Mustermann', email: 'erika@example.com', eventName: 'Sommerkonzert 2026'}
})
);
expect(axios.post).not.toHaveBeenCalled();
// One read connection only - no UPDATE issued, since the row's
// sync_status is already 'SKIPPED' from the insert.
expect(mockGetConnection).toHaveBeenCalledTimes(1);
});
it('logs and returns without calling the network when the signup row does not exist', async () => {
const {axios, mockGetConnection, logger, syncNewsletterSignup} = await freshImports();
mockGetConnection.mockResolvedValue(makeConn([]));
await syncNewsletterSignup(999);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('not found'), {signupId: 999});
expect(axios.post).not.toHaveBeenCalled();
});
});
describe('syncNewsletterSignup - enabled mode', () => {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
process.env = {
...ORIGINAL_ENV,
SALESFORCE_ENABLED: 'true',
SALESFORCE_API_URL: 'https://example.my.salesforce.com',
SALESFORCE_CLIENT_ID: 'client-id',
SALESFORCE_CLIENT_SECRET: 'client-secret'
};
});
it('fetches a token, posts the signup, and marks the row SENT with the returned record id', async () => {
const {axios, mockGetConnection, syncNewsletterSignup} = await freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
if (url.endsWith('/services/apexrest/newsletter/signup')) {
return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q1234', created: true}});
}
throw new Error(`unexpected url ${url}`);
});
await syncNewsletterSignup(7);
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/oauth2/token',
expect.any(String),
expect.objectContaining({headers: {'Content-Type': 'application/x-www-form-urlencoded'}})
);
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/apexrest/newsletter/signup',
{firstName: 'Erika', lastName: 'Mustermann', email: 'erika@example.com', eventName: 'Sommerkonzert 2026'},
expect.objectContaining({headers: {Authorization: 'Bearer tok-1'}})
);
expect(updateConn.query).toHaveBeenCalledWith(expect.stringContaining("sync_status = 'SENT'"), ['00Q1234', 7]);
});
it('reuses the cached token across two calls instead of fetching twice', async () => {
const {axios, mockGetConnection, syncNewsletterSignup} = await freshImports();
mockGetConnection
.mockResolvedValueOnce(makeConn([SIGNUP_ROW]))
.mockResolvedValueOnce(makeConn([]))
.mockResolvedValueOnce(makeConn([SIGNUP_ROW]))
.mockResolvedValueOnce(makeConn([]));
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q1234', created: true}});
});
await syncNewsletterSignup(7);
await syncNewsletterSignup(7);
const tokenCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/services/oauth2/token'));
expect(tokenCalls).toHaveLength(1);
});
it('retries once with a fresh token on a 401, then succeeds', async () => {
const {axios, mockGetConnection, syncNewsletterSignup} = await freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
let tokenFetches = 0;
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) {
tokenFetches += 1;
return Promise.resolve({data: {access_token: `tok-${tokenFetches}`}});
}
if (url.endsWith('/services/apexrest/newsletter/signup')) {
if (tokenFetches === 1) {
const err: any = new Error('Unauthorized');
err.response = {status: 401, data: {message: 'Session expired'}};
return Promise.reject(err);
}
return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q9999', created: true}});
}
throw new Error(`unexpected url ${url}`);
});
await syncNewsletterSignup(7);
expect(tokenFetches).toBe(2);
expect(updateConn.query).toHaveBeenCalledWith(expect.stringContaining("sync_status = 'SENT'"), ['00Q9999', 7]);
});
it('marks the row FAILED with the error message on a non-401 error, without throwing', async () => {
const {axios, mockGetConnection, logger, syncNewsletterSignup} = await freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
const err: any = new Error('Internal error');
err.response = {status: 500, data: {message: 'The newsletter signup could not be processed.'}};
return Promise.reject(err);
});
await expect(syncNewsletterSignup(7)).resolves.toBeUndefined();
expect(updateConn.query).toHaveBeenCalledWith(
expect.stringContaining("sync_status = 'FAILED'"),
['The newsletter signup could not be processed.', 7]
);
expect(logger.error).toHaveBeenCalledWith('syncNewsletterSignup failed', expect.objectContaining({signupId: 7}));
});
it('marks the row FAILED with a clear message when client credentials are not configured', async () => {
process.env.SALESFORCE_CLIENT_ID = '';
const {mockGetConnection, syncNewsletterSignup} = await freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
await syncNewsletterSignup(7);
expect(updateConn.query).toHaveBeenCalledWith(
expect.stringContaining("sync_status = 'FAILED'"),
[expect.stringContaining('SALESFORCE_CLIENT_ID'), 7]
);
});
});
+152
View File
@@ -0,0 +1,152 @@
import {describe, it, expect} from 'vitest';
import {MAX_ANSWER_ROWS, validateAnswers, validateGuestBook, validateNewsletter} from '../../src/models/feedback/public/submissions.service.js';
type QuestionLookup = Map<number, {eventQuestionId: number; questionId: number; type: 'SONG_PICK' | 'SONG_RATING' | 'FREE_TEXT'; label: string; position: number}>;
const songTitleById = new Map<number, string>([
[1, 'Abendlied'],
[2, 'Morgenlied']
]);
describe('validateAnswers', () => {
const questionsById: QuestionLookup = new Map([
[10, {eventQuestionId: 10, questionId: 100, type: 'SONG_PICK', label: 'Lieblingsstück?', position: 0}],
[11, {eventQuestionId: 11, questionId: 101, type: 'SONG_RATING', label: 'Bewertung', position: 1}],
[12, {eventQuestionId: 12, questionId: 102, type: 'FREE_TEXT', label: 'Sonstiges', position: 2}]
]);
it('produces a row for a valid SONG_PICK answer', () => {
const rows = validateAnswers([{eventQuestionId: 10, songId: 1}], questionsById, songTitleById);
expect(rows).toEqual([
{eventQuestionId: 10, questionId: 100, label: 'Lieblingsstück?', type: 'SONG_PICK', position: 0, songId: 1, songTitle: 'Abendlied', rating: null, text: null}
]);
});
it('ignores a SONG_PICK answer with an unknown songId', () => {
const rows = validateAnswers([{eventQuestionId: 10, songId: 999}], questionsById, songTitleById);
expect(rows).toHaveLength(0);
});
it('ignores an answer for an unknown eventQuestionId', () => {
const rows = validateAnswers([{eventQuestionId: 999, songId: 1}], questionsById, songTitleById);
expect(rows).toHaveLength(0);
});
it('produces one row per rated song for SONG_RATING, ignoring unknown songs', () => {
const rows = validateAnswers([
{eventQuestionId: 11, ratings: [{songId: 1, rating: 5}, {songId: 2, rating: 3}, {songId: 999, rating: 4}]}
], questionsById, songTitleById);
expect(rows).toHaveLength(2);
expect(rows.map(r => r.songId)).toEqual([1, 2]);
});
it('clamps ratings to the 1..5 range', () => {
const rows = validateAnswers([
{eventQuestionId: 11, ratings: [{songId: 1, rating: 9}, {songId: 2, rating: -3}]}
], questionsById, songTitleById);
expect(rows.find(r => r.songId === 1)?.rating).toBe(5);
expect(rows.find(r => r.songId === 2)?.rating).toBe(1);
});
it('an unrated song in a SONG_RATING block produces no row', () => {
const rows = validateAnswers([{eventQuestionId: 11, ratings: []}], questionsById, songTitleById);
expect(rows).toHaveLength(0);
});
it('trims FREE_TEXT and drops it if empty after trimming', () => {
const withText = validateAnswers([{eventQuestionId: 12, text: ' Danke für den Abend! '}], questionsById, songTitleById);
expect(withText[0].text).toBe('Danke für den Abend!');
const blank = validateAnswers([{eventQuestionId: 12, text: ' '}], questionsById, songTitleById);
expect(blank).toHaveLength(0);
});
it('caps FREE_TEXT at 5000 characters', () => {
const long = 'a'.repeat(6000);
const rows = validateAnswers([{eventQuestionId: 12, text: long}], questionsById, songTitleById);
expect(rows[0].text).toHaveLength(5000);
});
it('a fully empty answer set produces no rows (skipped questions produce no rows)', () => {
const rows = validateAnswers([], questionsById, songTitleById);
expect(rows).toHaveLength(0);
});
it('de-duplicates repeated ratings for the same song, keeping the last value', () => {
const rows = validateAnswers([
{eventQuestionId: 11, ratings: [{songId: 1, rating: 2}, {songId: 1, rating: 5}, {songId: 1, rating: 3}]}
], questionsById, songTitleById);
expect(rows).toHaveLength(1);
expect(rows[0].rating).toBe(3);
});
it('caps total generated rows at MAX_ANSWER_ROWS regardless of how many ratings are submitted', () => {
const massRatings = Array.from({length: MAX_ANSWER_ROWS + 500}, (_, i) => ({
songId: 1,
rating: (i % 5) + 1
}));
// Force distinct songIds so de-duplication alone can't be the thing capping the count.
const distinctSongTitleById = new Map<number, string>(
Array.from({length: MAX_ANSWER_ROWS + 500}, (_, i) => [i, `Song ${i}`])
);
const distinctRatings = massRatings.map((r, i) => ({songId: i, rating: r.rating}));
const rows = validateAnswers(
[{eventQuestionId: 11, ratings: distinctRatings}],
questionsById,
distinctSongTitleById
);
expect(rows.length).toBe(MAX_ANSWER_ROWS);
});
it('stops adding rows across multiple answers once the cap is reached', () => {
const distinctSongTitleById = new Map<number, string>(
Array.from({length: MAX_ANSWER_ROWS + 10}, (_, i) => [i, `Song ${i}`])
);
const answers = Array.from({length: MAX_ANSWER_ROWS + 10}, (_, i) => ({
eventQuestionId: 10,
songId: i
}));
// SONG_PICK only ever produces 0 or 1 row per answer entry, so this
// exercises the cap across many separate answers, not one big array.
const rows = validateAnswers(answers, questionsById, distinctSongTitleById);
expect(rows.length).toBe(MAX_ANSWER_ROWS);
});
});
describe('validateGuestBook', () => {
it('returns null when nothing was filled in', () => {
expect(validateGuestBook(undefined)).toBeNull();
expect(validateGuestBook({displayName: ' ', message: ' '})).toBeNull();
});
it('keeps a valid entry with only a display name', () => {
expect(validateGuestBook({displayName: 'Familie Müller'})).toEqual({displayName: 'Familie Müller', message: null});
});
it('caps the message at 2000 characters', () => {
const long = 'x'.repeat(3000);
const result = validateGuestBook({message: long});
expect(result?.message).toHaveLength(2000);
});
});
describe('validateNewsletter', () => {
it('returns null when the object is missing', () => {
expect(validateNewsletter(undefined)).toBeNull();
});
it('drops the signup silently when the email is invalid', () => {
expect(validateNewsletter({firstName: 'Anna', lastName: 'Beispiel', email: 'not-an-email'})).toBeNull();
});
it('drops the signup when first or last name is missing', () => {
expect(validateNewsletter({firstName: '', lastName: 'Beispiel', email: 'a@b.de'})).toBeNull();
expect(validateNewsletter({firstName: 'Anna', lastName: '', email: 'a@b.de'})).toBeNull();
});
it('accepts a fully valid signup', () => {
expect(validateNewsletter({firstName: 'Anna', lastName: 'Beispiel', email: 'anna@beispiel.de'})).toEqual({
firstName: 'Anna', lastName: 'Beispiel', email: 'anna@beispiel.de'
});
});
});
+1
View File
@@ -1,3 +1,4 @@
import {test, expect} from 'vitest';
test('Test template', async () => {
expect(true).toBe(true);
});
+132
View File
@@ -0,0 +1,132 @@
// tickets.confirmation-email builds and sends the redemption confirmation
// email, shared by the public redeem path and the admin resend action.
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/models/calendar/events/events.service.js', () => ({
getEventById: vi.fn()
}));
vi.mock('../../src/models/calendar/events/icalgenerator.service.js', () => ({
convertToIcal: vi.fn()
}));
vi.mock('../../src/common/common.mail.js', () => ({
MailService: {sendMail: vi.fn()}
}));
vi.mock('../../src/models/tickets/Tickets.db.js', () => ({
NachklangTicketsDB: {getConnection: vi.fn()}
}));
vi.mock('../../src/middleware/logger.js', () => ({
__esModule: true,
default: {info: vi.fn(), warn: vi.fn(), error: vi.fn()}
}));
import * as EventsService from '../../src/models/calendar/events/events.service.js';
import * as IcalService from '../../src/models/calendar/events/icalgenerator.service.js';
import {MailService} from '../../src/common/common.mail.js';
import logger from '../../src/middleware/logger.js';
import {NachklangTicketsDB} from '../../src/models/tickets/Tickets.db.js';
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../../src/models/tickets/tickets.confirmation-email.js';
const mockGetEvent = EventsService.getEventById as Mock;
const mockToIcal = IcalService.convertToIcal as Mock;
const mockSendMail = MailService.sendMail as Mock;
const mockLogger = logger as unknown as {info: Mock; warn: Mock; error: Mock};
const mockGetConnection = NachklangTicketsDB.getConnection as Mock;
const EVENT = {
eventId: 42,
name: 'Sommerkonzert 2026',
startDateTime: new Date('2026-07-01T19:00:00Z'),
location: 'Christuskirche',
status: 'PUBLISHED'
};
const RECIPIENT = {
eventId: 42,
contactName: 'Erika Mustermann',
contactEmail: 'erika@example.com',
guestNames: ['Erika Mustermann', 'Hans Mustermann']
};
beforeEach(() => {
vi.clearAllMocks();
mockGetEvent.mockResolvedValue(EVENT);
mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR');
mockSendMail.mockResolvedValue(true);
});
describe('sendRedemptionConfirmation', () => {
it('sends the German confirmation with the event details, guest list and .ics attachment', async () => {
const result = await sendRedemptionConfirmation(RECIPIENT);
expect(result).toBe(true);
expect(mockSendMail).toHaveBeenCalledTimes(1);
const [to, subject, body, options] = mockSendMail.mock.calls[0];
expect(to).toBe('erika@example.com');
expect(subject).toBe('Bestätigung: Sommerkonzert 2026');
expect(body).toContain('Hallo Erika Mustermann,');
expect(body).toContain('"Sommerkonzert 2026"');
expect(body).toContain('- Hans Mustermann');
expect(options.attachments).toEqual([
{filename: 'konzert.ics', content: 'BEGIN:VCALENDAR\nEND:VCALENDAR', contentType: 'text/calendar'}
]);
});
it('still sends (without an attachment) and warns when the .ics build fails', async () => {
mockToIcal.mockRejectedValue(new Error('ical boom'));
await sendRedemptionConfirmation(RECIPIENT);
const options = mockSendMail.mock.calls[0][3];
expect(options.attachments).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('without .ics attachment'));
});
it('returns false and does not send when the event no longer exists', async () => {
mockGetEvent.mockResolvedValue(null);
const result = await sendRedemptionConfirmation(RECIPIENT);
expect(result).toBe(false);
expect(mockSendMail).not.toHaveBeenCalled();
});
it('propagates the relay result', async () => {
mockSendMail.mockResolvedValue(false);
expect(await sendRedemptionConfirmation(RECIPIENT)).toBe(false);
});
});
describe('recordConfirmationEmailResult', () => {
const makeConn = () => ({query: vi.fn().mockResolvedValue(undefined), end: vi.fn().mockResolvedValue(undefined)});
it('writes SENT when the mail was accepted', async () => {
const conn = makeConn();
mockGetConnection.mockResolvedValue(conn);
await recordConfirmationEmailResult(7, true);
expect(conn.query).toHaveBeenCalledWith(
'UPDATE redemptions SET confirmation_email_status = ? WHERE redemption_id = ?',
['SENT', 7]
);
expect(conn.end).toHaveBeenCalled();
});
it('writes FAILED when the mail was not accepted', async () => {
const conn = makeConn();
mockGetConnection.mockResolvedValue(conn);
await recordConfirmationEmailResult(7, false);
expect(conn.query).toHaveBeenCalledWith(expect.any(String), ['FAILED', 7]);
});
it('swallows a DB error rather than throwing', async () => {
const conn = {query: vi.fn().mockRejectedValue(new Error('db down')), end: vi.fn().mockResolvedValue(undefined)};
mockGetConnection.mockResolvedValue(conn);
await expect(recordConfirmationEmailResult(7, true)).resolves.toBeUndefined();
expect(conn.end).toHaveBeenCalled();
});
});
@@ -0,0 +1,89 @@
// resendRedemptionConfirmation rebuilds the confirmation email from stored
// redemption data and records the new outcome. Only the resend path is
// exercised here; the shared send/record logic is covered by
// confirmation-email.test.ts.
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/models/tickets/Tickets.db.js', () => ({
NachklangTicketsDB: {getConnection: vi.fn()}
}));
vi.mock('../../src/models/tickets/tickets.confirmation-email.js', () => ({
sendRedemptionConfirmation: vi.fn(),
recordConfirmationEmailResult: vi.fn()
}));
import {NachklangTicketsDB} from '../../src/models/tickets/Tickets.db.js';
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../../src/models/tickets/tickets.confirmation-email.js';
import {resendRedemptionConfirmation} from '../../src/models/tickets/admin/redemptions.admin.service.js';
const mockGetConnection = NachklangTicketsDB.getConnection as Mock;
const mockSend = sendRedemptionConfirmation as Mock;
const mockRecord = recordConfirmationEmailResult as Mock;
const ACTIVE_ROW = {
redemption_id: 5,
code: 'ABC123',
event_id: 42,
status: 'ACTIVE',
contact_name: 'Erika Mustermann',
contact_email: 'erika@example.com',
contact_address: null,
guest_count: 2,
redeemed_at: new Date('2026-06-01T10:00:00Z'),
confirmation_email_status: 'FAILED'
};
// getRedemption issues: 1) SELECT redemptions, 2) SELECT redemption_guests
const connFor = (redemptionRows: any[], guestRows: any[] = []) => ({
query: vi
.fn()
.mockResolvedValueOnce(redemptionRows)
.mockResolvedValueOnce(guestRows),
end: vi.fn().mockResolvedValue(undefined)
});
beforeEach(() => {
vi.clearAllMocks();
mockSend.mockResolvedValue(true);
});
describe('resendRedemptionConfirmation', () => {
it('returns NOT_FOUND when the redemption does not exist', async () => {
mockGetConnection.mockResolvedValue(connFor([]));
expect(await resendRedemptionConfirmation(5)).toBe('NOT_FOUND');
expect(mockSend).not.toHaveBeenCalled();
});
it('returns NOT_ACTIVE for an undone redemption', async () => {
mockGetConnection.mockResolvedValue(connFor([{...ACTIVE_ROW, status: 'UNDONE'}]));
expect(await resendRedemptionConfirmation(5)).toBe('NOT_ACTIVE');
expect(mockSend).not.toHaveBeenCalled();
});
it('resends from the stored data and records SENT on success', async () => {
mockGetConnection.mockResolvedValue(connFor([ACTIVE_ROW], [{name: 'Erika Mustermann'}, {name: 'Hans Mustermann'}]));
const result = await resendRedemptionConfirmation(5);
expect(result).toBe('SENT');
expect(mockSend).toHaveBeenCalledWith({
eventId: 42,
contactName: 'Erika Mustermann',
contactEmail: 'erika@example.com',
guestNames: ['Erika Mustermann', 'Hans Mustermann']
});
expect(mockRecord).toHaveBeenCalledWith(5, true);
});
it('records FAILED and returns FAILED when the relay rejects the send', async () => {
mockGetConnection.mockResolvedValue(connFor([ACTIVE_ROW], [{name: 'Erika Mustermann'}]));
mockSend.mockResolvedValue(false);
const result = await resendRedemptionConfirmation(5);
expect(result).toBe('FAILED');
expect(mockRecord).toHaveBeenCalledWith(5, false);
});
});
+15 -98
View File
@@ -1,103 +1,20 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* The API is native ESM ("type": "module" in package.json). nodenext makes
tsc emit ESM, requires the ".js" suffix on relative imports, and treats
CommonJS dependencies (express, mariadb, winston, ...) via default imports. */
"module": "nodenext",
"target": "es2024",
"lib": ["es2024"],
"types": ["node"],
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
"outDir": "./dist",
"inlineSourceMap": true,
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
"inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["app.ts", "src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import {defineConfig} from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
// feedback.ratelimit throws at import time without a salt (see
// test/feedback/ratelimit.salt-guard.test.ts). Set one here so the suite
// passes on a clean checkout without a local .env; the salt-guard test
// deletes it explicitly before exercising the missing-salt path.
env: {
FEEDBACK_IP_SALT: 'vitest-salt'
},
reporters: [
'default',
['vitest-sonar-reporter', {outputFile: 'testResults/sonar-report.xml'}]
],
coverage: {
provider: 'v8',
reporter: ['text-summary', 'lcov'],
reportsDirectory: 'coverage',
include: ['app.ts', 'src/**/*.ts']
}
}
});