Dev #1
@@ -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)
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 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