Add a per-event "tickets mailed" flag and mention Abendkasse pickup in the confirmation email #18

Merged
Paddy merged 1 commits from feature/abendkasse-pickup-notice into master 2026-09-15 20:55:09 +00:00
9 changed files with 106 additions and 13 deletions
+13
View File
@@ -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 `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. 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/<domain>/` 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/<domain>/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. **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. **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.
+2
View File
@@ -1,3 +1,5 @@
USE nachklang_tickets; USE nachklang_tickets;
SOURCE /migrations/tickets/001_init.sql; SOURCE /migrations/tickets/001_init.sql;
SOURCE /migrations/tickets/002_add_require_address.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;
+8
View File
@@ -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 <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 004_add_tickets_mailed.sql
ALTER TABLE event_ticket_settings
ADD COLUMN tickets_mailed TINYINT(1) NOT NULL DEFAULT 0 AFTER require_address;
@@ -93,7 +93,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* /tickets/admin/events/{eventId}/settings: * /tickets/admin/events/{eventId}/settings:
* put: * put:
* summary: Set a concert's voucher settings * 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] * tags: [tickets-admin]
* security: * security:
* - AdminSessionCookie: [] * - AdminSessionCookie: []
@@ -122,6 +122,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* requireAddress: * requireAddress:
* type: boolean * type: boolean
* description: Only meaningful when collectAddress is true. * 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: * responses:
* 200: * 200:
* description: Saved * description: Saved
@@ -132,7 +135,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
*/ */
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => { eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
try { try {
const {capacity, redemptionDeadline, collectAddress, requireAddress} = req.body || {}; const {capacity, redemptionDeadline, collectAddress, requireAddress, ticketsMailed} = req.body || {};
await EventsAdminService.setEventSettings(Number(req.params.eventId), { await EventsAdminService.setEventSettings(Number(req.params.eventId), {
capacity: capacity ?? null, capacity: capacity ?? null,
// The mariadb driver needs an actual Date to serialize a DATETIME // 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". // rejected with "Incorrect datetime value".
redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null, redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null,
collectAddress: !!collectAddress, collectAddress: !!collectAddress,
requireAddress: !!collectAddress && !!requireAddress requireAddress: !!collectAddress && !!requireAddress,
ticketsMailed: !!ticketsMailed
}); });
res.status(200).send({status: 'OK'}); res.status(200).send({status: 'OK'});
} catch (e: any) { } catch (e: any) {
@@ -99,6 +99,7 @@ export const getEventStats = async (eventId: number): Promise<EventStats> => {
redemptionDeadline: ticketState.redemptionDeadline, redemptionDeadline: ticketState.redemptionDeadline,
collectAddress: ticketState.collectAddress, collectAddress: ticketState.collectAddress,
requireAddress: ticketState.requireAddress, requireAddress: ticketState.requireAddress,
ticketsMailed: ticketState.ticketsMailed,
guestsUsed: ticketState.guestsUsed, guestsUsed: ticketState.guestsUsed,
spotsRemaining: ticketState.spotsRemaining, spotsRemaining: ticketState.spotsRemaining,
unusedCodes, unusedCodes,
@@ -119,10 +120,10 @@ export const setEventSettings = async (eventId: number, settings: Omit<EventTick
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
await conn.query( await conn.query(
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address) `INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address, tickets_mailed)
VALUES (?,?,?,?,?) VALUES (?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address)`, ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address), tickets_mailed = VALUES(tickets_mailed)`,
[eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0] [eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0, settings.ticketsMailed ? 1 : 0]
); );
await conn.commit(); await conn.commit();
} catch (err) { } catch (err) {
+4 -2
View File
@@ -4,6 +4,7 @@ export interface EventTicketState {
redemptionDeadline: Date | null; redemptionDeadline: Date | null;
collectAddress: boolean; collectAddress: boolean;
requireAddress: boolean; requireAddress: boolean;
ticketsMailed: boolean;
guestsUsed: number; guestsUsed: number;
spotsRemaining: number | null; spotsRemaining: number | null;
} }
@@ -21,7 +22,7 @@ export interface EventTicketState {
* (uncapped) don't need this - there's no cap to race against. * (uncapped) don't need this - there's no cap to race against.
*/ */
export const getEventTicketState = async (conn: any, eventId: number, forUpdate = false): Promise<EventTicketState> => { export const getEventTicketState = async (conn: any, eventId: number, forUpdate = false): Promise<EventTicketState> => {
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 settingsRows = await conn.query(settingsQuery, [eventId]);
const capacity = settingsRows.length > 0 ? settingsRows[0].capacity : null; const capacity = settingsRows.length > 0 ? settingsRows[0].capacity : null;
const redemptionDeadline = settingsRows.length > 0 ? settingsRows[0].redemption_deadline : 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 // Only meaningful when collectAddress is also true - the field isn't
// shown/collected at all otherwise, so "required" is moot. // shown/collected at all otherwise, so "required" is moot.
const requireAddress = collectAddress && settingsRows.length > 0 ? !!settingsRows[0].require_address : false; 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( const usedRows = await conn.query(
"SELECT COALESCE(SUM(guest_count), 0) as used FROM redemptions WHERE event_id = ? AND status = 'ACTIVE'", "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 guestsUsed = Number(usedRows[0].used);
const spotsRemaining = capacity === null ? null : Math.max(0, capacity - guestsUsed); 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};
}; };
@@ -24,6 +24,23 @@ export interface ConfirmationRecipient {
guestNames: string[]; 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<boolean> => {
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 * Sends the redemption confirmation email for one redemption. Returns whether
* the mail was accepted by the relay. Never throws: a missing event is treated * 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; 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 guestList = recipient.guestNames.map(name => `- ${name}`).join('\n');
const body = const body =
`Hallo ${recipient.contactName},\n\n` + `Hallo ${recipient.contactName},\n\n` +
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` + `vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` + `Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
`Ort: ${event.location}\n\n` + `Ort: ${event.location}\n\n` +
`Angemeldete Gäste:\n${guestList}\n\n` + `Angemeldete Gäste:\n${guestList}` +
`Wir freuen uns auf dich!\n\nDein Nachklang-Team`; pickupNotice +
`\n\nWir freuen uns auf dich!\n\nDein Nachklang-Team`;
let icsAttachment; let icsAttachment;
try { try {
+8 -2
View File
@@ -149,7 +149,7 @@
* type: integer * type: integer
* EventTicketSettings: * EventTicketSettings:
* type: object * type: object
* required: [eventId, collectAddress, requireAddress] * required: [eventId, collectAddress, requireAddress, ticketsMailed]
* properties: * properties:
* eventId: * eventId:
* type: integer * type: integer
@@ -165,9 +165,12 @@
* requireAddress: * requireAddress:
* type: boolean * type: boolean
* description: Only meaningful when collectAddress is true. * 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: * EventStats:
* type: object * type: object
* required: [eventId, collectAddress, requireAddress, guestsUsed, unusedCodes, redeemedCodes, voidCodes] * required: [eventId, collectAddress, requireAddress, ticketsMailed, guestsUsed, unusedCodes, redeemedCodes, voidCodes]
* properties: * properties:
* eventId: * eventId:
* type: integer * type: integer
@@ -182,6 +185,8 @@
* type: boolean * type: boolean
* requireAddress: * requireAddress:
* type: boolean * type: boolean
* ticketsMailed:
* type: boolean
* guestsUsed: * guestsUsed:
* type: integer * type: integer
* spotsRemaining: * spotsRemaining:
@@ -291,6 +296,7 @@ export interface EventTicketSettings {
redemptionDeadline: Date | null; redemptionDeadline: Date | null;
collectAddress: boolean; collectAddress: boolean;
requireAddress: boolean; requireAddress: boolean;
ticketsMailed: boolean;
} }
export interface EventStats extends EventTicketSettings { export interface EventStats extends EventTicketSettings {
+34
View File
@@ -47,11 +47,19 @@ const RECIPIENT = {
guestNames: ['Erika Mustermann', 'Hans Mustermann'] 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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockGetEvent.mockResolvedValue(EVENT); mockGetEvent.mockResolvedValue(EVENT);
mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR'); mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR');
mockSendMail.mockResolvedValue(true); mockSendMail.mockResolvedValue(true);
mockGetConnection.mockResolvedValue(makeSettingsConn());
}); });
describe('sendRedemptionConfirmation', () => { describe('sendRedemptionConfirmation', () => {
@@ -95,6 +103,32 @@ describe('sendRedemptionConfirmation', () => {
expect(await sendRedemptionConfirmation(RECIPIENT)).toBe(false); 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', () => { describe('recordConfirmationEmailResult', () => {