5 Commits

Author SHA1 Message Date
Paddy e7d76f40de 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 <noreply@anthropic.com>
2026-09-15 19:46:56 +02:00
Paddy 5b3e10be94 Merge pull request 'Expose each concert's redemption deadline via the voucher/event APIs' (#17) from feature/voucher-redemption-deadline into master
Jenkins Production Deployment
Reviewed-on: #17
2026-09-15 17:32:38 +00:00
Paddy c53d38a0e9 Expose each concert's redemption deadline via the voucher/event APIs
The value already lived in event_ticket_settings but only the derived
deadlinePassed boolean reached callers - guests and the PDF export
couldn't show *when* a deadline is, only whether it already passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:30:42 +02:00
Paddy 3fc6894070 Merge pull request 'Record the calendar migration as complete and deployed' (#16) from docs/calendar-migration-complete into master
Reviewed-on: #16
2026-09-06 21:53:12 +00:00
Paddy 69c1e4926c Record the calendar migration as complete and deployed
Every step is live as of 2026-09-06. The status header said "awaiting deploy",
which is exactly the kind of staleness that nearly caused a bad deploy earlier
in this project - the step 4 checklist was written because "done" had been read
as "in production".

Also records the one surprise from the deploy: a cached pre-cutover bundle
looked signed in and showed every event's status as "Error", because the
legacy checkSessionValid route still answered between steps 4 and 5 while the
events call went out without a cookie and came back without a status field.
That route is gone now, so a stale bundle fails honestly instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:49:25 +02:00
11 changed files with 159 additions and 31 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;
+28 -10
View File
@@ -1,13 +1,22 @@
# Migrating the Calendar domain onto the admin identity module # Migrating the Calendar domain onto the admin identity module
Status: **complete.** Steps 1, 3 and 4 were deployed and verified in production on Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by
2026-09-06; step 2 was dropped by decision and part of step 5 brought forward. Step 5 is decision and part of step 5 brought forward; the rest went out as written.
implemented and awaiting deploy - see its own checklist below, whose ordering is the
**opposite** of step 4's.
Verified live after step 4: the public calendar still answers anonymously, all 23 public The calendar now shares one identity with the tickets, feedback and admin apps: writes sit
events kept a resolvable author, restricted calendars still refuse without a credential, behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that
legacy query credentials answer 401, and `calendar.nachklang.art` is trusted for sign-out. cookie optionally, and the calendar's own `users`/`sessions` tables are renamed aside and
referenced by nothing. `DEFERRED_SECURITY.md` items 1, 3 and 4 closed with it.
Verified in production after the final deploy: the public calendar answers anonymously on all
three endpoints (23 events, 23 VEVENTs in the iCal feed, the next-event teaser intact),
restricted calendars still refuse without a credential, unauthenticated writes answer 401,
all six `/calendar/users/*` routes answer 404, CORS grants only `Content-Type`, sign-out from
`calendar.nachklang.art` succeeds, and **every event kept its author** - rendered from the
`created_by_name` snapshot, since the table it was copied from no longer exists under that
name. That last one is the whole reason part of step 5 was brought forward.
Remaining, at your leisure: `DROP TABLE sessions_legacy_archive, users_legacy_archive;`
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
@@ -157,8 +166,8 @@ Each step is meant to leave production working on its own.
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather **One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
than `?password=` stops working permanently. The shared-password URLs are unaffected. than `?password=` stops working permanently. The shared-password URLs are unaffected.
5. **Drop the legacy path.** **Implemented 2026-09-06** on `feature/calendar-drop-legacy-path`; 5. **Drop the legacy path.** **Deployed 2026-09-06.** Gated on step 4 being live, which it
not yet deployed. Gated on step 4 being live, which it now is. was.
What went: What went:
@@ -178,7 +187,7 @@ Each step is meant to leave production working on its own.
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring) `DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring)
close with it - not by adding expiries but by deleting the code that issued them. close with it - not by adding expiries but by deleting the code that issued them.
### Deploy checklist — note the order is REVERSED from step 4 ### Deploy checklist — kept for the record; the order was REVERSED from step 4
Step 4's migration only added columns, so it went first. `004` *removes* columns and a Step 4's migration only added columns, so it went first. `004` *removes* columns and a
table that the currently running build still selects and joins, so running it first fails table that the currently running build still selects and joins, so running it first fails
@@ -209,6 +218,15 @@ Each step is meant to leave production working on its own.
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check **One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check
anything reading `/calendar/events/*/json` that is not the calendar frontend. anything reading `/calendar/events/*/json` that is not the calendar frontend.
**Observed during the deploy, worth keeping.** A browser holding a *cached pre-cutover*
Angular bundle looked signed in and showed every event's status as "Error". The old bundle
called `/calendar/users/checkSessionValid`, which still existed between step 4 and step 5,
so it rendered as authenticated - then fetched events with no cookie, got the anonymous
listing, which omits `status`, and the UI's status switch fell through to its error label.
Signing out and back in fixed it. After this step that route 404s, so a stale bundle now
fails honestly instead of faking a session. This is the same "does not look broken" window
the step 4 checklist warns about, seen from the other side.
## What the code actually looks like (surveyed 2026-09-06) ## What the code actually looks like (surveyed 2026-09-06)
Four things found while doing step 1 that change how the later steps should be built: Four things found while doing step 1 that change how the later steps should be built:
+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) {
@@ -14,6 +14,7 @@ export interface EventPickerEntry {
startDateTime: Date; startDateTime: Date;
location: string; location: string;
status: string | undefined; status: string | undefined;
redemptionDeadline: Date | null;
} }
/** /**
@@ -26,19 +27,26 @@ export interface EventPickerEntry {
*/ */
export const listEventsForPicker = async (): Promise<EventPickerEntry[]> => { export const listEventsForPicker = async (): Promise<EventPickerEntry[]> => {
let conn = await NachklangTicketsDB.getConnection(); let conn = await NachklangTicketsDB.getConnection();
let enabledEventIds: number[]; let deadlineByEventId: Map<number, Date | null>;
try { try {
const rows = await conn.query('SELECT event_id FROM event_ticket_settings'); const rows = await conn.query('SELECT event_id, redemption_deadline FROM event_ticket_settings');
enabledEventIds = rows.map((r: any) => r.event_id); deadlineByEventId = new Map(rows.map((r: any) => [r.event_id, r.redemption_deadline]));
} finally { } finally {
await conn.end(); await conn.end();
} }
if (enabledEventIds.length === 0) return []; if (deadlineByEventId.size === 0) return [];
const events = await Promise.all(enabledEventIds.map(id => CalendarEventsService.getEventById(id))); const events = await Promise.all([...deadlineByEventId.keys()].map(id => CalendarEventsService.getEventById(id)));
return events return events
.filter((e): e is NonNullable<typeof e> => e !== null && e.status !== 'DELETED') .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})) .map(e => ({
eventId: e.eventId,
name: e.name,
startDateTime: e.startDateTime,
location: e.location,
status: e.status,
redemptionDeadline: deadlineByEventId.get(e.eventId) ?? null
}))
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime()); .sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
}; };
@@ -59,7 +67,9 @@ export const listAvailableEventsToAdd = async (): Promise<EventPickerEntry[]> =>
const events = await CalendarEventsService.getAllEventsAdmin(PUBLIC_CALENDAR_ID); const events = await CalendarEventsService.getAllEventsAdmin(PUBLIC_CALENDAR_ID);
return events return events
.filter(e => e.status !== 'DELETED' && !enabledEventIds.has(e.eventId)) .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})) // Not yet added to the ticket shop, so there's no event_ticket_settings
// row and therefore no deadline to report.
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status, redemptionDeadline: null}))
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime()); .sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
}; };
@@ -89,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,
@@ -109,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) {
@@ -39,6 +39,7 @@ export const validateVoucher = async (code: string): Promise<VoucherValidation |
name: event.name, name: event.name,
startDateTime: event.startDateTime, startDateTime: event.startDateTime,
location: event.location, location: event.location,
redemptionDeadline: ticketState.redemptionDeadline,
deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline), deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline),
isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0, isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0,
spotsRemaining: ticketState.spotsRemaining, spotsRemaining: ticketState.spotsRemaining,
+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 {
+15 -3
View File
@@ -10,7 +10,7 @@
* enum: [ACTIVE, UNDONE] * enum: [ACTIVE, UNDONE]
* EligibleEvent: * EligibleEvent:
* type: object * type: object
* required: [eventId, name, startDateTime, location, deadlinePassed, isFull, collectAddress, requireAddress] * required: [eventId, name, startDateTime, location, redemptionDeadline, deadlinePassed, isFull, collectAddress, requireAddress]
* properties: * properties:
* eventId: * eventId:
* type: integer * type: integer
@@ -23,6 +23,11 @@
* format: date-time * format: date-time
* location: * location:
* type: string * type: string
* redemptionDeadline:
* type: string
* format: date-time
* nullable: true
* description: null when the event has no redemption deadline set
* deadlinePassed: * deadlinePassed:
* type: boolean * type: boolean
* isFull: * isFull:
@@ -144,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
@@ -160,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
@@ -177,6 +185,8 @@
* type: boolean * type: boolean
* requireAddress: * requireAddress:
* type: boolean * type: boolean
* ticketsMailed:
* type: boolean
* guestsUsed: * guestsUsed:
* type: integer * type: integer
* spotsRemaining: * spotsRemaining:
@@ -224,6 +234,7 @@ export interface EligibleEvent {
name: string; name: string;
startDateTime: Date; startDateTime: Date;
location: string; location: string;
redemptionDeadline: Date | null;
deadlinePassed: boolean; deadlinePassed: boolean;
isFull: boolean; isFull: boolean;
spotsRemaining: number | null; spotsRemaining: number | null;
@@ -285,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', () => {