Security recovery: hold sweep, dashboard updates, and admin fixes.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user