Close pre-sale registration a configurable time before events start.
Events gain nullable presale_closure_enabled / presale_close_minutes_before overrides; null inherits the new site_settings defaults (enabled, 120 min). A shared resolver computes the effective cutoff, which the public event API exposes as presaleClosesAt and the public booking endpoint enforces. Door and admin ticket creation are not gated. Admin: toggle + duration picker in the event modal (pre-filled from the site default) and a matching default card on Settings › General. Public: the event page shows "Registration Closed" after the cutoff and the checkout page redirects back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
745af4184f
commit
59acc32a46
@@ -8,6 +8,7 @@ import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calcula
|
||||
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';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -19,10 +20,13 @@ interface UserContext {
|
||||
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) {
|
||||
// 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.
|
||||
function normalizeEvent(event: any, settings?: any) {
|
||||
if (!event) return event;
|
||||
return {
|
||||
const normalized = {
|
||||
...event,
|
||||
// Convert price from string/decimal to clean number
|
||||
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
|
||||
@@ -30,7 +34,15 @@ function normalizeEvent(event: any) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Load every slug currently in use (canonical event slugs + historical aliases),
|
||||
@@ -138,8 +150,30 @@ const baseEventSchema = z.object({
|
||||
// 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://
|
||||
@@ -152,7 +186,7 @@ const createEventSchema = baseEventSchema.refine(
|
||||
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) => {
|
||||
@@ -166,7 +200,7 @@ const updateEventSchema = baseEventSchema.partial().refine(
|
||||
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) => {
|
||||
@@ -235,8 +269,9 @@ eventsRouter.get('/', async (c) => {
|
||||
});
|
||||
}
|
||||
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const eventsWithCounts = result.map((event: any) => {
|
||||
const normalized = normalizeEvent(event);
|
||||
const normalized = normalizeEvent(event, siteSettingsRow);
|
||||
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...normalized,
|
||||
@@ -269,7 +304,7 @@ eventsRouter.get('/:id', async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow());
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
return c.json({
|
||||
event: {
|
||||
@@ -281,10 +316,15 @@ eventsRouter.get('/:id', async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
async function getSiteTimezone(): Promise<string> {
|
||||
// Single site_settings row (or null when none has been created yet)
|
||||
async function getSiteSettingsRow(): Promise<any | null> {
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
return settings || null;
|
||||
}
|
||||
|
||||
function siteTimezoneOf(settings: any | null): string {
|
||||
return settings?.timezone || 'America/Asuncion';
|
||||
}
|
||||
|
||||
@@ -320,7 +360,7 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
|
||||
}
|
||||
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
const normalized = normalizeEvent(event);
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow());
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount: counts.paid,
|
||||
@@ -389,7 +429,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
// If we have a valid featured event, return it
|
||||
if (featuredEvent) {
|
||||
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||
const normalized = normalizeEvent(featuredEvent);
|
||||
const normalized = normalizeEvent(featuredEvent, settings);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
@@ -417,7 +457,8 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
const user = c.get('user');
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
const tz = await getSiteTimezone();
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
|
||||
// Convert data for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
@@ -442,7 +483,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
revalidateFrontendCache();
|
||||
|
||||
// Return normalized event data
|
||||
return c.json({ event: normalizeEvent(newEvent) }, 201);
|
||||
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow) }, 201);
|
||||
});
|
||||
|
||||
// Update event (admin/organizer only)
|
||||
@@ -458,7 +499,8 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const tz = await getSiteTimezone();
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
// Convert data for database compatibility
|
||||
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
|
||||
// Slug changes are handled explicitly below to manage aliases
|
||||
@@ -517,7 +559,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ event: normalizeEvent(updated) });
|
||||
return c.json({ event: normalizeEvent(updated, siteSettingsRow) });
|
||||
});
|
||||
|
||||
// Delete event (admin only)
|
||||
@@ -634,6 +676,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
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,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq, and, gte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { DEFAULT_PRESALE_CLOSURE_ENABLED, DEFAULT_PRESALE_CLOSE_MINUTES } from '../lib/presale.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -43,8 +44,26 @@ const updateSiteSettingsSchema = z.object({
|
||||
maintenanceMode: z.boolean().optional(),
|
||||
maintenanceMessage: z.string().optional().nullable(),
|
||||
maintenanceMessageEs: z.string().optional().nullable(),
|
||||
// Pre-sale closure defaults inherited by events that don't override them
|
||||
presaleClosureEnabled: z.boolean().optional(),
|
||||
presaleCloseMinutesBefore: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
// Booleans are stored as 0/1 integers on PostgreSQL; hand the frontend real booleans.
|
||||
function normalizeSettings(row: any) {
|
||||
if (!row) return row;
|
||||
return {
|
||||
...row,
|
||||
maintenanceMode: Boolean(row.maintenanceMode),
|
||||
presaleClosureEnabled: row.presaleClosureEnabled == null
|
||||
? DEFAULT_PRESALE_CLOSURE_ENABLED
|
||||
: Boolean(row.presaleClosureEnabled),
|
||||
presaleCloseMinutesBefore: row.presaleCloseMinutesBefore == null
|
||||
? DEFAULT_PRESALE_CLOSE_MINUTES
|
||||
: Number(row.presaleCloseMinutesBefore),
|
||||
};
|
||||
}
|
||||
|
||||
// Get site settings (public - needed for frontend timezone)
|
||||
siteSettingsRouter.get('/', async (c) => {
|
||||
const settings = await dbGet(
|
||||
@@ -69,11 +88,13 @@ siteSettingsRouter.get('/', async (c) => {
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
presaleClosureEnabled: DEFAULT_PRESALE_CLOSURE_ENABLED,
|
||||
presaleCloseMinutesBefore: DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ settings });
|
||||
return c.json({ settings: normalizeSettings(settings) });
|
||||
});
|
||||
|
||||
// Get available timezones
|
||||
@@ -145,13 +166,15 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
maintenanceMode: toDbBool(data.maintenanceMode || false),
|
||||
maintenanceMessage: data.maintenanceMessage || null,
|
||||
maintenanceMessageEs: data.maintenanceMessageEs || null,
|
||||
presaleClosureEnabled: toDbBool(data.presaleClosureEnabled ?? DEFAULT_PRESALE_CLOSURE_ENABLED),
|
||||
presaleCloseMinutesBefore: data.presaleCloseMinutesBefore ?? DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
await (db as any).insert(siteSettings).values(newSettings);
|
||||
|
||||
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
|
||||
return c.json({ settings: normalizeSettings(newSettings), message: 'Settings created successfully' }, 201);
|
||||
}
|
||||
|
||||
// Validate featured event if provided
|
||||
@@ -174,6 +197,9 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
if (typeof data.maintenanceMode === 'boolean') {
|
||||
updateData.maintenanceMode = toDbBool(data.maintenanceMode);
|
||||
}
|
||||
if (typeof data.presaleClosureEnabled === 'boolean') {
|
||||
updateData.presaleClosureEnabled = toDbBool(data.presaleClosureEnabled);
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(siteSettings)
|
||||
@@ -184,12 +210,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
|
||||
);
|
||||
|
||||
// Revalidate frontend cache if featured event changed
|
||||
if (data.featuredEventId !== undefined) {
|
||||
// Revalidate frontend cache if featured event changed or the pre-sale
|
||||
// defaults changed (public event pages embed the computed cutoff).
|
||||
if (
|
||||
data.featuredEventId !== undefined ||
|
||||
data.presaleClosureEnabled !== undefined ||
|
||||
data.presaleCloseMinutesBefore !== undefined
|
||||
) {
|
||||
revalidateFrontendCache();
|
||||
}
|
||||
|
||||
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
||||
return c.json({ settings: normalizeSettings(updated), message: 'Settings updated successfully' });
|
||||
});
|
||||
|
||||
// Set featured event (admin only) - convenience endpoint for event editor
|
||||
|
||||
@@ -11,6 +11,7 @@ import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||
import { isPresaleClosed } from '../lib/presale.js';
|
||||
|
||||
const ticketsRouter = new Hono();
|
||||
|
||||
@@ -111,6 +112,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
return c.json({ error: 'Event is not available for booking' }, 400);
|
||||
}
|
||||
|
||||
// Pre-sale closure: online registration stops N minutes before the event
|
||||
// starts (per-event override, else the site-wide default). Staff/door and
|
||||
// admin ticket creation use separate endpoints and are not gated.
|
||||
const siteSettingsRow = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
if (isPresaleClosed(event, siteSettingsRow)) {
|
||||
return c.json({ error: 'Registration for this event is closed' }, 400);
|
||||
}
|
||||
|
||||
// Validate the requested payment method is actually enabled for this event
|
||||
// (merge global options with any event-level overrides; override wins when not null)
|
||||
const globalPaymentOptions = await dbGet<any>(
|
||||
|
||||
Reference in New Issue
Block a user