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>
641 lines
21 KiB
TypeScript
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;
|