Files
Spanglish/backend/src/routes/admin.ts
Michilis 3dfb1689ad Booking flow: required terms and privacy checkbox with i18n
Also includes admin, dashboard, and API updates; PWA icon assets; and
assorted layout and utility changes on dev.
2026-04-27 03:21:15 +00:00

510 lines
16 KiB
TypeScript

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<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
);
const totalEvents = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(events)
);
const totalTickets = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
);
const confirmedTickets = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(eq((tickets as any).status, 'confirmed'))
);
const pendingPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'pending'))
);
const paidPayments = await dbAll<any>(
(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<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(contacts)
.where(eq((contacts as any).status, 'new'))
);
const totalSubscribers = await dbGet<any>(
(db as any)
.select({ count: sql<number>`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<any>((db as any).select().from(events));
const eventStats = await Promise.all(
allEvents.map(async (event: any) => {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(eq((tickets as any).eventId, event.id))
);
const confirmedCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`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<any>(
(db as any)
.select({ count: sql<number>`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<any>(query);
// Get user and event details for each ticket
const enrichedTickets = await Promise.all(
ticketList.map(async (ticket: any) => {
const user = await dbGet<any>(
(db as any)
.select()
.from(users)
.where(eq((users as any).id, ticket.userId))
);
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
const payment = await dbGet<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(query);
// Enrich with event and ticket data
const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => {
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) return null;
const event = await dbGet<any>(
(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;