3ea9e630ed
Prep PR for the admin auth module (docs/plan-admin-auth.md step 1).
better-auth 1.7 ships ESM only, so the API moves off CommonJS:
- "type": "module", module nodenext, target ES2024, .js suffixes on all
relative imports, require('mariadb'|'cors') replaced by imports, and
export= packages (winston, app-root-path, bcrypt) consumed via default
imports. The logger now uses appRoot.path explicitly.
- TypeScript 5.9, @types/node 26, tslint removed. Node 26 pinned via
engines and .nvmrc (Plesk runs 26).
- Jest 28 + ts-jest replaced by vitest 5. Eight test files depend on
hoisted module mocks with static imports and resetModules + require,
which Jest's ESM mode does not support; vitest keeps them nearly
verbatim. Coverage via @vitest/coverage-v8 (lcov), Sonar generic report
via vitest-sonar-reporter, so sonar-project.properties is unchanged.
vitest.config.ts sets FEEDBACK_IP_SALT so the suite passes without a
local .env.
- dotenv 8 -> 16 and axios 0.24 -> 1.x: their old typings are not
resolvable under nodenext.
- autoCommit: false dropped from the pool configs; it is not a mariadb
connector option and was silently ignored.
tsc clean, 96/96 tests green, compiled app boots and serves /, /docs and
CORS under Node ESM.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
275 lines
9.9 KiB
TypeScript
275 lines
9.9 KiB
TypeScript
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<RedemptionSummary[]> => {
|
|
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<number, string[]>();
|
|
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<RedemptionSummary | null> => {
|
|
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<UndoRedemptionResult> => {
|
|
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<EditRedemptionResult> => {
|
|
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<string, {before: any; after: any}> = {};
|
|
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<ResendConfirmationResult> => {
|
|
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<AuditLogEntry[]> => {
|
|
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();
|
|
}
|
|
};
|