Files
API/src/models/tickets/admin/events.admin.service.ts
T
Paddy 3ea9e630ed Migrate the API to native ESM and vitest; pin Node 26
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>
2026-09-05 16:02:26 +02:00

156 lines
5.7 KiB
TypeScript

import * as CalendarEventsService from '../../calendar/events/events.service.js';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {EventStats, EventTicketSettings} from '../tickets.interface.js';
// 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();
}
};