-- Sidelinja: Initial database schema -- Workspaces, kunnskapsgraf, jobbkø, meldinger, mediefiler -- -- Kjøres mot en tom PostgreSQL-database. -- Krever: PostgreSQL 15+ med pgcrypto (gen_random_uuid) BEGIN; -- ============================================================ -- Extensions -- ============================================================ CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- uuid_generate_v5() for deterministiske segment-UUIDs -- ============================================================ -- 0. Felles: updated_at trigger-funksjon -- ============================================================ -- Brukes av alle tabeller som trenger automatisk oppdatering av -- updated_at-kolonnen. Nødvendig for inkrementell synk, cache- -- invalidering og UI ("sist redigert"). CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- ============================================================ -- 1. Workspaces -- ============================================================ CREATE TABLE workspaces ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, -- URL-vennlig ID, brukes i filstier og media-ruting domain TEXT UNIQUE, -- Valgfritt eget domene for RSS/media settings JSONB NOT NULL DEFAULT '{}', -- Workspace-config: Whisper initial_prompt, AI system-prompts created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TRIGGER trg_workspaces_updated_at BEFORE UPDATE ON workspaces FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- ============================================================ -- 2. Brukere (tynn cache fra Authentik) -- ============================================================ CREATE TABLE users ( authentik_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, avatar_url TEXT, cached_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TYPE workspace_role AS ENUM ('owner', 'admin', 'member'); CREATE TABLE workspace_members ( workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE, role workspace_role NOT NULL DEFAULT 'member', joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (workspace_id, user_id) ); -- ============================================================ -- 3. Nodes — supertabell for alle grafentiteter -- ============================================================ CREATE TYPE node_type AS ENUM ( 'tema', 'aktør', 'faktoide', 'episode', 'segment', 'channel', 'melding', 'meeting' ); CREATE TABLE nodes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, node_type node_type NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_nodes_workspace ON nodes(workspace_id); CREATE INDEX idx_nodes_type ON nodes(workspace_id, node_type); CREATE TRIGGER trg_nodes_updated_at BEFORE UPDATE ON nodes FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- ============================================================ -- 4. Relasjonstyper (globale, ikke workspace-spesifikke) -- ============================================================ CREATE TABLE relation_types ( name TEXT PRIMARY KEY, label TEXT NOT NULL, description TEXT, system BOOLEAN NOT NULL DEFAULT false ); INSERT INTO relation_types (name, label, description, system) VALUES ('MENTIONS', 'Nevner', 'Refererer til denne aktøren/temaet', true), ('ABOUT', 'Handler om', 'Faktoide eller klipp som omhandler', true), ('DISCUSSED_IN', 'Diskutert i', 'Tema diskutert i dette segmentet', true), ('WORKS_FOR', 'Jobber for', 'Person tilknyttet organisasjon', true), ('CONTRADICTS', 'Motsier', 'Står i motsetning til', true), ('PART_OF', 'Del av', 'Tilhører / er underordnet', true), ('AFFECTS_AXIS', 'Påvirker akse', 'Valgomat: spørsmål påvirker akse', true); -- ============================================================ -- 5. Graph edges (workspace-scopet med RLS) -- ============================================================ -- workspace_id er denormalisert fra nodes for å muliggjøre RLS direkte -- på edges. Uten dette kan en direkte SELECT på graph_edges lekke -- relasjoner på tvers av workspaces. CREATE TABLE graph_edges ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, source_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, target_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, relation_type TEXT NOT NULL REFERENCES relation_types(name), context_id UUID REFERENCES nodes(id) ON DELETE SET NULL, confidence REAL CHECK (confidence BETWEEN 0.0 AND 1.0), created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, origin TEXT NOT NULL DEFAULT 'system', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT no_self_reference CHECK (source_id != target_id), CONSTRAINT unique_edge UNIQUE (source_id, target_id, relation_type) ); CREATE INDEX idx_edges_source ON graph_edges(source_id); CREATE INDEX idx_edges_target ON graph_edges(target_id); CREATE INDEX idx_edges_relation ON graph_edges(relation_type); CREATE INDEX idx_edges_workspace ON graph_edges(workspace_id); -- ============================================================ -- 6. Detailtabeller — aktører, temaer, episoder, segmenter -- ============================================================ -- Workspace-tilhørighet arves via FK til nodes. -- Spørringer filtrerer alltid via JOIN med nodes. CREATE TABLE actors ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, name TEXT NOT NULL, type TEXT -- 'person', 'organisasjon', etc. ); CREATE INDEX idx_actors_name ON actors(name); CREATE TABLE topics ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, name TEXT NOT NULL ); CREATE INDEX idx_topics_name ON topics(name); CREATE TABLE episodes ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, title TEXT NOT NULL, published_at TIMESTAMPTZ, guid TEXT UNIQUE NOT NULL -- RSS , immutabel etter opprettelse ); -- Forhindre endring av episode-GUID etter opprettelse. -- RSS-klienter bruker GUID for å identifisere episoder — endring bryter feeds. CREATE OR REPLACE FUNCTION prevent_guid_change() RETURNS TRIGGER AS $$ BEGIN IF OLD.guid IS NOT NULL AND NEW.guid != OLD.guid THEN RAISE EXCEPTION 'episode guid er immutabel og kan ikke endres etter opprettelse'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_episodes_guid_immutable BEFORE UPDATE ON episodes FOR EACH ROW EXECUTE FUNCTION prevent_guid_change(); CREATE TABLE segments ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, episode_id UUID NOT NULL REFERENCES episodes(id) ON DELETE CASCADE, start_time INTERVAL NOT NULL, end_time INTERVAL NOT NULL, transcript TEXT, CONSTRAINT valid_timerange CHECK (end_time > start_time) ); CREATE INDEX idx_segments_episode ON segments(episode_id); CREATE INDEX idx_segments_fts ON segments USING GIN (to_tsvector('norwegian', transcript)); -- ============================================================ -- 7. Channels (chat-kontekster knyttet til vilkårlig node) -- ============================================================ -- En channel er en meldingsstrøm knyttet til en parent-node. -- Enhver node (tema, episode, møte, aktør, ...) kan ha én eller flere channels. -- Config styrer hvilke capabilities kanalen har. CREATE TABLE channels ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, parent_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, name TEXT NOT NULL DEFAULT 'Diskusjon', config JSONB NOT NULL DEFAULT '{ "threads": true, "mentions": true, "attachments": true, "research_clips": false, "ttl_days": null }'::jsonb -- config.threads — tillat reply-to-tråder -- config.mentions — #/@ mention-parsing + grafkoblinger -- config.attachments — filopplasting -- config.research_clips — research_clip meldingstype -- config.ttl_days — null = permanent, tall = auto-slett etter N dager ); CREATE INDEX idx_channels_parent ON channels(parent_id); -- ============================================================ -- 8. Faktoider -- ============================================================ CREATE TABLE factoids ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, body TEXT NOT NULL, source_url TEXT, created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, -- NULL = slettet bruker created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE factoid_votes ( factoid_id UUID NOT NULL REFERENCES factoids(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE, vote SMALLINT NOT NULL CHECK (vote IN (-1, 1)), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (factoid_id, user_id) ); -- ============================================================ -- 9. Meldinger (chat) -- ============================================================ CREATE TYPE message_type AS ENUM ( 'text', -- Vanlig Markdown-melding 'research_clip', -- Innlimt artikkel (Ctrl+A) 'factoid', -- Faktoid delt i chat 'voice_memo', -- Lydmelding (lyd er master, transkripsjon som metadata) 'system' -- Automatisk systemmelding ); CREATE TABLE messages ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, reply_to UUID REFERENCES messages(id) ON DELETE SET NULL, author_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, -- NULL = slettet bruker message_type message_type NOT NULL DEFAULT 'text', body TEXT NOT NULL, metadata JSONB, edited_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_messages_channel ON messages(channel_id, created_at); CREATE TABLE message_revisions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, body TEXT NOT NULL, edited_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT revision_order UNIQUE (message_id, edited_at) ); CREATE TABLE message_votes ( message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE, vote SMALLINT NOT NULL CHECK (vote IN (-1, 1)), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (message_id, user_id) ); -- ============================================================ -- 10. Mediefiler (content-addressable, workspace-scopet) -- ============================================================ CREATE TABLE media_files ( sha256 TEXT PRIMARY KEY, workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, mime_type TEXT NOT NULL, file_size BIGINT NOT NULL, uploaded_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, -- NULL = slettet bruker uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_media_workspace ON media_files(workspace_id); CREATE TABLE message_attachments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, sha256 TEXT NOT NULL REFERENCES media_files(sha256), sort_order SMALLINT NOT NULL DEFAULT 0 ); -- ============================================================ -- 11. Jobbkø (workspace-scopet) -- ============================================================ CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'retry'); CREATE TABLE job_queue ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, job_type TEXT NOT NULL, payload JSONB NOT NULL, status job_status NOT NULL DEFAULT 'pending', priority SMALLINT NOT NULL DEFAULT 0, result JSONB, error_msg TEXT, attempts SMALLINT NOT NULL DEFAULT 0, max_attempts SMALLINT NOT NULL DEFAULT 3, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for ASC) WHERE status IN ('pending', 'retry'); CREATE INDEX idx_job_queue_workspace ON job_queue(workspace_id); -- ============================================================ -- 12. Row-Level Security (Workspace-isolasjon) -- ============================================================ -- SvelteKit setter session-variabel ved tilkobling: -- SET app.current_workspace_id = ''; -- -- Rust-workers kjører som superuser (bypasser RLS) for å prosessere -- jobber på tvers av workspaces. Workspace-isolasjon for workers -- sikres i applikasjonskode (job_queue.workspace_id). -- -- Detailtabeller (actors, topics, etc.) har ikke egen RLS — de -- arver workspace-tilhørighet via FK til nodes. -- Applikasjonskode filtrerer via JOIN med nodes. ALTER TABLE nodes ENABLE ROW LEVEL SECURITY; ALTER TABLE graph_edges ENABLE ROW LEVEL SECURITY; ALTER TABLE media_files ENABLE ROW LEVEL SECURITY; ALTER TABLE job_queue ENABLE ROW LEVEL SECURITY; CREATE POLICY workspace_isolation_nodes ON nodes USING (workspace_id = current_setting('app.current_workspace_id')::uuid); CREATE POLICY workspace_isolation_edges ON graph_edges USING (workspace_id = current_setting('app.current_workspace_id')::uuid); CREATE POLICY workspace_isolation_media ON media_files USING (workspace_id = current_setting('app.current_workspace_id')::uuid); CREATE POLICY workspace_isolation_jobs ON job_queue USING (workspace_id = current_setting('app.current_workspace_id')::uuid); COMMIT;