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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) };
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user