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:
Michilis
2026-09-14 20:52:09 +00:00
co-authored by Claude Fable 5.1
parent 745af4184f
commit 59acc32a46
17 changed files with 624 additions and 26 deletions
+36 -5
View File
@@ -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