import {NachklangTicketsDB} from '../Tickets.db.js'; import {getEventTicketState} from '../tickets.capacity.js'; import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email.js'; import {AuditLogEntry, RedemptionSummary} from '../tickets.interface.js'; import {isValidEmail} from '../tickets.validation.js'; const mapRedemptionRow = (row: any, guests: string[]): RedemptionSummary => ({ redemptionId: row.redemption_id, code: row.code, eventId: row.event_id, status: row.status, contactName: row.contact_name, contactEmail: row.contact_email, contactAddress: row.contact_address, guestCount: row.guest_count, guests, redeemedAt: row.redeemed_at, confirmationEmailStatus: row.confirmation_email_status ?? null }); export interface ListRedemptionsFilter { eventId?: number; status?: 'ACTIVE' | 'UNDONE'; } export const listRedemptions = async (filter: ListRedemptionsFilter): Promise => { let conn = await NachklangTicketsDB.getConnection(); try { const where: string[] = []; const params: any[] = []; if (filter.eventId !== undefined) { where.push('event_id = ?'); params.push(filter.eventId); } if (filter.status) { where.push('status = ?'); params.push(filter.status); } let query = 'SELECT * FROM redemptions'; if (where.length > 0) query += ' WHERE ' + where.join(' AND '); query += ' ORDER BY redeemed_at DESC'; const rows = await conn.query(query, params); if (rows.length === 0) return []; const redemptionIds = rows.map((r: any) => r.redemption_id); const guestRows = await conn.query( 'SELECT redemption_id, name FROM redemption_guests WHERE redemption_id IN (?) ORDER BY redemption_id, position', [redemptionIds] ); const guestsByRedemption = new Map(); for (const g of guestRows) { const list = guestsByRedemption.get(g.redemption_id) || []; list.push(g.name); guestsByRedemption.set(g.redemption_id, list); } return rows.map((row: any) => mapRedemptionRow(row, guestsByRedemption.get(row.redemption_id) || [])); } finally { await conn.end(); } }; export const getRedemption = async (redemptionId: number): Promise => { let conn = await NachklangTicketsDB.getConnection(); try { const rows = await conn.query('SELECT * FROM redemptions WHERE redemption_id = ?', [redemptionId]); if (rows.length === 0) return null; const guestRows = await conn.query('SELECT name FROM redemption_guests WHERE redemption_id = ? ORDER BY position', [redemptionId]); return mapRedemptionRow(rows[0], guestRows.map((g: any) => g.name)); } finally { await conn.end(); } }; export type UndoRedemptionResult = 'UNDONE' | 'NOT_FOUND' | 'NOT_ACTIVE'; /** * Reopens the code (back to UNUSED) and marks the redemption UNDONE * (soft-state, not deleted - guest names/contact info stay for the audit * trail). A later re-redemption of the same code creates a new * redemptions row rather than reviving this one. */ export const undoRedemption = async (redemptionId: number, adminEmail: string, reason: string | null): Promise => { let conn = await NachklangTicketsDB.getConnection(); try { await conn.beginTransaction(); const rows = await conn.query('SELECT * FROM redemptions WHERE redemption_id = ? FOR UPDATE', [redemptionId]); if (rows.length === 0) { await conn.rollback(); return 'NOT_FOUND'; } const redemption = rows[0]; if (redemption.status !== 'ACTIVE') { await conn.rollback(); return 'NOT_ACTIVE'; } await conn.query('UPDATE redemptions SET status = ? WHERE redemption_id = ?', ['UNDONE', redemptionId]); await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['UNUSED', redemption.code]); await conn.query( 'INSERT INTO voucher_audit_log (code, redemption_id, admin_email, action, reason) VALUES (?,?,?,?,?)', [redemption.code, redemptionId, adminEmail, 'UNDO', reason] ); await conn.commit(); return 'UNDONE'; } catch (err) { await conn.rollback(); throw err; } finally { await conn.end(); } }; export interface EditRedemptionInput { contactName?: string; contactEmail?: string; contactAddress?: string | null; guestNames?: string[]; } export type EditRedemptionResult = | {status: 'EDITED'} | {status: 'NOT_FOUND'} | {status: 'NOT_ACTIVE'} | {status: 'INVALID_EMAIL'} | {status: 'EXCEEDS_MAX_GUESTS'; maxGuests: number} | {status: 'CAPACITY_EXCEEDED'; spotsRemaining: number}; /** * Direct admin correction of a redemption's contact info and/or guest * list. Only the fields present in `input` are changed. Growing the guest * count is re-checked against both the voucher's own max_guests and the * event's remaining capacity (forUpdate=true, same race-safety approach as * the public redeem path). */ export const editRedemption = async (redemptionId: number, input: EditRedemptionInput, adminEmail: string, reason: string | null): Promise => { if (input.contactEmail !== undefined && !isValidEmail(input.contactEmail)) { return {status: 'INVALID_EMAIL'}; } let conn = await NachklangTicketsDB.getConnection(); try { await conn.beginTransaction(); const rows = await conn.query('SELECT * FROM redemptions WHERE redemption_id = ? FOR UPDATE', [redemptionId]); if (rows.length === 0) { await conn.rollback(); return {status: 'NOT_FOUND'}; } const before = rows[0]; if (before.status !== 'ACTIVE') { await conn.rollback(); return {status: 'NOT_ACTIVE'}; } const changeSummary: Record = {}; const fields: string[] = []; const values: any[] = []; if (input.contactName !== undefined && input.contactName !== before.contact_name) { changeSummary.contactName = {before: before.contact_name, after: input.contactName}; fields.push('contact_name = ?'); values.push(input.contactName); } if (input.contactEmail !== undefined && input.contactEmail !== before.contact_email) { changeSummary.contactEmail = {before: before.contact_email, after: input.contactEmail}; fields.push('contact_email = ?'); values.push(input.contactEmail); } if (input.contactAddress !== undefined && input.contactAddress !== before.contact_address) { changeSummary.contactAddress = {before: before.contact_address, after: input.contactAddress}; fields.push('contact_address = ?'); values.push(input.contactAddress); } if (input.guestNames !== undefined) { const newCount = input.guestNames.length; const delta = newCount - before.guest_count; if (delta > 0) { const voucherRows = await conn.query('SELECT max_guests FROM voucher_codes WHERE code = ?', [before.code]); const maxGuests = voucherRows[0].max_guests; if (newCount > maxGuests) { await conn.rollback(); return {status: 'EXCEEDS_MAX_GUESTS', maxGuests}; } const ticketState = await getEventTicketState(conn, before.event_id, true); if (ticketState.spotsRemaining !== null && delta > ticketState.spotsRemaining) { await conn.rollback(); return {status: 'CAPACITY_EXCEEDED', spotsRemaining: ticketState.spotsRemaining}; } } const oldGuestRows = await conn.query('SELECT name FROM redemption_guests WHERE redemption_id = ? ORDER BY position', [redemptionId]); changeSummary.guests = {before: oldGuestRows.map((g: any) => g.name), after: input.guestNames}; fields.push('guest_count = ?'); values.push(newCount); await conn.query('DELETE FROM redemption_guests WHERE redemption_id = ?', [redemptionId]); for (let i = 0; i < input.guestNames.length; i++) { await conn.query('INSERT INTO redemption_guests (redemption_id, name, position) VALUES (?,?,?)', [redemptionId, input.guestNames[i], i]); } } if (fields.length > 0) { values.push(redemptionId); await conn.query(`UPDATE redemptions SET ${fields.join(', ')} WHERE redemption_id = ?`, values); } if (Object.keys(changeSummary).length > 0) { await conn.query( 'INSERT INTO voucher_audit_log (code, redemption_id, admin_email, action, change_summary, reason) VALUES (?,?,?,?,?,?)', [before.code, redemptionId, adminEmail, 'EDIT', JSON.stringify(changeSummary), reason] ); } await conn.commit(); return {status: 'EDITED'}; } catch (err) { await conn.rollback(); throw err; } finally { await conn.end(); } }; export type ResendConfirmationResult = 'SENT' | 'FAILED' | 'NOT_FOUND' | 'NOT_ACTIVE'; /** * Rebuilds and re-sends the redemption confirmation email from the stored * redemption data, then records the new outcome on the row. Used by the admin * UI's "resend" action on a redemption whose confirmation email failed. Only * active redemptions can be resent. */ export const resendRedemptionConfirmation = async (redemptionId: number): Promise => { const redemption = await getRedemption(redemptionId); if (!redemption) return 'NOT_FOUND'; if (redemption.status !== 'ACTIVE') return 'NOT_ACTIVE'; const sent = await sendRedemptionConfirmation({ eventId: redemption.eventId, contactName: redemption.contactName, contactEmail: redemption.contactEmail, guestNames: redemption.guests }); await recordConfirmationEmailResult(redemptionId, sent); return sent ? 'SENT' : 'FAILED'; }; export const getAuditHistory = async (code: string): Promise => { let conn = await NachklangTicketsDB.getConnection(); try { const rows = await conn.query('SELECT * FROM voucher_audit_log WHERE code = ? ORDER BY created_at DESC', [code]); return rows.map((row: any) => ({ auditId: row.audit_id, code: row.code, redemptionId: row.redemption_id, adminEmail: row.admin_email, action: row.action, // The mariadb driver already deserializes JSON-typed columns into // objects - only parse if we somehow got a raw string back. changeSummary: typeof row.change_summary === 'string' ? JSON.parse(row.change_summary) : (row.change_summary ?? null), reason: row.reason, createdAt: row.created_at })); } finally { await conn.end(); } };