From b6499eb7b3e5e2ed75f1281afbf75897ac4dc8ce Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Sun, 23 Aug 2026 21:47:50 +0200 Subject: [PATCH] Add Tickets domain for the voucher-based ticket shop New domain mirroring the Feedback module's conventions: single-use voucher codes (wildcard + personalized), redemption with race-safe capacity and deadline enforcement, admin CRUD for vouchers/redemptions/event settings with an audit trail, and reuse of the existing Calendar session auth. Backend pieces: - Tickets domain (public redeem flow, admin vouchers/redemptions/events) - Calendar events.service.ts: add getEventById - Mailer: attachment/HTML support, fix a shared-mutable-state race - Per-event capacity/deadline/address settings, with an "address required" option on top of "address collected" - Email format validation on redeem and admin voucher/redemption input - Docker Compose dev stack (MariaDB + seed data) for local testing Co-Authored-By: Claude Sonnet 5 --- .env.example | 4 + app.ts | 5 +- docker-compose.dev.yml | 28 ++ docker/init/00-databases.sql | 10 + docker/init/01-calendar-schema-dev.sql | 89 ++++++ docker/init/02-feedback-schema.sql | 3 + docker/init/03-tickets-schema.sql | 3 + sql/tickets/001_init.sql | 102 ++++++ sql/tickets/002_add_require_address.sql | 7 + src/common/common.mail.nodemailer.ts | 44 +-- src/models/calendar/events/event.interface.ts | 6 +- src/models/calendar/events/events.service.ts | 58 ++++ src/models/tickets/Tickets.db.ts | 20 ++ src/models/tickets/Tickets.router.ts | 8 + src/models/tickets/admin/admin.router.ts | 35 ++ .../tickets/admin/events.admin.router.ts | 179 +++++++++++ .../tickets/admin/events.admin.service.ts | 155 +++++++++ .../tickets/admin/redemptions.admin.router.ts | 240 ++++++++++++++ .../admin/redemptions.admin.service.ts | 249 +++++++++++++++ .../tickets/admin/vouchers.admin.router.ts | 249 +++++++++++++++ .../tickets/admin/vouchers.admin.service.ts | 222 +++++++++++++ src/models/tickets/public/public.router.ts | 135 ++++++++ .../tickets/public/voucher.public.service.ts | 203 ++++++++++++ src/models/tickets/tickets.auth.ts | 63 ++++ src/models/tickets/tickets.capacity.ts | 41 +++ src/models/tickets/tickets.codes.ts | 38 +++ src/models/tickets/tickets.errors.ts | 18 ++ src/models/tickets/tickets.interface.ts | 300 ++++++++++++++++++ src/models/tickets/tickets.ratelimit.ts | 68 ++++ src/models/tickets/tickets.validation.ts | 6 + 30 files changed, 2564 insertions(+), 24 deletions(-) create mode 100644 docker-compose.dev.yml create mode 100644 docker/init/00-databases.sql create mode 100644 docker/init/01-calendar-schema-dev.sql create mode 100644 docker/init/02-feedback-schema.sql create mode 100644 docker/init/03-tickets-schema.sql create mode 100644 sql/tickets/001_init.sql create mode 100644 sql/tickets/002_add_require_address.sql create mode 100644 src/models/tickets/Tickets.db.ts create mode 100644 src/models/tickets/Tickets.router.ts create mode 100644 src/models/tickets/admin/admin.router.ts create mode 100644 src/models/tickets/admin/events.admin.router.ts create mode 100644 src/models/tickets/admin/events.admin.service.ts create mode 100644 src/models/tickets/admin/redemptions.admin.router.ts create mode 100644 src/models/tickets/admin/redemptions.admin.service.ts create mode 100644 src/models/tickets/admin/vouchers.admin.router.ts create mode 100644 src/models/tickets/admin/vouchers.admin.service.ts create mode 100644 src/models/tickets/public/public.router.ts create mode 100644 src/models/tickets/public/voucher.public.service.ts create mode 100644 src/models/tickets/tickets.auth.ts create mode 100644 src/models/tickets/tickets.capacity.ts create mode 100644 src/models/tickets/tickets.codes.ts create mode 100644 src/models/tickets/tickets.errors.ts create mode 100644 src/models/tickets/tickets.interface.ts create mode 100644 src/models/tickets/tickets.ratelimit.ts create mode 100644 src/models/tickets/tickets.validation.ts diff --git a/.env.example b/.env.example index 77d02c6..d258a41 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,10 @@ SALESFORCE_API_URL= SALESFORCE_CLIENT_ID= SALESFORCE_CLIENT_SECRET= +TICKETS_DB= +TICKETS_RATE_LIMIT_MAX=10 +TICKETS_RATE_LIMIT_WINDOW_MIN=10 + MEMBER_CREDENTIAL=123 CHOIR_CREDENTIAL=123 MANAGEMENT_CREDENTIAL=123 diff --git a/app.ts b/app.ts index 4160e6a..471175c 100644 --- a/app.ts +++ b/app.ts @@ -8,6 +8,7 @@ import logger from './src/middleware/logger'; // Router imports import {calendarRouter} from './src/models/calendar/Calendar.router'; import {feedbackRouter} from './src/models/feedback/Feedback.router'; +import {ticketsRouter} from './src/models/tickets/Tickets.router'; let cors = require('cors'); @@ -36,7 +37,8 @@ app.use(express.json()); let allowedHosts = [ 'https://www.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 localhostRegex = /^http:\/\/localhost:\d+$/; @@ -104,6 +106,7 @@ app.use( // Add routers app.use('/calendar', calendarRouter); app.use('/feedback', feedbackRouter); +app.use('/tickets', ticketsRouter); // this is a simple route to make sure everything is working properly app.get('/', (req: express.Request, res: express.Response) => { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..074df40 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,28 @@ +# Local dev only — not used in production/deployment. Spins up a MariaDB +# instance with the calendar (reconstructed dev schema, see docker/init's +# disclaimer), feedback, and tickets databases pre-seeded. +# +# Usage: +# docker compose -f docker-compose.dev.yml up -d +# +# Then point .env at: +# DB_HOST=127.0.0.1 +# DB_USER=nachklang +# DB_PASSWORD=devpassword +# CALENDAR_DB=nachklang_calendar +# FEEDBACK_DB=nachklang_feedback +# TICKETS_DB=nachklang_tickets +services: + mariadb: + image: mariadb:11 + environment: + MARIADB_ROOT_PASSWORD: rootdevpassword + ports: + - "3306:3306" + volumes: + - nachklang_dev_db:/var/lib/mysql + - ./sql:/migrations:ro + - ./docker/init:/docker-entrypoint-initdb.d:ro + +volumes: + nachklang_dev_db: diff --git a/docker/init/00-databases.sql b/docker/init/00-databases.sql new file mode 100644 index 0000000..08bbe1d --- /dev/null +++ b/docker/init/00-databases.sql @@ -0,0 +1,10 @@ +-- Local dev only. Creates the three databases + a dev user with full access. +CREATE DATABASE IF NOT EXISTS nachklang_calendar CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS nachklang_feedback CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS nachklang_tickets CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE USER IF NOT EXISTS 'nachklang'@'%' IDENTIFIED BY 'devpassword'; +GRANT ALL PRIVILEGES ON nachklang_calendar.* TO 'nachklang'@'%'; +GRANT ALL PRIVILEGES ON nachklang_feedback.* TO 'nachklang'@'%'; +GRANT ALL PRIVILEGES ON nachklang_tickets.* TO 'nachklang'@'%'; +FLUSH PRIVILEGES; diff --git a/docker/init/01-calendar-schema-dev.sql b/docker/init/01-calendar-schema-dev.sql new file mode 100644 index 0000000..e70493b --- /dev/null +++ b/docker/init/01-calendar-schema-dev.sql @@ -0,0 +1,89 @@ +-- Local dev only. Real schema, provided directly by the repo owner +-- (calendars, events, event_versions, sessions, users) - not a guess. +USE nachklang_calendar; + +CREATE TABLE `calendars` ( + `calendar_id` int(11) NOT NULL AUTO_INCREMENT, + `name` text NOT NULL, + `includes_calendars` text DEFAULT NULL, + PRIMARY KEY (`calendar_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `users` ( + `user_id` int(11) NOT NULL AUTO_INCREMENT, + `full_name` text NOT NULL, + `password_hash` text DEFAULT NULL, + `email` text NOT NULL, + `is_active` tinyint(1) DEFAULT 0, + `pw_reset_token_hash` text DEFAULT NULL, + `activation_token` text DEFAULT NULL, + PRIMARY KEY (`user_id`), + UNIQUE KEY `email` (`email`) USING HASH +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `sessions` ( + `session_id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `session_key_hash` text DEFAULT NULL, + `created_date` datetime DEFAULT current_timestamp(), + `valid_until` datetime DEFAULT (current_timestamp() + interval 30 day), + `last_ip` text DEFAULT NULL, + PRIMARY KEY (`session_id`), + KEY `sessions_users_user_id_fk` (`user_id`), + CONSTRAINT `sessions_users_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `events` ( + `event_id` int(11) NOT NULL AUTO_INCREMENT, + `calendar_id` int(11) NOT NULL, + `uuid` text NOT NULL, + `created_date` datetime DEFAULT current_timestamp(), + `created_by_id` int(11) NOT NULL, + PRIMARY KEY (`event_id`), + KEY `events_calendars_calendar_id_fk` (`calendar_id`), + KEY `events_users_user_id_fk` (`created_by_id`), + CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`), + CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `event_versions` ( + `event_version_id` int(11) NOT NULL AUTO_INCREMENT, + `event_id` int(11) NOT NULL, + `name` text DEFAULT NULL, + `description` text DEFAULT NULL, + `start_datetime` datetime DEFAULT NULL, + `end_datetime` datetime DEFAULT NULL, + `whole_day` tinyint(1) DEFAULT NULL, + `repeat_frequency` text DEFAULT NULL, + `location` text DEFAULT NULL, + `url` text DEFAULT NULL, + `version_created_by_id` int(11) DEFAULT NULL, + `status` text DEFAULT NULL, + `version_created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`event_version_id`), + KEY `event_versions_events_event_id_fk` (`event_id`), + KEY `event_versions_users_user_id_fk` (`version_created_by_id`), + CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES + (1, 'public', '[]'), + (2, 'members', '[]'), + (3, 'management', '[]'), + (4, 'choir', '[]'), + (5, 'birthdays', '[]'); + +-- Dev admin, password: devpassword +INSERT INTO users (email, password_hash, full_name, is_active) VALUES + ('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1); + +INSERT INTO events (calendar_id, uuid, created_by_id) VALUES + (1, UUID(), 1), + (1, UUID(), 1), + (1, UUID(), 1); + +INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id) VALUES + (1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', 1), + (2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 1), + (3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', 1); diff --git a/docker/init/02-feedback-schema.sql b/docker/init/02-feedback-schema.sql new file mode 100644 index 0000000..26546b0 --- /dev/null +++ b/docker/init/02-feedback-schema.sql @@ -0,0 +1,3 @@ +USE nachklang_feedback; +SOURCE /migrations/feedback/001_init.sql; +SOURCE /migrations/feedback/002_add_poster_image_url.sql; diff --git a/docker/init/03-tickets-schema.sql b/docker/init/03-tickets-schema.sql new file mode 100644 index 0000000..73bb372 --- /dev/null +++ b/docker/init/03-tickets-schema.sql @@ -0,0 +1,3 @@ +USE nachklang_tickets; +SOURCE /migrations/tickets/001_init.sql; +SOURCE /migrations/tickets/002_add_require_address.sql; diff --git a/sql/tickets/001_init.sql b/sql/tickets/001_init.sql new file mode 100644 index 0000000..f8b0739 --- /dev/null +++ b/sql/tickets/001_init.sql @@ -0,0 +1,102 @@ +-- Nachklang e.V. Tickets module — initial schema for TICKETS_DB +-- Apply manually against the TICKETS_DB database (separate from CALENDAR_DB +-- and FEEDBACK_DB). See nachklang-tickets/docs/plan-ticket-shop.md for the +-- full design rationale. +-- +-- Apply with e.g.: +-- mysql -h -u -p < 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; diff --git a/sql/tickets/002_add_require_address.sql b/sql/tickets/002_add_require_address.sql new file mode 100644 index 0000000..10e83a5 --- /dev/null +++ b/sql/tickets/002_add_require_address.sql @@ -0,0 +1,7 @@ +-- Nachklang e.V. Tickets module — adds a per-event "address required" flag, +-- distinct from collect_address (which only controls whether the field is +-- shown/collected at all). Apply manually against TICKETS_DB, after +-- 001_init.sql: +-- mysql -h -u -p < 002_add_require_address.sql +ALTER TABLE event_ticket_settings + ADD COLUMN require_address TINYINT(1) NOT NULL DEFAULT 0 AFTER collect_address; diff --git a/src/common/common.mail.nodemailer.ts b/src/common/common.mail.nodemailer.ts index 925669f..aa2e9f8 100644 --- a/src/common/common.mail.nodemailer.ts +++ b/src/common/common.mail.nodemailer.ts @@ -13,26 +13,30 @@ export namespace MailService { tls: {rejectUnauthorized: false} }); - const mailConfigurations = { + export interface MailAttachment { + filename: string; + content: string | Buffer; + contentType?: string; + } - // It should be a string of sender email - from: 'noreply@nachklang.art', + export interface SendMailOptions { + html?: string; + attachments?: MailAttachment[]; + } - // Comma Separated list of mails - to: 'mail@pmueller.me', - - // Subject of Email - subject: '', - - // This would be the text of email body - text: '' + // Builds a fresh options object per call rather than mutating a shared + // module-level one - the transporter is pooled, so overlapping sendMail + // calls (e.g. two guests redeeming at once) previously risked one + // call's recipient/subject/body being overwritten by another's before + // transporter.sendMail() read it. + export const sendMail = async (recipientAddress: string, subject: string, body: string, options?: SendMailOptions) => { + await transporter.sendMail({ + from: 'noreply@nachklang.art', + 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); - }; -} \ No newline at end of file +} diff --git a/src/models/calendar/events/event.interface.ts b/src/models/calendar/events/event.interface.ts index 0a9abf5..cafb87f 100644 --- a/src/models/calendar/events/event.interface.ts +++ b/src/models/calendar/events/event.interface.ts @@ -70,9 +70,9 @@ * description: The ID of the user who created the event * example: 456 * lastModifiedBy: - * type: string - * description: The name of the user who last modified the event - * example: "John Doe" + * type: string + * description: The name of the user who last modified the event + * example: "John Doe" * lastModifiedById: * type: integer * description: The ID of the user who last modified the event diff --git a/src/models/calendar/events/events.service.ts b/src/models/calendar/events/events.service.ts index c696ac6..54122b7 100644 --- a/src/models/calendar/events/events.service.ts +++ b/src/models/calendar/events/events.service.ts @@ -126,6 +126,64 @@ export const getAllEventsAdmin = async (calendarId: number): Promise => } }; +/** + * 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 => { + let conn = await NachklangCalendarDB.getConnection(); + try { + const eventsQuery = ` + SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e + INNER JOIN ( + SELECT event_id, MAX(event_version_id) AS latest_version + FROM event_versions + GROUP BY event_id + ) latest_versions + ON e.event_id = latest_versions.event_id + INNER JOIN event_versions v + ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version + LEFT OUTER JOIN users u ON u.user_id = e.created_by_id + LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id + WHERE e.event_id = ?`; + const eventsRes = await conn.query(eventsQuery, eventId); + + if (eventsRes.length === 0) { + return null; + } + + const row = eventsRes[0]; + return { + eventId: row.event_id, + calendarId: row.calendar_id, + uuid: row.uuid, + name: row.name, + description: row.description, + startDateTime: row.start_datetime, + endDateTime: row.end_datetime, + createdDate: row.created_date, + lastModifiedDate: row.version_created_at, + location: row.location, + createdBy: row.created_by_name, + createdById: row.created_by_id, + lastModifiedBy: row.last_modified_by_name, + lastModifiedById: row.version_created_by_id, + url: row.url, + wholeDay: row.whole_day, + repeatFrequency: row.repeat_frequency, + status: row.status + } as Event; + } catch (err) { + throw err; + } finally { + // Return connection + await conn.end(); + } +}; + /** * Create the given event in the database * @param event The event to create diff --git a/src/models/tickets/Tickets.db.ts b/src/models/tickets/Tickets.db.ts new file mode 100644 index 0000000..883c0e8 --- /dev/null +++ b/src/models/tickets/Tickets.db.ts @@ -0,0 +1,20 @@ +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(); + }; +} diff --git a/src/models/tickets/Tickets.router.ts b/src/models/tickets/Tickets.router.ts new file mode 100644 index 0000000..63c6486 --- /dev/null +++ b/src/models/tickets/Tickets.router.ts @@ -0,0 +1,8 @@ +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); diff --git a/src/models/tickets/admin/admin.router.ts b/src/models/tickets/admin/admin.router.ts new file mode 100644 index 0000000..6648872 --- /dev/null +++ b/src/models/tickets/admin/admin.router.ts @@ -0,0 +1,35 @@ +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); diff --git a/src/models/tickets/admin/events.admin.router.ts b/src/models/tickets/admin/events.admin.router.ts new file mode 100644 index 0000000..8a8b3ff --- /dev/null +++ b/src/models/tickets/admin/events.admin.router.ts @@ -0,0 +1,179 @@ +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); + } +}); diff --git a/src/models/tickets/admin/events.admin.service.ts b/src/models/tickets/admin/events.admin.service.ts new file mode 100644 index 0000000..05352f7 --- /dev/null +++ b/src/models/tickets/admin/events.admin.service.ts @@ -0,0 +1,155 @@ +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 => { + 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 => 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 => { + let conn = await NachklangTicketsDB.getConnection(); + let enabledEventIds: Set; + 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 => { + 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): Promise => { + 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 => { + 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(); + } +}; diff --git a/src/models/tickets/admin/redemptions.admin.router.ts b/src/models/tickets/admin/redemptions.admin.router.ts new file mode 100644 index 0000000..d047e28 --- /dev/null +++ b/src/models/tickets/admin/redemptions.admin.router.ts @@ -0,0 +1,240 @@ +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); + } +}); diff --git a/src/models/tickets/admin/redemptions.admin.service.ts b/src/models/tickets/admin/redemptions.admin.service.ts new file mode 100644 index 0000000..383883b --- /dev/null +++ b/src/models/tickets/admin/redemptions.admin.service.ts @@ -0,0 +1,249 @@ +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 => { + 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(); + 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 => { + 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 => { + 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 => { + 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 = {}; + 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 => { + 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(); + } +}; diff --git a/src/models/tickets/admin/vouchers.admin.router.ts b/src/models/tickets/admin/vouchers.admin.router.ts new file mode 100644 index 0000000..179553b --- /dev/null +++ b/src/models/tickets/admin/vouchers.admin.router.ts @@ -0,0 +1,249 @@ +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); + } +}); diff --git a/src/models/tickets/admin/vouchers.admin.service.ts b/src/models/tickets/admin/vouchers.admin.service.ts new file mode 100644 index 0000000..a10ecf6 --- /dev/null +++ b/src/models/tickets/admin/vouchers.admin.service.ts @@ -0,0 +1,222 @@ +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: [] +}); + +/** + * 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 => { + 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(); + 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 => { + 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(); + + const existingRows = await conn.query('SELECT code FROM voucher_codes'); + const existingCodes = new Set(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 => { + 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(); + + const existingRows = await conn.query('SELECT code FROM voucher_codes'); + const existingCodes = new Set(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 => { + 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 => { + 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 => { + 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(); + } +}; diff --git a/src/models/tickets/public/public.router.ts b/src/models/tickets/public/public.router.ts new file mode 100644 index 0000000..8d82991 --- /dev/null +++ b/src/models/tickets/public/public.router.ts @@ -0,0 +1,135 @@ +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}); + } +}); diff --git a/src/models/tickets/public/voucher.public.service.ts b/src/models/tickets/public/voucher.public.service.ts new file mode 100644 index 0000000..415ca2c --- /dev/null +++ b/src/models/tickets/public/voucher.public.service.ts @@ -0,0 +1,203 @@ +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 that were deleted from the calendar since + * the code was generated are silently skipped rather than erroring. + */ +export const validateVoucher = async (code: string): Promise => { + 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) 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 => { + 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'}; + } + + 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 => { + 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} + ); +}; diff --git a/src/models/tickets/tickets.auth.ts b/src/models/tickets/tickets.auth.ts new file mode 100644 index 0000000..6904313 --- /dev/null +++ b/src/models/tickets/tickets.auth.ts @@ -0,0 +1,63 @@ +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; + +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); + } +}; diff --git a/src/models/tickets/tickets.capacity.ts b/src/models/tickets/tickets.capacity.ts new file mode 100644 index 0000000..36b328f --- /dev/null +++ b/src/models/tickets/tickets.capacity.ts @@ -0,0 +1,41 @@ +export interface EventTicketState { + eventId: number; + capacity: number | null; + redemptionDeadline: Date | null; + collectAddress: boolean; + requireAddress: boolean; + guestsUsed: number; + spotsRemaining: number | null; +} + +/** + * Reads an event's voucher settings + live guest count within the caller's + * connection/transaction. Absence of a settings row means uncapped/no + * deadline/no address collection - the "absence over sentinels" convention + * also used by the Feedback module. + * + * Pass forUpdate=true from inside the redeem transaction to lock the + * settings row for the duration of that transaction, serializing concurrent + * redemptions against the same event so the guestsUsed sum computed here + * stays correct even under a last-spot race. Events with no settings row + * (uncapped) don't need this - there's no cap to race against. + */ +export const getEventTicketState = async (conn: any, eventId: number, forUpdate = false): Promise => { + 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}; +}; diff --git a/src/models/tickets/tickets.codes.ts b/src/models/tickets/tickets.codes.ts new file mode 100644 index 0000000..65560fb --- /dev/null +++ b/src/models/tickets/tickets.codes.ts @@ -0,0 +1,38 @@ +import * as crypto from 'crypto'; + +// Excludes 0/O, 1/I/L to avoid look-alike confusion when a code is +// hand-written, read aloud, or typed from a printed fallback under a QR +// code. 31 symbols * 8 chars ≈ 39.6 bits of entropy - effectively +// unguessable combined with rate-limiting on the redeem endpoint. +const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; +const CODE_LENGTH = 8; + +/** + * Generates a single random code. Uses crypto.randomInt (uniform, no + * modulo bias) rather than Math.random() since these gate a real-world + * concert invitation. + */ +export const generateCode = (): string => { + let code = ''; + for (let i = 0; i < CODE_LENGTH; i++) { + code += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)]; + } + return code; +}; + +/** + * Generates a code guaranteed not to collide with any row `existingCodes` + * already contains, tries up to `maxAttempts` times before giving up. + * Collisions are astronomically unlikely at this entropy - this exists as + * a correctness backstop, not because collisions are expected. + */ +export const generateUniqueCode = (existingCodes: Set, 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'); +}; diff --git a/src/models/tickets/tickets.errors.ts b/src/models/tickets/tickets.errors.ts new file mode 100644 index 0000000..1b508d8 --- /dev/null +++ b/src/models/tickets/tickets.errors.ts @@ -0,0 +1,18 @@ +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 + }); +}; diff --git a/src/models/tickets/tickets.interface.ts b/src/models/tickets/tickets.interface.ts new file mode 100644 index 0000000..b9adce2 --- /dev/null +++ b/src/models/tickets/tickets.interface.ts @@ -0,0 +1,300 @@ +/** + * @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 | null; + reason: string | null; + createdAt: Date; +} diff --git a/src/models/tickets/tickets.ratelimit.ts b/src/models/tickets/tickets.ratelimit.ts new file mode 100644 index 0000000..a357c7d --- /dev/null +++ b/src/models/tickets/tickets.ratelimit.ts @@ -0,0 +1,68 @@ +import * as crypto from 'crypto'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.TICKETS_RATE_LIMIT_WINDOW_MIN || '10', 10); +const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MIN * 60 * 1000; + +// Salted per-process (not persisted/configured) - these limiters are +// in-memory-only with no DB backstop, so the salt only needs to survive +// for the current process's lifetime, unlike Feedback's FEEDBACK_IP_SALT +// which also salts a persisted ip_hash column. +const IP_SALT = crypto.randomBytes(32).toString('hex'); + +export const hashIp = (ip: string): string => { + return crypto.createHash('sha256').update(IP_SALT + ip).digest('hex'); +}; + +/** + * Two independent budgets, not one shared counter: validating a code (GET) + * is a cheap, repeatable lookup a guest's own browser triggers on every + * page load/reload/back-navigation of their redemption link - a shared + * budget with redeem meant a guest could exhaust it just by reloading the + * page a few times before ever submitting. Redeeming (POST) is the + * sensitive, code-consuming action and stays tightly limited; validating + * is limited too (it's still the enumeration vector for guessing codes), + * just with a much larger allowance headroomed for normal page-reload + * behaviour. + */ +const createLimiter = (max: number) => { + const recentRequests = new Map(); + + 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)); diff --git a/src/models/tickets/tickets.validation.ts b/src/models/tickets/tickets.validation.ts new file mode 100644 index 0000000..b29fd50 --- /dev/null +++ b/src/models/tickets/tickets.validation.ts @@ -0,0 +1,6 @@ +// Intentionally permissive - "looks like an email" (something@something.tld), +// not full RFC 5322 validation. Good enough to catch typos without rejecting +// real addresses RFC 5322 edge cases would. +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const isValidEmail = (email: string): boolean => EMAIL_REGEX.test(email.trim());