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>
This commit is contained in:
+146
-10
@@ -1,10 +1,11 @@
|
||||
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 { 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 {
|
||||
@@ -31,6 +32,55 @@ function normalizeEvent(event: any) {
|
||||
};
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -63,6 +113,7 @@ const normalizeBoolean = (val: unknown): boolean => {
|
||||
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(),
|
||||
@@ -164,13 +215,10 @@ eventsRouter.get('/', async (c) => {
|
||||
return c.json({ events: eventsWithCounts });
|
||||
});
|
||||
|
||||
// Get single event (public)
|
||||
// Get single event (public) - resolves by id, canonical slug, or historical alias
|
||||
eventsRouter.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||
);
|
||||
const param = c.req.param('id');
|
||||
const event = await resolveEventByParam(param);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
@@ -184,7 +232,7 @@ eventsRouter.get('/:id', async (c) => {
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, id),
|
||||
eq((tickets as any).eventId, event.id),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
@@ -328,9 +376,14 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
// 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,
|
||||
@@ -351,7 +404,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
const id = c.req.param('id');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const existing = await dbGet(
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||
);
|
||||
if (!existing) {
|
||||
@@ -362,6 +415,8 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
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);
|
||||
@@ -370,6 +425,40 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
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)
|
||||
@@ -429,6 +518,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
// 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)
|
||||
@@ -471,11 +563,15 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
|
||||
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,
|
||||
title: `${existing.title} (Copy)`,
|
||||
slug,
|
||||
title: duplicatedTitle,
|
||||
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
|
||||
description: existing.description,
|
||||
descriptionEs: existing.descriptionEs,
|
||||
@@ -501,4 +597,44 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user