-- gsc-shell-api — initial schema. -- -- Lives in `gsc_core` so it's reachable from every app that needs chrome data. -- Owned by the `gsc_shell` role (created out of band). -- -- Idempotent: every CREATE uses IF NOT EXISTS where possible. CREATE SCHEMA IF NOT EXISTS shell; -- Apps registry. -- An "app" is a frontend that wants to render chrome from this service. CREATE TABLE IF NOT EXISTS shell.apps ( app_key TEXT PRIMARY KEY CHECK (app_key ~ '^[a-z][a-z0-9-]*$'), display_name TEXT NOT NULL, base_url TEXT NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Menu zones. Mirrors what the existing AdminShell expects. DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'menu_zone' AND typnamespace = 'shell'::regnamespace) THEN CREATE TYPE shell.menu_zone AS ENUM ('topbar', 'sidebar', 'footer', 'user-menu'); END IF; END $$; -- Menu items. One row per nav entry per app per zone. -- Hierarchical via parent_id (sidebar submenus, etc.). CREATE TABLE IF NOT EXISTS shell.menu_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_key TEXT NOT NULL REFERENCES shell.apps(app_key) ON DELETE CASCADE, parent_id UUID REFERENCES shell.menu_items(id) ON DELETE CASCADE, zone shell.menu_zone NOT NULL, key TEXT NOT NULL CHECK (key ~ '^[a-z][a-z0-9_-]*$'), translation_key TEXT NOT NULL, -- e.g. 'menu.dashboard' href TEXT NOT NULL, -- relative within app, or absolute if is_external icon TEXT, -- Phosphor class, e.g. 'ph-house' sort_order INT NOT NULL DEFAULT 0, is_external BOOLEAN NOT NULL DEFAULT false, is_active BOOLEAN NOT NULL DEFAULT true, metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (app_key, zone, key) ); CREATE INDEX IF NOT EXISTS idx_menu_items_app_zone ON shell.menu_items (app_key, zone, sort_order) WHERE is_active; CREATE INDEX IF NOT EXISTS idx_menu_items_parent ON shell.menu_items (parent_id) WHERE parent_id IS NOT NULL; -- Role grants. Empty grants = visible to anyone authenticated. OR semantics -- across rows: user sees the item if they hold ANY listed role. -- Roles are Keycloak realm roles; we store their names as plain text since -- Keycloak is the source of truth. CREATE TABLE IF NOT EXISTS shell.menu_role_grants ( menu_item_id UUID NOT NULL REFERENCES shell.menu_items(id) ON DELETE CASCADE, role TEXT NOT NULL, PRIMARY KEY (menu_item_id, role) ); -- Per-app branding. Logo, product name, footer text. Optional for now. CREATE TABLE IF NOT EXISTS shell.branding ( app_key TEXT PRIMARY KEY REFERENCES shell.apps(app_key) ON DELETE CASCADE, logo_url TEXT NOT NULL, product_name TEXT NOT NULL, footer_html TEXT, brand_color TEXT, -- optional CSS color override updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- updated_at maintenance. CREATE OR REPLACE FUNCTION shell.touch_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END $$ LANGUAGE plpgsql; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'apps_touch_updated_at') THEN CREATE TRIGGER apps_touch_updated_at BEFORE UPDATE ON shell.apps FOR EACH ROW EXECUTE FUNCTION shell.touch_updated_at(); END IF; IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'menu_items_touch_updated_at') THEN CREATE TRIGGER menu_items_touch_updated_at BEFORE UPDATE ON shell.menu_items FOR EACH ROW EXECUTE FUNCTION shell.touch_updated_at(); END IF; IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'branding_touch_updated_at') THEN CREATE TRIGGER branding_touch_updated_at BEFORE UPDATE ON shell.branding FOR EACH ROW EXECUTE FUNCTION shell.touch_updated_at(); END IF; END $$;