mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 15:46:35 +00:00
feat: AbleSign digital signage integration
- DB: ablesign_accounts (api_key_encrypted, workspace_id) + ablesign_screens (ablesign_screen_id, kiosk assignment, orientation) - API client: shared/ablesign.ts — list/register/update/delete screens, playlist CRUD, headless pairing (initiate player registration → register via admin API key → no UI shown on kiosk) - Admin routes: account CRUD, screen sync from AbleSign API, headless screen creation (Create & Pair), kiosk assignment, remote delete - Admin UI: AbleSign nav item, accounts page (add/sync/delete), screens page (add/assign to kiosk/delete) with kiosk dropdown - Follows cloud camera pattern: encrypted credentials, sync from vendor API, assign to kiosks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5ce526eb33
commit
c3bdcbce4c
7 changed files with 690 additions and 0 deletions
|
|
@ -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<InstanceType<typeof Config>, 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.
|
||||
|
|
|
|||
154
server/src/plugins/service-admin-http/routes-ablesign.ts
Normal file
154
server/src/plugins/service-admin-http/routes-ablesign.ts
Normal file
|
|
@ -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<Record<string, string>>(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<Record<string, string>>(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<Record<string, string>>(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` } });
|
||||
});
|
||||
}
|
||||
226
server/src/shared/ablesign.ts
Normal file
226
server/src/shared/ablesign.ts
Normal file
|
|
@ -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<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
"Authorization": `Bearer ${opts.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
};
|
||||
if (opts.workspaceId) {
|
||||
h["Workspace-Id"] = opts.workspaceId;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
async function apiFetch<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts: ApiOpts,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
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<AbleSignScreen> {
|
||||
return apiFetch("GET", `/screens/${screenId}`, opts);
|
||||
}
|
||||
|
||||
export async function registerScreen(
|
||||
opts: ApiOpts,
|
||||
registrationCode: string,
|
||||
title: string,
|
||||
orientation: string = "landscape",
|
||||
): Promise<AbleSignScreen> {
|
||||
return apiFetch("POST", "/screens", opts, {
|
||||
registrationCode,
|
||||
title,
|
||||
orientation,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateScreen(
|
||||
opts: ApiOpts,
|
||||
screenId: number,
|
||||
patch: { title?: string; description?: string; orientation?: string },
|
||||
): Promise<void> {
|
||||
await apiFetch("PUT", `/screens/${screenId}`, opts, patch);
|
||||
}
|
||||
|
||||
export async function deleteScreen(opts: ApiOpts, screenId: number): Promise<void> {
|
||||
await apiFetch("DELETE", `/screens/${screenId}`, opts);
|
||||
}
|
||||
|
||||
export async function getPlaylist(opts: ApiOpts, screenId: number): Promise<AbleSignPlaylist> {
|
||||
return apiFetch("GET", `/screens/${screenId}/playlist`, opts);
|
||||
}
|
||||
|
||||
export async function savePlaylist(
|
||||
opts: ApiOpts,
|
||||
screenId: number,
|
||||
playlist: AbleSignPlaylist,
|
||||
): Promise<void> {
|
||||
await apiFetch("PUT", `/screens/${screenId}/playlist`, opts, playlist);
|
||||
}
|
||||
|
||||
export async function addPlaylistItems(
|
||||
opts: ApiOpts,
|
||||
screenId: number,
|
||||
items: AbleSignPlaylistItem[],
|
||||
position: "start" | "end" = "end",
|
||||
): Promise<void> {
|
||||
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<AbleSignRegistration> {
|
||||
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<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2628,4 +2628,123 @@ export class Repository {
|
|||
async deleteCloudAccount(id: string): Promise<void> {
|
||||
await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// AbleSign accounts + screens
|
||||
// ===========================================================================
|
||||
|
||||
async listAbleSignAccounts(): Promise<any[]> {
|
||||
return this._all("SELECT * FROM ablesign_accounts ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
async getAbleSignAccount(id: string): Promise<any | undefined> {
|
||||
return this._get("SELECT * FROM ablesign_accounts WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
async createAbleSignAccount(input: {
|
||||
name: string;
|
||||
api_key_encrypted: string;
|
||||
workspace_id?: string;
|
||||
}): Promise<string> {
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<void> {
|
||||
await this._run("DELETE FROM ablesign_accounts WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
async listAbleSignScreens(accountId?: string): Promise<any[]> {
|
||||
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<any | undefined> {
|
||||
return this._get("SELECT * FROM ablesign_screens WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
async getAbleSignScreenByKiosk(kioskId: string): Promise<any | undefined> {
|
||||
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<string> {
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4379,3 +4379,163 @@ export function TenantEditPage(props: TenantEditPageProps) {
|
|||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- AbleSign Pages ---------------------------------------------------------
|
||||
|
||||
interface AbleSignPageProps {
|
||||
accounts: any[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function AbleSignPage(props: AbleSignPageProps) {
|
||||
return (
|
||||
<Layout title="AbleSign" activeNav="ablesign">
|
||||
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Accounts</h1>
|
||||
|
||||
{props.error ? <div class="alert alert-error" style="margin-bottom:1rem">{props.error}</div> : ""}
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="font-size:1.1rem; margin:0 0 1rem">Add Account</h2>
|
||||
<form method="POST" action="/admin/ablesign/add" style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:end">
|
||||
<label style="font-size:0.85rem">
|
||||
{"Name"}<br/>
|
||||
<input type="text" name="name" required style="width:12rem" placeholder="My AbleSign" />
|
||||
</label>
|
||||
<label style="font-size:0.85rem">
|
||||
{"API Key"}<br/>
|
||||
<input type="password" name="api_key" required style="width:16rem" placeholder="ak_..." />
|
||||
</label>
|
||||
<label style="font-size:0.85rem">
|
||||
{"Workspace ID (optional)"}<br/>
|
||||
<input type="text" name="workspace_id" style="width:8rem" />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-sm">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{props.accounts.length > 0 ? (
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>Screens</th>
|
||||
<th>Last Sync</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{props.accounts.map((a: any) => (
|
||||
<tr>
|
||||
<td><a href={`/admin/ablesign/${String(a.id)}/screens`}>{a.name}</a></td>
|
||||
<td>{String(a.screen_count ?? 0)}</td>
|
||||
<td style="font-size:0.85rem">
|
||||
{a.last_sync_at ? formatTime(a.last_sync_at) : "Never"}
|
||||
{a.last_sync_error && <span style="color:red" title={a.last_sync_error}>{" (error)"}</span>}
|
||||
</td>
|
||||
<td style="display:flex; gap:0.25rem">
|
||||
<a href={`/admin/ablesign/${String(a.id)}/screens`} class="btn btn-sm btn-ghost">Screens</a>
|
||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`} style="display:inline">
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Sync</button>
|
||||
</form>
|
||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/delete`} style="display:inline">
|
||||
<button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : ""}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
interface AbleSignScreensPageProps {
|
||||
account: any;
|
||||
screens: any[];
|
||||
kiosks: any[];
|
||||
}
|
||||
|
||||
export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
||||
const a = props.account;
|
||||
return (
|
||||
<Layout title={`AbleSign — ${String(a.name)}`} activeNav="ablesign">
|
||||
<h1 style="font-size:1.5rem; margin:0 0 0.5rem">{a.name} — Screens</h1>
|
||||
<p style="color:#999; margin:0 0 1.5rem; font-size:0.85rem">
|
||||
{String(a.screen_count ?? 0)} screens
|
||||
{a.last_sync_at && ` · synced ${formatTime(a.last_sync_at)}`}
|
||||
</p>
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="font-size:1rem; margin:0 0 0.75rem">Add Screen</h2>
|
||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/screens/add`} style="display:flex; gap:0.5rem; align-items:end">
|
||||
<label style="font-size:0.85rem">
|
||||
{"Screen Name"}<br/>
|
||||
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
|
||||
</form>
|
||||
<p style="font-size:0.8rem; color:#999; margin:0.5rem 0 0">
|
||||
Creates a new screen in AbleSign and pairs it automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:1rem">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem">
|
||||
<h2 style="font-size:1rem; margin:0">Screens</h2>
|
||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`}>
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{props.screens.length === 0 ? (
|
||||
<p style="color:#999; font-size:0.85rem">No screens yet. Add one above or sync from AbleSign.</p>
|
||||
) : (
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Title</th>
|
||||
<th>Orientation</th>
|
||||
<th>Status</th>
|
||||
<th>Assigned Kiosk</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{props.screens.map((s: any) => (
|
||||
<tr>
|
||||
<td>{s.title}</td>
|
||||
<td style="font-size:0.85rem">{s.orientation}</td>
|
||||
<td>
|
||||
{s.online
|
||||
? <span class="badge badge-green">Online</span>
|
||||
: <span class="badge badge-gray">Offline</span>}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/assign`}
|
||||
style="display:flex; gap:0.25rem; align-items:center">
|
||||
<select name="kiosk_id" style="font-size:0.85rem; max-width:14rem">
|
||||
<option value="">— None —</option>
|
||||
{props.kiosks.map((k: any) => (
|
||||
<option value={String(k.id)} selected={k.id === s.kiosk_id}>{k.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Assign</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/delete`} style="display:inline">
|
||||
<button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ function Sidebar(props: { activeNav?: string }) {
|
|||
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
||||
<NavItem href="/admin/os-updates" label="OS Updates" icon="●" active={a === "os-updates"} />
|
||||
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="☁" active={a === "cloud"} />
|
||||
<NavItem href="/admin/ablesign" label="AbleSign" icon="▶" active={a === "ablesign"} />
|
||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue