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,244 @@
|
||||
import {Guid} from 'guid-typescript';
|
||||
import {NachklangTicketsDB} from '../Tickets.db';
|
||||
import {generateUniqueCode} from '../tickets.codes';
|
||||
import {VoucherCode, VoucherStatus} from '../tickets.interface';
|
||||
import {isValidEmail} from '../tickets.validation';
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user