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
+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');