import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js'; import { eq, ne, desc, and, gte, inArray, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireEventPermission, canSeeAttendeePii, redactAttendee } from '../lib/eventPermissions.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js'; import { slugify, uniqueSlug } from '../lib/slugify.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; import { eventSeatBreakdownQuery } from '../lib/capacity.js'; import { resolvePresaleClosure } from '../lib/presale.js'; import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js'; import { publicSalesFields } from '../lib/salesState.js'; import { loadDoorMethods } from '../lib/doorPayments.js'; import { runOps, updateOp, type TxOp } from '../lib/txOps.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. // `settings` is the site_settings row; when given, the effective pre-sale // cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the // booking API agree on when registration closes. // The walk-in (door) price is internal: it is dropped unless the caller is // admin/organizer/staff and `includeWalkInPrice` is set. function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?: boolean } = {}) { if (!event) return event; const { walkInPrice, ...publicFields } = event; const normalized = { ...publicFields, ...(opts.includeWalkInPrice ? { walkInPrice: parseWalkInPrice(walkInPrice) } : {}), // 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), // Pre-sale overrides: null means "inherit the site default" presaleClosureEnabled: event.presaleClosureEnabled == null ? null : Boolean(event.presaleClosureEnabled), presaleCloseMinutesBefore: event.presaleCloseMinutesBefore == null ? null : Number(event.presaleCloseMinutesBefore), }; if (settings !== undefined) { const { closesAt } = resolvePresaleClosure(normalized, settings); return { ...normalized, presaleClosesAt: closesAt ? closesAt.toISOString() : null }; } return normalized; } // Seat counts plus the public sales state for a normalized event. `raw` is the // DB row: doorPrice is resolved from its walk-in price, and only in the `door` // state (see lib/salesState.ts). function withSeatsAndSales( raw: any, normalized: any, settings: any, counts: { paid: number; claimed: number }, nowMs: number = Date.now() ) { const availableSeats = calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed); return { ...normalized, bookedCount: counts.paid, claimedCount: counts.claimed, availableSeats, ...publicSalesFields(raw, settings, availableSeats, nowMs), }; } // Load every slug currently in use (canonical event slugs + historical aliases), // optionally excluding a given event's own canonical slug + aliases. async function getAllSlugsInUse(excludeEventId?: string): Promise { const eventRows = await dbAll( (db as any).select({ id: (events as any).id, slug: (events as any).slug }).from(events) ); const aliasRows = await dbAll( (db as any).select({ eventId: (eventSlugAliases as any).eventId, slug: (eventSlugAliases as any).slug }).from(eventSlugAliases) ); const slugs: string[] = []; for (const row of eventRows) { if (row.slug && row.id !== excludeEventId) slugs.push(row.slug); } for (const row of aliasRows) { if (row.slug && row.eventId !== excludeEventId) slugs.push(row.slug); } return slugs; } const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // Resolve an event by canonical slug, primary id, or a historical slug alias. // Slug is checked first because Postgres rejects non-UUID strings when comparing // against the uuid `id` column, so id lookups are guarded behind a UUID check there. async function resolveEventByParam(param: string): Promise { let event = await dbGet( (db as any).select().from(events).where(eq((events as any).slug, param)) ); if (!event && (!isPostgres() || UUID_PATTERN.test(param))) { event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, param)) ); } if (!event) { const alias = await dbGet( (db as any).select().from(eventSlugAliases).where(eq((eventSlugAliases as any).slug, param)) ); if (alias) { event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, alias.eventId)) ); } } return event || null; } // 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; }; // Walk-in price: empty/null means "not set" (fall back to price) and must stay // null, never 0 — 0 is a real value meaning a free walk-in. Unparseable input // fails validation instead of silently becoming 0 the way parsePrice does. const walkInPriceSchema = z.union([z.number(), z.string(), z.null()]) .transform((val) => { if (val === null) return null; if (typeof val === 'number') return val; const trimmed = val.trim(); if (trimmed === '') return null; return Number(trimmed.replace(',', '.')); }) .pipe(z.number().min(0, 'Walk-in price cannot be negative').nullable()) .optional(); // PYG has no minor unit, so a PYG walk-in price must be a whole number. function walkInPriceError(walkInPrice: number | null | undefined, currency: string | null | undefined): string | null { if (walkInPrice == null) return null; if ((currency || 'PYG') === 'PYG' && !Number.isInteger(walkInPrice)) { return 'walkInPrice: Walk-in price must be a whole number for PYG'; } return null; } // 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(), slug: z.string().optional(), 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), walkInPrice: walkInPriceSchema, // Groups recurring events for the finance overview ("" clears it) series: z.preprocess((v) => (typeof v === 'string' && v.trim() === '' ? null : v), z.string().trim().max(100).nullable()).optional(), 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 http(s) URLs only — reject schemes like // javascript:/data: that could be reflected into an href/src on the frontend. bannerUrl: z.string() .refine((v) => v === '' || v.startsWith('/') || /^https?:\/\//i.test(v), { message: 'Banner URL must be a relative path or an http(s) URL', }) .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('')), // Pre-sale closure overrides - null/omitted means "inherit the site default" presaleClosureEnabled: z.union([z.boolean(), z.number(), z.null()]) .transform((v) => (v === null ? null : normalizeBoolean(v))) .optional(), presaleCloseMinutesBefore: z.union([z.number(), z.string(), z.null()]) .transform((v) => { if (v === null) return null; const n = typeof v === 'string' ? parseInt(v, 10) : v; return Number.isFinite(n) ? Math.floor(n) : NaN; }) .pipe(z.number().int().min(0, 'Pre-sale closure time cannot be negative').nullable()) .optional(), }); // When pre-sale closure is explicitly enabled on an event, the cutoff must be set too. const presaleRefine = { check: (data: { presaleClosureEnabled?: boolean | null; presaleCloseMinutesBefore?: number | null }) => data.presaleClosureEnabled !== true || typeof data.presaleCloseMinutesBefore === 'number', options: { message: 'Pre-sale closure time is required when pre-sale closure is enabled', path: ['presaleCloseMinutesBefore'], }, }; 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'], } ).refine(presaleRefine.check, presaleRefine.options); 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'], } ).refine(presaleRefine.check, presaleRefine.options); // Get all events (public) eventsRouter.get('/', async (c) => { const status = c.req.query('status'); const upcoming = c.req.query('upcoming'); // Pagination is opt-in: callers that pass neither page nor pageSize (public // pages, admin filter dropdowns) still get the full list. const pageParam = c.req.query('page'); const pageSizeParam = c.req.query('pageSize'); const paginated = pageParam !== undefined || pageSizeParam !== undefined; const page = Math.max(parseInt(pageParam || '1', 10) || 1, 1); const pageSize = Math.min(Math.max(parseInt(pageSizeParam || '25', 10) || 25, 1), 200); // Only privileged users may see non-public events (drafts, archived, etc.). // Anonymous/regular callers are restricted to published events regardless of // any client-supplied status filter, so drafts cannot leak. const authUser: any = await getAuthUser(c); const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role); const includeWalkInPrice = canSeeWalkInPrice(authUser?.role); const conditions: any[] = []; if (upcoming === 'true') { // Upcoming feed is always published + future-dated, for everyone. conditions.push(eq((events as any).status, 'published')); conditions.push(gte((events as any).startDatetime, getNow())); } else if (isPrivileged) { // Admins/staff may filter by any status (or list everything when unset). if (status) { conditions.push(eq((events as any).status, status)); } } else { // Public listing: published events only, regardless of any status param. conditions.push(eq((events as any).status, 'published')); } const whereClause = conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : and(...conditions); let query = (db as any).select().from(events); if (whereClause) query = query.where(whereClause); query = query.orderBy(desc((events as any).startDatetime)); let total: number | undefined; if (paginated) { let countQuery = (db as any).select({ count: sql`count(*)` }).from(events); if (whereClause) countQuery = countQuery.where(whereClause); const totalRow = await dbGet(countQuery); total = Number(totalRow?.count || 0); query = query.limit(pageSize).offset((page - 1) * pageSize); } const result = await dbAll(query); // Single grouped query for seat counts across all events (avoids N+1: previously // this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in); // claimedCount = "I've paid" claims awaiting admin verification. Both hold seats, // so availableSeats subtracts them together — the same formula the booking-creation // capacity check enforces (lib/capacity.ts). // Scoped to the returned events so a page of 25 does not scan every ticket. const eventIds = result.map((event: any) => event.id); const countRows = eventIds.length > 0 ? await dbAll(eventSeatBreakdownQuery(db, eventIds)) : []; const countByEvent = new Map(); for (const row of countRows) { countByEvent.set(row.eventId, { paid: Number(row.paidCount) || 0, claimed: Number(row.claimedCount) || 0, }); } const siteSettingsRow = await getSiteSettingsRow(); const nowMs = Date.now(); const eventsWithCounts = result.map((event: any) => withSeatsAndSales( event, normalizeEvent(event, siteSettingsRow, { includeWalkInPrice }), siteSettingsRow, countByEvent.get(event.id) || { paid: 0, claimed: 0 }, nowMs, ) ); return paginated ? c.json({ events: eventsWithCounts, total, page, pageSize }) : c.json({ events: eventsWithCounts }); }); // Get single event (public) - resolves by id, canonical slug, or historical alias eventsRouter.get('/:id', async (c) => { const param = c.req.param('id'); const event = await resolveEventByParam(param); if (!event) { return c.json({ error: 'Event not found' }, 404); } const authUser: any = await getAuthUser(c); // Draft events are only visible to privileged users (admin preview); hide from public. if ((event as any).status === 'draft') { const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role); if (!isPrivileged) { return c.json({ error: 'Event not found' }, 404); } } const settings = await getSiteSettingsRow(); const normalized = normalizeEvent(event, settings, { includeWalkInPrice: canSeeWalkInPrice(authUser?.role), }); const counts = await getEventSeatCounts(event.id); const publicEvent = withSeatsAndSales(event, normalized, settings, counts); // Door tenders (never the comp "guest" one) for the page's "pay at the door" line. const doorPaymentMethods = publicEvent.salesState === 'door' ? (await loadDoorMethods(event.id)).filter((m) => m !== 'guest') : undefined; // serverTime lets the page schedule its refresh at presaleClosesAt even when // the visitor's clock is off. return c.json({ event: { ...publicEvent, ...(doorPaymentMethods ? { doorPaymentMethods } : {}) }, serverTime: new Date().toISOString(), }); }); // Single site_settings row (or null when none has been created yet) async function getSiteSettingsRow(): Promise { const settings = await dbGet( (db as any).select().from(siteSettings).limit(1) ); return settings || null; } function siteTimezoneOf(settings: any | null): string { return settings?.timezone || 'America/Asuncion'; } // Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat // counts for one event — see lib/capacity.ts for the seat-holding rule. async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> { const row = await dbGet(eventSeatBreakdownQuery(db, eventId)); return { paid: Number(row?.paidCount) || 0, claimed: Number(row?.claimedCount) || 0, }; } // Get the earliest upcoming published event with ticket counts (ignores featured promotion) async function getNextChronologicalUpcoming(): Promise { const now = getNow(); 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 null; } const counts = await getEventSeatCounts(event.id); const settings = await getSiteSettingsRow(); return withSeatsAndSales(event, normalizeEvent(event, settings), settings, counts); } // Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion eventsRouter.get('/next', async (c) => { const event = await getNextChronologicalUpcoming(); if (!event) { return c.json({ event: null }); } return c.json({ event: { ...event, isFeatured: false } }); }); // 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 counts = await getEventSeatCounts(featuredEvent.id); return c.json({ event: { ...withSeatsAndSales(featuredEvent, normalizeEvent(featuredEvent, settings), settings, counts), isFeatured: true, }, }); } // Fallback: get the next upcoming published event const event = await getNextChronologicalUpcoming(); if (!event) { return c.json({ event: null }); } return c.json({ event: { ...event, 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(); const siteSettingsRow = await getSiteSettingsRow(); const tz = siteTimezoneOf(siteSettingsRow); const walkInError = walkInPriceError(data.walkInPrice, data.currency); if (walkInError) return c.json({ error: walkInError }, 400); // Convert data for database compatibility const dbData = convertBooleansForDb(data); // Generate a unique slug from the title (manual slug is honored on update, not create) const existingSlugs = await getAllSlugsInUse(); const slug = uniqueSlug(data.title, existingSlugs); const newEvent = { id, ...dbData, slug, startDatetime: toDbDateTz(data.startDatetime, tz), endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : 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, siteSettingsRow, { includeWalkInPrice: true }) }, 201); }); // Update event (admin/organizer only) eventsRouter.put('/:id', requireEventPermission('edit_event', { globalRoles: ['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); } if (data.walkInPrice !== undefined || data.currency !== undefined) { const walkInError = walkInPriceError( data.walkInPrice !== undefined ? data.walkInPrice : parseWalkInPrice(existing.walkInPrice), data.currency ?? existing.currency, ); if (walkInError) return c.json({ error: walkInError }, 400); } const now = getNow(); const siteSettingsRow = await getSiteSettingsRow(); const tz = siteTimezoneOf(siteSettingsRow); // Convert data for database compatibility const updateData: Record = { ...convertBooleansForDb(data), updatedAt: now }; // Slug changes are handled explicitly below to manage aliases delete updateData.slug; // Convert datetime fields if present if (data.startDatetime) { updateData.startDatetime = toDbDateTz(data.startDatetime, tz); } if (data.endDatetime !== undefined) { updateData.endDatetime = data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null; } // Resolve slug: explicit admin edit takes priority, then title-derived regeneration const oldSlug: string | null = existing.slug || null; let newSlug: string | null = oldSlug; if (typeof data.slug === 'string' && data.slug.trim() !== '') { const normalized = slugify(data.slug); if (!normalized) { return c.json({ error: 'Invalid slug' }, 400); } if (normalized !== oldSlug) { const taken = await getAllSlugsInUse(id); if (taken.includes(normalized)) { return c.json({ error: 'Slug already in use' }, 400); } newSlug = normalized; } } else if (data.title && slugify(data.title) !== slugify(existing.title || '')) { const taken = await getAllSlugsInUse(id); newSlug = uniqueSlug(data.title, taken); } if (newSlug && newSlug !== oldSlug) { // If this slug was previously one of THIS event's aliases, reclaim it as canonical await (db as any) .delete(eventSlugAliases) .where(and(eq((eventSlugAliases as any).slug, newSlug), eq((eventSlugAliases as any).eventId, id))); // Preserve the old slug as an alias so existing shared links keep redirecting if (oldSlug) { try { await (db as any).insert(eventSlugAliases).values({ slug: oldSlug, eventId: id, createdAt: now }); } catch (e) { /* alias may already exist */ } } updateData.slug = newSlug; } const ops: TxOp[] = [updateOp(events, updateData, eq((events as any).id, id))]; // Anyone who booked but hasn't paid yet owes the current price, so open // payments follow a price/currency change. Settled payments keep what was // actually paid. Excluded: pending_approval (the customer already sent the // old amount), on_hold (under review) and Lightning (the invoice is fixed). const newPrice = data.price !== undefined ? data.price : Number(existing.price); const newCurrency = data.currency ?? existing.currency; if (newPrice !== Number(existing.price) || newCurrency !== existing.currency) { ops.push(updateOp( payments, { amount: newPrice, currency: newCurrency, updatedAt: now }, and( eq((payments as any).status, 'pending'), ne((payments as any).provider, 'lightning'), inArray( (payments as any).ticketId, (db as any).select({ id: (tickets as any).id }).from(tickets).where(eq((tickets as any).eventId, id)) ) ) )); } await runOps(ops); 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, siteSettingsRow, { includeWalkInPrice: true }) }); }); // 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)); // Delete slug aliases for this event await (db as any).delete(eventSlugAliases).where(eq((eventSlugAliases 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', requireEventPermission('view_attendees_names', { globalRoles: ['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: canSeeAttendeePii(c) ? attendees : attendees.map(redactAttendee) }); }); // 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(); const duplicatedTitle = `${existing.title} (Copy)`; const existingSlugs = await getAllSlugsInUse(); const slug = uniqueSlug(duplicatedTitle, existingSlugs); // Create a copy with modified title and draft status const duplicatedEvent = { id: newId, slug, title: duplicatedTitle, 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, walkInPrice: existing.walkInPrice ?? null, currency: existing.currency, capacity: existing.capacity, status: 'draft', bannerUrl: existing.bannerUrl, externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1) externalBookingUrl: existing.externalBookingUrl, presaleClosureEnabled: existing.presaleClosureEnabled ?? null, // Already in DB format (0/1/null) presaleCloseMinutesBefore: existing.presaleCloseMinutesBefore ?? null, series: existing.series ?? null, createdAt: now, updatedAt: now, }; await (db as any).insert(events).values(duplicatedEvent); return c.json({ event: normalizeEvent(duplicatedEvent, undefined, { includeWalkInPrice: true }), message: 'Event duplicated successfully' }, 201); }); // List slug aliases for an event (admin/organizer only) eventsRouter.get('/:id/slug-aliases', requireEventPermission('edit_event', { globalRoles: ['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 aliases = await dbAll( (db as any) .select({ slug: (eventSlugAliases as any).slug, createdAt: (eventSlugAliases as any).createdAt }) .from(eventSlugAliases) .where(eq((eventSlugAliases as any).eventId, id)) ); return c.json({ aliases }); }); // Remove a slug alias from an event (admin/organizer only) eventsRouter.delete('/:id/slug-aliases/:slug', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => { const id = c.req.param('id'); const slug = c.req.param('slug'); 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); } await (db as any) .delete(eventSlugAliases) .where(and(eq((eventSlugAliases as any).eventId, id), eq((eventSlugAliases as any).slug, slug))); return c.json({ message: 'Alias removed' }); }); export default eventsRouter;