b6499eb7b3
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>
156 lines
5.7 KiB
TypeScript
156 lines
5.7 KiB
TypeScript
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<EventPickerEntry[]> => {
|
|
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<typeof e> => 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<EventPickerEntry[]> => {
|
|
let conn = await NachklangTicketsDB.getConnection();
|
|
let enabledEventIds: Set<number>;
|
|
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<EventStats> => {
|
|
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<EventTicketSettings, 'eventId'>): Promise<void> => {
|
|
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<RemoveEventResult> => {
|
|
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();
|
|
}
|
|
};
|