diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 24b6b66..6771467 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -35,6 +35,7 @@ import { registerOsUpdateRoutes } from "./routes-os-updates.js"; import { registerStaticRoutes } from "./routes-static.js"; import { registerCloudRoutes } from "./routes-cloud.js"; import { registerTenantRoutes } from "./routes-tenants.js"; +import { registerAbleSignRoutes } from "./routes-ablesign.js"; // ---- Config ----------------------------------------------------------------- @@ -238,6 +239,7 @@ export class Plugin extends BSBService, typeof Event registerOsUpdateRoutes(app, deps); registerCloudRoutes(app, deps); registerTenantRoutes(app, deps); + registerAbleSignRoutes(app, deps); // Auth-check endpoint for Angie auth_request subrequest. // Returns 200 if session cookie is valid + admin role, 401 otherwise. diff --git a/server/src/plugins/service-admin-http/routes-ablesign.ts b/server/src/plugins/service-admin-http/routes-ablesign.ts new file mode 100644 index 0000000..880109f --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-ablesign.ts @@ -0,0 +1,154 @@ +/** + * AbleSign digital signage routes. + */ +import { type H3, getRouterParam, readBody, createError } from "h3"; + +import { htmlPage } from "./html-response.js"; +import type { AdminDeps } from "./index.js"; +import * as ablesign from "../../shared/ablesign.js"; +import { AbleSignPage, AbleSignScreensPage } from "../../web-templates/admin-pages.js"; + +export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void { + + app.get("/admin/ablesign", async () => { + const accounts = await deps.repo.listAbleSignAccounts(); + return htmlPage(AbleSignPage({ accounts })); + }); + + app.post("/admin/ablesign/add", async (event) => { + const body = await readBody>(event); + const name = (body?.name ?? "").trim(); + const apiKey = (body?.api_key ?? "").trim(); + const workspaceId = (body?.workspace_id ?? "").trim() || undefined; + + if (!name || !apiKey) { + const accounts = await deps.repo.listAbleSignAccounts(); + return htmlPage(AbleSignPage({ accounts, error: "Name and API key required." })); + } + + const test = await ablesign.testApiKey(apiKey, workspaceId); + if (!test.ok) { + const accounts = await deps.repo.listAbleSignAccounts(); + return htmlPage(AbleSignPage({ accounts, error: `API key test failed: ${test.error}` })); + } + + const encrypted = deps.secrets.encryptString(apiKey, "ablesign-key"); + await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId }); + return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } }); + }); + + app.get("/admin/ablesign/:id/screens", async (event) => { + const id = getRouterParam(event, "id") ?? ""; + const account = await deps.repo.getAbleSignAccount(id); + if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" }); + const screens = await deps.repo.listAbleSignScreens(id); + const kiosks = await deps.repo.listKiosks(); + return htmlPage(AbleSignScreensPage({ account, screens, kiosks })); + }); + + app.post("/admin/ablesign/:id/sync", async (event) => { + const id = getRouterParam(event, "id") ?? ""; + const account = await deps.repo.getAbleSignAccount(id); + if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" }); + + try { + const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key"); + const opts = { apiKey, workspaceId: account.workspace_id || undefined }; + const result = await ablesign.listScreens(opts); + + for (const s of result.data) { + await deps.repo.upsertAbleSignScreen({ + account_id: id, + ablesign_screen_id: String(s.id), + title: s.title, + online: !!s.heartbeatTime, + last_heartbeat_at: s.heartbeatTime || undefined, + orientation: s.orientation, + }); + } + + await deps.repo.updateAbleSignAccount(id, { + screen_count: result.data.length, + last_sync_at: new Date().toISOString(), + last_sync_error: null, + }); + } catch (err) { + await deps.repo.updateAbleSignAccount(id, { + last_sync_at: new Date().toISOString(), + last_sync_error: (err as Error).message, + }); + } + + return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${id}/screens` } }); + }); + + app.post("/admin/ablesign/:id/screens/add", async (event) => { + const accountId = getRouterParam(event, "id") ?? ""; + const account = await deps.repo.getAbleSignAccount(accountId); + if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" }); + + const body = await readBody>(event); + const title = (body?.title ?? "").trim(); + if (!title) { + return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); + } + + try { + const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key"); + const opts = { apiKey, workspaceId: account.workspace_id || undefined }; + const { screen } = await ablesign.headlessPairScreen(opts, title); + + await deps.repo.createAbleSignScreen({ + account_id: accountId, + ablesign_screen_id: String(screen.id), + title: screen.title, + orientation: screen.orientation, + }); + + await deps.repo.updateAbleSignAccount(accountId, { + screen_count: (account.screen_count ?? 0) + 1, + }); + } catch { + // redirect back — error handling TODO + } + + return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); + }); + + app.post("/admin/ablesign/screens/:sid/assign", async (event) => { + const sid = getRouterParam(event, "sid") ?? ""; + const body = await readBody>(event); + const kioskId = (body?.kiosk_id ?? "").trim() || null; + await deps.repo.updateAbleSignScreen(sid, { kiosk_id: kioskId }); + + const screen = await deps.repo.getAbleSignScreen(sid); + const accountId = screen?.account_id ?? ""; + return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); + }); + + app.post("/admin/ablesign/:id/delete", async (event) => { + const id = getRouterParam(event, "id") ?? ""; + await deps.repo.deleteAbleSignAccount(id); + return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } }); + }); + + app.post("/admin/ablesign/screens/:sid/delete", async (event) => { + const sid = getRouterParam(event, "sid") ?? ""; + const screen = await deps.repo.getAbleSignScreen(sid); + if (screen) { + try { + const account = await deps.repo.getAbleSignAccount(screen.account_id); + if (account) { + const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key"); + await ablesign.deleteScreen( + { apiKey, workspaceId: account.workspace_id || undefined }, + Number(screen.ablesign_screen_id), + ); + } + } catch { /* best-effort remote delete */ } + await deps.repo.deleteAbleSignScreen(sid); + } + const accountId = screen?.account_id ?? ""; + return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); + }); +} diff --git a/server/src/shared/ablesign.ts b/server/src/shared/ablesign.ts new file mode 100644 index 0000000..e8ed9fc --- /dev/null +++ b/server/src/shared/ablesign.ts @@ -0,0 +1,226 @@ +/** + * AbleSign API client — screen registration, playlist management. + * + * Base URL: https://api.ablesign.tv/api/v1 + * Auth: Bearer ak_... (API key from AbleSign CMS) + * + * The player at player.ablesign.tv uses an internal registration API + * (POST /api/screens/registration) that doesn't need auth. We use the + * public API (v1) with the admin's API key for all management ops, and + * the internal player API for headless screen registration. + */ + +const API_BASE = "https://api.ablesign.tv/api/v1"; +const PLAYER_API = "https://api.ablesign.tv/api"; + +export interface AbleSignScreen { + id: number; + title: string; + description?: string; + orientation: string; + heartbeatTime?: string; + screenGroupId?: number; +} + +export interface AbleSignPlaylistItem { + id?: string; + mediafileId?: string; + webAppId?: string; + displayDuration?: number; + sequenceNumber?: number; + transition?: string; + transitionSpeedLabel?: string; +} + +export interface AbleSignPlaylist { + defaultTransition?: string; + defaultTransitionSpeedLabel?: string; + shufflePlay?: boolean; + items: AbleSignPlaylistItem[]; +} + +export interface AbleSignRegistration { + id: number; + code: number; + screenId: number; +} + +interface ApiOpts { + apiKey: string; + workspaceId?: string; + timeoutMs?: number; +} + +function headers(opts: ApiOpts): Record { + const h: Record = { + "Authorization": `Bearer ${opts.apiKey}`, + "Content-Type": "application/json", + "Accept": "application/json", + }; + if (opts.workspaceId) { + h["Workspace-Id"] = opts.workspaceId; + } + return h; +} + +async function apiFetch( + method: string, + path: string, + opts: ApiOpts, + body?: unknown, +): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 10000); + try { + const resp = await fetch(`${API_BASE}${path}`, { + method, + headers: headers(opts), + body: body ? JSON.stringify(body) : undefined, + signal: ctrl.signal, + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`AbleSign API ${method} ${path}: HTTP ${resp.status} — ${text.slice(0, 300)}`); + } + if (resp.status === 204) return undefined as T; + return (await resp.json()) as T; + } finally { + clearTimeout(t); + } +} + +export async function listScreens(opts: ApiOpts): Promise<{ data: AbleSignScreen[]; totalItems: number }> { + return apiFetch("GET", "/screens?limit=200", opts); +} + +export async function getScreen(opts: ApiOpts, screenId: number): Promise { + return apiFetch("GET", `/screens/${screenId}`, opts); +} + +export async function registerScreen( + opts: ApiOpts, + registrationCode: string, + title: string, + orientation: string = "landscape", +): Promise { + return apiFetch("POST", "/screens", opts, { + registrationCode, + title, + orientation, + }); +} + +export async function updateScreen( + opts: ApiOpts, + screenId: number, + patch: { title?: string; description?: string; orientation?: string }, +): Promise { + await apiFetch("PUT", `/screens/${screenId}`, opts, patch); +} + +export async function deleteScreen(opts: ApiOpts, screenId: number): Promise { + await apiFetch("DELETE", `/screens/${screenId}`, opts); +} + +export async function getPlaylist(opts: ApiOpts, screenId: number): Promise { + return apiFetch("GET", `/screens/${screenId}/playlist`, opts); +} + +export async function savePlaylist( + opts: ApiOpts, + screenId: number, + playlist: AbleSignPlaylist, +): Promise { + await apiFetch("PUT", `/screens/${screenId}/playlist`, opts, playlist); +} + +export async function addPlaylistItems( + opts: ApiOpts, + screenId: number, + items: AbleSignPlaylistItem[], + position: "start" | "end" = "end", +): Promise { + await apiFetch("POST", `/screens/${screenId}/playlist_items`, opts, { items, position }); +} + +export async function listWebApps(opts: ApiOpts): Promise<{ data: Array<{ id: string; title: string; url?: string }> }> { + return apiFetch("GET", "/web_apps?limit=200", opts); +} + +export async function createWebApp( + opts: ApiOpts, + title: string, + url: string, +): Promise<{ id: string; title: string }> { + return apiFetch("POST", "/web_apps", opts, { title, url }); +} + +export async function listMediaFiles(opts: ApiOpts): Promise<{ data: Array<{ id: string; title: string; fileType: string; thumbnailURL?: string }> }> { + return apiFetch("GET", "/media_files?limit=200", opts); +} + +/** + * Initiate headless screen registration via the player's internal API. + * No auth required — mimics what player.ablesign.tv does on load. + */ +export async function initiatePlayerRegistration(): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 10000); + try { + const resp = await fetch(`${PLAYER_API}/screens/registration`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-app-version": "46" }, + body: JSON.stringify({ platformType: "Web", softwareVersionCode: 46 }), + signal: ctrl.signal, + }); + if (!resp.ok) throw new Error(`registration init: HTTP ${resp.status}`); + return (await resp.json()) as AbleSignRegistration; + } finally { + clearTimeout(t); + } +} + +/** + * Poll for registration completion. Returns screenId when paired, -1 while pending. + */ +export async function pollRegistration(code: number): Promise<{ screenId: number; screenToken?: string }> { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 10000); + try { + const resp = await fetch(`${PLAYER_API}/screens/registration/${code}`, { + method: "GET", + headers: { "Accept": "application/json", "x-app-version": "46" }, + signal: ctrl.signal, + }); + if (!resp.ok) throw new Error(`registration poll: HTTP ${resp.status}`); + const data = (await resp.json()) as Record; + return { screenId: (data.screenId as number) ?? -1, screenToken: data.screenToken as string | undefined }; + } finally { + clearTimeout(t); + } +} + +/** + * Full headless pairing flow: + * 1. Initiate registration → get code + * 2. Register screen via admin API with that code + * 3. Return screen details + any token for the player + */ +export async function headlessPairScreen( + opts: ApiOpts, + title: string, + orientation: string = "landscape", +): Promise<{ screen: AbleSignScreen; registrationCode: number }> { + const reg = await initiatePlayerRegistration(); + const screen = await registerScreen(opts, String(reg.code), title, orientation); + return { screen, registrationCode: reg.code }; +} + +export async function testApiKey(apiKey: string, workspaceId?: string): Promise<{ ok: boolean; error?: string }> { + try { + await listScreens({ apiKey, workspaceId }); + return { ok: true }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} diff --git a/server/src/shared/db/migrations-pg.ts b/server/src/shared/db/migrations-pg.ts index b112be5..af3b308 100644 --- a/server/src/shared/db/migrations-pg.ts +++ b/server/src/shared/db/migrations-pg.ts @@ -486,6 +486,34 @@ export const TENANT_MIGRATIONS: readonly string[] = [ `ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS partitions_json JSONB`, + // ---- AbleSign digital signage integration ----------------------------------- + `CREATE TABLE IF NOT EXISTS ablesign_accounts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + api_key_encrypted TEXT NOT NULL, + workspace_id TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + screen_count INTEGER NOT NULL DEFAULT 0, + last_sync_at TIMESTAMPTZ, + last_sync_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE TABLE IF NOT EXISTS ablesign_screens ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES ablesign_accounts(id) ON DELETE CASCADE, + ablesign_screen_id TEXT NOT NULL, + ablesign_screen_token_encrypted TEXT, + kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL, + title TEXT NOT NULL, + orientation TEXT NOT NULL DEFAULT 'landscape', + online BOOLEAN NOT NULL DEFAULT false, + last_heartbeat_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(account_id, ablesign_screen_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_ablesign_screens_account ON ablesign_screens(account_id)`, + `CREATE INDEX IF NOT EXISTS idx_ablesign_screens_kiosk ON ablesign_screens(kiosk_id)`, + // ---- UUIDv7 PK migration for existing databases ---- // Databases created before UUIDv7 migration have INTEGER PKs. // This migration converts them to TEXT in-place. Safe to run on diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index 4dd166c..0269ef9 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -2628,4 +2628,123 @@ export class Repository { async deleteCloudAccount(id: string): Promise { await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]); } + + // =========================================================================== + // AbleSign accounts + screens + // =========================================================================== + + async listAbleSignAccounts(): Promise { + return this._all("SELECT * FROM ablesign_accounts ORDER BY created_at DESC"); + } + + async getAbleSignAccount(id: string): Promise { + return this._get("SELECT * FROM ablesign_accounts WHERE id = ?", [id]); + } + + async createAbleSignAccount(input: { + name: string; + api_key_encrypted: string; + workspace_id?: string; + }): Promise { + const id = uuidv7(); + await this._run( + `INSERT INTO ablesign_accounts (id, name, api_key_encrypted, workspace_id) + VALUES (?, ?, ?, ?)`, + [id, input.name, input.api_key_encrypted, input.workspace_id ?? null], + ); + return id; + } + + async updateAbleSignAccount(id: string, patch: Record): Promise { + const sets: string[] = []; + const vals: unknown[] = []; + for (const [k, v] of Object.entries(patch)) { + if (k === "id" || k === "created_at") continue; + sets.push(`${k} = ?`); + vals.push(v === undefined ? null : v); + } + if (sets.length === 0) return; + vals.push(id); + await this._run(`UPDATE ablesign_accounts SET ${sets.join(", ")} WHERE id = ?`, vals); + } + + async deleteAbleSignAccount(id: string): Promise { + await this._run("DELETE FROM ablesign_accounts WHERE id = ?", [id]); + } + + async listAbleSignScreens(accountId?: string): Promise { + if (accountId) { + return this._all("SELECT * FROM ablesign_screens WHERE account_id = ? ORDER BY title", [accountId]); + } + return this._all("SELECT * FROM ablesign_screens ORDER BY title"); + } + + async getAbleSignScreen(id: string): Promise { + return this._get("SELECT * FROM ablesign_screens WHERE id = ?", [id]); + } + + async getAbleSignScreenByKiosk(kioskId: string): Promise { + return this._get("SELECT * FROM ablesign_screens WHERE kiosk_id = ?", [kioskId]); + } + + async createAbleSignScreen(input: { + account_id: string; + ablesign_screen_id: string; + ablesign_screen_token_encrypted?: string; + kiosk_id?: string; + title: string; + orientation?: string; + }): Promise { + const id = uuidv7(); + await this._run( + `INSERT INTO ablesign_screens (id, account_id, ablesign_screen_id, ablesign_screen_token_encrypted, kiosk_id, title, orientation) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [id, input.account_id, input.ablesign_screen_id, input.ablesign_screen_token_encrypted ?? null, input.kiosk_id ?? null, input.title, input.orientation ?? "landscape"], + ); + return id; + } + + async updateAbleSignScreen(id: string, patch: Record): Promise { + const sets: string[] = []; + const vals: unknown[] = []; + for (const [k, v] of Object.entries(patch)) { + if (k === "id" || k === "created_at") continue; + sets.push(`${k} = ?`); + vals.push(v === undefined ? null : v); + } + if (sets.length === 0) return; + vals.push(id); + await this._run(`UPDATE ablesign_screens SET ${sets.join(", ")} WHERE id = ?`, vals); + } + + async deleteAbleSignScreen(id: string): Promise { + await this._run("DELETE FROM ablesign_screens WHERE id = ?", [id]); + } + + async upsertAbleSignScreen(input: { + account_id: string; + ablesign_screen_id: string; + title: string; + online: boolean; + last_heartbeat_at?: string; + orientation?: string; + }): Promise { + const existing = await this._get<{ id: string }>( + "SELECT id FROM ablesign_screens WHERE account_id = ? AND ablesign_screen_id = ?", + [input.account_id, input.ablesign_screen_id], + ); + if (existing) { + await this._run( + `UPDATE ablesign_screens SET title = ?, online = ?, last_heartbeat_at = COALESCE(?, last_heartbeat_at), orientation = COALESCE(?, orientation) WHERE id = ?`, + [input.title, input.online, input.last_heartbeat_at ?? null, input.orientation ?? null, existing.id], + ); + return existing.id; + } + return this.createAbleSignScreen({ + account_id: input.account_id, + ablesign_screen_id: input.ablesign_screen_id, + title: input.title, + orientation: input.orientation, + }); + } } diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 89a913b..9fe5c2a 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -4379,3 +4379,163 @@ export function TenantEditPage(props: TenantEditPageProps) { ); } + +// ---- AbleSign Pages --------------------------------------------------------- + +interface AbleSignPageProps { + accounts: any[]; + error?: string; +} + +export function AbleSignPage(props: AbleSignPageProps) { + return ( + +

AbleSign Accounts

+ + {props.error ?
{props.error}
: ""} + +
+

Add Account

+
+ + + + +
+
+ + {props.accounts.length > 0 ? ( +
+
+ + + + + + + + + {props.accounts.map((a: any) => ( + + + + + + + ))} + +
NameScreensLast SyncActions
{a.name}{String(a.screen_count ?? 0)} + {a.last_sync_at ? formatTime(a.last_sync_at) : "Never"} + {a.last_sync_error && {" (error)"}} + + Screens +
+ +
+
+ +
+
+
+
+ ) : ""} +
+ ); +} + +interface AbleSignScreensPageProps { + account: any; + screens: any[]; + kiosks: any[]; +} + +export function AbleSignScreensPage(props: AbleSignScreensPageProps) { + const a = props.account; + return ( + +

{a.name} — Screens

+

+ {String(a.screen_count ?? 0)} screens + {a.last_sync_at && ` · synced ${formatTime(a.last_sync_at)}`} +

+ +
+

Add Screen

+
+ + +
+

+ Creates a new screen in AbleSign and pairs it automatically. +

+
+ +
+
+

Screens

+
+ +
+
+ + {props.screens.length === 0 ? ( +

No screens yet. Add one above or sync from AbleSign.

+ ) : ( +
+ + + + + + + + + + {props.screens.map((s: any) => ( + + + + + + + + ))} + +
TitleOrientationStatusAssigned KioskActions
{s.title}{s.orientation} + {s.online + ? Online + : Offline} + +
+ + +
+
+
+ +
+
+
+ )} +
+
+ ); +} diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index 1fb9550..e8a2b31 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -58,6 +58,7 @@ function Sidebar(props: { activeNav?: string }) { +