- Omorganiser docs/: konsepter, features, infra og proposals i egne mapper - Ny docs/erfaringer/ med lærdommer fra chat-implementering (Svelte 5, SpacetimeDB, adapter-mønster) - Oppdater ARCHITECTURE.md: Lag 1 status, ny §10 Erfaringslogg, SpacetimeDB i lokal dev - Oppdater synkronisering.md med implementeringsstatus og designvalg - Oppdater lokal.md med SpacetimeDB og AI Gateway - Utvid PG-skjema med channels, messages, media_files, message_revisions - Legg til seed_dev.sql, migration_safety.md, .env.example - Nye feature-specs: chat, kanban, whiteboard, live_ai, lydmeldinger m.fl. - Nye konsept-specs: studioet, møterommet, redaksjonen, den asynkrone gjesten m.fl. - SpacetimeDB og AI Gateway i docker-compose.dev.yml - collect-docs.sh inkluderer erfaringer/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
363 lines
15 KiB
PL/PgSQL
363 lines
15 KiB
PL/PgSQL
-- 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 <guid>, 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 = '<uuid>';
|
|
--
|
|
-- 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;
|