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
30 changed files with 24 additions and 2598 deletions
-4
View File
@@ -19,10 +19,6 @@ SALESFORCE_API_URL=
SALESFORCE_CLIENT_ID= SALESFORCE_CLIENT_ID=
SALESFORCE_CLIENT_SECRET= SALESFORCE_CLIENT_SECRET=
TICKETS_DB=
TICKETS_RATE_LIMIT_MAX=10
TICKETS_RATE_LIMIT_WINDOW_MIN=10
MEMBER_CREDENTIAL=123 MEMBER_CREDENTIAL=123
CHOIR_CREDENTIAL=123 CHOIR_CREDENTIAL=123
MANAGEMENT_CREDENTIAL=123 MANAGEMENT_CREDENTIAL=123
+1 -4
View File
@@ -8,7 +8,6 @@ import logger from './src/middleware/logger';
// Router imports // Router imports
import {calendarRouter} from './src/models/calendar/Calendar.router'; import {calendarRouter} from './src/models/calendar/Calendar.router';
import {feedbackRouter} from './src/models/feedback/Feedback.router'; import {feedbackRouter} from './src/models/feedback/Feedback.router';
import {ticketsRouter} from './src/models/tickets/Tickets.router';
let cors = require('cors'); let cors = require('cors');
@@ -37,8 +36,7 @@ app.use(express.json());
let allowedHosts = [ let allowedHosts = [
'https://www.nachklang.art', 'https://www.nachklang.art',
'https://calendar.nachklang.art', 'https://calendar.nachklang.art',
'https://feedback.nachklang.art', 'https://feedback.nachklang.art'
'https://tickets.nachklang.art'
]; ];
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV !== 'production';
const localhostRegex = /^http:\/\/localhost:\d+$/; const localhostRegex = /^http:\/\/localhost:\d+$/;
@@ -106,7 +104,6 @@ app.use(
// Add routers // Add routers
app.use('/calendar', calendarRouter); app.use('/calendar', calendarRouter);
app.use('/feedback', feedbackRouter); app.use('/feedback', feedbackRouter);
app.use('/tickets', ticketsRouter);
// this is a simple route to make sure everything is working properly // this is a simple route to make sure everything is working properly
app.get('/', (req: express.Request, res: express.Response) => { 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;
-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;
+20 -24
View File
@@ -13,30 +13,26 @@ export namespace MailService {
tls: {rejectUnauthorized: false} tls: {rejectUnauthorized: false}
}); });
export interface MailAttachment { const mailConfigurations = {
filename: string;
content: string | Buffer;
contentType?: string;
}
export interface SendMailOptions { // It should be a string of sender email
html?: string; from: 'noreply@nachklang.art',
attachments?: MailAttachment[];
}
// Builds a fresh options object per call rather than mutating a shared // Comma Separated list of mails
// module-level one - the transporter is pooled, so overlapping sendMail to: 'mail@pmueller.me',
// calls (e.g. two guests redeeming at once) previously risked one
// call's recipient/subject/body being overwritten by another's before // Subject of Email
// transporter.sendMail() read it. subject: '',
export const sendMail = async (recipientAddress: string, subject: string, body: string, options?: SendMailOptions) => {
await transporter.sendMail({ // This would be the text of email body
from: 'noreply@nachklang.art', text: ''
to: recipientAddress,
subject: subject,
text: body,
html: options?.html,
attachments: options?.attachments
});
}; };
}
export const sendMail = async (recipientAddress: string, subject: string, body: string) => {
mailConfigurations.to = recipientAddress;
mailConfigurations.subject = subject;
mailConfigurations.text = body;
await transporter.sendMail(mailConfigurations);
};
}
@@ -70,9 +70,9 @@
* description: The ID of the user who created the event * description: The ID of the user who created the event
* example: 456 * example: 456
* lastModifiedBy: * lastModifiedBy:
* type: string * type: string
* description: The name of the user who last modified the event * description: The name of the user who last modified the event
* example: "John Doe" * example: "John Doe"
* lastModifiedById: * lastModifiedById:
* type: integer * type: integer
* description: The ID of the user who last modified the event * description: The ID of the user who last modified the event
@@ -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 * Create the given event in the database
* @param event The event to create * @param event The event to create
-20
View File
@@ -1,20 +0,0 @@
import * as dotenv from 'dotenv';
const mariadb = require('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,
autoCommit: false
});
export const getConnection = async () => {
return pool.getConnection();
};
}
-8
View File
@@ -1,8 +0,0 @@
import express from 'express';
import {adminRouter} from './admin/admin.router';
import {publicRouter} from './public/public.router';
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';
import {vouchersAdminRouter} from './vouchers.admin.router';
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router';
import {eventsAdminRouter} from './events.admin.router';
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';
import {sendServerError} from '../tickets.errors';
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';
import {NachklangTicketsDB} from '../Tickets.db';
import {getEventTicketState} from '../tickets.capacity';
import {EventStats, EventTicketSettings} from '../tickets.interface';
// 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,240 +0,0 @@
import express, {Request, Response} from 'express';
import * as RedemptionsAdminService from './redemptions.admin.service';
import {sendServerError} from '../tickets.errors';
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/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,249 +0,0 @@
import {NachklangTicketsDB} from '../Tickets.db';
import {getEventTicketState} from '../tickets.capacity';
import {AuditLogEntry, RedemptionSummary} from '../tickets.interface';
import {isValidEmail} from '../tickets.validation';
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
});
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 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';
import {sendServerError} from '../tickets.errors';
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';
import {generateUniqueCode} from '../tickets.codes';
import {VoucherCode, VoucherStatus} from '../tickets.interface';
import {isValidEmail} from '../tickets.validation';
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';
import {sendServerError} from '../tickets.errors';
import {hashIp, redeemLimiter, validateLimiter} from '../tickets.ratelimit';
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,215 +0,0 @@
import * as EventsService from '../../calendar/events/events.service';
import * as IcalService from '../../calendar/events/icalgenerator.service';
import {MailService} from '../../../common/common.mail.nodemailer';
import logger from '../../../middleware/logger';
import {NachklangTicketsDB} from '../Tickets.db';
import {getEventTicketState} from '../tickets.capacity';
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface';
import {isValidEmail} from '../tickets.validation';
const formatGermanDateTime = (date: Date): string => {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'Europe/Berlin'
}).format(date);
};
/**
* 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. Caught
// rather than left to propagate - the redemption already succeeded, so
// a mail-server hiccup must not turn into a false failure response to
// a guest who has, in fact, already secured their spot.
try {
await sendConfirmationEmail(eventId, request, redemptionId);
} catch (e: any) {
logger.error('Redemption ' + redemptionId + ' succeeded but confirmation email failed to send: ' + e.message);
}
return {status: 'OK', redemptionId};
};
const sendConfirmationEmail = async (eventId: number, request: RedeemRequest, redemptionId: number): Promise<void> => {
const event = await EventsService.getEventById(eventId);
if (!event) return;
const guestList = request.guests.map(g => `- ${g.name}`).join('\n');
const body = `Hallo ${request.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 {
icsAttachment = undefined;
}
await MailService.sendMail(
request.contactEmail,
`Bestätigung: ${event.name}`,
body,
{attachments: icsAttachment}
);
};
-63
View File
@@ -1,63 +0,0 @@
import express from 'express';
import * as UserService from '../calendar/users/users.service';
import {sendServerError} from './tickets.errors';
/**
* 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');
};
-18
View File
@@ -1,18 +0,0 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger';
/**
* 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
});
};
-300
View File
@@ -1,300 +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
* 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;
}
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());