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
+40
View File
@@ -94,6 +94,8 @@ async function migrate() {
banner_url TEXT,
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url TEXT,
presale_closure_enabled INTEGER,
presale_close_minutes_before INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
@@ -107,6 +109,14 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
} catch (e) { /* column may already exist */ }
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
} catch (e) { /* column may already exist */ }
// Add short description columns to events
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
@@ -510,6 +520,8 @@ async function migrate() {
maintenance_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT,
maintenance_message_es TEXT,
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id)
)
@@ -520,6 +532,14 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`);
} catch (e) { /* column may already exist */ }
// Pre-sale closure site-wide defaults
try {
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
} catch (e) { /* column may already exist */ }
// Legal pages table for admin-editable legal content
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS legal_pages (
@@ -730,6 +750,8 @@ async function migrate() {
banner_url VARCHAR(500),
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url VARCHAR(500),
presale_closure_enabled INTEGER,
presale_close_minutes_before INTEGER,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
@@ -743,6 +765,14 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
} catch (e) { /* column may already exist */ }
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
} catch (e) { /* column may already exist */ }
// Add short description columns to events
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
@@ -1104,6 +1134,8 @@ async function migrate() {
maintenance_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT,
maintenance_message_es TEXT,
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
updated_at TIMESTAMP NOT NULL,
updated_by UUID REFERENCES users(id)
)
@@ -1114,6 +1146,14 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`);
} catch (e) { /* column may already exist */ }
// Pre-sale closure site-wide defaults
try {
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
} catch (e) { /* column may already exist */ }
// Legal pages table for admin-editable legal content
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS legal_pages (
+12
View File
@@ -88,6 +88,9 @@ export const sqliteEvents = sqliteTable('events', {
bannerUrl: text('banner_url'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'),
// Pre-sale closure: null = inherit the site_settings default
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }),
presaleCloseMinutesBefore: integer('presale_close_minutes_before'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
@@ -387,6 +390,9 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
maintenanceMessage: text('maintenance_message'),
maintenanceMessageEs: text('maintenance_message_es'),
// Pre-sale closure defaults (events inherit these unless they override)
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }).notNull().default(true),
presaleCloseMinutesBefore: integer('presale_close_minutes_before').notNull().default(120),
// Metadata
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
@@ -476,6 +482,9 @@ export const pgEvents = pgTable('events', {
bannerUrl: varchar('banner_url', { length: 500 }),
externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0),
externalBookingUrl: varchar('external_booking_url', { length: 500 }),
// Pre-sale closure: null = inherit the site_settings default
presaleClosureEnabled: pgInteger('presale_closure_enabled'),
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
@@ -761,6 +770,9 @@ export const pgSiteSettings = pgTable('site_settings', {
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
maintenanceMessage: pgText('maintenance_message'),
maintenanceMessageEs: pgText('maintenance_message_es'),
// Pre-sale closure defaults (events inherit these unless they override)
presaleClosureEnabled: pgInteger('presale_closure_enabled').notNull().default(1),
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before').notNull().default(120),
// Metadata
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import {
resolvePresaleClosure,
isPresaleClosed,
DEFAULT_PRESALE_CLOSE_MINUTES,
} from './presale.js';
const START = '2030-01-01T20:00:00.000Z';
const startMs = new Date(START).getTime();
const minutes = (n: number) => n * 60_000;
describe('resolvePresaleClosure', () => {
it('falls back to the built-in defaults when neither event nor settings specify anything', () => {
const r = resolvePresaleClosure({ startDatetime: START }, null);
expect(r.enabled).toBe(true);
expect(r.minutesBefore).toBe(DEFAULT_PRESALE_CLOSE_MINUTES);
expect(r.closesAt?.getTime()).toBe(startMs - minutes(DEFAULT_PRESALE_CLOSE_MINUTES));
});
it('inherits from site settings when the event has null overrides', () => {
const r = resolvePresaleClosure(
{ startDatetime: START, presaleClosureEnabled: null, presaleCloseMinutesBefore: null },
{ presaleClosureEnabled: true, presaleCloseMinutesBefore: 30 }
);
expect(r.minutesBefore).toBe(30);
expect(r.closesAt?.getTime()).toBe(startMs - minutes(30));
});
it('lets the event override the site settings', () => {
const r = resolvePresaleClosure(
{ startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 1440 },
{ presaleClosureEnabled: false, presaleCloseMinutesBefore: 30 }
);
expect(r.enabled).toBe(true);
expect(r.closesAt?.getTime()).toBe(startMs - minutes(1440));
});
it('returns closesAt null when closure is disabled (site-wide or per event)', () => {
expect(resolvePresaleClosure({ startDatetime: START }, { presaleClosureEnabled: false }).closesAt).toBeNull();
expect(
resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: false }, { presaleClosureEnabled: true }).closesAt
).toBeNull();
});
it('accepts PostgreSQL 0/1 integers and string minutes', () => {
const r = resolvePresaleClosure(
{ startDatetime: new Date(START), presaleClosureEnabled: 1, presaleCloseMinutesBefore: '45' },
{ presaleClosureEnabled: 0, presaleCloseMinutesBefore: 10 }
);
expect(r.enabled).toBe(true);
expect(r.minutesBefore).toBe(45);
const off = resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: 0 }, { presaleClosureEnabled: 1 });
expect(off.enabled).toBe(false);
});
it('closes at the event start when minutes is 0', () => {
const r = resolvePresaleClosure({ startDatetime: START, presaleCloseMinutesBefore: 0 }, null);
expect(r.closesAt?.getTime()).toBe(startMs);
});
});
describe('isPresaleClosed', () => {
const event = { startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 60 };
it('is open before the cutoff and closed from the cutoff onwards', () => {
const cutoff = startMs - minutes(60);
expect(isPresaleClosed(event, null, cutoff - 1)).toBe(false);
expect(isPresaleClosed(event, null, cutoff)).toBe(true);
expect(isPresaleClosed(event, null, startMs + minutes(5))).toBe(true);
});
it('never closes when closure is disabled', () => {
expect(isPresaleClosed({ ...event, presaleClosureEnabled: false }, null, startMs + minutes(60))).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Pre-sale closure: online registration for an event closes a configurable
* number of minutes before the event starts.
*
* Each event may override the site-wide default; a `null`/`undefined` value on
* the event means "inherit from site_settings". Values arrive as booleans on
* SQLite and as 0/1 integers on PostgreSQL, so both are accepted here.
*/
export const DEFAULT_PRESALE_CLOSURE_ENABLED = true;
export const DEFAULT_PRESALE_CLOSE_MINUTES = 120;
export interface PresaleEventLike {
startDatetime: string | Date;
presaleClosureEnabled?: boolean | number | null;
presaleCloseMinutesBefore?: number | string | null;
}
export interface PresaleSettingsLike {
presaleClosureEnabled?: boolean | number | null;
presaleCloseMinutesBefore?: number | string | null;
}
export interface ResolvedPresaleClosure {
enabled: boolean;
minutesBefore: number;
/** When online registration closes, or null when closure is disabled. */
closesAt: Date | null;
}
function toBool(value: boolean | number | null | undefined): boolean | null {
if (value === null || value === undefined) return null;
return typeof value === 'number' ? value !== 0 : Boolean(value);
}
function toMinutes(value: number | string | null | undefined): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === 'string' ? parseInt(value, 10) : value;
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
}
export function resolvePresaleClosure(
event: PresaleEventLike,
settings?: PresaleSettingsLike | null
): ResolvedPresaleClosure {
const enabled =
toBool(event.presaleClosureEnabled) ??
toBool(settings?.presaleClosureEnabled) ??
DEFAULT_PRESALE_CLOSURE_ENABLED;
const minutesBefore =
toMinutes(event.presaleCloseMinutesBefore) ??
toMinutes(settings?.presaleCloseMinutesBefore) ??
DEFAULT_PRESALE_CLOSE_MINUTES;
if (!enabled) return { enabled, minutesBefore, closesAt: null };
const startMs = new Date(event.startDatetime).getTime();
if (!Number.isFinite(startMs)) return { enabled, minutesBefore, closesAt: null };
return { enabled, minutesBefore, closesAt: new Date(startMs - minutesBefore * 60_000) };
}
export function isPresaleClosed(
event: PresaleEventLike,
settings?: PresaleSettingsLike | null,
nowMs: number = Date.now()
): boolean {
const { closesAt } = resolvePresaleClosure(event, settings);
return closesAt !== null && closesAt.getTime() <= nowMs;
}
+58 -14
View File
@@ -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,
};
+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
+11
View File
@@ -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>(