10 Commits

Author SHA1 Message Date
Paddy fa34a6c9fb Add posterImageUrl to events
Concert posters already live on the public website, so events store a URL
to an existing image rather than hosting an uploaded file.
2026-08-18 22:07:31 +02:00
Paddy c2ddb11c4c Fix silent newsletter validation drops, surface skipped-sync visibility, consolidate duplicated helpers
Three fixes from the earlier review, plus cleanup:

- submissions.service.ts: a newsletter opt-in present but failing
  validation (e.g. malformed email) was silently dropped with no signal
  to the client - the rest of the submission saved, but the visitor had
  no way to know their newsletter signup didn't go through. Added
  newsletterDropped to the submit response so the frontend can tell them.

- reports.admin.service.ts: the newsletter summary tracked
  total/sent/pending/failed but silently omitted SKIPPED (stub-mode)
  signups from any bucket - every current signup showed total>0 with
  every bucket reading 0, indistinguishable from "we don't know what
  happened". Added a skipped count.

- Consolidated two things duplicated across the module: sendServerError
  (reimplemented ~11 times, three of those as identical local copies of
  the same function) into feedback.errors.ts, and formatDatetime/
  toMysqlDatetime (the same local-time formatting logic under two names,
  in csv.service.ts and events.admin.service.ts respectively) into
  feedback.dates.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 17:45:37 +02:00
Paddy 080b987914 Add unit tests for the Salesforce integration
Covers integrations/salesforce.service.ts: disabled-mode logging-only
path, signup-not-found, success (token fetch + POST + mark SENT), token
reuse across calls, retry-once-on-401, failure marks FAILED with the
error message, and a missing-client-credentials configuration error.
100% statement coverage on the file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 17:45:06 +02:00
Paddy 1a51b37097 Wire up the Salesforce newsletter sync integration
Implements integrations/salesforce.service.ts per the plan's §5.6 seam
(syncNewsletterSignup(signupId)), against the real contract now that the
Salesforce side exists (see the nk-salesforce repo's
feature/newsletter-signup-integration branch): OAuth2 client-credentials
auth, POST to /services/apexrest/newsletter/signup with
{firstName, lastName, email, eventName}, response gives back which object
(Lead or Person Account) and its id. Token is cached in memory with a
conservative TTL and refreshed on a 401 rather than trusting expires_in,
which Salesforce's client-credentials token response doesn't reliably
return.

submissions.service.ts now captures the newsletter_signups insert's id
and fires syncNewsletterSignup after commit, fire-and-forget - the one
piece that was previously entirely missing, so flipping
SALESFORCE_ENABLED=true would have left every signup stuck at PENDING
forever with nothing to process it (found during an earlier review pass).

Replaced the placeholder SALESFORCE_API_TOKEN env var with
SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET in .env.example and
CLAUDE.md, matching the real auth mechanism instead of the static-token
guess from before the contract was known. Also fixed CLAUDE.md's stale
"still scaffolding-only" note about the Feedback domain.

Not yet covered by tests - the Salesforce-side contract was validated
end-to-end against a real sandbox, but this file has no unit tests yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 19:56:01 +02:00
Paddy 01a914f31e Paginate and add search to the newsletter signups endpoint
Unlike guest book and free-text, newsletter signups had no LIMIT at all and the frontend rendered every row in one plain table. Newsletter opt-in is a single checkbox rather than typed text, so it's plausibly the largest per-event list - fix it the same way as guest book: paginated (page/pageSize, capped at 200/page) plus an optional ?search= over first name, last name, and email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 21:49:00 +02:00
Paddy 2988a70d8f Add search to the admin guest book endpoint
At scale (many submissions after a concert), paging through the guest book 20 entries at a time with no way to find a specific person is impractical. Add an optional ?search= query param that filters entries whose name or message contains the term (case-insensitive), with LIKE wildcards escaped so a literal % or _ in a search term can't be misinterpreted as a pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 21:32:32 +02:00
Paddy 07d757e9af Remove hardcoded USE statement from feedback schema migration
001_init.sql opened with USE `nachklang-feedback`, contradicting its own
documented apply instructions (mysql ... <FEEDBACK_DB> < 001_init.sql,
which already selects the database via the command line). Following the
file's own usage note literally would fail unless a database happened to
be named exactly nachklang-feedback rather than whatever FEEDBACK_DB is
configured to in .env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 21:02:47 +02:00
Paddy 56074d4441 Allow dev CORS from LAN IPs; add submission deletion
The dev-only CORS bypass in app.ts only ever matched
http://localhost:<port>, never the LAN IP a phone actually connects
through over WiFi - so testing the feedback form from a real device
against a local dev API had its submissions silently rejected by CORS.
Extended the bypass to also allow private LAN ranges (192.168.x.x,
10.x.x.x, 172.16-31.x.x), dev-only as before.

Also adds DELETE /feedback/admin/submissions/:submissionId (cascades
to the submission's answers, guest book entry, and newsletter signup
in explicit dependency order, single-path by submission_id) so an
admin can remove an individual abusive/inappropriate entry - decided
in IMPLEMENTATION_PLAN.md §7 item 8. getGuestBookEntries now also
returns submissionId so the admin UI can target the delete call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 22:55:32 +02:00
Paddy 7488ac673f Add example .env 2026-08-05 23:37:17 +02:00
Paddy 17ca6399e0 Add Feedback domain module: public submission flow, admin CRUD, reporting
New /feedback API domain backed by its own FEEDBACK_DB, mirroring the
Calendar domain's router -> service -> DB pool layering:

- Public endpoints (no auth): eligible-events listing, event config,
  submission with honeypot + rate limiting (in-memory + DB backstop).
- Admin endpoints (session-header auth, reusing Calendar's users/sessions
  via a swappable feedback.auth.ts boundary): events/songs/questions CRUD,
  bulk reorder/assignment, aggregated reporting, CSV export.
- Schema in sql/feedback/001_init.sql (8 tables), applied and verified
  against the real FEEDBACK_DB.
- 64 Jest tests covering validation, auth, rate limiting, CSV escaping,
  and report aggregation (pure functions, no DB needed).

Includes fixes from a security review: path traversal defense doesn't
apply here (that's the frontend proxy, separate repo), but the
rate-limiter cluster does - recordSubmission now counts every processed
request (not just successful ones), the in-memory Map evicts empty
entries instead of growing unbounded, FEEDBACK_IP_SALT is required at
boot instead of silently degrading to unsalted hashing, and submission
answer/rating arrays are capped and de-duplicated to bound insert
amplification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 23:32:22 +02:00
84 changed files with 6274 additions and 5743 deletions
-6
View File
@@ -1,5 +1,3 @@
# 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=
@@ -21,10 +19,6 @@ 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
@@ -1 +0,0 @@
26
+4 -14
View File
@@ -8,13 +8,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
npm run build # Compile TypeScript → dist/
npm run start # Build and start (tsc && node ./dist/app.js)
npm run debug # Start with DEBUG=* environment variable
npm run test # Run the vitest suite once with coverage (lcov + testResults/sonar-report.xml)
npm run test:watch # vitest in watch mode
npm run test # Run Jest tests with coverage (outputs sonar-report.xml)
```
Run a single test file:
```bash
npx vitest run test/some.test.ts
npx jest test/some.test.ts
```
## Architecture
@@ -47,11 +46,6 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca
## 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=
@@ -75,10 +69,6 @@ CHOIR_CREDENTIAL=
MANAGEMENT_CREDENTIAL=
```
## TypeScript / module system
## TypeScript config
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.
Strict mode enabled, target ES2016, compiled output in `./dist`, inline source maps. Tests run through `ts-jest` directly against `.ts` sources.
+6 -8
View File
@@ -3,15 +3,15 @@ import * as http from 'http';
import * as dotenv from 'dotenv';
import swaggerUi from 'swagger-ui-express';
import swaggerJSDoc from 'swagger-jsdoc';
import cors from 'cors';
import logger from './src/middleware/logger.js';
import logger from './src/middleware/logger';
// Router imports
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';
import {calendarRouter} from './src/models/calendar/Calendar.router';
import {feedbackRouter} from './src/models/feedback/Feedback.router';
let cors = require('cors');
dotenv.config();
if (!process.env.PORT) {
@@ -36,8 +36,7 @@ app.use(express.json());
let allowedHosts = [
'https://www.nachklang.art',
'https://calendar.nachklang.art',
'https://feedback.nachklang.art',
'https://tickets.nachklang.art'
'https://feedback.nachklang.art'
];
const isDev = process.env.NODE_ENV !== 'production';
const localhostRegex = /^http:\/\/localhost:\d+$/;
@@ -105,7 +104,6 @@ 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
@@ -1,28 +0,0 @@
# 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
@@ -1,10 +0,0 @@
-- 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
@@ -1,89 +0,0 @@
-- 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
@@ -1,3 +0,0 @@
USE nachklang_feedback;
SOURCE /migrations/feedback/001_init.sql;
SOURCE /migrations/feedback/002_add_poster_image_url.sql;
-3
View File
@@ -1,3 +0,0 @@
USE nachklang_tickets;
SOURCE /migrations/tickets/001_init.sql;
SOURCE /migrations/tickets/002_add_require_address.sql;
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: [
'test'
]
};
+5904 -2166
View File
File diff suppressed because it is too large Load Diff
+18 -14
View File
@@ -3,30 +3,26 @@
"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": "vitest run --coverage",
"test:watch": "vitest"
"test": "jest --coverage --testResultsProcessor ./node_modules/jest-sonar-reporter/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"app-root-path": "^3.0.0",
"axios": "^1.20.0",
"axios": "^0.24.0",
"bcrypt": "^5.0.1",
"cors": "^2.8.5",
"debug": "^4.3.1",
"dotenv": "^16.6.1",
"dotenv": "^8.2.0",
"express": "^4.18.2",
"guid-typescript": "^1.0.9",
"mariadb": "^3.0.2",
"nodemailer": "^6.9.8",
"random-words": "^1.1.1",
"swagger-jsdoc": "^6.1.0",
"swagger-ui-express": "^4.3.0",
@@ -35,19 +31,27 @@
"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.15",
"@types/node": "^26.4.1",
"@types/jest": "^28.1.3",
"@types/node": "^18.11.17",
"@types/nodemailer": "^6.4.14",
"@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",
"typescript": "^5.9.3",
"vitest": "^5.0.0",
"vitest-sonar-reporter": "^3.0.0"
"ts-jest": "^28.0.5",
"tslint": "^6.1.3",
"typescript": "^4.9.4"
},
"jestSonar": {
"sonar56x": true,
"reportPath": "testResults",
"reportFile": "sonar-report.xml",
"indent": 4
}
}
-102
View File
@@ -1,102 +0,0 @@
-- 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
@@ -1,7 +0,0 @@
-- 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;
@@ -1,7 +0,0 @@
-- 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;
+38
View File
@@ -0,0 +1,38 @@
import * as nodemailer from 'nodemailer';
export namespace MailService {
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
pool: true,
port: 465,
secure: true,
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD
},
tls: {rejectUnauthorized: false}
});
const mailConfigurations = {
// It should be a string of sender email
from: 'noreply@nachklang.art',
// Comma Separated list of mails
to: 'mail@pmueller.me',
// Subject of Email
subject: '',
// This would be the text of email body
text: ''
};
export const sendMail = async (recipientAddress: string, subject: string, body: string) => {
mailConfigurations.to = recipientAddress;
mailConfigurations.subject = subject;
mailConfigurations.text = body;
await transporter.sendMail(mailConfigurations);
};
}
-107
View File
@@ -1,107 +0,0 @@
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
@@ -1,66 +0,0 @@
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 appRoot from 'app-root-path';
import winston from 'winston';
import * as appRoot from 'app-root-path';
import * as winston from 'winston';
const options = {
file_info: {
level: 'info',
filename: `${appRoot.path}/logs/app.log`,
filename: `${appRoot}/logs/app.log`,
handleExceptions: true,
json: true,
maxsize: 5242880, // 5MB
@@ -13,7 +13,7 @@ const options = {
},
file_error: {
level: 'error',
filename: `${appRoot.path}/logs/error.log`,
filename: `${appRoot}/logs/error.log`,
handleExceptions: true,
json: true,
maxsize: 5242880, // 5MB
@@ -22,7 +22,7 @@ const options = {
},
file_debug: {
level: 'debug',
filename: `${appRoot.path}/logs/debug.log`,
filename: `${appRoot}/logs/debug.log`,
handleExceptions: true,
json: true,
maxsize: 5242880, // 5MB
+4 -2
View File
@@ -1,5 +1,6 @@
import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
const mariadb = require('mariadb');
dotenv.config();
@@ -9,7 +10,8 @@ export namespace NachklangCalendarDB {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.CALENDAR_DB,
connectionLimit: 5
connectionLimit: 5,
autoCommit: false
});
export const getConnection = async () => {
+3 -3
View File
@@ -3,9 +3,9 @@
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
import {eventsRouter} from './events/events.router.js';
import {usersRouter} from './users/users.router.js';
import logger from '../../middleware/logger';
import {eventsRouter} from './events/events.router';
import {usersRouter} from './users/users.router';
/**
* Router Definition
@@ -1,5 +1,5 @@
import * as dotenv from 'dotenv';
import * as UserService from '../users/users.service.js';
import * as UserService from '../users/users.service';
dotenv.config();
@@ -70,9 +70,9 @@
* 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"
* 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
+6 -6
View File
@@ -3,13 +3,13 @@
*/
import express, {Request, Response} from 'express';
import {Event} from './event.interface.js';
import * as EventService from './events.service.js';
import * as iCalService from './icalgenerator.service.js';
import * as CredentialService from './credentials.service.js';
import * as UserService from '../users/users.service.js';
import {Event} from './event.interface';
import * as EventService from './events.service';
import * as iCalService from './icalgenerator.service';
import * as CredentialService from './credentials.service';
import * as UserService from '../users/users.service';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
import logger from '../../../middleware/logger';
/**
+2 -60
View File
@@ -1,7 +1,7 @@
import * as dotenv from 'dotenv';
import {Guid} from 'guid-typescript';
import {Event} from './event.interface.js';
import {NachklangCalendarDB} from '../Calendar.db.js';
import {Event} from './event.interface';
import {NachklangCalendarDB} from '../Calendar.db';
dotenv.config();
@@ -126,64 +126,6 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
}
};
/**
* Returns a single event by id (latest version, any status), or null if it
* doesn't exist. Unlike getAllEvents/getAllEventsAdmin this isn't scoped to
* a calendar - callers that need to enforce calendar/status visibility
* should check the returned event's calendarId/status themselves.
* @param eventId The event id
*/
export const getEventById = async (eventId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
const eventsQuery = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.event_id = ?`;
const eventsRes = await conn.query(eventsQuery, eventId);
if (eventsRes.length === 0) {
return null;
}
const row = eventsRes[0];
return {
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency,
status: row.status
} as Event;
} catch (err) {
throw err;
} finally {
// Return connection
await conn.end();
}
};
/**
* Create the given event in the database
* @param event The event to create
@@ -1,4 +1,4 @@
import {Event} from './event.interface.js';
import {Event} from './event.interface';
/**
* Interface to external classes - Turns the given events into an ical string
+4 -4
View File
@@ -3,11 +3,11 @@
*/
import express, {Request, Response} from 'express';
import * as UserService from './users.service.js';
import {Session} from './session.interface.js';
import {User} from './user.interface.js';
import * as UserService from './users.service';
import {Session} from './session.interface';
import {User} from './user.interface';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
import logger from '../../../middleware/logger';
/**
* Router Definition
+6 -9
View File
@@ -1,10 +1,10 @@
import * as dotenv from 'dotenv';
import bcrypt from 'bcrypt';
import * as 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';
import {User} from './user.interface';
import {Session} from './session.interface';
import {NachklangCalendarDB} from '../Calendar.db';
import {MailService} from "../../../common/common.mail.nodemailer";
dotenv.config();
@@ -54,10 +54,7 @@ export const createUser = async (email: string, password: string, fullName: stri
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.
// Send email with activation link (after commit so we don't block on email delivery)
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 {
+4 -2
View File
@@ -1,5 +1,6 @@
import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
const mariadb = require('mariadb');
dotenv.config();
@@ -9,7 +10,8 @@ export namespace NachklangFeedbackDB {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.FEEDBACK_DB,
connectionLimit: 5
connectionLimit: 5,
autoCommit: false
});
export const getConnection = async () => {
+3 -3
View File
@@ -2,9 +2,9 @@
* 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';
import {publicRouter} from './public/public.router';
import {adminRouter} from './admin/admin.router';
import {sendServerError} from './feedback.errors';
/**
* Router Definition
+1 -1
View File
@@ -81,7 +81,7 @@
* type: boolean
*/
import {QuestionType, Song} from '../feedback.interface.js';
import {QuestionType, Song} from '../feedback.interface';
export interface EventAdminSummary {
eventId: number;
+7 -7
View File
@@ -2,13 +2,13 @@
* 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';
import {requireAdminAuth} from '../feedback.auth';
import {sendServerError} from '../feedback.errors';
import {eventsAdminRouter} from './events.admin.router';
import {songsAdminRouter} from './songs.admin.router';
import {questionsAdminRouter} from './questions.admin.router';
import {reportsAdminRouter} from './reports.admin.router';
import * as ReportsAdminService from './reports.admin.service';
/**
* Router Definition
+2 -2
View File
@@ -1,5 +1,5 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {formatDatetime} from '../feedback.dates.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {formatDatetime} from '../feedback.dates';
const CSV_SEPARATOR = ';';
const UTF8_BOM = '';
@@ -2,9 +2,9 @@
* 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';
import * as EventsAdminService from './events.admin.service';
import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -1,7 +1,7 @@
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';
import {NachklangFeedbackDB} from '../Feedback.db';
import {Song} from '../feedback.interface';
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface';
import {formatDatetime} from '../feedback.dates';
const UMLAUT_MAP: Record<string, string> = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
@@ -2,8 +2,8 @@
* 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';
import * as QuestionsAdminService from './questions.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -1,6 +1,6 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {QuestionType} from '../feedback.interface.js';
import {AdminQuestion} from './admin.interface.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {QuestionType} from '../feedback.interface';
import {AdminQuestion} from './admin.interface';
const mapRow = (row: any): AdminQuestion => ({
questionId: row.question_id,
@@ -1,4 +1,4 @@
import {QuestionType} from '../feedback.interface.js';
import {QuestionType} from '../feedback.interface';
export interface SongPickResult {
songId: number;
@@ -2,10 +2,10 @@
* 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';
import * as ReportsAdminService from './reports.admin.service';
import * as CsvService from './csv.service';
import * as EventsAdminService from './events.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -1,7 +1,7 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {
AnswerRow, EventReport, FreeTextReport, SongPickReport, SongRatingReport
} from './reports.admin.interface.js';
} from './reports.admin.interface';
const FREE_TEXT_CAP = 500;
@@ -2,8 +2,8 @@
* 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';
import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -1,4 +1,4 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {NachklangFeedbackDB} from '../Feedback.db';
export const addSong = async (eventId: number, title: string, composer: string | null): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
+2 -2
View File
@@ -1,6 +1,6 @@
import express from 'express';
import * as UserService from '../calendar/users/users.service.js';
import {sendServerError} from './feedback.errors.js';
import * as UserService from '../calendar/users/users.service';
import {sendServerError} from './feedback.errors';
/**
* This file is the ONLY place in the feedback module that knows how admin
+1 -1
View File
@@ -1,6 +1,6 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
import logger from '../../middleware/logger';
/**
* The feedback module's standard catch-block response: log with a
+1 -1
View File
@@ -1,6 +1,6 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
import {NachklangFeedbackDB} from './Feedback.db.js';
import {NachklangFeedbackDB} from './Feedback.db';
dotenv.config();
@@ -1,6 +1,6 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import logger from '../../../middleware/logger.js';
import {salesforceApexRestPost} from '../../../common/salesforce.client.js';
import axios from 'axios';
import {NachklangFeedbackDB} from '../Feedback.db';
import logger from '../../../middleware/logger';
// Newsletter opt-ins sync to Salesforce, which already runs a full
// double-opt-in subscription flow (Person Account for existing constituents,
@@ -9,8 +9,7 @@ import {salesforceApexRestPost} from '../../../common/salesforce.client.js';
// 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.
// visitor's feedback submission.
interface SalesforceSuccessResponse {
status: 'PENDING_CONFIRMATION' | 'ALREADY_SUBSCRIBED';
@@ -27,8 +26,54 @@ interface NewsletterSignupRow {
event_name: string;
}
const postSignup = (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise<SalesforceSuccessResponse> =>
salesforceApexRestPost<SalesforceSuccessResponse>('/services/apexrest/newsletter/signup', payload);
// 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 the retry-once logic in postSignup).
const TOKEN_CACHE_MS = 15 * 60 * 1000;
let cachedToken: {accessToken: string; fetchedAt: number} | null = null;
const getAccessToken = async (forceRefresh: boolean): Promise<string> => {
if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) {
return cachedToken.accessToken;
}
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.');
}
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;
};
const postSignup = async (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise<SalesforceSuccessResponse> => {
const instanceUrl = process.env.SALESFORCE_API_URL;
const url = `${instanceUrl}/services/apexrest/newsletter/signup`;
try {
const token = await getAccessToken(false);
const res = await axios.post<SalesforceSuccessResponse>(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
} catch (err: any) {
// The cached token may have expired server-side even though our
// conservative local TTL hasn't - retry once with a forced refresh
// before treating this as a real failure.
if (err?.response?.status === 401) {
const token = await getAccessToken(true);
const res = await axios.post<SalesforceSuccessResponse>(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
}
throw err;
}
};
const markSynced = async (signupId: number, externalId: string): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
@@ -1,5 +1,5 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface';
/**
* Returns all events currently eligible to receive feedback:
+5 -5
View File
@@ -2,11 +2,11 @@
* 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';
import logger from '../../../middleware/logger';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service';
import {submitFeedback} from './submissions.service';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -1,9 +1,9 @@
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';
import {NachklangFeedbackDB} from '../Feedback.db';
import {QuestionType} from '../feedback.interface';
import {getEventConfigBySlug} from './events.public.service';
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface';
import {syncNewsletterSignup} from '../integrations/salesforce.service';
import logger from '../../../middleware/logger';
// 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.
-18
View File
@@ -1,18 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,35 +0,0 @@
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);
@@ -1,179 +0,0 @@
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);
}
});
@@ -1,155 +0,0 @@
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();
}
};
@@ -1,289 +0,0 @@
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);
}
});
@@ -1,274 +0,0 @@
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();
}
};
@@ -1,249 +0,0 @@
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);
}
});
@@ -1,244 +0,0 @@
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
@@ -1,135 +0,0 @@
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});
}
});
@@ -1,184 +0,0 @@
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
@@ -1,63 +0,0 @@
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
@@ -1,41 +0,0 @@
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
@@ -1,38 +0,0 @@
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');
};
@@ -1,76 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,307 +0,0 @@
/**
* @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
@@ -1,68 +0,0 @@
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
@@ -1,6 +0,0 @@
// 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
@@ -1,116 +0,0 @@
// 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
@@ -1,125 +0,0 @@
// 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');
});
});
+2 -3
View File
@@ -1,6 +1,5 @@
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';
import {escapeCsvField} from '../../src/models/feedback/admin/csv.service';
import {formatDatetime} from '../../src/models/feedback/feedback.dates';
describe('escapeCsvField', () => {
it('passes plain text through unchanged', () => {
+1 -2
View File
@@ -1,5 +1,4 @@
import {describe, it, expect} from 'vitest';
import {computeDefaultDeadline, slugBase, slugifyName} from '../../src/models/feedback/admin/events.admin.service.js';
import {computeDefaultDeadline, slugBase, slugifyName} from '../../src/models/feedback/admin/events.admin.service';
describe('slugifyName', () => {
it('lowercases and hyphenates', () => {
+9 -10
View File
@@ -1,14 +1,13 @@
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()
jest.mock('../../src/models/calendar/users/users.service', () => ({
checkSession: jest.fn()
}));
import * as UserService from '../../src/models/calendar/users/users.service.js';
import {requireAdminAuth, sessionHeaderAuthenticator} from '../../src/models/feedback/feedback.auth.js';
import * as UserService from '../../src/models/calendar/users/users.service';
import {requireAdminAuth, sessionHeaderAuthenticator} from '../../src/models/feedback/feedback.auth';
const mockCheckSession = UserService.checkSession as Mock;
const mockCheckSession = UserService.checkSession as jest.Mock;
const makeReq = (headers: Record<string, string>): Request => {
return {
@@ -19,8 +18,8 @@ const makeReq = (headers: Record<string, string>): Request => {
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.status = jest.fn().mockReturnValue(res);
res.send = jest.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
@@ -66,7 +65,7 @@ describe('requireAdminAuth', () => {
mockCheckSession.mockResolvedValue(null);
const req = makeReq({});
const res = makeRes();
const next = vi.fn();
const next = jest.fn();
await requireAdminAuth(req, res, next);
@@ -78,7 +77,7 @@ describe('requireAdminAuth', () => {
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();
const next = jest.fn();
await requireAdminAuth(req, res, next);
+1 -2
View File
@@ -1,5 +1,4 @@
import {describe, it, expect} from 'vitest';
import {isHoneypotTriggered} from '../../src/models/feedback/public/public.router.js';
import {isHoneypotTriggered} from '../../src/models/feedback/public/public.router';
describe('isHoneypotTriggered', () => {
it('is false when the field is absent', () => {
+10 -11
View File
@@ -1,10 +1,9 @@
// 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()}
jest.mock('dotenv', () => ({config: jest.fn()}));
jest.mock('../../src/models/feedback/Feedback.db', () => ({
NachklangFeedbackDB: {getConnection: jest.fn()}
}));
describe('FEEDBACK_IP_SALT enforcement', () => {
@@ -12,18 +11,18 @@ describe('FEEDBACK_IP_SALT enforcement', () => {
afterEach(() => {
process.env.FEEDBACK_IP_SALT = originalSalt;
vi.resetModules();
jest.resetModules();
});
it('throws at import time when FEEDBACK_IP_SALT is unset, rather than silently hashing unsalted', async () => {
vi.resetModules();
it('throws at import time when FEEDBACK_IP_SALT is unset, rather than silently hashing unsalted', () => {
jest.resetModules();
delete process.env.FEEDBACK_IP_SALT;
await expect(import('../../src/models/feedback/feedback.ratelimit.js')).rejects.toThrow(/FEEDBACK_IP_SALT/);
expect(() => require('../../src/models/feedback/feedback.ratelimit')).toThrow(/FEEDBACK_IP_SALT/);
});
it('does not throw when FEEDBACK_IP_SALT is set', async () => {
vi.resetModules();
it('does not throw when FEEDBACK_IP_SALT is set', () => {
jest.resetModules();
process.env.FEEDBACK_IP_SALT = 'a-real-salt';
await expect(import('../../src/models/feedback/feedback.ratelimit.js')).resolves.toBeDefined();
expect(() => require('../../src/models/feedback/feedback.ratelimit')).not.toThrow();
});
});
+1 -2
View File
@@ -1,5 +1,4 @@
import {describe, it, expect} from 'vitest';
import {hashIp} from '../../src/models/feedback/feedback.ratelimit.js';
import {hashIp} from '../../src/models/feedback/feedback.ratelimit';
describe('hashIp', () => {
it('never returns the raw IP', () => {
+2 -3
View File
@@ -1,6 +1,5 @@
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';
import {aggregateReport} from '../../src/models/feedback/admin/reports.admin.service';
import {AnswerRow} from '../../src/models/feedback/admin/reports.admin.interface';
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};
+28 -32
View File
@@ -1,19 +1,17 @@
// 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.
// registry for a clean cache. That also invalidates any jest.mock() factory
// instance captured before the reset, so every mocked dependency (axios,
// Feedback.db, the logger) is re-required fresh after each reset rather
// than referenced from a top-level import.
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()}
jest.mock('axios');
jest.mock('../../src/models/feedback/Feedback.db', () => ({
NachklangFeedbackDB: {getConnection: jest.fn()}
}));
vi.mock('../../src/middleware/logger.js', () => ({
jest.mock('../../src/middleware/logger', () => ({
__esModule: true,
default: {info: vi.fn(), error: vi.fn()}
default: {info: jest.fn(), error: jest.fn()}
}));
const SIGNUP_ROW = {
@@ -25,31 +23,30 @@ const SIGNUP_ROW = {
};
const makeConn = (rows: any[]) => ({
query: vi.fn().mockResolvedValue(rows),
end: vi.fn().mockResolvedValue(undefined)
query: jest.fn().mockResolvedValue(rows),
end: jest.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};
// Re-requires every mocked dependency fresh (see the note above) and
// returns the live references plus the service under test.
const freshImports = () => {
const axios = require('axios');
const {NachklangFeedbackDB} = require('../../src/models/feedback/Feedback.db');
const logger = require('../../src/middleware/logger').default;
const {syncNewsletterSignup} = require('../../src/models/feedback/integrations/salesforce.service');
return {axios, mockGetConnection: NachklangFeedbackDB.getConnection as jest.Mock, logger, syncNewsletterSignup};
};
const ORIGINAL_ENV = {...process.env};
describe('syncNewsletterSignup - disabled mode', () => {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
jest.resetModules();
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 {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports();
const conn = makeConn([SIGNUP_ROW]);
mockGetConnection.mockResolvedValue(conn);
@@ -69,7 +66,7 @@ describe('syncNewsletterSignup - disabled mode', () => {
});
it('logs and returns without calling the network when the signup row does not exist', async () => {
const {axios, mockGetConnection, logger, syncNewsletterSignup} = await freshImports();
const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports();
mockGetConnection.mockResolvedValue(makeConn([]));
await syncNewsletterSignup(999);
@@ -81,8 +78,7 @@ describe('syncNewsletterSignup - disabled mode', () => {
describe('syncNewsletterSignup - enabled mode', () => {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
jest.resetModules();
process.env = {
...ORIGINAL_ENV,
SALESFORCE_ENABLED: 'true',
@@ -93,7 +89,7 @@ describe('syncNewsletterSignup - enabled mode', () => {
});
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 {axios, mockGetConnection, syncNewsletterSignup} = freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
axios.post.mockImplementation((url: string) => {
@@ -120,7 +116,7 @@ describe('syncNewsletterSignup - enabled mode', () => {
});
it('reuses the cached token across two calls instead of fetching twice', async () => {
const {axios, mockGetConnection, syncNewsletterSignup} = await freshImports();
const {axios, mockGetConnection, syncNewsletterSignup} = freshImports();
mockGetConnection
.mockResolvedValueOnce(makeConn([SIGNUP_ROW]))
.mockResolvedValueOnce(makeConn([]))
@@ -139,7 +135,7 @@ describe('syncNewsletterSignup - enabled mode', () => {
});
it('retries once with a fresh token on a 401, then succeeds', async () => {
const {axios, mockGetConnection, syncNewsletterSignup} = await freshImports();
const {axios, mockGetConnection, syncNewsletterSignup} = freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
@@ -167,7 +163,7 @@ describe('syncNewsletterSignup - enabled mode', () => {
});
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 {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
axios.post.mockImplementation((url: string) => {
@@ -188,7 +184,7 @@ describe('syncNewsletterSignup - enabled mode', () => {
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 {mockGetConnection, syncNewsletterSignup} = freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
+1 -2
View File
@@ -1,5 +1,4 @@
import {describe, it, expect} from 'vitest';
import {MAX_ANSWER_ROWS, validateAnswers, validateGuestBook, validateNewsletter} from '../../src/models/feedback/public/submissions.service.js';
import {MAX_ANSWER_ROWS, validateAnswers, validateGuestBook, validateNewsletter} from '../../src/models/feedback/public/submissions.service';
type QuestionLookup = Map<number, {eventQuestionId: number; questionId: number; type: 'SONG_PICK' | 'SONG_RATING' | 'FREE_TEXT'; label: string; position: number}>;
-1
View File
@@ -1,4 +1,3 @@
import {test, expect} from 'vitest';
test('Test template', async () => {
expect(true).toBe(true);
});
-132
View File
@@ -1,132 +0,0 @@
// 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();
});
});
@@ -1,89 +0,0 @@
// 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);
});
});
+98 -15
View File
@@ -1,20 +1,103 @@
{
"compilerOptions": {
/* 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"],
/* Visit https://aka.ms/tsconfig to read more about this file */
"outDir": "./dist",
"inlineSourceMap": true,
/* 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. */
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["app.ts", "src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
/* 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. */
}
}
-25
View File
@@ -1,25 +0,0 @@
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']
}
}
});