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>
245 lines
7.8 KiB
TypeScript
245 lines
7.8 KiB
TypeScript
import {Guid} from 'guid-typescript';
|
|
import {NachklangTicketsDB} from '../Tickets.db.js';
|
|
import {generateUniqueCode} from '../tickets.codes.js';
|
|
import {VoucherCode, VoucherStatus} from '../tickets.interface.js';
|
|
import {isValidEmail} from '../tickets.validation.js';
|
|
|
|
export interface WildcardGenerateInput {
|
|
eventIds: number[];
|
|
maxGuests: number;
|
|
quantity: number;
|
|
}
|
|
|
|
export interface PersonalizedRowInput {
|
|
name: string;
|
|
email: string;
|
|
eventIds: number[];
|
|
maxGuests: number;
|
|
}
|
|
|
|
const mapVoucherRow = (row: any): VoucherCode => ({
|
|
code: row.code,
|
|
status: row.status,
|
|
maxGuests: row.max_guests,
|
|
prefillName: row.prefill_name,
|
|
prefillEmail: row.prefill_email,
|
|
batchId: row.batch_id,
|
|
createdByEmail: row.created_by_email,
|
|
createdAt: row.created_at,
|
|
eligibleEventIds: []
|
|
});
|
|
|
|
/**
|
|
* Guards the event allow-list (event_ticket_settings) at the one place
|
|
* codes actually get minted - the admin event picker already filters to
|
|
* allow-listed events, but that's cosmetic unless generation enforces the
|
|
* same rule server-side. Without this, any event_id could be passed
|
|
* directly (bypassing the picker) and get a fully-uncapped, no-deadline
|
|
* redeemable code minted for a non-concert Calendar event.
|
|
*/
|
|
const assertEventsAllowListed = async (conn: any, eventIds: number[]): Promise<void> => {
|
|
const uniqueIds = [...new Set(eventIds)];
|
|
const rows = await conn.query('SELECT event_id FROM event_ticket_settings WHERE event_id IN (?)', [uniqueIds]);
|
|
const allowListed = new Set<number>(rows.map((r: any) => r.event_id));
|
|
const missing = uniqueIds.filter(id => !allowListed.has(id));
|
|
if (missing.length > 0) {
|
|
throw new Error(`event(s) not added to the ticket shop yet: ${missing.join(', ')}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Attaches eligibleEventIds to a list of voucher rows in one extra query,
|
|
* rather than N+1 per code.
|
|
*/
|
|
const attachEligibleEvents = async (conn: any, vouchers: VoucherCode[]): Promise<VoucherCode[]> => {
|
|
if (vouchers.length === 0) return vouchers;
|
|
const codes = vouchers.map(v => v.code);
|
|
const rows = await conn.query('SELECT code, event_id FROM voucher_code_events WHERE code IN (?)', [codes]);
|
|
const byCode = new Map<string, number[]>();
|
|
for (const row of rows) {
|
|
const list = byCode.get(row.code) || [];
|
|
list.push(row.event_id);
|
|
byCode.set(row.code, list);
|
|
}
|
|
for (const voucher of vouchers) {
|
|
voucher.eligibleEventIds = byCode.get(voucher.code) || [];
|
|
}
|
|
return vouchers;
|
|
};
|
|
|
|
/**
|
|
* Batch-generates N wildcard codes sharing the same eligible events and
|
|
* max-guest count. All codes get the same batchId so the admin UI can group
|
|
* "codes generated together" (e.g. for printing a sheet for the conductor).
|
|
*/
|
|
export const generateWildcardBatch = async (input: WildcardGenerateInput, createdByEmail: string): Promise<string[]> => {
|
|
if (input.quantity < 1 || input.quantity > 500) {
|
|
throw new Error('quantity must be between 1 and 500');
|
|
}
|
|
if (input.eventIds.length === 0) {
|
|
throw new Error('at least one eligible event is required');
|
|
}
|
|
|
|
let conn = await NachklangTicketsDB.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
|
|
await assertEventsAllowListed(conn, input.eventIds);
|
|
|
|
const existingRows = await conn.query('SELECT code FROM voucher_codes');
|
|
const existingCodes = new Set<string>(existingRows.map((r: any) => r.code));
|
|
|
|
const batchId = Guid.create().toString();
|
|
const codes: string[] = [];
|
|
for (let i = 0; i < input.quantity; i++) {
|
|
const code = generateUniqueCode(existingCodes);
|
|
codes.push(code);
|
|
await conn.query(
|
|
'INSERT INTO voucher_codes (code, status, max_guests, batch_id, created_by_email) VALUES (?,?,?,?,?)',
|
|
[code, 'UNUSED', input.maxGuests, batchId, createdByEmail]
|
|
);
|
|
for (const eventId of input.eventIds) {
|
|
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
|
|
}
|
|
}
|
|
|
|
await conn.commit();
|
|
return codes;
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw err;
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Bulk-creates personalized codes from a list of rows (repeating-row admin
|
|
* UI - see docs/plan-ticket-shop.md). One code per row, all sharing a
|
|
* batchId for the submission.
|
|
*/
|
|
export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], createdByEmail: string): Promise<string[]> => {
|
|
if (rows.length === 0) {
|
|
throw new Error('at least one row is required');
|
|
}
|
|
for (const row of rows) {
|
|
if (!row.name || !row.email || row.eventIds.length === 0) {
|
|
throw new Error('each row requires a name, email, and at least one eligible event');
|
|
}
|
|
if (!isValidEmail(row.email)) {
|
|
throw new Error(`"${row.email}" does not look like a valid email address`);
|
|
}
|
|
}
|
|
|
|
let conn = await NachklangTicketsDB.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
|
|
await assertEventsAllowListed(conn, rows.flatMap(r => r.eventIds));
|
|
|
|
const existingRows = await conn.query('SELECT code FROM voucher_codes');
|
|
const existingCodes = new Set<string>(existingRows.map((r: any) => r.code));
|
|
|
|
const batchId = Guid.create().toString();
|
|
const codes: string[] = [];
|
|
for (const row of rows) {
|
|
const code = generateUniqueCode(existingCodes);
|
|
codes.push(code);
|
|
await conn.query(
|
|
'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)',
|
|
[code, 'UNUSED', row.maxGuests, row.name, row.email, batchId, createdByEmail]
|
|
);
|
|
for (const eventId of row.eventIds) {
|
|
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
|
|
}
|
|
}
|
|
|
|
await conn.commit();
|
|
return codes;
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw err;
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
export interface ListVouchersFilter {
|
|
eventId?: number;
|
|
status?: VoucherStatus;
|
|
}
|
|
|
|
export const listVouchers = async (filter: ListVouchersFilter): Promise<VoucherCode[]> => {
|
|
let conn = await NachklangTicketsDB.getConnection();
|
|
try {
|
|
let query = 'SELECT vc.* FROM voucher_codes vc';
|
|
const params: any[] = [];
|
|
const where: string[] = [];
|
|
|
|
if (filter.eventId !== undefined) {
|
|
query += ' INNER JOIN voucher_code_events vce ON vce.code = vc.code';
|
|
where.push('vce.event_id = ?');
|
|
params.push(filter.eventId);
|
|
}
|
|
if (filter.status) {
|
|
where.push('vc.status = ?');
|
|
params.push(filter.status);
|
|
}
|
|
if (where.length > 0) {
|
|
query += ' WHERE ' + where.join(' AND ');
|
|
}
|
|
query += ' GROUP BY vc.code ORDER BY vc.created_at DESC';
|
|
|
|
const rows = await conn.query(query, params);
|
|
const vouchers = rows.map(mapVoucherRow);
|
|
return await attachEligibleEvents(conn, vouchers);
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
export const getVoucher = async (code: string): Promise<VoucherCode | null> => {
|
|
let conn = await NachklangTicketsDB.getConnection();
|
|
try {
|
|
const rows = await conn.query('SELECT * FROM voucher_codes WHERE code = ?', [code]);
|
|
if (rows.length === 0) return null;
|
|
const [voucher] = await attachEligibleEvents(conn, [mapVoucherRow(rows[0])]);
|
|
return voucher;
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
export type VoidCodeResult = 'VOIDED' | 'NOT_FOUND' | 'NOT_UNUSED';
|
|
|
|
export const voidCode = async (code: string, adminEmail: string, reason: string | null): Promise<VoidCodeResult> => {
|
|
let conn = await NachklangTicketsDB.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
|
|
const rows = await conn.query('SELECT status FROM voucher_codes WHERE code = ?', [code]);
|
|
if (rows.length === 0) {
|
|
await conn.rollback();
|
|
return 'NOT_FOUND';
|
|
}
|
|
if (rows[0].status !== 'UNUSED') {
|
|
await conn.rollback();
|
|
return 'NOT_UNUSED';
|
|
}
|
|
|
|
await conn.query('UPDATE voucher_codes SET status = ? WHERE code = ?', ['VOID', code]);
|
|
await conn.query(
|
|
'INSERT INTO voucher_audit_log (code, redemption_id, admin_email, action, reason) VALUES (?,NULL,?,?,?)',
|
|
[code, adminEmail, 'VOID', reason]
|
|
);
|
|
|
|
await conn.commit();
|
|
return 'VOIDED';
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw err;
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|