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>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -99,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,
|
||||
@@ -119,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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
* type: integer
|
||||
* EventTicketSettings:
|
||||
* type: object
|
||||
* required: [eventId, collectAddress, requireAddress]
|
||||
* required: [eventId, collectAddress, requireAddress, ticketsMailed]
|
||||
* properties:
|
||||
* eventId:
|
||||
* type: integer
|
||||
@@ -165,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
|
||||
@@ -182,6 +185,8 @@
|
||||
* type: boolean
|
||||
* requireAddress:
|
||||
* type: boolean
|
||||
* ticketsMailed:
|
||||
* type: boolean
|
||||
* guestsUsed:
|
||||
* type: integer
|
||||
* spotsRemaining:
|
||||
@@ -291,6 +296,7 @@ export interface EventTicketSettings {
|
||||
redemptionDeadline: Date | null;
|
||||
collectAddress: boolean;
|
||||
requireAddress: boolean;
|
||||
ticketsMailed: boolean;
|
||||
}
|
||||
|
||||
export interface EventStats extends EventTicketSettings {
|
||||
|
||||
Reference in New Issue
Block a user