Portal: per-license schedule service, shared validator, and key-only GET
Implements the shared validateSchedule validator (daily + weekly, midnight spillover, Sunday->Monday wrap, adjacency, empty-block rejection), a per-License-Key schedule service (get/set type, CRUD blocks, weekly prefill with confirmation), the content-hash version/ETag serializer, the new GET /api/v2/licenses/[licenseKey]/schedule (200/304/404, type null), and additive evolution of the legacy GET /api/v2/schedule. Adds validator, serializer (against docs/fixtures), endpoint ETag/304, and service tests.
This commit is contained in:
@@ -23,6 +23,187 @@ export type BlockInput = {
|
||||
|
||||
export type ValidationResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
// ─── Schedule-wide validation (daily + weekly, midnight spillover) ─────
|
||||
|
||||
export type ScheduleType = 'daily' | 'weekly';
|
||||
|
||||
export const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
|
||||
export type WeekDay = (typeof WEEK_DAYS)[number];
|
||||
|
||||
/** A Block as seen by the validator. `day` is null for daily schedules. */
|
||||
export type ValidationBlock = {
|
||||
id?: string;
|
||||
day?: string | null;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
proclamationIds?: string[];
|
||||
};
|
||||
|
||||
/** Block length in minutes, counting a wrap past midnight as the next day. */
|
||||
function blockLinearLength(block: { startMinutes: number; endMinutes: number }): number {
|
||||
return block.endMinutes > block.startMinutes
|
||||
? block.endMinutes - block.startMinutes
|
||||
: block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
|
||||
}
|
||||
|
||||
/** Linear half-open interval `[start, end)` relative to `dayIndex`. */
|
||||
function dayInterval(
|
||||
block: { startMinutes: number; endMinutes: number },
|
||||
dayIndex: number
|
||||
): [number, number] {
|
||||
const base = dayIndex * MINUTES_PER_DAY;
|
||||
const start = base + block.startMinutes;
|
||||
return [start, start + blockLinearLength(block)];
|
||||
}
|
||||
|
||||
function isCrossing(block: { startMinutes: number; endMinutes: number }): boolean {
|
||||
return block.endMinutes < block.startMinutes;
|
||||
}
|
||||
|
||||
function blocksForDay(blocks: ValidationBlock[], dayIndex: number): ValidationBlock[] {
|
||||
const day = WEEK_DAYS[dayIndex];
|
||||
return blocks.filter((b) => (b.day ?? null) === day);
|
||||
}
|
||||
|
||||
/**
|
||||
* First overlapping pair of Blocks in a schedule, or null. Implements the
|
||||
* contract's overlap rules: touching boundaries are fine; a midnight-crossing
|
||||
* Block spills into the next day (weekly Sunday→Monday) and must not overlap the
|
||||
* next day's early Blocks.
|
||||
*/
|
||||
export function findScheduleOverlap(
|
||||
type: ScheduleType,
|
||||
blocks: ValidationBlock[]
|
||||
): { a: ValidationBlock; b: ValidationBlock } | null {
|
||||
if (type === 'daily') {
|
||||
// Daily is a single day list that wraps onto itself: a crossing Block
|
||||
// spills into the next day's copy of the same list. First check the
|
||||
// same-day intervals, then every crossing Block against the next day.
|
||||
const intervals = blocks.map((b) => ({ block: b, interval: dayInterval(b, 0) }));
|
||||
const sameDay = firstPairOverlap(intervals);
|
||||
if (sameDay) return sameDay;
|
||||
|
||||
for (const crossing of blocks.filter(isCrossing)) {
|
||||
// The crossing Block's post-midnight tail, expressed in this day's
|
||||
// 0-based frame (it spans [0, endMinutes)).
|
||||
const spill: IntervalEntry = {
|
||||
block: crossing,
|
||||
interval: [0, crossing.endMinutes]
|
||||
};
|
||||
for (const { block, interval } of intervals) {
|
||||
if (block === crossing) continue;
|
||||
if (segmentsIntersect(spill.interval, interval)) {
|
||||
return { a: crossing, b: block };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let d = 0; d < WEEK_DAYS.length; d++) {
|
||||
const dayBlocks = blocksForDay(blocks, d);
|
||||
const intervals = dayBlocks.map((b) => ({ block: b, interval: dayInterval(b, d) }));
|
||||
|
||||
// Same-day overlaps (a crossing Block's late segment vs the same day).
|
||||
const pair = firstPairOverlap(intervals);
|
||||
if (pair) return pair;
|
||||
|
||||
// Spillover from the previous day's crossing Blocks into this day's early
|
||||
// Blocks. All spills start at midnight, so their union is [0, max(end)).
|
||||
const prevIndex = (d - 1 + WEEK_DAYS.length) % WEEK_DAYS.length;
|
||||
const prevCrossing = blocksForDay(blocks, prevIndex).filter(isCrossing);
|
||||
for (const prev of prevCrossing) {
|
||||
const spill: [number, number] = [d * MINUTES_PER_DAY, d * MINUTES_PER_DAY + prev.endMinutes];
|
||||
for (const { block, interval } of intervals) {
|
||||
if (segmentsIntersect(spill, interval)) {
|
||||
return { a: prev, b: block };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type IntervalEntry = { block: ValidationBlock; interval: [number, number] };
|
||||
|
||||
function firstPairOverlap(
|
||||
entries: IntervalEntry[]
|
||||
): { a: ValidationBlock; b: ValidationBlock } | null {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
if (segmentsIntersect(entries[i].interval, entries[j].interval)) {
|
||||
return { a: entries[i].block, b: entries[j].block };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Validate one Block's shape (time range + at least one Proclamation). */
|
||||
export function validateBlockShape(block: ValidationBlock): ValidationResult {
|
||||
const { startMinutes, endMinutes, proclamationIds } = block;
|
||||
if (
|
||||
!Number.isInteger(startMinutes) ||
|
||||
!Number.isInteger(endMinutes) ||
|
||||
startMinutes < 0 ||
|
||||
startMinutes > MINUTES_PER_DAY ||
|
||||
endMinutes < 0 ||
|
||||
endMinutes > MINUTES_PER_DAY
|
||||
) {
|
||||
return { ok: false, error: 'Enter a valid start and end time.' };
|
||||
}
|
||||
if (startMinutes === endMinutes) {
|
||||
return { ok: false, error: "Start and end time can't be the same." };
|
||||
}
|
||||
if (block.day != null && !WEEK_DAYS.includes(block.day as WeekDay)) {
|
||||
return { ok: false, error: 'Pick a valid day of the week.' };
|
||||
}
|
||||
if (proclamationIds !== undefined) {
|
||||
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
|
||||
return { ok: false, error: 'Select at least one proclamation.' };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a whole schedule. The server is authoritative; the client calls the
|
||||
* same function for instant feedback.
|
||||
*/
|
||||
export function validateSchedule(type: ScheduleType, blocks: ValidationBlock[]): ValidationResult {
|
||||
for (const block of blocks) {
|
||||
const shape = validateBlockShape(block);
|
||||
if (!shape.ok) return shape;
|
||||
}
|
||||
const overlap = findScheduleOverlap(type, blocks);
|
||||
if (overlap) {
|
||||
return { ok: false, error: overlapError(overlap.a, overlap.b) };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a candidate Block against the schedule's other Blocks (which must
|
||||
* exclude the candidate when editing).
|
||||
*/
|
||||
export function validateBlockAgainstSchedule(
|
||||
type: ScheduleType,
|
||||
candidate: ValidationBlock,
|
||||
siblings: ValidationBlock[]
|
||||
): ValidationResult {
|
||||
const shape = validateBlockShape(candidate);
|
||||
if (!shape.ok) return shape;
|
||||
const overlap = findScheduleOverlap(type, [...siblings, candidate]);
|
||||
if (overlap) {
|
||||
return { ok: false, error: overlapError(overlap.a, overlap.b) };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function overlapError(a: ValidationBlock, b: ValidationBlock): string {
|
||||
return `This overlaps with your ${formatTime(a.startMinutes)}–${formatTime(a.endMinutes)} block.`;
|
||||
}
|
||||
|
||||
function normalizeMinutes(minutes: number): number {
|
||||
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user