From 64f47a9a6bb2ea4f7bc8c4f3d64b412d165941a0 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Tue, 26 May 2026 07:11:45 +0200 Subject: [PATCH] refactor: migrate all auto-increment PKs to UUIDv7 text IDs Replace SERIAL/AUTOINCREMENT integer primary keys with UUIDv7 text IDs across all 15 entity tables (users, api_keys, displays, cameras, camera_streams, layouts, layout_cells, entities, kiosks, labels, kiosk_gpio_bindings, event_log, kiosk_logs, audit_log, camera_event_subscriptions). SetupState keeps id=1 INTEGER singleton. Changes: - types.ts: all id fields number->string, all FK fields number->string - mappers.ts: n(r["id"])->s(r["id"]) for PKs, nn()->sn() for nullable FKs - repository.ts: import uuidv7, generate IDs before INSERT, remove RETURNING id, change all method signatures from number to string - migrations-pg.ts: SERIAL->TEXT NOT NULL PRIMARY KEY, INTEGER FK->TEXT FK - bundle.ts: all bundle interface IDs number->string - pairing.ts, auth.ts: kioskId/userId types number->string - coordinator-registry.ts: kioskId number->string - audit.ts: actor_id number->string - mqtt-bridge.ts: kioskId number->string in publish/subscribe - All route handlers: Number(getRouterParam)->getRouterParam ?? "" - admin-pages.tsx: template function params and Map types number->string - kiosk/src/bundle.rs: flexible serde deserializer that accepts both u32 (old) and String (new) IDs for backward compatibility Fresh PG database -- no data migration needed, just schema changes. SQLite migrations unchanged (dev-only, recreate DB for UUIDv7). Co-Authored-By: Claude Opus 4.6 (1M context) --- kiosk/src/bundle.rs | 9 +- .../plugins/service-admin-http/middleware.ts | 2 +- .../service-admin-http/routes-admin.ts | 202 +++++------ .../service-admin-http/routes-firmware.ts | 10 +- .../service-admin-http/routes-os-updates.ts | 10 +- server/src/plugins/service-api-http/index.ts | 16 +- .../plugins/service-coordinator-ws/index.ts | 26 +- server/src/shared/audit.ts | 6 +- server/src/shared/auth.ts | 4 +- server/src/shared/bundle.ts | 26 +- server/src/shared/coordinator-registry.ts | 6 +- server/src/shared/db/mappers.ts | 90 ++--- server/src/shared/db/migrations-pg.ts | 92 ++--- server/src/shared/db/repository.ts | 343 +++++++++--------- server/src/shared/mqtt-bridge.ts | 12 +- server/src/shared/pairing.ts | 8 +- server/src/shared/types.ts | 96 ++--- server/src/web-templates/admin-pages.tsx | 38 +- 18 files changed, 506 insertions(+), 490 deletions(-) diff --git a/kiosk/src/bundle.rs b/kiosk/src/bundle.rs index a9059ab..5063343 100644 --- a/kiosk/src/bundle.rs +++ b/kiosk/src/bundle.rs @@ -1,8 +1,10 @@ use serde::{Deserialize, Serialize}; + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct KioskBundle { - pub kiosk_id: u32, + #[serde(deserialize_with = "de_flexible_id")] + pub kiosk_id: String, pub kiosk_name: String, /// Legacy single-display field (mirrors `displays[0]`). New code should /// iterate `displays` instead. @@ -31,7 +33,7 @@ impl KioskBundle { } if let Some(d) = &self.display { return vec![BundleDisplayWithLayouts { - id: d.id, + id: d.id.clone(), name: d.name.clone(), width_px: d.width_px, height_px: d.height_px, @@ -90,7 +92,8 @@ pub struct BundleCell { pub row_span: u32, pub col_span: u32, pub content_type: String, - pub camera_id: Option, + #[serde(default, deserialize_with = "de_flexible_id_opt")] + pub camera_id: Option, pub stream_selector: Option, pub web_url: Option, pub html_content: Option, diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index 753f4fb..d9c1ebc 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -22,7 +22,7 @@ declare module "h3" { function syntheticApiKeyUser(keyPrefix: string): User { return { - id: 0, + id: "", username: `api:${keyPrefix}`, password_hash: "", role: "admin", diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 09ee114..f742b5f 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -163,9 +163,9 @@ function formValues(v: FormValue): string[] { return v ? [v] : []; } -function kioskOnvifSoapTransport(kioskId: number) { +function kioskOnvifSoapTransport(kioskId: string) { return async (url: string, action: string, body: string, timeoutMs: number): Promise => { - if (!Number.isInteger(kioskId) || kioskId <= 0) { + if (!kioskId) { throw new Error("invalid kiosk selected for discovery"); } const response = await getCoordinator().requestKiosk<{ @@ -209,7 +209,7 @@ async function importDiscoveredCamera( username: string, password: string, streams: DiscoverAddStream[], -): Promise { +): Promise { if (streams.length === 0) return null; const main = streams.find((s) => s.role === "main") ?? streams[0]!; // Camera row's rtsp_url: full URL with credentials for display / backward compat. @@ -283,7 +283,7 @@ function cellsOverlap( } interface CellPos { - id: number; + id: string; row: number; col: number; row_span: number; @@ -292,12 +292,12 @@ interface CellPos { async function resolveOverlaps( deps: AdminDeps, - layoutId: number, - anchorId: number, + layoutId: string, + anchorId: string, pushAxis: "row" | "col", ): Promise { const all = await deps.repo.layoutCells(layoutId); - const positions = new Map(); + const positions = new Map(); for (const c of all) { positions.set(c.id, { id: c.id, row: c.row, col: c.col, row_span: c.row_span, col_span: c.col_span }); } @@ -353,8 +353,8 @@ async function resolveOverlaps( async function shiftCellsForExpansion( deps: AdminDeps, - layoutId: number, - cellId: number, + layoutId: string, + cellId: string, direction: "left" | "right" | "above" | "bottom", ): Promise { const cell = await deps.repo.getLayoutCellById(cellId); @@ -379,7 +379,7 @@ async function shiftCellsForExpansion( async function shiftCellsForInsertion( deps: AdminDeps, - layoutId: number, + layoutId: string, axis: "row" | "col", fromIndex: number, crossStart: number, @@ -541,8 +541,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/cameras", async (event) => { const user = event.context.user!; const cameras = await deps.repo.listCameras(); - const streamCounts = new Map(); - const activeKiosks = new Map(); // camera_id → count of kiosks rendering + const streamCounts = new Map(); + const activeKiosks = new Map(); // camera_id → count of kiosks rendering for (const cam of cameras) { streamCounts.set(cam.id, (await deps.repo.listCameraStreams(cam.id)).length); activeKiosks.set(cam.id, (await deps.repo.listKiosksRenderingCamera(cam.id)).length); @@ -643,7 +643,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { try { const soapTransport = runner.startsWith("kiosk:") - ? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length))) + ? kioskOnvifSoapTransport(runner.slice("kiosk:".length)) : undefined; const cameras = await onvifDiscover({ host, port, username, password, soapTransport }); return htmlPage(CameraDiscoverResultsPage({ @@ -740,11 +740,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { errors.push("Select an entity type."); } - let cameraId: number | null = null; + let cameraId: string | null = null; let htmlContent: string | null = null; let webUrl: string | null = null; if (type === "camera") { - cameraId = body?.["camera_id"] ? Number(body["camera_id"]) : null; + cameraId = body?.["camera_id"] ? String(body["camera_id"] ?? "") : null; if (!cameraId) errors.push("Pick a camera."); } else if (type === "html") { htmlContent = body?.["html_content"] ?? null; @@ -776,7 +776,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/entities/:id", async (event) => { const user = event.context.user!; - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const ent = await deps.repo.getEntityById(id); if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); return htmlPage(EntityEditPage({ @@ -787,14 +787,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/entities/:id", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const ent = await deps.repo.getEntityById(id); if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); const body = await readBody>(event); const patch: { name?: string; description?: string | null; - camera_id?: number | null; + camera_id?: string | null; html_content?: string | null; web_url?: string | null; } = { @@ -802,7 +802,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { description: (body?.["description"] ?? "").trim() || null, }; if (ent.type === "camera") { - patch.camera_id = body?.["camera_id"] ? Number(body["camera_id"]) : null; + patch.camera_id = body?.["camera_id"] ? String(body["camera_id"] ?? "") : null; } else if (ent.type === "html") { patch.html_content = body?.["html_content"] ?? null; } else if (ent.type === "web") { @@ -814,7 +814,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/entities/:id/delete", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); await deps.repo.deleteEntity(id); notifyKiosks(); return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); @@ -825,7 +825,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // no kiosk currently has the camera in its active layout (or every kiosk // attempt times out). Used by the EntityEditPage "Test" preview. app.get("/admin/entities/:id/snapshot", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const ent = await deps.repo.getEntityById(id); if (!ent || ent.type !== "camera" || ent.camera_id == null) { return new Response("Not a camera entity", { status: 404 }); @@ -902,7 +902,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const labelsStr = (body?.["initial_labels"] ?? "").trim(); const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined; const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim(); - const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined; + const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? replaceIdRaw : undefined; const force = body?.["force"] === "1"; try { @@ -939,7 +939,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const user = event.context.user!; const layouts = await deps.repo.listLayouts(); // For each layout, how many displays use it (for the list view). - const displayCounts = new Map(); + const displayCounts = new Map(); for (const l of layouts) { displayCounts.set(l.id, (await deps.repo.listDisplaysForLayout(l.id)).length); } @@ -986,7 +986,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/layouts/:id", async (event) => { const user = event.context.user!; - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const layout = await deps.repo.getLayoutById(id); if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); const cells = await deps.repo.layoutCells(id); @@ -1005,7 +1005,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/layouts/:id", async (event) => { event.context.obs?.log.info("layout update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); const coolingStr = body?.["cooling_timeout_seconds"] ?? ""; const coolingTimeout = coolingStr.trim() === "" ? null : parseInt(coolingStr, 10); @@ -1026,7 +1026,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // For htmx requests (hx-request header), returns the grid fragment; otherwise // returns a 302 to the layout edit page. app.post("/admin/layouts/:id/cells", async (event) => { - const layoutId = Number(getRouterParam(event, "id")); + const layoutId = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); let row = 0; @@ -1036,7 +1036,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const direction = typeof body?.["direction"] === "string" ? (body["direction"] as string) : ""; if (afterCellIdRaw && direction) { - const afterId = Number(afterCellIdRaw); + const afterId = String(afterCellIdRaw); const cells = await deps.repo.layoutCells(layoutId); const ref = cells.find((c) => c.id === afterId); if (!ref) { @@ -1097,8 +1097,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // GET a single cell in read mode (used by htmx Cancel button in inline edit). app.get("/admin/layouts/:id/cells/:cellId", async (event) => { - const layoutId = Number(getRouterParam(event, "id")); - const cellId = Number(getRouterParam(event, "cellId")); + const layoutId = (getRouterParam(event, "id") ?? ""); + const cellId = (getRouterParam(event, "cellId") ?? ""); const cell = await deps.repo.getLayoutCellById(cellId); if (!cell || cell.layout_id !== layoutId) { return new Response("Not Found", { status: 404 }); @@ -1110,8 +1110,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // GET a single cell in edit mode (htmx swap target for cell click). app.get("/admin/layouts/:id/cells/:cellId/edit", async (event) => { - const layoutId = Number(getRouterParam(event, "id")); - const cellId = Number(getRouterParam(event, "cellId")); + const layoutId = (getRouterParam(event, "id") ?? ""); + const cellId = (getRouterParam(event, "cellId") ?? ""); const cell = await deps.repo.getLayoutCellById(cellId); if (!cell || cell.layout_id !== layoutId) { return new Response("Not Found", { status: 404 }); @@ -1124,14 +1124,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Update a cell's entity binding + dimensions. Legacy content_type/web/html // columns are managed by assignCellEntity for bundle compatibility. app.post("/admin/layouts/:id/cells/:cellId", async (event) => { - const layoutId = Number(getRouterParam(event, "id")); - const cellId = Number(getRouterParam(event, "cellId")); + const layoutId = (getRouterParam(event, "id") ?? ""); + const cellId = (getRouterParam(event, "cellId") ?? ""); const body = await readBody>(event); const entityIdRaw = body?.["entity_id"]; const entityId = - entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null; - await deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null); + entityIdRaw && String(entityIdRaw).trim() !== "" ? String(entityIdRaw) : null; + await deps.repo.assignCellEntity(cellId, entityId != null && entityId !== "" ? entityId : null); // stream_selector + spans + fit are still settable on the cell. const dimsPatch: Record = {}; @@ -1221,8 +1221,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Resize a cell by ±1 on row_span or col_span. Returns the grid fragment. app.post("/admin/layouts/:id/cells/:cellId/resize", async (event) => { - const layoutId = Number(getRouterParam(event, "id")); - const cellId = Number(getRouterParam(event, "cellId")); + const layoutId = (getRouterParam(event, "id") ?? ""); + const cellId = (getRouterParam(event, "cellId") ?? ""); const body = await readBody>(event); const dim = String(body?.["dim"] ?? ""); const delta = Number(body?.["delta"] ?? 0) || 0; @@ -1257,8 +1257,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Visual editor: drag-to-move a cell to a new grid position. app.post("/admin/layouts/:id/cells/:cellId/move", async (event) => { - const layoutId = Number(getRouterParam(event, "id")); - const cellId = Number(getRouterParam(event, "cellId")); + const layoutId = (getRouterParam(event, "id") ?? ""); + const cellId = (getRouterParam(event, "cellId") ?? ""); const body = await readBody<{ row: number; col: number }>(event); const row = Number(body?.row ?? 0); const col = Number(body?.col ?? 0); @@ -1270,8 +1270,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/layouts/:id/cells/:cellId/delete", async (event) => { - const layoutId = Number(getRouterParam(event, "id")); - const cellId = Number(getRouterParam(event, "cellId")); + const layoutId = (getRouterParam(event, "id") ?? ""); + const cellId = (getRouterParam(event, "cellId") ?? ""); await deps.repo.deleteLayoutCell(cellId); notifyKiosks(); if (isHtmxRequest(event)) { @@ -1284,7 +1284,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/layouts/:id/clone", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const clone = await deps.repo.cloneLayout(id); notifyKiosks(); return new Response(null, { status: 302, headers: { location: `/admin/layouts/${clone.id}` } }); @@ -1292,7 +1292,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/layouts/:id/delete", async (event) => { event.context.obs?.log.info("layout delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); await deps.repo.deleteLayout(id); notifyKiosks(); return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); @@ -1308,7 +1308,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/displays/:id", async (event) => { const user = event.context.user!; - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const display = await deps.repo.getDisplayById(id); if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } }); const attachedLayouts = await deps.repo.listLayoutsForDisplay(id); @@ -1325,13 +1325,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/displays/:id", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); const defaultLayoutIdRaw = body?.["default_layout_id"]; - const defaultLayoutId = defaultLayoutIdRaw ? Number(defaultLayoutIdRaw) : null; + const defaultLayoutId = defaultLayoutIdRaw ? String(defaultLayoutIdRaw) : null; // Validate default_layout_id is actually attached to this display. - let validatedDefault: number | null = defaultLayoutId; + let validatedDefault: string | null = defaultLayoutId; if (defaultLayoutId != null) { const attached = await deps.repo.listLayoutsForDisplay(id); if (!attached.some((l) => l.id === defaultLayoutId)) { @@ -1353,7 +1353,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); // Render the attached + available layouts region for a display. - const renderDisplayLayoutsFragment = async (displayId: number): Promise => { + const renderDisplayLayoutsFragment = async (displayId: string): Promise => { const display = await deps.repo.getDisplayById(displayId); const attached = await deps.repo.listLayoutsForDisplay(displayId); const attachedIds = new Set(attached.map((l) => l.id)); @@ -1366,10 +1366,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Attach a layout to a display. app.post("/admin/displays/:id/layouts", async (event) => { - const displayId = Number(getRouterParam(event, "id")); + const displayId = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); - const layoutId = body?.["layout_id"] ? Number(body["layout_id"]) : null; - if (layoutId && Number.isFinite(layoutId)) { + const layoutId = body?.["layout_id"] ? String(body["layout_id"]) : null; + if (layoutId && layoutId) { await deps.repo.attachLayoutToDisplay(displayId, layoutId); notifyKiosks(); } @@ -1381,8 +1381,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Detach a layout from a display. app.post("/admin/displays/:id/layouts/:layoutId/remove", async (event) => { - const displayId = Number(getRouterParam(event, "id")); - const layoutId = Number(getRouterParam(event, "layoutId")); + const displayId = (getRouterParam(event, "id") ?? ""); + const layoutId = (getRouterParam(event, "layoutId") ?? ""); await deps.repo.detachLayoutFromDisplay(displayId, layoutId); notifyKiosks(); if (isHtmxRequest(event)) { @@ -1412,7 +1412,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/labels/:id/delete", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); await deps.repo.deleteLabel(id); return new Response(null, { status: 302, headers: { location: "/admin/labels" } }); }); @@ -1421,7 +1421,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/cameras/:id", async (event) => { const user = event.context.user!; - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const camera = await deps.repo.getCameraById(id); if (!camera) return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); @@ -1464,7 +1464,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/cameras/:id", async (event) => { event.context.obs?.log.info("camera update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const cam = await deps.repo.getCameraById(id); if (cam?.type === "cloud") { return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); @@ -1541,10 +1541,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/cameras/:id/labels", async (event) => { - const camId = Number(getRouterParam(event, "id")); + const camId = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase(); - let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; + let labelId = body?.["label_id"] ? String(body["label_id"] ?? "") : null; if (newLabel) { const label = await deps.repo.ensureLabel(newLabel); @@ -1560,9 +1560,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/cameras/:id/labels/remove", async (event) => { - const camId = Number(getRouterParam(event, "id")); + const camId = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); - const labelId = Number(body?.["label_id"]); + const labelId = String(body?.["label_id"] ?? ""); await deps.repo.detachCameraLabel(camId, labelId); if (isHtmxRequest(event)) { return htmlFragment(renderCameraLabels(camId, await deps.repo.cameraLabelIds(camId), await deps.repo.listLabels())); @@ -1573,7 +1573,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Refresh supported ONVIF event topics from the camera. // MERGE: new topics are added to the existing list, never removed. app.post("/admin/cameras/:id/refresh-events", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const cam = await deps.repo.getCameraById(id); if (!cam || cam.type !== "onvif" || !cam.onvif_host) { return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); @@ -1591,7 +1591,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { runner = online ? `kiosk:${online.id}` : "server"; } const soapTransport = runner.startsWith("kiosk:") - ? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length))) + ? kioskOnvifSoapTransport(runner.slice("kiosk:".length)) : undefined; try { const discoveredTopics = await onvifGetEventProperties({ @@ -1618,7 +1618,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Subscribe to all inactive event topics for this camera. app.post("/admin/cameras/:id/subscribe-events", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const cam = await deps.repo.getCameraById(id); if (!cam) { return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); @@ -1629,7 +1629,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/cameras/:id/delete", async (event) => { event.context.obs?.log.info("camera delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); await deps.repo.deleteCamera(id); notifyKiosks(); deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted", source: "server" }); @@ -1643,7 +1643,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }; const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); app.get("/admin/cameras/:id/events", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const { events } = await deps.repo.queryEvents({ camera_id: id, limit: 20, @@ -1672,7 +1672,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/kiosks/:id", async (event) => { const user = event.context.user!; - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); const kioskLabels = (await deps.repo.listKioskLabels(id)).map((kl) => ({ @@ -1709,7 +1709,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- GPIO bindings ---------------------------------------------------- app.post("/admin/kiosks/:id/gpio", async (event) => { - const kioskId = Number(getRouterParam(event, "id")); + const kioskId = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); const pin = Number(body?.["pin"]); const direction = (body?.["direction"] ?? "in") === "out" ? "out" : "in"; @@ -1735,8 +1735,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/kiosks/:id/gpio/:bindingId/delete", async (event) => { - const kioskId = Number(getRouterParam(event, "id")); - const bindingId = Number(getRouterParam(event, "bindingId")); + const kioskId = (getRouterParam(event, "id") ?? ""); + const bindingId = (getRouterParam(event, "bindingId") ?? ""); await deps.repo.deleteGpioBinding(bindingId); notifyKiosks(); if (isHtmxRequest(event)) { @@ -1748,7 +1748,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/kiosks/:id", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); const kiosk = await deps.repo.getKioskById(id); await deps.repo.updateKiosk(id, { @@ -1776,7 +1776,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // cluster key it received at pairing), then bumps managed_config_version // so the next heartbeat ships it to the kiosk. app.post("/admin/kiosks/:id/managed-config", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) throw new Error("kiosk not found"); if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image"); @@ -1845,11 +1845,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/kiosks/:id/labels", async (event) => { - const kioskId = Number(getRouterParam(event, "id")); + const kioskId = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase(); const role = (body?.["role"] ?? "consume") as "consume" | "operate"; - let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; + let labelId = body?.["label_id"] ? String(body["label_id"] ?? "") : null; if (newLabel) { const label = await deps.repo.ensureLabel(newLabel); @@ -1870,9 +1870,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/kiosks/:id/labels/remove", async (event) => { - const kioskId = Number(getRouterParam(event, "id")); + const kioskId = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); - const labelId = Number(body?.["label_id"]); + const labelId = String(body?.["label_id"] ?? ""); await deps.repo.detachKioskLabel(kioskId, labelId); if (isHtmxRequest(event)) { const kioskLabels = (await deps.repo.listKioskLabels(kioskId)).map((kl) => ({ @@ -1887,7 +1887,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/kiosks/:id/delete", async (event) => { event.context.obs?.log.info("kiosk delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); await deps.repo.deleteKiosk(id); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); }); @@ -1897,7 +1897,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // /ws/admin/debug/:kioskId and render output. The WS connection is // authenticated via the admin's API key. app.get("/admin/kiosks/:id/logs", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); const user = event.context.user!; @@ -1945,7 +1945,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.get("/admin/kiosks/:id/terminal", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); // WS auth: browser sends session cookie automatically on WS upgrade. @@ -2076,7 +2076,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); // ---- Layout switch ---------------------------------------------------- - const emitLayoutChanged = async (displayId: number | null, kioskId: number | null, layoutId: number) => { + const emitLayoutChanged = async (displayId: string | null, kioskId: string | null, layoutId: string) => { const layout = await deps.repo.getLayoutById(layoutId); deps.nodered.forward("layout.changed", { display_id: displayId, @@ -2088,13 +2088,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }; const displayLayoutSwitch = async (event: any) => { - const displayId = Number(getRouterParam(event, "displayId")); - let layoutId = Number(getRouterParam(event, "layoutId")); - if (!Number.isFinite(layoutId) || layoutId <= 0) { + const displayId = (getRouterParam(event, "displayId") ?? ""); + let layoutId = getRouterParam(event, "layoutId") ?? ""; + if (!layoutId) { const body = await readBody>(event); - layoutId = Number(body?.["layout_id"]); + layoutId = String(body?.["layout_id"] ?? ""); } - if (Number.isFinite(displayId) && Number.isFinite(layoutId)) { + if (displayId && layoutId) { const display = await deps.repo.getDisplayById(displayId); const attached = await deps.repo.listLayoutsForDisplay(displayId); const isAttached = attached.some((l) => l.id === layoutId); @@ -2114,7 +2114,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); const displayPower = async (event: any, state: "on" | "standby") => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const display = await deps.repo.getDisplayById(id); if (display?.kiosk_id) { getCoordinator().sendToKiosk(display.kiosk_id, { @@ -2144,7 +2144,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); // ---- CEC power commands ----------------------------------------------- - const emitDisplayPower = async (kioskId: number, state: "on" | "standby") => { + const emitDisplayPower = async (kioskId: string, state: "on" | "standby") => { const displays = await deps.repo.listDisplaysForKiosk(kioskId); const displayId = displays[0]?.id ?? null; const actual = state === "on" ? "awake" : "standby"; @@ -2164,7 +2164,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }; app.post("/admin/kiosks/:id/power/standby", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); getCoordinator().sendToKiosk(id, { type: "standby" }); await emitDisplayPower(id, "standby"); await audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id }); @@ -2172,7 +2172,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/admin/kiosks/:id/power/wake", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); getCoordinator().sendToKiosk(id, { type: "wake" }); await emitDisplayPower(id, "on"); await audit(deps.repo, event as any, "display.wake", { resource_type: "kiosk", resource_id: id }); @@ -2181,7 +2181,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Fan control ------------------------------------------------------ app.post("/admin/kiosks/:id/fan", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); const mode = body?.["mode"]; if (mode === "auto") { @@ -2216,7 +2216,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.get("/api/admin/cameras/:id", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const cam = await deps.repo.getCameraById(id); if (!cam) return jsonResponse({ error: "not_found" }, 404); const streams = await deps.repo.listCameraStreams(id); @@ -2231,7 +2231,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.get("/api/admin/displays/:id", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const display = await deps.repo.getDisplayById(id); if (!display) return jsonResponse({ error: "not_found" }, 404); const attachedLayouts = await deps.repo.listLayoutsForDisplay(id); @@ -2258,7 +2258,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.get("/api/admin/kiosks/:id", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return jsonResponse({ error: "not_found" }, 404); const displays = await deps.repo.listDisplaysForKiosk(id); @@ -2276,7 +2276,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.get("/api/admin/layouts/:id", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const layout = await deps.repo.getLayoutById(id); if (!layout) return jsonResponse({ error: "not_found" }, 404); const cells = await deps.repo.layoutCells(id); @@ -2290,7 +2290,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.get("/api/admin/entities/:id", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const entity = await deps.repo.getEntityById(id); if (!entity) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ entity }); @@ -2302,11 +2302,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // across all set ops. Returns the post-mutation entity. app.post("/api/admin/displays/:id/default-layout", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = (await readBody>(event)) ?? {}; const raw = body["value"] ?? body["default_layout_id"]; - const layoutId = raw == null || raw === "" ? null : Number(raw); - if (raw != null && raw !== "" && !Number.isFinite(layoutId)) { + const layoutId = raw == null || raw === "" ? null : String(raw); + if (raw != null && raw !== "" && !layoutId) { return jsonResponse({ error: "invalid_value" }, 400); } if (layoutId != null) { @@ -2322,7 +2322,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/api/admin/kiosks/:id/enabled", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = (await readBody>(event)) ?? {}; const enabled = Boolean(body["value"] ?? body["enabled"]); await deps.repo.updateKiosk(id, { enabled } as any); @@ -2332,7 +2332,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/api/admin/cameras/:id/enabled", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = (await readBody>(event)) ?? {}; const enabled = Boolean(body["value"] ?? body["enabled"]); await deps.repo.updateCamera(id, { enabled } as any); @@ -2344,7 +2344,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/api/admin/layouts/:id/priority", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = (await readBody>(event)) ?? {}; const value = String(body["value"] ?? body["priority"] ?? "").toLowerCase(); if (value !== "hot" && value !== "normal" && value !== "cold") { @@ -2358,7 +2358,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); app.post("/api/admin/entities/:id/name", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = (await readBody>(event)) ?? {}; const name = String(body["value"] ?? body["name"] ?? "").trim(); if (!name || name.length > 128) { diff --git a/server/src/plugins/service-admin-http/routes-firmware.ts b/server/src/plugins/service-admin-http/routes-firmware.ts index bd394e0..f568b6f 100644 --- a/server/src/plugins/service-admin-http/routes-firmware.ts +++ b/server/src/plugins/service-admin-http/routes-firmware.ts @@ -153,7 +153,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { // ---- Per-kiosk firmware settings ---------------------------------------- // POST channel + target_version (used by KioskFirmwarePanel form) app.post("/admin/kiosks/:id/firmware", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel; const targetRaw = (body?.["target_version"] ?? "").trim(); @@ -176,7 +176,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { // and pulls /api/kiosk/firmware/check immediately. The actual download // happens kiosk-side over the existing kiosk_key channel. app.post("/admin/kiosks/:id/firmware/push", (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const dispatched = getCoordinator().sendToKiosk(id, { type: "firmware_check" }); return { ok: true, dispatched }; }); @@ -204,10 +204,10 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" }); const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100); const targetsRaw = body?.["target_kiosk_ids"]; - const targets: number[] = Array.isArray(targetsRaw) - ? targetsRaw.map((s) => Number(s)).filter((n) => Number.isFinite(n)) + const targets: string[] = Array.isArray(targetsRaw) + ? targetsRaw.map((s) => String(s)).filter((s) => s !== "") : typeof targetsRaw === "string" && targetsRaw - ? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) + ? targetsRaw.split(",").map((s) => s.trim()).filter((s) => s !== "") : []; const user = event.context.user!; const rollout = await deps.repo.createFirmwareRollout({ diff --git a/server/src/plugins/service-admin-http/routes-os-updates.ts b/server/src/plugins/service-admin-http/routes-os-updates.ts index ca60bc7..eaf8fb0 100644 --- a/server/src/plugins/service-admin-http/routes-os-updates.ts +++ b/server/src/plugins/service-admin-http/routes-os-updates.ts @@ -44,7 +44,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { // ---- Per-kiosk OS-update settings --------------------------------------- app.post("/admin/kiosks/:id/os-update", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const body = await readBody>(event); const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel; const targetRaw = (body?.["target_version"] ?? "").trim(); @@ -65,7 +65,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { // Push OS update now: server pings the kiosk via WS coordinator. app.post("/admin/kiosks/:id/os-update/push", async (event) => { - const id = Number(getRouterParam(event, "id")); + const id = (getRouterParam(event, "id") ?? ""); const { getCoordinator } = await import("../../shared/coordinator-registry.js"); const dispatched = getCoordinator().sendToKiosk(id, { type: "os_check" }); return { ok: true, dispatched }; @@ -93,10 +93,10 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" }); const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100); const targetsRaw = body?.["target_kiosk_ids"]; - const targets: number[] = Array.isArray(targetsRaw) - ? targetsRaw.map((s) => Number(s)).filter((n) => Number.isFinite(n)) + const targets: string[] = Array.isArray(targetsRaw) + ? targetsRaw.map((s) => String(s)).filter((s) => s !== "") : typeof targetsRaw === "string" && targetsRaw - ? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) + ? targetsRaw.split(",").map((s) => s.trim()).filter((s) => s !== "") : []; const user = event.context.user!; const rollout = await deps.repo.createOsUpdateRollout({ diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 311acb2..3de9ec4 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -557,7 +557,7 @@ function registerKioskRoutes( // Sync displays reported by the kiosk if (Array.isArray(body?.displays)) { const existing = await repo.listDisplaysForKiosk(kiosk.id); - const seenDisplayIds = new Set(); + const seenDisplayIds = new Set(); for (const [position, reported] of body.displays.entries()) { const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0 ? reported.index! @@ -659,7 +659,7 @@ function registerKioskRoutes( const body = await readBody<{ topic: string; source_type?: string; - camera_id?: number; + camera_id?: string; property_op?: string; payload?: Record; }>(event); @@ -699,9 +699,9 @@ function registerKioskRoutes( // Side-effect: persist active layout per display so the admin UI can // surface "currently showing X" without having to query event_log. if (body.topic === "layout.changed") { - const displayId = Number(body.payload?.["display_id"]); - const layoutId = Number(body.payload?.["layout_id"]); - if (Number.isInteger(displayId) && Number.isInteger(layoutId)) { + const displayId = String(body.payload?.["display_id"] ?? ""); + const layoutId = String(body.payload?.["layout_id"] ?? ""); + if (displayId && layoutId) { try { await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any); } catch { @@ -1080,7 +1080,7 @@ function registerKioskRoutes( const kiosk = await auth.verifyKioskKey(token); if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); - const cameraId = Number(getRouterParam(event, "id")); + const cameraId = (getRouterParam(event, "id") ?? ""); const camera = await repo.getCameraById(cameraId); if (!camera || camera.type !== "cloud" || !camera.cloud_account_id || !camera.cloud_vendor_camera_id) { throw createError({ statusCode: 404, statusMessage: "Cloud camera not found" }); @@ -1117,11 +1117,11 @@ function registerKioskRoutes( * targets the same half of the fleet across re-checks. Switch from 50%→100% * gracefully adds the previously-excluded half rather than reshuffling. */ -function isKioskInRolloutBucket(kioskId: number, rolloutId: string, percentage: number): boolean { +function isKioskInRolloutBucket(kioskId: string, rolloutId: string, percentage: number): boolean { if (percentage >= 100) return true; if (percentage <= 0) return false; const h = createHash("sha256") - .update(`${rolloutId}:${String(kioskId)}`) + .update(`${rolloutId}:${kioskId}`) .digest(); const bucket = h.readUInt32BE(0) % 100; return bucket < percentage; diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index a0a56d7..f389403 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -86,14 +86,14 @@ export const EventSchemas = createEventSchemas({ // ---- Connected kiosks ------------------------------------------------------- interface ConnectedKiosk { - id: number; + id: string; name: string; ws: WebSocket; } -const connectedKiosks = new Map(); +const connectedKiosks = new Map(); const pendingRequests = new Map void; reject: (err: Error) => void; timer: ReturnType; @@ -101,9 +101,9 @@ const pendingRequests = new Map>(); +const debugSubscribers = new Map>(); -function addDebugSubscriber(kioskId: number, adminWs: WebSocket): void { +function addDebugSubscriber(kioskId: string, adminWs: WebSocket): void { let subs = debugSubscribers.get(kioskId); if (!subs) { subs = new Set(); debugSubscribers.set(kioskId, subs); } subs.add(adminWs); @@ -117,7 +117,7 @@ function addDebugSubscriber(kioskId: number, adminWs: WebSocket): void { }); } -function relayToDebugSubscribers(kioskId: number, message: string): void { +function relayToDebugSubscribers(kioskId: string, message: string): void { const subs = debugSubscribers.get(kioskId); if (!subs) return; for (const ws of subs) { @@ -136,9 +136,9 @@ function parseCookieValue(header: string, name: string): string | null { // Per-kiosk message queue: if kiosk is offline, buffer messages here. // Drain on reconnect. FIFO, cap at 100 messages per kiosk. const MESSAGE_QUEUE_CAP = 100; -const offlineQueues = new Map(); +const offlineQueues = new Map(); -function sendToKiosk(kioskId: number, message: object): boolean { +function sendToKiosk(kioskId: string, message: object): boolean { const k = connectedKiosks.get(kioskId); const payload = JSON.stringify(message); if (!k || k.ws.readyState !== WebSocket.OPEN) { @@ -153,7 +153,7 @@ function sendToKiosk(kioskId: number, message: object): boolean { return true; } -function drainOfflineQueue(kioskId: number): void { +function drainOfflineQueue(kioskId: string): void { const q = offlineQueues.get(kioskId); if (!q || q.length === 0) return; const k = connectedKiosks.get(kioskId); @@ -164,7 +164,7 @@ function drainOfflineQueue(kioskId: number): void { offlineQueues.delete(kioskId); } -function requestKiosk(kioskId: number, message: object, timeoutMs = 10000): Promise { +function requestKiosk(kioskId: string, message: object, timeoutMs = 10000): Promise { const requestId = randomUUID(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -271,8 +271,8 @@ export class Plugin extends BSBService, typeof Event // Subscribes to a kiosk's journal + terminal output stream. if (url.pathname.startsWith("/ws/admin/debug/")) { const kioskIdStr = url.pathname.split("/").pop() ?? ""; - const kioskId = Number(kioskIdStr); - if (!Number.isInteger(kioskId) || kioskId <= 0) { + const kioskId = String(kioskIdStr); + if (!Number.isInteger(kioskId) || kioskId === "") { socket.write("HTTP/1.1 400 Bad Request\r\n\r\n"); socket.destroy(); return; @@ -454,7 +454,7 @@ export class Plugin extends BSBService, typeof Event requestKiosk, broadcastAll, notifyBundleChanged: () => broadcastAll({ type: "reload-bundle" }), - notifyKioskBundleChanged: (kioskId: number) => + notifyKioskBundleChanged: (kioskId: string) => sendToKiosk(kioskId, { type: "reload-bundle" }), }); diff --git a/server/src/shared/audit.ts b/server/src/shared/audit.ts index 2be7ffd..d7e896b 100644 --- a/server/src/shared/audit.ts +++ b/server/src/shared/audit.ts @@ -12,7 +12,7 @@ import type { AuditActorType, AuditResult } from "./types.js"; interface AuditCtx { context?: { - user?: { id?: number; username?: string }; + user?: { id?: string; username?: string }; apiKeyPrefix?: string; session?: unknown; }; @@ -26,7 +26,7 @@ export interface AuditInput { result?: AuditResult; /** Override actor (e.g. when system performs action on behalf of nobody). */ actor_type?: AuditActorType; - actor_id?: number | null; + actor_id?: string | null; actor_label?: string | null; } @@ -39,7 +39,7 @@ export async function audit( try { const ctx = event?.context; let actor_type: AuditActorType = input.actor_type ?? "system"; - let actor_id: number | null = input.actor_id ?? null; + let actor_id: string | null = input.actor_id ?? null; let actor_label: string | null = input.actor_label ?? null; if (!input.actor_type && ctx) { diff --git a/server/src/shared/auth.ts b/server/src/shared/auth.ts index 1e570b5..079b77e 100644 --- a/server/src/shared/auth.ts +++ b/server/src/shared/auth.ts @@ -52,7 +52,7 @@ export interface AuthApi { expiresAt: string | null; }): Promise<{ apiKey: ApiKey; plaintext: string }>; verifyApiKey(plaintext: string, ip: string | null): Promise; - verifyKioskKey(plaintext: string): Promise<{ id: number } | null>; + verifyKioskKey(plaintext: string): Promise<{ id: string } | null>; } // ---- Constants -------------------------------------------------------------- @@ -274,7 +274,7 @@ export function createAuth( return null; } - async function verifyKioskKey(plaintext: string): Promise<{ id: number } | null> { + async function verifyKioskKey(plaintext: string): Promise<{ id: string } | null> { if (plaintext.length < 8) return null; const prefix = plaintext.slice(0, 8); const candidates = await repo.listKiosksByKeyPrefix(prefix); diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index 28b013b..a311974 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -40,7 +40,7 @@ function buildStreamRtspUri(stream: CameraStream, cam: Camera): string { } export interface BundleCamera { - id: number; + id: string; name: string; type: string; rtsp_url: string | null; @@ -52,7 +52,7 @@ export interface BundleCamera { event_sink: string; stream_policy: string; streams: Array<{ - id: number; + id: string; role: string; name: string; /** Final playable RTSP URL with properly encoded credentials. */ @@ -70,7 +70,7 @@ export interface BundleCell { row_span: number; col_span: number; content_type: string; - camera_id: number | null; + camera_id: string | null; stream_selector: string | null; web_url: string | null; html_content: string | null; @@ -94,7 +94,7 @@ export interface BundleCell { } export interface BundleLayout { - id: number; + id: string; name: string; /** Computed from cells: max(col + col_span). 1 if no cells. */ grid_cols: number; @@ -102,7 +102,7 @@ export interface BundleLayout { grid_rows: number; priority: string; cooling_timeout_seconds: number | null; - preload_camera_ids: number[]; + preload_camera_ids: string[]; resets_idle_timer: boolean; /** True if the kiosk's display has this layout as its default_layout_id. */ is_default: boolean; @@ -110,13 +110,13 @@ export interface BundleLayout { } export interface BundleDisplay { - id: number; + id: string; name: string; width_px: number; height_px: number; idle_timeout_seconds: number; sleep_timeout_seconds: number; - default_layout_id: number | null; + default_layout_id: string | null; } export interface BundleDisplayWithLayouts extends BundleDisplay { @@ -124,7 +124,7 @@ export interface BundleDisplayWithLayouts extends BundleDisplay { } export interface BundleGpioBinding { - id: number; + id: string; chip: string; pin: number; direction: "in" | "out"; @@ -134,7 +134,7 @@ export interface BundleGpioBinding { } export interface KioskBundle { - kiosk_id: number; + kiosk_id: string; kiosk_name: string; /** * @deprecated Use `displays` (array). Kept for backward compat with older @@ -156,7 +156,7 @@ export interface KioskBundle { export async function generateBundle( repo: Repository, secrets: SecretsApi, - kioskId: number, + kioskId: string, clusterKey: string | undefined, obs?: Observable, ): Promise { @@ -194,13 +194,13 @@ export async function generateBundle( } // Collect camera IDs across ALL displays' layouts (de-duped). - const allLayoutIds = new Set(); + const allLayoutIds = new Set(); for (const d of displays) { for (const l of await repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id); } const cameras = await repo.camerasForLayoutIds([...allLayoutIds]); - async function buildLayouts(displayId: number, defaultLayoutId: number | null): Promise { + async function buildLayouts(displayId: string, defaultLayoutId: string | null): Promise { const layouts = await repo.layoutsForDisplayId(displayId); const result: BundleLayout[] = []; for (const l of layouts) { @@ -308,7 +308,7 @@ export async function generateBundle( const effectiveStreams = streams.length > 0 ? streams : ( cam.type === "rtsp" && cam.rtsp_url ? [{ - id: 0, + id: "", role: "main" as const, name: "Main", rtsp_uri: cam.rtsp_url, diff --git a/server/src/shared/coordinator-registry.ts b/server/src/shared/coordinator-registry.ts index 312f0b0..5466341 100644 --- a/server/src/shared/coordinator-registry.ts +++ b/server/src/shared/coordinator-registry.ts @@ -3,11 +3,11 @@ * service-coordinator-ws sets the implementation in its init(). */ export interface CoordinatorApi { - sendToKiosk(kioskId: number, message: object): boolean; - requestKiosk(kioskId: number, message: object, timeoutMs?: number): Promise; + sendToKiosk(kioskId: string, message: object): boolean; + requestKiosk(kioskId: string, message: object, timeoutMs?: number): Promise; broadcastAll(message: object): void; notifyBundleChanged(): void; - notifyKioskBundleChanged(kioskId: number): void; + notifyKioskBundleChanged(kioskId: string): void; } const noop: CoordinatorApi = { diff --git a/server/src/shared/db/mappers.ts b/server/src/shared/db/mappers.ts index 8fa279b..827ecb3 100644 --- a/server/src/shared/db/mappers.ts +++ b/server/src/shared/db/mappers.ts @@ -69,7 +69,7 @@ const nn = (v: unknown): number | null => export function rowToUser(r: Row): User { return { - id: n(r["id"]), + id: s(r["id"]), username: s(r["username"]), password_hash: s(r["password_hash"]), role: s(r["role"]) as UserRole, @@ -88,7 +88,7 @@ export function rowToUser(r: Row): User { export function rowToSession(r: Row): Session { return { id: s(r["id"]), - user_id: n(r["user_id"]), + user_id: s(r["user_id"]), csrf_token: s(r["csrf_token"]), totp_pending: b(r["totp_pending"]), user_agent: sn(r["user_agent"]), @@ -102,7 +102,7 @@ export function rowToSession(r: Row): Session { export function rowToApiKey(r: Row): ApiKey { return { - id: n(r["id"]), + id: s(r["id"]), name: s(r["name"]), key_hash: s(r["key_hash"]), key_prefix: s(r["key_prefix"]), @@ -128,14 +128,14 @@ export function rowToSetupState(r: Row): SetupState { export function rowToDisplay(r: Row): Display { return { - id: n(r["id"]), + id: s(r["id"]), name: s(r["name"]), index: n(r["index"]), is_primary: b(r["is_primary"]), - kiosk_id: nn(r["kiosk_id"]), + kiosk_id: sn(r["kiosk_id"]), width_px: n(r["width_px"]), height_px: n(r["height_px"]), - default_layout_id: nn(r["default_layout_id"]), + default_layout_id: sn(r["default_layout_id"]), idle_timeout_seconds: n(r["idle_timeout_seconds"]), sleep_timeout_seconds: n(r["sleep_timeout_seconds"]), cec_enabled: b(r["cec_enabled"]), @@ -147,13 +147,13 @@ export function rowToDisplay(r: Row): Display { state_check_enabled: b(r["state_check_enabled"]), state_check_interval_seconds: n(r["state_check_interval_seconds"]), is_enabled: b(r["is_enabled"]), - active_layout_id: nn(r["active_layout_id"]), + active_layout_id: sn(r["active_layout_id"]), }; } export function rowToCamera(r: Row): Camera { return { - id: n(r["id"]), + id: s(r["id"]), name: s(r["name"]), type: s(r["type"]) as CameraType, rtsp_url: sn(r["rtsp_url"]), @@ -178,8 +178,8 @@ export function rowToCamera(r: Row): Camera { export function rowToCameraStream(r: Row): CameraStream { return { - id: n(r["id"]), - camera_id: n(r["camera_id"]), + id: s(r["id"]), + camera_id: s(r["camera_id"]), role: s(r["role"]) as StreamRole, name: s(r["name"]), profile_token: sn(r["profile_token"]), @@ -198,7 +198,7 @@ export function rowToCameraStream(r: Row): CameraStream { export function rowToLayoutTemplate(r: Row): LayoutTemplate { return { - id: n(r["id"]), + id: s(r["id"]), name: s(r["name"]), description: sn(r["description"]), regions: j(r["regions"], []), @@ -210,17 +210,17 @@ export function rowToLayoutTemplate(r: Row): LayoutTemplate { export function rowToLayout(r: Row): Layout { return { - id: n(r["id"]), + id: s(r["id"]), name: s(r["name"]), description: sn(r["description"]), - template_id: nn(r["template_id"]), + template_id: sn(r["template_id"]), regions: j(r["regions"], []), grid_cols: n(r["grid_cols"]) || 1, grid_rows: n(r["grid_rows"]) || 1, - display_id: nn(r["display_id"]), + display_id: sn(r["display_id"]), priority: s(r["priority"]) as LayoutPriority, cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]), - preload_camera_ids: j(r["preload_camera_ids"], []), + preload_camera_ids: j(r["preload_camera_ids"], []), is_default: b(r["is_default"]), resets_idle_timer: b(r["resets_idle_timer"]), }; @@ -228,32 +228,32 @@ export function rowToLayout(r: Row): Layout { export function rowToLayoutCell(r: Row): LayoutCell { return { - id: n(r["id"]), - layout_id: n(r["layout_id"]), + id: s(r["id"]), + layout_id: s(r["layout_id"]), region_name: s(r["region_name"]), row: n(r["row"]), col: n(r["col"]), row_span: n(r["row_span"]) || 1, col_span: n(r["col_span"]) || 1, content_type: s(r["content_type"]) as CellContentType, - camera_id: nn(r["camera_id"]), + camera_id: sn(r["camera_id"]), stream_selector: s(r["stream_selector"]) as StreamSelector, web_url: sn(r["web_url"]), html_content: sn(r["html_content"]), cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]), options: j>(r["options"], {}), - entity_id: nn(r["entity_id"]), + entity_id: sn(r["entity_id"]), fit: (s(r["fit"]) || "cover") as "cover" | "contain" | "fill", }; } export function rowToEntity(r: Row): Entity { return { - id: n(r["id"]), + id: s(r["id"]), name: s(r["name"]), type: s(r["type"]) as EntityType, description: sn(r["description"]), - camera_id: nn(r["camera_id"]), + camera_id: sn(r["camera_id"]), html_content: sn(r["html_content"]), web_url: sn(r["web_url"]), dashboard_id: sn(r["dashboard_id"]), @@ -263,7 +263,7 @@ export function rowToEntity(r: Row): Entity { export function rowToKiosk(r: Row): Kiosk { return { - id: n(r["id"]), + id: s(r["id"]), name: s(r["name"]), description: sn(r["description"]), key_hash: s(r["key_hash"]), @@ -276,7 +276,7 @@ export function rowToKiosk(r: Row): Kiosk { paired_at: sn(r["paired_at"]), last_seen_at: sn(r["last_seen_at"]), last_bundle_version: sn(r["last_bundle_version"]), - display_id: nn(r["display_id"]), + display_id: sn(r["display_id"]), cpu_temp_c: nn(r["cpu_temp_c"]), cpu_load_percent: nn(r["cpu_load_percent"]), fan_rpm: nn(r["fan_rpm"]), @@ -314,10 +314,10 @@ export function rowToKiosk(r: Row): Kiosk { export function rowToAuditEntry(r: Row): AuditEntry { return { - id: n(r["id"]), + id: s(r["id"]), ts: s(r["ts"]), actor_type: s(r["actor_type"]) as AuditActorType, - actor_id: nn(r["actor_id"]), + actor_id: sn(r["actor_id"]), actor_label: sn(r["actor_label"]), action: s(r["action"]), resource_type: sn(r["resource_type"]), @@ -340,7 +340,7 @@ export function rowToFirmwareRelease(r: Row): FirmwareRelease { signature: s(r["signature"]), release_notes: sn(r["release_notes"]), uploaded_at: s(r["uploaded_at"]), - uploaded_by: nn(r["uploaded_by"]), + uploaded_by: sn(r["uploaded_by"]), yanked_at: sn(r["yanked_at"]), }; } @@ -349,13 +349,13 @@ export function rowToFirmwareRollout(r: Row): FirmwareRollout { return { id: s(r["id"]), release_id: s(r["release_id"]), - target_kiosk_ids: j(r["target_kiosk_ids"], []), + target_kiosk_ids: j(r["target_kiosk_ids"], []), state: s(r["state"]) as FirmwareRolloutState, percentage: n(r["percentage"]), started_at: sn(r["started_at"]), finished_at: sn(r["finished_at"]), created_at: s(r["created_at"]), - created_by: nn(r["created_by"]), + created_by: sn(r["created_by"]), }; } @@ -371,7 +371,7 @@ export function rowToOsUpdateRelease(r: Row): OsUpdateRelease { bundle_format: "raucb", release_notes: sn(r["release_notes"]), uploaded_at: s(r["uploaded_at"]), - uploaded_by: nn(r["uploaded_by"]), + uploaded_by: sn(r["uploaded_by"]), yanked_at: sn(r["yanked_at"]), }; } @@ -380,19 +380,19 @@ export function rowToOsUpdateRollout(r: Row): OsUpdateRollout { return { id: s(r["id"]), release_id: s(r["release_id"]), - target_kiosk_ids: j(r["target_kiosk_ids"], []), + target_kiosk_ids: j(r["target_kiosk_ids"], []), state: s(r["state"]) as OsUpdateRolloutState, percentage: n(r["percentage"]), started_at: sn(r["started_at"]), finished_at: sn(r["finished_at"]), created_at: s(r["created_at"]), - created_by: nn(r["created_by"]), + created_by: sn(r["created_by"]), }; } export function rowToLabel(r: Row): Label { return { - id: n(r["id"]), + id: s(r["id"]), name: s(r["name"]), description: sn(r["description"]), color: sn(r["color"]), @@ -402,8 +402,8 @@ export function rowToLabel(r: Row): Label { export function rowToKioskLabel(r: Row): KioskLabel { return { - kiosk_id: n(r["kiosk_id"]), - label_id: n(r["label_id"]), + kiosk_id: s(r["kiosk_id"]), + label_id: s(r["label_id"]), role: s(r["role"]) as LabelRole, }; } @@ -417,7 +417,7 @@ export function rowToPairingCode(r: Row): PairingCode { issued_at: s(r["issued_at"]), expires_at: s(r["expires_at"]), consumed_at: sn(r["consumed_at"]), - consumed_by_kiosk_id: nn(r["consumed_by_kiosk_id"]), + consumed_by_kiosk_id: sn(r["consumed_by_kiosk_id"]), extras: j>(r["extras"], {}), }; } @@ -426,8 +426,8 @@ export function rowToKioskGpioBinding(r: Row): KioskGpioBinding { const pullRaw = sn(r["pull"]); const edgeRaw = sn(r["edge"]); return { - id: n(r["id"]), - kiosk_id: n(r["kiosk_id"]), + id: s(r["id"]), + kiosk_id: s(r["kiosk_id"]), chip: s(r["chip"]) || "gpiochip0", pin: n(r["pin"]), direction: s(r["direction"]) as GpioDirection, @@ -440,9 +440,9 @@ export function rowToKioskGpioBinding(r: Row): KioskGpioBinding { export function rowToEventLog(r: Row): EventLog { return { - id: n(r["id"]), - source_kiosk_id: nn(r["source_kiosk_id"]), - source_camera_id: nn(r["source_camera_id"]), + id: s(r["id"]), + source_kiosk_id: sn(r["source_kiosk_id"]), + source_camera_id: sn(r["source_camera_id"]), source_type: s(r["source_type"]) as EventSourceType, topic: s(r["topic"]), property_op: sn(r["property_op"]), @@ -454,8 +454,8 @@ export function rowToEventLog(r: Row): EventLog { export function rowToKioskLog(r: Row): KioskLog { return { - id: n(r["id"]), - kiosk_id: n(r["kiosk_id"]), + id: s(r["id"]), + kiosk_id: s(r["kiosk_id"]), level: s(r["level"]) as KioskLogLevel, message: s(r["message"]), context: j>(r["context"], {}), @@ -480,11 +480,11 @@ export function rowToCloudAccount(r: Row): CloudAccount { export function rowToCameraEventSubscription(r: Row): CameraEventSubscription { return { - id: n(r["id"]), - camera_id: n(r["camera_id"]), + id: s(r["id"]), + camera_id: s(r["camera_id"]), topic: s(r["topic"]), status: s(r["status"]) as EventSubscriptionStatus, - subscribed_by_kiosk_id: nn(r["subscribed_by_kiosk_id"]), + subscribed_by_kiosk_id: sn(r["subscribed_by_kiosk_id"]), event_source: sn(r["event_source"]), event_sink: sn(r["event_sink"]), last_event_at: sn(r["last_event_at"]), diff --git a/server/src/shared/db/migrations-pg.ts b/server/src/shared/db/migrations-pg.ts index 5c2ee92..67a53c3 100644 --- a/server/src/shared/db/migrations-pg.ts +++ b/server/src/shared/db/migrations-pg.ts @@ -36,7 +36,7 @@ export const PUBLIC_MIGRATIONS: readonly string[] = [ )`, `CREATE TABLE IF NOT EXISTS global_admins ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, @@ -60,7 +60,7 @@ export const PUBLIC_MIGRATIONS: readonly string[] = [ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- users --------------------------------------------------------------- `CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')), @@ -78,7 +78,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- sessions ------------------------------------------------------------ `CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_token TEXT NOT NULL, totp_pending BOOLEAN NOT NULL DEFAULT false, user_agent TEXT, @@ -93,7 +93,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- api_keys ------------------------------------------------------------ `CREATE TABLE IF NOT EXISTS api_keys ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, key_hash TEXT NOT NULL, key_prefix TEXT NOT NULL, @@ -119,14 +119,14 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- displays (final schema — no UNIQUE on index, has kiosk_id) ---------- `CREATE TABLE IF NOT EXISTS displays ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, "index" INTEGER NOT NULL, is_primary BOOLEAN NOT NULL DEFAULT false, - kiosk_id INTEGER, + kiosk_id TEXT, width_px INTEGER NOT NULL DEFAULT 1920, height_px INTEGER NOT NULL DEFAULT 1080, - default_layout_id INTEGER, + default_layout_id TEXT, idle_timeout_seconds INTEGER NOT NULL DEFAULT 600, sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800, cec_enabled BOOLEAN NOT NULL DEFAULT true, @@ -140,14 +140,14 @@ export const TENANT_MIGRATIONS: readonly string[] = [ state_check_enabled BOOLEAN NOT NULL DEFAULT false, state_check_interval_seconds INTEGER NOT NULL DEFAULT 60, is_enabled BOOLEAN NOT NULL DEFAULT true, - active_layout_id INTEGER + active_layout_id TEXT )`, `CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`, `CREATE INDEX IF NOT EXISTS idx_displays_kiosk_index ON displays(kiosk_id, "index")`, // ---- cameras ------------------------------------------------------------- `CREATE TABLE IF NOT EXISTS cameras ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')), rtsp_url TEXT, @@ -172,8 +172,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [ `CREATE INDEX IF NOT EXISTS idx_cameras_cloud_account ON cameras(cloud_account_id)`, `CREATE TABLE IF NOT EXISTS camera_streams ( - id SERIAL PRIMARY KEY, - camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, + id TEXT NOT NULL PRIMARY KEY, + camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')), name TEXT NOT NULL, profile_token TEXT, @@ -192,7 +192,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- kiosks (final schema — all telemetry + update columns) -------------- `CREATE TABLE IF NOT EXISTS kiosks ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, key_hash TEXT NOT NULL, @@ -205,7 +205,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ paired_at TIMESTAMPTZ, last_seen_at TIMESTAMPTZ, last_bundle_version TEXT, - display_id INTEGER REFERENCES displays(id) ON DELETE SET NULL, + display_id TEXT REFERENCES displays(id) ON DELETE SET NULL, encrypt_key_encrypted TEXT, cpu_temp_c REAL, cpu_load_percent REAL, @@ -243,7 +243,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- layouts (final schema — no template_id, no display_id) -------------- `CREATE TABLE IF NOT EXISTS layouts ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')), @@ -254,28 +254,28 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- display_layouts (join table) ---------------------------------------- `CREATE TABLE IF NOT EXISTS display_layouts ( - display_id INTEGER NOT NULL REFERENCES displays(id) ON DELETE CASCADE, - layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, + display_id TEXT NOT NULL REFERENCES displays(id) ON DELETE CASCADE, + layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, PRIMARY KEY (display_id, layout_id) )`, `CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`, // ---- layout_cells (final schema — no region_name) ------------------------ `CREATE TABLE IF NOT EXISTS layout_cells ( - id SERIAL PRIMARY KEY, - layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, + id TEXT NOT NULL PRIMARY KEY, + layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, "row" INTEGER NOT NULL DEFAULT 0, col INTEGER NOT NULL DEFAULT 0, row_span INTEGER NOT NULL DEFAULT 1, col_span INTEGER NOT NULL DEFAULT 1, content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')), - camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, + camera_id TEXT REFERENCES cameras(id) ON DELETE SET NULL, stream_selector TEXT, web_url TEXT, html_content TEXT, cooling_timeout_seconds INTEGER, options JSONB NOT NULL DEFAULT '{}', - entity_id INTEGER, + entity_id TEXT, fit TEXT NOT NULL DEFAULT 'cover' )`, `CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`, @@ -283,26 +283,26 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- labels -------------------------------------------------------------- `CREATE TABLE IF NOT EXISTS labels ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, color TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, `CREATE TABLE IF NOT EXISTS kiosk_labels ( - kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, + label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK(role IN ('consume', 'operate')), PRIMARY KEY (kiosk_id, label_id, role) )`, `CREATE TABLE IF NOT EXISTS camera_labels ( - camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, + label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (camera_id, label_id) )`, `CREATE TABLE IF NOT EXISTS layout_labels ( - layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, + label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (layout_id, label_id) )`, @@ -315,15 +315,15 @@ export const TENANT_MIGRATIONS: readonly string[] = [ issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, - consumed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, + consumed_by_kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL, extras JSONB NOT NULL DEFAULT '{}' )`, // ---- event_log ----------------------------------------------------------- `CREATE TABLE IF NOT EXISTS event_log ( - id SERIAL PRIMARY KEY, - source_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, - source_camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, + id TEXT NOT NULL PRIMARY KEY, + source_kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL, + source_camera_id TEXT REFERENCES cameras(id) ON DELETE SET NULL, source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')), topic TEXT NOT NULL, property_op TEXT, @@ -336,11 +336,11 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- entities ------------------------------------------------------------ `CREATE TABLE IF NOT EXISTS entities ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')), description TEXT, - camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE, + camera_id TEXT REFERENCES cameras(id) ON DELETE CASCADE, html_content TEXT, web_url TEXT, dashboard_id TEXT, @@ -360,7 +360,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ signature TEXT NOT NULL, release_notes TEXT, uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), - uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + uploaded_by TEXT REFERENCES users(id) ON DELETE SET NULL, yanked_at TIMESTAMPTZ, UNIQUE(version, arch) )`, @@ -375,7 +375,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ started_at TIMESTAMPTZ, finished_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL + created_by TEXT REFERENCES users(id) ON DELETE SET NULL )`, `CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`, @@ -391,7 +391,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'), release_notes TEXT, uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), - uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + uploaded_by TEXT REFERENCES users(id) ON DELETE SET NULL, yanked_at TIMESTAMPTZ, UNIQUE(version, compatibility) )`, @@ -406,16 +406,16 @@ export const TENANT_MIGRATIONS: readonly string[] = [ started_at TIMESTAMPTZ, finished_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL + created_by TEXT REFERENCES users(id) ON DELETE SET NULL )`, `CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`, // ---- audit_log ----------------------------------------------------------- `CREATE TABLE IF NOT EXISTS audit_log ( - id SERIAL PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, ts TIMESTAMPTZ NOT NULL DEFAULT now(), actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')), - actor_id INTEGER, + actor_id TEXT, actor_label TEXT, action TEXT NOT NULL, resource_type TEXT, @@ -430,8 +430,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- kiosk GPIO bindings ------------------------------------------------- `CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings ( - id SERIAL PRIMARY KEY, - kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, + id TEXT NOT NULL PRIMARY KEY, + kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, chip TEXT NOT NULL DEFAULT 'gpiochip4', pin INTEGER NOT NULL, direction TEXT NOT NULL DEFAULT 'in' CHECK(direction IN ('in', 'out')), @@ -445,8 +445,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- kiosk_logs ---------------------------------------------------------- `CREATE TABLE IF NOT EXISTS kiosk_logs ( - id SERIAL PRIMARY KEY, - kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, + id TEXT NOT NULL PRIMARY KEY, + kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')), message TEXT NOT NULL, context JSONB NOT NULL DEFAULT '{}', @@ -471,11 +471,11 @@ export const TENANT_MIGRATIONS: readonly string[] = [ // ---- camera_event_subscriptions --------------------------------------------- `CREATE TABLE IF NOT EXISTS camera_event_subscriptions ( - id SERIAL PRIMARY KEY, - camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, + id TEXT NOT NULL PRIMARY KEY, + camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, topic TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('inactive', 'pending', 'active', 'failed')), - subscribed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, + subscribed_by_kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL, last_event_at TIMESTAMPTZ, error_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index c9348a8..0c8fed9 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -9,6 +9,7 @@ * cross workers with the same handle. */ import { randomBytes } from "node:crypto"; +import { uuidv7 } from "uuidv7"; import type { Observable } from "@bsb/base"; import type { DbAdapter, RunResult, Row } from "./db-adapter.js"; @@ -215,7 +216,7 @@ export class Repository { return r?.c ?? 0; } - async getUserById(id: number): Promise { + async getUserById(id: string): Promise { const r = await this._get("SELECT * FROM users WHERE id = ?", [id]); return r ? rowToUser(r as Record) : null; } @@ -231,11 +232,13 @@ export class Repository { role?: UserRole; must_change_password?: boolean; }): Promise { + const id = uuidv7(); const role: UserRole = input.role ?? "operator"; - const result = await this._run( - `INSERT INTO users (username, password_hash, role, is_active, must_change_password) - VALUES (?, ?, ?, ?, ?) RETURNING id`, + await this._run( + `INSERT INTO users (id, username, password_hash, role, is_active, must_change_password) + VALUES (?, ?, ?, ?, ?, ?)`, [ + id, input.username, input.password_hash, role, @@ -243,14 +246,13 @@ export class Repository { Boolean(input.must_change_password), ], ); - const id = Number(result.lastInsertRowid); void this.notify("users", "create", id); const u = await this.getUserById(id); if (!u) throw new Error("user vanished after insert"); return u; } - async updateUser(id: number, patch: Partial): Promise { + async updateUser(id: string, patch: Partial): Promise { const cols: string[] = []; const vals: unknown[] = []; if ("password_hash" in patch) { @@ -301,7 +303,7 @@ export class Repository { async createSession(input: { id: string; - user_id: number; + user_id: string; csrf_token: string; totp_pending: boolean; user_agent: string | null; @@ -350,7 +352,7 @@ export class Repository { await this._run("UPDATE sessions SET revoked_at = ? WHERE id = ?", [isoNow(), id]); } - async revokeAllSessionsForUser(userId: number): Promise { + async revokeAllSessionsForUser(userId: string): Promise { await this._run( `UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, @@ -369,10 +371,12 @@ export class Repository { scopes: ApiKeyScope[]; expires_at: string | null; }): Promise { - const result = await this._run( - `INSERT INTO api_keys (name, key_hash, key_prefix, scopes, expires_at) - VALUES (?, ?, ?, ?, ?) RETURNING id`, + const id = uuidv7(); + await this._run( + `INSERT INTO api_keys (id, name, key_hash, key_prefix, scopes, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, [ + id, input.name, input.key_hash, input.key_prefix, @@ -380,14 +384,13 @@ export class Repository { input.expires_at, ], ); - const id = Number(result.lastInsertRowid); void this.notify("api_keys", "create", id); const k = await this.getApiKeyById(id); if (!k) throw new Error("api_key vanished after insert"); return k; } - async getApiKeyById(id: number): Promise { + async getApiKeyById(id: string): Promise { const r = await this._get("SELECT * FROM api_keys WHERE id = ?", [id]); return r ? rowToApiKey(r as Record) : null; } @@ -401,7 +404,7 @@ export class Repository { return rs.map((r) => rowToApiKey(r as Record)); } - async touchApiKey(id: number, ip: string | null): Promise { + async touchApiKey(id: string, ip: string | null): Promise { await this._run( "UPDATE api_keys SET last_used_at = ?, last_used_ip = ? WHERE id = ?", [isoNow(), ip, id], @@ -417,35 +420,37 @@ export class Repository { return rs.map((r) => rowToDisplay(r as Record)); } - async getDisplayById(id: number): Promise { + async getDisplayById(id: string): Promise { const r = await this._get("SELECT * FROM displays WHERE id = ?", [id]); return r ? rowToDisplay(r as Record) : null; } async createDefaultDisplay(): Promise { - const result = await this._run( - `INSERT INTO displays (name, "index", is_primary) - VALUES ('primary', 0, ?) RETURNING id`, - [false], + const id = uuidv7(); + await this._run( + `INSERT INTO displays (id, name, "index", is_primary) + VALUES (?, 'primary', 0, ?)`, + [id, false], ); - const id = Number(result.lastInsertRowid); void this.notify("displays", "create", id); const d = await this.getDisplayById(id); if (!d) throw new Error("display vanished after insert"); return d; } - async createDisplayForKiosk(kioskId: number, input: { + async createDisplayForKiosk(kioskId: string, input: { name: string; index?: number; width_px?: number; height_px?: number; }): Promise { const idx = input.index ?? await this.nextDisplayIndexForKiosk(kioskId); - const result = await this._run( - `INSERT INTO displays (name, "index", is_primary, kiosk_id, width_px, height_px) - VALUES (?, ?, ?, ?, ?, ?) RETURNING id`, + const id = uuidv7(); + await this._run( + `INSERT INTO displays (id, name, "index", is_primary, kiosk_id, width_px, height_px) + VALUES (?, ?, ?, ?, ?, ?, ?)`, [ + id, input.name, idx, false, @@ -454,14 +459,13 @@ export class Repository { input.height_px ?? 1080, ], ); - const id = Number(result.lastInsertRowid); void this.notify("displays", "create", id); const d = await this.getDisplayById(id); if (!d) throw new Error("display vanished after insert"); return d; } - async listDisplaysForKiosk(kioskId: number): Promise { + async listDisplaysForKiosk(kioskId: string): Promise { const rs = await this._all( 'SELECT * FROM displays WHERE kiosk_id = ? ORDER BY "index"', [kioskId], @@ -473,7 +477,7 @@ export class Repository { * Kiosks currently rendering this camera (active layout has a cell * pointing at it). Subset of listKiosksWithCameraInBundle. */ - async listKiosksRenderingCamera(cameraId: number): Promise { + async listKiosksRenderingCamera(cameraId: string): Promise { const rs = await this._all( `SELECT DISTINCT k.* FROM kiosks k @@ -495,7 +499,7 @@ export class Repository { * LAN position. Only when NO kiosk has the camera should the server * fall back to pulling the stream itself. */ - async listKiosksWithCameraInBundle(cameraId: number): Promise { + async listKiosksWithCameraInBundle(cameraId: string): Promise { const rs = await this._all( `SELECT DISTINCT k.* FROM kiosks k @@ -509,12 +513,12 @@ export class Repository { return rs.map((r) => rowToKiosk(r as Record)); } - private async nextDisplayIndexForKiosk(kioskId: number): Promise { + private async nextDisplayIndexForKiosk(kioskId: string): Promise { const r = await this._get<{ m: number | null }>('SELECT MAX("index") AS m FROM displays WHERE kiosk_id = ?', [kioskId]); return (r?.m ?? -1) + 1; } - async updateDisplay(id: number, patch: Partial): Promise { + async updateDisplay(id: string, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { @@ -542,7 +546,7 @@ export class Repository { return rs.map((r) => rowToLayout(r as Record)); } - async getLayoutById(id: number): Promise { + async getLayoutById(id: string): Promise { const r = await this._get("SELECT * FROM layouts WHERE id = ?", [id]); return r ? rowToLayout(r as Record) : null; } @@ -552,12 +556,12 @@ export class Repository { * `display_layouts` join table. Kept as a thin alias for any * callers still on the old API. */ - async layoutsForDisplay(displayId: number): Promise { + async layoutsForDisplay(displayId: string): Promise { return this.listLayoutsForDisplay(displayId); } /** All layouts attached to the given display, via display_layouts. */ - async listLayoutsForDisplay(displayId: number): Promise { + async listLayoutsForDisplay(displayId: string): Promise { const rs = await this._all( `SELECT l.* FROM layouts l JOIN display_layouts dl ON dl.layout_id = l.id @@ -569,7 +573,7 @@ export class Repository { } /** Inverse: all displays that have this layout attached. */ - async listDisplaysForLayout(layoutId: number): Promise { + async listDisplaysForLayout(layoutId: string): Promise { const rs = await this._all( `SELECT d.* FROM displays d JOIN display_layouts dl ON dl.display_id = d.id @@ -581,7 +585,7 @@ export class Repository { } /** Idempotent attach. */ - async attachLayoutToDisplay(displayId: number, layoutId: number): Promise { + async attachLayoutToDisplay(displayId: string, layoutId: string): Promise { await this._run( `INSERT OR IGNORE INTO display_layouts (display_id, layout_id) VALUES (?, ?)`, @@ -591,7 +595,7 @@ export class Repository { } /** Detach. If the display's default_layout_id pointed at this layout, clear it. */ - async detachLayoutFromDisplay(displayId: number, layoutId: number): Promise { + async detachLayoutFromDisplay(displayId: string, layoutId: string): Promise { await this._run( `DELETE FROM display_layouts WHERE display_id = ? AND layout_id = ?`, [displayId, layoutId], @@ -609,13 +613,15 @@ export class Repository { description?: string | null; priority?: string; cooling_timeout_seconds?: number | null; - preload_camera_ids?: number[]; + preload_camera_ids?: string[]; resets_idle_timer?: boolean; }): Promise { - const result = await this._run( - `INSERT INTO layouts (name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer) - VALUES (?, ?, ?, ?, ?, ?) RETURNING id`, + const id = uuidv7(); + await this._run( + `INSERT INTO layouts (id, name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer) + VALUES (?, ?, ?, ?, ?, ?, ?)`, [ + id, input.name, input.description ?? null, input.priority ?? "normal", @@ -624,14 +630,13 @@ export class Repository { Boolean(input.resets_idle_timer ?? true), ], ); - const id = Number(result.lastInsertRowid); void this.notify("layouts", "create", id); const r = await this.getLayoutById(id); if (!r) throw new Error("layout vanished after insert"); return r; } - async updateLayout(id: number, patch: Partial): Promise { + async updateLayout(id: string, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { @@ -647,7 +652,7 @@ export class Repository { void this.notify("layouts", "update", id); } - async cloneLayout(id: number): Promise { + async cloneLayout(id: string): Promise { const src = await this.getLayoutById(id); if (!src) throw new Error("layout not found"); @@ -687,7 +692,7 @@ export class Repository { }); } - const labels = await this._all<{ label_id: number }>( + const labels = await this._all<{ label_id: string }>( "SELECT label_id FROM layout_labels WHERE layout_id = ?", [id], ); @@ -695,7 +700,7 @@ export class Repository { await this.attachLayoutLabel(clone.id, ll.label_id); } - const displays = await this._all<{ display_id: number }>( + const displays = await this._all<{ display_id: string }>( "SELECT display_id FROM display_layouts WHERE layout_id = ?", [id], ); @@ -706,7 +711,7 @@ export class Repository { return clone; } - async deleteLayout(id: number): Promise { + async deleteLayout(id: string): Promise { await this._run(`DELETE FROM layout_cells WHERE layout_id = ?`, [id]); await this._run(`DELETE FROM layout_labels WHERE layout_id = ?`, [id]); await this._run(`DELETE FROM display_layouts WHERE layout_id = ?`, [id]); @@ -721,19 +726,19 @@ export class Repository { // =========================================================================== async createLayoutCell(input: { - layout_id: number; + layout_id: string; row: number; col: number; row_span?: number; col_span?: number; content_type?: string; - camera_id?: number | null; + camera_id?: string | null; stream_selector?: string | null; web_url?: string | null; html_content?: string | null; cooling_timeout_seconds?: number | null; options?: Record; - entity_id?: number | null; + entity_id?: string | null; fit?: "cover" | "contain" | "fill"; }): Promise { // Resolve content fields from the entity (if given). The legacy columns @@ -741,7 +746,7 @@ export class Repository { // entities materialise as web cells pointing at /dash/ so the existing // kiosk's WebKit cell path renders them with no app changes. let contentType = input.content_type ?? "none"; - let cameraId: number | null = input.camera_id ?? null; + let cameraId: string | null = input.camera_id ?? null; let webUrl: string | null = input.web_url ?? null; let htmlContent: string | null = input.html_content ?? null; if (input.entity_id != null) { @@ -757,10 +762,12 @@ export class Repository { } } - const result = await this._run( - `INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options, entity_id, fit) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + const id = uuidv7(); + await this._run( + `INSERT INTO layout_cells (id, layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options, entity_id, fit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ + id, input.layout_id, input.row, input.col, @@ -777,7 +784,6 @@ export class Repository { input.fit ?? "cover", ], ); - const id = Number(result.lastInsertRowid); void this.notify("layout_cells", "create", id); const r = await this._get("SELECT * FROM layout_cells WHERE id = ?", [id]); if (!r) throw new Error("layout_cell vanished after insert"); @@ -789,7 +795,7 @@ export class Repository { * type/camera/url/html into the legacy cell columns so bundle generation stays * compatible with the existing kiosk. */ - async assignCellEntity(cellId: number, entityId: number | null): Promise { + async assignCellEntity(cellId: string, entityId: string | null): Promise { if (entityId == null) { await this._run( `UPDATE layout_cells @@ -831,7 +837,7 @@ export class Repository { void this.notify("layout_cells", "update", cellId); } - async updateLayoutCell(id: number, patch: Partial): Promise { + async updateLayoutCell(id: string, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { @@ -847,7 +853,7 @@ export class Repository { void this.notify("layout_cells", "update", id); } - async deleteLayoutCell(id: number): Promise { + async deleteLayoutCell(id: string): Promise { await this._run(`DELETE FROM layout_cells WHERE id = ?`, [id]); void this.notify("layout_cells", "delete", id); } @@ -876,7 +882,7 @@ export class Repository { void this.notify("layout_cells", "update", layoutId); } - async listLayoutCells(layoutId: number): Promise { + async listLayoutCells(layoutId: string): Promise { const rs = await this._all( `SELECT * FROM layout_cells WHERE layout_id = ? ORDER BY "row", col`, [layoutId], @@ -884,7 +890,7 @@ export class Repository { return rs.map((r) => rowToLayoutCell(r as Record)); } - async getLayoutCellById(id: number): Promise { + async getLayoutCellById(id: string): Promise { const r = await this._get("SELECT * FROM layout_cells WHERE id = ?", [id]); return r ? rowToLayoutCell(r as Record) : null; } @@ -894,11 +900,11 @@ export class Repository { // =========================================================================== /** Bundle generation: layouts attached to a display via display_layouts. */ - async layoutsForDisplayId(displayId: number): Promise { + async layoutsForDisplayId(displayId: string): Promise { return this.listLayoutsForDisplay(displayId); } - async camerasForLayoutIds(layoutIds: number[]): Promise { + async camerasForLayoutIds(layoutIds: string[]): Promise { if (layoutIds.length === 0) return []; const placeholders = layoutIds.map(() => "?").join(","); const rs = await this._all( @@ -921,7 +927,7 @@ export class Repository { return rs.map((r) => rowToCamera(r as Record)); } - async getCameraById(id: number): Promise { + async getCameraById(id: string): Promise { const r = await this._get("SELECT * FROM cameras WHERE id = ?", [id]); return r ? rowToCamera(r as Record) : null; } @@ -942,12 +948,14 @@ export class Repository { capabilities?: string[]; stream_policy?: StreamPolicy; }): Promise { - const result = await this._run( + const id = uuidv7(); + await this._run( `INSERT INTO cameras - (name, type, rtsp_url, onvif_host, onvif_port, onvif_username, + (id, name, type, rtsp_url, onvif_host, onvif_port, onvif_username, onvif_password, capabilities, stream_policy) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ + id, input.name, input.type, input.rtsp_url ?? null, @@ -959,7 +967,6 @@ export class Repository { input.stream_policy ?? "auto", ], ); - const id = Number(result.lastInsertRowid); void this.notify("cameras", "create", id); const c = await this.getCameraById(id); if (!c) throw new Error("camera vanished after insert"); @@ -989,14 +996,14 @@ export class Repository { void this.notify("cameras", "update", cam.id); return (await this.getCameraById(cam.id))!; } - const result = await this._run( + const id = uuidv7(); + await this._run( `INSERT INTO cameras - (name, type, cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type, enabled) - VALUES (?, 'cloud', ?, ?, ?, ?, ?) RETURNING id`, - [input.name, input.cloud_account_id, input.cloud_vendor_camera_id, + (id, name, type, cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type, enabled) + VALUES (?, ?, 'cloud', ?, ?, ?, ?, ?)`, + [id, input.name, input.cloud_account_id, input.cloud_vendor_camera_id, input.cloud_stream_url, input.cloud_stream_type, Boolean(input.enabled)], ); - const id = Number(result.lastInsertRowid); void this.notify("cameras", "create", id); const c = await this.getCameraById(id); if (!c) throw new Error("cloud camera vanished after insert"); @@ -1028,7 +1035,7 @@ export class Repository { return result.changes; } - async listCameraStreams(cameraId: number): Promise { + async listCameraStreams(cameraId: string): Promise { const rs = await this._all( "SELECT * FROM camera_streams WHERE camera_id = ?", [cameraId], @@ -1037,7 +1044,7 @@ export class Repository { } async createCameraStream(input: { - camera_id: number; + camera_id: string; role: StreamRole; name: string; rtsp_uri: string; @@ -1052,13 +1059,15 @@ export class Repository { bitrate_kbps?: number | null; is_discovered?: boolean; }): Promise { - const result = await this._run( + const id = uuidv7(); + await this._run( `INSERT INTO camera_streams - (camera_id, role, name, profile_token, rtsp_uri, + (id, camera_id, role, name, profile_token, rtsp_uri, rtsp_host, rtsp_port, rtsp_path, width, height, encoding, framerate, bitrate_kbps, is_discovered) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ + id, input.camera_id, input.role, input.name, @@ -1075,14 +1084,13 @@ export class Repository { Boolean(input.is_discovered), ], ); - const id = Number(result.lastInsertRowid); const r = await this._get("SELECT * FROM camera_streams WHERE id = ?", [id]); if (!r) throw new Error("camera_stream vanished after insert"); void this.notify("camera_streams", "create", id); return rowToCameraStream(r as Record); } - async updateCameraStream(id: number, patch: Partial): Promise { + async updateCameraStream(id: string, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { @@ -1115,12 +1123,12 @@ export class Repository { description?: string | null; color?: string | null; }): Promise