From 68a979f1a118b4b57a87dd3564d71c61cfd981c0 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 10:49:06 -0500 Subject: [PATCH] Portal: transactional schedule copy service copySchedule copies one License Key's Schedule to one or more other keys by value in a single Prisma transaction (all targets or none), assigning fresh Block IDs and preserving each block's dynamicAudio and Proclamation order. Source/targets are scoped to the caller's account; self-copy and empty target lists are rejected. The resulting schedule is run through the shared validator before commit. Adds tests: daily/weekly copy, overwrite, multiple targets, rollback, cross-account rejection, new IDs, changed version/ETag, copy independence, and Kiosk-payload parity with the shared fixtures. --- src/lib/server/schedule-copy.spec.ts | 414 +++++++++++++++++++++++++++ src/lib/server/schedule.ts | 135 +++++++++ 2 files changed, 549 insertions(+) create mode 100644 src/lib/server/schedule-copy.spec.ts diff --git a/src/lib/server/schedule-copy.spec.ts b/src/lib/server/schedule-copy.spec.ts new file mode 100644 index 0000000..6ae91d2 --- /dev/null +++ b/src/lib/server/schedule-copy.spec.ts @@ -0,0 +1,414 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// In-memory Prisma double shared by the service and the assertions. It supports +// just the operations `copySchedule` uses, plus a shared store so we can read +// back copies and compute their Kiosk-facing payloads/ETags. It is created via +// vi.hoisted so the `vi.mock` factory (which is hoisted above imports) can see +// it. +const store = vi.hoisted(() => { + type Block = { + id: string; + scheduleId: string; + day: string | null; + startMinutes: number; + endMinutes: number; + dynamicAudio: boolean; + proclamations: { proclamation: { id: string }; sortOrder: number }[]; + }; + type Schedule = { id: string; licenseKeyId: string | null; type: string | null }; + + const state = { + schedules: [] as Schedule[], + blocks: [] as Block[], + idCounter: 0, + failOnBlockCreate: false + }; + + function nextId(prefix: string) { + state.idCounter += 1; + return `${prefix}-${state.idCounter}`; + } + + const tx = { + schedule: { + findUnique: async ({ where }: { where: { licenseKeyId?: string; id?: string } }) => { + if (where.licenseKeyId !== undefined) { + return state.schedules.find((s) => s.licenseKeyId === where.licenseKeyId) ?? null; + } + return state.schedules.find((s) => s.id === where.id) ?? null; + }, + create: async ({ data }: { data: { licenseKeyId: string; type: string | null } }) => { + const row: Schedule = { id: nextId('sched'), ...data }; + state.schedules.push(row); + return row; + }, + update: async ({ where, data }: { where: { id: string }; data: Partial }) => { + const row = state.schedules.find((s) => s.id === where.id); + if (!row) throw new Error('missing schedule'); + Object.assign(row, data); + return row; + } + }, + block: { + deleteMany: async ({ where }: { where: { scheduleId: string } }) => { + state.blocks = state.blocks.filter((b) => b.scheduleId !== where.scheduleId); + return { count: 0 }; + }, + create: async ({ data }: { data: Record }) => { + if (state.failOnBlockCreate) throw new Error('db down'); + const { proclamations, ...rest } = data as { + proclamations: { create: { proclamationId: string; sortOrder: number }[] }; + } & Record; + const row: Block = { + id: nextId('block'), + scheduleId: rest.scheduleId as string, + day: (rest.day as string | null) ?? null, + startMinutes: rest.startMinutes as number, + endMinutes: rest.endMinutes as number, + dynamicAudio: rest.dynamicAudio as boolean, + proclamations: proclamations.create.map((p) => ({ + proclamation: { id: p.proclamationId }, + sortOrder: p.sortOrder + })) + }; + state.blocks.push(row); + return row; + } + } + }; + + const transaction = vi.fn(async (fn: (txArg: unknown) => Promise) => { + const scheduleSnapshot = state.schedules.map((s) => ({ ...s })); + const blockSnapshot = state.blocks.map((b) => ({ ...b, proclamations: [...b.proclamations] })); + try { + await fn(tx); + } catch (error) { + state.schedules = scheduleSnapshot; + state.blocks = blockSnapshot; + throw error; + } + }); + + const scheduleFindUnique = vi.fn(); + + return { state, tx, transaction, scheduleFindUnique, nextId }; +}); + +vi.mock('$lib/db', () => ({ + prisma: { + schedule: { findUnique: store.scheduleFindUnique }, + $transaction: store.transaction + } +})); + +import { buildSchedulePayload } from './v2-serializers'; +import { copySchedule, scheduleBlocksToInput, type OwnedLicense } from './schedule'; + +const owned: OwnedLicense[] = [ + { id: 'lk-src', systemName: 'Living Room', key: 'AAAA-AAAA-AAAA-AAAA' }, + { id: 'lk-t1', systemName: 'Kitchen', key: 'BBBB-BBBB-BBBB-BBBB' }, + { id: 'lk-t2', systemName: null, key: 'CCCC-CCCC-CCCC-CCCC' } +]; + +/** Seed the source schedule and a full block list. */ +function seedSource( + type: 'daily' | 'weekly', + blockSpecs: Partial<(typeof state.blocks)[number]>[] +) { + store.state.schedules = [{ id: 'sched-src', licenseKeyId: 'lk-src', type }]; + store.state.blocks = blockSpecs.map((spec, i) => ({ + id: `src-block-${i}`, + scheduleId: 'sched-src', + day: spec.day ?? null, + startMinutes: spec.startMinutes ?? 0, + endMinutes: spec.endMinutes ?? 60, + dynamicAudio: spec.dynamicAudio ?? true, + proclamations: spec.proclamations ?? [{ proclamation: { id: 'p1' }, sortOrder: 0 }] + })); + store.scheduleFindUnique.mockImplementation( + async ({ where }: { where: { licenseKeyId: string } }) => { + if (where.licenseKeyId !== 'lk-src') return null; + return { + ...store.state.schedules.find((s) => s.licenseKeyId === 'lk-src'), + blocks: store.state.blocks + .filter((b) => b.scheduleId === 'sched-src') + .map((b) => ({ ...b, proclamations: b.proclamations })) + }; + } + ); +} + +const state = store.state; + +function payloadFor(licenseKeyId: string) { + const schedule = state.schedules.find((s) => s.licenseKeyId === licenseKeyId) ?? null; + const scheduleBlocks = state.blocks.filter((b) => b.scheduleId === schedule?.id); + return buildSchedulePayload( + schedule?.type === 'daily' || schedule?.type === 'weekly' ? schedule.type : null, + scheduleBlocksToInput( + scheduleBlocks.map((b) => ({ + id: b.id, + day: b.day, + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + dynamicAudio: b.dynamicAudio, + proclamations: b.proclamations.sort((x, y) => x.sortOrder - y.sortOrder).map((p) => p) + })) + ) + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + state.idCounter = 0; + state.failOnBlockCreate = false; + state.schedules = []; + state.blocks = []; +}); + +describe('copySchedule', () => { + it('copies a daily schedule and changes the target version', async () => { + seedSource('daily', [ + { startMinutes: 360, endMinutes: 480, dynamicAudio: true }, + { startMinutes: 1080, endMinutes: 1230, dynamicAudio: false } + ]); + const before = payloadFor('lk-t1'); // no schedule yet -> null payload + + const result = await copySchedule(owned, 'lk-src', ['lk-t1']); + + expect(result.ok).toBe(true); + const after = payloadFor('lk-t1'); + expect(after.type).toBe('daily'); + expect(after.daily).toHaveLength(2); + expect(after.daily[0].dynamicAudio).toBe(true); + expect(after.daily[1].dynamicAudio).toBe(false); + expect(after.version).not.toBe(before.version); + }); + + it('copies a weekly schedule preserving day layout', async () => { + seedSource('weekly', [ + { day: 'mon', startMinutes: 360, endMinutes: 480 }, + { day: 'sun', startMinutes: 1380, endMinutes: 60 } + ]); + + const result = await copySchedule(owned, 'lk-src', ['lk-t1']); + + expect(result.ok).toBe(true); + const after = payloadFor('lk-t1'); + expect(after.type).toBe('weekly'); + expect(Object.keys(after.weekly).sort()).toEqual([ + 'fri', + 'mon', + 'sat', + 'sun', + 'thu', + 'tue', + 'wed' + ]); + expect(after.weekly.mon).toHaveLength(1); + expect(after.weekly.sun).toHaveLength(1); + expect(after.weekly.tue).toEqual([]); + expect(after.blocks).toEqual([]); + }); + + it('overwrites an existing target schedule', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + state.schedules.push({ id: 'sched-t1', licenseKeyId: 'lk-t1', type: 'daily' }); + state.blocks.push({ + id: 'old-block', + scheduleId: 'sched-t1', + day: null, + startMinutes: 1000, + endMinutes: 1030, + dynamicAudio: false, + proclamations: [{ proclamation: { id: 'old' }, sortOrder: 0 }] + }); + + const result = await copySchedule(owned, 'lk-src', ['lk-t1']); + + expect(result.ok).toBe(true); + const target = state.blocks.filter((b) => b.scheduleId === 'sched-t1'); + expect(target).toHaveLength(1); + expect(target[0].startMinutes).toBe(360); + expect(target[0].proclamations).toEqual([{ proclamation: { id: 'p1' }, sortOrder: 0 }]); + }); + + it('copies to multiple targets', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + + const result = await copySchedule(owned, 'lk-src', ['lk-t1', 'lk-t2']); + + expect(result).toMatchObject({ ok: true, copied: 2 }); + expect(payloadFor('lk-t1').daily).toHaveLength(1); + expect(payloadFor('lk-t2').daily).toHaveLength(1); + }); + + it('rolls back every target when one fails', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + state.failOnBlockCreate = true; + + const result = await copySchedule(owned, 'lk-src', ['lk-t1', 'lk-t2']); + + expect(result.ok).toBe(false); + // No target schedule was left behind. + expect(state.schedules.find((s) => s.licenseKeyId === 'lk-t1')).toBeUndefined(); + expect(state.schedules.find((s) => s.licenseKeyId === 'lk-t2')).toBeUndefined(); + expect(state.blocks.filter((b) => b.scheduleId !== 'sched-src')).toHaveLength(0); + }); + + it('rejects a target outside the account', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + const result = await copySchedule(owned, 'lk-src', ['lk-other']); + expect(result).toEqual({ ok: false, error: 'Target system not found.' }); + expect(store.transaction).not.toHaveBeenCalled(); + }); + + it('rejects a target equal to the source', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + const result = await copySchedule(owned, 'lk-src', ['lk-src']); + expect(result.ok).toBe(false); + expect(store.transaction).not.toHaveBeenCalled(); + }); + + it('rejects a source outside the account', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + const result = await copySchedule(owned, 'lk-other', ['lk-t1']); + expect(result).toEqual({ ok: false, error: 'Source system not found.' }); + }); + + it('rejects a source with no schedule', async () => { + state.schedules = []; + state.blocks = []; + store.scheduleFindUnique.mockResolvedValue(null); + const result = await copySchedule(owned, 'lk-src', ['lk-t1']); + expect(result).toEqual({ ok: false, error: 'The source system has no schedule to copy.' }); + }); + + it('assigns new Block IDs on the copy', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + await copySchedule(owned, 'lk-src', ['lk-t1']); + + const copied = state.blocks.filter((b) => b.scheduleId !== 'sched-src'); + expect(copied).toHaveLength(1); + expect(copied[0].id).not.toBe('src-block-0'); + expect(copied[0].id).toMatch(/^block-/); + }); + + it('produces independent copies (source edits do not affect targets)', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + await copySchedule(owned, 'lk-src', ['lk-t1']); + const targetVersion = payloadFor('lk-t1').version; + + // Edit the source in place. + state.blocks = state.blocks.map((b) => + b.scheduleId === 'sched-src' ? { ...b, endMinutes: 999 } : b + ); + + expect(payloadFor('lk-t1').version).toBe(targetVersion); + expect(payloadFor('lk-src').version).not.toBe(targetVersion); + }); + + it('rejects a target list with no targets', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + const result = await copySchedule(owned, 'lk-src', []); + expect(result).toEqual({ ok: false, error: 'Pick at least one system to copy to.' }); + }); + + it('changes the target ETag to match the served payload version', async () => { + seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]); + await copySchedule(owned, 'lk-src', ['lk-t1']); + + const payload = payloadFor('lk-t1'); + const etag = `"${payload.version}"`; + const expected = `"${createHash('sha1') + .update(JSON.stringify({ type: payload.type, daily: payload.daily, weekly: payload.weekly })) + .digest('hex')}"`; + expect(etag).toBe(expected); + }); +}); + +describe('copied payload is consumed like any Kiosk schedule', () => { + const readFixture = (name: string) => + JSON.parse(readFileSync(resolve(process.cwd(), 'docs/fixtures', name), 'utf8')); + + it('matches the daily fixture shape after copy (IDs differ)', async () => { + const fixture = readFixture('schedule-daily.json'); + // Seed the source from the daily fixture content. + seedSource( + 'daily', + fixture.daily.map((b: Record) => ({ + day: null, + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + dynamicAudio: b.dynamicAudio, + proclamations: (b.proclamationIds as string[]).map((id, i) => ({ + proclamation: { id }, + sortOrder: i + })) + })) + ); + + const result = await copySchedule(owned, 'lk-src', ['lk-t1']); + expect(result.ok).toBe(true); + + const payload = payloadFor('lk-t1'); + expect(payload.type).toBe('daily'); + // Same times/audio/ids as the fixture; only block IDs differ. + expect( + payload.daily.map((b) => ({ + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + dynamicAudio: b.dynamicAudio, + proclamationIds: b.proclamationIds + })) + ).toEqual( + fixture.daily.map((b: Record) => ({ + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + dynamicAudio: b.dynamicAudio, + proclamationIds: b.proclamationIds + })) + ); + expect(payload.blocks).toEqual(payload.daily); + }); + + it('matches the weekly fixture day layout after copy', async () => { + const fixture = readFixture('schedule-weekly.json'); + const specs: Record[] = []; + for (const [day, list] of Object.entries( + fixture.weekly as Record[]> + )) { + for (const b of list) { + specs.push({ + day, + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + dynamicAudio: b.dynamicAudio, + proclamations: (b.proclamationIds as string[]).map((id, i) => ({ + proclamation: { id }, + sortOrder: i + })) + }); + } + } + seedSource('weekly', specs); + + await copySchedule(owned, 'lk-src', ['lk-t1']); + const payload = payloadFor('lk-t1'); + + expect(payload.type).toBe('weekly'); + expect(payload.blocks).toEqual([]); + const nonEmptyDays = Object.entries(payload.weekly) + .filter(([, list]) => list.length > 0) + .map(([day]) => day) + .sort(); + const fixtureDays = Object.entries(fixture.weekly as Record) + .filter(([, list]) => list.length > 0) + .map(([day]) => day) + .sort(); + expect(nonEmptyDays).toEqual(fixtureDays); + }); +}); diff --git a/src/lib/server/schedule.ts b/src/lib/server/schedule.ts index 8ee5eb8..7a9e4db 100644 --- a/src/lib/server/schedule.ts +++ b/src/lib/server/schedule.ts @@ -371,3 +371,138 @@ export function scheduleBlocksToInput( proclamationIds: block.proclamations.map((entry) => entry.proclamation.id) })); } + +// ─── Copy a Schedule between License Keys ────────────────────────────── + +export type CopyScheduleResult = + | { ok: true; copied: number; targets: { id: string; label: string }[] } + | { ok: false; error: string }; + +/** A License Key row as needed for ownership checks and labels. */ +export type OwnedLicense = { id: string; systemName: string | null; key: string }; + +function licenseLabel(license: { systemName: string | null; key: string }): string { + return license.systemName ?? license.key; +} + +/** + * Copy one License Key's Schedule to one or more other License Keys, by value, + * in a single transaction: all targets succeed or none does. Targets get the + * source's `type` and fresh Block IDs (so every target's content-derived + * `version`/ETag changes and its Kiosk refetches). `dynamicAudio` and + * Proclamation references are preserved. + * + * `ownedLicenses` must be the caller's account License Keys; the source and + * every target are checked against it. The source must have a Schedule with a + * non-null type. Each resulting Schedule is validated before commit. + */ +export async function copySchedule( + ownedLicenses: OwnedLicense[], + sourceId: string, + targetIds: string[] +): Promise { + const byId = new Map(ownedLicenses.map((l) => [l.id, l])); + const source = byId.get(sourceId); + if (!source) return { ok: false, error: 'Source system not found.' }; + + const uniqueTargets = [...new Set(targetIds)].filter((id) => id.length > 0); + if (uniqueTargets.length === 0) { + return { ok: false, error: 'Pick at least one system to copy to.' }; + } + if (uniqueTargets.some((id) => id === sourceId)) { + return { ok: false, error: "A schedule can't be copied onto its own system." }; + } + for (const id of uniqueTargets) { + if (!byId.has(id)) return { ok: false, error: 'Target system not found.' }; + } + + const sourceSchedule = await prisma.schedule.findUnique({ + where: { licenseKeyId: sourceId }, + include: { blocks: { include: blockInclude } } + }); + const sourceType: ScheduleType | null = + sourceSchedule && isScheduleType(sourceSchedule.type) ? sourceSchedule.type : null; + if (!sourceSchedule || sourceType === null) { + return { ok: false, error: 'The source system has no schedule to copy.' }; + } + + // Snapshot the source content (order preserved). + const sourceBlocks = sourceSchedule.blocks.map((block) => ({ + day: block.day, + startMinutes: block.startMinutes, + endMinutes: block.endMinutes, + dynamicAudio: block.dynamicAudio, + proclamationIds: block.proclamations + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((entry) => entry.proclamation.id) + })); + + // Validate the resulting schedule once (it is identical for every target). + const validation = validateSchedule( + sourceType, + sourceBlocks.map((b) => + toValidationBlock({ + day: b.day, + startMinutes: b.startMinutes, + endMinutes: b.endMinutes, + proclamationIds: b.proclamationIds + }) + ) + ); + if (!validation.ok) { + return { ok: false, error: `The source schedule is invalid: ${validation.error}` }; + } + + try { + await prisma.$transaction(async (tx) => { + for (const targetId of uniqueTargets) { + // Upsert the target Schedule, replacing its type. + const existing = await tx.schedule.findUnique({ where: { licenseKeyId: targetId } }); + let scheduleId: string; + if (existing) { + await tx.block.deleteMany({ where: { scheduleId: existing.id } }); + await tx.schedule.update({ + where: { id: existing.id }, + data: { type: sourceType } + }); + scheduleId = existing.id; + } else { + const created = await tx.schedule.create({ + data: { licenseKeyId: targetId, type: sourceType } + }); + scheduleId = created.id; + } + + // New Block IDs per target; Proclamation refs + dynamicAudio kept. + for (const block of sourceBlocks) { + await tx.block.create({ + data: { + scheduleId, + day: block.day, + startMinutes: block.startMinutes, + endMinutes: block.endMinutes, + dynamicAudio: block.dynamicAudio, + proclamations: { + create: block.proclamationIds.map((proclamationId, index) => ({ + proclamationId, + sortOrder: index + })) + } + } + }); + } + } + }); + } catch { + return { ok: false, error: 'Copy failed; no systems were changed.' }; + } + + return { + ok: true, + copied: uniqueTargets.length, + targets: uniqueTargets.map((id) => { + const license = byId.get(id) as OwnedLicense; + return { id, label: licenseLabel(license) }; + }) + }; +}