Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7d76f40de | |||
| 5b3e10be94 | |||
| c53d38a0e9 | |||
| 3fc6894070 | |||
| 69c1e4926c | |||
| a49b939fdc |
@@ -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/<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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
# Migrating the Calendar domain onto the admin identity module
|
||||
|
||||
Status: **complete.** Steps 1, 3 and 4 were deployed and verified in production on
|
||||
2026-09-06; step 2 was dropped by decision and part of step 5 brought forward. Step 5 is
|
||||
implemented and awaiting deploy - see its own checklist below, whose ordering is the
|
||||
**opposite** of step 4's.
|
||||
Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by
|
||||
decision and part of step 5 brought forward; the rest went out as written.
|
||||
|
||||
Verified live after step 4: the public calendar still answers anonymously, all 23 public
|
||||
events kept a resolvable author, restricted calendars still refuse without a credential,
|
||||
legacy query credentials answer 401, and `calendar.nachklang.art` is trusted for sign-out.
|
||||
The calendar now shares one identity with the tickets, feedback and admin apps: writes sit
|
||||
behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that
|
||||
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
|
||||
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
|
||||
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`;
|
||||
not yet deployed. Gated on step 4 being live, which it now is.
|
||||
5. **Drop the legacy path.** **Deployed 2026-09-06.** Gated on step 4 being live, which it
|
||||
was.
|
||||
|
||||
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)
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
Four things found while doing step 1 that change how the later steps should be built:
|
||||
|
||||
@@ -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:
|
||||
* 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) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface EventPickerEntry {
|
||||
startDateTime: Date;
|
||||
location: string;
|
||||
status: string | undefined;
|
||||
redemptionDeadline: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,19 +27,26 @@ export interface EventPickerEntry {
|
||||
*/
|
||||
export const listEventsForPicker = async (): Promise<EventPickerEntry[]> => {
|
||||
let conn = await NachklangTicketsDB.getConnection();
|
||||
let enabledEventIds: number[];
|
||||
let deadlineByEventId: Map<number, Date | null>;
|
||||
try {
|
||||
const rows = await conn.query('SELECT event_id FROM event_ticket_settings');
|
||||
enabledEventIds = rows.map((r: any) => r.event_id);
|
||||
const rows = await conn.query('SELECT event_id, redemption_deadline FROM event_ticket_settings');
|
||||
deadlineByEventId = new Map(rows.map((r: any) => [r.event_id, r.redemption_deadline]));
|
||||
} finally {
|
||||
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
|
||||
.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());
|
||||
};
|
||||
|
||||
@@ -59,7 +67,9 @@ export const listAvailableEventsToAdd = async (): Promise<EventPickerEntry[]> =>
|
||||
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}))
|
||||
// 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());
|
||||
};
|
||||
|
||||
@@ -89,6 +99,7 @@ export const getEventStats = async (eventId: number): Promise<EventStats> => {
|
||||
redemptionDeadline: ticketState.redemptionDeadline,
|
||||
collectAddress: ticketState.collectAddress,
|
||||
requireAddress: ticketState.requireAddress,
|
||||
ticketsMailed: ticketState.ticketsMailed,
|
||||
guestsUsed: ticketState.guestsUsed,
|
||||
spotsRemaining: ticketState.spotsRemaining,
|
||||
unusedCodes,
|
||||
@@ -109,10 +120,10 @@ export const setEventSettings = async (eventId: number, settings: Omit<EventTick
|
||||
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]
|
||||
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address, tickets_mailed)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
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, settings.ticketsMailed ? 1 : 0]
|
||||
);
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
|
||||
@@ -39,6 +39,7 @@ export const validateVoucher = async (code: string): Promise<VoucherValidation |
|
||||
name: event.name,
|
||||
startDateTime: event.startDateTime,
|
||||
location: event.location,
|
||||
redemptionDeadline: ticketState.redemptionDeadline,
|
||||
deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline),
|
||||
isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0,
|
||||
spotsRemaining: ticketState.spotsRemaining,
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface EventTicketState {
|
||||
redemptionDeadline: Date | null;
|
||||
collectAddress: boolean;
|
||||
requireAddress: boolean;
|
||||
ticketsMailed: boolean;
|
||||
guestsUsed: number;
|
||||
spotsRemaining: number | null;
|
||||
}
|
||||
@@ -21,7 +22,7 @@ export interface EventTicketState {
|
||||
* (uncapped) don't need this - there's no cap to race against.
|
||||
*/
|
||||
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 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};
|
||||
};
|
||||
|
||||
@@ -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<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
|
||||
* 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 {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* enum: [ACTIVE, UNDONE]
|
||||
* EligibleEvent:
|
||||
* type: object
|
||||
* required: [eventId, name, startDateTime, location, deadlinePassed, isFull, collectAddress, requireAddress]
|
||||
* required: [eventId, name, startDateTime, location, redemptionDeadline, deadlinePassed, isFull, collectAddress, requireAddress]
|
||||
* properties:
|
||||
* eventId:
|
||||
* type: integer
|
||||
@@ -23,6 +23,11 @@
|
||||
* format: date-time
|
||||
* location:
|
||||
* type: string
|
||||
* redemptionDeadline:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* nullable: true
|
||||
* description: null when the event has no redemption deadline set
|
||||
* deadlinePassed:
|
||||
* type: boolean
|
||||
* isFull:
|
||||
@@ -144,7 +149,7 @@
|
||||
* type: integer
|
||||
* EventTicketSettings:
|
||||
* type: object
|
||||
* required: [eventId, collectAddress, requireAddress]
|
||||
* required: [eventId, collectAddress, requireAddress, ticketsMailed]
|
||||
* properties:
|
||||
* eventId:
|
||||
* type: integer
|
||||
@@ -160,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
|
||||
@@ -177,6 +185,8 @@
|
||||
* type: boolean
|
||||
* requireAddress:
|
||||
* type: boolean
|
||||
* ticketsMailed:
|
||||
* type: boolean
|
||||
* guestsUsed:
|
||||
* type: integer
|
||||
* spotsRemaining:
|
||||
@@ -224,6 +234,7 @@ export interface EligibleEvent {
|
||||
name: string;
|
||||
startDateTime: Date;
|
||||
location: string;
|
||||
redemptionDeadline: Date | null;
|
||||
deadlinePassed: boolean;
|
||||
isFull: boolean;
|
||||
spotsRemaining: number | null;
|
||||
@@ -285,6 +296,7 @@ export interface EventTicketSettings {
|
||||
redemptionDeadline: Date | null;
|
||||
collectAddress: boolean;
|
||||
requireAddress: boolean;
|
||||
ticketsMailed: boolean;
|
||||
}
|
||||
|
||||
export interface EventStats extends EventTicketSettings {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user