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.
This commit is contained in:
NeilClack
2026-09-29 10:49:06 -05:00
parent f6c937b892
commit 68a979f1a1
2 changed files with 549 additions and 0 deletions
+414
View File
@@ -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<Schedule> }) => {
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<string, unknown> }) => {
if (state.failOnBlockCreate) throw new Error('db down');
const { proclamations, ...rest } = data as {
proclamations: { create: { proclamationId: string; sortOrder: number }[] };
} & Record<string, unknown>;
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<void>) => {
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<string, unknown>) => ({
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<string, unknown>) => ({
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<string, unknown>[] = [];
for (const [day, list] of Object.entries(
fixture.weekly as Record<string, Record<string, unknown>[]>
)) {
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<string, unknown[]>)
.filter(([, list]) => list.length > 0)
.map(([day]) => day)
.sort();
expect(nonEmptyDays).toEqual(fixtureDays);
});
});
+135
View File
@@ -371,3 +371,138 @@ export function scheduleBlocksToInput(
proclamationIds: block.proclamations.map((entry) => entry.proclamation.id) 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<CopyScheduleResult> {
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) };
})
};
}