From e7d76f40deb55b473dd42a2a5e1a598cbcbb7169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Tue, 15 Sep 2026 19:46:56 +0200 Subject: [PATCH] Add a per-event "tickets mailed" flag and mention Abendkasse pickup in the confirmation email New event_ticket_settings.tickets_mailed column (default false, since no event mails physical tickets today). When false, the redemption confirmation email tells the guest their tickets await pickup at the Abendkasse under their name instead. Read directly by sendRedemptionConfirmation from event_ticket_settings, so both send paths (redeem flow, admin resend) pick it up without either caller changing. Also fixes docker/init/03-tickets-schema.sql, found missing the SOURCE line for migration 003 while adding 004 - local tickets dev databases have been silently missing confirmation_email_status. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 13 +++++++ docker/init/03-tickets-schema.sql | 2 ++ sql/tickets/004_add_tickets_mailed.sql | 8 +++++ .../tickets/admin/events.admin.router.ts | 10 ++++-- .../tickets/admin/events.admin.service.ts | 9 ++--- src/models/tickets/tickets.capacity.ts | 6 ++-- .../tickets/tickets.confirmation-email.ts | 27 +++++++++++++-- src/models/tickets/tickets.interface.ts | 10 ++++-- test/tickets/confirmation-email.test.ts | 34 +++++++++++++++++++ 9 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 sql/tickets/004_add_tickets_mailed.sql 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', () => {