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
+15 -3
View File
@@ -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
View File
@@ -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');
+93 -25
View File
@@ -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
+47 -9
View File
@@ -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