Add Tickets domain for the voucher-based ticket shop
New domain mirroring the Feedback module's conventions: single-use voucher codes (wildcard + personalized), redemption with race-safe capacity and deadline enforcement, admin CRUD for vouchers/redemptions/event settings with an audit trail, and reuse of the existing Calendar session auth. Backend pieces: - Tickets domain (public redeem flow, admin vouchers/redemptions/events) - Calendar events.service.ts: add getEventById - Mailer: attachment/HTML support, fix a shared-mutable-state race - Per-event capacity/deadline/address settings, with an "address required" option on top of "address collected" - Email format validation on redeem and admin voucher/redemption input - Docker Compose dev stack (MariaDB + seed data) for local testing Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import {requireAdminAuth} from '../tickets.auth';
|
||||
import {vouchersAdminRouter} from './vouchers.admin.router';
|
||||
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router';
|
||||
import {eventsAdminRouter} from './events.admin.router';
|
||||
|
||||
export const adminRouter = express.Router();
|
||||
|
||||
// Applied once at the top of the admin router tree - every route below
|
||||
// requires a valid admin session (mirrors Feedback's admin.router.ts).
|
||||
adminRouter.use(requireAdminAuth);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/me:
|
||||
* get:
|
||||
* summary: Validate the current admin session
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
adminRouter.get('/me', (req: Request, res: Response) => {
|
||||
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
||||
});
|
||||
|
||||
adminRouter.use('/vouchers', vouchersAdminRouter);
|
||||
adminRouter.use('/vouchers', voucherHistoryRouter);
|
||||
adminRouter.use('/redemptions', redemptionsAdminRouter);
|
||||
adminRouter.use('/events', eventsAdminRouter);
|
||||
@@ -0,0 +1,179 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as EventsAdminService from './events.admin.service';
|
||||
import {sendServerError} from '../tickets.errors';
|
||||
|
||||
export const eventsAdminRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/events:
|
||||
* get:
|
||||
* summary: List concerts for the admin event picker
|
||||
* description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events).
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.status(200).send(await EventsAdminService.listEventsForPicker());
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/events/available:
|
||||
* get:
|
||||
* summary: List public-calendar events not yet added to the ticket shop
|
||||
* description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.status(200).send(await EventsAdminService.listAvailableEventsToAdd());
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/events/{eventId}/stats:
|
||||
* get:
|
||||
* summary: Get a concert's voucher/capacity stats
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/EventStats'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.status(200).send(await EventsAdminService.getEventStats(Number(req.params.eventId)));
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/events/{eventId}/settings:
|
||||
* put:
|
||||
* summary: Set a concert's voucher settings
|
||||
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* capacity:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* redemptionDeadline:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* nullable: true
|
||||
* collectAddress:
|
||||
* type: boolean
|
||||
* requireAddress:
|
||||
* type: boolean
|
||||
* description: Only meaningful when collectAddress is true.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {capacity, redemptionDeadline, collectAddress, requireAddress} = req.body || {};
|
||||
await EventsAdminService.setEventSettings(Number(req.params.eventId), {
|
||||
capacity: capacity ?? null,
|
||||
// The mariadb driver needs an actual Date to serialize a DATETIME
|
||||
// column correctly - a raw ISO string (as arrives over JSON) gets
|
||||
// rejected with "Incorrect datetime value".
|
||||
redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null,
|
||||
collectAddress: !!collectAddress,
|
||||
requireAddress: !!collectAddress && !!requireAddress
|
||||
});
|
||||
res.status(200).send({status: 'OK'});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/events/{eventId}/settings:
|
||||
* delete:
|
||||
* summary: Remove an event from the ticket shop
|
||||
* description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Removed
|
||||
* 409:
|
||||
* description: Vouchers already reference this event
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await EventsAdminService.removeEvent(Number(req.params.eventId));
|
||||
if (result === 'HAS_VOUCHERS') {
|
||||
res.status(409).send({status: 'HAS_VOUCHERS', message: 'Für dieses Konzert existieren bereits Gutscheine.'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send({status: 'OK'});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import * as CalendarEventsService from '../../calendar/events/events.service';
|
||||
import {NachklangTicketsDB} from '../Tickets.db';
|
||||
import {getEventTicketState} from '../tickets.capacity';
|
||||
import {EventStats, EventTicketSettings} from '../tickets.interface';
|
||||
|
||||
// Concerts are managed on the public calendar (calendarId 1) - see
|
||||
// docs/plan-ticket-shop.md. getAllEventsAdmin includes DRAFT events so
|
||||
// organizers can generate vouchers for a concert before it's announced.
|
||||
const PUBLIC_CALENDAR_ID = 1;
|
||||
|
||||
export interface EventPickerEntry {
|
||||
eventId: number;
|
||||
name: string;
|
||||
startDateTime: Date;
|
||||
location: string;
|
||||
status: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The public calendar holds more than concerts (rehearsal announcements,
|
||||
* general notices, etc.), and Calendar's own Event has no category field to
|
||||
* tell them apart. `event_ticket_settings` doubles as the ticket shop's
|
||||
* allow-list: a Calendar event only appears here once an admin has
|
||||
* explicitly added it (see addEvent/removeEvent below) - even with every
|
||||
* setting left at its default (uncapped, no deadline, no address).
|
||||
*/
|
||||
export const listEventsForPicker = async (): Promise<EventPickerEntry[]> => {
|
||||
let conn = await NachklangTicketsDB.getConnection();
|
||||
let enabledEventIds: number[];
|
||||
try {
|
||||
const rows = await conn.query('SELECT event_id FROM event_ticket_settings');
|
||||
enabledEventIds = rows.map((r: any) => r.event_id);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
if (enabledEventIds.length === 0) return [];
|
||||
|
||||
const events = await Promise.all(enabledEventIds.map(id => CalendarEventsService.getEventById(id)));
|
||||
return events
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null && e.status !== 'DELETED')
|
||||
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status}))
|
||||
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
|
||||
};
|
||||
|
||||
/**
|
||||
* Public-calendar events that could be added to the ticket shop but
|
||||
* haven't been yet - source list for the "add a concert" picker.
|
||||
*/
|
||||
export const listAvailableEventsToAdd = async (): Promise<EventPickerEntry[]> => {
|
||||
let conn = await NachklangTicketsDB.getConnection();
|
||||
let enabledEventIds: Set<number>;
|
||||
try {
|
||||
const rows = await conn.query('SELECT event_id FROM event_ticket_settings');
|
||||
enabledEventIds = new Set(rows.map((r: any) => r.event_id));
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
const events = await CalendarEventsService.getAllEventsAdmin(PUBLIC_CALENDAR_ID);
|
||||
return events
|
||||
.filter(e => e.status !== 'DELETED' && !enabledEventIds.has(e.eventId))
|
||||
.map(e => ({eventId: e.eventId, name: e.name, startDateTime: e.startDateTime, location: e.location, status: e.status}))
|
||||
.sort((a, b) => a.startDateTime.getTime() - b.startDateTime.getTime());
|
||||
};
|
||||
|
||||
export const getEventStats = async (eventId: number): Promise<EventStats> => {
|
||||
let conn = await NachklangTicketsDB.getConnection();
|
||||
try {
|
||||
const ticketState = await getEventTicketState(conn, eventId);
|
||||
|
||||
const countRows = await conn.query(
|
||||
`SELECT vc.status, COUNT(DISTINCT vc.code) as cnt
|
||||
FROM voucher_codes vc
|
||||
INNER JOIN voucher_code_events vce ON vce.code = vc.code
|
||||
WHERE vce.event_id = ?
|
||||
GROUP BY vc.status`,
|
||||
[eventId]
|
||||
);
|
||||
let unusedCodes = 0, redeemedCodes = 0, voidCodes = 0;
|
||||
for (const row of countRows) {
|
||||
if (row.status === 'UNUSED') unusedCodes = Number(row.cnt);
|
||||
if (row.status === 'REDEEMED') redeemedCodes = Number(row.cnt);
|
||||
if (row.status === 'VOID') voidCodes = Number(row.cnt);
|
||||
}
|
||||
|
||||
return {
|
||||
eventId,
|
||||
capacity: ticketState.capacity,
|
||||
redemptionDeadline: ticketState.redemptionDeadline,
|
||||
collectAddress: ticketState.collectAddress,
|
||||
requireAddress: ticketState.requireAddress,
|
||||
guestsUsed: ticketState.guestsUsed,
|
||||
spotsRemaining: ticketState.spotsRemaining,
|
||||
unusedCodes,
|
||||
redeemedCodes,
|
||||
voidCodes
|
||||
};
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upsert - also doubles as "add this event to the ticket shop" when called
|
||||
* with all-default values (see listEventsForPicker).
|
||||
*/
|
||||
export const setEventSettings = async (eventId: number, settings: Omit<EventTicketSettings, 'eventId'>): Promise<void> => {
|
||||
let conn = await NachklangTicketsDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
await conn.query(
|
||||
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address)`,
|
||||
[eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0]
|
||||
);
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export type RemoveEventResult = 'REMOVED' | 'HAS_VOUCHERS';
|
||||
|
||||
/**
|
||||
* Removes an event from the ticket shop (deletes its settings row, so it
|
||||
* drops out of listEventsForPicker and reappears in the "add" list).
|
||||
* Refuses if vouchers already reference it - existing vouchers/redemptions
|
||||
* stay valid and keep working even for an event no longer offered for new
|
||||
* voucher generation, so this only blocks removing one that's still in use.
|
||||
*/
|
||||
export const removeEvent = async (eventId: number): Promise<RemoveEventResult> => {
|
||||
let conn = await NachklangTicketsDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const voucherRows = await conn.query('SELECT 1 FROM voucher_code_events WHERE event_id = ? LIMIT 1', [eventId]);
|
||||
if (voucherRows.length > 0) {
|
||||
await conn.rollback();
|
||||
return 'HAS_VOUCHERS';
|
||||
}
|
||||
|
||||
await conn.query('DELETE FROM event_ticket_settings WHERE event_id = ?', [eventId]);
|
||||
await conn.commit();
|
||||
return 'REMOVED';
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as RedemptionsAdminService from './redemptions.admin.service';
|
||||
import {sendServerError} from '../tickets.errors';
|
||||
|
||||
export const redemptionsAdminRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/redemptions:
|
||||
* get:
|
||||
* summary: List redemptions (admin)
|
||||
* description: Filterable by event and status (ACTIVE/UNDONE).
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: query
|
||||
* name: eventId
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/RedemptionStatus'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/RedemptionSummary'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const eventId = req.query.eventId !== undefined ? Number(req.query.eventId) : undefined;
|
||||
const status = req.query.status as any;
|
||||
res.status(200).send(await RedemptionsAdminService.listRedemptions({eventId, status}));
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/redemptions/{redemptionId}:
|
||||
* get:
|
||||
* summary: Get a single redemption (admin)
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: redemptionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 404:
|
||||
* description: Unknown redemption
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* patch:
|
||||
* summary: Edit a redemption's contact info and/or guest list
|
||||
* description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: redemptionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* contactName:
|
||||
* type: string
|
||||
* contactEmail:
|
||||
* type: string
|
||||
* contactAddress:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* guestNames:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* reason:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Edited
|
||||
* 404:
|
||||
* description: Unknown redemption
|
||||
* 409:
|
||||
* description: Not active, exceeds max guests, or exceeds remaining capacity
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const redemption = await RedemptionsAdminService.getRedemption(Number(req.params.redemptionId));
|
||||
if (!redemption) {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send(redemption);
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {contactName, contactEmail, contactAddress, guestNames, reason} = req.body || {};
|
||||
const result = await RedemptionsAdminService.editRedemption(
|
||||
Number(req.params.redemptionId),
|
||||
{contactName, contactEmail, contactAddress, guestNames},
|
||||
res.locals.admin.email,
|
||||
reason || null
|
||||
);
|
||||
|
||||
switch (result.status) {
|
||||
case 'EDITED':
|
||||
res.status(200).send({status: 'OK'});
|
||||
return;
|
||||
case 'NOT_FOUND':
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
case 'NOT_ACTIVE':
|
||||
res.status(409).send({status: 'NOT_ACTIVE', message: 'This redemption is not active.'});
|
||||
return;
|
||||
case 'INVALID_EMAIL':
|
||||
res.status(400).send({status: 'INVALID_EMAIL', message: 'Die E-Mail-Adresse sieht nicht gültig aus.'});
|
||||
return;
|
||||
case 'EXCEEDS_MAX_GUESTS':
|
||||
res.status(409).send({status: 'EXCEEDS_MAX_GUESTS', maxGuests: result.maxGuests});
|
||||
return;
|
||||
case 'CAPACITY_EXCEEDED':
|
||||
res.status(409).send({status: 'CAPACITY_EXCEEDED', spotsRemaining: result.spotsRemaining});
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/redemptions/{redemptionId}/undo:
|
||||
* post:
|
||||
* summary: Undo a redemption
|
||||
* description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: redemptionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* reason:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Undone
|
||||
* 404:
|
||||
* description: Unknown redemption
|
||||
* 409:
|
||||
* description: Redemption is not active
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await RedemptionsAdminService.undoRedemption(Number(req.params.redemptionId), res.locals.admin.email, req.body?.reason || null);
|
||||
if (result === 'NOT_FOUND') {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
if (result === 'NOT_ACTIVE') {
|
||||
res.status(409).send({status: 'NOT_ACTIVE', message: 'This redemption is not active.'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send({status: 'OK'});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/vouchers/{code}/history:
|
||||
* get:
|
||||
* summary: Get a voucher's admin-action audit trail
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: code
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AuditLogEntry'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
export const voucherHistoryRouter = express.Router();
|
||||
voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.status(200).send(await RedemptionsAdminService.getAuditHistory(req.params.code));
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import {NachklangTicketsDB} from '../Tickets.db';
|
||||
import {getEventTicketState} from '../tickets.capacity';
|
||||
import {AuditLogEntry, RedemptionSummary} from '../tickets.interface';
|
||||
import {isValidEmail} from '../tickets.validation';
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
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 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();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as VouchersAdminService from './vouchers.admin.service';
|
||||
import {sendServerError} from '../tickets.errors';
|
||||
|
||||
export const vouchersAdminRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/vouchers:
|
||||
* get:
|
||||
* summary: List vouchers (admin)
|
||||
* description: Filterable by event and status.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: query
|
||||
* name: eventId
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/VoucherStatus'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/VoucherCode'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const eventId = req.query.eventId !== undefined ? Number(req.query.eventId) : undefined;
|
||||
const status = req.query.status as any;
|
||||
res.status(200).send(await VouchersAdminService.listVouchers({eventId, status}));
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/vouchers/wildcard:
|
||||
* post:
|
||||
* summary: Batch-generate wildcard codes
|
||||
* description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [eventIds, quantity]
|
||||
* properties:
|
||||
* eventIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* maxGuests:
|
||||
* type: integer
|
||||
* default: 2
|
||||
* quantity:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* codes:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Invalid input
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {eventIds, maxGuests, quantity} = req.body || {};
|
||||
if (!Array.isArray(eventIds) || eventIds.length === 0 || !quantity) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'eventIds and quantity are required'});
|
||||
return;
|
||||
}
|
||||
const codes = await VouchersAdminService.generateWildcardBatch(
|
||||
{eventIds, maxGuests: maxGuests || 2, quantity},
|
||||
res.locals.admin.email
|
||||
);
|
||||
res.status(201).send({codes});
|
||||
} catch (e: any) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: e.message});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/vouchers/personalized:
|
||||
* post:
|
||||
* summary: Bulk-create personalized codes
|
||||
* description: One code per row (name, email, eligible events, max guests), grouped under one batchId.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [rows]
|
||||
* properties:
|
||||
* rows:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* required: [name, email, eventIds]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* eventIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* maxGuests:
|
||||
* type: integer
|
||||
* default: 2
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created
|
||||
* 400:
|
||||
* description: Invalid input
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rows = (req.body?.rows || []).map((r: any) => ({
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
eventIds: r.eventIds || [],
|
||||
maxGuests: r.maxGuests || 2
|
||||
}));
|
||||
const codes = await VouchersAdminService.generatePersonalizedBatch(rows, res.locals.admin.email);
|
||||
res.status(201).send({codes});
|
||||
} catch (e: any) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: e.message});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/vouchers/{code}:
|
||||
* get:
|
||||
* summary: Get a single voucher (admin)
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: code
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 404:
|
||||
* description: Unknown code
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const voucher = await VouchersAdminService.getVoucher(req.params.code);
|
||||
if (!voucher) {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send(voucher);
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/admin/vouchers/{code}/void:
|
||||
* post:
|
||||
* summary: Void an unredeemed code
|
||||
* description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: code
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* reason:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Voided
|
||||
* 404:
|
||||
* description: Unknown code
|
||||
* 409:
|
||||
* description: Code is not in UNUSED status
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await VouchersAdminService.voidCode(req.params.code, res.locals.admin.email, req.body?.reason || null);
|
||||
if (result === 'NOT_FOUND') {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
if (result === 'NOT_UNUSED') {
|
||||
res.status(409).send({status: 'NOT_UNUSED', message: 'Only unused codes can be voided.'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send({status: 'OK'});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
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: []
|
||||
});
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
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();
|
||||
|
||||
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