Compare commits
4 Commits
3fc6894070
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d522f35663 | |||
| e7d76f40de | |||
| 5b3e10be94 | |||
| c53d38a0e9 |
@@ -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;
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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