diff --git a/server/api/schedule.patch.ts b/server/api/schedule.patch.ts new file mode 100644 index 0000000..473a7e7 --- /dev/null +++ b/server/api/schedule.patch.ts @@ -0,0 +1,88 @@ +import type { SchedulePatch } from "~/shared/types/schedule"; +import { readAccounts, readSchedule, writeSchedule } from "~/server/database"; +import { broadcastUpdate } from "~/server/streams"; +import { applyChangeArray } from "~/shared/utils/changes"; + +function isChange(change: unknown) { + return ( + typeof change === "object" + && change !== null + && "op" in change + && ( + change.op === "set" || change.op === "del" + ) + && "data" in change + && typeof change.data === "object" + && change.data !== null + && "id" in change.data + && typeof change.data.id === "string" + ) +} + +function isChangeArray(data: unknown) { + return data instanceof Array && data.every(item => isChange(item)); +} + +function isPatch(data: unknown): SchedulePatch { + if ( + typeof data !== "object" + || data === null + || data instanceof Array + || "locations" in data && !isChangeArray(data.locations) + || "events" in data && !isChangeArray(data.events) + || "roles" in data && !isChangeArray(data.roles) + || "rota" in data && !isChangeArray(data.rota) + ) + throw new Error("Invalid patch data") + return data // TODO: Actually validate the whole structure with e.g ajv or zod +} + +export default defineEventHandler(async (event) => { + const session = await requireAccountSession(event); + const accounts = await readAccounts(); + const account = accounts.find(a => a.id === session.accountId); + if (!account) { + throw new Error("Account does not exist"); + } + + if (account.type !== "admin" && account.type !== "crew") { + throw createError({ + status: 403, + statusMessage: "Forbidden", + message: "Only crew and admin accounts can edit the schedule.", + }); + } + + const schedule = await readSchedule(); + const patch = await readValidatedBody(event, isPatch); + + // Validate edit restrictions for crew + if (account.type === "crew") { + if (patch.locations?.length) { + throw createError({ + status: 403, + statusMessage: "Forbidden", + message: "Only admin accounts can edit locations.", + }); + } + for (const event of patch.events ?? []) { + const id = event.op === "set" ? event.data.id : event.id; + const original = schedule.events.find(e => e.id === id); + if (original && !original.crew) { + throw createError({ + status: 403, + statusMessage: "Forbidden", + message: "Only admin accounts can edit public events.", + }); + } + } + } + + if (patch.events) applyChangeArray(patch.events, schedule.events); + if (patch.locations) applyChangeArray(patch.locations, schedule.locations); + if (patch.roles) applyChangeArray(patch.roles, schedule.roles = schedule.roles ?? []); + if (patch.rota) applyChangeArray(patch.rota, schedule.rota = schedule.rota ?? []); + + await writeSchedule(schedule); + await broadcastUpdate(schedule); +}) diff --git a/shared/types/schedule.d.ts b/shared/types/schedule.d.ts index 7d8787b..ad8736b 100644 --- a/shared/types/schedule.d.ts +++ b/shared/types/schedule.d.ts @@ -49,3 +49,15 @@ export interface Schedule { roles?: Role[], rota?: Shift[], } + +export type ChangeRecord = + | { op: "set", data: T } + | { op: "del", data: { id: string }} +; + +export interface SchedulePatch { + locations?: ChangeRecord[], + events?: ChangeRecord[], + roles?: ChangeRecord[], + rota?: ChangeRecord[], +} diff --git a/shared/utils/changes.ts b/shared/utils/changes.ts new file mode 100644 index 0000000..facb2d7 --- /dev/null +++ b/shared/utils/changes.ts @@ -0,0 +1,21 @@ +import type { ChangeRecord } from "~/shared/types/schedule"; + +export function applyChange(change: ChangeRecord, data: T[]) { + const index = data.findIndex(item => item.id === change.data.id); + if (change.op === "del") { + if (index !== -1) + data.splice(index, 1); + } else if (change.op === "set") { + if (index !== -1) + data.splice(index, 1, change.data); + else + data.push(change.data) + } +} + +export function applyChangeArray(changes: ChangeRecord[], data: T[]) { + // Note: quadratic complexity due to findIndex in applyChange + for (const change of changes) { + applyChange(change, data); + } +}