Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7d76f40de | |||
| 5b3e10be94 | |||
| 3fc6894070 | |||
| 69c1e4926c |
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user