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 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 21:47:50 +02:00
parent b05f6b9da0
commit b6499eb7b3
30 changed files with 2564 additions and 24 deletions
+102
View File
@@ -0,0 +1,102 @@
-- Nachklang e.V. Tickets module — initial schema for TICKETS_DB
-- Apply manually against the TICKETS_DB database (separate from CALENDAR_DB
-- and FEEDBACK_DB). See nachklang-tickets/docs/plan-ticket-shop.md for the
-- full design rationale.
--
-- Apply with e.g.:
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 001_init.sql
--
-- Deliberately no USE statement here: the target database is selected via
-- the mysql command line above (whatever TICKETS_DB is actually named in
-- .env), not hardcoded to a literal schema name.
--
-- `event_id` columns below refer to Calendar's `events.event_id` (a
-- different database). Deliberately no cross-database foreign key —
-- Tickets reads Calendar events via events.service.ts in the same Node
-- process, not via a DB-level join.
-- 1. voucher_codes --------------------------------------------------------
CREATE TABLE voucher_codes (
code VARCHAR(12) NOT NULL PRIMARY KEY,
status ENUM('UNUSED','REDEEMED','VOID') NOT NULL DEFAULT 'UNUSED',
max_guests INT NOT NULL DEFAULT 2,
prefill_name VARCHAR(255) NULL,
prefill_email VARCHAR(255) NULL,
batch_id VARCHAR(36) NULL,
created_by_email VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_vc_batch (batch_id),
KEY idx_vc_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. voucher_code_events (join: which concerts a code may be redeemed for) -
CREATE TABLE voucher_code_events (
voucher_code_event_id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(12) NOT NULL,
event_id INT NOT NULL,
CONSTRAINT fk_vce_code FOREIGN KEY (code) REFERENCES voucher_codes(code) ON DELETE CASCADE,
UNIQUE KEY uq_vce_code_event (code, event_id),
KEY idx_vce_event (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. event_ticket_settings (per-concert voucher config) ---------------------
-- One row per Calendar event_id that has ever had voucher settings
-- configured. Absent row == uncapped, no deadline, address not collected
-- (see docs/plan-ticket-shop.md — "absence over sentinels").
CREATE TABLE event_ticket_settings (
event_id INT NOT NULL PRIMARY KEY,
capacity INT NULL,
redemption_deadline DATETIME NULL,
collect_address TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. redemptions ------------------------------------------------------------
-- `status` is soft-state rather than a hard delete on undo, so guest data
-- and history survive an undo for the audit trail. Capacity/reporting
-- queries filter status = 'ACTIVE'. A code that gets redeemed again after
-- being undone creates a new row here rather than reviving the old one.
CREATE TABLE redemptions (
redemption_id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(12) NOT NULL,
event_id INT NOT NULL,
status ENUM('ACTIVE','UNDONE') NOT NULL DEFAULT 'ACTIVE',
contact_name VARCHAR(255) NOT NULL,
contact_email VARCHAR(255) NOT NULL,
contact_address TEXT NULL,
guest_count INT NOT NULL,
redeemed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_red_code FOREIGN KEY (code) REFERENCES voucher_codes(code),
KEY idx_red_event_status (event_id, status),
KEY idx_red_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. redemption_guests --------------------------------------------------------
-- One row per attendee, including the primary contact (position 0).
CREATE TABLE redemption_guests (
redemption_guest_id INT AUTO_INCREMENT PRIMARY KEY,
redemption_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
position INT NOT NULL DEFAULT 0,
CONSTRAINT fk_rg_redemption FOREIGN KEY (redemption_id) REFERENCES redemptions(redemption_id) ON DELETE CASCADE,
KEY idx_rg_redemption (redemption_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6. voucher_audit_log ----------------------------------------------------
-- Lightweight admin-action history per code (not a full version-history
-- system) — who did what and why. Covers EDIT/VOID/UNDO only; the guest's
-- own redemption isn't an admin action so it isn't logged here (it's
-- already timestamped on `redemptions.redeemed_at`).
CREATE TABLE voucher_audit_log (
audit_id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(12) NOT NULL,
redemption_id INT NULL,
admin_email VARCHAR(255) NOT NULL,
action ENUM('EDIT','VOID','UNDO') NOT NULL,
change_summary JSON NULL,
reason VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_val_code FOREIGN KEY (code) REFERENCES voucher_codes(code) ON DELETE CASCADE,
CONSTRAINT fk_val_redemption FOREIGN KEY (redemption_id) REFERENCES redemptions(redemption_id) ON DELETE SET NULL,
KEY idx_val_code_time (code, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+7
View File
@@ -0,0 +1,7 @@
-- Nachklang e.V. Tickets module — adds a per-event "address required" flag,
-- distinct from collect_address (which only controls whether the field is
-- shown/collected at all). Apply manually against TICKETS_DB, after
-- 001_init.sql:
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 002_add_require_address.sql
ALTER TABLE event_ticket_settings
ADD COLUMN require_address TINYINT(1) NOT NULL DEFAULT 0 AFTER collect_address;