Security recovery: hold sweep, dashboard updates, and admin fixes.

This commit is contained in:
Michilis
2026-07-01 05:51:38 +00:00
parent 38526f17b5
commit cacc52ec24
45 changed files with 1452 additions and 474 deletions
+116
View File
@@ -0,0 +1,116 @@
// Shared capacity-checked recovery for on-hold bookings.
//
// When a booking is put on hold, its ticket(s) drop out of the capacity-counting
// statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. Recovering
// an on-hold booking (user "I've paid" again, or an admin reactivating / marking it
// paid) must atomically re-check that the event still has room before re-reserving
// the seat, exactly like the original booking-creation flow in routes/tickets.ts.
import { eq, and, inArray, sql } from 'drizzle-orm';
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
export class HoldCapacityError extends Error {
constructor(public available: number) {
super('EVENT_FULL');
}
}
interface ReserveOptions {
paidByAdminId?: string;
extraPaymentFields?: Record<string, any>;
}
/**
* Re-reserve seats for a group of on-hold tickets (e.g. all tickets sharing a
* bookingId), atomically re-checking capacity before flipping their status.
* Throws HoldCapacityError if the event no longer has room for ticketIds.length seats.
*/
export async function reserveOnHoldBooking(
eventId: string,
ticketIds: string[],
targetTicketStatus: 'pending' | 'confirmed',
targetPaymentStatus: 'pending_approval' | 'paid',
options: ReserveOptions = {}
): Promise<void> {
if (ticketIds.length === 0) return;
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) {
throw new Error('Event not found');
}
const now = getNow();
const paymentUpdate: Record<string, any> = {
status: targetPaymentStatus,
updatedAt: now,
...options.extraPaymentFields,
};
if (targetPaymentStatus === 'paid') {
paymentUpdate.paidAt = now;
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
}
const assertCapacity = (reserved: number) => {
if (isEventSoldOut(event.capacity, reserved)) {
throw new HoldCapacityError(0);
}
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
if (ticketIds.length > seatsLeft) {
throw new HoldCapacityError(seatsLeft);
}
};
if (isSqlite()) {
(db as any).transaction((tx: any) => {
const countRow = tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
))
.get();
assertCapacity(Number(countRow?.count || 0));
tx.update(tickets)
.set({ status: targetTicketStatus })
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'on_hold')
))
.run();
tx.update(payments)
.set(paymentUpdate)
.where(inArray((payments as any).ticketId, ticketIds))
.run();
});
} else {
await (db as any).transaction(async (tx: any) => {
const countRow = await dbGet<any>(
tx
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
))
);
assertCapacity(Number(countRow?.count || 0));
await tx.update(tickets)
.set({ status: targetTicketStatus })
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'on_hold')
));
await tx.update(payments)
.set(paymentUpdate)
.where(inArray((payments as any).ticketId, ticketIds));
});
}
}
+98
View File
@@ -0,0 +1,98 @@
// Auto-hold stale pending-approval bookings.
//
// A payment enters 'pending_approval' when a user clicks "I've paid" on a manual
// payment method (bank transfer / TPago) and is waiting for an admin to review it.
// If no admin acts within HOLD_THRESHOLD_HOURS, this job silently moves the payment
// (and its ticket) to 'on_hold', which drops it out of the capacity-counting statuses
// ('pending', 'confirmed', 'checked_in') and so releases the seat back to the event.
// The user receives no notification — they can recover via "I've paid" again, and an
// admin can reactivate or mark it paid directly, both re-checking capacity.
import { and, eq, lt, inArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js';
function getThresholdMs(): number {
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
return (Number.isFinite(hours) && hours > 0 ? hours : 72) * 60 * 60 * 1000;
}
/**
* Move stale pending-approval payments (and their tickets) to 'on_hold'.
* Returns the number of payments put on hold.
*/
export async function sweepStaleApprovals(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
(db as any)
.select({
ticketId: (payments as any).ticketId,
paymentId: (payments as any).id,
})
.from(payments)
.where(and(
eq((payments as any).status, 'pending_approval'),
lt((payments as any).createdAt, cutoff)
))
);
if (stale.length === 0) return 0;
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
const paymentIds = stale.map((s) => s.paymentId);
const now = getNow();
await (db as any)
.update(payments)
.set({ status: 'on_hold', updatedAt: now })
.where(inArray((payments as any).id, paymentIds));
if (ticketIds.length > 0) {
await (db as any)
.update(tickets)
.set({ status: 'on_hold' })
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'pending')
));
}
console.log(`[HoldSweep] Put ${stale.length} stale pending-approval payment(s) on hold.`);
return stale.length;
}
let sweepTimer: ReturnType<typeof setInterval> | null = null;
/**
* Start a periodic sweep of stale pending-approval payments. Each run is guarded by
* a distributed lock so that, across multiple replicas, only one instance does the
* work per interval.
*/
export function startHoldSweep(): void {
const intervalMs = parseInt(process.env.HOLD_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
const run = () => {
getLock()
.withLock('sweep-hold-stale-approvals', Math.min(intervalMs, 60_000), () =>
sweepStaleApprovals()
)
.catch((err) =>
console.error('[HoldSweep] Run failed:', err?.message || err)
);
};
// Run shortly after startup, then on the interval.
setTimeout(run, 45_000).unref?.();
sweepTimer = setInterval(run, intervalMs);
sweepTimer.unref?.();
console.log(`[HoldSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
}
export function stopHoldSweep(): void {
if (sweepTimer) {
clearInterval(sweepTimer);
sweepTimer = null;
}
}