Reviewed-on: #8 Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech> Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
This commit was merged in pull request #8.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import * as EventsService from '../../calendar/events/events.service';
|
||||
import * as IcalService from '../../calendar/events/icalgenerator.service';
|
||||
import {MailService} from '../../../common/common.mail.nodemailer';
|
||||
import logger from '../../../middleware/logger';
|
||||
import {NachklangTicketsDB} from '../Tickets.db';
|
||||
import {getEventTicketState} from '../tickets.capacity';
|
||||
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface';
|
||||
import {isValidEmail} from '../tickets.validation';
|
||||
|
||||
const formatGermanDateTime = (date: Date): string => {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Europe/Berlin'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the eligible-events list for a code: for each event it's linked
|
||||
* to, merges live Calendar event details with the Tickets module's own
|
||||
* capacity/deadline state. Events deleted from the calendar since the code
|
||||
* was generated are silently skipped rather than erroring. DRAFT events are
|
||||
* deliberately still eligible - vouchers are sometimes sent out before a
|
||||
* concert is publicly announced (see docs/plan-ticket-shop.md), so only
|
||||
* DELETED is excluded here, not draft/unpublished status.
|
||||
*/
|
||||
export const validateVoucher = async (code: string): Promise<VoucherValidation | null> => {
|
||||
let conn = await NachklangTicketsDB.getConnection();
|
||||
try {
|
||||
const voucherRows = await conn.query('SELECT * FROM voucher_codes WHERE code = ?', [code]);
|
||||
if (voucherRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const voucher = voucherRows[0];
|
||||
|
||||
const eventIdRows = await conn.query('SELECT event_id FROM voucher_code_events WHERE code = ?', [code]);
|
||||
const eligibleEvents: EligibleEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const row of eventIdRows) {
|
||||
const eventId = row.event_id;
|
||||
const event = await EventsService.getEventById(eventId);
|
||||
if (!event || event.status === 'DELETED') continue;
|
||||
|
||||
const ticketState = await getEventTicketState(conn, eventId);
|
||||
eligibleEvents.push({
|
||||
eventId,
|
||||
name: event.name,
|
||||
startDateTime: event.startDateTime,
|
||||
location: event.location,
|
||||
deadlinePassed: ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline),
|
||||
isFull: ticketState.spotsRemaining !== null && ticketState.spotsRemaining <= 0,
|
||||
spotsRemaining: ticketState.spotsRemaining,
|
||||
collectAddress: ticketState.collectAddress,
|
||||
requireAddress: ticketState.requireAddress
|
||||
});
|
||||
}
|
||||
|
||||
eligibleEvents.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
|
||||
|
||||
return {
|
||||
code: voucher.code,
|
||||
status: voucher.status,
|
||||
maxGuests: voucher.max_guests,
|
||||
prefillName: voucher.prefill_name,
|
||||
prefillEmail: voucher.prefill_email,
|
||||
eligibleEvents
|
||||
};
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export type RedeemResult =
|
||||
| {status: 'OK'; redemptionId: number}
|
||||
| {status: 'NOT_FOUND'}
|
||||
| {status: 'ALREADY_USED'}
|
||||
| {status: 'INVALID_EVENT'}
|
||||
| {status: 'DEADLINE_PASSED'}
|
||||
| {status: 'CAPACITY_EXCEEDED'; spotsRemaining: number}
|
||||
| {status: 'ADDRESS_REQUIRED'};
|
||||
|
||||
export const redeemVoucher = async (code: string, request: RedeemRequest): Promise<RedeemResult> => {
|
||||
if (!request.contactName || !request.contactEmail || !Array.isArray(request.guests) || request.guests.length === 0) {
|
||||
throw new Error('contactName, contactEmail, and at least one guest are required');
|
||||
}
|
||||
if (!isValidEmail(request.contactEmail)) {
|
||||
throw new Error('contactEmail does not look like a valid email address');
|
||||
}
|
||||
for (const guest of request.guests) {
|
||||
if (!guest.name) {
|
||||
throw new Error('every guest requires a name');
|
||||
}
|
||||
}
|
||||
|
||||
let conn = await NachklangTicketsDB.getConnection();
|
||||
let redemptionId: number;
|
||||
let eventId: number;
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// Locks the code row so two concurrent requests for the same code
|
||||
// can't both pass the "still UNUSED" check.
|
||||
const voucherRows = await conn.query('SELECT * FROM voucher_codes WHERE code = ? FOR UPDATE', [code]);
|
||||
if (voucherRows.length === 0) {
|
||||
await conn.rollback();
|
||||
return {status: 'NOT_FOUND'};
|
||||
}
|
||||
const voucher = voucherRows[0];
|
||||
if (voucher.status !== 'UNUSED') {
|
||||
await conn.rollback();
|
||||
return {status: 'ALREADY_USED'};
|
||||
}
|
||||
|
||||
const eligibleRows = await conn.query('SELECT 1 FROM voucher_code_events WHERE code = ? AND event_id = ?', [code, request.eventId]);
|
||||
if (eligibleRows.length === 0) {
|
||||
await conn.rollback();
|
||||
return {status: 'INVALID_EVENT'};
|
||||
}
|
||||
|
||||
// A concert can be canceled/deleted from the calendar after codes were
|
||||
// issued - without this, such a code would stay silently redeemable.
|
||||
// DRAFT is deliberately still allowed (see validateVoucher's comment).
|
||||
const event = await EventsService.getEventById(request.eventId);
|
||||
if (!event || event.status === 'DELETED') {
|
||||
await conn.rollback();
|
||||
return {status: 'INVALID_EVENT'};
|
||||
}
|
||||
|
||||
if (request.guests.length > voucher.max_guests) {
|
||||
await conn.rollback();
|
||||
throw new Error(`this code allows at most ${voucher.max_guests} guests`);
|
||||
}
|
||||
|
||||
// forUpdate=true serializes concurrent redemptions against this event
|
||||
// so the capacity check below can't race past a hard cap.
|
||||
const ticketState = await getEventTicketState(conn, request.eventId, true);
|
||||
const now = new Date();
|
||||
if (ticketState.redemptionDeadline !== null && now > new Date(ticketState.redemptionDeadline)) {
|
||||
await conn.rollback();
|
||||
return {status: 'DEADLINE_PASSED'};
|
||||
}
|
||||
if (ticketState.spotsRemaining !== null && request.guests.length > ticketState.spotsRemaining) {
|
||||
await conn.rollback();
|
||||
return {status: 'CAPACITY_EXCEEDED', spotsRemaining: ticketState.spotsRemaining};
|
||||
}
|
||||
if (ticketState.requireAddress && !request.contactAddress?.trim()) {
|
||||
await conn.rollback();
|
||||
return {status: 'ADDRESS_REQUIRED'};
|
||||
}
|
||||
|
||||
const contactAddress = ticketState.collectAddress ? (request.contactAddress || null) : null;
|
||||
|
||||
const redemptionRes = await conn.query(
|
||||
'INSERT INTO redemptions (code, event_id, contact_name, contact_email, contact_address, guest_count) VALUES (?,?,?,?,?,?) RETURNING redemption_id',
|
||||
[code, request.eventId, request.contactName, request.contactEmail, contactAddress, request.guests.length]
|
||||
);
|
||||
redemptionId = redemptionRes[0].redemption_id;
|
||||
eventId = request.eventId;
|
||||
|
||||
for (let i = 0; i < request.guests.length; i++) {
|
||||
await conn.query('INSERT INTO redemption_guests (redemption_id, name, position) VALUES (?,?,?)', [redemptionId, request.guests[i].name, i]);
|
||||
}
|
||||
|
||||
await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['REDEEMED', code]);
|
||||
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
// Sent after commit, mirroring the Calendar/Feedback convention: a mail
|
||||
// delivery failure shouldn't roll back a successful redemption. Caught
|
||||
// rather than left to propagate - the redemption already succeeded, so
|
||||
// a mail-server hiccup must not turn into a false failure response to
|
||||
// a guest who has, in fact, already secured their spot.
|
||||
try {
|
||||
await sendConfirmationEmail(eventId, request, redemptionId);
|
||||
} catch (e: any) {
|
||||
logger.error('Redemption ' + redemptionId + ' succeeded but confirmation email failed to send: ' + e.message);
|
||||
}
|
||||
|
||||
return {status: 'OK', redemptionId};
|
||||
};
|
||||
|
||||
const sendConfirmationEmail = async (eventId: number, request: RedeemRequest, redemptionId: number): Promise<void> => {
|
||||
const event = await EventsService.getEventById(eventId);
|
||||
if (!event) return;
|
||||
|
||||
const guestList = request.guests.map(g => `- ${g.name}`).join('\n');
|
||||
const body = `Hallo ${request.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`;
|
||||
|
||||
let icsAttachment;
|
||||
try {
|
||||
const ics = await IcalService.convertToIcal([event]);
|
||||
icsAttachment = [{filename: 'konzert.ics', content: ics, contentType: 'text/calendar'}];
|
||||
} catch {
|
||||
icsAttachment = undefined;
|
||||
}
|
||||
|
||||
await MailService.sendMail(
|
||||
request.contactEmail,
|
||||
`Bestätigung: ${event.name}`,
|
||||
body,
|
||||
{attachments: icsAttachment}
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user