feat: managed entities (read-only) + AbleSign auto-creates entity

- Entity type: add 'ablesign' to EntityType + CellContentType
- Entity.managed boolean: true for auto-created entities (camera sync,
  cloud cams, AbleSign). UI blocks editing managed entities.
- Entity.ablesign_screen_id: links to ablesign_screens row
- ensureCameraEntity now sets managed=true
- AbleSign screen creation auto-creates managed entity with
  web_url=player.ablesign.tv and ablesign_screen_id FK
- PG migration: alter entities CHECK constraint + add columns
- Entity edit route rejects POST for managed entities

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-26 17:08:19 +02:00
parent c3bdcbce4c
commit 73dbd9b6bf
No known key found for this signature in database
6 changed files with 46 additions and 8 deletions

View file

@ -96,15 +96,34 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
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);
const { screen, registrationCode } = await ablesign.headlessPairScreen(opts, title);
await deps.repo.createAbleSignScreen({
// Poll once for token (may not be available immediately).
let screenToken: string | undefined;
try {
const poll = await ablesign.pollRegistration(registrationCode);
screenToken = poll.screenToken;
} catch { /* token may not be ready yet — kiosk can work without it initially */ }
const screenRowId = await deps.repo.createAbleSignScreen({
account_id: accountId,
ablesign_screen_id: String(screen.id),
ablesign_screen_token_encrypted: screenToken
? deps.secrets.encryptString(screenToken, "ablesign-token")
: undefined,
title: screen.title,
orientation: screen.orientation,
});
await deps.repo.createEntity({
name: `AbleSign: ${screen.title}`,
type: "ablesign",
description: `AbleSign screen (ID: ${String(screen.id)})`,
web_url: "https://player.ablesign.tv",
ablesign_screen_id: screenRowId,
managed: true,
});
await deps.repo.updateAbleSignAccount(accountId, {
screen_count: (account.screen_count ?? 0) + 1,
});

View file

@ -790,6 +790,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const id = (getRouterParam(event, "id") ?? "");
const ent = await deps.repo.getEntityById(id);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
if ((ent as any).managed) {
return new Response(null, { status: 302, headers: { location: `/admin/entities/${String(id)}` } });
}
const body = await readBody<Record<string, string>>(event);
const patch: {
name?: string;

View file

@ -258,6 +258,8 @@ export function rowToEntity(r: Row): Entity {
html_content: sn(r["html_content"]),
web_url: sn(r["web_url"]),
dashboard_id: sn(r["dashboard_id"]),
ablesign_screen_id: sn(r["ablesign_screen_id"]),
managed: !!r["managed"],
created_at: s(r["created_at"]),
};
}

View file

@ -514,6 +514,12 @@ export const TENANT_MIGRATIONS: readonly string[] = [
`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)`,
// Add ablesign entity type + ablesign_screen_id column to entities
`ALTER TABLE entities DROP CONSTRAINT IF EXISTS entities_type_check`,
`ALTER TABLE entities ADD CONSTRAINT entities_type_check CHECK(type IN ('camera', 'html', 'web', 'dashboard', 'ablesign'))`,
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS ablesign_screen_id TEXT REFERENCES ablesign_screens(id) ON DELETE CASCADE`,
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false`,
// ---- 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

View file

@ -2307,11 +2307,13 @@ export class Repository {
html_content?: string | null;
web_url?: string | null;
dashboard_id?: string | null;
ablesign_screen_id?: string | null;
managed?: boolean;
}): Promise<Entity> {
const id = uuidv7();
await this._run(
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO entities (id, name, type, description, camera_id, html_content, web_url, dashboard_id, ablesign_screen_id, managed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
input.name,
@ -2319,8 +2321,10 @@ export class Repository {
input.description ?? null,
input.type === "camera" ? (input.camera_id ?? null) : null,
input.type === "html" ? (input.html_content ?? null) : null,
input.type === "web" ? (input.web_url ?? null) : null,
input.type === "web" || input.type === "ablesign" ? (input.web_url ?? null) : null,
input.type === "dashboard" ? (input.dashboard_id ?? null) : null,
input.type === "ablesign" ? (input.ablesign_screen_id ?? null) : null,
input.managed ?? false,
],
);
void this.notify("entities", "create", id);
@ -2405,7 +2409,7 @@ export class Repository {
if (await this.getEntityByName(name)) {
name = `${camera.name} (cam ${camera.id.slice(0, 8)})`;
}
return this.createEntity({ name, type: "camera", camera_id: camera.id });
return this.createEntity({ name, type: "camera", camera_id: camera.id, managed: true });
}
async updateKiosk(id: string, patch: Partial<Kiosk>): Promise<void> {

View file

@ -12,8 +12,8 @@ export type StreamRole = "main" | "sub" | "other";
export type StreamSelector = "auto" | "main" | "sub";
export type StreamPolicy = "auto" | "always_main" | "always_sub";
export type LayoutPriority = "hot" | "normal" | "cold";
export type CellContentType = "none" | "camera" | "web" | "html";
export type EntityType = "camera" | "html" | "web" | "dashboard";
export type CellContentType = "none" | "camera" | "web" | "html" | "ablesign";
export type EntityType = "camera" | "html" | "web" | "dashboard" | "ablesign";
export interface Entity {
id: string;
@ -25,6 +25,10 @@ export interface Entity {
web_url: string | null;
/** Node-RED dashboard tab id; populated when type === "dashboard". */
dashboard_id: string | null;
/** AbleSign screen row id; populated when type === "ablesign". */
ablesign_screen_id: string | null;
/** True for entities auto-created by camera sync, cloud cams, AbleSign. Read-only in UI. */
managed: boolean;
created_at: string;
}
export type DesiredPowerState = "follow_layout" | "on" | "standby";