From 59acc32a46cbde315672c5da5693c9185d5ac1b6 Mon Sep 17 00:00:00 2001 From: Michilis Date: Mon, 14 Sep 2026 20:52:09 +0000 Subject: [PATCH] Close pre-sale registration a configurable time before events start. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/db/migrate.ts | 40 ++++++++ backend/src/db/schema.ts | 12 +++ backend/src/lib/presale.test.ts | 75 +++++++++++++++ backend/src/lib/presale.ts | 70 ++++++++++++++ backend/src/routes/events.ts | 72 ++++++++++++--- backend/src/routes/site-settings.ts | 41 ++++++++- backend/src/routes/tickets.ts | 11 +++ .../src/app/(public)/book/[eventId]/page.tsx | 19 +++- .../events/[id]/EventDetailClient.tsx | 16 +++- .../src/app/(public)/events/[id]/page.tsx | 2 + .../events/_components/EventFormModal.tsx | 87 +++++++++++++++++- frontend/src/app/admin/settings/page.tsx | 67 ++++++++++++++ .../src/components/admin/DurationInput.tsx | 91 +++++++++++++++++++ frontend/src/i18n/locales/en.json | 4 +- frontend/src/i18n/locales/es.json | 4 +- frontend/src/lib/api/types.ts | 5 + frontend/src/lib/utils.ts | 34 +++++++ 17 files changed, 624 insertions(+), 26 deletions(-) create mode 100644 backend/src/lib/presale.test.ts create mode 100644 backend/src/lib/presale.ts create mode 100644 frontend/src/components/admin/DurationInput.tsx diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index a08fbfe..78d2622 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -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 ( diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index abae95f..05a87d6 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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), diff --git a/backend/src/lib/presale.test.ts b/backend/src/lib/presale.test.ts new file mode 100644 index 0000000..25811b6 --- /dev/null +++ b/backend/src/lib/presale.test.ts @@ -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); + }); +}); diff --git a/backend/src/lib/presale.ts b/backend/src/lib/presale.ts new file mode 100644 index 0000000..8811bd1 --- /dev/null +++ b/backend/src/lib/presale.ts @@ -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; +} diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 589c1c8..1c2ba86 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -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 { +// Single site_settings row (or null when none has been created yet) +async function getSiteSettingsRow(): Promise { const settings = await dbGet( (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 { } 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 = { ...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, }; diff --git a/backend/src/routes/site-settings.ts b/backend/src/routes/site-settings.ts index e8a1389..e373cc2 100644 --- a/backend/src/routes/site-settings.ts +++ b/backend/src/routes/site-settings.ts @@ -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 diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 49f266c..9590cfe 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -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( + (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( diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index a159ed5..4fa02bd 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; -import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; +import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils'; import { isSafeExternalUrl } from '@/lib/safeRedirect'; import toast from 'react-hot-toast'; import type { @@ -108,6 +108,14 @@ export default function BookingPage() { return; } + // Pre-sale closure: the booking API rejects after the cutoff, so + // bounce back to the event page instead of showing a dead form. + if (isPresaleClosed(eventRes.event)) { + toast.error(t('events.details.registrationClosed')); + router.push(`/events/${eventRes.event.slug}`); + return; + } + // Server-authoritative availability — same formula the booking API // enforces, so a sold-out event is caught here, not at submit time. if (isEventSoldOut(eventRes.event)) { @@ -364,11 +372,18 @@ export default function BookingPage() { toast.success(t('booking.success.message')); } } catch (error: any) { + const message = String(error?.message || ''); + // Pre-sale closed while the form was open: send the user back to the + // event page, which now shows the "Registration Closed" state. + if (/registration .*closed/i.test(message)) { + toast.error(t('events.details.registrationClosed')); + if (event?.slug) router.push(`/events/${event.slug}`); + return; + } toast.error(error.message || t('booking.form.errors.bookingFailed')); // Capacity race on the last seats: refresh availability so the page // reflects reality (sold-out block / lower quantity cap) instead of the // stale counts loaded when the form was opened. - const message = String(error?.message || ''); if (/sold out|seats available/i.test(message)) { try { const { event: freshEvent } = await eventsApi.getById(params.eventId as string); diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index ba0eb77..0b1fb2d 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; -import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; +import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import ShareButtons from '@/components/ShareButtons'; @@ -69,7 +69,9 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail const isCancelled = event.status === 'cancelled'; // Only calculate isPastEvent after mount to avoid hydration mismatch const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; - const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted'); + // Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent + const presaleClosed = mounted ? isPresaleClosed(event) : false; + const canBook = !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted'); // Booking card content - reused for mobile and desktop positions const BookingCardContent = () => ( @@ -143,11 +145,21 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail )} + + {canBook && !event.externalBookingEnabled && event.presaleClosesAt && ( +

+ {t('events.details.registrationClosesAt', { + date: `${formatDate(event.presaleClosesAt)} ${fmtTime(event.presaleClosesAt)}`, + })} +

+ )} {!event.externalBookingEnabled && (

diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index cec0e03..638a674 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -25,6 +25,7 @@ interface Event { bannerUrl?: string; availableSeats?: number; bookedCount?: number; + presaleClosesAt?: string | null; createdAt: string; updatedAt: string; } @@ -125,6 +126,7 @@ function generateEventJsonLd(event: Event) { : 'https://schema.org/SoldOut', url: `${siteUrl}/events/${event.slug}`, validFrom: new Date().toISOString(), + ...(event.presaleClosesAt ? { validThrough: event.presaleClosesAt } : {}), }, image: event.bannerUrl ? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`) diff --git a/frontend/src/app/admin/events/_components/EventFormModal.tsx b/frontend/src/app/admin/events/_components/EventFormModal.tsx index 7af3652..514815e 100644 --- a/frontend/src/app/admin/events/_components/EventFormModal.tsx +++ b/frontend/src/app/admin/events/_components/EventFormModal.tsx @@ -6,10 +6,11 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import MediaPicker from '@/components/MediaPicker'; +import DurationInput from '@/components/admin/DurationInput'; import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import { useLanguage } from '@/context/LanguageContext'; -import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; +import { parseDate, EVENT_TIMEZONE, minutesToDuration } from '@/lib/utils'; interface EventFormData { title: string; @@ -30,6 +31,22 @@ interface EventFormData { bannerUrl: string; externalBookingEnabled: boolean; externalBookingUrl: string; + presaleClosureEnabled: boolean; + presaleCloseMinutesBefore: number; +} + +// Site-wide pre-sale defaults, used to pre-fill events that haven't overridden them +interface PresaleDefaults { + enabled: boolean; + minutesBefore: number; +} + +const FALLBACK_PRESALE_DEFAULTS: PresaleDefaults = { enabled: true, minutesBefore: 120 }; + +function describeDuration(minutes: number): string { + const { value, unit } = minutesToDuration(minutes); + const label = value === 1 ? unit.slice(0, -1) : unit; + return `${value} ${label}`; } const EMPTY_FORM: EventFormData = { @@ -38,6 +55,8 @@ const EMPTY_FORM: EventFormData = { startDatetime: '', endDatetime: '', location: '', locationUrl: '', price: 0, currency: 'PYG', capacity: 50, status: 'draft', bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '', + presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled, + presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore, }; function isoToLocalDatetime(isoString: string): string { @@ -89,6 +108,30 @@ export default function EventFormModal({ const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]); const [saving, setSaving] = useState(false); const [settingFeatured, setSettingFeatured] = useState(false); + const [presaleDefaults, setPresaleDefaults] = useState(FALLBACK_PRESALE_DEFAULTS); + + useEffect(() => { + if (!open) return; + let cancelled = false; + // Site defaults fill in whatever the event hasn't overridden (null = inherit) + siteSettingsApi.get() + .then(({ settings }) => { + if (cancelled) return; + const defaults: PresaleDefaults = { + enabled: settings.presaleClosureEnabled ?? FALLBACK_PRESALE_DEFAULTS.enabled, + minutesBefore: settings.presaleCloseMinutesBefore ?? FALLBACK_PRESALE_DEFAULTS.minutesBefore, + }; + setPresaleDefaults(defaults); + setFormData((prev) => ({ + ...prev, + presaleClosureEnabled: event?.presaleClosureEnabled ?? defaults.enabled, + presaleCloseMinutesBefore: event?.presaleCloseMinutesBefore ?? defaults.minutesBefore, + })); + }) + .catch(() => { /* keep fallback defaults */ }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, event]); useEffect(() => { if (!open) return; @@ -104,6 +147,8 @@ export default function EventFormModal({ status: event.status, bannerUrl: event.bannerUrl || '', externalBookingEnabled: event.externalBookingEnabled || false, externalBookingUrl: event.externalBookingUrl || '', + presaleClosureEnabled: event.presaleClosureEnabled ?? presaleDefaults.enabled, + presaleCloseMinutesBefore: event.presaleCloseMinutesBefore ?? presaleDefaults.minutesBefore, }); loadSlugAliases(event.id); } else { @@ -161,6 +206,14 @@ export default function EventFormModal({ setSaving(false); return; } + if ( + formData.presaleClosureEnabled && + (!Number.isFinite(formData.presaleCloseMinutesBefore) || formData.presaleCloseMinutesBefore < 0) + ) { + toast.error('Pre-sale closure time must be zero or more'); + setSaving(false); + return; + } const eventData: Partial = { title: formData.title, titleEs: formData.titleEs || undefined, description: formData.description, descriptionEs: formData.descriptionEs || undefined, @@ -172,6 +225,9 @@ export default function EventFormModal({ status: formData.status, bannerUrl: formData.bannerUrl || undefined, externalBookingEnabled: formData.externalBookingEnabled, externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, + // Saving from the modal always writes explicit values (overrides the site default) + presaleClosureEnabled: formData.presaleClosureEnabled, + presaleCloseMinutesBefore: formData.presaleCloseMinutesBefore, }; if (event) { // Only send slug when editing so creates still auto-generate from title @@ -346,6 +402,35 @@ export default function EventFormModal({ )} +

+
+
+ +

Stop online registration before the event starts

+
+ +
+ {formData.presaleClosureEnabled && ( + setFormData({ ...formData, presaleCloseMinutesBefore: minutes })} /> + )} +

+ Site default: {presaleDefaults.enabled + ? `closes ${describeDuration(presaleDefaults.minutesBefore)} before the start` + : 'off'} + {' '}(Admin › Settings › General). +

+
+ setFormData({ ...formData, bannerUrl: url })} relatedId={event?.id} relatedType="event" /> diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index af1a7a3..964c6bd 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -8,8 +8,10 @@ import { parseDate } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; +import DurationInput from '@/components/admin/DurationInput'; import { Cog6ToothIcon, + TicketIcon, GlobeAltIcon, ClockIcon, EnvelopeIcon, @@ -47,6 +49,8 @@ export default function AdminSettingsPage() { maintenanceMode: false, maintenanceMessage: null, maintenanceMessageEs: null, + presaleClosureEnabled: true, + presaleCloseMinutesBefore: 120, }); const [legalSettings, setLegalSettings] = useState({ @@ -508,6 +512,69 @@ export default function AdminSettingsPage() { + {/* Pre-sale Closure defaults */} + +
+
+
+ +
+
+

+ {locale === 'es' ? 'Cierre de Preventa' : 'Pre-sale Closure'} +

+

+ {locale === 'es' + ? 'Valor por defecto para los eventos. Cada evento puede cambiarlo en su ventana de edición.' + : 'Default for events. Each event can override this in its edit dialog.'} +

+
+
+ +
+
+

+ {locale === 'es' ? 'Cerrar la preventa antes del evento' : 'Close pre-sale before the event starts'} +

+

+ {settings.presaleClosureEnabled + ? (locale === 'es' ? 'Las inscripciones en línea se cierran antes del inicio' : 'Online registration stops before the start time') + : (locale === 'es' ? 'Las inscripciones siguen abiertas hasta el inicio' : 'Registration stays open until the event starts')} +

+
+ +
+ + {settings.presaleClosureEnabled && ( +
+ updateSetting('presaleCloseMinutesBefore', minutes)} + unitLabels={locale === 'es' + ? { minutes: 'minutos', hours: 'horas', days: 'días' } + : { minutes: 'minutes', hours: 'hours', days: 'days' }} + helper={locale === 'es' + ? 'Se aplica a los eventos que no tienen su propia configuración.' + : 'Applies to events that have not set their own value.'} + /> +
+ )} +
+
+ {/* Maintenance Mode */}
diff --git a/frontend/src/components/admin/DurationInput.tsx b/frontend/src/components/admin/DurationInput.tsx new file mode 100644 index 0000000..5cdcff6 --- /dev/null +++ b/frontend/src/components/admin/DurationInput.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Input from '@/components/ui/Input'; +import { DurationUnit, durationToMinutes, minutesToDuration } from '@/lib/utils'; + +interface DurationInputProps { + label?: string; + /** Duration in minutes (the stored unit). */ + valueMinutes: number; + onChange: (minutes: number) => void; + disabled?: boolean; + helper?: string; + /** Unit labels, overridable for Spanish admin pages. */ + unitLabels?: Record; +} + +const DEFAULT_UNIT_LABELS: Record = { + minutes: 'minutes', + hours: 'hours', + days: 'days', +}; + +/** + * Number + unit (minutes / hours / days) picker that always reports minutes. + * The unit is local UI state so switching hours -> minutes keeps the typed + * number rather than the stored value. + */ +export default function DurationInput({ + label, + valueMinutes, + onChange, + disabled, + helper, + unitLabels = DEFAULT_UNIT_LABELS, +}: DurationInputProps) { + const initial = minutesToDuration(valueMinutes); + const [unit, setUnit] = useState(initial.unit); + const [value, setValue] = useState(String(initial.value)); + + // Re-sync when the parent swaps in a new stored value (e.g. modal reopened + // for another event) that doesn't match what this input last reported. + useEffect(() => { + if (durationToMinutes(Number(value), unit) === valueMinutes) return; + const next = minutesToDuration(valueMinutes); + setUnit(next.unit); + setValue(String(next.value)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [valueMinutes]); + + const emit = (nextValue: string, nextUnit: DurationUnit) => { + const n = Number(nextValue); + onChange(durationToMinutes(Number.isFinite(n) ? n : 0, nextUnit)); + }; + + return ( +
+
+
+ { + setValue(e.target.value); + emit(e.target.value, unit); + }} + /> +
+ +
+ {helper &&

{helper}

} +
+ ); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 916d6f2..d32de1c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -88,7 +88,9 @@ "spotsLeft": "spots left", "soldOut": "Sold Out", "cancelled": "Cancelled", - "eventEnded": "Event Ended" + "eventEnded": "Event Ended", + "registrationClosed": "Registration Closed", + "registrationClosesAt": "Registration closes {date}" }, "booking": { "join": "Join Event", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index fed3414..1061ef3 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -88,7 +88,9 @@ "spotsLeft": "lugares disponibles", "soldOut": "Agotado", "cancelled": "Cancelado", - "eventEnded": "Evento Finalizado" + "eventEnded": "Evento Finalizado", + "registrationClosed": "Inscripciones Cerradas", + "registrationClosesAt": "Las inscripciones cierran el {date}" }, "booking": { "join": "Unirse al Evento", diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index aa2346c..af0f304 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -18,6 +18,9 @@ export interface Event { bannerUrl?: string; externalBookingEnabled?: boolean; externalBookingUrl?: string; + presaleClosureEnabled?: boolean | null; // null = inherit the site default + presaleCloseMinutesBefore?: number | null; // null = inherit the site default + presaleClosesAt?: string | null; // server-computed cutoff (ISO); null = never closes bookedCount?: number; // paid seats (confirmed + checked_in) claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats) availableSeats?: number; // capacity - booked - claimed; the server-authoritative number @@ -484,6 +487,8 @@ export interface SiteSettings { maintenanceMode: boolean; maintenanceMessage?: string | null; maintenanceMessageEs?: string | null; + presaleClosureEnabled: boolean; + presaleCloseMinutesBefore: number; updatedAt?: string; updatedBy?: string; } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index b91961e..10f98ee 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -235,3 +235,37 @@ export function isEventSoldOut(event: { }): boolean { return eventSpotsLeft(event) <= 0; } + +/** + * True once online registration has closed for an event. Relies on the + * server-computed `presaleClosesAt` (event override or site default), so the + * page and the booking API agree on the cutoff. + */ +export function isPresaleClosed( + event: { presaleClosesAt?: string | null }, + now: Date = new Date() +): boolean { + if (!event.presaleClosesAt) return false; + return parseDate(event.presaleClosesAt).getTime() <= now.getTime(); +} + +export type DurationUnit = 'minutes' | 'hours' | 'days'; + +const MINUTES_PER_UNIT: Record = { + minutes: 1, + hours: 60, + days: 1440, +}; + +/** Split a minute count into the largest unit that divides it evenly. */ +export function minutesToDuration(minutes: number): { value: number; unit: DurationUnit } { + const safe = Number.isFinite(minutes) && minutes >= 0 ? Math.floor(minutes) : 0; + if (safe > 0 && safe % MINUTES_PER_UNIT.days === 0) return { value: safe / MINUTES_PER_UNIT.days, unit: 'days' }; + if (safe > 0 && safe % MINUTES_PER_UNIT.hours === 0) return { value: safe / MINUTES_PER_UNIT.hours, unit: 'hours' }; + return { value: safe, unit: 'minutes' }; +} + +export function durationToMinutes(value: number, unit: DurationUnit): number { + const safe = Number.isFinite(value) && value >= 0 ? value : 0; + return Math.round(safe * MINUTES_PER_UNIT[unit]); +}