Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes. Co-authored-by: Cursor <cursoragent@cursor.com>
663 lines
23 KiB
TypeScript
663 lines
23 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 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('')),
|
|
});
|
|
|
|
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');
|
|
|
|
// 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 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'));
|
|
}
|
|
|
|
let query = (db as any).select().from(events);
|
|
if (conditions.length > 0) {
|
|
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
|
}
|
|
|
|
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
|
|
|
|
// Single grouped query for booked counts across all events (avoids N+1: previously
|
|
// this ran one COUNT query per event).
|
|
const countRows = await dbAll<any>(
|
|
(db as any)
|
|
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
|
.from(tickets)
|
|
.where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
|
|
.groupBy((tickets as any).eventId)
|
|
);
|
|
const countByEvent = new Map<string, number>();
|
|
for (const row of countRows) {
|
|
countByEvent.set(row.eventId, Number(row.count) || 0);
|
|
}
|
|
|
|
const eventsWithCounts = result.map((event: any) => {
|
|
const normalized = normalizeEvent(event);
|
|
const bookedCount = countByEvent.get(event.id) || 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);
|
|
}
|
|
|
|
// Draft events are only visible to privileged users (admin preview); hide from public.
|
|
if ((event as any).status === 'draft') {
|
|
const authUser: any = await getAuthUser(c);
|
|
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
|
if (!isPrivileged) {
|
|
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;
|