import {Guid} from 'guid-typescript'; import {NachklangTicketsDB} from '../Tickets.db.js'; import {generateUniqueCode} from '../tickets.codes.js'; import {VoucherCode, VoucherStatus} from '../tickets.interface.js'; import {isValidEmail} from '../tickets.validation.js'; export interface WildcardGenerateInput { eventIds: number[]; maxGuests: number; quantity: number; } export interface PersonalizedRowInput { name: string; email: string; eventIds: number[]; maxGuests: number; } const mapVoucherRow = (row: any): VoucherCode => ({ code: row.code, status: row.status, maxGuests: row.max_guests, prefillName: row.prefill_name, prefillEmail: row.prefill_email, batchId: row.batch_id, createdByEmail: row.created_by_email, createdAt: row.created_at, eligibleEventIds: [] }); /** * Guards the event allow-list (event_ticket_settings) at the one place * codes actually get minted - the admin event picker already filters to * allow-listed events, but that's cosmetic unless generation enforces the * same rule server-side. Without this, any event_id could be passed * directly (bypassing the picker) and get a fully-uncapped, no-deadline * redeemable code minted for a non-concert Calendar event. */ const assertEventsAllowListed = async (conn: any, eventIds: number[]): Promise => { const uniqueIds = [...new Set(eventIds)]; const rows = await conn.query('SELECT event_id FROM event_ticket_settings WHERE event_id IN (?)', [uniqueIds]); const allowListed = new Set(rows.map((r: any) => r.event_id)); const missing = uniqueIds.filter(id => !allowListed.has(id)); if (missing.length > 0) { throw new Error(`event(s) not added to the ticket shop yet: ${missing.join(', ')}`); } }; /** * Attaches eligibleEventIds to a list of voucher rows in one extra query, * rather than N+1 per code. */ const attachEligibleEvents = async (conn: any, vouchers: VoucherCode[]): Promise => { 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(); await assertEventsAllowListed(conn, input.eventIds); 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(); await assertEventsAllowListed(conn, rows.flatMap(r => r.eventIds)); 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(); } };