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'; const adminRouter = new Hono(); // Dashboard overview stats (admin) adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => { const now = getNow(); // Get upcoming events const upcomingEvents = 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) ); // 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')) ); const pendingPayments = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(payments) .where(eq((payments as any).status, 'pending')) ); const paidPayments = await dbAll( (db as any) .select() .from(payments) .where(eq((payments as any).status, 'paid')) ); const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 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, 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 const allEvents = await dbAll((db as any).select().from(events)); const eventStats = await Promise.all( allEvents.map(async (event: any) => { const ticketCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where(eq((tickets as any).eventId, event.id)) ); const confirmedCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, event.id), eq((tickets as any).status, 'confirmed'), ne((tickets as any).isGuest, 1) ) ) ); const checkedInCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, event.id), eq((tickets as any).status, 'checked_in'), ne((tickets as any).isGuest, 1) ) ) ); return { id: event.id, title: event.title, date: event.startDatetime, capacity: event.capacity, totalBookings: ticketCount?.count || 0, confirmedBookings: confirmedCount?.count || 0, checkedIn: checkedInCount?.count || 0, revenue: (confirmedCount?.count || 0) * 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); // Get user and event details for each ticket const enrichedTickets = await Promise.all( ticketList.map(async (ticket: any) => { const user = await dbGet( (db as any) .select() .from(users) .where(eq((users as any).id, ticket.userId)) ); const event = await dbGet( (db as any) .select() .from(events) .where(eq((events as any).id, ticket.eventId)) ); const payment = await dbGet( (db as any) .select() .from(payments) .where(eq((payments as any).ticketId, ticket.id)) ); return { ticketId: ticket.id, ticketStatus: ticket.status, qrCode: ticket.qrCode, checkinAt: ticket.checkinAt, userName: user?.name, userEmail: user?.email, userPhone: user?.phone, 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 || '', 'Status': ticket.status, 'Checked In': isCheckedIn ? 'true' : 'false', 'Check-in Time': ticket.checkinAt || '', 'Payment Status': payment?.status || '', 'Booked At': ticket.createdAt || '', 'Notes': ticket.adminNote || '', }; }) ); // Generate CSV 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; }; const columns = [ 'Ticket ID', 'Full Name', 'Email', 'Phone', '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 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; }; const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', '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(' '), '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, 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;