Security recovery: hold sweep, dashboard updates, and admin fixes.
This commit is contained in:
@@ -114,3 +114,10 @@ PENDING_BOOKING_TTL_MINUTES=30
|
||||
# How often the cleanup job runs, in milliseconds (default: 300000 = 5 min)
|
||||
PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000
|
||||
|
||||
# Auto-Hold Sweep
|
||||
# Bookings awaiting admin payment approval are put on hold (seat released) after
|
||||
# this many hours with no confirmation (default: 72)
|
||||
HOLD_THRESHOLD_HOURS=72
|
||||
# How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min)
|
||||
HOLD_SWEEP_INTERVAL_MS=900000
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ export const sqliteTickets = sqliteTable('tickets', {
|
||||
attendeePhone: text('attendee_phone'),
|
||||
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
|
||||
preferredLanguage: text('preferred_language'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'),
|
||||
checkinAt: text('checkin_at'),
|
||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||
qrCode: text('qr_code'),
|
||||
@@ -119,7 +119,7 @@ export const sqlitePayments = sqliteTable('payments', {
|
||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
||||
amount: real('amount').notNull(),
|
||||
currency: text('currency').notNull().default('PYG'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
||||
reference: text('reference'),
|
||||
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
|
||||
payerName: text('payer_name'), // Name of payer if different from attendee
|
||||
|
||||
@@ -26,6 +26,7 @@ import faqRoutes from './routes/faq.js';
|
||||
import emailService from './lib/email.js';
|
||||
import { initEmailQueue } from './lib/emailQueue.js';
|
||||
import { startBookingCleanup } from './lib/bookingCleanup.js';
|
||||
import { startHoldSweep } from './lib/holdSweep.js';
|
||||
import { getLock } from './lib/stores/lock.js';
|
||||
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
||||
|
||||
@@ -1932,6 +1933,9 @@ initEmailQueue(emailService);
|
||||
// Periodically expire abandoned pending bookings so they stop holding seats.
|
||||
startBookingCleanup();
|
||||
|
||||
// Periodically put stale pending-approval payments on hold, releasing their seats.
|
||||
startHoldSweep();
|
||||
|
||||
// Initialize email templates on startup.
|
||||
// Guarded by a distributed lock so that, when running multiple replicas, only
|
||||
// one instance seeds/updates templates per boot instead of all of them racing.
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,14 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending'))
|
||||
);
|
||||
|
||||
|
||||
const onHoldPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'on_hold'))
|
||||
);
|
||||
|
||||
const revenueRow = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` })
|
||||
@@ -108,6 +115,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
totalTickets: totalTickets?.count || 0,
|
||||
confirmedTickets: confirmedTickets?.count || 0,
|
||||
pendingPayments: pendingPayments?.count || 0,
|
||||
onHoldPayments: onHoldPayments?.count || 0,
|
||||
totalRevenue,
|
||||
newContacts: newContacts?.count || 0,
|
||||
totalSubscribers: totalSubscribers?.count || 0,
|
||||
@@ -213,6 +221,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
userName: user?.name,
|
||||
userEmail: user?.email,
|
||||
userPhone: user?.phone,
|
||||
attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null,
|
||||
eventTitle: event?.title,
|
||||
eventDate: event?.startDatetime,
|
||||
paymentStatus: payment?.status,
|
||||
@@ -291,6 +300,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
'Full Name': fullName,
|
||||
'Email': ticket.attendeeEmail || '',
|
||||
'Phone': ticket.attendeePhone || '',
|
||||
'RUC': ticket.attendeeRuc || '',
|
||||
'Status': ticket.status,
|
||||
'Checked In': isCheckedIn ? 'true' : 'false',
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
@@ -302,7 +312,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
);
|
||||
|
||||
const columns = [
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC',
|
||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||
'Booked At', 'Notes',
|
||||
];
|
||||
@@ -380,12 +390,13 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
|
||||
});
|
||||
}
|
||||
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'RUC', 'Status', 'Check-in Time', 'Booked At'];
|
||||
|
||||
const rows = ticketList.map((ticket: any) => ({
|
||||
'Ticket ID': ticket.id,
|
||||
'Booking ID': ticket.bookingId || '',
|
||||
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
||||
'RUC': ticket.attendeeRuc || '',
|
||||
'Status': ticket.status,
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
@@ -458,6 +469,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
attendeeFirstName: ticket.attendeeFirstName,
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeeRuc: ticket.attendeeRuc || null,
|
||||
eventId: event?.id,
|
||||
eventTitle: event?.title,
|
||||
eventDate: event?.startDatetime,
|
||||
|
||||
+115
-31
@@ -2,15 +2,16 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
|
||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||
import { eq, desc, and, or, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
|
||||
const paymentsRouter = new Hono();
|
||||
|
||||
const updatePaymentSchema = z.object({
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']),
|
||||
reference: z.string().optional(),
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
@@ -85,6 +86,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
attendeeRuc: ticket.attendeeRuc,
|
||||
status: ticket.status,
|
||||
} : null,
|
||||
event: event ? {
|
||||
@@ -95,7 +97,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Filter by event(s)
|
||||
if (eventId) {
|
||||
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
|
||||
@@ -164,12 +166,13 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
|
||||
|
||||
// Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id
|
||||
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, revenueRow] = await Promise.all([
|
||||
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, onHoldRow, revenueRow] = await Promise.all([
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments)),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'on_hold'))),
|
||||
dbGet<any>((db as any).select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))),
|
||||
]);
|
||||
|
||||
@@ -180,6 +183,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
paid: Number(paidRow?.count || 0),
|
||||
refunded: Number(refundedRow?.count || 0),
|
||||
failed: Number(failedRow?.count || 0),
|
||||
onHold: Number(onHoldRow?.count || 0),
|
||||
totalRevenue: Number(revenueRow?.total || 0),
|
||||
},
|
||||
});
|
||||
@@ -317,13 +321,13 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Can approve pending or pending_approval payments
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
// Can approve pending, pending_approval, or on_hold payments
|
||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||
}
|
||||
|
||||
|
||||
const now = getNow();
|
||||
|
||||
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -331,10 +335,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
|
||||
// Check if this is part of a multi-ticket booking
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
|
||||
if (ticket?.bookingId) {
|
||||
// Get all tickets in this booking
|
||||
ticketsToConfirm = await dbAll(
|
||||
@@ -345,27 +349,54 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
);
|
||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
|
||||
if (payment.status === 'on_hold') {
|
||||
// The seat was released when this booking went on hold - re-check capacity
|
||||
// before confirming it directly.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{ paidByAdminId: user.id }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (adminNote) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ adminNote })
|
||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)));
|
||||
}
|
||||
} else {
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||
if (sendEmail !== false) {
|
||||
Promise.all([
|
||||
@@ -405,7 +436,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
|
||||
}
|
||||
|
||||
@@ -464,6 +495,59 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
||||
});
|
||||
|
||||
// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat
|
||||
paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (payment.status !== 'on_hold') {
|
||||
return c.json({ error: 'Only on-hold payments can be reactivated' }, 400);
|
||||
}
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
let ticketsToReactivate: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToReactivate = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToReactivate.map((t: any) => t.id),
|
||||
'pending',
|
||||
'pending_approval'
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
|
||||
});
|
||||
|
||||
// Send payment reminder email
|
||||
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
|
||||
const ticketsRouter = new Hono();
|
||||
|
||||
@@ -53,7 +54,7 @@ function isPaymentMethodEnabled(method: string, merged: Record<string, any>): bo
|
||||
}
|
||||
|
||||
const updateTicketSchema = z.object({
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold']).optional(),
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -167,12 +168,21 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
rucNumber: data.ruc || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await (db as any).insert(users).values(user);
|
||||
} else if (data.ruc) {
|
||||
// Keep the user's saved RUC up to date for future bookings, but never blank
|
||||
// out an existing value if this booking didn't include one.
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ rucNumber: data.ruc, updatedAt: now })
|
||||
.where(eq((users as any).id, user.id));
|
||||
user.rucNumber = data.ruc;
|
||||
}
|
||||
|
||||
|
||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||
const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false;
|
||||
|
||||
@@ -1104,26 +1114,47 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm all tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
// Update ticket status
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, t.id));
|
||||
|
||||
// Update payment status
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, t.id));
|
||||
if (ticket.status === 'on_hold') {
|
||||
// The seat was released when this booking went on hold - re-check capacity
|
||||
// before confirming it directly.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{ paidByAdminId: user.id }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
// Confirm all tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
// Update ticket status
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, t.id));
|
||||
|
||||
// Update payment status
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, t.id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -1194,18 +1225,55 @@ ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, wind
|
||||
}
|
||||
|
||||
if (payment.status === 'paid') {
|
||||
return c.json({
|
||||
payment,
|
||||
return c.json({
|
||||
payment,
|
||||
message: 'Payment has already been confirmed.',
|
||||
alreadyProcessed: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// A booking that was auto-released after the hold threshold: recover it by
|
||||
// re-reserving the seat(s) and moving back into the admin approval queue.
|
||||
if (payment.status === 'on_hold') {
|
||||
let ticketsToRecover: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToRecover = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToRecover.map((t: any) => t.id),
|
||||
'pending',
|
||||
'pending_approval',
|
||||
{ extraPaymentFields: { userMarkedPaidAt: getNow(), payerName: payerName?.trim() || null } }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const recoveredPayment = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, payment.id))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
payment: recoveredPayment,
|
||||
message: 'Payment marked as sent. Waiting for admin approval.',
|
||||
});
|
||||
}
|
||||
|
||||
// Only allow if currently pending
|
||||
if (payment.status !== 'pending') {
|
||||
return c.json({ error: 'Payment has already been processed' }, 400);
|
||||
}
|
||||
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Update payment status to pending_approval for this ticket and any siblings
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
@@ -27,7 +27,43 @@ const updateUserSchema = z.object({
|
||||
// Get all users (admin only)
|
||||
usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const role = c.req.query('role');
|
||||
|
||||
const search = c.req.query('search');
|
||||
const accountStatus = c.req.query('accountStatus');
|
||||
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
|
||||
const registeredAfter = c.req.query('registeredAfter');
|
||||
const registeredBefore = c.req.query('registeredBefore');
|
||||
const eventId = c.req.query('eventId');
|
||||
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (role) conditions.push(eq((users as any).role, role));
|
||||
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
|
||||
if (registeredAfter) conditions.push(gte((users as any).createdAt, registeredAfter));
|
||||
if (registeredBefore) conditions.push(lte((users as any).createdAt, registeredBefore));
|
||||
if (search) {
|
||||
const like = `%${search.toLowerCase()}%`;
|
||||
conditions.push(sql`(
|
||||
LOWER(${(users as any).name}) LIKE ${like}
|
||||
OR LOWER(${(users as any).email}) LIKE ${like}
|
||||
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
|
||||
)`);
|
||||
}
|
||||
if (hasBookings === 'yes') {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
} else if (hasBookings === 'no') {
|
||||
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
}
|
||||
if (eventId) {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
|
||||
}
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const totalQuery = whereClause
|
||||
? (db as any).select({ count: sql<number>`count(*)` }).from(users).where(whereClause)
|
||||
: (db as any).select({ count: sql<number>`count(*)` }).from(users);
|
||||
const totalRow = await dbGet<any>(totalQuery);
|
||||
|
||||
let query = (db as any).select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
@@ -40,14 +76,16 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
accountStatus: (users as any).accountStatus,
|
||||
createdAt: (users as any).createdAt,
|
||||
}).from(users);
|
||||
|
||||
if (role) {
|
||||
query = query.where(eq((users as any).role, role));
|
||||
|
||||
if (whereClause) {
|
||||
query = query.where(whereClause);
|
||||
}
|
||||
|
||||
const result = await dbAll(query.orderBy(desc((users as any).createdAt)));
|
||||
|
||||
return c.json({ users: result });
|
||||
|
||||
const result = await dbAll(
|
||||
query.orderBy(desc((users as any).createdAt)).limit(pageSize).offset((page - 1) * pageSize)
|
||||
);
|
||||
|
||||
return c.json({ users: result, total: Number(totalRow?.count || 0), page, pageSize });
|
||||
});
|
||||
|
||||
// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
|
||||
|
||||
Reference in New Issue
Block a user