From 3bb8e0189bd42f5ed1e9fa1427bf8e096ea62d95 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 10:22:38 -0500 Subject: [PATCH] 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. --- src/lib/schedule-utils.spec.ts | 97 ++++++ src/lib/schedule-utils.ts | 181 ++++++++++ src/lib/server/schedule.spec.ts | 185 +++++----- src/lib/server/schedule.ts | 324 +++++++++++++++--- src/lib/server/v2-schedule.spec.ts | 169 +++++++++ src/lib/server/v2-serializers.ts | 65 ++++ .../(user)/schedule/+page.server.ts | 126 +++++-- .../(user)/schedule/page.server.spec.ts | 109 +++--- .../licenses/[licenseKey]/schedule/+server.ts | 48 +++ .../[licenseKey]/schedule/server.spec.ts | 105 ++++++ src/routes/api/v2/schedule/+server.ts | 26 +- src/routes/api/v2/schedule/schedule.spec.ts | 81 +++-- 12 files changed, 1285 insertions(+), 231 deletions(-) create mode 100644 src/lib/server/v2-schedule.spec.ts create mode 100644 src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts create mode 100644 src/routes/api/v2/licenses/[licenseKey]/schedule/server.spec.ts diff --git a/src/lib/schedule-utils.spec.ts b/src/lib/schedule-utils.spec.ts index 8d27ef5..b0317d0 100644 --- a/src/lib/schedule-utils.spec.ts +++ b/src/lib/schedule-utils.spec.ts @@ -3,6 +3,7 @@ import { blockDuration, blockSegments, findOverlap, + findScheduleOverlap, fiveMinuteOptions, formatTime, hmToMinutes, @@ -11,6 +12,8 @@ import { snapMinutes, toHm, validateBlock, + validateBlockShape, + validateSchedule, type BlockInput, type BlockSibling } from './schedule-utils'; @@ -282,3 +285,97 @@ describe('validateBlock', () => { expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true); }); }); + +// ─── Schedule-wide validation (daily + weekly) ───────────────────────── + +const block = ( + startMinutes: number, + endMinutes: number, + day: string | null = null, + proclamationIds: string[] = ['p1'] +) => ({ day, startMinutes, endMinutes, proclamationIds }); + +describe('validateBlockShape', () => { + it('rejects start == end', () => { + expect(validateBlockShape(block(360, 360))).toEqual({ + ok: false, + error: "Start and end time can't be the same." + }); + }); + + it('rejects an empty block', () => { + expect(validateBlockShape(block(360, 420, null, []))).toEqual({ + ok: false, + error: 'Select at least one proclamation.' + }); + }); + + it('accepts a single-minute block', () => { + expect(validateBlockShape(block(360, 361))).toEqual({ ok: true }); + }); +}); + +describe('validateSchedule daily', () => { + it('accepts adjacent blocks (touching boundaries)', () => { + expect(validateSchedule('daily', [block(360, 420), block(420, 510)])).toEqual({ ok: true }); + }); + + it('accepts a single-minute block', () => { + expect(validateSchedule('daily', [block(360, 361)])).toEqual({ ok: true }); + }); + + it('rejects overlapping blocks', () => { + expect(validateSchedule('daily', [block(360, 480), block(420, 540)]).ok).toBe(false); + }); + + it('rejects a midnight-crossing block that spills into an early block', () => { + // 21:00→09:00 covers [0,540) on the next day; 08:00→08:30 overlaps. + expect(validateSchedule('daily', [block(1260, 540, null), block(480, 510, null)]).ok).toBe( + false + ); + }); + + it('accepts a crossing block and its exact complement', () => { + expect(validateSchedule('daily', [block(1260, 540, null), block(540, 1260, null)])).toEqual({ + ok: true + }); + }); +}); + +describe('validateSchedule weekly', () => { + it('applies rules per day', () => { + expect(validateSchedule('weekly', [block(360, 420, 'mon'), block(360, 420, 'tue')])).toEqual({ + ok: true + }); + }); + + it('rejects overlaps within the same day', () => { + expect(validateSchedule('weekly', [block(360, 480, 'wed'), block(420, 540, 'wed')]).ok).toBe( + false + ); + }); + + it('rejects Sunday midnight-crossing spilling into Monday', () => { + expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(0, 120, 'mon')]).ok).toBe( + false + ); + }); + + it('accepts a Sunday crossing block adjacent to a Monday block', () => { + expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(60, 120, 'mon')])).toEqual({ + ok: true + }); + }); + + it('rejects a Saturday crossing spilling into Sunday', () => { + expect(validateSchedule('weekly', [block(1380, 60, 'sat'), block(30, 90, 'sun')]).ok).toBe( + false + ); + }); +}); + +describe('findScheduleOverlap', () => { + it('returns null for gaps', () => { + expect(findScheduleOverlap('daily', [block(360, 420), block(600, 660)])).toBeNull(); + }); +}); diff --git a/src/lib/schedule-utils.ts b/src/lib/schedule-utils.ts index 935f470..8738873 100644 --- a/src/lib/schedule-utils.ts +++ b/src/lib/schedule-utils.ts @@ -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; } diff --git a/src/lib/server/schedule.spec.ts b/src/lib/server/schedule.spec.ts index 2006c91..f4d79f8 100644 --- a/src/lib/server/schedule.spec.ts +++ b/src/lib/server/schedule.spec.ts @@ -2,27 +2,40 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('$lib/db', () => ({ prisma: { - schedule: { findUnique: vi.fn(), create: vi.fn() }, + schedule: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, + licenseKey: { findUnique: vi.fn() }, block: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), + createMany: vi.fn(), update: vi.fn(), - delete: vi.fn() - } + delete: vi.fn(), + deleteMany: vi.fn() + }, + blockProclamations: { createMany: vi.fn() } } })); import { prisma } from '$lib/db'; -import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule'; +import { + createBlock, + deleteBlock, + getOrCreateScheduleForLicense, + listBlocksForLicense, + setScheduleType, + updateBlock +} from './schedule'; const scheduleFindUnique = prisma.schedule.findUnique as ReturnType; const scheduleCreate = prisma.schedule.create as ReturnType; +const scheduleUpdate = prisma.schedule.update as ReturnType; const blockFindMany = prisma.block.findMany as ReturnType; const blockFindFirst = prisma.block.findFirst as ReturnType; const blockCreate = prisma.block.create as ReturnType; const blockUpdate = prisma.block.update as ReturnType; const blockDelete = prisma.block.delete as ReturnType; +const blockDeleteMany = prisma.block.deleteMany as ReturnType; const validInput = { startMinutes: 6 * 60, @@ -34,52 +47,95 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe('getOrCreateSchedule', () => { +describe('getOrCreateScheduleForLicense', () => { it('returns the existing schedule', async () => { - scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' }); + scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' }); - const result = await getOrCreateSchedule('u1'); + const result = await getOrCreateScheduleForLicense('lk1'); - expect(result).toEqual({ id: 's1', userId: 'u1' }); + expect(result).toEqual({ id: 's1', licenseKeyId: 'lk1' }); expect(scheduleCreate).not.toHaveBeenCalled(); }); - it('creates the singleton schedule when missing', async () => { + it('creates a schedule keyed by license when missing', async () => { scheduleFindUnique.mockResolvedValue(null); - scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' }); + scheduleCreate.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' }); - await getOrCreateSchedule('u1'); + await getOrCreateScheduleForLicense('lk1'); - expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } }); + expect(scheduleCreate).toHaveBeenCalledWith({ data: { licenseKeyId: 'lk1' } }); }); }); -describe('listBlocks', () => { - it('scopes to the user and includes proclamations with frequencies', async () => { +describe('listBlocksForLicense', () => { + it('scopes to the license key', async () => { blockFindMany.mockResolvedValue([]); - await listBlocks('u1'); + await listBlocksForLicense('lk1'); expect(blockFindMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { schedule: { userId: 'u1' } }, - orderBy: { startMinutes: 'asc' }, + where: { schedule: { licenseKeyId: 'lk1' } }, include: expect.objectContaining({ proclamations: expect.anything() }) }) ); }); }); +describe('setScheduleType', () => { + beforeEach(() => { + scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null }); + }); + + it('set type when empty', async () => { + blockFindMany.mockResolvedValue([]); + scheduleUpdate.mockResolvedValue({}); + + const result = await setScheduleType('lk1', 'daily'); + + expect(result).toEqual({ ok: true }); + expect(scheduleUpdate).toHaveBeenCalledWith({ + where: { id: 's1' }, + data: { type: 'daily' } + }); + }); + + it('requires confirmation to switch a populated schedule', async () => { + scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' }); + blockFindMany.mockResolvedValue([ + { id: 'b1', startMinutes: 0, endMinutes: 60, dynamicAudio: true, proclamations: [] } + ]); + + const result = await setScheduleType('lk1', 'weekly'); + + expect(result).toEqual({ ok: false, error: 'confirm' }); + expect(scheduleUpdate).not.toHaveBeenCalled(); + }); + + it('clearing to null deletes the blocks', async () => { + scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' }); + blockFindMany.mockResolvedValue([]); + blockDeleteMany.mockResolvedValue({ count: 0 }); + scheduleUpdate.mockResolvedValue({}); + + const result = await setScheduleType('lk1', null, { confirmed: true }); + + expect(result).toEqual({ ok: true }); + expect(blockDeleteMany).toHaveBeenCalledWith({ where: { scheduleId: 's1' } }); + expect(scheduleUpdate).toHaveBeenCalledWith({ where: { id: 's1' }, data: { type: null } }); + }); +}); + describe('createBlock', () => { beforeEach(() => { - scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' }); + scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' }); }); it('creates a block with deduped proclamations in order', async () => { blockFindMany.mockResolvedValue([]); blockCreate.mockResolvedValue({ id: 'new-block' }); - const result = await createBlock('u1', { + const result = await createBlock('lk1', { ...validInput, proclamationIds: ['p1', 'p1', 'p2'] }); @@ -88,6 +144,7 @@ describe('createBlock', () => { expect(blockCreate).toHaveBeenCalledWith({ data: { scheduleId: 's1', + day: null, startMinutes: validInput.startMinutes, endMinutes: validInput.endMinutes, dynamicAudio: true, @@ -101,63 +158,21 @@ describe('createBlock', () => { }); }); - it('defaults dynamic audio on when the field is absent', async () => { - blockFindMany.mockResolvedValue([]); - blockCreate.mockResolvedValue({ id: 'new-block' }); + it('rejects when no type is set', async () => { + scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null }); - await createBlock('u1', validInput); + const result = await createBlock('lk1', validInput); - expect(blockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ dynamicAudio: true }) - }) - ); - }); - - it('persists dynamic audio off when the field is explicitly false', async () => { - blockFindMany.mockResolvedValue([]); - blockCreate.mockResolvedValue({ id: 'new-block' }); - - await createBlock('u1', { ...validInput, dynamicAudio: false }); - - expect(blockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ dynamicAudio: false }) - }) - ); - }); - - it('persists the dynamic audio flag', async () => { - blockFindMany.mockResolvedValue([]); - blockCreate.mockResolvedValue({ id: 'new-block' }); - - await createBlock('u1', { ...validInput, dynamicAudio: true }); - - expect(blockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ dynamicAudio: true }) - }) - ); - }); - - it('rejects a fifth block without creating', async () => { - blockFindMany.mockResolvedValue([ - { id: 'a', startMinutes: 0, endMinutes: 60 }, - { id: 'b', startMinutes: 60, endMinutes: 120 }, - { id: 'c', startMinutes: 120, endMinutes: 180 }, - { id: 'd', startMinutes: 180, endMinutes: 240 } - ]); - - const result = await createBlock('u1', validInput); - - expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' }); + expect(result).toEqual({ ok: false, error: 'Set a schedule type first.' }); expect(blockCreate).not.toHaveBeenCalled(); }); it('rejects an overlapping block with a specific message', async () => { - blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]); + blockFindMany.mockResolvedValue([ + { id: 'a', day: null, startMinutes: 7 * 60, endMinutes: 9 * 60, proclamations: [] } + ]); - const result = await createBlock('u1', validInput); + const result = await createBlock('lk1', validInput); expect(result).toEqual({ ok: false, @@ -165,13 +180,22 @@ describe('createBlock', () => { }); expect(blockCreate).not.toHaveBeenCalled(); }); + + it('rejects an empty block before creating', async () => { + blockFindMany.mockResolvedValue([]); + + const result = await createBlock('lk1', { ...validInput, proclamationIds: [] }); + + expect(result).toEqual({ ok: false, error: 'Select at least one proclamation.' }); + expect(blockCreate).not.toHaveBeenCalled(); + }); }); describe('updateBlock', () => { - it('returns not found when the block does not belong to the user', async () => { + it('returns not found when the block does not belong to the license', async () => { blockFindFirst.mockResolvedValue(null); - const result = await updateBlock('u1', 'missing', validInput); + const result = await updateBlock('lk1', 'missing', validInput); expect(result).toEqual({ ok: false, error: 'Block not found.' }); expect(blockUpdate).not.toHaveBeenCalled(); @@ -179,19 +203,22 @@ describe('updateBlock', () => { it('replaces proclamations and excludes itself from overlap checks', async () => { blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' }); + scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' }); blockFindMany.mockResolvedValue([]); blockUpdate.mockResolvedValue({ id: 'b1' }); - const result = await updateBlock('u1', 'b1', validInput); + const result = await updateBlock('lk1', 'b1', validInput); expect(result).toEqual({ ok: true, id: 'b1' }); - expect(blockFindMany).toHaveBeenCalledWith({ - where: { scheduleId: 's1', id: { not: 'b1' } }, - select: { id: true, startMinutes: true, endMinutes: true } - }); + expect(blockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { scheduleId: 's1', id: { not: 'b1' } } + }) + ); expect(blockUpdate).toHaveBeenCalledWith({ where: { id: 'b1' }, data: { + day: null, startMinutes: validInput.startMinutes, endMinutes: validInput.endMinutes, dynamicAudio: true, @@ -205,10 +232,10 @@ describe('updateBlock', () => { }); describe('deleteBlock', () => { - it('returns not found when the block does not belong to the user', async () => { + it('returns not found when the block does not belong to the license', async () => { blockFindFirst.mockResolvedValue(null); - const result = await deleteBlock('u1', 'missing'); + const result = await deleteBlock('lk1', 'missing'); expect(result).toEqual({ ok: false, error: 'Block not found.' }); expect(blockDelete).not.toHaveBeenCalled(); @@ -218,7 +245,7 @@ describe('deleteBlock', () => { blockFindFirst.mockResolvedValue({ id: 'b1' }); blockDelete.mockResolvedValue({ id: 'b1' }); - const result = await deleteBlock('u1', 'b1'); + const result = await deleteBlock('lk1', 'b1'); expect(result).toEqual({ ok: true, id: 'b1' }); expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } }); diff --git a/src/lib/server/schedule.ts b/src/lib/server/schedule.ts index 4023d94..8ee5eb8 100644 --- a/src/lib/server/schedule.ts +++ b/src/lib/server/schedule.ts @@ -1,78 +1,281 @@ // Schedule service layer. Owns the database orchestration and enforcement of -// the Block business rules (at most 4 per user, at least one Proclamation, -// no overnight ranges, no overlaps). Pure validation lives in -// `$lib/schedule-utils` so the client modal can reuse it. +// the Block business rules. Pure validation lives in `$lib/schedule-utils` so +// the client can reuse the exact same rules. +// +// A Schedule belongs to exactly one License Key. `type` is "daily" | "weekly" | +// null (null = no schedule). Daily Blocks have day=null; weekly Blocks carry a +// week day. Times are wall-clock minutes from midnight. import { prisma } from '$lib/db'; -import { validateBlock, type BlockInput } from '$lib/schedule-utils'; +import { + MAX_BLOCKS, + WEEK_DAYS, + validateBlockAgainstSchedule, + validateSchedule, + type ScheduleType, + type ValidationBlock +} from '$lib/schedule-utils'; export { MAX_BLOCKS, MINUTES_PER_DAY, SNAP_MINUTES, MINUTE_STEP, + WEEK_DAYS, formatTime, toHm, snapMinutes, findOverlap, - validateBlock + findScheduleOverlap, + validateBlock, + validateBlockAgainstSchedule, + validateBlockShape, + validateSchedule } from '$lib/schedule-utils'; -export type { BlockInput, BlockSibling, ValidationResult } from '$lib/schedule-utils'; +export type { + BlockInput as LegacyBlockInput, + BlockSibling, + ValidationResult, + ValidationBlock, + ScheduleType +} from '$lib/schedule-utils'; + +export type BlockInput = { + day?: string | null; + startMinutes: number; + endMinutes: number; + proclamationIds: string[]; + dynamicAudio?: boolean; +}; export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string }; +export type TypeMutationResult = { ok: true } | { ok: false; error: string }; function uniqueIds(ids: string[]): string[] { return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))]; } -/** - * New Blocks default `dynamicAudio` to true when the field is absent. Existing - * stored Blocks are never rewritten by this default. - */ function dynamicAudioOrDefault(input: BlockInput): boolean { return input.dynamicAudio !== false; } -/** Ensure the user has a Schedule row, creating the singleton on first use. */ -export async function getOrCreateSchedule(userId: string) { - const existing = await prisma.schedule.findUnique({ where: { userId } }); +function isScheduleType(value: unknown): value is ScheduleType { + return value === 'daily' || value === 'weekly'; +} + +/** Normalise the day for a schedule type: daily always uses null. */ +function normaliseDay(type: ScheduleType | null, day: string | null | undefined): string | null { + if (type !== 'weekly') return null; + return day ?? null; +} + +const blockInclude = { + proclamations: { + orderBy: { sortOrder: 'asc' as const }, + include: { + proclamation: { include: { solfeggioFrequency: true } } + } + } +}; + +/** Ensure the License Key has a Schedule row. New rows start with type null. */ +export async function getOrCreateScheduleForLicense(licenseKeyId: string) { + const existing = await prisma.schedule.findUnique({ where: { licenseKeyId } }); if (existing) return existing; - return prisma.schedule.create({ data: { userId } }); + return prisma.schedule.create({ data: { licenseKeyId } }); +} + +/** The Schedule (with Blocks and Proclamations) for a License Key, or null. */ +export async function getScheduleForLicense(licenseKeyId: string) { + return prisma.schedule.findUnique({ + where: { licenseKeyId }, + include: { blocks: { include: blockInclude } } + }); +} + +/** The Schedule for a License Key string, or null. */ +export async function getScheduleForLicenseKey(licenseKey: string) { + const license = await prisma.licenseKey.findUnique({ + where: { key: licenseKey }, + select: { id: true } + }); + if (!license) return { license: null, schedule: null }; + const schedule = await getScheduleForLicense(license.id); + return { license, schedule }; +} + +/** Blocks for a License Key's schedule, ordered by day then start. */ +export async function listBlocksForLicense(licenseKeyId: string) { + return prisma.block.findMany({ + where: { schedule: { licenseKeyId } }, + orderBy: [{ day: 'asc' }, { startMinutes: 'asc' }], + include: blockInclude + }); +} + +function toValidationBlock(block: { + id?: string; + day: string | null; + startMinutes: number; + endMinutes: number; + proclamationIds?: string[]; +}): ValidationBlock { + return { + id: block.id, + day: block.day, + startMinutes: block.startMinutes, + endMinutes: block.endMinutes, + proclamationIds: block.proclamationIds + }; } /** - * The user's Blocks with their selected Proclamations (and frequencies for - * canonical-id mapping), ordered by start time. + * Change (or clear) a Schedule's type. daily→weekly prefills all seven days + * with the daily Blocks; weekly→daily and clearing start empty. Returns an + * error result when confirmation is implicitly required but not given. */ -export async function listBlocks(userId: string) { - return prisma.block.findMany({ - where: { schedule: { userId } }, - orderBy: { startMinutes: 'asc' }, - include: { - proclamations: { - orderBy: { sortOrder: 'asc' }, - include: { - proclamation: { include: { solfeggioFrequency: true } } - } +export async function setScheduleType( + licenseKeyId: string, + type: ScheduleType | null, + opts: { confirmed?: boolean } = {} +): Promise { + if (type !== null && !isScheduleType(type)) { + return { ok: false, error: 'Pick a schedule type.' }; + } + + const schedule = await getOrCreateScheduleForLicense(licenseKeyId); + if (schedule.type === type) return { ok: true }; + + const existing = await prisma.block.findMany({ + where: { scheduleId: schedule.id }, + include: { proclamations: { orderBy: { sortOrder: 'asc' } } } + }); + + // Changing an existing, populated schedule requires confirmation. + if ( + (schedule.type === 'daily' || schedule.type === 'weekly') && + type !== null && + !opts.confirmed + ) { + return { ok: false, error: 'confirm' }; + } + + if (type === 'weekly' && schedule.type === 'daily' && existing.length > 0) { + // Prefill all seven days with a copy of the daily Blocks. + await prisma.block.createMany({ + data: existing.flatMap((block) => + WEEK_DAYS.map((day) => ({ + scheduleId: schedule.id, + day, + startMinutes: block.startMinutes, + endMinutes: block.endMinutes, + dynamicAudio: block.dynamicAudio + })) + ) + }); + const clones = await prisma.block.findMany({ + where: { scheduleId: schedule.id, day: { not: null } } + }); + for (const clone of clones) { + const source = existing.find( + (b) => + b.startMinutes === clone.startMinutes && + b.endMinutes === clone.endMinutes && + b.dynamicAudio === clone.dynamicAudio + ); + if (source) { + await prisma.blockProclamations.createMany({ + data: source.proclamations.map((p, index) => ({ + blockId: clone.id, + proclamationId: p.proclamationId, + sortOrder: index + })), + skipDuplicates: true + }); } } - }); + } else if (type !== 'weekly') { + // weekly→daily, daily→null, weekly→null: clear the Blocks. + await prisma.block.deleteMany({ where: { scheduleId: schedule.id } }); + } + + await prisma.schedule.update({ where: { id: schedule.id }, data: { type } }); + return { ok: true }; } -export async function createBlock(userId: string, input: BlockInput): Promise { - const schedule = await getOrCreateSchedule(userId); - const siblings = await prisma.block.findMany({ - where: { scheduleId: schedule.id }, - select: { id: true, startMinutes: true, endMinutes: true } +async function ensureValid( + licenseKeyId: string, + day: string | null, + candidate: BlockInput, + excludeBlockId?: string +): Promise { + const schedule = await getOrCreateScheduleForLicense(licenseKeyId); + const type: ScheduleType | null = isScheduleType(schedule.type) ? schedule.type : null; + if (type === null) { + return { ok: false, error: 'Set a schedule type first.' }; + } + if (type === 'weekly' && (day === null || !(WEEK_DAYS as readonly string[]).includes(day))) { + return { ok: false, error: 'Pick a valid day of the week.' }; + } + + const existing = await prisma.block.findMany({ + where: { + scheduleId: schedule.id, + ...(excludeBlockId ? { id: { not: excludeBlockId } } : {}) + }, + select: { + id: true, + day: true, + startMinutes: true, + endMinutes: true, + proclamations: { select: { proclamationId: true } } + } }); - const validation = validateBlock(input, siblings, { isCreate: true }); + const siblings: ValidationBlock[] = existing.map((b) => + toValidationBlock({ + id: b.id, + day: b.day, + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + proclamationIds: b.proclamations.map((p) => p.proclamationId) + }) + ); + + const candidateBlock = toValidationBlock({ + day, + startMinutes: candidate.startMinutes, + endMinutes: candidate.endMinutes, + proclamationIds: uniqueIds(candidate.proclamationIds) + }); + + const validation = validateBlockAgainstSchedule(type, candidateBlock, siblings); if (!validation.ok) return validation; + const sameDayCount = siblings.filter((b) => normaliseDay(type, b.day) === day).length; + if (!excludeBlockId && sameDayCount >= MAX_BLOCKS) { + return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks per day.` }; + } + + return { ok: true, scheduleId: schedule.id, type }; +} + +export async function createBlock( + licenseKeyId: string, + input: BlockInput +): Promise { + const schedule = await getOrCreateScheduleForLicense(licenseKeyId); + const type: ScheduleType | null = isScheduleType(schedule.type) ? schedule.type : null; + const day = normaliseDay(type, input.day); + + const checked = await ensureValid(licenseKeyId, day, input); + if (!checked.ok) return { ok: false, error: checked.error }; + const proclamationIds = uniqueIds(input.proclamationIds); const block = await prisma.block.create({ data: { scheduleId: schedule.id, + day, startMinutes: input.startMinutes, endMinutes: input.endMinutes, dynamicAudio: dynamicAudioOrDefault(input), @@ -84,33 +287,33 @@ export async function createBlock(userId: string, input: BlockInput): Promise { const block = await prisma.block.findFirst({ - where: { id: blockId, schedule: { userId } }, + where: { id: blockId, schedule: { licenseKeyId } }, select: { id: true, scheduleId: true } }); if (!block) return { ok: false, error: 'Block not found.' }; - const siblings = await prisma.block.findMany({ - where: { scheduleId: block.scheduleId, id: { not: blockId } }, - select: { id: true, startMinutes: true, endMinutes: true } - }); + const schedule = await prisma.schedule.findUnique({ where: { id: block.scheduleId } }); + const type: ScheduleType | null = + schedule && isScheduleType(schedule.type) ? schedule.type : null; + const day = normaliseDay(type, input.day); - const validation = validateBlock(input, siblings); - if (!validation.ok) return validation; + const checked = await ensureValid(licenseKeyId, day, input, blockId); + if (!checked.ok) return { ok: false, error: checked.error }; const proclamationIds = uniqueIds(input.proclamationIds); await prisma.block.update({ where: { id: blockId }, data: { + day, startMinutes: input.startMinutes, endMinutes: input.endMinutes, dynamicAudio: dynamicAudioOrDefault(input), @@ -123,13 +326,15 @@ export async function updateBlock( } } }); - return { ok: true, id: blockId }; } -export async function deleteBlock(userId: string, blockId: string): Promise { +export async function deleteBlock( + licenseKeyId: string, + blockId: string +): Promise { const block = await prisma.block.findFirst({ - where: { id: blockId, schedule: { userId } }, + where: { id: blockId, schedule: { licenseKeyId } }, select: { id: true } }); if (!block) return { ok: false, error: 'Block not found.' }; @@ -137,3 +342,32 @@ export async function deleteBlock(userId: string, blockId: string): Promise { + return validateSchedule(type, blocks); +} + +/** Flatten a loaded Schedule's Blocks (with Proclamations) for serialization. */ +export function scheduleBlocksToInput( + blocks: { + id: string; + day: string | null; + startMinutes: number; + endMinutes: number; + dynamicAudio: boolean; + proclamations: { proclamation: { id: string } }[]; + }[] +) { + return blocks.map((block) => ({ + id: block.id, + day: block.day, + startMinutes: block.startMinutes, + endMinutes: block.endMinutes, + dynamicAudio: block.dynamicAudio, + proclamationIds: block.proclamations.map((entry) => entry.proclamation.id) + })); +} diff --git a/src/lib/server/v2-schedule.spec.ts b/src/lib/server/v2-schedule.spec.ts new file mode 100644 index 0000000..28d2d08 --- /dev/null +++ b/src/lib/server/v2-schedule.spec.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { buildSchedulePayload, type ScheduleBlockInput } from './v2-serializers'; + +const FIXTURES = resolve(process.cwd(), 'docs/fixtures'); + +function load(name: string): { + type: 'daily' | 'weekly' | null; + daily: { + id: string; + startMinutes: number; + endMinutes: number; + dynamicAudio: boolean; + proclamationIds: string[]; + }[]; + blocks: { + id: string; + startMinutes: number; + endMinutes: number; + dynamicAudio: boolean; + proclamationIds: string[]; + }[]; + weekly: Record< + string, + { + id: string; + startMinutes: number; + endMinutes: number; + dynamicAudio: boolean; + proclamationIds: string[]; + }[] + >; +} { + return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8')); +} + +/** Convert a fixture payload back into DB-shaped blocks. */ +function fixtureBlocks(name: string): { + type: 'daily' | 'weekly' | null; + blocks: ScheduleBlockInput[]; +} { + const fixture = load(name); + const blocks: ScheduleBlockInput[] = []; + if (fixture.type === 'daily') { + for (const b of fixture.daily) { + blocks.push({ + id: b.id, + day: null, + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + dynamicAudio: b.dynamicAudio, + proclamationIds: b.proclamationIds + }); + } + } else if (fixture.type === 'weekly') { + for (const [day, list] of Object.entries(fixture.weekly)) { + for (const b of list) { + blocks.push({ + id: b.id, + day, + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + dynamicAudio: b.dynamicAudio, + proclamationIds: b.proclamationIds + }); + } + } + } + return { type: fixture.type, blocks }; +} + +describe('buildSchedulePayload against fixtures', () => { + it('round-trips the daily fixture content field-for-field', () => { + const { type, blocks } = fixtureBlocks('schedule-daily.json'); + const payload = buildSchedulePayload(type, blocks); + const fixture = load('schedule-daily.json'); + + expect(payload.type).toBe('daily'); + expect(payload.daily).toEqual(fixture.daily); + expect(payload.blocks).toEqual(fixture.blocks); + expect(payload.weekly).toEqual({}); + }); + + it('populates all seven weekly keys and leaves blocks empty for weekly', () => { + const { type, blocks } = fixtureBlocks('schedule-weekly.json'); + const payload = buildSchedulePayload(type, blocks); + const fixture = load('schedule-weekly.json'); + + expect(payload.type).toBe('weekly'); + expect(Object.keys(payload.weekly).sort()).toEqual([ + 'fri', + 'mon', + 'sat', + 'sun', + 'thu', + 'tue', + 'wed' + ]); + expect(payload.weekly.sun).toEqual(fixture.weekly.sun); + expect(payload.weekly.tue).toEqual([]); + expect(payload.blocks).toEqual([]); + expect(payload.daily).toEqual([]); + }); + + it('produces type null with empty lists for the none fixture', () => { + const payload = buildSchedulePayload(null, []); + + expect(payload.type).toBeNull(); + expect(payload.daily).toEqual([]); + expect(payload.weekly).toEqual({}); + expect(payload.blocks).toEqual([]); + }); + + it('preserves midnight-crossing and adjacency in the daily flattening', () => { + const pick = (b: { + id: string; + startMinutes: number; + endMinutes: number; + dynamicAudio: boolean; + proclamationIds: string[]; + }) => ({ + id: b.id, + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + dynamicAudio: b.dynamicAudio, + proclamationIds: b.proclamationIds + }); + for (const name of ['schedule-midnight-crossing.json', 'schedule-adjacent.json']) { + const { type, blocks } = fixtureBlocks(name); + const payload = buildSchedulePayload(type, blocks); + const fixture = load(name); + // The fixture lists blocks in file order; the serializer sorts by start + // time. Compare as an order-independent set of the shared fields. + const byId = (list: ReturnType[]) => + [...list].sort((a, b) => a.id.localeCompare(b.id)).map(pick); + expect(byId(payload.blocks)).toEqual(byId(fixture.blocks)); + } + }); + + it('preserves the dynamicAudio flag per block', () => { + const { type, blocks } = fixtureBlocks('schedule-dynamic-audio.json'); + const payload = buildSchedulePayload(type, blocks); + expect(payload.blocks.map((b) => b.dynamicAudio)).toEqual([true, false]); + }); +}); + +describe('version (ETag)', () => { + it('is stable for identical content', () => { + const { type, blocks } = fixtureBlocks('schedule-daily.json'); + expect(buildSchedulePayload(type, blocks).version).toBe( + buildSchedulePayload(type, blocks).version + ); + }); + + it('changes when the schedule changes', () => { + const { type, blocks } = fixtureBlocks('schedule-daily.json'); + const a = buildSchedulePayload(type, blocks).version; + const changed = blocks.map((b, i) => (i === 0 ? { ...b, endMinutes: b.endMinutes + 5 } : b)); + const b = buildSchedulePayload(type, changed).version; + expect(a).not.toBe(b); + }); + + it('changes with the type', () => { + const daily = buildSchedulePayload('daily', []).version; + const weekly = buildSchedulePayload('weekly', []).version; + expect(daily).not.toBe(weekly); + }); +}); diff --git a/src/lib/server/v2-serializers.ts b/src/lib/server/v2-serializers.ts index 28276f3..d58d2b6 100644 --- a/src/lib/server/v2-serializers.ts +++ b/src/lib/server/v2-serializers.ts @@ -1,4 +1,5 @@ import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils'; +import { createHash } from 'node:crypto'; // Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()` // map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these @@ -190,3 +191,67 @@ export function serializeScheduleBlock( proclamationIds }; } + +/** A Block as read from the database, flattened for serialization. */ +export type ScheduleBlockInput = { + id: string; + day: string | null; + startMinutes: number; + endMinutes: number; + dynamicAudio?: boolean; + proclamationIds: string[]; +}; + +export type V2ScheduleType = 'daily' | 'weekly' | null; + +export type V2SchedulePayload = { + version: string; + type: V2ScheduleType; + daily: V2ScheduleBlock[]; + weekly: Record; + blocks: V2ScheduleBlock[]; +}; + +const WEEK_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; + +function serialise(block: ScheduleBlockInput): V2ScheduleBlock { + return serializeScheduleBlock(block, block.proclamationIds); +} + +/** + * Build the target schedule payload. `version` is an opaque content hash that + * doubles as the ETag; it is stable for equal content and changes on any + * mutation. Only the active type's lists are populated. `blocks` is the legacy + * daily flattening (empty for weekly) kept for older Kiosks. + */ +export function buildSchedulePayload( + type: V2ScheduleType, + blocks: ScheduleBlockInput[] +): V2SchedulePayload { + const dailyBlocks: V2ScheduleBlock[] = []; + const weekly: Record = {}; + + if (type === 'daily') { + const ordered = [...blocks].sort((a, b) => a.startMinutes - b.startMinutes); + for (const block of ordered) dailyBlocks.push(serialise(block)); + } else if (type === 'weekly') { + for (const key of WEEK_KEYS) weekly[key] = []; + const ordered = [...blocks].sort((a, b) => a.startMinutes - b.startMinutes); + for (const block of ordered) { + const key = block.day ?? ''; + if (key in weekly) weekly[key].push(serialise(block)); + } + } + + const version = createHash('sha1') + .update(JSON.stringify({ type, daily: dailyBlocks, weekly })) + .digest('hex'); + + return { + version, + type, + daily: dailyBlocks, + weekly, + blocks: type === 'daily' ? dailyBlocks : [] + }; +} diff --git a/src/routes/(protected)/(user)/schedule/+page.server.ts b/src/routes/(protected)/(user)/schedule/+page.server.ts index 10359d1..6b07395 100644 --- a/src/routes/(protected)/(user)/schedule/+page.server.ts +++ b/src/routes/(protected)/(user)/schedule/+page.server.ts @@ -1,11 +1,21 @@ import type { Actions, PageServerLoad } from './$types'; import { error, fail } from '@sveltejs/kit'; import { prisma } from '$lib/db'; -import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule'; +import { + createBlock, + deleteBlock, + getScheduleForLicense, + setScheduleType, + updateBlock +} from '$lib/server/schedule'; + +const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; function parseInput(formData: FormData) { const startMinutes = Number(formData.get('startMinutes')); const endMinutes = Number(formData.get('endMinutes')); + const dayRaw = formData.get('day')?.toString() ?? ''; + const day = WEEK_DAYS.includes(dayRaw) ? dayRaw : null; let proclamationIds: string[] = []; const raw = formData.get('proclamationIds'); if (typeof raw === 'string' && raw.length > 0) { @@ -19,16 +29,29 @@ function parseInput(formData: FormData) { } } const dynamicAudio = formData.get('dynamicAudio') === 'true'; - return { startMinutes, endMinutes, proclamationIds, dynamicAudio }; + return { day, startMinutes, endMinutes, proclamationIds, dynamicAudio }; } -export const load: PageServerLoad = async ({ locals }) => { +/** Ensure the given License Key belongs to the signed-in account. */ +async function ownedLicense(userId: string, licenseKeyId: string) { + const license = await prisma.licenseKey.findFirst({ + where: { id: licenseKeyId, userId }, + select: { id: true, key: true, systemName: true } + }); + return license; +} + +export const load: PageServerLoad = async ({ locals, url }) => { if (!locals.user?.id) { throw error(403, 'not authorized'); } - const [blocks, categories, groups, proclamations] = await Promise.all([ - listBlocks(locals.user.id), + const [licenses, categories, groups, proclamations] = await Promise.all([ + prisma.licenseKey.findMany({ + where: { userId: locals.user.id }, + orderBy: { createdAt: 'asc' }, + select: { id: true, key: true, systemName: true, active: true } + }), prisma.proclamationCategory.findMany({ select: { id: true, name: true }, orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] @@ -45,27 +68,75 @@ export const load: PageServerLoad = async ({ locals }) => { }) ]); - const blockViews = blocks.map((block) => ({ - id: block.id, - startMinutes: block.startMinutes, - endMinutes: block.endMinutes, - dynamicAudio: block.dynamicAudio, - proclamations: block.proclamations.map((entry) => ({ - id: entry.proclamation.id, - name: entry.proclamation.name, - categoryId: entry.proclamation.categoryId, - groupId: entry.proclamation.groupId - })) - })); + const requested = url.searchParams.get('license'); + const selected = licenses.find((l) => l.id === requested) ?? licenses[0] ?? null; - return { blocks: blockViews, categories, groups, proclamations }; + let type: 'daily' | 'weekly' | null = null; + let blocks: { + id: string; + day: string | null; + startMinutes: number; + endMinutes: number; + dynamicAudio: boolean; + proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[]; + }[] = []; + + if (selected) { + const schedule = await getScheduleForLicense(selected.id); + if (schedule) { + type = schedule.type === 'daily' || schedule.type === 'weekly' ? schedule.type : null; + blocks = schedule.blocks.map((block) => ({ + id: block.id, + day: block.day, + startMinutes: block.startMinutes, + endMinutes: block.endMinutes, + dynamicAudio: block.dynamicAudio, + proclamations: block.proclamations.map((entry) => ({ + id: entry.proclamation.id, + name: entry.proclamation.name, + categoryId: entry.proclamation.categoryId, + groupId: entry.proclamation.groupId + })) + })); + } + } + + return { + licenses, + selectedLicenseId: selected?.id ?? null, + type, + blocks, + categories, + groups, + proclamations + }; }; export const actions: Actions = { + setType: async ({ request, locals }) => { + if (!locals.user?.id) throw error(403, 'not authorized'); + const formData = await request.formData(); + const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? ''; + const rawType = formData.get('type')?.toString() ?? ''; + const type = rawType === 'daily' || rawType === 'weekly' ? rawType : null; + const confirm = formData.get('confirm') === 'true'; + + const license = await ownedLicense(locals.user.id, licenseKeyId); + if (!license) return fail(404, { error: 'System not found.' }); + + const result = await setScheduleType(licenseKeyId, type, { confirmed: confirm }); + if (!result.ok) return fail(400, { error: result.error }); + return { success: true }; + }, + createBlock: async ({ request, locals }) => { if (!locals.user?.id) throw error(403, 'not authorized'); - const input = parseInput(await request.formData()); - const result = await createBlock(locals.user.id, input); + const formData = await request.formData(); + const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? ''; + const license = await ownedLicense(locals.user.id, licenseKeyId); + if (!license) return fail(404, { error: 'System not found.' }); + + const result = await createBlock(licenseKeyId, parseInput(formData)); if (!result.ok) return fail(400, { error: result.error }); return { success: true }; }, @@ -73,18 +144,27 @@ export const actions: Actions = { updateBlock: async ({ request, locals }) => { if (!locals.user?.id) throw error(403, 'not authorized'); const formData = await request.formData(); + const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? ''; const id = formData.get('id')?.toString(); + const license = await ownedLicense(locals.user.id, licenseKeyId); + if (!license) return fail(404, { error: 'System not found.' }); if (!id) return fail(400, { error: 'Block not found.' }); - const result = await updateBlock(locals.user.id, id, parseInput(formData)); + + const result = await updateBlock(licenseKeyId, id, parseInput(formData)); if (!result.ok) return fail(400, { error: result.error }); return { success: true }; }, deleteBlock: async ({ request, locals }) => { if (!locals.user?.id) throw error(403, 'not authorized'); - const id = (await request.formData()).get('id')?.toString(); + const formData = await request.formData(); + const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? ''; + const id = formData.get('id')?.toString(); + const license = await ownedLicense(locals.user.id, licenseKeyId); + if (!license) return fail(404, { error: 'System not found.' }); if (!id) return fail(400, { error: 'Block not found.' }); - const result = await deleteBlock(locals.user.id, id); + + const result = await deleteBlock(licenseKeyId, id); if (!result.ok) return fail(400, { error: result.error }); return { success: true }; } diff --git a/src/routes/(protected)/(user)/schedule/page.server.spec.ts b/src/routes/(protected)/(user)/schedule/page.server.spec.ts index 4d6eab0..e289202 100644 --- a/src/routes/(protected)/(user)/schedule/page.server.spec.ts +++ b/src/routes/(protected)/(user)/schedule/page.server.spec.ts @@ -6,23 +6,33 @@ vi.mock('$lib/db', () => ({ prisma: { proclamationCategory: { findMany: vi.fn() }, proclamationGroup: { findMany: vi.fn() }, - proclamation: { findMany: vi.fn() } + proclamation: { findMany: vi.fn() }, + licenseKey: { findMany: vi.fn(), findFirst: vi.fn() } } })); vi.mock('$lib/server/schedule', () => ({ - listBlocks: vi.fn(), + getScheduleForLicense: vi.fn(), + setScheduleType: vi.fn(), createBlock: vi.fn(), updateBlock: vi.fn(), deleteBlock: vi.fn() })); import { prisma } from '$lib/db'; -import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule'; +import { + createBlock, + deleteBlock, + getScheduleForLicense, + setScheduleType +} from '$lib/server/schedule'; import { actions, load } from './+page.server'; const procFindMany = prisma.proclamation.findMany as ReturnType; -const listBlocksMock = listBlocks as ReturnType; +const licenseFindMany = prisma.licenseKey.findMany as ReturnType; +const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType; +const getScheduleMock = getScheduleForLicense as ReturnType; +const setTypeMock = setScheduleType as ReturnType; const createBlockMock = createBlock as ReturnType; const deleteBlockMock = deleteBlock as ReturnType; @@ -37,18 +47,24 @@ beforeEach(() => { { id: 1, name: 'General' } ]); procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]); - listBlocksMock.mockResolvedValue([]); + licenseFindMany.mockResolvedValue([ + { id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true } + ]); + licenseFindFirst.mockResolvedValue({ id: 'lk1' }); + getScheduleMock.mockResolvedValue(null); }); describe('schedule load', () => { it('rejects unauthenticated requests', async () => { - await expect(load({ locals: createLocals() } as never)).rejects.toMatchObject({ + await expect( + load({ locals: createLocals(), url: new URL('http://x/') } as never) + ).rejects.toMatchObject({ status: 403 }); }); it('never selects proclamation content', async () => { - await load({ locals } as never); + await load({ locals, url: new URL('http://x/') } as never); expect(procFindMany).toHaveBeenCalledWith( expect.objectContaining({ @@ -58,34 +74,37 @@ describe('schedule load', () => { }); it('maps blocks to title/category/group only', async () => { - listBlocksMock.mockResolvedValue([ - { - id: 'b1', - startMinutes: 360, - endMinutes: 480, - dynamicAudio: true, - proclamations: [ - { - proclamation: { - id: 'p1', - name: 'Peace', - categoryId: 2, - groupId: 1, - content: '

secret

' + getScheduleMock.mockResolvedValue({ + id: 's1', + type: 'daily', + blocks: [ + { + id: 'b1', + day: null, + startMinutes: 360, + endMinutes: 480, + dynamicAudio: true, + proclamations: [ + { + proclamation: { + id: 'p1', + name: 'Peace', + categoryId: 2, + groupId: 1, + content: '

secret

' + } } - } - ] - } - ]); + ] + } + ] + }); - const result = (await load({ locals } as never)) as { - blocks: { - id: string; - dynamicAudio: boolean; - proclamations: Record[]; - }[]; + const result = (await load({ locals, url: new URL('http://x/') } as never)) as { + type: string | null; + blocks: { id: string; dynamicAudio: boolean; proclamations: Record[] }[]; }; + expect(result.type).toBe('daily'); expect(result.blocks[0].dynamicAudio).toBe(true); expect(result.blocks[0].proclamations[0]).toEqual({ id: 'p1', @@ -112,6 +131,7 @@ describe('schedule actions', () => { const result = await actions.createBlock!({ request: createFormRequest({ + licenseKeyId: 'lk1', startMinutes: '360', endMinutes: '480', proclamationIds: '[]' @@ -131,6 +151,8 @@ describe('schedule actions', () => { const result = await actions.createBlock!({ request: createFormRequest({ + licenseKeyId: 'lk1', + day: 'mon', startMinutes: '360', endMinutes: '480', proclamationIds: JSON.stringify(['p1', 'p2']), @@ -140,7 +162,8 @@ describe('schedule actions', () => { } as never); expect(result).toEqual({ success: true }); - expect(createBlockMock).toHaveBeenCalledWith('user-1', { + expect(createBlockMock).toHaveBeenCalledWith('lk1', { + day: 'mon', startMinutes: 360, endMinutes: 480, proclamationIds: ['p1', 'p2'], @@ -148,27 +171,23 @@ describe('schedule actions', () => { }); }); - it('createBlock defaults dynamic audio to false when the field is absent', async () => { - createBlockMock.mockResolvedValue({ ok: true, id: 'b1' }); + it('setType requiring confirmation returns the confirm error', async () => { + setTypeMock.mockResolvedValue({ ok: false, error: 'confirm' }); - await actions.createBlock!({ - request: createFormRequest({ - startMinutes: '360', - endMinutes: '480', - proclamationIds: JSON.stringify(['p1']) - }), + const result = await actions.setType!({ + request: createFormRequest({ licenseKeyId: 'lk1', type: 'weekly' }), locals } as never); - expect(createBlockMock).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ dynamicAudio: false }) - ); + expect(isActionFailure(result)).toBe(true); + if (isActionFailure(result)) { + expect(result.data).toEqual({ error: 'confirm' }); + } }); it('deleteBlock returns not found without an id', async () => { const result = await actions.deleteBlock!({ - request: createFormRequest({}), + request: createFormRequest({ licenseKeyId: 'lk1' }), locals } as never); diff --git a/src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts b/src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts new file mode 100644 index 0000000..736903f --- /dev/null +++ b/src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts @@ -0,0 +1,48 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { prisma } from '$lib/db'; +import { clientIp, rateLimited } from '$lib/server/v2-helpers'; +import { rateLimit } from '$lib/server/v2-rate-limit'; +import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule'; +import { buildSchedulePayload, type V2ScheduleType } from '$lib/server/v2-serializers'; + +function scheduleType(value: string | null | undefined): V2ScheduleType { + return value === 'daily' || value === 'weekly' ? value : null; +} + +/** + * Kiosk-facing, key-only schedule read. Returns the target payload with an ETag + * (the content-derived `version`); a matching `If-None-Match` yields 304. An + * unknown License Key is 404; a key with no Schedule returns `type: null`. + */ +export const GET: RequestHandler = async ({ request, params }) => { + const ip = clientIp(request); + const rl = rateLimit({ key: `license-schedule:${ip}`, limit: 10, windowMs: 60_000 }); + if (!rl.ok) { + return rateLimited(rl.retryAfterMs); + } + + const licenseKey = (params.licenseKey ?? '').trim(); + const license = await prisma.licenseKey.findUnique({ + where: { key: licenseKey }, + select: { id: true } + }); + if (!license) { + throw error(404, 'unknown_license'); + } + + const schedule = await getScheduleForLicense(license.id); + const type = scheduleType(schedule?.type); + const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : []; + const payload = buildSchedulePayload(type, blocks); + const etag = `"${payload.version}"`; + + if (request.headers.get('if-none-match') === etag) { + return new Response(null, { status: 304, headers: { etag } }); + } + + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json', etag } + }); +}; diff --git a/src/routes/api/v2/licenses/[licenseKey]/schedule/server.spec.ts b/src/routes/api/v2/licenses/[licenseKey]/schedule/server.spec.ts new file mode 100644 index 0000000..0cd0f86 --- /dev/null +++ b/src/routes/api/v2/licenses/[licenseKey]/schedule/server.spec.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isHttpError } from '@sveltejs/kit'; +import { _clearRateLimits } from '$lib/server/v2-rate-limit'; + +vi.mock('$lib/db', () => ({ + prisma: { + licenseKey: { findUnique: vi.fn() } + } +})); + +vi.mock('$lib/server/schedule', () => ({ + getScheduleForLicense: vi.fn(), + scheduleBlocksToInput: vi.fn() +})); + +import { prisma } from '$lib/db'; +import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule'; +import { GET } from './+server'; + +type GETEvent = Parameters[0]; + +const mockFindUnique = prisma.licenseKey.findUnique as ReturnType; +const mockGetSchedule = getScheduleForLicense as ReturnType; +const mockToInput = scheduleBlocksToInput as ReturnType; + +const KEY = 'ABCD-EFGH-IJKL-MNOP'; + +function event(ifNoneMatch?: string): GETEvent { + const headers = new Headers(); + if (ifNoneMatch) headers.set('if-none-match', ifNoneMatch); + return { + request: new Request(`http://localhost/api/v2/licenses/${KEY}/schedule`, { headers }), + params: { licenseKey: KEY } + } as GETEvent; +} + +beforeEach(() => { + vi.clearAllMocks(); + _clearRateLimits(); +}); + +describe('GET /api/v2/licenses/[licenseKey]/schedule', () => { + it('returns 404 for an unknown license', async () => { + mockFindUnique.mockResolvedValue(null); + await expect(GET(event())).rejects.toSatisfy( + (e: unknown) => isHttpError(e) && e.status === 404 && e.body.message === 'unknown_license' + ); + }); + + it('returns type null with empty lists when there is no schedule', async () => { + mockFindUnique.mockResolvedValue({ id: 'lk1' }); + mockGetSchedule.mockResolvedValue(null); + + const res = await GET(event()); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.type).toBeNull(); + expect(body.daily).toEqual([]); + expect(body.blocks).toEqual([]); + expect(body.version).toBeTruthy(); + expect(res.headers.get('etag')).toBe(`"${body.version}"`); + }); + + it('returns the daily payload with an ETag', async () => { + mockFindUnique.mockResolvedValue({ id: 'lk1' }); + mockGetSchedule.mockResolvedValue({ type: 'daily' }); + mockToInput.mockReturnValue([ + { + id: 'b1', + day: null, + startMinutes: 360, + endMinutes: 420, + dynamicAudio: true, + proclamationIds: ['p1'] + } + ]); + + const res = await GET(event()); + expect(res.status).toBe(200); + const body = (await res.json()) as { type: string; daily: unknown[]; blocks: unknown[] }; + expect(body.type).toBe('daily'); + expect(body.daily).toHaveLength(1); + expect(body.blocks).toHaveLength(1); + }); + + it('returns 304 when If-None-Match matches the ETag', async () => { + mockFindUnique.mockResolvedValue({ id: 'lk1' }); + mockGetSchedule.mockResolvedValue(null); + + const first = await GET(event()); + const etag = first.headers.get('etag') as string; + const second = await GET(event(etag)); + + expect(second.status).toBe(304); + expect(second.headers.get('etag')).toBe(etag); + }); + + it('returns 200 when If-None-Match does not match', async () => { + mockFindUnique.mockResolvedValue({ id: 'lk1' }); + mockGetSchedule.mockResolvedValue(null); + + const res = await GET(event('"stale"')); + expect(res.status).toBe(200); + }); +}); diff --git a/src/routes/api/v2/schedule/+server.ts b/src/routes/api/v2/schedule/+server.ts index da4d212..b0eff46 100644 --- a/src/routes/api/v2/schedule/+server.ts +++ b/src/routes/api/v2/schedule/+server.ts @@ -3,16 +3,24 @@ import type { RequestHandler } from './$types'; import { prisma } from '$lib/db'; import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers'; import { rateLimit } from '$lib/server/v2-rate-limit'; -import { listBlocks } from '$lib/server/schedule'; -import { serializeScheduleBlock } from '$lib/server/v2-serializers'; +import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule'; +import { buildSchedulePayload } from '$lib/server/v2-serializers'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +function scheduleType(value: string | null | undefined) { + return value === 'daily' || value === 'weekly' ? value : null; +} + /** * Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with * `email` + `licenseKey` query parameters and validates only that the pair * belong together — license active/entitlement is deliberately not checked * here, since an invalid license would already lock the kiosk out. + * + * Extended additively with `version`, `type`, `daily`, and `weekly`. `email` + * and `blocks` keep their exact previous meaning (daily flattening; empty for + * weekly), so old Kiosks are unaffected. No ETag/304 here. */ export const GET: RequestHandler = async ({ request, url }) => { const ip = clientIp(request); @@ -50,14 +58,10 @@ export const GET: RequestHandler = async ({ request, url }) => { throw error(400, "this key doesn't appear to belong to you"); } - const blocks = await listBlocks(user.id); + const schedule = await getScheduleForLicense(storedKey.id); + const type = scheduleType(schedule?.type); + const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : []; + const payload = buildSchedulePayload(type, blocks); - const serialized = blocks.map((block) => - serializeScheduleBlock( - block, - block.proclamations.map((entry) => entry.proclamation.id) - ) - ); - - return jsonOk({ email, blocks: serialized }); + return jsonOk({ email, ...payload }); }; diff --git a/src/routes/api/v2/schedule/schedule.spec.ts b/src/routes/api/v2/schedule/schedule.spec.ts index cf26dee..228e8ae 100644 --- a/src/routes/api/v2/schedule/schedule.spec.ts +++ b/src/routes/api/v2/schedule/schedule.spec.ts @@ -3,19 +3,24 @@ import { _clearRateLimits } from '$lib/server/v2-rate-limit'; vi.mock('$lib/db', () => ({ prisma: { - schedule: { findUnique: vi.fn(), create: vi.fn() }, - block: { findMany: vi.fn() }, licenseKey: { findUnique: vi.fn() }, user: { findUnique: vi.fn() } } })); +vi.mock('$lib/server/schedule', () => ({ + getScheduleForLicense: vi.fn(), + scheduleBlocksToInput: vi.fn() +})); + import { prisma } from '$lib/db'; +import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule'; import { GET } from './+server'; const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType; const userFindUnique = prisma.user.findUnique as ReturnType; -const blockFindMany = prisma.block.findMany as ReturnType; +const getScheduleMock = getScheduleForLicense as ReturnType; +const toInputMock = scheduleBlocksToInput as ReturnType; function call(params: Record) { const url = new URL('http://localhost/api/v2/schedule'); @@ -29,7 +34,8 @@ const KEY = 'AAAA-BBBB-CCCC-DDDD'; beforeEach(() => { vi.clearAllMocks(); _clearRateLimits(); - blockFindMany.mockResolvedValue([]); + getScheduleMock.mockResolvedValue(null); + toInputMock.mockReturnValue([]); }); describe('GET /api/v2/schedule auth', () => { @@ -50,7 +56,7 @@ describe('GET /api/v2/schedule auth', () => { }); it('rejects an unknown user', async () => { - licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null }); + licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null }); userFindUnique.mockResolvedValue(null); await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({ @@ -60,7 +66,12 @@ describe('GET /api/v2/schedule auth', () => { }); it('rejects a key that does not belong to the email account', async () => { - licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'someone-else', email: null }); + licenseKeyFindUnique.mockResolvedValue({ + id: 'lk1', + key: KEY, + userId: 'someone-else', + email: null + }); userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' }); await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({ @@ -88,52 +99,62 @@ describe('GET /api/v2/schedule auth', () => { describe('GET /api/v2/schedule success', () => { it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => { licenseKeyFindUnique.mockResolvedValue({ + id: 'lk1', key: KEY, userId: 'user-1', email: null, active: false }); userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' }); - blockFindMany.mockResolvedValue([ + getScheduleMock.mockResolvedValue({ type: 'daily' }); + toInputMock.mockReturnValue([ { id: 'block-1', + day: null, startMinutes: 21 * 60, endMinutes: 9 * 60, dynamicAudio: true, - proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }] + proclamationIds: ['p1', 'p2'] } ]); const response = await call({ email: 'jane@example.com', licenseKey: KEY }); expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - email: 'jane@example.com', - blocks: [ - { - id: 'block-1', - startTime: '21:00', - startMinutes: 1260, - endTime: '09:00', - endMinutes: 540, - overnight: true, - durationMinutes: 720, - dynamicAudio: true, - proclamationIds: ['p1', 'p2'] - } - ] - }); + const body = (await response.json()) as { email: string; blocks: unknown[]; type: string }; + expect(body.email).toBe('jane@example.com'); + expect(body.blocks).toEqual([ + { + id: 'block-1', + startTime: '21:00', + startMinutes: 1260, + endTime: '09:00', + endMinutes: 540, + overnight: true, + durationMinutes: 720, + dynamicAudio: true, + proclamationIds: ['p1', 'p2'] + } + ]); + // Additive fields for new Kiosks. + expect(body.type).toBe('daily'); + expect(body).toHaveProperty('version'); + expect(body).toHaveProperty('daily'); + expect(body).toHaveProperty('weekly'); }); it('returns IDs for every selected proclamation regardless of frequency', async () => { - licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null }); + licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null }); userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' }); - blockFindMany.mockResolvedValue([ + getScheduleMock.mockResolvedValue({ type: 'daily' }); + toInputMock.mockReturnValue([ { id: 'block-1', + day: null, startMinutes: 0, endMinutes: 60, - proclamations: [{ proclamation: { id: 'p1' } }] + dynamicAudio: false, + proclamationIds: ['p1'] } ]); @@ -157,6 +178,7 @@ describe('GET /api/v2/schedule success', () => { it('accepts the legacy `key` query alias and an email-only license', async () => { licenseKeyFindUnique.mockResolvedValue({ + id: 'lk1', key: KEY, userId: null, email: 'jane@example.com' @@ -166,6 +188,9 @@ describe('GET /api/v2/schedule success', () => { const response = await call({ email: 'jane@example.com', key: KEY }); expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ email: 'jane@example.com', blocks: [] }); + const body = (await response.json()) as { email: string; blocks: unknown[]; type: null }; + expect(body.email).toBe('jane@example.com'); + expect(body.blocks).toEqual([]); + expect(body.type).toBeNull(); }); });