diff --git a/src/routes/(protected)/(user)/schedule/+page.server.ts b/src/routes/(protected)/(user)/schedule/+page.server.ts index 6b07395..9f5d908 100644 --- a/src/routes/(protected)/(user)/schedule/+page.server.ts +++ b/src/routes/(protected)/(user)/schedule/+page.server.ts @@ -2,6 +2,7 @@ import type { Actions, PageServerLoad } from './$types'; import { error, fail } from '@sveltejs/kit'; import { prisma } from '$lib/db'; import { + copySchedule, createBlock, deleteBlock, 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 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 blocks: { @@ -102,7 +118,7 @@ export const load: PageServerLoad = async ({ locals, url }) => { } return { - licenses, + licenses: licenseViews, selectedLicenseId: selected?.id ?? null, type, blocks, @@ -167,5 +183,34 @@ export const actions: Actions = { const result = await deleteBlock(licenseKeyId, id); if (!result.ok) return fail(400, { error: result.error }); 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 + }; } }; diff --git a/src/routes/(protected)/(user)/schedule/page.server.spec.ts b/src/routes/(protected)/(user)/schedule/page.server.spec.ts index e289202..cc6a96e 100644 --- a/src/routes/(protected)/(user)/schedule/page.server.spec.ts +++ b/src/routes/(protected)/(user)/schedule/page.server.spec.ts @@ -7,7 +7,8 @@ vi.mock('$lib/db', () => ({ proclamationCategory: { findMany: vi.fn() }, proclamationGroup: { 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(), createBlock: vi.fn(), updateBlock: vi.fn(), - deleteBlock: vi.fn() + deleteBlock: vi.fn(), + copySchedule: vi.fn() })); import { prisma } from '$lib/db'; import { + copySchedule, createBlock, deleteBlock, getScheduleForLicense, @@ -31,10 +34,12 @@ import { actions, load } from './+page.server'; const procFindMany = prisma.proclamation.findMany as ReturnType; const licenseFindMany = prisma.licenseKey.findMany as ReturnType; const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType; +const scheduleFindMany = prisma.schedule.findMany as ReturnType; const getScheduleMock = getScheduleForLicense as ReturnType; const setTypeMock = setScheduleType as ReturnType; const createBlockMock = createBlock as ReturnType; const deleteBlockMock = deleteBlock as ReturnType; +const copyScheduleMock = copySchedule as ReturnType; const locals = createLocals(mockUser() as never); @@ -51,6 +56,7 @@ beforeEach(() => { { id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true } ]); licenseFindFirst.mockResolvedValue({ id: 'lk1' }); + scheduleFindMany.mockResolvedValue([]); getScheduleMock.mockResolvedValue(null); }); @@ -197,4 +203,76 @@ describe('schedule actions', () => { } 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.' }); + } + }); });