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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user