Portal: copy action and hasSchedule flag in the schedule loader

Adds a session-authenticated copy action that scopes the source and targets
to the caller's License Keys and returns a summary of the systems updated.
The loader now reports hasSchedule per system so the Copy UI can warn before
overwriting. Adds action tests (auth, cross-account, summary, error).
This commit is contained in:
NeilClack
2026-09-29 10:49:10 -05:00
parent 68a979f1a1
commit 242be5d241
2 changed files with 127 additions and 4 deletions
@@ -2,6 +2,7 @@ import type { Actions, PageServerLoad } from './$types';
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import { prisma } from '$lib/db'; import { prisma } from '$lib/db';
import { import {
copySchedule,
createBlock, createBlock,
deleteBlock, deleteBlock,
getScheduleForLicense, getScheduleForLicense,
@@ -68,8 +69,23 @@ export const load: PageServerLoad = async ({ locals, url }) => {
}) })
]); ]);
// Which License Keys already have a (typed) Schedule — used by the Copy UI
// to warn before overwriting a target.
const typedSchedules = await prisma.schedule.findMany({
where: { licenseKeyId: { in: licenses.map((l) => l.id) }, type: { not: null } },
select: { licenseKeyId: true }
});
const scheduledIds = new Set(typedSchedules.map((s) => s.licenseKeyId));
const licenseViews = licenses.map((l) => ({
id: l.id,
key: l.key,
systemName: l.systemName,
active: l.active,
hasSchedule: scheduledIds.has(l.id)
}));
const requested = url.searchParams.get('license'); const requested = url.searchParams.get('license');
const selected = licenses.find((l) => l.id === requested) ?? licenses[0] ?? null; const selected = licenseViews.find((l) => l.id === requested) ?? licenseViews[0] ?? null;
let type: 'daily' | 'weekly' | null = null; let type: 'daily' | 'weekly' | null = null;
let blocks: { let blocks: {
@@ -102,7 +118,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
} }
return { return {
licenses, licenses: licenseViews,
selectedLicenseId: selected?.id ?? null, selectedLicenseId: selected?.id ?? null,
type, type,
blocks, blocks,
@@ -167,5 +183,34 @@ export const actions: Actions = {
const result = await deleteBlock(licenseKeyId, id); const result = await deleteBlock(licenseKeyId, id);
if (!result.ok) return fail(400, { error: result.error }); if (!result.ok) return fail(400, { error: result.error });
return { success: true }; return { success: true };
},
copy: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const sourceId = formData.get('sourceLicenseKeyId')?.toString() ?? '';
const targetIds = formData
.getAll('targetIds')
.map((v) => v.toString())
.filter((id) => id.length > 0);
// Only the caller's own systems are eligible; ownership is enforced by
// scoping the lookup to the session user.
const owned = await prisma.licenseKey.findMany({
where: { userId: locals.user.id },
select: { id: true, systemName: true, key: true }
});
if (!owned.some((l) => l.id === sourceId)) {
return fail(404, { error: 'System not found.' });
}
const result = await copySchedule(owned, sourceId, targetIds);
if (!result.ok) return fail(400, { error: result.error });
return {
success: true,
action: 'copy',
copied: result.copied,
targets: result.targets
};
} }
}; };
@@ -7,7 +7,8 @@ vi.mock('$lib/db', () => ({
proclamationCategory: { findMany: vi.fn() }, proclamationCategory: { findMany: vi.fn() },
proclamationGroup: { findMany: vi.fn() }, proclamationGroup: { findMany: vi.fn() },
proclamation: { findMany: vi.fn() }, proclamation: { findMany: vi.fn() },
licenseKey: { findMany: vi.fn(), findFirst: vi.fn() } licenseKey: { findMany: vi.fn(), findFirst: vi.fn() },
schedule: { findMany: vi.fn() }
} }
})); }));
@@ -16,11 +17,13 @@ vi.mock('$lib/server/schedule', () => ({
setScheduleType: vi.fn(), setScheduleType: vi.fn(),
createBlock: vi.fn(), createBlock: vi.fn(),
updateBlock: vi.fn(), updateBlock: vi.fn(),
deleteBlock: vi.fn() deleteBlock: vi.fn(),
copySchedule: vi.fn()
})); }));
import { prisma } from '$lib/db'; import { prisma } from '$lib/db';
import { import {
copySchedule,
createBlock, createBlock,
deleteBlock, deleteBlock,
getScheduleForLicense, getScheduleForLicense,
@@ -31,10 +34,12 @@ import { actions, load } from './+page.server';
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>; const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const licenseFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>; const licenseFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>; const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
const scheduleFindMany = prisma.schedule.findMany as ReturnType<typeof vi.fn>;
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>; const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>; const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>;
const createBlockMock = createBlock as ReturnType<typeof vi.fn>; const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>; const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
const copyScheduleMock = copySchedule as ReturnType<typeof vi.fn>;
const locals = createLocals(mockUser() as never); const locals = createLocals(mockUser() as never);
@@ -51,6 +56,7 @@ beforeEach(() => {
{ id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true } { id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true }
]); ]);
licenseFindFirst.mockResolvedValue({ id: 'lk1' }); licenseFindFirst.mockResolvedValue({ id: 'lk1' });
scheduleFindMany.mockResolvedValue([]);
getScheduleMock.mockResolvedValue(null); getScheduleMock.mockResolvedValue(null);
}); });
@@ -197,4 +203,76 @@ describe('schedule actions', () => {
} }
expect(deleteBlockMock).not.toHaveBeenCalled(); expect(deleteBlockMock).not.toHaveBeenCalled();
}); });
it('copy rejects unauthenticated requests', async () => {
await expect(
actions.copy!({
request: createFormRequest({ sourceLicenseKeyId: 'lk1' }),
locals: createLocals()
} as never)
).rejects.toMatchObject({ status: 403 });
});
it('copy rejects a source not owned by the account', async () => {
licenseFindMany.mockResolvedValue([]); // none owned
const result = await actions.copy!({
request: createFormRequest({ sourceLicenseKeyId: 'lk-other' }),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) expect(result.status).toBe(404);
expect(copyScheduleMock).not.toHaveBeenCalled();
});
it('copy passes the owned systems and targets and returns a summary', async () => {
licenseFindMany.mockResolvedValue([
{ id: 'lk1', systemName: 'Living Room', key: 'AAAA-BBBB-CCCC-DDDD' },
{ id: 'lk2', systemName: 'Kitchen', key: 'EEEE-FFFF-GGGG-HHHH' }
]);
copyScheduleMock.mockResolvedValue({
ok: true,
copied: 1,
targets: [{ id: 'lk2', label: 'Kitchen' }]
});
const form = new FormData();
form.append('sourceLicenseKeyId', 'lk1');
form.append('targetIds', 'lk2');
const request = new Request('http://localhost', { method: 'POST', body: form });
const result = await actions.copy!({ request, locals } as never);
expect(result).toEqual({
success: true,
action: 'copy',
copied: 1,
targets: [{ id: 'lk2', label: 'Kitchen' }]
});
expect(copyScheduleMock).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ id: 'lk1' }),
expect.objectContaining({ id: 'lk2' })
]),
'lk1',
['lk2']
);
});
it('copy surfaces a service error', async () => {
copyScheduleMock.mockResolvedValue({ ok: false, error: 'Target system not found.' });
const form = new FormData();
form.append('sourceLicenseKeyId', 'lk1');
form.append('targetIds', 'lk-bad');
const request = new Request('http://localhost', { method: 'POST', body: form });
const result = await actions.copy!({ request, locals } as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.data).toEqual({ error: 'Target system not found.' });
}
});
}); });