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(); } };