Add Tickets domain for the voucher-based ticket shop (#8)
Jenkins Production Deployment

Reviewed-on: #8
Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech>
Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
This commit was merged in pull request #8.
This commit is contained in:
2026-08-24 21:18:00 +00:00
committed by Patrick Müller
parent b05f6b9da0
commit 3c4f3331d8
30 changed files with 2598 additions and 24 deletions
@@ -0,0 +1,215 @@
import * as EventsService from '../../calendar/events/events.service';
import * as IcalService from '../../calendar/events/icalgenerator.service';
import {MailService} from '../../../common/common.mail.nodemailer';
import logger from '../../../middleware/logger';
import {NachklangTicketsDB} from '../Tickets.db';
import {getEventTicketState} from '../tickets.capacity';
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface';
import {isValidEmail} from '../tickets.validation';
const formatGermanDateTime = (date: Date): string => {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'Europe/Berlin'
}).format(date);
};
/**
* Builds the eligible-events list for a code: for each event it's linked
* to, merges live Calendar event details with the Tickets module's own
* capacity/deadline state. Events deleted from the calendar since the code
* was generated are silently skipped rather than erroring. DRAFT events are
* deliberately still eligible - vouchers are sometimes sent out before a
* concert is publicly announced (see docs/plan-ticket-shop.md), so only
* DELETED is excluded here, not draft/unpublished status.
*/
export const validateVoucher = async (code: string): Promise<VoucherValidation | null> => {
let conn = await NachklangTicketsDB.getConnection();
try {
const voucherRows = await conn.query('SELECT * FROM voucher_codes WHERE code = ?', [code]);
if (voucherRows.length === 0) {
return null;
}
const voucher = voucherRows[0];
const eventIdRows = await conn.query('SELECT event_id FROM voucher_code_events WHERE code = ?', [code]);
const eligibleEvents: EligibleEvent[] = [];
const now = new Date();
for (const row of eventIdRows) {
const eventId = row.event_id;
const event = await EventsService.getEventById(eventId);
if (!event || event.status === 'DELETED') continue;
const ticketState = await getEventTicketState(conn, eventId);
eligibleEvents.push({
eventId,
name: event.name,
startDateTime: event.startDateTime,
location: event.location,
deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline),
isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0,
spotsRemaining: ticketState.spotsRemaining,
collectAddress: ticketState.collectAddress,
requireAddress: ticketState.requireAddress
});
}
eligibleEvents.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
return {
code: voucher.code,
status: voucher.status,
maxGuests: voucher.max_guests,
prefillName: voucher.prefill_name,
prefillEmail: voucher.prefill_email,
eligibleEvents
};
} finally {
await conn.end();
}
};
export type RedeemResult =
| {status: 'OK'; redemptionId: number}
| {status: 'NOT_FOUND'}
| {status: 'ALREADY_USED'}
| {status: 'INVALID_EVENT'}
| {status: 'DEADLINE_PASSED'}
| {status: 'CAPACITY_EXCEEDED'; spotsRemaining: number}
| {status: 'ADDRESS_REQUIRED'};
export const redeemVoucher = async (code: string, request: RedeemRequest): Promise<RedeemResult> => {
if (!request.contactName || !request.contactEmail || !Array.isArray(request.guests) || request.guests.length === 0) {
throw new Error('contactName, contactEmail, and at least one guest are required');
}
if (!isValidEmail(request.contactEmail)) {
throw new Error('contactEmail does not look like a valid email address');
}
for (const guest of request.guests) {
if (!guest.name) {
throw new Error('every guest requires a name');
}
}
let conn = await NachklangTicketsDB.getConnection();
let redemptionId: number;
let eventId: number;
try {
await conn.beginTransaction();
// Locks the code row so two concurrent requests for the same code
// can't both pass the "still UNUSED" check.
const voucherRows = await conn.query('SELECT * FROM voucher_codes WHERE code = ? FOR UPDATE', [code]);
if (voucherRows.length === 0) {
await conn.rollback();
return {status: 'NOT_FOUND'};
}
const voucher = voucherRows[0];
if (voucher.status !== 'UNUSED') {
await conn.rollback();
return {status: 'ALREADY_USED'};
}
const eligibleRows = await conn.query('SELECT 1 FROM voucher_code_events WHERE code = ? AND event_id = ?', [code, request.eventId]);
if (eligibleRows.length === 0) {
await conn.rollback();
return {status: 'INVALID_EVENT'};
}
// A concert can be canceled/deleted from the calendar after codes were
// issued - without this, such a code would stay silently redeemable.
// DRAFT is deliberately still allowed (see validateVoucher's comment).
const event = await EventsService.getEventById(request.eventId);
if (!event || event.status === 'DELETED') {
await conn.rollback();
return {status: 'INVALID_EVENT'};
}
if (request.guests.length > voucher.max_guests) {
await conn.rollback();
throw new Error(`this code allows at most ${voucher.max_guests} guests`);
}
// forUpdate=true serializes concurrent redemptions against this event
// so the capacity check below can't race past a hard cap.
const ticketState = await getEventTicketState(conn, request.eventId, true);
const now = new Date();
if (ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline)) {
await conn.rollback();
return {status: 'DEADLINE_PASSED'};
}
if (ticketState.spotsRemaining !== null && request.guests.length > ticketState.spotsRemaining) {
await conn.rollback();
return {status: 'CAPACITY_EXCEEDED', spotsRemaining: ticketState.spotsRemaining};
}
if (ticketState.requireAddress && !request.contactAddress?.trim()) {
await conn.rollback();
return {status: 'ADDRESS_REQUIRED'};
}
const contactAddress = ticketState.collectAddress ? (request.contactAddress || null) : null;
const redemptionRes = await conn.query(
'INSERT INTO redemptions (code, event_id, contact_name, contact_email, contact_address, guest_count) VALUES (?,?,?,?,?,?) RETURNING redemption_id',
[code, request.eventId, request.contactName, request.contactEmail, contactAddress, request.guests.length]
);
redemptionId = redemptionRes[0].redemption_id;
eventId = request.eventId;
for (let i = 0; i < request.guests.length; i++) {
await conn.query('INSERT INTO redemption_guests (redemption_id, name, position) VALUES (?,?,?)', [redemptionId, request.guests[i].name, i]);
}
await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['REDEEMED', code]);
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
// Sent after commit, mirroring the Calendar/Feedback convention: a mail
// delivery failure shouldn't roll back a successful redemption. Caught
// rather than left to propagate - the redemption already succeeded, so
// a mail-server hiccup must not turn into a false failure response to
// a guest who has, in fact, already secured their spot.
try {
await sendConfirmationEmail(eventId, request, redemptionId);
} catch (e: any) {
logger.error('Redemption ' + redemptionId + ' succeeded but confirmation email failed to send: ' + e.message);
}
return {status: 'OK', redemptionId};
};
const sendConfirmationEmail = async (eventId: number, request: RedeemRequest, redemptionId: number): Promise<void> => {
const event = await EventsService.getEventById(eventId);
if (!event) return;
const guestList = request.guests.map(g => `- ${g.name}`).join('\n');
const body = `Hallo ${request.contactName},\n\n` +
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
`Ort: ${event.location}\n\n` +
`Angemeldete Gäste:\n${guestList}\n\n` +
`Wir freuen uns auf dich!\n\nDein Nachklang-Team`;
let icsAttachment;
try {
const ics = await IcalService.convertToIcal([event]);
icsAttachment = [{filename: 'konzert.ics', content: ics, contentType: 'text/calendar'}];
} catch {
icsAttachment = undefined;
}
await MailService.sendMail(
request.contactEmail,
`Bestätigung: ${event.name}`,
body,
{attachments: icsAttachment}
);
};