import * as EventsService from '../../calendar/events/events.service'; import logger from '../../../middleware/logger'; import {NachklangTicketsDB} from '../Tickets.db'; import {getEventTicketState} from '../tickets.capacity'; import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email'; import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface'; import {isValidEmail} from '../tickets.validation'; /** * 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 => { 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 => { if (!request.contactName || !request.contactEmail || !Array.isArray(request.guests) || request.guests.length === 0) { throw new Error('contactName, contactEmail, and at least one guest are required'); } if (!isValidEmail(request.contactEmail)) { throw new Error('contactEmail does not look like a valid email address'); } for (const guest of request.guests) { if (!guest.name) { throw new Error('every guest requires a name'); } } let conn = await NachklangTicketsDB.getConnection(); let redemptionId: number; let eventId: number; try { await conn.beginTransaction(); // Locks the code row so two concurrent requests for the same code // can't both pass the "still UNUSED" check. const voucherRows = await conn.query('SELECT * FROM voucher_codes WHERE code = ? FOR UPDATE', [code]); if (voucherRows.length === 0) { await conn.rollback(); return {status: 'NOT_FOUND'}; } const voucher = voucherRows[0]; if (voucher.status !== 'UNUSED') { await conn.rollback(); return {status: 'ALREADY_USED'}; } const eligibleRows = await conn.query('SELECT 1 FROM voucher_code_events WHERE code = ? AND event_id = ?', [code, request.eventId]); if (eligibleRows.length === 0) { await conn.rollback(); return {status: 'INVALID_EVENT'}; } // A concert can be canceled/deleted from the calendar after codes were // issued - without this, such a code would stay silently redeemable. // DRAFT is deliberately still allowed (see validateVoucher's comment). const event = await EventsService.getEventById(request.eventId); if (!event || event.status === 'DELETED') { await conn.rollback(); return {status: 'INVALID_EVENT'}; } if (request.guests.length > voucher.max_guests) { await conn.rollback(); throw new Error(`this code allows at most ${voucher.max_guests} guests`); } // forUpdate=true serializes concurrent redemptions against this event // so the capacity check below can't race past a hard cap. const ticketState = await getEventTicketState(conn, request.eventId, true); const now = new Date(); if (ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline)) { await conn.rollback(); return {status: 'DEADLINE_PASSED'}; } if (ticketState.spotsRemaining !== null && request.guests.length > ticketState.spotsRemaining) { await conn.rollback(); return {status: 'CAPACITY_EXCEEDED', spotsRemaining: ticketState.spotsRemaining}; } if (ticketState.requireAddress && !request.contactAddress?.trim()) { await conn.rollback(); return {status: 'ADDRESS_REQUIRED'}; } const contactAddress = ticketState.collectAddress ? (request.contactAddress || null) : null; const redemptionRes = await conn.query( 'INSERT INTO redemptions (code, event_id, contact_name, contact_email, contact_address, guest_count) VALUES (?,?,?,?,?,?) RETURNING redemption_id', [code, request.eventId, request.contactName, request.contactEmail, contactAddress, request.guests.length] ); redemptionId = redemptionRes[0].redemption_id; eventId = request.eventId; for (let i = 0; i < request.guests.length; i++) { await conn.query('INSERT INTO redemption_guests (redemption_id, name, position) VALUES (?,?,?)', [redemptionId, request.guests[i].name, i]); } await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['REDEEMED', code]); await conn.commit(); } catch (err) { await conn.rollback(); throw err; } finally { await conn.end(); } // Sent after commit, mirroring the Calendar/Feedback convention: a mail // delivery failure shouldn't roll back a successful redemption, and the // guest has in fact already secured their spot. The send itself no longer // throws on a delivery problem; its result is recorded on the redemption // so staff can spot and resend a failed confirmation from the admin UI. try { const sent = await sendRedemptionConfirmation({ eventId, contactName: request.contactName, contactEmail: request.contactEmail, guestNames: request.guests.map(g => g.name) }); await recordConfirmationEmailResult(redemptionId, sent); } catch (e: any) { logger.error('Redemption ' + redemptionId + ' committed but the confirmation email step failed: ' + e.message); } return {status: 'OK', redemptionId}; };