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:
NeilClack
2026-09-29 10:23:10 -05:00
parent 138bf03ea9
commit 3bb8e0189b
12 changed files with 1285 additions and 231 deletions
+106 -79
View File
@@ -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
View File
@@ -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)
}));
}
+169
View File
@@ -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);
});
});
+65
View File
@@ -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 : []
};
}