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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+106
-79
@@ -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<typeof vi.fn>;
|
||||
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
|
||||
const scheduleUpdate = prisma.schedule.update as ReturnType<typeof vi.fn>;
|
||||
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
|
||||
const blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
|
||||
const blockCreate = prisma.block.create as ReturnType<typeof vi.fn>;
|
||||
const blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
|
||||
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
|
||||
const blockDeleteMany = prisma.block.deleteMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
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' } });
|
||||
|
||||
+279
-45
@@ -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<TypeMutationResult> {
|
||||
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<BlockMutationResult> {
|
||||
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<BlockMutationResult & { scheduleId?: string; type?: ScheduleType | null }> {
|
||||
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<BlockMutationResult> {
|
||||
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<Bl
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true, id: block.id };
|
||||
}
|
||||
|
||||
export async function updateBlock(
|
||||
userId: string,
|
||||
licenseKeyId: string,
|
||||
blockId: string,
|
||||
input: BlockInput
|
||||
): Promise<BlockMutationResult> {
|
||||
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<BlockMutationResult> {
|
||||
export async function deleteBlock(
|
||||
licenseKeyId: string,
|
||||
blockId: string
|
||||
): Promise<BlockMutationResult> {
|
||||
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<Bloc
|
||||
await prisma.block.delete({ where: { id: blockId } });
|
||||
return { ok: true, id: blockId };
|
||||
}
|
||||
|
||||
/** Validate a whole schedule payload (used by tests and the endpoint). */
|
||||
export function validateScheduleBlocks(
|
||||
type: ScheduleType,
|
||||
blocks: ValidationBlock[]
|
||||
): ReturnType<typeof validateSchedule> {
|
||||
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)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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<typeof pick>[]) =>
|
||||
[...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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, V2ScheduleBlock[]>;
|
||||
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<string, V2ScheduleBlock[]> = {};
|
||||
|
||||
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 : []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
const listBlocksMock = listBlocks as ReturnType<typeof vi.fn>;
|
||||
const licenseFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
|
||||
const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
||||
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||
const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>;
|
||||
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
|
||||
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -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: '<p>secret</p>'
|
||||
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: '<p>secret</p>'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const result = (await load({ locals } as never)) as {
|
||||
blocks: {
|
||||
id: string;
|
||||
dynamicAudio: boolean;
|
||||
proclamations: Record<string, unknown>[];
|
||||
}[];
|
||||
const result = (await load({ locals, url: new URL('http://x/') } as never)) as {
|
||||
type: string | null;
|
||||
blocks: { id: string; dynamicAudio: boolean; proclamations: Record<string, unknown>[] }[];
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
};
|
||||
@@ -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<typeof GET>[0];
|
||||
|
||||
const mockFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
||||
const mockGetSchedule = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||
const mockToInput = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
|
||||
|
||||
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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
|
||||
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||
const toInputMock = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
|
||||
|
||||
function call(params: Record<string, string>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user