/** * Repository — typed accessor over the sqlite handle. * * Keeps prepared statements cached for the life of the connection. All * mutating methods invoke the `notify` callback with (table, op, id) so the * surrounding plugin can broadcast a `store.changed` event. * * NOT THREAD SAFE — node:sqlite is single-threaded, and so is Node. Don't * 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"; import type { ApiKey, ApiKeyScope, AuditActorType, AuditEntry, AuditResult, Camera, CameraEventSubscription, CameraStream, CameraType, CloudAccount, Display, Entity, EntityType, EventLog, EventQueryFilters, EventSourceType, EventSubscriptionStatus, FirmwareChannel, FirmwareRelease, FirmwareRollout, FirmwareRolloutState, GpioDirection, GpioEdge, GpioPull, Kiosk, KioskGpioBinding, KioskLabel, KioskLog, KioskLogLevel, KioskLogQueryFilters, Label, LabelRole, Layout, LayoutCell, LayoutTemplate, OsUpdateRelease, OsUpdateRollout, OsUpdateRolloutState, PairingCode, Session, SetupState, StreamPolicy, StreamRole, Tenant, User, UserRole, } from "../types.js"; import { rowToApiKey, rowToAuditEntry, rowToCamera, rowToCameraEventSubscription, rowToCloudAccount, rowToCameraStream, rowToDisplay, rowToEntity, rowToEventLog, rowToFirmwareRelease, rowToFirmwareRollout, rowToKiosk, rowToKioskGpioBinding, rowToKioskLog, rowToLabel, rowToLayout, rowToLayoutCell, rowToLayoutTemplate, rowToOsUpdateRelease, rowToOsUpdateRollout, rowToPairingCode, rowToSession, rowToSetupState, rowToTenant, rowToUser, } from "./mappers.js"; import { J, isoIn, isoNow, j } from "./util.js"; type NotifyFn = ( table: string, op: "create" | "update" | "delete", id?: string | number, ) => Promise; export class Repository { readonly adapter: DbAdapter; private readonly notify: NotifyFn; private _obs?: Observable; constructor(adapter: DbAdapter, notify: NotifyFn) { this.adapter = adapter; this.notify = notify; } /** Set a per-request observable for DB call tracing. */ withObs(obs: Observable): this { this._obs = obs; return this; } /** Clear the per-request observable. */ clearObs(): this { this._obs = undefined; return this; } /** Run a write statement. Params are passed as an array. */ private async _run(sql: string, params: unknown[] = []): Promise { const span = this._obs?.startSpan("db.run", { "db.statement": sql.slice(0, 100) }); try { const result = await this.adapter.run(sql, params as any); span?.end(); return result; } catch (err) { span?.log.error("db error: {err}", { err: (err as Error).message }); span?.end(); throw err; } } /** Single-row query. */ private async _get(sql: string, params: unknown[] = []): Promise { const span = this._obs?.startSpan("db.get", { "db.statement": sql.slice(0, 100) }); try { const result = await this.adapter.get(sql, params as any); span?.end(); return result; } catch (err) { span?.log.error("db error: {err}", { err: (err as Error).message }); span?.end(); throw err; } } /** Multi-row query. */ private async _all(sql: string, params: unknown[] = []): Promise { const span = this._obs?.startSpan("db.all", { "db.statement": sql.slice(0, 100) }); try { const result = await this.adapter.all(sql, params as any); span?.end(); return result; } catch (err) { span?.log.error("db error: {err}", { err: (err as Error).message }); span?.end(); throw err; } } /** Execute DDL. */ private _exec(sql: string): Promise { return this.adapter.exec(sql); } /** Ad-hoc transaction. */ async transact(fn: () => Promise): Promise { return this.adapter.transaction(fn); } // =========================================================================== // tenants (PUBLIC schema — always use public.tenants explicitly) // =========================================================================== /** List all tenants. Queries public.tenants regardless of current search_path. */ async listTenants(): Promise { if (this.adapter.dialect() !== "postgres") return []; const rs = await this._all( "SELECT * FROM public.tenants ORDER BY created_at", ); return rs.map((r) => rowToTenant(r as Record)); } /** Get tenant by UUID. */ async getTenantById(id: string): Promise { if (this.adapter.dialect() !== "postgres") return null; const r = await this._get("SELECT * FROM public.tenants WHERE id = ?", [id]); return r ? rowToTenant(r as Record) : null; } /** Get tenant by slug. */ async getTenantBySlug(slug: string): Promise { if (this.adapter.dialect() !== "postgres") return null; const r = await this._get("SELECT * FROM public.tenants WHERE slug = ?", [slug]); return r ? rowToTenant(r as Record) : null; } /** Create a new tenant in public.tenants. Does NOT create the PG schema — call createTenantSchema separately. */ async createTenant(input: { name: string; slug: string; max_kiosks?: number | null; max_cameras?: number | null; max_users?: number | null; }): Promise { const schemaName = input.slug === "default" ? "public" : `tenant_${input.slug}`; await this._run( `INSERT INTO public.tenants (name, slug, schema_name, is_active, max_kiosks, max_cameras, max_users) VALUES (?, ?, ?, true, ?, ?, ?)`, [ input.name, input.slug, schemaName, input.max_kiosks ?? null, input.max_cameras ?? null, input.max_users ?? null, ], ); void this.notify("tenants", "create"); const t = await this.getTenantBySlug(input.slug); if (!t) throw new Error("tenant vanished after insert"); return t; } /** Update tenant metadata. */ async updateTenant(id: string, patch: Partial>): Promise { const sets: string[] = []; const vals: unknown[] = []; if ("name" in patch) { sets.push("name = ?"); vals.push(patch.name); } if ("is_active" in patch) { sets.push("is_active = ?"); vals.push(Boolean(patch.is_active)); } if ("max_kiosks" in patch) { sets.push("max_kiosks = ?"); vals.push(patch.max_kiosks ?? null); } if ("max_cameras" in patch) { sets.push("max_cameras = ?"); vals.push(patch.max_cameras ?? null); } if ("max_users" in patch) { sets.push("max_users = ?"); vals.push(patch.max_users ?? null); } if (sets.length === 0) return; vals.push(id); await this._run(`UPDATE public.tenants SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("tenants", "update", id); } /** Delete a tenant. WARNING: does not drop the PG schema — that must be done separately if desired. */ async deleteTenant(id: string): Promise { await this._run("DELETE FROM public.tenants WHERE id = ?", [id]); void this.notify("tenants", "delete", id); } /** Count tenants. Used to check if multi-tenant is enabled. */ async countTenants(): Promise { if (this.adapter.dialect() !== "postgres") return 0; const r = await this._get<{ c: number }>("SELECT COUNT(*) AS c FROM public.tenants"); return r?.c ?? 0; } // =========================================================================== // setup_state // =========================================================================== async getSetupState(): Promise { const r = await this._get("SELECT * FROM setup_state WHERE id = 1"); if (!r) throw new Error("setup_state row missing"); return rowToSetupState(r as Record); } async isSetupComplete(): Promise { return (await this.getSetupState()).is_complete && (await this.countUsers()) > 0; } async markSetupComplete(): Promise { await this._run( `UPDATE setup_state SET is_complete = ?, completed_at = COALESCE(completed_at, ?) WHERE id = 1`, [true, isoNow()], ); void this.notify("setup_state", "update", 1); } async setSetupExtra(key: string, value: unknown): Promise { const cur = (await this.getSetupState()).extras; cur[key] = value; await this._run("UPDATE setup_state SET extras = ? WHERE id = 1", [J(cur)]); } async getSetupExtra(key: string): Promise { return (await this.getSetupState()).extras[key]; } async markClusterKeyProvisioned(): Promise { await this._run( "UPDATE setup_state SET cluster_key_provisioned = ? WHERE id = 1", [true], ); } // =========================================================================== // users // =========================================================================== async countUsers(): Promise { const r = await this._get<{ c: number }>("SELECT COUNT(*) AS c FROM users"); return r?.c ?? 0; } async getUserById(id: string): Promise { const r = await this._get("SELECT * FROM users WHERE id = ?", [id]); return r ? rowToUser(r as Record) : null; } async getUserByUsername(username: string): Promise { const r = await this._get("SELECT * FROM users WHERE username = ?", [username]); return r ? rowToUser(r as Record) : null; } async createUser(input: { username: string; password_hash: string; role?: UserRole; must_change_password?: boolean; }): Promise { const id = uuidv7(); const role: UserRole = input.role ?? "operator"; await this._run( `INSERT INTO users (id, username, password_hash, role, is_active, must_change_password) VALUES (?, ?, ?, ?, ?, ?)`, [ id, input.username, input.password_hash, role, true, Boolean(input.must_change_password), ], ); 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: string, patch: Partial): Promise { const cols: string[] = []; const vals: unknown[] = []; if ("password_hash" in patch) { cols.push("password_hash = ?"); vals.push(patch.password_hash); } if ("totp_enabled" in patch) { cols.push("totp_enabled = ?"); vals.push(Boolean(patch.totp_enabled)); } if ("totp_secret_encrypted" in patch) { cols.push("totp_secret_encrypted = ?"); vals.push(patch.totp_secret_encrypted); } if ("recovery_codes_hashed" in patch) { cols.push("recovery_codes_hashed = ?"); vals.push(J(patch.recovery_codes_hashed)); } if ("must_change_password" in patch) { cols.push("must_change_password = ?"); vals.push(Boolean(patch.must_change_password)); } if ("failed_login_count" in patch) { cols.push("failed_login_count = ?"); vals.push(patch.failed_login_count); } if ("locked_until" in patch) { cols.push("locked_until = ?"); vals.push(patch.locked_until); } if ("last_login_at" in patch) { cols.push("last_login_at = ?"); vals.push(patch.last_login_at); } if ("is_active" in patch) { cols.push("is_active = ?"); vals.push(Boolean(patch.is_active)); } if (cols.length === 0) return; vals.push(id); await this._run(`UPDATE users SET ${cols.join(", ")} WHERE id = ?`, vals); void this.notify("users", "update", id); } // =========================================================================== // sessions // =========================================================================== async createSession(input: { id: string; user_id: string; csrf_token: string; totp_pending: boolean; user_agent: string | null; ip_address: string | null; expires_at: string; // absolute }): Promise { await this._run( `INSERT INTO sessions (id, user_id, csrf_token, totp_pending, user_agent, ip_address, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ input.id, input.user_id, input.csrf_token, Boolean(input.totp_pending), input.user_agent, input.ip_address, input.expires_at, ], ); const s = await this.getSessionById(input.id); if (!s) throw new Error("session vanished after insert"); return s; } async getSessionById(id: string): Promise { const r = await this._get("SELECT * FROM sessions WHERE id = ?", [id]); return r ? rowToSession(r as Record) : null; } async touchSession(id: string, lastSeenAt: string): Promise { await this._run("UPDATE sessions SET last_seen_at = ? WHERE id = ?", [ lastSeenAt, id, ]); } async setSessionTotpPending(id: string, pending: boolean): Promise { await this._run("UPDATE sessions SET totp_pending = ? WHERE id = ?", [ pending, id, ]); } async revokeSession(id: string): Promise { await this._run("UPDATE sessions SET revoked_at = ? WHERE id = ?", [isoNow(), id]); } async revokeAllSessionsForUser(userId: string): Promise { await this._run( `UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, [isoNow(), userId], ); } // =========================================================================== // api_keys // =========================================================================== async createApiKey(input: { name: string; key_hash: string; key_prefix: string; scopes: ApiKeyScope[]; expires_at: string | null; }): Promise { 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, J(input.scopes), input.expires_at, ], ); 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: string): Promise { const r = await this._get("SELECT * FROM api_keys WHERE id = ?", [id]); return r ? rowToApiKey(r as Record) : null; } /** Lookup all candidates for a given prefix (typically returns 0 or 1). */ async listApiKeysByPrefix(prefix: string): Promise { const rs = await this._all( "SELECT * FROM api_keys WHERE key_prefix = ? AND revoked_at IS NULL", [prefix], ); return rs.map((r) => rowToApiKey(r as Record)); } 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], ); } // =========================================================================== // displays // =========================================================================== async listDisplays(): Promise { const rs = await this._all('SELECT * FROM displays ORDER BY "index"'); return rs.map((r) => rowToDisplay(r as Record)); } 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 id = uuidv7(); await this._run( `INSERT INTO displays (id, name, "index", is_primary) VALUES (?, 'primary', 0, ?)`, [id, false], ); 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: string, input: { name: string; index?: number; width_px?: number; height_px?: number; }): Promise { const idx = input.index ?? await this.nextDisplayIndexForKiosk(kioskId); 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, kioskId, input.width_px ?? 1920, input.height_px ?? 1080, ], ); 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: string): Promise { const rs = await this._all( 'SELECT * FROM displays WHERE kiosk_id = ? ORDER BY "index"', [kioskId], ); return rs.map((r) => rowToDisplay(r as Record)); } /** * Kiosks currently rendering this camera (active layout has a cell * pointing at it). Subset of listKiosksWithCameraInBundle. */ async listKiosksRenderingCamera(cameraId: string): Promise { const rs = await this._all( `SELECT DISTINCT k.* FROM kiosks k JOIN displays d ON d.kiosk_id = k.id JOIN layout_cells lc ON lc.layout_id = d.active_layout_id WHERE lc.camera_id = ? AND d.active_layout_id IS NOT NULL AND k.enabled = true`, [cameraId], ); return rs.map((r) => rowToKiosk(r as Record)); } /** * Kiosks that have this camera in ANY of their layouts (bundle-level). * The kiosk's cached bundle includes the camera even when it's not the * active layout, so snapshot requests via the kiosk LAN endpoint still * resolve — the kiosk opens a short-lived RTSP connection from its own * LAN position. Only when NO kiosk has the camera should the server * fall back to pulling the stream itself. */ async listKiosksWithCameraInBundle(cameraId: string): Promise { const rs = await this._all( `SELECT DISTINCT k.* FROM kiosks k JOIN displays d ON d.kiosk_id = k.id JOIN display_layouts dl ON dl.display_id = d.id JOIN layout_cells lc ON lc.layout_id = dl.layout_id WHERE lc.camera_id = ? AND k.enabled = true`, [cameraId], ); return rs.map((r) => rowToKiosk(r as Record)); } 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: string, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { if (k === "id") continue; const col = k === "index" ? `"index"` : k; sets.push(`${col} = ?`); vals.push(v === undefined ? null : v); } if (sets.length === 0) return; vals.push(id); await this._run(`UPDATE displays SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("displays", "update", id); } // =========================================================================== // layout templates // =========================================================================== // =========================================================================== // layouts // =========================================================================== async listLayouts(): Promise { const rs = await this._all("SELECT * FROM layouts ORDER BY name"); return rs.map((r) => rowToLayout(r as Record)); } async getLayoutById(id: string): Promise { const r = await this._get("SELECT * FROM layouts WHERE id = ?", [id]); return r ? rowToLayout(r as Record) : null; } /** * @deprecated Use `listLayoutsForDisplay` which goes through the * `display_layouts` join table. Kept as a thin alias for any * callers still on the old API. */ async layoutsForDisplay(displayId: string): Promise { return this.listLayoutsForDisplay(displayId); } /** All layouts attached to the given display, via display_layouts. */ 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 WHERE dl.display_id = ? ORDER BY l.name`, [displayId], ); return rs.map((r) => rowToLayout(r as Record)); } /** Inverse: all displays that have this layout attached. */ 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 WHERE dl.layout_id = ? ORDER BY d."index"`, [layoutId], ); return rs.map((r) => rowToDisplay(r as Record)); } /** Idempotent attach. */ async attachLayoutToDisplay(displayId: string, layoutId: string): Promise { await this._run( `INSERT OR IGNORE INTO display_layouts (display_id, layout_id) VALUES (?, ?)`, [displayId, layoutId], ); void this.notify("display_layouts", "create", layoutId); } /** Detach. If the display's default_layout_id pointed at this layout, clear it. */ async detachLayoutFromDisplay(displayId: string, layoutId: string): Promise { await this._run( `DELETE FROM display_layouts WHERE display_id = ? AND layout_id = ?`, [displayId, layoutId], ); await this._run( `UPDATE displays SET default_layout_id = NULL WHERE id = ? AND default_layout_id = ?`, [displayId, layoutId], ); void this.notify("display_layouts", "delete", layoutId); } async createLayout(input: { name: string; description?: string | null; priority?: string; cooling_timeout_seconds?: number | null; preload_camera_ids?: string[]; resets_idle_timer?: boolean; }): Promise { 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", input.cooling_timeout_seconds ?? null, J(input.preload_camera_ids ?? []), Boolean(input.resets_idle_timer ?? true), ], ); 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: string, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { if (k === "id" || k === "display_id") continue; // display_id deprecated sets.push(`${k} = ?`); if (k === "preload_camera_ids" || k === "regions") vals.push(J(v)); else if (typeof v === "boolean") vals.push(v); else vals.push(v === undefined ? null : v); } if (sets.length === 0) return; vals.push(id); await this._run(`UPDATE layouts SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("layouts", "update", id); } async cloneLayout(id: string): Promise { const src = await this.getLayoutById(id); if (!src) throw new Error("layout not found"); let cloneName = `${src.name} (copy)`; let suffix = 2; while (await this._get("SELECT 1 FROM layouts WHERE name = ?", [cloneName])) { cloneName = `${src.name} (copy ${String(suffix)})`; suffix++; } const clone = await this.createLayout({ name: cloneName, description: src.description, priority: src.priority, cooling_timeout_seconds: src.cooling_timeout_seconds, preload_camera_ids: src.preload_camera_ids, resets_idle_timer: src.resets_idle_timer, }); const cells = await this.listLayoutCells(id); for (const c of cells) { await this.createLayoutCell({ layout_id: clone.id, row: c.row, col: c.col, row_span: c.row_span, col_span: c.col_span, content_type: c.content_type, camera_id: c.camera_id, stream_selector: c.stream_selector, web_url: c.web_url, html_content: c.html_content, cooling_timeout_seconds: c.cooling_timeout_seconds, options: c.options, entity_id: c.entity_id, fit: c.fit, }); } const labels = await this._all<{ label_id: string }>( "SELECT label_id FROM layout_labels WHERE layout_id = ?", [id], ); for (const ll of labels) { await this.attachLayoutLabel(clone.id, ll.label_id); } const displays = await this._all<{ display_id: string }>( "SELECT display_id FROM display_layouts WHERE layout_id = ?", [id], ); for (const dl of displays) { await this.attachLayoutToDisplay(dl.display_id, clone.id); } return clone; } 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]); // Any display whose default pointed here gets cleared. await this._run(`UPDATE displays SET default_layout_id = NULL WHERE default_layout_id = ?`, [id]); await this._run(`DELETE FROM layouts WHERE id = ?`, [id]); void this.notify("layouts", "delete", id); } // =========================================================================== // layout cells // =========================================================================== async createLayoutCell(input: { layout_id: string; row: number; col: number; row_span?: number; col_span?: number; content_type?: string; 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?: string | null; fit?: "cover" | "contain" | "fill"; }): Promise { // Resolve content fields from the entity (if given). The legacy columns // remain populated for backward-compatible bundle generation. Dashboard // 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: 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) { const ent = await this.getEntityById(input.entity_id); if (ent) { contentType = ent.type === "dashboard" ? "web" : ent.type; cameraId = ent.type === "camera" ? ent.camera_id : null; webUrl = ent.type === "web" ? ent.web_url : ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` : null; htmlContent = ent.type === "html" ? ent.html_content : null; } } 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, input.row_span ?? 1, input.col_span ?? 1, contentType, cameraId, input.stream_selector ?? "auto", webUrl, htmlContent, input.cooling_timeout_seconds ?? null, J(input.options ?? {}), input.entity_id ?? null, input.fit ?? "cover", ], ); 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"); return rowToLayoutCell(r as Record); } /** * Assign (or clear) the entity for a cell. Also mirrors the resolved entity's * type/camera/url/html into the legacy cell columns so bundle generation stays * compatible with the existing kiosk. */ async assignCellEntity(cellId: string, entityId: string | null): Promise { if (entityId == null) { await this._run( `UPDATE layout_cells SET entity_id = NULL, content_type = 'none', camera_id = NULL, web_url = NULL, html_content = NULL WHERE id = ?`, [cellId], ); void this.notify("layout_cells", "update", cellId); return; } const ent = await this.getEntityById(entityId); if (!ent) return; const cellContentType = ent.type === "dashboard" ? "web" : ent.type; const cellWebUrl = ent.type === "web" ? ent.web_url : ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` : null; await this._run( `UPDATE layout_cells SET entity_id = ?, content_type = ?, camera_id = ?, web_url = ?, html_content = ? WHERE id = ?`, [ ent.id, cellContentType, ent.type === "camera" ? ent.camera_id : null, cellWebUrl, ent.type === "html" ? ent.html_content : null, cellId, ], ); void this.notify("layout_cells", "update", cellId); } async updateLayoutCell(id: string, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { if (k === "id" || k === "layout_id") continue; const col = k === "row" ? `"row"` : k; sets.push(`${col} = ?`); if (k === "options") vals.push(J(v)); else vals.push(v === undefined ? null : v); } if (sets.length === 0) return; vals.push(id); await this._run(`UPDATE layout_cells SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("layout_cells", "update", id); } async deleteLayoutCell(id: string): Promise { await this._run(`DELETE FROM layout_cells WHERE id = ?`, [id]); void this.notify("layout_cells", "delete", id); } /** * Shift cells along an axis to make room for an insertion (or close a gap * after a deletion). For axis="row", any cell whose `row >= fromIndex` has * its row bumped by `delta`. Same for axis="col". Used by the visual * builder when adding a cell to the top/left of an existing one. */ async shiftCellsForLayout( layoutId: number, axis: "row" | "col", fromIndex: number, delta: number, ): Promise { if (delta === 0) return; const colName = axis === "row" ? `"row"` : "col"; await this._run( `UPDATE layout_cells SET ${colName} = ${colName} + ? WHERE layout_id = ? AND ${colName} >= ?`, [delta, layoutId, fromIndex], ); void this.notify("layout_cells", "update", layoutId); } async listLayoutCells(layoutId: string): Promise { const rs = await this._all( `SELECT * FROM layout_cells WHERE layout_id = ? ORDER BY "row", col`, [layoutId], ); return rs.map((r) => rowToLayoutCell(r as Record)); } async getLayoutCellById(id: string): Promise { const r = await this._get("SELECT * FROM layout_cells WHERE id = ?", [id]); return r ? rowToLayoutCell(r as Record) : null; } // =========================================================================== // display-chain bundle queries (kiosk → display → layouts → cells → cameras) // =========================================================================== /** Bundle generation: layouts attached to a display via display_layouts. */ async layoutsForDisplayId(displayId: string): Promise { return this.listLayoutsForDisplay(displayId); } async camerasForLayoutIds(layoutIds: string[]): Promise { if (layoutIds.length === 0) return []; const placeholders = layoutIds.map(() => "?").join(","); const rs = await this._all( `SELECT DISTINCT c.* FROM cameras c JOIN layout_cells lc ON lc.camera_id = c.id WHERE lc.layout_id IN (${placeholders}) AND c.enabled = true ORDER BY c.name`, layoutIds, ); return rs.map((r) => rowToCamera(r as Record)); } // =========================================================================== // cameras // =========================================================================== async listCameras(): Promise { const rs = await this._all("SELECT * FROM cameras ORDER BY name"); return rs.map((r) => rowToCamera(r as Record)); } async getCameraById(id: string): Promise { const r = await this._get("SELECT * FROM cameras WHERE id = ?", [id]); return r ? rowToCamera(r as Record) : null; } async getCameraByName(name: string): Promise { const r = await this._get("SELECT * FROM cameras WHERE name = ?", [name]); return r ? rowToCamera(r as Record) : null; } async createCamera(input: { name: string; type: CameraType; rtsp_url?: string | null; onvif_host?: string | null; onvif_port?: number | null; onvif_username?: string | null; onvif_password?: string | null; // already-encrypted ciphertext capabilities?: string[]; stream_policy?: StreamPolicy; }): Promise { const id = uuidv7(); await this._run( `INSERT INTO cameras (id, name, type, rtsp_url, onvif_host, onvif_port, onvif_username, onvif_password, capabilities, stream_policy) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, input.name, input.type, input.rtsp_url ?? null, input.onvif_host ?? null, input.onvif_port ?? null, input.onvif_username ?? null, input.onvif_password ?? null, J(input.capabilities ?? []), input.stream_policy ?? "auto", ], ); void this.notify("cameras", "create", id); const c = await this.getCameraById(id); if (!c) throw new Error("camera vanished after insert"); // Mirror this camera as a reusable entity so it's pickable in cell editors. await this.ensureCameraEntity(c); return c; } async upsertCloudCamera(input: { cloud_account_id: string; cloud_vendor_camera_id: string; name: string; cloud_stream_url: string | null; cloud_stream_type: string | null; enabled: boolean; }): Promise { const existing = await this._get( "SELECT * FROM cameras WHERE cloud_account_id = ? AND cloud_vendor_camera_id = ?", [input.cloud_account_id, input.cloud_vendor_camera_id], ); if (existing) { const cam = rowToCamera(existing as Record); await this._run( `UPDATE cameras SET name = ?, cloud_stream_url = ?, cloud_stream_type = ?, enabled = ? WHERE id = ?`, [input.name, input.cloud_stream_url, input.cloud_stream_type, Boolean(input.enabled), cam.id], ); void this.notify("cameras", "update", cam.id); return (await this.getCameraById(cam.id))!; } const id = uuidv7(); await this._run( `INSERT INTO cameras (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)], ); void this.notify("cameras", "create", id); const c = await this.getCameraById(id); if (!c) throw new Error("cloud camera vanished after insert"); await this.ensureCameraEntity(c); return c; } async listCloudCamerasByAccount(accountId: string): Promise { const rs = await this._all( "SELECT * FROM cameras WHERE cloud_account_id = ? ORDER BY name", [accountId], ); return rs.map((r) => rowToCamera(r as Record)); } async deleteCloudCamerasNotIn(accountId: string, keepVendorIds: string[]): Promise { if (keepVendorIds.length === 0) { const result = await this._run( "DELETE FROM cameras WHERE cloud_account_id = ?", [accountId], ); return result.changes; } const placeholders = keepVendorIds.map(() => "?").join(","); const result = await this._run( `DELETE FROM cameras WHERE cloud_account_id = ? AND cloud_vendor_camera_id NOT IN (${placeholders})`, [accountId, ...keepVendorIds], ); return result.changes; } async listCameraStreams(cameraId: string): Promise { const rs = await this._all( "SELECT * FROM camera_streams WHERE camera_id = ?", [cameraId], ); return rs.map((r) => rowToCameraStream(r as Record)); } async createCameraStream(input: { camera_id: string; role: StreamRole; name: string; rtsp_uri: string; profile_token?: string | null; rtsp_host?: string | null; rtsp_port?: number | null; rtsp_path?: string | null; width?: number | null; height?: number | null; encoding?: string | null; framerate?: number | null; bitrate_kbps?: number | null; is_discovered?: boolean; }): Promise { const id = uuidv7(); await this._run( `INSERT INTO camera_streams (id, camera_id, role, name, profile_token, rtsp_uri, rtsp_host, rtsp_port, rtsp_path, width, height, encoding, framerate, bitrate_kbps, is_discovered) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, input.camera_id, input.role, input.name, input.profile_token ?? null, input.rtsp_uri, input.rtsp_host ?? null, input.rtsp_port ?? null, input.rtsp_path ?? null, input.width ?? null, input.height ?? null, input.encoding ?? null, input.framerate ?? null, input.bitrate_kbps ?? null, Boolean(input.is_discovered), ], ); 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: string, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { if (k === "id" || k === "camera_id") continue; sets.push(`${k} = ?`); vals.push(v === undefined ? null : v); } if (sets.length === 0) return; vals.push(id); await this._run(`UPDATE camera_streams SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("camera_streams", "update", id); } // =========================================================================== // labels (incl. join tables) // =========================================================================== async listLabels(): Promise { const rs = await this._all("SELECT * FROM labels ORDER BY name"); return rs.map((r) => rowToLabel(r as Record)); } async getLabelByName(name: string): Promise