import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js'; import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; interface UserContext { id: string; email: string; name: string; role: string; } const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); // Helper to normalize event data for API response // PostgreSQL decimal returns strings, booleans are stored as integers function normalizeEvent(event: any) { if (!event) return event; return { ...event, // Convert price from string/decimal to clean number price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price), // Convert capacity from string to number if needed capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity), // Convert boolean integers to actual booleans for frontend externalBookingEnabled: Boolean(event.externalBookingEnabled), }; } // Custom validation error handler const validationHook = (result: any, c: any) => { if (!result.success) { const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', '); return c.json({ error: errors }, 400); } }; // Helper to parse price from string (handles both "45000" and "41,44" formats) const parsePrice = (val: unknown): number => { if (typeof val === 'number') return val; if (typeof val === 'string') { // Replace comma with dot for decimal parsing (European format) const normalized = val.replace(',', '.'); const parsed = parseFloat(normalized); return isNaN(parsed) ? 0 : parsed; } return 0; }; // Helper to normalize boolean (handles true/false and 0/1) const normalizeBoolean = (val: unknown): boolean => { if (typeof val === 'boolean') return val; if (typeof val === 'number') return val !== 0; if (val === 'true') return true; if (val === 'false') return false; return false; }; const baseEventSchema = z.object({ title: z.string().min(1), titleEs: z.string().optional().nullable(), description: z.string().min(1), descriptionEs: z.string().optional().nullable(), shortDescription: z.string().max(300).optional().nullable(), shortDescriptionEs: z.string().max(300).optional().nullable(), startDatetime: z.string(), endDatetime: z.string().optional().nullable(), location: z.string().min(1), locationUrl: z.string().url().optional().nullable().or(z.literal('')), // Accept price as number or string (handles "45000" and "41,44" formats) price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0), currency: z.string().default('PYG'), capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50), status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'), // Accept relative paths (/uploads/...) or full URLs bannerUrl: z.string().optional().nullable().or(z.literal('')), // External booking support - accept boolean or number (0/1 from DB) externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false), externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')), }); const createEventSchema = baseEventSchema.refine( (data) => { // If external booking is enabled, URL must be provided and must start with https:// if (data.externalBookingEnabled) { return !!(data.externalBookingUrl && data.externalBookingUrl.startsWith('https://')); } return true; }, { message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled', path: ['externalBookingUrl'], } ); const updateEventSchema = baseEventSchema.partial().refine( (data) => { // If external booking is enabled, URL must be provided and must start with https:// if (data.externalBookingEnabled) { return !!(data.externalBookingUrl && data.externalBookingUrl.startsWith('https://')); } return true; }, { message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled', path: ['externalBookingUrl'], } ); // Get all events (public) eventsRouter.get('/', async (c) => { const status = c.req.query('status'); const upcoming = c.req.query('upcoming'); let query = (db as any).select().from(events); if (status) { query = query.where(eq((events as any).status, status)); } if (upcoming === 'true') { const now = getNow(); query = query.where( and( eq((events as any).status, 'published'), gte((events as any).startDatetime, now) ) ); } const result = await dbAll(query.orderBy(desc((events as any).startDatetime))); // Get ticket counts for each event const eventsWithCounts = await Promise.all( result.map(async (event: any) => { // Count confirmed AND checked_in tickets (checked_in were previously confirmed) // This ensures check-in doesn't affect capacity/spots_left const ticketCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, event.id), sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) ); const normalized = normalizeEvent(event); const bookedCount = ticketCount?.count || 0; return { ...normalized, bookedCount, availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), }; }) ); return c.json({ events: eventsWithCounts }); }); // Get single event (public) eventsRouter.get('/:id', async (c) => { const id = c.req.param('id'); const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, id)) ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Count confirmed AND checked_in tickets (checked_in were previously confirmed) // This ensures check-in doesn't affect capacity/spots_left const ticketCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, id), sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) ); const normalized = normalizeEvent(event); const bookedCount = ticketCount?.count || 0; return c.json({ event: { ...normalized, bookedCount, availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), }, }); }); // Helper function to get ticket count for an event async function getEventTicketCount(eventId: string): Promise { const ticketCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, eventId), sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) ); return ticketCount?.count || 0; } // Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming eventsRouter.get('/next/upcoming', async (c) => { const now = getNow(); const nowMs = Date.now(); // First, check if there's a featured event in site settings const settings = await dbGet( (db as any).select().from(siteSettings).limit(1) ); let featuredEvent = null; let shouldUnsetFeatured = false; if (settings?.featuredEventId) { featuredEvent = await dbGet( (db as any) .select() .from(events) .where(eq((events as any).id, settings.featuredEventId)) ); if (featuredEvent) { const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime; const isPublished = featuredEvent.status === 'published'; const hasNotEnded = new Date(eventEndTime).getTime() > nowMs; if (!isPublished || !hasNotEnded) { shouldUnsetFeatured = true; featuredEvent = null; } } else { shouldUnsetFeatured = true; } } if (shouldUnsetFeatured && settings) { try { await (db as any) .update(siteSettings) .set({ featuredEventId: null, updatedAt: now }) .where(eq((siteSettings as any).id, settings.id)); console.log('Featured event auto-cleared (event ended or unpublished)'); revalidateFrontendCache(); } catch (err: any) { console.error('Failed to clear featured event:', err); } } // If we have a valid featured event, return it if (featuredEvent) { const bookedCount = await getEventTicketCount(featuredEvent.id); const normalized = normalizeEvent(featuredEvent); return c.json({ event: { ...normalized, bookedCount, availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), isFeatured: true, }, }); } // Fallback: get the next upcoming published event const event = await dbGet( (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(1) ); if (!event) { return c.json({ event: null }); } const bookedCount = await getEventTicketCount(event.id); const normalized = normalizeEvent(event); return c.json({ event: { ...normalized, bookedCount, availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), isFeatured: false, }, }); }); // Create event (admin/organizer only) eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', createEventSchema, validationHook), async (c) => { const data = c.req.valid('json'); const user = c.get('user'); const now = getNow(); const id = generateId(); // Convert data for database compatibility const dbData = convertBooleansForDb(data); const newEvent = { id, ...dbData, startDatetime: toDbDate(data.startDatetime), endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null, createdAt: now, updatedAt: now, }; await (db as any).insert(events).values(newEvent); // Revalidate sitemap when a new event is created revalidateFrontendCache(); // Return normalized event data return c.json({ event: normalizeEvent(newEvent) }, 201); }); // Update event (admin/organizer only) eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateEventSchema, validationHook), async (c) => { const id = c.req.param('id'); const data = c.req.valid('json'); const existing = await dbGet( (db as any).select().from(events).where(eq((events as any).id, id)) ); if (!existing) { return c.json({ error: 'Event not found' }, 404); } const now = getNow(); // Convert data for database compatibility const updateData: Record = { ...convertBooleansForDb(data), updatedAt: now }; // Convert datetime fields if present if (data.startDatetime) { updateData.startDatetime = toDbDate(data.startDatetime); } if (data.endDatetime !== undefined) { updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : null; } await (db as any) .update(events) .set(updateData) .where(eq((events as any).id, id)); const updated = await dbGet( (db as any).select().from(events).where(eq((events as any).id, id)) ); // Revalidate sitemap when an event is updated (status/dates may have changed) revalidateFrontendCache(); return c.json({ event: normalizeEvent(updated) }); }); // Delete event (admin only) eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => { const id = c.req.param('id'); const existing = await dbGet( (db as any).select().from(events).where(eq((events as any).id, id)) ); if (!existing) { return c.json({ error: 'Event not found' }, 404); } // Get all tickets for this event const eventTickets = await dbAll( (db as any) .select() .from(tickets) .where(eq((tickets as any).eventId, id)) ); // Delete invoices and payments for all tickets of this event for (const ticket of eventTickets) { // Get payments for this ticket const ticketPayments = await dbAll( (db as any) .select() .from(payments) .where(eq((payments as any).ticketId, ticket.id)) ); // Delete invoices for each payment for (const payment of ticketPayments) { await (db as any).delete(invoices).where(eq((invoices as any).paymentId, payment.id)); } // Delete payments for this ticket await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id)); } // Delete all tickets for this event await (db as any).delete(tickets).where(eq((tickets as any).eventId, id)); // Delete event payment overrides await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id)); // Set eventId to null on email logs (they reference this event but can exist without it) await (db as any) .update(emailLogs) .set({ eventId: null }) .where(eq((emailLogs as any).eventId, id)); // Finally delete the event await (db as any).delete(events).where(eq((events as any).id, id)); // Revalidate sitemap when an event is deleted revalidateFrontendCache(); return c.json({ message: 'Event deleted successfully' }); }); // Get event attendees (admin/organizer only) eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => { const id = c.req.param('id'); const attendees = await dbAll( (db as any) .select() .from(tickets) .where(eq((tickets as any).eventId, id)) ); return c.json({ attendees }); }); // Duplicate event (admin/organizer only) eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (c) => { const id = c.req.param('id'); const existing = await dbGet( (db as any).select().from(events).where(eq((events as any).id, id)) ); if (!existing) { return c.json({ error: 'Event not found' }, 404); } const now = getNow(); const newId = generateId(); // Create a copy with modified title and draft status const duplicatedEvent = { id: newId, title: `${existing.title} (Copy)`, titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null, description: existing.description, descriptionEs: existing.descriptionEs, shortDescription: existing.shortDescription, shortDescriptionEs: existing.shortDescriptionEs, startDatetime: existing.startDatetime, // Already in DB format from existing record endDatetime: existing.endDatetime, location: existing.location, locationUrl: existing.locationUrl, price: existing.price, currency: existing.currency, capacity: existing.capacity, status: 'draft', bannerUrl: existing.bannerUrl, externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1) externalBookingUrl: existing.externalBookingUrl, createdAt: now, updatedAt: now, }; await (db as any).insert(events).values(duplicatedEvent); return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201); }); export default eventsRouter;