import { Hono } from 'hono'; import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js'; import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { getNow } from '../lib/utils.js'; import { eventSeatBreakdownQuery } from '../lib/capacity.js'; const adminRouter = new Hono(); // Escape a value for inclusion in a CSV cell (RFC 4180 quoting). const csvEscape = (value: string) => { if (value == null) return ''; const str = String(value); if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { return '"' + str.replace(/"/g, '""') + '"'; } return str; }; // Dashboard overview stats (admin) adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => { const now = getNow(); // Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts) // so the dashboard's capacity alerts reflect real availability. const upcomingEventsRaw = await dbAll( (db as any) .select() .from(events) .where( and( eq((events as any).status, 'published'), gte((events as any).startDatetime, now) ) ) .orderBy((events as any).startDatetime) .limit(5) ); const seatRows = await dbAll(eventSeatBreakdownQuery(db)); const seatsByEvent = new Map(); for (const row of seatRows) { seatsByEvent.set(row.eventId, { paid: Number(row.paidCount) || 0, claimed: Number(row.claimedCount) || 0, }); } const upcomingEvents = upcomingEventsRaw.map((event: any) => { const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 }; return { ...event, bookedCount: counts.paid, claimedCount: counts.claimed, availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed), }; }); // Get recent tickets const recentTickets = await dbAll( (db as any) .select() .from(tickets) .orderBy(desc((tickets as any).createdAt)) .limit(10) ); // Get total stats const totalUsers = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(users) ); const totalEvents = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(events) ); const totalTickets = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) ); const confirmedTickets = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where(eq((tickets as any).status, 'confirmed')) ); // 'pending' = checkout opened, nothing paid or claimed (informational); // 'pending_approval' = customer says they paid, needs admin verification (actionable). const pendingPayments = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(payments) .where(eq((payments as any).status, 'pending')) ); const awaitingApprovalPayments = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(payments) .where(eq((payments as any).status, 'pending_approval')) ); 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)` }) .from(payments) .where(eq((payments as any).status, 'paid')) ); const totalRevenue = Number(revenueRow?.total || 0); const newContacts = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(contacts) .where(eq((contacts as any).status, 'new')) ); const totalSubscribers = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(emailSubscribers) .where(eq((emailSubscribers as any).status, 'active')) ); return c.json({ dashboard: { stats: { totalUsers: totalUsers?.count || 0, totalEvents: totalEvents?.count || 0, totalTickets: totalTickets?.count || 0, confirmedTickets: confirmedTickets?.count || 0, pendingPayments: pendingPayments?.count || 0, awaitingApprovalPayments: awaitingApprovalPayments?.count || 0, onHoldPayments: onHoldPayments?.count || 0, totalRevenue, newContacts: newContacts?.count || 0, totalSubscribers: totalSubscribers?.count || 0, }, upcomingEvents, recentTickets, }, }); }); // Get analytics data (admin) adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { // Get events with ticket counts using grouped aggregates (avoids 3 queries per event). const allEvents = await dbAll((db as any).select().from(events)); const totalRows = await dbAll( (db as any) .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) .from(tickets) .groupBy((tickets as any).eventId) ); const confirmedRows = await dbAll( (db as any) .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) .from(tickets) .where(and(eq((tickets as any).status, 'confirmed'), ne((tickets as any).isGuest, 1))) .groupBy((tickets as any).eventId) ); const checkedInRows = await dbAll( (db as any) .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) .from(tickets) .where(and(eq((tickets as any).status, 'checked_in'), ne((tickets as any).isGuest, 1))) .groupBy((tickets as any).eventId) ); const toMap = (rows: any[]) => { const m = new Map(); for (const r of rows) m.set(r.eventId, Number(r.count) || 0); return m; }; const totalMap = toMap(totalRows); const confirmedMap = toMap(confirmedRows); const checkedInMap = toMap(checkedInRows); const eventStats = allEvents.map((event: any) => { const confirmedBookings = confirmedMap.get(event.id) || 0; return { id: event.id, title: event.title, date: event.startDatetime, capacity: event.capacity, totalBookings: totalMap.get(event.id) || 0, confirmedBookings, checkedIn: checkedInMap.get(event.id) || 0, revenue: confirmedBookings * event.price, }; }); return c.json({ analytics: { events: eventStats, }, }); }); // Export data (admin) adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => { const eventId = c.req.query('eventId'); let query = (db as any).select().from(tickets); if (eventId) { query = query.where(eq((tickets as any).eventId, eventId)); } const ticketList = await dbAll(query); const userIds = [...new Set(ticketList.map((t: any) => t.userId).filter(Boolean))]; const eventIds = [...new Set(ticketList.map((t: any) => t.eventId).filter(Boolean))]; const ticketIds = ticketList.map((t: any) => t.id); const [userRows, eventRows, paymentRows] = await Promise.all([ userIds.length ? dbAll((db as any).select().from(users).where(inArray((users as any).id, userIds))) : Promise.resolve([]), eventIds.length ? dbAll((db as any).select().from(events).where(inArray((events as any).id, eventIds))) : Promise.resolve([]), ticketIds.length ? dbAll((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))) : Promise.resolve([]), ]); const usersById = new Map(userRows.map((u: any) => [u.id, u])); const eventsById = new Map(eventRows.map((e: any) => [e.id, e])); const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p])); const enrichedTickets = ticketList.map((ticket: any) => { const user = usersById.get(ticket.userId); const event = eventsById.get(ticket.eventId); const payment = paymentsByTicketId.get(ticket.id); return { ticketId: ticket.id, ticketStatus: ticket.status, qrCode: ticket.qrCode, checkinAt: ticket.checkinAt, userName: user?.name, userEmail: user?.email, userPhone: user?.phone, attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null, eventTitle: event?.title, eventDate: event?.startDatetime, paymentStatus: payment?.status, paymentAmount: payment?.amount, createdAt: ticket.createdAt, }; }); return c.json({ tickets: enrichedTickets }); }); // Export attendees for a specific event (admin) — CSV download adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => { const eventId = c.req.param('eventId'); const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all const q = c.req.query('q') || ''; // Verify event exists const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, eventId)) ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Build query for tickets belonging to this event let conditions: any[] = [eq((tickets as any).eventId, eventId)]; if (status === 'confirmed') { conditions.push(eq((tickets as any).status, 'confirmed')); } else if (status === 'checked_in') { conditions.push(eq((tickets as any).status, 'checked_in')); } else if (status === 'confirmed_pending') { conditions.push(inArray((tickets as any).status, ['confirmed', 'pending'])); } else { // "all" — include everything } let ticketList = await dbAll( (db as any) .select() .from(tickets) .where(conditions.length === 1 ? conditions[0] : and(...conditions)) .orderBy(desc((tickets as any).createdAt)) ); // Apply text search filter in-memory if (q) { const query = q.toLowerCase(); ticketList = ticketList.filter((t: any) => { const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase(); return ( fullName.includes(query) || (t.attendeeEmail || '').toLowerCase().includes(query) || (t.attendeePhone || '').toLowerCase().includes(query) || t.id.toLowerCase().includes(query) ); }); } // Enrich each ticket with payment data const rows = await Promise.all( ticketList.map(async (ticket: any) => { const payment = await dbGet( (db as any) .select() .from(payments) .where(eq((payments as any).ticketId, ticket.id)) ); const fullName = [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '); const isCheckedIn = ticket.status === 'checked_in'; return { 'Ticket ID': ticket.id, '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 || '', 'Payment Status': payment?.status || '', 'Booked At': ticket.createdAt || '', 'Notes': ticket.adminNote || '', }; }) ); const columns = [ 'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC', 'Status', 'Checked In', 'Check-in Time', 'Payment Status', 'Booked At', 'Notes', ]; const headerLine = columns.map(csvEscape).join(','); const dataLines = rows.map((row: any) => columns.map((col) => csvEscape(row[col])).join(',') ); const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n'); // BOM for UTF-8 // Build filename: event-slug-attendees-YYYY-MM-DD.csv const slug = (event.title || 'event') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); const dateStr = new Date().toISOString().split('T')[0]; const filename = `${slug}-attendees-${dateStr}.csv`; c.header('Content-Type', 'text/csv; charset=utf-8'); c.header('Content-Disposition', `attachment; filename="${filename}"`); return c.body(csvContent); }); // Legacy alias — keep old path working adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => { const newUrl = new URL(c.req.url); newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export'); return c.redirect(newUrl.toString(), 301); }); // Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only) adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => { const eventId = c.req.param('eventId'); const status = c.req.query('status') || 'all'; // confirmed | checked_in | all const q = c.req.query('q') || ''; // Verify event exists const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, eventId)) ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Only confirmed/checked_in for tickets export let conditions: any[] = [ eq((tickets as any).eventId, eventId), inArray((tickets as any).status, ['confirmed', 'checked_in']), ]; if (status === 'confirmed') { conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'confirmed')]; } else if (status === 'checked_in') { conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'checked_in')]; } let ticketList = await dbAll( (db as any) .select() .from(tickets) .where(and(...conditions)) .orderBy(desc((tickets as any).createdAt)) ); // Apply text search filter if (q) { const query = q.toLowerCase(); ticketList = ticketList.filter((t: any) => { const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase(); return ( fullName.includes(query) || t.id.toLowerCase().includes(query) ); }); } 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 || '', })); const headerLine = columns.map(csvEscape).join(','); const dataLines = rows.map((row: any) => columns.map((col: string) => csvEscape(row[col])).join(',') ); const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n'); const slug = (event.title || 'event') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); const dateStr = new Date().toISOString().split('T')[0]; const filename = `${slug}-tickets-${dateStr}.csv`; c.header('Content-Type', 'text/csv; charset=utf-8'); c.header('Content-Disposition', `attachment; filename="${filename}"`); return c.body(csvContent); }); // Export financial data (admin) adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => { const startDate = c.req.query('startDate'); const endDate = c.req.query('endDate'); const eventId = c.req.query('eventId'); // Get all payments let query = (db as any).select().from(payments); const allPayments = await dbAll(query); // Enrich with event and ticket data const enrichedPayments = await Promise.all( allPayments.map(async (payment: any) => { const ticket = await dbGet( (db as any) .select() .from(tickets) .where(eq((tickets as any).id, payment.ticketId)) ); if (!ticket) return null; const event = await dbGet( (db as any) .select() .from(events) .where(eq((events as any).id, ticket.eventId)) ); // Apply filters if (eventId && ticket.eventId !== eventId) return null; if (startDate && payment.createdAt < startDate) return null; if (endDate && payment.createdAt > endDate) return null; return { paymentId: payment.id, amount: payment.amount, currency: payment.currency, provider: payment.provider, status: payment.status, reference: payment.reference, paidAt: payment.paidAt, createdAt: payment.createdAt, ticketId: ticket.id, attendeeFirstName: ticket.attendeeFirstName, attendeeLastName: ticket.attendeeLastName, attendeeEmail: ticket.attendeeEmail, attendeeRuc: ticket.attendeeRuc || null, eventId: event?.id, eventTitle: event?.title, eventDate: event?.startDatetime, }; }) ); const filteredPayments = enrichedPayments.filter(p => p !== null); // Calculate summary const summary = { totalPayments: filteredPayments.length, totalPaid: filteredPayments.filter((p: any) => p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), totalPending: filteredPayments.filter((p: any) => p.status === 'pending').reduce((sum: number, p: any) => sum + p.amount, 0), totalRefunded: filteredPayments.filter((p: any) => p.status === 'refunded').reduce((sum: number, p: any) => sum + p.amount, 0), byProvider: { bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), }, paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length, pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length, refundedCount: filteredPayments.filter((p: any) => p.status === 'refunded').length, failedCount: filteredPayments.filter((p: any) => p.status === 'failed').length, }; return c.json({ payments: filteredPayments, summary }); }); export default adminRouter;