69904b749c
Our SMTP host's IP reputation gets its mail blocked by allowlist-based receivers (t-online.de). Route voucher confirmations, account activation and password-reset mail through the Salesforce org's MTA + DKIM instead, via a new EmailSendResource Apex REST endpoint. - common/salesforce.client.ts: shared client-credentials access (token cache + retry-once-on-401), extracted from the newsletter sync so it is no longer duplicated - common.mail.nodemailer.ts -> common.mail.ts: posts to the org endpoint, never throws on a delivery failure (logs + returns a boolean), retries once on a transient failure, base64-encodes attachments with a 3 MB cap - drop the nodemailer dependency - fixes activation/reset email failures that previously threw after the transaction had already committed - tickets.confirmation-email.ts: shared confirmation-email builder used by both the public redeem path and a new admin resend action - redemptions gain a confirmation_email_status column (migration 003) so the admin UI can flag a failed send; POST /tickets/admin/redemptions/:id/resend-confirmation rebuilds and resends Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
7.1 KiB
TypeScript
185 lines
7.1 KiB
TypeScript
import * as EventsService from '../../calendar/events/events.service';
|
|
import logger from '../../../middleware/logger';
|
|
import {NachklangTicketsDB} from '../Tickets.db';
|
|
import {getEventTicketState} from '../tickets.capacity';
|
|
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email';
|
|
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface';
|
|
import {isValidEmail} from '../tickets.validation';
|
|
|
|
/**
|
|
* 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, and the
|
|
// guest has in fact already secured their spot. The send itself no longer
|
|
// throws on a delivery problem; its result is recorded on the redemption
|
|
// so staff can spot and resend a failed confirmation from the admin UI.
|
|
try {
|
|
const sent = await sendRedemptionConfirmation({
|
|
eventId,
|
|
contactName: request.contactName,
|
|
contactEmail: request.contactEmail,
|
|
guestNames: request.guests.map(g => g.name)
|
|
});
|
|
await recordConfirmationEmailResult(redemptionId, sent);
|
|
} catch (e: any) {
|
|
logger.error('Redemption ' + redemptionId + ' committed but the confirmation email step failed: ' + e.message);
|
|
}
|
|
|
|
return {status: 'OK', redemptionId};
|
|
};
|