import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk"; import { SignageError, signageBoundary } from "../errors.js"; import { isUuid, optionalBoolean, optionalInstant, optionalInteger, optionalString, readJsonObject, requiredString, requireUuidParam, } from "../http.js"; import { createSignageStore } from "../store/drizzle.js"; import type { SignageStore, StoredSlotPresentation, StoredTimelineDetail } from "../store/types.js"; import { addProviderSource, addSlot, createTimeline, deleteTimeline, duplicateTimeline, removeProviderSource, removeSlot, reorderSlots, requireTimeline, SIGNAGE_TIMELINE_LIMITS, updateSlot, updateTimeline, } from "../timelines/service.js"; const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u; function store(context: ModuleRouteHandlerContext): SignageStore { return createSignageStore(context.services.database); } /** The wire shape of a timeline. Instants are ISO 8601 strings, which is what a browser should * receive — never an invented epoch or a locale-formatted representation. */ function describe(detail: StoredTimelineDetail): Record { return { slots: detail.slots.map((slot) => ({ durationOverrideMs: slot.durationOverrideMs, eligibleFrom: slot.eligibleFrom?.toISOString() ?? null, eligibleUntil: slot.eligibleUntil?.toISOString() ?? null, enabled: slot.enabled, id: slot.id, itemId: slot.itemId, kind: slot.kind, position: slot.position, presentation: slot.presentation, providerId: slot.providerId, sourceId: slot.sourceId, })), sources: detail.sources.map((source) => ({ id: source.id, label: source.label, providerId: source.providerId, sourceId: source.sourceId, // The selection scope is deliberately **not** exposed. It is an internal stable identity a // provider receives; a dashboard that showed it would invite someone to depend on its shape. })), timeline: { description: detail.timeline.description, eligibleFrom: detail.timeline.eligibleFrom?.toISOString() ?? null, eligibleUntil: detail.timeline.eligibleUntil?.toISOString() ?? null, id: detail.timeline.id, name: detail.timeline.name, revision: detail.timeline.revision, updatedAt: detail.timeline.updatedAt.toISOString(), }, }; } function readPresentation( body: Record, ): Partial | undefined { const raw = body.presentation; if (raw === undefined) { return undefined; } if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "presentation" }); } const value = raw as Record; const mode = value.backgroundMode; if ( mode !== undefined && mode !== null && mode !== "blurred" && mode !== "solid" && mode !== "black" ) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundMode" }); } return { ...(mode === undefined ? {} : { backgroundMode: mode as StoredSlotPresentation["backgroundMode"] }), ...(value.backgroundColor === undefined ? {} : { backgroundColor: optionalString(value, "backgroundColor", { maxLength: 7 }) ?? null }), ...(value.backgroundDarkened === undefined ? {} : { backgroundDarkened: optionalBoolean(value, "backgroundDarkened") ?? null }), ...(value.blurPixels === undefined ? {} : { blurPixels: optionalInteger(value, "blurPixels", { maximum: 120, minimum: 0 }) ?? null }), ...(value.foregroundShadow === undefined ? {} : { foregroundShadow: optionalBoolean(value, "foregroundShadow") ?? null }), }; } function requireOpaqueId(body: Record, field: string): string { const value = body[field]; if (typeof value !== "string" || !PROVIDER_ID.test(value)) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field }); } return value; } export function registerTimelineRoutes(http: ModuleHttpRouteRegistrar): void { http.route({ method: "GET", path: "/timelines", permission: "signage.view", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const timelines = await store(context).listTimelines(); return Response.json({ timelines: timelines.map((timeline) => ({ description: timeline.description, eligibleFrom: timeline.eligibleFrom?.toISOString() ?? null, eligibleUntil: timeline.eligibleUntil?.toISOString() ?? null, id: timeline.id, name: timeline.name, revision: timeline.revision, updatedAt: timeline.updatedAt.toISOString(), })), }); }); }, }); http.route({ method: "POST", path: "/timelines", permission: "signage.timelines.manage", maxBodyBytes: 8192, handler(request, context) { return signageBoundary(async () => { const body = await readJsonObject(request); const detail = await createTimeline({ description: optionalString(body, "description", { maxLength: 500 }) ?? null, eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null, eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null, name: requiredString(body, "name", { maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength, }), store: store(context), }); return Response.json(describe(detail), { status: 201 }); }); }, }); http.route({ method: "GET", path: "/timelines/:timelineId", permission: "signage.view", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); return Response.json(describe(await requireTimeline(store(context), timelineId))); }); }, }); http.route({ method: "PATCH", path: "/timelines/:timelineId", permission: "signage.timelines.manage", maxBodyBytes: 8192, handler(request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); const body = await readJsonObject(request); const detail = await updateTimeline({ changes: { ...(body.name === undefined ? {} : { name: requiredString(body, "name", { maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength, }), }), ...(body.description === undefined ? {} : { description: optionalString(body, "description", { maxLength: 500 }) ?? null }), ...(body.eligibleFrom === undefined ? {} : { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }), ...(body.eligibleUntil === undefined ? {} : { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }), }, store: store(context), timelineId, }); return Response.json(describe(detail)); }); }, }); http.route({ method: "DELETE", path: "/timelines/:timelineId", permission: "signage.timelines.manage", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); await deleteTimeline({ store: store(context), timelineId }); return new Response(null, { status: 204 }); }); }, }); http.route({ method: "POST", path: "/timelines/:timelineId/duplicate", permission: "signage.timelines.manage", maxBodyBytes: 4096, handler(request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); const body = await readJsonObject(request); const detail = await duplicateTimeline({ name: requiredString(body, "name", { maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength }), store: store(context), timelineId, }); return Response.json(describe(detail), { status: 201 }); }); }, }); http.route({ method: "POST", path: "/timelines/:timelineId/sources", permission: "signage.timelines.manage", maxBodyBytes: 4096, handler(request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); const body = await readJsonObject(request); const detail = await addProviderSource({ label: requiredString(body, "label", { maxLength: 200 }), providerId: requireOpaqueId(body, "providerId"), sourceId: requireOpaqueId(body, "sourceId"), store: store(context), timelineId, }); return Response.json(describe(detail), { status: 201 }); }); }, }); http.route({ method: "DELETE", path: "/timelines/:timelineId/sources/:sourceId", permission: "signage.timelines.manage", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); const sourceId = requireUuidParam(context, "sourceId"); return Response.json( describe(await removeProviderSource({ sourceId, store: store(context), timelineId })), ); }); }, }); http.route({ method: "POST", path: "/timelines/:timelineId/slots", permission: "signage.timelines.manage", maxBodyBytes: 8192, handler(request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); const body = await readJsonObject(request); const sourceId = body.sourceId; const detail = await addSlot({ durationOverrideMs: optionalInteger(body, "durationOverrideMs", { maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs, minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs, }), eligibleFrom: optionalInstant(body, "eligibleFrom"), eligibleUntil: optionalInstant(body, "eligibleUntil"), enabled: optionalBoolean(body, "enabled") ?? true, ...(sourceId === undefined ? {} : { source: { sourceId: isUuid(sourceId) ? sourceId : (() => { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" }); })(), }, }), ...(body.itemId === undefined ? {} : { pinned: { itemId: requireOpaqueId(body, "itemId"), providerId: requireOpaqueId(body, "providerId"), }, }), position: optionalInteger(body, "position", { maximum: 10_000, minimum: 0 }) ?? undefined, presentation: readPresentation(body), store: store(context), timelineId, }); return Response.json(describe(detail), { status: 201 }); }); }, }); http.route({ method: "PATCH", path: "/timelines/:timelineId/slots/:slotId", permission: "signage.timelines.manage", maxBodyBytes: 8192, handler(request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); const slotId = requireUuidParam(context, "slotId"); const body = await readJsonObject(request); const presentation = readPresentation(body); const detail = await updateSlot({ changes: { ...(body.enabled === undefined ? {} : { enabled: optionalBoolean(body, "enabled") ?? true }), ...(body.durationOverrideMs === undefined ? {} : { durationOverrideMs: optionalInteger(body, "durationOverrideMs", { maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs, minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs, }) as number | null, }), ...(body.eligibleFrom === undefined ? {} : { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }), ...(body.eligibleUntil === undefined ? {} : { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }), ...(body.itemId === undefined ? {} : { itemId: requireOpaqueId(body, "itemId") }), ...(presentation === undefined ? {} : { presentation }), }, slotId, store: store(context), timelineId, }); return Response.json(describe(detail)); }); }, }); http.route({ method: "DELETE", path: "/timelines/:timelineId/slots/:slotId", permission: "signage.timelines.manage", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); const slotId = requireUuidParam(context, "slotId"); return Response.json( describe(await removeSlot({ slotId, store: store(context), timelineId })), ); }); }, }); http.route({ method: "PUT", path: "/timelines/:timelineId/slot-order", permission: "signage.timelines.manage", maxBodyBytes: 16_384, handler(request, context) { return signageBoundary(async () => { const timelineId = requireUuidParam(context, "timelineId"); const body = await readJsonObject(request); const ids = body.slotIds; if ( !Array.isArray(ids) || ids.length > SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline || !ids.every((id) => isUuid(id)) ) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" }); } return Response.json( describe( await reorderSlots({ orderedSlotIds: ids as string[], store: store(context), timelineId, }), ), ); }); }, }); }