diff --git a/CLAUDE.md b/CLAUDE.md index 7945ca0..696493a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,19 @@ error. Only the integration tests catch this. `better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not use the published `@better-auth/cli`; it lags the library. +**`docker-compose.dev.yml` builds a fresh local dev database from `docker/init/`, not +from `sql//` directly** - the two domains use different mechanisms and both need +to be kept in sync by hand whenever a migration is added: `docker/init/03-tickets-schema.sql` +and `02-feedback-schema.sql` are thin files that `SOURCE` every `sql//NNN_*.sql` +in order (add the new migration's `SOURCE` line there too); `01-calendar-schema-dev.sql` +and `04-admin-schema.sql` instead fold each migration's effect directly into one +reconstructed CREATE-TABLE schema (own header comment: "keep the two in step") - no +`SOURCE` list to extend, edit the reconstructed schema itself. Found +`03-tickets-schema.sql` missing the `SOURCE` line for `003_add_confirmation_email_status.sql` +while adding `004` - every tickets dev DB spun up since that migration was added has been +silently missing the column (mail sends still succeed, `recordConfirmationEmailResult`'s +`UPDATE` just fails and logs). Fixed. + **Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both. **Calendar types and IDs:** `public` (1), `members` (2), `management` (3), `choir` (4), `birthdays` (5). `credentials.service.ts` enforces which session/credential can read each calendar. diff --git a/docker/init/03-tickets-schema.sql b/docker/init/03-tickets-schema.sql index 73bb372..78b6d8c 100644 --- a/docker/init/03-tickets-schema.sql +++ b/docker/init/03-tickets-schema.sql @@ -1,3 +1,5 @@ USE nachklang_tickets; SOURCE /migrations/tickets/001_init.sql; SOURCE /migrations/tickets/002_add_require_address.sql; +SOURCE /migrations/tickets/003_add_confirmation_email_status.sql; +SOURCE /migrations/tickets/004_add_tickets_mailed.sql; diff --git a/sql/tickets/004_add_tickets_mailed.sql b/sql/tickets/004_add_tickets_mailed.sql new file mode 100644 index 0000000..d97a3fd --- /dev/null +++ b/sql/tickets/004_add_tickets_mailed.sql @@ -0,0 +1,8 @@ +-- Nachklang e.V. Tickets module — adds a per-event "tickets are mailed" flag. +-- Defaults to 0 (not mailed) because no event mails physical tickets today - +-- the redemption confirmation email uses this to decide whether to tell the +-- guest their tickets await pickup at the Abendkasse instead. Apply manually +-- against TICKETS_DB, after 003_add_confirmation_email_status.sql: +-- mysql -h -u -p < 004_add_tickets_mailed.sql +ALTER TABLE event_ticket_settings + ADD COLUMN tickets_mailed TINYINT(1) NOT NULL DEFAULT 0 AFTER require_address; diff --git a/src/models/tickets/admin/events.admin.router.ts b/src/models/tickets/admin/events.admin.router.ts index d483a3a..6f5910d 100644 --- a/src/models/tickets/admin/events.admin.router.ts +++ b/src/models/tickets/admin/events.admin.router.ts @@ -93,7 +93,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => * /tickets/admin/events/{eventId}/settings: * put: * summary: Set a concert's voucher settings - * description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address. + * description: Upserts capacity (null = uncapped), redemption deadline (null = none), whether to collect a mailing address, and whether tickets are mailed to guests. * tags: [tickets-admin] * security: * - AdminSessionCookie: [] @@ -122,6 +122,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => * requireAddress: * type: boolean * description: Only meaningful when collectAddress is true. + * ticketsMailed: + * type: boolean + * description: When false, the redemption confirmation email tells the guest their tickets await pickup at the Abendkasse instead. * responses: * 200: * description: Saved @@ -132,7 +135,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => */ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => { try { - const {capacity, redemptionDeadline, collectAddress, requireAddress} = req.body || {}; + const {capacity, redemptionDeadline, collectAddress, requireAddress, ticketsMailed} = req.body || {}; await EventsAdminService.setEventSettings(Number(req.params.eventId), { capacity: capacity ?? null, // The mariadb driver needs an actual Date to serialize a DATETIME @@ -140,7 +143,8 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) // rejected with "Incorrect datetime value". redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null, collectAddress: !!collectAddress, - requireAddress: !!collectAddress && !!requireAddress + requireAddress: !!collectAddress && !!requireAddress, + ticketsMailed: !!ticketsMailed }); res.status(200).send({status: 'OK'}); } catch (e: any) { diff --git a/src/models/tickets/admin/events.admin.service.ts b/src/models/tickets/admin/events.admin.service.ts index 62eaafa..2053004 100644 --- a/src/models/tickets/admin/events.admin.service.ts +++ b/src/models/tickets/admin/events.admin.service.ts @@ -99,6 +99,7 @@ export const getEventStats = async (eventId: number): Promise => { redemptionDeadline: ticketState.redemptionDeadline, collectAddress: ticketState.collectAddress, requireAddress: ticketState.requireAddress, + ticketsMailed: ticketState.ticketsMailed, guestsUsed: ticketState.guestsUsed, spotsRemaining: ticketState.spotsRemaining, unusedCodes, @@ -119,10 +120,10 @@ export const setEventSettings = async (eventId: number, settings: Omit => { - const settingsQuery = `SELECT capacity, redemption_deadline, collect_address, require_address FROM event_ticket_settings WHERE event_id = ?${forUpdate ? ' FOR UPDATE' : ''}`; + const settingsQuery = `SELECT capacity, redemption_deadline, collect_address, require_address, tickets_mailed FROM event_ticket_settings WHERE event_id = ?${forUpdate ? ' FOR UPDATE' : ''}`; const settingsRows = await conn.query(settingsQuery, [eventId]); const capacity = settingsRows.length > 0 ? settingsRows[0].capacity : null; const redemptionDeadline = settingsRows.length > 0 ? settingsRows[0].redemption_deadline : null; @@ -29,6 +30,7 @@ export const getEventTicketState = async (conn: any, eventId: number, forUpdate // Only meaningful when collectAddress is also true - the field isn't // shown/collected at all otherwise, so "required" is moot. const requireAddress = collectAddress && settingsRows.length > 0 ? !!settingsRows[0].require_address : false; + const ticketsMailed = settingsRows.length > 0 ? !!settingsRows[0].tickets_mailed : false; const usedRows = await conn.query( "SELECT COALESCE(SUM(guest_count), 0) as used FROM redemptions WHERE event_id = ? AND status = 'ACTIVE'", @@ -37,5 +39,5 @@ export const getEventTicketState = async (conn: any, eventId: number, forUpdate const guestsUsed = Number(usedRows[0].used); const spotsRemaining = capacity === null ? null : Math.max(0, capacity - guestsUsed); - return {eventId, capacity, redemptionDeadline, collectAddress, requireAddress, guestsUsed, spotsRemaining}; + return {eventId, capacity, redemptionDeadline, collectAddress, requireAddress, ticketsMailed, guestsUsed, spotsRemaining}; }; diff --git a/src/models/tickets/tickets.confirmation-email.ts b/src/models/tickets/tickets.confirmation-email.ts index 0b7f49c..96c8162 100644 --- a/src/models/tickets/tickets.confirmation-email.ts +++ b/src/models/tickets/tickets.confirmation-email.ts @@ -24,6 +24,23 @@ export interface ConfirmationRecipient { guestNames: string[]; } +/** + * Whether an event's tickets are mailed to guests - read directly rather than + * via getEventTicketState (tickets.capacity.ts) since that also computes a + * live guest count this function doesn't need. Absent settings row (no + * ticket-shop config yet) defaults to false, same "absence over sentinels" + * convention as the rest of event_ticket_settings. + */ +const ticketsAreMailed = async (eventId: number): Promise => { + let conn = await NachklangTicketsDB.getConnection(); + try { + const rows = await conn.query('SELECT tickets_mailed FROM event_ticket_settings WHERE event_id = ?', [eventId]); + return rows.length > 0 ? !!rows[0].tickets_mailed : false; + } finally { + await conn.end(); + } +}; + /** * Sends the redemption confirmation email for one redemption. Returns whether * the mail was accepted by the relay. Never throws: a missing event is treated @@ -36,14 +53,20 @@ export const sendRedemptionConfirmation = async (recipient: ConfirmationRecipien return false; } + const ticketsMailed = await ticketsAreMailed(recipient.eventId); + const pickupNotice = ticketsMailed + ? '' + : `\n\nDeine Tickets werden nicht postalisch versendet: Sie liegen am Konzertabend unter dem Namen ${recipient.contactName} für dich an der Abendkasse bereit.`; + const guestList = recipient.guestNames.map(name => `- ${name}`).join('\n'); const body = `Hallo ${recipient.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`; + `Angemeldete Gäste:\n${guestList}` + + pickupNotice + + `\n\nWir freuen uns auf dich!\n\nDein Nachklang-Team`; let icsAttachment; try { diff --git a/src/models/tickets/tickets.interface.ts b/src/models/tickets/tickets.interface.ts index eb07414..a27e6a9 100644 --- a/src/models/tickets/tickets.interface.ts +++ b/src/models/tickets/tickets.interface.ts @@ -149,7 +149,7 @@ * type: integer * EventTicketSettings: * type: object - * required: [eventId, collectAddress, requireAddress] + * required: [eventId, collectAddress, requireAddress, ticketsMailed] * properties: * eventId: * type: integer @@ -165,9 +165,12 @@ * requireAddress: * type: boolean * description: Only meaningful when collectAddress is true. + * ticketsMailed: + * type: boolean + * description: When false, the redemption confirmation email tells the guest their tickets await pickup at the Abendkasse instead. * EventStats: * type: object - * required: [eventId, collectAddress, requireAddress, guestsUsed, unusedCodes, redeemedCodes, voidCodes] + * required: [eventId, collectAddress, requireAddress, ticketsMailed, guestsUsed, unusedCodes, redeemedCodes, voidCodes] * properties: * eventId: * type: integer @@ -182,6 +185,8 @@ * type: boolean * requireAddress: * type: boolean + * ticketsMailed: + * type: boolean * guestsUsed: * type: integer * spotsRemaining: @@ -291,6 +296,7 @@ export interface EventTicketSettings { redemptionDeadline: Date | null; collectAddress: boolean; requireAddress: boolean; + ticketsMailed: boolean; } export interface EventStats extends EventTicketSettings { diff --git a/test/tickets/confirmation-email.test.ts b/test/tickets/confirmation-email.test.ts index de574a8..f6b113c 100644 --- a/test/tickets/confirmation-email.test.ts +++ b/test/tickets/confirmation-email.test.ts @@ -47,11 +47,19 @@ const RECIPIENT = { guestNames: ['Erika Mustermann', 'Hans Mustermann'] }; +// Default: no event_ticket_settings row, same "absence over sentinels" case +// as everywhere else - ticketsAreMailed reads this as false (not mailed). +const makeSettingsConn = (ticketsMailed?: boolean) => ({ + query: vi.fn().mockResolvedValue(ticketsMailed === undefined ? [] : [{tickets_mailed: ticketsMailed ? 1 : 0}]), + end: vi.fn().mockResolvedValue(undefined) +}); + beforeEach(() => { vi.clearAllMocks(); mockGetEvent.mockResolvedValue(EVENT); mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR'); mockSendMail.mockResolvedValue(true); + mockGetConnection.mockResolvedValue(makeSettingsConn()); }); describe('sendRedemptionConfirmation', () => { @@ -95,6 +103,32 @@ describe('sendRedemptionConfirmation', () => { expect(await sendRedemptionConfirmation(RECIPIENT)).toBe(false); }); + + it('adds the Abendkasse pickup notice when the event does not mail tickets', async () => { + mockGetConnection.mockResolvedValue(makeSettingsConn(false)); + + await sendRedemptionConfirmation(RECIPIENT); + + const body = mockSendMail.mock.calls[0][2]; + expect(body).toContain('Abendkasse'); + expect(body).toContain('Erika Mustermann'); + }); + + it('adds the pickup notice when there is no ticket-shop settings row at all', async () => { + mockGetConnection.mockResolvedValue(makeSettingsConn()); + + await sendRedemptionConfirmation(RECIPIENT); + + expect(mockSendMail.mock.calls[0][2]).toContain('Abendkasse'); + }); + + it('omits the pickup notice when the event mails tickets', async () => { + mockGetConnection.mockResolvedValue(makeSettingsConn(true)); + + await sendRedemptionConfirmation(RECIPIENT); + + expect(mockSendMail.mock.calls[0][2]).not.toContain('Abendkasse'); + }); }); describe('recordConfirmationEmailResult', () => {