Also includes admin, dashboard, and API updates; PWA icon assets; and assorted layout and utility changes on dev.
510 lines
16 KiB
TypeScript
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;
|