From cacc52ec240fbf7e3e0a0c55edde102b1b870b28 Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 1 Jul 2026 05:51:38 +0000 Subject: [PATCH 1/7] Security recovery: hold sweep, dashboard updates, and admin fixes. --- backend/.env.example | 7 + backend/src/db/schema.ts | 4 +- backend/src/index.ts | 4 + backend/src/lib/holdRecovery.ts | 116 ++++++++ backend/src/lib/holdSweep.ts | 98 +++++++ backend/src/routes/admin.ts | 18 +- backend/src/routes/payments.ts | 146 ++++++++--- backend/src/routes/tickets.ts | 118 +++++++-- backend/src/routes/users.ts | 56 +++- deploy/front-end_nginx.conf | 19 +- .../src/app/(public)/community/layout.tsx | 3 + frontend/src/app/(public)/contact/layout.tsx | 3 + .../dashboard/components/OverviewTab.tsx | 36 ++- .../dashboard/components/PaymentsTab.tsx | 17 +- .../dashboard/components/TicketsTab.tsx | 23 +- .../components/_shared/AttentionBanner.tsx | 37 +++ .../components/_shared/PayActions.tsx | 42 ++- .../dashboard/components/_shared/helpers.ts | 10 + .../dashboard/components/_shared/status.tsx | 8 +- .../src/app/(public)/events/EventsClient.tsx | 147 +++++++++++ .../src/app/(public)/events/[id]/page.tsx | 4 +- frontend/src/app/(public)/events/layout.tsx | 7 +- frontend/src/app/(public)/events/page.tsx | 176 ++----------- frontend/src/app/(public)/faq/FaqClient.tsx | 95 +++++++ frontend/src/app/(public)/faq/layout.tsx | 3 + frontend/src/app/(public)/faq/page.tsx | 128 ++------- .../src/app/(public)/legal/[slug]/page.tsx | 3 +- frontend/src/app/(public)/page.tsx | 12 +- frontend/src/app/admin/[...notFound]/page.tsx | 5 + frontend/src/app/admin/bookings/page.tsx | 56 +++- .../events/[id]/_components/StatusBadge.tsx | 1 + .../admin/events/[id]/_modals/EventModals.tsx | 3 + .../admin/events/[id]/_tabs/AttendeesTab.tsx | 18 ++ frontend/src/app/admin/events/[id]/_types.ts | 2 +- frontend/src/app/admin/events/[id]/page.tsx | 19 +- frontend/src/app/admin/layout.tsx | 15 +- frontend/src/app/admin/not-found.tsx | 9 + frontend/src/app/admin/payments/page.tsx | 85 +++++- frontend/src/app/admin/users/page.tsx | 247 +++++++++++++++--- frontend/src/app/layout.tsx | 22 +- frontend/src/app/not-found.tsx | 34 +-- frontend/src/components/NotFoundMessage.tsx | 29 ++ frontend/src/lib/api/payments.ts | 5 + frontend/src/lib/api/types.ts | 6 +- frontend/src/lib/api/users.ts | 30 ++- 45 files changed, 1452 insertions(+), 474 deletions(-) create mode 100644 backend/src/lib/holdRecovery.ts create mode 100644 backend/src/lib/holdSweep.ts create mode 100644 frontend/src/app/(public)/events/EventsClient.tsx create mode 100644 frontend/src/app/(public)/faq/FaqClient.tsx create mode 100644 frontend/src/app/admin/[...notFound]/page.tsx create mode 100644 frontend/src/app/admin/not-found.tsx create mode 100644 frontend/src/components/NotFoundMessage.tsx diff --git a/backend/.env.example b/backend/.env.example index 71c348b..8e0af46 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 + diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index addab36..a79e2df 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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 diff --git a/backend/src/index.ts b/backend/src/index.ts index 9532ae7..69115fe 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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. diff --git a/backend/src/lib/holdRecovery.ts b/backend/src/lib/holdRecovery.ts new file mode 100644 index 0000000..ebdb5c5 --- /dev/null +++ b/backend/src/lib/holdRecovery.ts @@ -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; +} + +/** + * 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 { + if (ticketIds.length === 0) return; + + const event = await dbGet( + (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 = { + 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`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( + tx + .select({ count: sql`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)); + }); + } +} diff --git a/backend/src/lib/holdSweep.ts b/backend/src/lib/holdSweep.ts new file mode 100644 index 0000000..ebc5905 --- /dev/null +++ b/backend/src/lib/holdSweep.ts @@ -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 { + 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 | 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; + } +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index c9327a8..c7a355f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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( + (db as any) + .select({ count: sql`count(*)` }) + .from(payments) + .where(eq((payments as any).status, 'on_hold')) + ); + const revenueRow = await dbGet( (db as any) .select({ total: sql`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, diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index f480479..46dc1c1 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -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((db as any).select({ count: sql`count(*)` }).from(payments)), dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))), dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))), dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))), dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'on_hold'))), dbGet((db as any).select({ total: sql`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( (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( + (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( + (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( + (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'); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 007e6b4..8e923c7 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -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): 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( (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( + (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 diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 3773812..03dcada 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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`count(*)` }).from(users).where(whereClause) + : (db as any).select({ count: sql`count(*)` }).from(users); + const totalRow = await dbGet(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 diff --git a/deploy/front-end_nginx.conf b/deploy/front-end_nginx.conf index 30b7c88..9b96a40 100644 --- a/deploy/front-end_nginx.conf +++ b/deploy/front-end_nginx.conf @@ -18,11 +18,28 @@ server { } } +# Canonical host is the apex (non-www). Redirect the www HTTPS vhost to it with a +# 301 so only one host is served and indexed. server { listen 443 ssl; http2 on; - server_name spanglishcommunity.com www.spanglishcommunity.com; + server_name www.spanglishcommunity.com; + + ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem; + + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + return 301 https://spanglishcommunity.com$request_uri; +} + +server { + listen 443 ssl; + http2 on; + + server_name spanglishcommunity.com; # Upload size limit (covers same-origin /api uploads via this vhost) client_max_body_size 20m; diff --git a/frontend/src/app/(public)/community/layout.tsx b/frontend/src/app/(public)/community/layout.tsx index 51dac59..4a731b5 100644 --- a/frontend/src/app/(public)/community/layout.tsx +++ b/frontend/src/app/(public)/community/layout.tsx @@ -3,6 +3,9 @@ import type { Metadata } from 'next'; export const metadata: Metadata = { title: 'Join Our Language Exchange Community', description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.', + alternates: { + canonical: '/community', + }, openGraph: { title: 'Join Our Language Exchange Community – Spanglish', description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.', diff --git a/frontend/src/app/(public)/contact/layout.tsx b/frontend/src/app/(public)/contact/layout.tsx index 91e100d..3d3e90e 100644 --- a/frontend/src/app/(public)/contact/layout.tsx +++ b/frontend/src/app/(public)/contact/layout.tsx @@ -3,6 +3,9 @@ import type { Metadata } from 'next'; export const metadata: Metadata = { title: 'Contact Us', description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', + alternates: { + canonical: '/contact', + }, openGraph: { title: 'Contact Us – Spanglish', description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', diff --git a/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx b/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx index 83dd86a..27586b1 100644 --- a/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx @@ -22,6 +22,7 @@ import { groupByBooking, isUnpaid, isAwaitingApproval, + isOnHold, ticketAmount, shareTicket, isToday, @@ -57,11 +58,12 @@ export default function OverviewTab({ // awaiting approval), ordered by soonest event. const attentionTicket = useMemo(() => { const candidates = activeTickets.filter( - (t) => isUnpaid(t) || isAwaitingApproval(t) + (t) => isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t) ); + const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2); candidates.sort((a, b) => { - const aUnpaid = isUnpaid(a) ? 0 : 1; - const bUnpaid = isUnpaid(b) ? 0 : 1; + const aUnpaid = priority(a); + const bUnpaid = priority(b); if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid; const aStart = a.event?.startDatetime ? parseDate(a.event.startDatetime).getTime() @@ -190,7 +192,19 @@ export default function OverviewTab({
- {isUnpaid(t) ? ( + {isOnHold(t) ? ( + + ) : isUnpaid(t) ? ( + ) : isUnpaid(ticket) ? (
@@ -131,7 +132,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
{/* Actions */}
- {isUnpaid(ticket) ? ( + {isOnHold(ticket) ? ( + + ) : isUnpaid(ticket) ? ( +
+ +
+

+ {locale === 'es' + ? `Tu lugar para ${eventTitle} fue liberado` + : `Your spot for ${eventTitle} was released`} +

+

+ {locale === 'es' + ? `El pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.` + : `Payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`} +

+
+
+
+ +
+
+ ); + } + if (isAwaitingApproval(ticket)) { return (
diff --git a/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx b/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx index 0729d3d..3e2ffdb 100644 --- a/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx +++ b/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx @@ -23,6 +23,11 @@ interface PayActionsProps { /** Stack the two buttons full-width (cards) vs inline (rows). */ layout?: 'stack' | 'inline'; className?: string; + /** + * The booking's spot was released after the hold threshold passed. Hides the + * "Pay now" link (money was already sent) and labels the retry "Rebook". + */ + onHold?: boolean; } /** @@ -41,6 +46,7 @@ export default function PayActions({ size = 'sm', layout = 'stack', className, + onHold = false, }: PayActionsProps) { const { t } = useLanguage(); const [confirming, setConfirming] = useState(false); @@ -76,18 +82,22 @@ export default function PayActions({ return ( <>
- - - + {!onHold && ( + + + + )}
@@ -108,16 +118,24 @@ export default function PayActions({

- {locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?'} + {onHold + ? (locale === 'es' ? '¿Reservar de nuevo?' : 'Rebook your spot?') + : (locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?')}

- {locale === 'es' - ? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?` - : `Did you already send the ${pyg(amount, currency)} for ${destination}?`} + {onHold + ? (locale === 'es' + ? `Intentaremos reservar tu lugar de nuevo para ${destination}.` + : `We'll try to re-reserve your spot for ${destination}.`) + : (locale === 'es' + ? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?` + : `Did you already send the ${pyg(amount, currency)} for ${destination}?`)}

+ +
+ + {/* Events grid */} +
+ {displayedEvents.length === 0 ? ( +
+ +

{t('events.noEvents')}

+
+ ) : ( +
+ {displayedEvents.map((event) => ( + + + {/* Event banner */} + {event.bannerUrl ? ( + {`${event.title} + ) : ( +
+ +
+ )} + +
+
+

+ {locale === 'es' && event.titleEs ? event.titleEs : event.title} +

+ {getStatusBadge(event)} +
+ +
+
+ + {formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)} +
+
+ + {event.location} +
+ {!event.externalBookingEnabled && ( +
+ + + {Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')} + +
+ )} +
+ +
+ + {event.price === 0 + ? t('events.details.free') + : formatPrice(event.price, event.currency)} + + +
+
+
+ + ))} +
+ )} +
+ + + ); +} diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index 1ef74ba..fd1246c 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -123,7 +123,9 @@ function generateEventJsonLd(event: Event) { url: `${siteUrl}/events/${event.slug}`, validFrom: new Date().toISOString(), }, - image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, + image: event.bannerUrl + ? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`) + : `${siteUrl}/images/og-image.jpg`, url: `${siteUrl}/events/${event.slug}`, }; } diff --git a/frontend/src/app/(public)/events/layout.tsx b/frontend/src/app/(public)/events/layout.tsx index 76e27b1..d4fae38 100644 --- a/frontend/src/app/(public)/events/layout.tsx +++ b/frontend/src/app/(public)/events/layout.tsx @@ -1,8 +1,13 @@ import type { Metadata } from 'next'; +// Note: the page title for the listing lives on events/page.tsx, not here. A +// plain-string title in this layout would reset the root title template for the +// child /events/[id] route, stripping the brand suffix from event detail titles. export const metadata: Metadata = { - title: 'Upcoming Language Exchange Events in Asunción', description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.', + alternates: { + canonical: '/events', + }, openGraph: { title: 'Upcoming Language Exchange Events in Asunción – Spanglish', description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.', diff --git a/frontend/src/app/(public)/events/page.tsx b/frontend/src/app/(public)/events/page.tsx index a247a46..1fb9c7c 100644 --- a/frontend/src/app/(public)/events/page.tsx +++ b/frontend/src/app/(public)/events/page.tsx @@ -1,157 +1,29 @@ -'use client'; +import type { Metadata } from 'next'; +import { Event } from '@/lib/api'; +import EventsClient from './EventsClient'; -import { useState, useEffect } from 'react'; -import Link from 'next/link'; -import { useLanguage } from '@/context/LanguageContext'; -import { eventsApi, Event } from '@/lib/api'; -import { formatPrice, formatDateShort, formatTime } from '@/lib/utils'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline'; -import clsx from 'clsx'; +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -export default function EventsPage() { - const { t, locale } = useLanguage(); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming'); +// Listing title lives here (not in the layout) so the root title template still +// applies to the sibling /events/[id] detail route. Picks up "%s – Spanglish". +export const metadata: Metadata = { + title: 'Upcoming Language Exchange Events in Asunción', +}; - useEffect(() => { - eventsApi.getAll() - .then(({ events }) => setEvents(events)) - .catch(console.error) - .finally(() => setLoading(false)); - }, []); +// Fetch the public (published) event list on the server so event titles, dates, +// and locations appear in the initial HTML rather than only after JS runs. +async function getEvents(): Promise { + try { + const res = await fetch(`${apiUrl}/api/events`, { next: { revalidate: 60 } }); + if (!res.ok) return []; + const data = await res.json(); + return data.events || []; + } catch { + return []; + } +} - const now = new Date(); - const upcomingEvents = events.filter(e => - e.status === 'published' && new Date(e.startDatetime) >= now - ); - const pastEvents = events.filter(e => - e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now) - ); - - const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents; - - const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es'); - const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); - - const getStatusBadge = (event: Event) => { - if (event.status === 'cancelled') { - return {t('events.details.cancelled')}; - } - if (event.availableSeats === 0) { - return {t('events.details.soldOut')}; - } - return null; - }; - - return ( -
-
-

{t('events.title')}

- - {/* Filter tabs */} -
- - -
- - {/* Events grid */} -
- {loading ? ( -
-
-
- ) : displayedEvents.length === 0 ? ( -
- -

{t('events.noEvents')}

-
- ) : ( -
- {displayedEvents.map((event) => ( - - - {/* Event banner */} - {event.bannerUrl ? ( - {`${event.title} - ) : ( -
- -
- )} - -
-
-

- {locale === 'es' && event.titleEs ? event.titleEs : event.title} -

- {getStatusBadge(event)} -
- -
-
- - {formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)} -
-
- - {event.location} -
- {!event.externalBookingEnabled && ( -
- - - {Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')} - -
- )} -
- -
- - {event.price === 0 - ? t('events.details.free') - : formatPrice(event.price, event.currency)} - - -
-
-
- - ))} -
- )} -
-
-
- ); +export default async function EventsPage() { + const events = await getEvents(); + return ; } diff --git a/frontend/src/app/(public)/faq/FaqClient.tsx b/frontend/src/app/(public)/faq/FaqClient.tsx new file mode 100644 index 0000000..1d242d1 --- /dev/null +++ b/frontend/src/app/(public)/faq/FaqClient.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; +import { useLanguage } from '@/context/LanguageContext'; +import { FaqItem } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; + +// Receives the FAQ list already fetched on the server so the questions and +// answers are present in the initial HTML for crawlers. The accordion below is +// purely a visual toggle; the answer text stays in the DOM either way. +export default function FaqClient({ initialFaqs }: { initialFaqs: FaqItem[] }) { + const { locale } = useLanguage(); + const [openIndex, setOpenIndex] = useState(null); + + const toggleFAQ = (index: number) => { + setOpenIndex(openIndex === index ? null : index); + }; + + return ( +
+
+
+

+ {locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'} +

+

+ {locale === 'es' + ? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish' + : 'Find answers to the most common questions about Spanglish'} +

+
+ + {initialFaqs.length === 0 ? ( + +

+ {locale === 'es' + ? 'No hay preguntas frecuentes publicadas en este momento.' + : 'No FAQ questions are published at the moment.'} +

+
+ ) : ( +
+ {initialFaqs.map((faq, index) => ( + + +
+
+ {locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer} +
+
+
+ ))} +
+ )} + + +

+ {locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'} +

+

+ {locale === 'es' + ? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!' + : "Don't hesitate to reach out. We're here to help!"} +

+
+ {locale === 'es' ? 'Contáctanos' : 'Contact Us'} + + +
+
+ ); +} diff --git a/frontend/src/app/(public)/faq/layout.tsx b/frontend/src/app/(public)/faq/layout.tsx index bc95501..9fb94df 100644 --- a/frontend/src/app/(public)/faq/layout.tsx +++ b/frontend/src/app/(public)/faq/layout.tsx @@ -20,6 +20,9 @@ async function getFaqForSchema(): Promise<{ question: string; answer: string }[] export const metadata: Metadata = { title: 'Frequently Asked Questions', description: 'Find answers to common questions about Spanglish language exchange events in Asunción. Learn about how events work, who can attend, and more.', + alternates: { + canonical: '/faq', + }, openGraph: { title: 'Frequently Asked Questions – Spanglish', description: 'Find answers to common questions about Spanglish language exchange events in Asunción.', diff --git a/frontend/src/app/(public)/faq/page.tsx b/frontend/src/app/(public)/faq/page.tsx index 33c11b5..3fae217 100644 --- a/frontend/src/app/(public)/faq/page.tsx +++ b/frontend/src/app/(public)/faq/page.tsx @@ -1,116 +1,22 @@ -'use client'; +import { FaqItem } from '@/lib/api'; +import FaqClient from './FaqClient'; -import { useState, useEffect } from 'react'; -import { useLanguage } from '@/context/LanguageContext'; -import { faqApi, FaqItem } from '@/lib/api'; -import Card from '@/components/ui/Card'; -import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import clsx from 'clsx'; +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -export default function FAQPage() { - const { locale } = useLanguage(); - const [faqs, setFaqs] = useState([]); - const [loading, setLoading] = useState(true); - const [openIndex, setOpenIndex] = useState(null); - - useEffect(() => { - let cancelled = false; - faqApi.getList().then((res) => { - if (!cancelled) { - setFaqs(res.faqs); - } - }).finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { cancelled = true; }; - }, []); - - const toggleFAQ = (index: number) => { - setOpenIndex(openIndex === index ? null : index); - }; - - if (loading) { - return ( -
-
-
-
-
- ); +// Fetch the published FAQ list on the server so the questions and answers are +// rendered into the initial HTML (crawlers see the content without running JS). +async function getFaqs(): Promise { + try { + const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } }); + if (!res.ok) return []; + const data = await res.json(); + return data.faqs || []; + } catch { + return []; } +} - return ( -
-
-
-

- {locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'} -

-

- {locale === 'es' - ? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish' - : 'Find answers to the most common questions about Spanglish'} -

-
- - {faqs.length === 0 ? ( - -

- {locale === 'es' - ? 'No hay preguntas frecuentes publicadas en este momento.' - : 'No FAQ questions are published at the moment.'} -

-
- ) : ( -
- {faqs.map((faq, index) => ( - - -
-
- {locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer} -
-
-
- ))} -
- )} - - -

- {locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'} -

-

- {locale === 'es' - ? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!' - : "Don't hesitate to reach out. We're here to help!"} -

- - {locale === 'es' ? 'Contáctanos' : 'Contact Us'} - -
-
-
- ); +export default async function FAQPage() { + const faqs = await getFaqs(); + return ; } diff --git a/frontend/src/app/(public)/legal/[slug]/page.tsx b/frontend/src/app/(public)/legal/[slug]/page.tsx index e0e3d0c..5e2252e 100644 --- a/frontend/src/app/(public)/legal/[slug]/page.tsx +++ b/frontend/src/app/(public)/legal/[slug]/page.tsx @@ -40,7 +40,8 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro } return { - title: `${legalPage.title} – Spanglish`, + // The root layout's title template appends " – Spanglish"; do not repeat it here. + title: legalPage.title, description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`, robots: { index: true, diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index 3ddffab..33a13ad 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -59,7 +59,9 @@ export async function generateMetadata(): Promise { if (!event) { return { - title: 'Spanglish – Language Exchange Events in Asunción', + // Title already carries the brand, so bypass the "%s – Spanglish" + // template to avoid doubling it. + title: { absolute: 'Spanglish – Language Exchange Events in Asunción' }, description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.', }; @@ -76,7 +78,9 @@ export async function generateMetadata(): Promise { const description = `Next event: ${eventDate} – ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`; return { - title: 'Spanglish – Language Exchange Events in Asunción', + // Title already carries the brand, so bypass the "%s – Spanglish" + // template to avoid doubling it. + title: { absolute: 'Spanglish – Language Exchange Events in Asunción' }, description, openGraph: { title: 'Spanglish – Language Exchange Events in Asunción', @@ -142,7 +146,9 @@ function generateNextEventJsonLd(event: NextEvent) { : 'https://schema.org/SoldOut', url: `${siteUrl}/events/${event.slug}`, }, - image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, + image: event.bannerUrl + ? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`) + : `${siteUrl}/images/og-image.jpg`, url: `${siteUrl}/events/${event.slug}`, }; } diff --git a/frontend/src/app/admin/[...notFound]/page.tsx b/frontend/src/app/admin/[...notFound]/page.tsx new file mode 100644 index 0000000..a3e1fd3 --- /dev/null +++ b/frontend/src/app/admin/[...notFound]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function AdminCatchAll() { + notFound(); +} diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index b6f1c8a..77a902c 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; -import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api'; +import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api'; import { parseDate } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -17,6 +17,7 @@ import { PhoneIcon, FunnelIcon, MagnifyingGlassIcon, + ArrowPathIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; @@ -101,6 +102,20 @@ export default function AdminBookingsPage() { } }; + const handleReactivate = async (ticket: TicketWithDetails) => { + if (!ticket.payment?.id) return; + setProcessing(ticket.id); + try { + await paymentsApi.reactivate(ticket.payment.id); + toast.success('Booking reactivated'); + loadData(); + } catch (error: any) { + toast.error(error.message || 'Failed to reactivate booking'); + } finally { + setProcessing(null); + } + }; + const handleCancel = async (ticketId: string) => { if (!confirm('Are you sure you want to cancel this booking?')) return; @@ -133,6 +148,7 @@ export default function AdminBookingsPage() { case 'pending': return 'bg-yellow-100 text-yellow-800'; case 'cancelled': return 'bg-red-100 text-red-800'; case 'checked_in': return 'bg-blue-100 text-blue-800'; + case 'on_hold': return 'bg-slate-100 text-slate-600'; default: return 'bg-gray-100 text-gray-800'; } }; @@ -144,6 +160,7 @@ export default function AdminBookingsPage() { case 'failed': case 'cancelled': return 'bg-red-100 text-red-800'; case 'refunded': return 'bg-purple-100 text-purple-800'; + case 'on_hold': return 'bg-slate-100 text-slate-600'; default: return 'bg-gray-100 text-gray-800'; } }; @@ -191,6 +208,7 @@ export default function AdminBookingsPage() { confirmed: tickets.filter(t => t.status === 'confirmed').length, checkedIn: tickets.filter(t => t.status === 'checked_in').length, cancelled: tickets.filter(t => t.status === 'cancelled').length, + onHold: tickets.filter(t => t.status === 'on_hold').length, pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length, }; @@ -218,6 +236,9 @@ export default function AdminBookingsPage() { if (ticket.status === 'pending' && ticket.payment?.status === 'pending') { return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' }; } + if (ticket.status === 'on_hold') { + return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' }; + } if (ticket.status === 'confirmed') { return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' }; } @@ -239,7 +260,7 @@ export default function AdminBookingsPage() {
{/* Stats Cards */} -
+

{stats.total}

Total

@@ -260,6 +281,10 @@ export default function AdminBookingsPage() {

{stats.cancelled}

Cancelled

+ +

{stats.onHold}

+

On Hold

+

{stats.pendingPayment}

Pending Pay

@@ -305,6 +330,7 @@ export default function AdminBookingsPage() { +
@@ -316,6 +342,7 @@ export default function AdminBookingsPage() { +
@@ -368,6 +395,7 @@ export default function AdminBookingsPage() { Attendee + RUC Event Payment Status @@ -378,7 +406,7 @@ export default function AdminBookingsPage() { {sortedTickets.length === 0 ? ( - + No bookings found. @@ -392,6 +420,7 @@ export default function AdminBookingsPage() {

{ticket.attendeeEmail || 'N/A'}

{ticket.attendeePhone &&

{ticket.attendeePhone}

} + {ticket.attendeeRuc || '-'} {ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'} @@ -431,6 +460,18 @@ export default function AdminBookingsPage() { Check In )} + {ticket.status === 'on_hold' && ( + <> + + + + )} {(ticket.status === 'pending' || ticket.status === 'confirmed') && ( handleCancel(ticket.id)} className="text-red-600"> @@ -519,6 +560,13 @@ export default function AdminBookingsPage() { )} + {ticket.status === 'on_hold' && ( + + handleReactivate(ticket)}> + Reactivate + + + )} {ticket.status === 'checked_in' && ( Attended @@ -557,6 +605,7 @@ export default function AdminBookingsPage() { { value: 'confirmed', label: `Confirmed (${stats.confirmed})` }, { value: 'checked_in', label: `Checked In (${stats.checkedIn})` }, { value: 'cancelled', label: `Cancelled (${stats.cancelled})` }, + { value: 'on_hold', label: `On Hold (${stats.onHold})` }, ].map((opt) => ( )} + {ticket.status === 'on_hold' && ( + handleReactivate(ticket)}> + Reactivate + + )} handleOpenNoteModal(ticket)}> {ticket.adminNote ? 'Edit Note' : 'Add Note'} @@ -307,6 +319,7 @@ export function AttendeesTab({

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

{ticket.attendeeEmail}

{ticket.attendeePhone &&

{ticket.attendeePhone}

} + {ticket.attendeeRuc &&

RUC: {ticket.attendeeRuc}

}
@@ -328,6 +341,11 @@ export function AttendeesTab({ )} + {ticket.status === 'on_hold' && ( + handleReactivate(ticket)}> + Reactivate + + )} handleOpenNoteModal(ticket)}> {ticket.adminNote ? 'Edit Note' : 'Add Note'} diff --git a/frontend/src/app/admin/events/[id]/_types.ts b/frontend/src/app/admin/events/[id]/_types.ts index 0078e41..937b622 100644 --- a/frontend/src/app/admin/events/[id]/_types.ts +++ b/frontend/src/app/admin/events/[id]/_types.ts @@ -2,7 +2,7 @@ import type { ComponentType } from 'react'; export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments'; -export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'; +export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled' | 'on_hold'; export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in'; export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in'; diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 4e5944d..d5271d8 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; -import { ticketsApi, emailsApi, adminApi, Ticket } from '@/lib/api'; +import { ticketsApi, emailsApi, adminApi, paymentsApi, Ticket } from '@/lib/api'; import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -163,6 +163,17 @@ export default function AdminEventDetailPage() { } }; + const handleReactivate = async (ticket: Ticket) => { + if (!ticket.payment?.id) return; + try { + await paymentsApi.reactivate(ticket.payment.id); + toast.success('Booking reactivated'); + loadEventData(); + } catch (error: any) { + toast.error(error.message || 'Failed to reactivate booking'); + } + }; + const handleRemoveCheckin = async (ticketId: string) => { if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return; try { @@ -421,6 +432,7 @@ export default function AdminEventDetailPage() { const pendingCount = getTicketsByStatus('pending').length; const checkedInCount = getTicketsByStatus('checked_in').length; const cancelledCount = getTicketsByStatus('cancelled').length; + const onHoldCount = getTicketsByStatus('on_hold').length; const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length; const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length; const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price; @@ -435,7 +447,7 @@ export default function AdminEventDetailPage() { // ========== Primary action for a ticket ========== const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => { - if (ticket.status === 'pending') { + if (ticket.status === 'pending' || ticket.status === 'on_hold') { return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' }; } if (ticket.status === 'confirmed') { @@ -671,6 +683,7 @@ export default function AdminEventDetailPage() { confirmedCount={confirmedCount} checkedInCount={checkedInCount} cancelledCount={cancelledCount} + onHoldCount={onHoldCount} exporting={exporting} showExportDropdown={showExportDropdown} setShowExportDropdown={setShowExportDropdown} @@ -685,6 +698,7 @@ export default function AdminEventDetailPage() { setShowAddTicketSheet={setShowAddTicketSheet} getPrimaryAction={getPrimaryAction} handleOpenNoteModal={handleOpenNoteModal} + handleReactivate={handleReactivate} /> )} @@ -742,6 +756,7 @@ export default function AdminEventDetailPage() { confirmedCount={confirmedCount} checkedInCount={checkedInCount} cancelledCount={cancelledCount} + onHoldCount={onHoldCount} statusFilter={statusFilter} setStatusFilter={setStatusFilter} mobileFilterOpen={mobileFilterOpen} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index 679a960..d4cdced 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -62,6 +62,10 @@ export default function AdminLayout({ const allowedPathsForRole = new Set( navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href) ); + // All known admin routes regardless of role, used only to tell "not allowed + // for this role" apart from "doesn't exist" - the latter should render the + // 404 page instead of bouncing to the default route. + const allAdminHrefs = new Set(navigationWithRoles.map((item) => item.href)); const defaultAdminRoute = userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin'; @@ -79,11 +83,14 @@ export default function AdminLayout({ router.replace(defaultAdminRoute); return; } - const isPathAllowed = (path: string) => { - if (allowedPathsForRole.has(path)) return true; - return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/')); + const matchesHrefSet = (path: string, hrefs: Set) => { + if (hrefs.has(path)) return true; + return Array.from(hrefs).some((href) => path.startsWith(href + '/')); }; - if (!isPathAllowed(pathname)) { + // Unknown route entirely (e.g. a typo'd URL) - let it fall through to the + // admin 404 page instead of silently redirecting away. + if (!matchesHrefSet(pathname, allAdminHrefs)) return; + if (!matchesHrefSet(pathname, allowedPathsForRole)) { router.replace(defaultAdminRoute); } }, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]); diff --git a/frontend/src/app/admin/not-found.tsx b/frontend/src/app/admin/not-found.tsx new file mode 100644 index 0000000..2430064 --- /dev/null +++ b/frontend/src/app/admin/not-found.tsx @@ -0,0 +1,9 @@ +import { NotFoundMessage } from '@/components/NotFoundMessage'; + +export default function AdminNotFound() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index c12c28d..255d7ac 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -147,6 +147,20 @@ export default function AdminPaymentsPage() { } }; + const handleReactivate = async (payment: PaymentWithDetails) => { + setProcessing(true); + try { + await paymentsApi.reactivate(payment.id); + toast.success(locale === 'es' ? 'Reserva reactivada' : 'Booking reactivated'); + setSelectedPayment(null); + loadData(); + } catch (error: any) { + toast.error(error.message || 'Failed to reactivate booking'); + } finally { + setProcessing(false); + } + }; + const handleRefund = async (id: string) => { if (!confirm('Are you sure you want to process this refund?')) return; @@ -178,7 +192,7 @@ export default function AdminPaymentsPage() { const downloadCSV = () => { if (!exportData) return; - const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'Event Title', 'Event Date']; + const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'RUC', 'Event Title', 'Event Date']; const rows = exportData.payments.map(p => [ p.paymentId, p.amount, @@ -190,6 +204,7 @@ export default function AdminPaymentsPage() { p.createdAt, `${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(), p.attendeeEmail || '', + p.attendeeRuc || '', p.eventTitle, p.eventDate, ]); @@ -225,6 +240,7 @@ export default function AdminPaymentsPage() { refunded: 'bg-blue-100 text-blue-700', failed: 'bg-red-100 text-red-700', cancelled: 'bg-gray-100 text-gray-700', + on_hold: 'bg-slate-100 text-slate-600', }; const labels: Record = { pending: locale === 'es' ? 'Pendiente' : 'Pending', @@ -233,6 +249,7 @@ export default function AdminPaymentsPage() { refunded: locale === 'es' ? 'Reembolsado' : 'Refunded', failed: locale === 'es' ? 'Fallido' : 'Failed', cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled', + on_hold: locale === 'es' ? 'En Espera' : 'On Hold', }; return ( @@ -343,6 +360,8 @@ export default function AdminPaymentsPage() { payments.filter(p => p.status === 'paid') ); const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments); + const onHoldPayments = payments.filter(p => p.status === 'on_hold'); + const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments); if (loading) { return ( @@ -414,6 +433,9 @@ export default function AdminPaymentsPage() { {selectedPayment.ticket.attendeePhone && (

{selectedPayment.ticket.attendeePhone}

)} + {selectedPayment.ticket.attendeeRuc && ( +

RUC: {selectedPayment.ticket.attendeeRuc}

+ )}
)} @@ -475,6 +497,15 @@ export default function AdminPaymentsPage() { + {selectedPayment.status === 'on_hold' && ( +
+ +
+ )} +
- -
- -
+ + {selectedPayment.status !== 'on_hold' && ( +
+ +
+ )} @@ -627,7 +660,7 @@ export default function AdminPaymentsPage() { )} {/* Summary Cards */} -
+
@@ -642,6 +675,20 @@ export default function AdminPaymentsPage() {
+ +
+
+ +
+
+

{locale === 'es' ? 'En Espera' : 'On Hold'}

+

{onHoldBookingsCount}

+ {onHoldPayments.length !== onHoldBookingsCount && ( +

({onHoldPayments.length} tickets)

+ )} +
+
+
@@ -816,6 +863,7 @@ export default function AdminPaymentsPage() { +
@@ -897,6 +945,7 @@ export default function AdminPaymentsPage() { {locale === 'es' ? 'Asistente' : 'Attendee'} + RUC {locale === 'es' ? 'Evento' : 'Event'} {locale === 'es' ? 'Monto' : 'Amount'} {locale === 'es' ? 'Método' : 'Method'} @@ -906,7 +955,7 @@ export default function AdminPaymentsPage() { {filteredPayments.length === 0 ? ( - {locale === 'es' ? 'No se encontraron pagos' : 'No payments found'} + {locale === 'es' ? 'No se encontraron pagos' : 'No payments found'} ) : ( filteredPayments.map((payment) => { const bookingInfo = getBookingInfo(payment); @@ -920,6 +969,7 @@ export default function AdminPaymentsPage() {
) : -} + {payment.ticket?.attendeeRuc || '-'} {payment.event?.title || '-'}

{formatCurrency(bookingInfo.bookingTotal, payment.currency)}

@@ -933,11 +983,16 @@ export default function AdminPaymentsPage() { {getStatusBadge(payment.status)}
- {(payment.status === 'pending' || payment.status === 'pending_approval') && ( + {(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && ( )} + {payment.status === 'on_hold' && ( + + )} {payment.status === 'paid' && ( )} + {payment.status === 'on_hold' && ( + + )} {payment.status === 'paid' && (
diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index 07f789f..c8ef8e5 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -2,22 +2,38 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; -import { usersApi, User } from '@/lib/api'; +import { usersApi, eventsApi, User, Event } from '@/lib/api'; import { parseDate } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents'; -import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import { CheckCircleIcon } from '@heroicons/react/24/outline'; +import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; +type RegisteredRange = '' | '7d' | '30d' | '90d'; + +function registeredAfterFromRange(range: RegisteredRange): string | undefined { + if (!range) return undefined; + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90; + const date = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + return date.toISOString(); +} + export default function AdminUsersPage() { const { t, locale } = useLanguage(); const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [roleFilter, setRoleFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [hasBookingsFilter, setHasBookingsFilter] = useState<'' | 'yes' | 'no'>(''); + const [registeredRange, setRegisteredRange] = useState(''); + const [eventFilter, setEventFilter] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); const [editingUser, setEditingUser] = useState(null); const [editForm, setEditForm] = useState({ name: '', @@ -30,14 +46,45 @@ export default function AdminUsersPage() { const [saving, setSaving] = useState(false); const [mobileFilterOpen, setMobileFilterOpen] = useState(false); + // Debounce the search box like the Emails page does (300ms). + useEffect(() => { + const handle = setTimeout(() => setDebouncedSearch(searchQuery), 300); + return () => clearTimeout(handle); + }, [searchQuery]); + + useEffect(() => { + eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {}); + }, []); + useEffect(() => { loadUsers(); - }, [roleFilter]); + }, [roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]); + + const hasActiveFilters = + roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery; + + const clearFilters = () => { + setRoleFilter(''); + setStatusFilter(''); + setHasBookingsFilter(''); + setRegisteredRange(''); + setEventFilter(''); + setSearchQuery(''); + }; const loadUsers = async () => { try { - const { users } = await usersApi.getAll(roleFilter || undefined); + const { users, total } = await usersApi.getAll({ + role: roleFilter || undefined, + accountStatus: statusFilter || undefined, + hasBookings: hasBookingsFilter || undefined, + registeredAfter: registeredAfterFromRange(registeredRange), + eventId: eventFilter || undefined, + search: debouncedSearch.trim() || undefined, + pageSize: 200, + }); setUsers(users); + setTotal(total); } catch (error) { toast.error('Failed to load users'); } finally { @@ -129,16 +176,29 @@ export default function AdminUsersPage() { return (
-

{t('admin.users.title')}

+

{t('admin.users.title')} ({total})

{/* Desktop Filters */} -
+
+
+ +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow" + /> +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {hasActiveFilters && ( +
+ Showing {users.length} of {total} + +
+ )} {/* Mobile Toolbar */} -
- - {roleFilter && ( - - )} - {users.length} users + {hasActiveFilters && ( + + )} + {total} users +
{/* Desktop: Table */} @@ -176,6 +293,7 @@ export default function AdminUsersPage() { User Contact + RUC Role Joined Actions @@ -183,7 +301,7 @@ export default function AdminUsersPage() { {users.length === 0 ? ( - No users found + No users found ) : ( users.map((user) => ( @@ -199,6 +317,7 @@ export default function AdminUsersPage() {
{user.phone || '-'} + {user.rucNumber || '-'} setRoleFilter(e.target.value)} + className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]"> + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 1c7250c..b793263 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -26,20 +26,6 @@ export const metadata: Metadata = { template: '%s – Spanglish', }, description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.', - keywords: [ - 'language exchange', - 'Spanglish', - 'Spanglish social', - 'English Spanish meetup', - 'language exchange Asunción', - 'practice English Asunción', - 'intercambio de idiomas', - 'intercambio de idiomas Asunción', - 'English Spanish Paraguay', - 'language events Paraguay', - 'Asunción', - 'Paraguay', - ], authors: [{ name: 'Spanglish' }], creator: 'Spanglish', publisher: 'Spanglish', @@ -82,12 +68,10 @@ export const metadata: Metadata = { 'max-snippet': -1, }, }, + // Each route overrides this with its own path so the canonical is + // self-referential. Resolved against metadataBase above. alternates: { - canonical: siteUrl, - languages: { - 'en': siteUrl, - 'es': `${siteUrl}/es`, - }, + canonical: '/', }, category: 'events', manifest: '/manifest.json', diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx index 2436093..a3e28db 100644 --- a/frontend/src/app/not-found.tsx +++ b/frontend/src/app/not-found.tsx @@ -1,5 +1,7 @@ import type { Metadata } from 'next'; -import Link from 'next/link'; +import Header from '@/components/layout/Header'; +import Footer from '@/components/layout/Footer'; +import { NotFoundMessage } from '@/components/NotFoundMessage'; export const metadata: Metadata = { title: 'Page Not Found – Spanglish', @@ -12,30 +14,12 @@ export const metadata: Metadata = { export default function NotFound() { return ( -
-
-

404

-

- Page Not Found -

-

- The page you are looking for might have been removed, had its name changed, or is temporarily unavailable. -

-
- - Go Home - - - View Events - -
-
+
+
+
+ +
+
); } diff --git a/frontend/src/components/NotFoundMessage.tsx b/frontend/src/components/NotFoundMessage.tsx new file mode 100644 index 0000000..9d3dbb3 --- /dev/null +++ b/frontend/src/components/NotFoundMessage.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; + +export function NotFoundMessage() { + return ( +
+

404

+

+ Page Not Found +

+

+ The page you are looking for might have been removed, had its name changed, or is temporarily unavailable. +

+
+ + Go Home + + + View Events + +
+
+ ); +} diff --git a/frontend/src/lib/api/payments.ts b/frontend/src/lib/api/payments.ts index 53bef31..2a86e6a 100644 --- a/frontend/src/lib/api/payments.ts +++ b/frontend/src/lib/api/payments.ts @@ -46,4 +46,9 @@ export const paymentsApi = { refund: (id: string) => fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }), + + reactivate: (id: string) => + fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, { + method: 'POST', + }), }; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 59e2ab9..fd0d6e6 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -37,7 +37,7 @@ export interface Ticket { attendeePhone?: string; attendeeRuc?: string; preferredLanguage?: string; - status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in'; + status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in' | 'on_hold'; checkinAt?: string; checkedInByAdminId?: string; qrCode: string; @@ -111,7 +111,7 @@ export interface Payment { provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; amount: number; currency: string; - status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed'; + status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold'; reference?: string; userMarkedPaidAt?: string; payerName?: string; // Name of payer if different from attendee @@ -131,6 +131,7 @@ export interface PaymentWithDetails extends Payment { attendeeLastName?: string; attendeeEmail?: string; attendeePhone?: string; + attendeeRuc?: string; status: string; } | null; event: { @@ -325,6 +326,7 @@ export interface ExportedPayment { attendeeFirstName: string; attendeeLastName?: string; attendeeEmail?: string; + attendeeRuc?: string; eventId: string; eventTitle: string; eventDate: string; diff --git a/frontend/src/lib/api/users.ts b/frontend/src/lib/api/users.ts index 25f60ea..f4d35c7 100644 --- a/frontend/src/lib/api/users.ts +++ b/frontend/src/lib/api/users.ts @@ -1,10 +1,34 @@ import { fetchApi } from './client'; import type { User } from './types'; +export interface UsersListParams { + role?: string; + search?: string; + accountStatus?: string; + hasBookings?: 'yes' | 'no'; + registeredAfter?: string; + registeredBefore?: string; + eventId?: string; + page?: number; + pageSize?: number; +} + export const usersApi = { - getAll: (role?: string) => { - const query = role ? `?role=${role}` : ''; - return fetchApi<{ users: User[] }>(`/api/users${query}`); + getAll: (params?: UsersListParams | string) => { + // Back-compat: allow the old `getAll(role)` call shape. + const p: UsersListParams = typeof params === 'string' ? { role: params } : params || {}; + const query = new URLSearchParams(); + if (p.role) query.set('role', p.role); + if (p.search) query.set('search', p.search); + if (p.accountStatus) query.set('accountStatus', p.accountStatus); + if (p.hasBookings) query.set('hasBookings', p.hasBookings); + if (p.registeredAfter) query.set('registeredAfter', p.registeredAfter); + if (p.registeredBefore) query.set('registeredBefore', p.registeredBefore); + if (p.eventId) query.set('eventId', p.eventId); + if (p.page) query.set('page', String(p.page)); + if (p.pageSize) query.set('pageSize', String(p.pageSize)); + const qs = query.toString(); + return fetchApi<{ users: User[]; total: number; page: number; pageSize: number }>(`/api/users${qs ? `?${qs}` : ''}`); }, getById: (id: string) => fetchApi<{ user: User }>(`/api/users/${id}`), From 09bfb17f03cf6d6d078d7a2b0f68832638a153a4 Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 1 Jul 2026 06:06:22 +0000 Subject: [PATCH 2/7] Add custom error and global-error pages matching site design Adds app/error.tsx (client error boundary, reuses Header/Footer/Button) and app/global-error.tsx (root layout crash fallback with self-contained html/body and manually replicated styling), both bilingual (ES/EN) and matching the brand's colors, fonts, and tone. Co-Authored-By: Claude Sonnet 5 --- frontend/src/app/error.tsx | 54 +++++++++++++++++++++ frontend/src/app/global-error.tsx | 79 +++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 frontend/src/app/error.tsx create mode 100644 frontend/src/app/global-error.tsx diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx new file mode 100644 index 0000000..8dfcd05 --- /dev/null +++ b/frontend/src/app/error.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect } from 'react'; +import Link from 'next/link'; +import Header from '@/components/layout/Header'; +import Footer from '@/components/layout/Footer'; +import Button from '@/components/ui/Button'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + console.error(error); + } + }, [error]); + + return ( +
+
+
+
+
😅
+

+ ¡Ups! Algo salió mal +

+

+ Oops, something went wrong +

+

+ No fue tu culpa, fue un error de nuestro lado. Prueba de nuevo o vuelve al inicio mientras lo revisamos. +
+ It was not your fault, something broke on our end. Try again or head back home while we take a look. +

+
+ + + + +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/global-error.tsx b/frontend/src/app/global-error.tsx new file mode 100644 index 0000000..f419e73 --- /dev/null +++ b/frontend/src/app/global-error.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useEffect } from 'react'; +import { Poppins } from 'next/font/google'; +import './globals.css'; + +const poppins = Poppins({ + subsets: ['latin'], + display: 'swap', + variable: '--font-poppins', + weight: ['400', '500', '600', '700'], +}); + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + console.error(error); + } + }, [error]); + + return ( + + +
+
+
+ + Spanglish + +
+
+ +
+
+
😵
+

+ ¡Ups! Algo salió mal +

+

+ Oops, something went wrong +

+

+ Toda la página tuvo un problema al cargar. Prueba de nuevo o vuelve al inicio. +
+ The whole page had trouble loading. Try again or head back home. +

+
+ + + Ir al inicio · Go home + +
+
+
+ +
+

+ © {new Date().getFullYear()} Spanglish +

+
+
+ + + ); +} From 78ffd0ae529f52ed8c0a88b1f9c9ddb0f338460b Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 1 Jul 2026 16:48:28 +0000 Subject: [PATCH 3/7] Allow Lightning invoice reuse and re-payment from the dashboard. Store LNbits invoice data on payments, add an invoice endpoint that reuses valid invoices or regenerates expired ones, and wire the booking payment page to fetch and display invoices via a shared watcher hook. Co-authored-by: Cursor --- backend/src/db/migrate.ts | 18 +++ backend/src/db/schema.ts | 6 + backend/src/lib/lnbits.ts | 5 + backend/src/routes/lnbits.ts | 153 +++++++++++++++++- backend/src/routes/tickets.ts | 23 ++- .../src/app/(public)/book/[eventId]/page.tsx | 12 +- .../app/(public)/booking/[ticketId]/page.tsx | 112 +++++++++++-- .../_hooks => hooks}/useLightningWatcher.ts | 18 +-- frontend/src/lib/api/tickets.ts | 9 ++ frontend/src/lib/api/types.ts | 9 ++ 10 files changed, 331 insertions(+), 34 deletions(-) rename frontend/src/{app/(public)/book/[eventId]/_hooks => hooks}/useLightningWatcher.ts (84%) diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 03c7ca6..b998a3b 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -238,6 +238,15 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`); } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); + } catch (e) { /* column may already exist */ } // Invoices table await (db as any).run(sql` @@ -719,6 +728,15 @@ async function migrate() { try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`); } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); + } catch (e) { /* column may already exist */ } // Invoices table await (db as any).execute(sql` diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index a79e2df..ee75c02 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -121,6 +121,9 @@ export const sqlitePayments = sqliteTable('payments', { currency: text('currency').notNull().default('PYG'), status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'), reference: text('reference'), + lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call + lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires + lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion) userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid" payerName: text('payer_name'), // Name of payer if different from attendee paidAt: text('paid_at'), @@ -476,6 +479,9 @@ export const pgPayments = pgTable('payments', { currency: varchar('currency', { length: 10 }).notNull().default('PYG'), status: varchar('status', { length: 20 }).notNull().default('pending'), reference: varchar('reference', { length: 255 }), + lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call + lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires + lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion) userMarkedPaidAt: timestamp('user_marked_paid_at'), payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee paidAt: timestamp('paid_at'), diff --git a/backend/src/lib/lnbits.ts b/backend/src/lib/lnbits.ts index 66cc131..bd2bc87 100644 --- a/backend/src/lib/lnbits.ts +++ b/backend/src/lib/lnbits.ts @@ -36,6 +36,11 @@ export interface CreateInvoiceParams { extra?: Record; // Additional metadata } +// How long a booking's Lightning invoice is valid for, in seconds. Shared +// between initial booking creation and invoice regeneration so both produce +// invoices with the same lifetime. +export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes + /** * Check if LNbits is configured */ diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index 1493071..af34907 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -1,9 +1,15 @@ import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; -import { db, dbGet, dbAll, tickets, payments } from '../db/index.js'; -import { eq, and } from 'drizzle-orm'; -import { getNow } from '../lib/utils.js'; -import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; +import { db, dbGet, dbAll, tickets, payments, events } from '../db/index.js'; +import { eq, and, inArray } from 'drizzle-orm'; +import { getNow, toDbDate } from '../lib/utils.js'; +import { + verifyWebhookPayment, + getPaymentStatus, + createInvoice, + isLNbitsConfigured, + LNBITS_INVOICE_EXPIRY_SECONDS, +} from '../lib/lnbits.js'; import emailService from '../lib/email.js'; import { getPubSub } from '../lib/stores/pubsub.js'; import { getLock } from '../lib/stores/lock.js'; @@ -391,6 +397,145 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { }); }); +/** + * Get a Lightning invoice for a ticket to pay or re-pay. + * + * Reuses the stored invoice if it still has more than 5 minutes of validity + * left; otherwise generates a fresh one from LNbits. This is what lets a user + * come back to an unpaid Lightning booking later (e.g. from "Pay now" on the + * dashboard) instead of hitting a dead end. + */ +lnbitsRouter.post('/invoice/:ticketId', async (c) => { + const ticketId = c.req.param('ticketId'); + + const ticket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) + ); + if (!ticket) { + return c.json({ error: 'Ticket not found' }, 404); + } + + const payment = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId)) + ); + if (!payment) { + return c.json({ error: 'Payment not found' }, 404); + } + + if (payment.provider !== 'lightning') { + return c.json({ error: 'This booking is not a Lightning payment' }, 400); + } + + if (ticket.status === 'confirmed' || payment.status === 'paid') { + return c.json({ alreadyPaid: true }); + } + + if (ticket.status !== 'pending' || payment.status !== 'pending') { + return c.json({ + error: 'This booking is no longer active. Please make a new booking.', + }, 400); + } + + // Gather every ticket/payment in the booking group - a multi-ticket booking + // shares a single Lightning invoice for the combined total. + let groupTickets: any[] = [ticket]; + if (ticket.bookingId) { + groupTickets = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + } + const groupPayments = await dbAll( + (db as any).select().from(payments).where(inArray((payments as any).ticketId, groupTickets.map((t: any) => t.id))) + ); + const invoiceHolder = groupPayments.find((p: any) => p.reference) || payment; + const totalAmount = groupPayments.reduce((sum: number, p: any) => sum + Number(p.amount), 0); + const currency = invoiceHolder.currency; + + const now = Date.now(); + const FIVE_MIN_MS = 5 * 60 * 1000; + const expiresAtMs = invoiceHolder.lnbitsExpiresAt ? new Date(invoiceHolder.lnbitsExpiresAt).getTime() : 0; + + if (invoiceHolder.reference && invoiceHolder.lnbitsInvoice && expiresAtMs - now > FIVE_MIN_MS) { + return c.json({ + invoice: { + paymentHash: invoiceHolder.reference, + paymentRequest: invoiceHolder.lnbitsInvoice, + amount: invoiceHolder.lnbitsAmountSats || 0, + fiatAmount: totalAmount, + fiatCurrency: currency, + expiresAt: invoiceHolder.lnbitsExpiresAt, + }, + reused: true, + }); + } + + // No usable stored invoice (never created, expired, or expiring soon) - get a fresh one. + if (!isLNbitsConfigured()) { + return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400); + } + + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)) + ); + + const apiUrl = process.env.API_URL || 'http://localhost:3001'; + const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || ''; + const webhookUrl = webhookSecret + ? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}` + : `${apiUrl}/api/lnbits/webhook`; + const attendeeName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); + + try { + const lnbitsInvoice = await createInvoice({ + amount: totalAmount, + unit: currency, + memo: `Spanglish: ${event?.title || 'Event'} - ${attendeeName}${groupTickets.length > 1 ? ` (${groupTickets.length} tickets)` : ''}`, + webhookUrl, + expiry: LNBITS_INVOICE_EXPIRY_SECONDS, + extra: { + ticketId: invoiceHolder.ticketId, + bookingId: ticket.bookingId || null, + ticketIds: groupTickets.map((t: any) => t.id), + eventId: ticket.eventId, + eventTitle: event?.title, + attendeeName, + attendeeEmail: ticket.attendeeEmail, + ticketCount: groupTickets.length, + }, + }); + + const lnbitsExpiresAt = toDbDate(new Date(now + LNBITS_INVOICE_EXPIRY_SECONDS * 1000)); + + await (db as any) + .update(payments) + .set({ + reference: lnbitsInvoice.paymentHash, + lnbitsInvoice: lnbitsInvoice.paymentRequest, + lnbitsExpiresAt, + lnbitsAmountSats: lnbitsInvoice.amount, + updatedAt: getNow(), + }) + .where(eq((payments as any).id, invoiceHolder.id)); + + return c.json({ + invoice: { + paymentHash: lnbitsInvoice.paymentHash, + paymentRequest: lnbitsInvoice.paymentRequest, + amount: lnbitsInvoice.amount, + fiatAmount: lnbitsInvoice.fiatAmount ?? totalAmount, + fiatCurrency: lnbitsInvoice.fiatCurrency ?? currency, + expiresAt: lnbitsExpiresAt, + }, + reused: false, + }); + } catch (error: any) { + console.error('Failed to create Lightning invoice:', error); + return c.json({ + error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`, + }, 500); + } +}); + /** * Get payment status for a ticket (fallback polling endpoint) */ diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 8e923c7..08fcde1 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -4,8 +4,8 @@ import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js'; import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; -import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; +import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; +import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; import emailService from '../lib/email.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; @@ -418,7 +418,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc. memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`, webhookUrl, - expiry: 900, // 15 minutes expiry for faster UX + expiry: LNBITS_INVOICE_EXPIRY_SECONDS, // 15 minutes expiry for faster UX extra: { ticketId: primaryTicket.id, bookingId: ticketCount > 1 ? bookingId : null, @@ -430,13 +430,22 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { ticketCount, }, }); - - // Update primary payment with LNbits payment hash reference + + const lnbitsExpiresAt = toDbDate(new Date(Date.now() + LNBITS_INVOICE_EXPIRY_SECONDS * 1000)); + + // Update primary payment with the LNbits invoice - the BOLT11 string and + // expiry are persisted so the "Pay now" page can redisplay this same + // invoice later instead of erroring out on an unpaid Lightning booking. await (db as any) .update(payments) - .set({ reference: lnbitsInvoice.paymentHash }) + .set({ + reference: lnbitsInvoice.paymentHash, + lnbitsInvoice: lnbitsInvoice.paymentRequest, + lnbitsExpiresAt, + lnbitsAmountSats: lnbitsInvoice.amount, + }) .where(eq((payments as any).id, primaryPayment.id)); - + (primaryPayment as any).reference = lnbitsInvoice.paymentHash; } catch (error: any) { console.error('Failed to create Lightning invoice:', error); diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 8296779..5533f3b 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -16,7 +16,7 @@ import type { PaymentMethod, } from './_types'; import { buildPaymentMethods, formatRuc, rucPattern } from './_logic/booking'; -import { useLightningWatcher } from './_hooks/useLightningWatcher'; +import { useLightningWatcher } from '@/hooks/useLightningWatcher'; import { PayingStep } from './_steps/PayingStep'; import { ManualPaymentStep } from './_steps/ManualPaymentStep'; import { PendingApprovalStep } from './_steps/PendingApprovalStep'; @@ -35,7 +35,6 @@ export default function BookingPage() { const [step, setStep] = useState('form'); const [submitting, setSubmitting] = useState(false); const [bookingResult, setBookingResult] = useState(null); - const [, setPaymentPending] = useState(false); const [markingPaid, setMarkingPaid] = useState(false); // State for payer name (when paid under different name) @@ -245,7 +244,13 @@ export default function BookingPage() { }; // Watch for Lightning payment confirmation while on the paying step. - useLightningWatcher(step, bookingResult?.ticketId, locale, setPaymentPending, setStep); + useLightningWatcher( + step === 'paying', + bookingResult?.ticketId, + locale, + () => setStep('success'), + () => {} + ); // Handle "I Have Paid" button click const handleMarkPaymentSent = async () => { @@ -330,7 +335,6 @@ export default function BookingPage() { }; setBookingResult(result); setStep('paying'); - setPaymentPending(true); // Payment confirmation is handled by the paying-step watcher effect. } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { // Manual payment methods - show payment details diff --git a/frontend/src/app/(public)/booking/[ticketId]/page.tsx b/frontend/src/app/(public)/booking/[ticketId]/page.tsx index 5a371ed..f883eb5 100644 --- a/frontend/src/app/(public)/booking/[ticketId]/page.tsx +++ b/frontend/src/app/(public)/booking/[ticketId]/page.tsx @@ -1,15 +1,17 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; -import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api'; +import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig, LightningInvoice } from '@/lib/api'; import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils'; +import { useLightningWatcher } from '@/hooks/useLightningWatcher'; +import { PayingStep } from '@/app/(public)/book/[eventId]/_steps/PayingStep'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; -import { - CheckCircleIcon, +import { + CheckCircleIcon, ClockIcon, XCircleIcon, TicketIcon, @@ -22,7 +24,7 @@ import { } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; -type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error'; +type PaymentStep = 'loading' | 'manual_payment' | 'lightning_payment' | 'pending_approval' | 'confirmed' | 'error'; export default function BookingPaymentPage() { const params = useParams(); @@ -33,10 +35,36 @@ export default function BookingPaymentPage() { const [step, setStep] = useState('loading'); const [markingPaid, setMarkingPaid] = useState(false); const [error, setError] = useState(null); + const [lightningInvoice, setLightningInvoice] = useState(null); + const [invoiceExpired, setInvoiceExpired] = useState(false); + const [fetchingInvoice, setFetchingInvoice] = useState(false); const ticketId = params.ticketId as string; const requestedStep = searchParams.get('step'); + // Get (or refresh) the Lightning invoice for this ticket - reuses the + // stored invoice if still valid, otherwise the backend generates a new one. + const fetchLightningInvoice = useCallback(async () => { + setFetchingInvoice(true); + try { + const result = await ticketsApi.getLightningInvoice(ticketId); + if (result.alreadyPaid) { + setStep('confirmed'); + return; + } + if (result.invoice) { + setLightningInvoice(result.invoice); + setInvoiceExpired(false); + setStep('lightning_payment'); + } + } catch (err: any) { + setError(err.message || 'Failed to load Lightning invoice'); + setStep('error'); + } finally { + setFetchingInvoice(false); + } + }, [ticketId]); + // Fetch ticket and payment config useEffect(() => { if (!ticketId) return; @@ -45,7 +73,7 @@ export default function BookingPaymentPage() { try { // Get ticket with event and payment info const { ticket: ticketData } = await ticketsApi.getById(ticketId); - + if (!ticketData) { setError('Booking not found'); setStep('error'); @@ -54,10 +82,24 @@ export default function BookingPaymentPage() { setTicket(ticketData); - // Only proceed for manual payment methods const paymentMethod = ticketData.payment?.provider; + + if (paymentMethod === 'lightning') { + if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') { + setStep('confirmed'); + } else if (ticketData.status === 'cancelled') { + setError(locale === 'es' + ? 'Esta reserva ya no está activa. Por favor realiza una nueva reserva.' + : 'This booking is no longer active. Please make a new booking.'); + setStep('error'); + } else { + await fetchLightningInvoice(); + } + return; + } + if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) { - // Not a manual payment method, redirect to success page or show appropriate state + // Not a manual or Lightning payment method, show appropriate state if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') { setStep('confirmed'); } else { @@ -75,7 +117,7 @@ export default function BookingPaymentPage() { // Determine which step to show based on payment status const paymentStatus = ticketData.payment?.status; - + if (paymentStatus === 'paid' || ticketData.status === 'confirmed') { setStep('confirmed'); } else if (paymentStatus === 'pending_approval') { @@ -96,6 +138,15 @@ export default function BookingPaymentPage() { loadBookingData(); }, [ticketId]); + // Watch for Lightning payment confirmation while the invoice is on screen. + useLightningWatcher( + step === 'lightning_payment' && !invoiceExpired, + ticketId, + locale, + () => setStep('confirmed'), + () => setInvoiceExpired(true) + ); + // Handle "I Have Paid" button click const handleMarkPaymentSent = async () => { if (!ticket) return; @@ -301,6 +352,49 @@ export default function BookingPaymentPage() { ); } + // Lightning payment step - show the (reused or freshly generated) invoice + if (step === 'lightning_payment' && ticket) { + if (invoiceExpired) { + return ( +
+
+ +
+ +
+

+ {locale === 'es' ? 'La factura expiró' : 'Invoice expired'} +

+

+ {locale === 'es' + ? 'Genera una nueva factura Lightning para continuar con el pago.' + : 'Generate a new Lightning invoice to continue with payment.'} +

+ +
+
+
+ ); + } + + if (!lightningInvoice) { + return ( +
+
+
+

+ {locale === 'es' ? 'Preparando tu factura...' : 'Preparing your invoice...'} +

+
+
+ ); + } + + return ; + } + // Manual payment step - show payment details and "I have paid" button if (step === 'manual_payment' && ticket && paymentConfig) { const isBankTransfer = ticket.payment?.provider === 'bank_transfer'; diff --git a/frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts b/frontend/src/hooks/useLightningWatcher.ts similarity index 84% rename from frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts rename to frontend/src/hooks/useLightningWatcher.ts index c36f5a0..eebc6f6 100644 --- a/frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts +++ b/frontend/src/hooks/useLightningWatcher.ts @@ -1,22 +1,21 @@ import { useEffect } from 'react'; import toast from 'react-hot-toast'; import { ticketsApi } from '@/lib/api'; -import type { BookingStep } from '../_types'; /** - * Watch for Lightning payment confirmation while on the paying step. + * Watch for Lightning payment confirmation while an invoice is on screen. * SSE gives instant updates; a 3s poll runs in parallel as a safety net so a * buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI. */ export function useLightningWatcher( - step: BookingStep, + active: boolean, ticketId: string | undefined, locale: string, - setPaymentPending: (value: boolean) => void, - setStep: (value: BookingStep) => void + onPaid: () => void, + onExpired: () => void ) { useEffect(() => { - if (step !== 'paying' || !ticketId) return; + if (!active || !ticketId) return; let settled = false; let pollTimer: ReturnType | null = null; @@ -25,15 +24,14 @@ export function useLightningWatcher( if (settled) return; settled = true; toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!'); - setPaymentPending(false); - setStep('success'); + onPaid(); }; const expire = () => { if (settled) return; settled = true; toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired'); - setPaymentPending(false); + onExpired(); }; // Always same-origin so the streaming proxy route handler is used (it @@ -79,5 +77,5 @@ export function useLightningWatcher( eventSource.close(); if (pollTimer) clearTimeout(pollTimer); }; - }, [step, ticketId, locale]); + }, [active, ticketId, locale]); } diff --git a/frontend/src/lib/api/tickets.ts b/frontend/src/lib/api/tickets.ts index dd35357..f7e5f03 100644 --- a/frontend/src/lib/api/tickets.ts +++ b/frontend/src/lib/api/tickets.ts @@ -6,6 +6,7 @@ import type { TicketValidationResult, TicketSearchResult, LiveSearchResult, + LightningInvoice, } from './types'; export const ticketsApi = { @@ -137,6 +138,14 @@ export const ticketsApi = { `/api/lnbits/status/${ticketId}` ), + // Get a Lightning invoice to pay/re-pay a ticket - reuses the stored invoice + // if it's still valid, otherwise generates a fresh one. + getLightningInvoice: (ticketId: string) => + fetchApi<{ invoice?: LightningInvoice; reused?: boolean; alreadyPaid?: boolean }>( + `/api/lnbits/invoice/${ticketId}`, + { method: 'POST' } + ), + // Get PDF download URL (returns the URL, not the PDF itself) getPdfUrl: (id: string) => `${API_BASE}/api/tickets/${id}/pdf`, }; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index fd0d6e6..8d5c43a 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -25,6 +25,15 @@ export interface Event { updatedAt: string; } +export interface LightningInvoice { + paymentHash: string; + paymentRequest: string; // BOLT11 invoice + amount: number; // Amount in satoshis + fiatAmount?: number; // Original fiat amount + fiatCurrency?: string; // Original fiat currency + expiresAt?: string; +} + export interface Ticket { id: string; bookingId?: string; // Groups multiple tickets from same booking From 8d5fbf18b4dd603b0f18741628f7b682b243adc0 Mon Sep 17 00:00:00 2001 From: Michilis Date: Fri, 3 Jul 2026 01:51:18 +0000 Subject: [PATCH 4/7] Fix event time save off-by-one using bundled tz data. Use moment-timezone in parseEventDatetime so naive America/Asuncion wall-clock times convert correctly regardless of the host Node tz database. Also normalize user registration date filters with toDbDate for Postgres. Co-authored-by: Cursor --- backend/package.json | 1 + backend/src/lib/utils.ts | 16 +++++----------- backend/src/routes/users.ts | 6 +++--- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/backend/package.json b/backend/package.json index f2a5bed..a045340 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "hono": "^4.4.7", "ioredis": "^5.11.1", "jose": "^5.4.0", + "moment-timezone": "^0.6.2", "nanoid": "^5.0.7", "nodemailer": "^7.0.13", "pdfkit": "^0.17.2", diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts index ac4e7da..103ded7 100644 --- a/backend/src/lib/utils.ts +++ b/backend/src/lib/utils.ts @@ -1,5 +1,6 @@ import { nanoid } from 'nanoid'; import { randomUUID } from 'crypto'; +import moment from 'moment-timezone'; /** * Get database type (reads env var each time to handle module loading order) @@ -59,17 +60,10 @@ export function parseEventDatetime( return new Date(datetime); } - // Treat the digits as UTC so we have a stable reference instant. - const fakeUTC = new Date(datetime + 'Z'); - - // Ask Intl what that UTC instant looks like in both UTC and the target tz. - const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' }); - const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone }); - - // The gap between the two tells us the tz offset at this point in time. - const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime(); - - return new Date(fakeUTC.getTime() + offsetMs); + // Interpret the wall-clock digits as local time in `timezone` using + // moment-timezone's bundled IANA data. This keeps the conversion correct + // regardless of the host Node runtime's (possibly stale) tz database. + return moment.tz(datetime, timezone).toDate(); } /** diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 03dcada..5c21728 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -4,7 +4,7 @@ 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, and, gte, lte } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; -import { getNow } from '../lib/utils.js'; +import { getNow, toDbDate } from '../lib/utils.js'; interface UserContext { id: string; @@ -39,8 +39,8 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => { 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 (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter))); + if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore))); if (search) { const like = `%${search.toLowerCase()}%`; conditions.push(sql`( From cb422332c8d5f7d0bf1151ddef9ecf28d597b680 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 12 Jul 2026 22:04:22 +0000 Subject: [PATCH 5/7] Fix booking defaults, dashboard alerts, admin edit flow, and ended-event payments. Require an explicit payment method on booking, hide stale release banners for past or inactive events, open event editing in place on the detail page, and auto-reject unconfirmed payments after events end without sending email. Co-authored-by: Cursor --- backend/.env.example | 6 + backend/src/index.ts | 4 + backend/src/lib/eventEndSweep.ts | 124 ++++++ .../book/[eventId]/_steps/BookingFormStep.tsx | 7 +- .../src/app/(public)/book/[eventId]/_types.ts | 3 +- .../src/app/(public)/book/[eventId]/page.tsx | 28 +- .../dashboard/components/OverviewTab.tsx | 5 +- .../dashboard/components/_shared/helpers.ts | 16 + frontend/src/app/admin/events/[id]/page.tsx | 38 +- .../events/_components/EventFormModal.tsx | 392 ++++++++++++++++++ frontend/src/app/admin/events/page.tsx | 376 +---------------- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/es.json | 1 + 13 files changed, 619 insertions(+), 382 deletions(-) create mode 100644 backend/src/lib/eventEndSweep.ts create mode 100644 frontend/src/app/admin/events/_components/EventFormModal.tsx diff --git a/backend/.env.example b/backend/.env.example index 8e0af46..c1339c5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -121,3 +121,9 @@ HOLD_THRESHOLD_HOURS=72 # How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min) HOLD_SWEEP_INTERVAL_MS=900000 +# Ended-Event Payment Sweep +# Once an event is over, any unconfirmed payment (pending / pending_approval / +# on_hold) is auto-rejected and its ticket cancelled. No email is sent. +# How often this sweep runs, in milliseconds (default: 900000 = 15 min) +EVENT_END_SWEEP_INTERVAL_MS=900000 + diff --git a/backend/src/index.ts b/backend/src/index.ts index 69115fe..d7773c9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,6 +27,7 @@ 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 { startEventEndSweep } from './lib/eventEndSweep.js'; import { getLock } from './lib/stores/lock.js'; import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js'; @@ -1936,6 +1937,9 @@ startBookingCleanup(); // Periodically put stale pending-approval payments on hold, releasing their seats. startHoldSweep(); +// Periodically auto-reject unconfirmed payments once their event is over (no email). +startEventEndSweep(); + // 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. diff --git a/backend/src/lib/eventEndSweep.ts b/backend/src/lib/eventEndSweep.ts new file mode 100644 index 0000000..1b096e2 --- /dev/null +++ b/backend/src/lib/eventEndSweep.ts @@ -0,0 +1,124 @@ +// Auto-reject unconfirmed payments once their event is over. +// +// After an event ends, any booking whose payment was never confirmed +// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored. +// This job silently fails those payments and cancels their tickets so they stop +// lingering as "pending" forever. It deliberately sends NO email — unlike the +// admin reject route, this is a housekeeping sweep and users are not notified. +// +// An event is considered over when COALESCE(end_datetime, start_datetime) is in +// the past. Updates are guarded by the current status so re-running is a no-op. + +import { and, eq, inArray } from 'drizzle-orm'; +import { db, dbAll, tickets, payments, events } from '../db/index.js'; +import { getNow } from './utils.js'; +import { getLock } from './stores/lock.js'; + +// Payment statuses that represent an unconfirmed booking. +const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold']; +// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled). +const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold']; + +/** + * Fail unconfirmed payments (and cancel their tickets) for events that have + * already ended. Returns the number of payments rejected. + */ +export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise { + // Pull candidate rows first, then decide "ended" in JS so the comparison works + // identically for SQLite (ISO text) and Postgres (timestamp) datetime columns. + const rows = await dbAll<{ + paymentId: string; + ticketId: string; + endDatetime: string | Date | null; + startDatetime: string | Date | null; + }>( + (db as any) + .select({ + paymentId: (payments as any).id, + ticketId: (tickets as any).id, + endDatetime: (events as any).endDatetime, + startDatetime: (events as any).startDatetime, + }) + .from(payments) + .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) + .innerJoin(events, eq((tickets as any).eventId, (events as any).id)) + .where(and( + inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES), + inArray((tickets as any).status, ACTIVE_TICKET_STATUSES), + )) + ); + + const nowMs = Date.now(); + const ended = rows.filter((r) => { + const ref = r.endDatetime || r.startDatetime; + if (!ref) return false; + return new Date(ref as any).getTime() < nowMs; + }); + + if (ended.length === 0) return 0; + + const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId))); + const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id))); + const now = getNow(); + + // Fail the payments. The status guard keeps this idempotent and avoids + // clobbering anything that changed since we read the candidates. + await (db as any) + .update(payments) + .set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now }) + .where(and( + inArray((payments as any).id, paymentIds), + inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES), + )); + + // Cancel the associated tickets, freeing any seats they still hold. + if (ticketIds.length > 0) { + await (db as any) + .update(tickets) + .set({ status: 'cancelled' }) + .where(and( + inArray((tickets as any).id, ticketIds), + inArray((tickets as any).status, ACTIVE_TICKET_STATUSES), + )); + } + + console.log( + `[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` + + `cancelled ${ticketIds.length} ticket(s).` + ); + return paymentIds.length; +} + +let sweepTimer: ReturnType | null = null; + +/** + * Start a periodic sweep that auto-rejects unconfirmed payments for ended + * events. Each run is guarded by a distributed lock so that, across multiple + * replicas, only one instance does the work per interval. + */ +export function startEventEndSweep(): void { + const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min + + const run = () => { + getLock() + .withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () => + rejectUnconfirmedPaymentsForEndedEvents() + ) + .catch((err) => + console.error('[EventEndSweep] Run failed:', err?.message || err) + ); + }; + + // Run shortly after startup, then on the interval. + setTimeout(run, 60_000).unref?.(); + sweepTimer = setInterval(run, intervalMs); + sweepTimer.unref?.(); + console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`); +} + +export function stopEventEndSweep(): void { + if (sweepTimer) { + clearInterval(sweepTimer); + sweepTimer = null; + } +} diff --git a/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx index 46cb494..b495d7b 100644 --- a/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx +++ b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx @@ -335,7 +335,7 @@ export function BookingFormStep({
+ {errors.paymentMethod && ( +

{errors.paymentMethod}

+ )} {/* Terms & Privacy agreement */} @@ -430,7 +433,7 @@ export function BookingFormStep({ size="lg" className="w-full" isLoading={submitting} - disabled={paymentMethods.length === 0 || !agreedToTerms} + disabled={paymentMethods.length === 0 || !formData.paymentMethod || !agreedToTerms} > {formData.paymentMethod === 'cash' ? t('booking.form.reserveSpot') diff --git a/frontend/src/app/(public)/book/[eventId]/_types.ts b/frontend/src/app/(public)/book/[eventId]/_types.ts index 9a4ec81..e5f67c8 100644 --- a/frontend/src/app/(public)/book/[eventId]/_types.ts +++ b/frontend/src/app/(public)/book/[eventId]/_types.ts @@ -13,7 +13,8 @@ export interface BookingFormData { email: string; phone: string; preferredLanguage: 'en' | 'es'; - paymentMethod: PaymentMethod; + // Empty until the user explicitly picks a method (no default selection). + paymentMethod: PaymentMethod | ''; ruc: string; } diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 5533f3b..04d7cd7 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -57,7 +57,7 @@ export default function BookingPage() { email: '', phone: '', preferredLanguage: locale as 'en' | 'es', - paymentMethod: 'cash', + paymentMethod: '', ruc: '', }); @@ -130,18 +130,7 @@ export default function BookingPage() { return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' }); }); setPaymentConfig(paymentRes.paymentOptions); - - // Set default payment method based on what's enabled - const config = paymentRes.paymentOptions; - if (config.lightningEnabled) { - setFormData(prev => ({ ...prev, paymentMethod: 'lightning' })); - } else if (config.cashEnabled) { - setFormData(prev => ({ ...prev, paymentMethod: 'cash' })); - } else if (config.bankTransferEnabled) { - setFormData(prev => ({ ...prev, paymentMethod: 'bank_transfer' })); - } else if (config.tpagoEnabled) { - setFormData(prev => ({ ...prev, paymentMethod: 'tpago' })); - } + // No payment method is pre-selected; the user must choose one. }) .catch(() => router.push('/events')) .finally(() => setLoading(false)); @@ -216,6 +205,15 @@ export default function BookingPage() { } } + // Payment method must be explicitly chosen and currently enabled + const availableMethods = buildPaymentMethods(paymentConfig, locale); + if ( + !formData.paymentMethod || + !availableMethods.some((m) => m.id === formData.paymentMethod) + ) { + newErrors.paymentMethod = t('booking.form.errors.paymentMethodRequired'); + } + // Validate additional attendees (if multi-ticket) attendees.forEach((attendee, index) => { if (!attendee.firstName.trim() || attendee.firstName.length < 2) { @@ -304,7 +302,7 @@ export default function BookingPage() { email: formData.email, phone: formData.phone, preferredLanguage: formData.preferredLanguage, - paymentMethod: formData.paymentMethod, + paymentMethod: formData.paymentMethod as PaymentMethod, ...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }), // Include attendees array for multi-ticket bookings ...(allAttendees.length > 1 && { attendees: allAttendees }), @@ -366,7 +364,7 @@ export default function BookingPage() { bookingId, qrCode: primaryTicket.qrCode, qrCodes: ticketsList?.map((t: any) => t.qrCode), - paymentMethod: formData.paymentMethod, + paymentMethod: formData.paymentMethod as PaymentMethod, ticketCount, }); setStep('success'); diff --git a/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx b/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx index 27586b1..e07845e 100644 --- a/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx @@ -23,6 +23,7 @@ import { isUnpaid, isAwaitingApproval, isOnHold, + isActionableAttention, ticketAmount, shareTicket, isToday, @@ -58,7 +59,9 @@ export default function OverviewTab({ // awaiting approval), ordered by soonest event. const attentionTicket = useMemo(() => { const candidates = activeTickets.filter( - (t) => isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t) + (t) => + isActionableAttention(t) && + (isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t)) ); const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2); candidates.sort((a, b) => { diff --git a/frontend/src/app/(public)/dashboard/components/_shared/helpers.ts b/frontend/src/app/(public)/dashboard/components/_shared/helpers.ts index 14dbbeb..3e8208d 100644 --- a/frontend/src/app/(public)/dashboard/components/_shared/helpers.ts +++ b/frontend/src/app/(public)/dashboard/components/_shared/helpers.ts @@ -35,6 +35,22 @@ export function isAwaitingApproval(ticket: { payment?: { status?: string } | nul return ticket.payment?.status === 'pending_approval'; } +/** + * Whether an attention banner (e.g. "your spot was released", "payment pending") + * is still worth showing to the user. It only makes sense to nudge the user when + * the event still exists, is still bookable (published/unlisted), and has not + * already ended. This suppresses stale banners for deleted, unpublished, + * cancelled/completed/archived, or past events. + */ +export function isActionableAttention(ticket: UserTicket): boolean { + const event = ticket.event; + if (!event) return false; + if (event.status !== 'published' && event.status !== 'unlisted') return false; + const refDate = event.endDatetime || event.startDatetime; + if (!refDate) return false; + return parseDate(refDate).getTime() > Date.now(); +} + /** * The moment the seat hold expires. null when paid/awaiting/cancelled or when * the data needed to compute it is missing. diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index d5271d8..78bf943 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -1,10 +1,10 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; -import { ticketsApi, emailsApi, adminApi, paymentsApi, Ticket } from '@/lib/api'; +import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api'; import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -48,10 +48,10 @@ import { TicketsTab } from './_tabs/TicketsTab'; import { EmailTab } from './_tabs/EmailTab'; import { PaymentsTab } from './_tabs/PaymentsTab'; import { EventModals } from './_modals/EventModals'; +import EventFormModal from '../_components/EventFormModal'; export default function AdminEventDetailPage() { const params = useParams(); - const router = useRouter(); const eventId = params.id as string; const { locale } = useLanguage(); @@ -116,6 +116,17 @@ export default function AdminEventDetailPage() { // Payment options state + handlers const payments = usePaymentOverrides(eventId, locale); + // Edit event modal (opens in place instead of redirecting to the list page) + const [showEditForm, setShowEditForm] = useState(false); + const [featuredEventId, setFeaturedEventId] = useState(null); + + useEffect(() => { + siteSettingsApi + .get() + .then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null)) + .catch(() => {}); + }, []); + // Mobile-specific state const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false); const [mobileFilterOpen, setMobileFilterOpen] = useState(false); @@ -484,12 +495,10 @@ export default function AdminEventDetailPage() { View Public - - - +
{/* Mobile header overflow menu */}
@@ -505,7 +514,7 @@ export default function AdminEventDetailPage() { { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}> View Public - { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}> + { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}> Edit Event { toggleStats(); setMobileHeaderMenuOpen(false); }}> @@ -796,6 +805,15 @@ export default function AdminEventDetailPage() { setPreviewHtml={setPreviewHtml} /> + setShowEditForm(false)} + onSaved={() => { setShowEditForm(false); loadEventData(); }} + /> +
); diff --git a/frontend/src/app/admin/events/_components/EventFormModal.tsx b/frontend/src/app/admin/events/_components/EventFormModal.tsx new file mode 100644 index 0000000..7af3652 --- /dev/null +++ b/frontend/src/app/admin/events/_components/EventFormModal.tsx @@ -0,0 +1,392 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { eventsApi, siteSettingsApi, Event } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import MediaPicker from '@/components/MediaPicker'; +import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; +import { useLanguage } from '@/context/LanguageContext'; +import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; + +interface EventFormData { + title: string; + titleEs: string; + slug: string; + description: string; + descriptionEs: string; + shortDescription: string; + shortDescriptionEs: string; + startDatetime: string; + endDatetime: string; + location: string; + locationUrl: string; + price: number; + currency: string; + capacity: number; + status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; + bannerUrl: string; + externalBookingEnabled: boolean; + externalBookingUrl: string; +} + +const EMPTY_FORM: EventFormData = { + title: '', titleEs: '', slug: '', description: '', descriptionEs: '', + shortDescription: '', shortDescriptionEs: '', + startDatetime: '', endDatetime: '', location: '', locationUrl: '', + price: 0, currency: 'PYG', capacity: 50, status: 'draft', + bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '', +}; + +function isoToLocalDatetime(isoString: string): string { + const date = parseDate(isoString); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: EVENT_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date); + const get = (type: string) => parts.find(p => p.type === type)!.value; + const h = get('hour') === '24' ? '00' : get('hour'); + return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`; +} + +interface EventFormModalProps { + /** When true the modal is rendered. */ + open: boolean; + /** The event being edited, or null to create a new event. */ + event: Event | null; + /** Currently featured event id (owned by the parent page). */ + featuredEventId: string | null; + /** Notify the parent when the featured event changes. */ + onFeaturedChange: (id: string | null) => void; + /** Close the modal without saving. */ + onClose: () => void; + /** Called after a successful create/update so the parent can refresh. */ + onSaved: () => void; +} + +/** + * Shared create/edit event modal used by both the events list page and the + * single-event detail page. Owns its own form state so it can be dropped in + * anywhere; the parent only supplies the event to edit and refresh callbacks. + */ +export default function EventFormModal({ + open, + event, + featuredEventId, + onFeaturedChange, + onClose, + onSaved, +}: EventFormModalProps) { + const { t } = useLanguage(); + const [formData, setFormData] = useState(EMPTY_FORM); + const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]); + const [saving, setSaving] = useState(false); + const [settingFeatured, setSettingFeatured] = useState(false); + + useEffect(() => { + if (!open) return; + if (event) { + setFormData({ + title: event.title, titleEs: event.titleEs || '', slug: event.slug || '', + description: event.description, descriptionEs: event.descriptionEs || '', + shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '', + startDatetime: isoToLocalDatetime(event.startDatetime), + endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '', + location: event.location, locationUrl: event.locationUrl || '', + price: event.price, currency: event.currency, capacity: event.capacity, + status: event.status, bannerUrl: event.bannerUrl || '', + externalBookingEnabled: event.externalBookingEnabled || false, + externalBookingUrl: event.externalBookingUrl || '', + }); + loadSlugAliases(event.id); + } else { + setFormData(EMPTY_FORM); + setSlugAliases([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, event]); + + const loadSlugAliases = async (eventId: string) => { + try { + const { aliases } = await eventsApi.getSlugAliases(eventId); + setSlugAliases(aliases); + } catch (error) { + setSlugAliases([]); + } + }; + + const handleRemoveAlias = async (slug: string) => { + if (!event) return; + if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return; + try { + await eventsApi.deleteSlugAlias(event.id, slug); + toast.success('Alias removed'); + setSlugAliases((prev) => prev.filter((a) => a.slug !== slug)); + } catch (error: any) { + toast.error(error.message || 'Failed to remove alias'); + } + }; + + const handleSetFeatured = async (eventId: string | null) => { + setSettingFeatured(true); + try { + await siteSettingsApi.setFeaturedEvent(eventId); + onFeaturedChange(eventId); + toast.success(eventId ? 'Event set as featured' : 'Featured event removed'); + } catch (error: any) { + toast.error(error.message || 'Failed to update featured event'); + } finally { + setSettingFeatured(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + if (formData.externalBookingEnabled && !formData.externalBookingUrl) { + toast.error('External booking URL is required when external booking is enabled'); + setSaving(false); + return; + } + if (formData.externalBookingEnabled && !formData.externalBookingUrl.startsWith('https://')) { + toast.error('External booking URL must be a valid HTTPS link'); + setSaving(false); + return; + } + const eventData: Partial = { + title: formData.title, titleEs: formData.titleEs || undefined, + description: formData.description, descriptionEs: formData.descriptionEs || undefined, + shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined, + startDatetime: formData.startDatetime, + endDatetime: formData.endDatetime || undefined, + location: formData.location, locationUrl: formData.locationUrl || undefined, + price: formData.price, currency: formData.currency, capacity: formData.capacity, + status: formData.status, bannerUrl: formData.bannerUrl || undefined, + externalBookingEnabled: formData.externalBookingEnabled, + externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, + }; + if (event) { + // Only send slug when editing so creates still auto-generate from title + eventData.slug = formData.slug || undefined; + await eventsApi.update(event.id, eventData); + toast.success('Event updated'); + } else { + await eventsApi.create(eventData); + toast.success('Event created'); + } + onSaved(); + } catch (error: any) { + toast.error(error.message || 'Failed to save event'); + } finally { + setSaving(false); + } + }; + + if (!open) return null; + + return ( +
+ +
+

+ {event ? t('admin.events.edit') : t('admin.events.create')} +

+ +
+ +
+
+ setFormData({ ...formData, title: e.target.value })} required /> + setFormData({ ...formData, titleEs: e.target.value })} /> +
+ + {event && ( +
+ setFormData({ ...formData, slug: e.target.value })} + placeholder="auto-generated from title" /> +

+ Public URL: /events/{formData.slug || '...'} + . Changing the slug keeps the old one as a redirecting alias. +

+ {slugAliases.length > 0 && ( +
+

URL aliases

+

+ Old URLs that still redirect to the current slug. Removing one breaks those links. +

+
    + {slugAliases.map((alias) => ( +
  • + /events/{alias.slug} + +
  • + ))} +
+
+ )} +
+ )} + +
+ +