Files
Spanglish/backend/src/routes/events.ts
T
MichilisandCursor 1b2463f4bc Add human-readable event URL slugs with legacy redirect support.
Store unique slugs on events, backfill existing records, redirect old UUID and alias URLs to canonical slug pages, and expose slug editing plus alias management in the admin event modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 04:09:05 +00:00

641 lines
21 KiB
TypeScript

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, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.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';
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),
};
}
// 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<string[]> {
const eventRows = await dbAll<any>(
(db as any).select({ id: (events as any).id, slug: (events as any).slug }).from(events)
);
const aliasRows = await dbAll<any>(
(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<any | null> {
let event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).slug, param))
);
if (!event && (!isPostgres() || UUID_PATTERN.test(param))) {
event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, param))
);
}
if (!event) {
const alias = await dbGet<any>(
(db as any).select().from(eventSlugAliases).where(eq((eventSlugAliases as any).slug, param))
);
if (alias) {
event = await dbGet<any>(
(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;
};
// 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),
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<any>(
(db as any)
.select({ count: sql<number>`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) - 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);
}
// 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<any>(
(db as any)
.select({ count: sql<number>`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 c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
},
});
});
async function getSiteTimezone(): Promise<string> {
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
return settings?.timezone || 'America/Asuncion';
}
// Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`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<any>(
(db as any).select().from(siteSettings).limit(1)
);
let featuredEvent = null;
let shouldUnsetFeatured = false;
if (settings?.featuredEventId) {
featuredEvent = await dbGet<any>(
(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<any>(
(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();
const tz = await getSiteTimezone();
// 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) }, 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<any>(
(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 tz = await getSiteTimezone();
// Convert data for database compatibility
const updateData: Record<string, any> = { ...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;
}
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<any>(
(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<any>(
(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', 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<any>(
(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,
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);
});
// List slug aliases for an event (admin/organizer only)
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const existing = await dbGet<any>(
(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<any>(
(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', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const slug = c.req.param('slug');
const existing = await dbGet<any>(
(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;