Meldingsboks-migrasjon: universell diskusjonsprimitiv + entities

Migrering 0005 samler kanban-kort, kalenderhendelser, faktoider og
notater til én felles messages-tabell med view-config-tabeller.
Actors og topics erstattes av unified entities-tabell.

- 0005_meldingsboks.sql: messages utvides med title/pinned/visibility,
  kanban_card_view + calendar_event_view + message_reactions opprettes,
  entities erstatter actors+topics, gamle tabeller droppes
- seed_dev.sql: oppdatert til meldingsboks-modell + 5 test-entiteter
  med graf-relasjoner
- API-ruter: kanban/kalender/notater bruker messages + view-config
- Dokumentasjon: meldingsboks feature-spec, oppdatert arkitektur,
  kunnskapsgraf, jobbkø, konseptdokumenter og proposals

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-15 15:32:15 +01:00
parent 024a91e1b3
commit 1faef972dd
20 changed files with 936 additions and 141 deletions

View file

@ -205,17 +205,18 @@ Chat (channels), Kanban, Kalender, Notater/Scratchpad, Whiteboard, Live transkri
- [x] SpacetimeDB grunnoppsett (Docker, Rust WASM-modul, TypeScript-bindings)
- [x] SvelteKit skjelett med Authentik-integrasjon + Workspace-switcher
- [x] AI Gateway (LiteLLM) oppsett + config
- [ ] Git-repostruktur for transkripsjoner (ett repo per workspace)
- [x] Git-repostruktur for transkripsjoner (ett repo per workspace) — spec i `docs/concepts/podcastfabrikken.md` §5.2
### Lag 2 — Kjernekomponenter (krever Lag 1)
- [ ] **Meldingsboks-migrasjon** (0005): Universell diskusjonsprimitiv som erstatter separate modeller for chat, kanban-kort, kalenderhendelser, faktoider og notater. Se `docs/features/meldingsboks.md`. Migrerer eksisterende data fra `kanban_cards`, `calendar_events`, `factoids`, `notes` til ny `messages`-tabell + view-config-tabeller. **Bør gjøres før videre arbeid på chat/kanban/kalender for å unngå bygge-på-gammel-modell.**
- [ ] Jobbkø-worker (Rust)
- [ ] Kunnskapsgraf CRUD (SvelteKit server-side)
- [ ] pgvector-migrasjon (0005): `CREATE EXTENSION vector;` + embedding-kolonner på nodes — gjøres tidlig for å unngå smertefull migrasjon i Lag 4
- [ ] RLS Leak Hunter i CI (se `docs/setup/migration_safety.md`)
- [~] Chat med channels (PG-adapter + SpacetimeDB hybrid-adapter ferdig, sync-worker gjenstår)
- [~] Kanban (PG-adapter ferdig med drag & drop, redigeringsmodal, CRUD API. SpacetimeDB-sync gjenstår)
- [~] Kalender (PG-adapter ferdig med månedsvisning, fargekoder, heldags/tidshendelser. SpacetimeDB-sync gjenstår)
- [~] Notater/Scratchpad (PG-adapter ferdig med auto-save, debounce, tittel+innhold. Rich text og SpacetimeDB-sync gjenstår)
- [ ] pgvector-migrasjon (0006): `CREATE EXTENSION vector;` + embedding-kolonner på nodes — gjøres tidlig for å unngå smertefull migrasjon i Lag 4
- [ ] **RLS Leak Hunter i CI** (se `docs/setup/migration_safety.md`)**KRITISK, bør være første CI-steg.** En glemt RLS-policy på en ny tabell (f.eks. `guest_tokens`, fremtidige valgomat-tabeller) kan lekke data uten at noen merker det. Automatisér med testcontainers + fullstendig leak-test mot alle tabeller med `workspace_id`.
- [~] Chat med channels (PG-adapter + SpacetimeDB hybrid-adapter ferdig**refaktoreres til meldingsboks-modell**, sync-worker gjenstår)
- [~] Kanban (PG-adapter ferdig med drag & drop**refaktoreres til meldingsboks + kanban_card_view**, SpacetimeDB-sync gjenstår)
- [~] Kalender (PG-adapter ferdig med månedsvisning**refaktoreres til meldingsboks + calendar_event_view**, SpacetimeDB-sync gjenstår)
- [~] Notater/Scratchpad (PG-adapter ferdig **refaktoreres til meldingsboks**, rich text og SpacetimeDB-sync gjenstår)
- [ ] Lydmeldinger & Diktering (opptak + Whisper + AI-opprydding)
- [ ] Prompt-Laboratorium (prompt-testing mot egne data)
- [ ] Promptfoo testsett for første jobbtyper (norsk testdata)
@ -260,6 +261,7 @@ SvelteKit-appen inkluderer en intern admin-side (`/admin/observability`) som sam
- **Container-status:** Healthcheck-resultater fra Docker (via `docker compose ps` / Docker socket)
- **Jobbkø:** Pending/running/error-count med sparkline-grafer (siste 24t)
- **AI Gateway:** Token-bruk per jobbtype, kostnad per workspace, failover-hendelser (fra LiteLLMs innebygde logging). Inkluderer workspace-budsjett status (se `docs/infra/ai_gateway.md` §6).
- **Database-ytelse:** `pg_stat_statements`-utvidelse for query-kostnader per workspace/feature. Identifiserer hvilke features som spiser CPU/RAM (f.eks. tunge graf-spørringer, valgomat-PCA).
- **Disk/Minne:** Mediamappe-størrelse per workspace, PG-størrelse, SpacetimeDB-minnebruk (med graf over tid)
- **Sikkerhet:** Siste secret-rotasjon timestamp (`.env`-endringer), RLS Leak Hunter siste kjøring, antall aktive guest-tokens
- **SpacetimeDB:** Minnebruk-graf, `sync_outbox`-størrelse (indikerer sync-etterslep), tilkoblede klienter per workspace

View file

@ -32,6 +32,7 @@ Self-hosted på Hetzner VPS med full datakontroll.
- `prompt_lab.md` — Internt verktøy for testing og deploy av LLM-prompts
- `kalender.md` — Redaksjonell kalender med abonnementsmodell og ICS-eksport
- `notater.md` — Scratchpad/notatblokk med auto-save og debounce
- `meldingsboks.md` — Universell diskusjonsprimitiv (erstatter chat/kanban-kort/kalender/faktoider/notater)
- `docs/infra/` — Infrastruktur (ikke brukersynlig):
- `jobbkø.md` — Felles PostgreSQL-basert køsystem for alle bakgrunnsjobber
- `synkronisering.md` — PostgreSQL ↔ SpacetimeDB dataflyt og eierskapsmodell

View file

@ -87,6 +87,14 @@ clamav:
```
SvelteKit kaller ClamAV via `clamdscan` (socket) etter filopplasting, før filen flyttes til endelig plassering. Infiserte filer slettes umiddelbart og tokenet flagges for manuell gjennomgang.
**Fremtidig hardening — prosess-isolasjon:**
Ved økt eksponering (mange aktive guest-tokens, offentlige lenker) bør opplastede filer prosesseres i en isolert kontekst per token. Mulige tilnærminger:
- Firejail/bubblewrap-sandbox for Whisper-prosessering av gjeste-audio
- Dedikert temp-mappe per token som slettes etter prosessering
- Docker sidecar-container for uautentisert filopplasting med egne cgroups
Dette er komplementært til ClamAV (som fanger kjent malware) — sandboxing beskytter mot ukjente angrep. Implementeres når gjeste-tokens eksponeres bredere enn redaksjonell bruk.
### 4.3 Flyt (teknisk)
```
Gjest åpner URL med token

View file

@ -75,9 +75,43 @@ Hver workspace har sin egen podcast-konfigurasjon, lagret i `workspaces.settings
### 5.1 Mediefiler
Lydfiler lagres i undermapper per workspace: `/srv/sidelinja/media/{workspace_slug}/`. Caddy ruter trafikk basert på domene (fra `workspaces.domain`) til riktig undermappe.
### 5.2 Transkripsjoner
### 5.2 Transkripsjoner (Git-repostruktur)
Det opprettes **ett Forgejo-repo per workspace** for SRT-filer, slik at historikk og redigering ikke blandes på tvers av podcaster.
#### Repo-oppretting
Repoet opprettes **on-demand** ved første transkripsjonsjobb for en workspace, via Forgejo API. Ikke alle workspaces trenger transkripsjonsrepo.
#### Filnavnkonvensjon
Flat struktur med prosesseringstidspunkt som filnavn:
```
20260315_143022.srt
20260401_091500.srt
```
* **Format:** `YYYYMMDD_HHMMSS.srt` — settes automatisk av Rust-worker ved prosessering
* **Sortering:** Kronologisk i enhver filvisning
* **Unikhet:** Tidsstempel garanterer unikhet uten suffiks-logikk
* **Ingen metadata i filnavn:** Episodenummer, tittel, slug og annen metadata lever i PostgreSQL, ikke i filnavnet. Filnavnet er en stabil identifikator som aldri endres.
#### Mediefiler matcher Git
Lydfilen i `/srv/sidelinja/media/{workspace_slug}/` bruker **samme navnekonvensjon** som SRT-filen: `20260315_143022.mp3` matcher `20260315_143022.srt`. Dette kobler mediefil og transkripsjon uten databaseoppslag.
#### Reprosessering (redigert lyd)
Når en lydfil redigeres og transkriberes på nytt, **beholdes det opprinnelige filnavnet**. Rust-worker overskriver SRT-filen i Git — historikken viser endringene via `git log`/`git diff`. Mediefilen i arkivet døpes om til å matche Git-filnavnet dersom den opprinnelig hadde et annet navn.
#### Forgejo-bruker
En dedikert servicebruker **"serverassistent"** opprettes i Forgejo med push-tilgang til transkripsjonsrepoer. Ingen admin-rettigheter.
#### Webhook-flyt
```
Forgejo push-webhook → SvelteKit POST /api/webhooks/forgejo
→ INSERT INTO job_queue (type: 'srt_parse', payload: {repo, commit, workspace_id})
→ Rust-worker plukker opp jobben og parser SRT → avledede formater i PG
```
SvelteKit validerer webhook-signatur og legger jobb i køen. Rust-worker forblir en ren kø-consumer uten eget HTTP-endepunkt.
#### SRT-editor
En enkel SRT-editor bygges i SvelteKit (Lag 3, sammen med Podcastfabrikken): segmenter som redigerbare tekstfelt med tidsstempler, "Lagre" committer tilbake til Git via Forgejo API. Forgejo web-UI fungerer som fallback for power users.
### 5.3 AI-prompts
* **Whisper `initial_prompt`:** Navnelister og kontekst lagres per workspace i `settings.whisper_prompt`. Rust-worker bygger prompten fra statisk liste + aktører i workspace-ets kunnskapsgraf.
* **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i `settings.llm_prompts` slik at AI-en kjenner konteksten og vertene for akkurat den podcasten.

View file

@ -88,7 +88,16 @@ Av kostnads- og ytelseshensyn skjer all AI-bruk asynkront i backend via jobbkøe
| Aktive sesjoner, live PCA-state | Flyktig (SpacetimeDB) | Tåler tap — bruker svarer på nytt |
| AI-genererte kandidatprofiler | Avledet (PG) | Kan regenereres fra partiprogrammer |
## 9. Instruks for Claude Code
## 9. Skaleringsrisiko
### PCA i SpacetimeDB
PCA-beregning i SpacetimeDB er minnekrevende og udokumentert for store datasett. Ved tusenvis av samtidige brukere med 50+ akser kan minnebruken eksplodere. Tiltak:
- **Materialized Views i PostgreSQL** for Sidelinja Explorer — aldri kjør tunge aggregeringer on-the-fly. Oppdater views via nattlig jobb eller etter batch-sync fra SpacetimeDB.
- **Batch-sync med størrelsesbegrensning** — sync_outbox kan bli bottleneck ved høy svaraktivitet. Vurder dedikert sync-frekvens for valgomat-data.
- **PCA-fallback i PG** — hvis SpacetimeDB-minnebruk overstiger terskel, flytt PCA-beregning til PostgreSQL (via `pg_stat_statements` for kostnadsmåling) med lengre oppdateringsintervall.
- **Overvåk tidlig** — legg til valgomat-spesifikke metrikker i `/admin/observability` (antall aktive sesjoner, PCA-beregningstid, SpacetimeDB-minnebruk for valgomat-tabeller).
## 10. Instruks for Claude Code
1. **Datamodell:** Utvid enum `node_type` i PostgreSQL med `valgomat_question` og `valgomat_axis`. Bruk `graph_edges` med `relation_type = 'AFFECTS_AXIS'` og oppdater `confidence`-feltet for å håndtere crowdsourcet vekting av spørsmål opp mot ulike akser.
2. **SpacetimeDB Reducers:** Implementer innkommende events som `SubmitSwipe`, `SuggestAxis`, og match-algoritmen i Rust inne i SpacetimeDB. Pass på at reducere støtter anonym `session_id`.
3. **State Management:** SvelteKit skal ikke kreve innlogging for forsiden. Implementer Auth-guards slik at opprettelse av spørsmål, stemmegivning på andres forslag og visning av kommentarer gir `403 Forbidden` for uautoriserte og trigger Authentik-flyten.

View file

@ -16,31 +16,55 @@ Alle entiteter i systemet arver sin UUID fra én sentral tabell. Dette gir ekte
```sql
CREATE TYPE node_type AS ENUM (
'tema', 'aktør', 'faktoide', 'episode', 'segment', 'melding'
'entitet', -- person, organisasjon, sted, tema, konsept (erstatter 'aktør' og 'tema')
'episode', -- podcast-episode
'segment', -- tidsavgrenset del av episode
'melding', -- meldingsboks (chat, kanban-kort, kalenderhendelse, faktoide, notat)
'channel', -- gruppering av meldinger
'kanban_board', -- strukturelt
'calendar', -- strukturelt
'meeting' -- LiveKit-møte
);
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()
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
**Merk:** Gamle enum-verdier (`'aktør'`, `'tema'`, `'faktoide'`, `'note'`, `'kanban_card'`, `'calendar_event'`) kan ikke fjernes fra PostgreSQL ENUM, men skal aldri brukes i ny kode.
### 3.2 Detailtabeller
Hver nodetype har sin egen tabell med FK til `nodes`. Eksempler:
Hver nodetype har sin egen tabell med FK til `nodes`.
#### Entiteter (erstatter `actors` og `topics`)
Alt som kan nevnes med `#` i chat er en entitet — personer, organisasjoner, steder, temaer, konsepter. Én tabell, én autocomplete, én `#`-mekanisme.
```sql
CREATE TABLE actors (
CREATE TABLE entities (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT -- 'person', 'organisasjon', etc.
name TEXT NOT NULL, -- Autoritativ skrivemåte
type TEXT NOT NULL, -- 'person', 'organisasjon', 'sted', 'tema', 'konsept'
aliases TEXT[] DEFAULT '{}', -- Forkortelser, kallenavn, vanlige feilstavinger
avatar_url TEXT -- Portrett, flagg, logo, kommunevåpen
);
CREATE TABLE topics (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
name TEXT NOT NULL
);
CREATE INDEX idx_entities_name ON entities(name);
CREATE INDEX idx_entities_aliases ON entities USING GIN(aliases);
```
Autocomplete søker i både `name` og `aliases`. `name` er den autoritative formen som vises i UI. Eksempler:
- `name: 'Jonas Gahr Støre'`, `aliases: {'JGS', 'Støre'}`, `type: 'person'`
- `name: 'Arbeiderpartiet'`, `aliases: {'AP', 'Ap', 'DNA'}`, `type: 'organisasjon'`
- `name: 'Lørenskog'`, `aliases: {'Lørenskog kommune'}`, `type: 'sted'`
- `name: 'Skolepolitikk'`, `aliases: {}`, `type: 'tema'`
#### Episoder og segmenter
```sql
CREATE TABLE episodes (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
title TEXT NOT NULL,

View file

@ -0,0 +1,305 @@
# Feature: Meldingsboks — universell diskusjonsprimitiv
**Filsti:** `docs/features/meldingsboks.md`
## 1. Konsept
Meldingsboksen er den sentrale byggeklossen for alt ustrukturert innhold i Sidelinja. Én datamodell som erstatter separate tabeller for chat-meldinger, kanban-kort, kalenderhendelser, faktoider og notater. Samme objekt, samme diskusjon, vist i flere kontekster via view-config.
### 1.1 Hva meldingsboksen erstatter
| Tidligere modell | Egen tabell | Blir nå |
|---|---|---|
| Chat-melding | `messages` | Meldingsboks (node) |
| Kanban-kort | `kanban_cards` | Meldingsboks + `kanban_card_view` |
| Kalenderhendelse | `calendar_events` | Meldingsboks + `calendar_event_view` |
| Faktoide | `factoids` | Meldingsboks med `ABOUT`-edge |
| Notat | `notes` | Meldingsboks med tittel |
### 1.2 Hva som IKKE er meldingsbokser
Typed nodes med strukturelt unike skjemaer forblir egne detailtabeller:
| Type | Hvorfor egen tabell |
|---|---|
| Entitet | `name`, `type`, `aliases`, `avatar_url` — autocomplete, Whisper-prompt, autoritativ navngiving |
| Episode | `title`, `guid` (immutabel RSS-krav), `published_at` |
| Segment | `episode_id`, `start_time`/`end_time`, `transcript`, FTS-indeks |
**Entiteter** (erstatter `actors` + `topics`) er alt som kan nevnes med `#`: personer, organisasjoner, steder, temaer, konsepter. Se `docs/features/kunnskapsgraf_og_relasjoner.md` §3.2.
Typed nodes kan **kobles til meldingsbokser** via edges for diskusjon. En entitet har ikke innebygd diskusjon, men en meldingsboks kan knyttes til den med en `DISCUSSED_IN`-edge.
## 2. Alle meldinger er noder
Hver meldingsboks er en fullverdig node i kunnskapsgrafen (`node_type = 'melding'`). Ingen vektklasser, ingen promoteringslogikk. Opprettelse er alltid: `INSERT INTO nodes` + `INSERT INTO messages` i én transaksjon.
**Hvorfor:** De fleste meldinger i en aktiv redaksjon ender opp med å trenge graf-tilkobling uansett (mentions, svar, stemmer). Promoteringslogikk legger til kompleksitet uten reell gevinst. `nodes`-tabellen tåler volumet — TTL rydder opp i flyktige meldinger, og `node_type`-filter sikrer at spørringer aldri treffer hele tabellen.
**Konsekvens:** Ethvert svar er en rik entitet som kan kobles til kanban, kalender, graf — full fleksibilitet uten spesialtilfeller. Et svar på tredje nivå i en diskusjon kan bli en kalenderoppføring, og konteksten forsvinner ikke.
## 3. Datamodell
### 3.1 Messages (erstatter `messages`, `kanban_cards`, `calendar_events`, `factoids`, `notes`)
```sql
CREATE TABLE messages (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
channel_id UUID REFERENCES nodes(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,
message_type message_type NOT NULL DEFAULT 'text',
title TEXT, -- Kanban-kort, notater, kalenderhendelser, faktoider
body TEXT NOT NULL,
metadata JSONB, -- Ekstra data per message_type
pinned BOOLEAN NOT NULL DEFAULT false,
visibility TEXT NOT NULL DEFAULT 'workspace', -- 'workspace' | 'private'
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX idx_messages_reply ON messages(reply_to) WHERE reply_to IS NOT NULL;
CREATE TRIGGER trg_messages_updated_at BEFORE UPDATE ON messages
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
```
**Forskjeller fra gammel `messages`-tabell:**
- `id` er FK til `nodes(id)`**alle meldinger er noder** i kunnskapsgrafen
- Ingen `workspace_id` — arves via `nodes.workspace_id` (RLS på nodes gjelder)
- `channel_id` er nullable — notater og standalone-bokser trenger ikke en channel
- `title` er førsteklasses felt — brukes av kanban-kort, notater, kalenderhendelser
- `pinned` for manuell fritak fra TTL-sletting
- `visibility` styrer synlighet — `'workspace'` (alle i workspacet) eller `'private'` (kun forfatter). Private meldingsbokser kan brukes som kladd for notater, kanban-kort og kalenderhendelser. Endre til `'workspace'` for å dele.
### 3.1.1 Kontekst-navigering via `reply_to`
Når en meldingsboks har roller i flere kontekster (f.eks. et svar som også er en kalenderoppføring), gir `reply_to`-kjeden alltid vei tilbake til opprinnelig diskusjon:
```
📅 Kalenderen: "Planleggingsmøte for konferanse"
↩ Fra diskusjon i #Mediepolitikk → "Vi burde kanskje..."
```
UI-et følger `reply_to` → forelder → `channel_id` → parent node for å bygge brødsmulesti. Ingen ekstra data nødvendig — `reply_to` + view-config gir hele bildet i begge retninger:
- **Fra kalenderen:** "Hvor kom denne fra?" → følg `reply_to` oppover
- **Fra chatten:** "Hva ble dette?" → se at svaret har `calendar_event_view` → vis kalender-badge
### 3.2 Kanban view-config (erstatter `kanban_cards`)
```sql
CREATE TABLE kanban_card_view (
message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
column_id UUID NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE,
position REAL NOT NULL DEFAULT 0,
color TEXT,
assignee_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL
);
CREATE INDEX idx_kanban_card_view_column ON kanban_card_view(column_id, position);
```
Et kanban-kort er en meldingsboks + en rad i `kanban_card_view`. Tittel og beskrivelse lever i `messages.title`/`messages.body`. Flytt mellom kolonner = `UPDATE kanban_card_view SET column_id = ...`.
`kanban_boards` og `kanban_columns` forblir uendret — de er strukturelle tabeller, ikke grafnoder (kolonner er intern organisering).
### 3.3 Kalender view-config (erstatter `calendar_events`)
```sql
CREATE TABLE calendar_event_view (
message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
calendar_id UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ,
all_day BOOLEAN NOT NULL DEFAULT false,
color TEXT
);
CREATE INDEX idx_calendar_event_view_calendar ON calendar_event_view(calendar_id, starts_at);
```
`calendars`-tabellen forblir uendret.
### 3.4 Reaksjoner (erstatter `factoid_votes` og `message_votes`)
```sql
CREATE TABLE message_reactions (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
reaction TEXT NOT NULL, -- 'upvote', 'downvote', '👍', '🔥', etc.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id, reaction)
);
```
Én tabell for alle interaksjoner — opp/ned-stemmer (`'upvote'`/`'downvote'`) og emoji-reaksjoner (`'👍'`, `'🔥'`) i samme modell. Ikke graf-edges — reaksjoner er høyfrekvente, lav-semantiske operasjoner.
Sortering etter stemmer: `SELECT COUNT(*) FILTER (WHERE reaction = 'upvote') - COUNT(*) FILTER (WHERE reaction = 'downvote') AS score`.
### 3.5 Message revisions (uendret)
```sql
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)
);
```
### 3.6 Nye relasjonstyper
Eksisterende relasjonstyper dekker behovene — ingen nye nødvendig:
- `DISCUSSED_IN` — kobler typed nodes til meldingsbokser (diskusjon på en aktør/tema)
- `ABOUT` — kobler faktoide-meldinger til aktører/temaer
- `MENTIONS` — chat-mentions via `#`-tags
### 3.7 node_type enum (opprydding)
Aktive verdier etter migrering:
- `'entitet'` — person, organisasjon, sted, tema, konsept (erstatter `'aktør'` og `'tema'`)
- `'episode'`, `'segment'` — typed nodes
- `'melding'` — meldingsboks (chat, kanban-kort, kalenderhendelse, faktoide, notat)
- `'channel'` — gruppering av meldinger
- `'kanban_board'`, `'calendar'`, `'meeting'` — strukturelle
Utfasede verdier (kan ikke fjernes fra PG ENUM, men skal aldri brukes i ny kode):
`'aktør'`, `'tema'`, `'faktoide'`, `'note'`, `'kanban_card'`, `'calendar_event'`
## 4. Multi-rolle via view-config
Konvertering = legg til view-config. Ingen data flyttes:
```
Meldingsboks (messages + nodes)
├── kanban_card_view → vises på kanban-brettet
├── calendar_event_view → vises i kalenderen
├── edge: ABOUT → aktør/tema → vises som faktoide
├── edge: DISCUSSED_IN ← typed node → diskusjon på aktør/tema/episode
├── reply_to → parent melding → tråd-kontekst
└── reply_to ← svar → diskusjonstråd følger med i alle kontekster
```
En meldingsboks kan ha **flere roller samtidig**: et kanban-kort som også er en kalenderhendelse, med en diskusjonstråd under seg. Svar på meldingsboksen følger med uansett kontekst — diskusjonen er alltid tilgjengelig.
## 5. Channels
Channels forblir som grupperingsmekanisme. En channel samler meldingsbokser under ett scope — typisk knyttet til en typed node (tema, episode, møte).
Channels **opprettes ved behov**, ikke automatisk for alle noder. Når en bruker starter en diskusjon på en aktør, opprettes en channel i samme transaksjon.
Channel-config (`threads`, `mentions`, `attachments`, `ttl_days`) arves fra kontekst (se `docs/features/chat.md` §2.2).
## 6. Nesting og utskilling
Maks 3 nivåer visuelt (via `reply_to`-kjeding):
1. Boks (trådstart)
2. Svar på boks
3. Svar på svar
Ved nivå 3 tilbyr systemet: **"Skill ut som egen diskusjon?"**
- Svaret promoteres til boks (ny node)
- Originaltråden får en lenke-melding: "→ Diskusjonen fortsetter her"
- Den nye boksen lever sitt eget liv
## 7. Eierskap, kurasjon og prominens
### 7.1 Eierskap
Trådstarter og workspace-admin deler eierskap over en diskusjonstråd. Eierskap gir tilgang til kurasjonsverktøy (se 7.2).
### 7.2 Kurasjon (TODO — UI-features, bygges inkrementelt)
Datamodellen trenger ikke endres for disse — alt håndteres via `messages.metadata` (JSONB):
- **Absorber svar**`metadata.absorbed = true`. Svaret kollapses visuelt, ikke slettet.
- **Kollapser utdaterte svar**`metadata.collapsed = true`. Alltid tilgjengelig med klikk.
- **Fest viktige svar**`metadata.featured = true`. Vises prominent i lang tråd.
### 7.3 Prominens (avledet, ikke lagret)
Hvor viktig en meldingsboks er, beregnes fra eksisterende data — aldri lagret som en score:
- Antall svar (`COUNT``reply_to`)
- Stemmer (`SUM` fra `message_votes`)
- Antall roller (finnes i `kanban_card_view`, `calendar_event_view`?)
- Graf-koblinger (`COUNT` fra `graph_edges`)
- Alder på siste aktivitet (`MAX(created_at)` fra svar)
Beregnes ved visning eller caches i materialized view ved behov. Algoritmen kan justeres uten migrasjoner eller skjemaendringer.
## 8. TTL og livsløp
### 8.1 To-trinns fading
1. **Skjult fra visning** — meldingen forsvinner fra default UI etter TTL (arvet fra channel/workspace). `messages.metadata.hidden_at` settes.
2. **Slettet** — etter tilleggsperiode (dobbel TTL) fjernes raden permanent.
### 8.2 Alder som dynamisk faktor
Tid bidrar til utfasing — eldre meldinger uten aktivitet eller koblinger fader naturlig. Prominens-scoren (§7.3) synker med alder, og TTL-jobben bruker den til å avgjøre hva som skjules og slettes.
### 8.3 Fritak-regler
En melding slettes **ikke** hvis:
- Den har **graf-edge(s)** (`ABOUT`, `MENTIONS`, `DISCUSSED_IN`, etc.) — koblet til noe varig i kunnskapsgrafen. Dette er det som gjør faktoider immune: en `ABOUT`-edge til en aktør/tema betyr at informasjonen har verdi utover konteksten den ble skrevet i.
- Den har `kanban_card_view`-rad i en aktiv kolonne
- Den har `calendar_event_view`-rad med fremtidig tidspunkt
- Den har aktive svar (siste svar innenfor TTL)
- `pinned = true`
**Prinsippet:** Grafen bestemmer hva som er varig. Ingen spesialhåndtering for faktoider — enhver melding med en graf-kobling overlever. Meldinger uten koblinger fader med tid.
Lever boksen, lever alt under den — svar beholdes uansett alder.
### 8.4 Konfigurerbarhet
```
Workspace-default TTL: 30 dager (workspaces.settings.default_ttl_days)
└── Channel kan overstyre: config.ttl_days
└── Individuelle meldinger frittes via reglene over
```
## 9. `<MessageBox>` Svelte-komponent
Én komponent som rendrer en meldingsboks i alle kontekster:
- **Kompakt modus** — kanban: tittel + "3 svar" + fargekode
- **Kalender-modus** — tittel + tidspunkt + fargekode
- **Utvidet modus** — full diskusjon med innrykk (maks 3 nivåer)
- Leser kontekst og tilpasser capabilities (stemmer, mentions, vedlegg)
- Lazy-loader tråd ved expand (ytelse)
## 10. Konsekvenser for eksisterende kode
### 10.1 Tabeller som fjernes
- `kanban_cards` → erstattes av `kanban_card_view`
- `calendar_events` → erstattes av `calendar_event_view`
- `factoids` + `factoid_votes` → erstattes av `messages` + `message_reactions`
- `message_votes` → erstattes av `message_reactions`
- `notes` → erstattes av `messages` med tittel
### 10.2 Tabeller som forblir uendret
- `kanban_boards`, `kanban_columns` — strukturelle
- `calendars` — strukturell
- `channels` — gruppering
- `message_revisions` — revisjonshistorikk
- `message_attachments`, `media_files` — vedlegg
### 10.3 API-endringer
Eksisterende API-ruter (`/api/kanban/`, `/api/calendar/`, `/api/notes/`) refaktoreres til å bruke den nye datamodellen. Grensesnittet mot frontend kan holdes stabilt — endringene er i datalaget.
## 11. Migrering (0005_meldingsboks.sql)
Migrasjonen konverterer eksisterende data:
1. **Endre `messages`-tabellen:** Legg til `title`, `pinned`. `id → nodes(id)` beholdes (alle meldinger er allerede noder).
2. **Migrer kanban-kort:** For hver `kanban_cards`-rad: opprett `messages`-rad (med `title`, `body` fra description, gjenbruk eksisterende node-id) + `kanban_card_view`-rad.
3. **Migrer kalenderhendelser:** For hver `calendar_events`-rad: opprett `messages`-rad + `calendar_event_view`-rad.
4. **Migrer faktoider:** For hver `factoids`-rad: opprett `messages`-rad (med `body`, `message_type = 'factoid'`). Flytt `factoid_votes` til `message_votes`. Opprett `ABOUT`-edges i `graph_edges`.
5. **Migrer notater:** For hver `notes`-rad: opprett `messages`-rad (med `title`, `body` fra content).
6. **Drop gamle tabeller:** `kanban_cards`, `calendar_events`, `factoids`, `factoid_votes`, `notes`.
**Merk:** Migrasjonen bør ha en tilhørende down-migrering som gjenskaper de gamle tabellene og flytter data tilbake.
## 12. Instruks for Claude Code
- **Opprettelse av meldingsboks:** Alltid INSERT i `nodes` (type 'melding') + INSERT i `messages` med samme id. Alt i én transaksjon.
- **Kanban-kort:** INSERT i `nodes` + `messages` (med tittel) + `kanban_card_view`. Én transaksjon.
- **Kalenderhendelse:** INSERT i `nodes` + `messages` (med tittel) + `calendar_event_view`. Én transaksjon.
- **Faktoide:** INSERT i `nodes` + `messages` (`message_type = 'factoid'`) + `graph_edges` med `ABOUT`-relasjon til aktør/tema. Én transaksjon.
- **Notat:** INSERT i `nodes` + `messages` (med tittel + body). `channel_id` peker på en personal/workspace channel eller er NULL.
- **Konvertering mellom roller:** Legg til view-config-rad (kanban/kalender). Aldri kopier data.
- **Kontekst-navigering:** Bruk `reply_to`-kjeden for å bygge brødsmulesti tilbake til opprinnelig diskusjonskontekst.
- **TTL:** Implementér som nattlig jobbkø-jobb (`message_ttl_cleanup`). Sjekk fritak-regler før sletting. Ved sletting: slett fra `messages` → cascade til `nodes` via FK.
- **RLS:** Workspace-isolasjon arves fra `nodes.workspace_id`. Visibility håndteres i applikasjonskode (SvelteKit): `WHERE visibility = 'workspace' OR author_id = $current_user`. Ikke i RLS — RLS håndterer kun workspace-grenser.
- **Visibility:** Default `'workspace'`. Sett `'private'` for personlige kladder. Endre til `'workspace'` for å dele — ingen kopiering nødvendig.

View file

@ -39,20 +39,40 @@ CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for AS
## 4. Worker-arkitektur (Rust)
### 4.1 Designprinsipp: Orkestrator, ikke prosesseringsmotor
Workeren gjør lite tung prosessering selv. Den er en **orkestrator** som koordinerer eksterne tjenester:
| Jobbtype | Hva workeren gjør | Tung logikk i workeren? |
|---|---|---|
| `whisper_transcribe` | HTTP-kall til faster-whisper-server, commit SRT til Forgejo | Nei — venter på svar |
| `openrouter_analyze` | HTTP-kall til AI Gateway | Nei — venter på svar |
| `srt_parse` | Parser SRT-tekst, skriver avledede formater til PG | Lett strengparsing |
| `stats_parse` | Parser Caddy-loggfiler, skriver til PG | Lett I/O |
| `research_clip` | HTTP-kall til AI Gateway | Nei — venter på svar |
| `generate_embeddings` | HTTP-kall til AI Gateway | Nei — venter på svar |
Ny jobbtype = ny handler-funksjon (bygg request, håndter respons, feilhåndtering). Tynt glue-code. Rekompilering er triviell og inkrementell.
### 4.2 Én worker, prioritetsstyrt
Én enkelt worker-prosess håndterer **alle jobbtyper**. Prioritering skjer via `priority`-kolonnen i køen — SQL-spørringen plukker alltid viktigste jobb først. Ingen behov for separate prosesser per jobbtype.
```
┌─────────────────────────────────────────────┐
│ Rust Worker-prosess (én per jobbtype) │
┌──────────────────────────────────────────────────
│ Rust Worker (sidelinja-worker)
│ │
│ Loop: │
│ Konfigurasjon: │
│ --max-concurrent 3 (samtidige jobber) │
│ --poll-interval 1s │
│ │
│ Loop (per ledig slot): │
│ 1. SELECT ... FOR UPDATE SKIP LOCKED │
│ WHERE status IN ('pending','retry') │
│ AND job_type = $type │
│ AND scheduled_for <= now() │
│ ORDER BY priority DESC, scheduled_for │
│ LIMIT 1 │
│ │
│ 2. UPDATE status = 'running' │
│ 3. Utfør jobben │
│ 3. Dispatch til handler basert på job_type
│ 4a. OK: UPDATE status = 'completed' │
│ 4b. Feil: attempts += 1 │
│ Hvis attempts < max_attempts:
@ -61,10 +81,24 @@ CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for AS
│ + backoff(attempts) │
│ Ellers: status = 'error' │
│ │
│ Poll-intervall: 1 sekund (konfigurerbart) │
└─────────────────────────────────────────────┘
└──────────────────────────────────────────────────┘
```
### 4.3 Prioritetsmodell
| Prioritet | Kategori | Eksempler |
|---|---|---|
| 10 | Brukerrettet / sanntid | `dictation_cleanup`, `research_clip` |
| 5 | Normal | `whisper_transcribe`, `openrouter_analyze`, `srt_parse` |
| 1 | Bakgrunn | `stats_parse`, `generate_embeddings`, `prompt_eval` |
Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på kontekst. En manuelt trigget transkripsjon kan få høyere prioritet enn en automatisk nattjobb.
### 4.4 Ressursstyring
* **Concurrency:** `--max-concurrent` begrenser antall samtidige jobber. Default 3 — passer for 8 vCPU der noen slots er Whisper (CPU-tung) og resten er HTTP-kall (ventetid).
* **Resource Governor (Whisper):** Når et LiveKit-rom er aktivt, reduserer workeren Whisper-tråder (`--threads 2` i HTTP-kall til faster-whisper) for å beskytte lydkvaliteten. Sjekkes via LiveKit room-status før Whisper-kall.
* **Skalering senere:** Dersom volumet øker, kan workeren splittes til to binærer fra samme crate (`worker-heavy`, `worker-light`) via CLI-argument (`--types whisper_transcribe,openrouter_analyze`). Ingen kodeendring nødvendig — kun deploy-konfigurasjon.
**Backoff-strategi:** Eksponentiell: `30s × 2^(attempts-1)` (30s, 60s, 120s).
## 5. Jobbtyper
@ -92,10 +126,11 @@ Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypas
- Jobber med `status = 'error'` skal være synlige i admin-visningen (SvelteKit `/admin/jobs`)
- Valgfritt: SpacetimeDB-event ved statusendring slik at UI kan vise fremdrift i sanntid (f.eks. "Transkriberer... 2/3 forsøk")
## 7. Instruks for Claude Code
- Implementer worker-logikken som et Rust-bibliotek (`sidelinja-jobs`) som de ulike binærene kan bruke
- Hver jobbtype får sin egen handler-funksjon, men deler polling-loopen
- Unngå å spinne opp mange tråder — én tokio-task per jobbtype er tilstrekkelig
## 8. Instruks for Claude Code
- Én binær: `sidelinja-worker`. Én Rust-crate med polling-loop + handler-dispatch
- Hver jobbtype implementeres som en handler-funksjon som registreres i en `HashMap<String, Handler>`
- Bruk `tokio` med semaphore for concurrency-kontroll (`--max-concurrent`)
- Aldri lagre lydfiler i `payload` — bruk filstier
- Opprett alltid jobber med riktig `workspace_id` — hent fra konteksten (innlogget bruker, webhook, etc.)
- Ved `stats_parse`: denne erstatter den frittstående cronjobben beskrevet i podcast_statistikk.md — bruk jobbkøen med `scheduled_for` for periodisk kjøring
- Splitt til flere binærer kun hvis det blir eksplisitt bedt om — start med én

View file

@ -31,6 +31,9 @@ Når en idé modnes nok til å bli implementert, skrives en full spec i `docs/fe
| [Contradiction Detector](contradiction_detector.md) | Middels | Høy | Live AI, kunnskapsgraf, pgvector, segmenter |
| [Auto-Highlight Reel](auto_highlight_reel.md) | Middels | Høy | Podcastfabrikken, jobbkø, AI Gateway, Caddy byte-range |
| [Audience Voice Memo](audience_voice_memo.md) | Lav | Høy | Den Asynkrone Gjesten, Live transkripsjon, Live AI |
| [Personlig workspace](personlig_workspace.md) | LavMiddels | Middels | Workspace-modell, meldingsboks |
**Forfremmet til feature:** [Meldingsboks](../features/meldingsboks.md) — universell diskusjonsprimitiv (erstatter separate modeller for chat, kanban-kort, kalenderhendelser, faktoider, notater).
**Lavthengende frukter** (lav innsats, høy wow): Serendipity Roulette, Podcast Time Machine, Meme Generator, Audience Voice Memo.

View file

@ -0,0 +1,47 @@
# Forslag: Personlig workspace
## Ide
Hver bruker får et implisitt, privat workspace med alle verktøyene et vanlig workspace har — kanban, kalender, notater, graf-koblinger. Alt kan kladdes privat og flyttes til et delt workspace når det er klart.
## Hvorfor er dette interessant?
- Redaksjonsmedlemmer trenger et sted å jobbe uforstyrret — research, kladder, oppgavelister
- `visibility = 'private'` på meldingsbokser løser private notater *innenfor* et workspace, men gir ikke en egen arbeidsflate med egne kanban-brett, kalendere og graf
- Et personlig workspace gir full fleksibilitet: private kanban-brett for personlige oppgaver, privat kalender, private research-notater med graf-koblinger — alt med eksisterende infrastruktur
## Hva det bygger på
- Workspace-modellen (RLS, workspace_members)
- Meldingsboks (alt er allerede workspace-scopet)
## Åpne spørsmål
### Opprettelse
- Automatisk ved brukerregistrering, eller on-demand?
- Navnekonvensjon for slug: `user-{authentik_id}` eller `personal-{display_name}`?
### Flytt mellom workspaces
Hovedutfordringen. En meldingsboks som flyttes fra personlig til delt workspace krever:
- `nodes.workspace_id` endres
- `graph_edges` som refererer til noden — flyttes med, eller brytes?
- Svar (`reply_to`-kjeden) — følger med, eller forblir i kilde?
- `kanban_card_view` / `calendar_event_view` — peker på strukturer (kolonner, kalendere) i kilde-workspacet
Mulige strategier:
1. **Kopier, ikke flytt** — opprett ny node i mål-workspace, behold original i personlig. Lenke mellom dem med edge.
2. **Flytt atomisk** — flytt noden og alle avhengigheter i én transaksjon. Komplekst men rent.
3. **Del, ikke flytt** — endre `visibility` fra `'private'` til `'workspace'` uten å bytte workspace. Krever at personlige og delte meldinger kan sameksistere i samme workspace (allerede støttet).
Strategi 3 er enklest og allerede implementert via `visibility`-kolonnen. Et personlig workspace trengs da bare hvis brukeren vil ha *helt separerte* verktøy (eget kanban-brett, egen kalender).
### Workspace-switcher
- Vises personlig workspace i workspace-switcheren?
- Visuelt skille mellom personlige og delte workspaces?
### Kvoter og TTL
- Eget disk-/lagringsbudsjett per personlig workspace?
- Strengere TTL for å unngå at personlige workspaces vokser ubegrenset?
### Alternativ: "Visibility er nok"
Det kan hende at `visibility = 'private'` på meldingsbokser innenfor delte workspaces dekker 90% av behovet. Et personlig workspace er da overkill — brukeren jobber bare privat i det delte workspacet og deler når klar. Verdt å evaluere etter at visibility er i bruk en stund.
## Innsats: Lav (workspace-opprettelse) / Middels (flytt-mellom-workspaces)
## Wow-faktor: Middels

View file

@ -0,0 +1,258 @@
-- Meldingsboks: Universell diskusjonsprimitiv
-- Erstatter separate tabeller for kanban-kort, kalenderhendelser, faktoider og notater.
-- Alle disse blir meldingsbokser (messages) med view-config-tabeller.
-- Se docs/features/meldingsboks.md for full spec.
--
-- Avhenger av: 0001, 0002, 0003, 0004
BEGIN;
-- ============================================================
-- 1. ALTER messages-tabellen
-- ============================================================
-- channel_id nullable (notater, standalone-bokser trenger ikke channel)
ALTER TABLE messages ALTER COLUMN channel_id DROP NOT NULL;
-- Nye kolonner
ALTER TABLE messages ADD COLUMN title TEXT;
ALTER TABLE messages ADD COLUMN pinned BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE messages ADD COLUMN visibility TEXT NOT NULL DEFAULT 'workspace';
ALTER TABLE messages ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
-- Indeks for reply_to (tråd-oppslag)
CREATE INDEX IF NOT EXISTS idx_messages_reply ON messages(reply_to) WHERE reply_to IS NOT NULL;
-- updated_at trigger
CREATE TRIGGER trg_messages_updated_at BEFORE UPDATE ON messages
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ============================================================
-- 2. View-config-tabeller
-- ============================================================
-- Kanban: view-metadata for meldingsbokser som vises som kort
CREATE TABLE kanban_card_view (
message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
column_id UUID NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE,
position REAL NOT NULL DEFAULT 0,
color TEXT,
assignee_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL
);
CREATE INDEX idx_kanban_card_view_column ON kanban_card_view(column_id, position);
-- Kalender: view-metadata for meldingsbokser som vises som hendelser
CREATE TABLE calendar_event_view (
message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
calendar_id UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ,
all_day BOOLEAN NOT NULL DEFAULT false,
color TEXT
);
CREATE INDEX idx_calendar_event_view_calendar ON calendar_event_view(calendar_id, starts_at);
-- ============================================================
-- 3. Migrer kanban_cards → messages + kanban_card_view
-- ============================================================
-- Oppdater node_type til 'melding'
UPDATE nodes SET node_type = 'melding'
WHERE id IN (SELECT id FROM kanban_cards);
-- Opprett meldingsbokser fra kanban-kort
INSERT INTO messages (id, channel_id, author_id, message_type, title, body, created_at, updated_at)
SELECT
kc.id,
NULL,
kc.created_by,
'text',
kc.title,
COALESCE(kc.description, ''),
kc.created_at,
kc.updated_at
FROM kanban_cards kc;
-- Opprett kanban view-config
INSERT INTO kanban_card_view (message_id, column_id, position, assignee_id)
SELECT
kc.id,
kc.column_id,
kc.position,
kc.assignee_id
FROM kanban_cards kc;
-- ============================================================
-- 4. Migrer calendar_events → messages + calendar_event_view
-- ============================================================
UPDATE nodes SET node_type = 'melding'
WHERE id IN (SELECT id FROM calendar_events);
INSERT INTO messages (id, channel_id, author_id, message_type, title, body, created_at, updated_at)
SELECT
ce.id,
NULL,
ce.created_by,
'text',
ce.title,
COALESCE(ce.description, ''),
ce.created_at,
ce.created_at
FROM calendar_events ce;
INSERT INTO calendar_event_view (message_id, calendar_id, starts_at, ends_at, all_day, color)
SELECT
ce.id,
ce.calendar_id,
ce.starts_at,
ce.ends_at,
ce.all_day,
ce.color
FROM calendar_events ce;
-- ============================================================
-- 5. Reaksjoner (erstatter message_votes og factoid_votes)
-- ============================================================
CREATE TABLE message_reactions (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
reaction TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id, reaction)
);
-- Migrer eksisterende message_votes til reactions
INSERT INTO message_reactions (message_id, user_id, reaction, created_at)
SELECT
mv.message_id,
mv.user_id,
CASE WHEN mv.vote = 1 THEN 'upvote' ELSE 'downvote' END,
mv.created_at
FROM message_votes mv;
-- ============================================================
-- 6. Migrer factoids → messages
-- ============================================================
UPDATE nodes SET node_type = 'melding'
WHERE id IN (SELECT id FROM factoids);
INSERT INTO messages (id, channel_id, author_id, message_type, body, created_at, updated_at)
SELECT
f.id,
NULL,
f.created_by,
'factoid',
f.body,
f.created_at,
f.created_at
FROM factoids f;
-- Flytt factoid_votes til reactions
INSERT INTO message_reactions (message_id, user_id, reaction, created_at)
SELECT
fv.factoid_id,
fv.user_id,
CASE WHEN fv.vote = 1 THEN 'upvote' ELSE 'downvote' END,
fv.created_at
FROM factoid_votes fv
ON CONFLICT (message_id, user_id, reaction) DO NOTHING;
-- ============================================================
-- 7. Migrer notes → messages
-- ============================================================
UPDATE nodes SET node_type = 'melding'
WHERE id IN (SELECT id FROM notes);
INSERT INTO messages (id, channel_id, author_id, message_type, title, body, created_at, updated_at)
SELECT
n.id,
NULL,
n.created_by,
'text',
n.title,
COALESCE(n.content, ''),
n.created_at,
n.updated_at
FROM notes n;
-- ============================================================
-- 8. Drop gamle tabeller
-- ============================================================
-- Triggers først
DROP TRIGGER IF EXISTS trg_kanban_cards_updated_at ON kanban_cards;
DROP TRIGGER IF EXISTS trg_calendar_events_updated_at ON calendar_events;
DROP TRIGGER IF EXISTS trg_notes_updated_at ON notes;
-- Tabeller (rekkefølge: FK-avhengigheter)
DROP TABLE factoid_votes;
DROP TABLE factoids;
DROP TABLE message_votes;
DROP TABLE kanban_cards;
DROP TABLE calendar_events;
DROP TABLE notes;
-- ============================================================
-- RLS: Ikke nødvendig på messages/view-config-tabeller.
-- Workspace-isolasjon arves via FK til nodes (samme mønster
-- som episodes, segments, entities).
-- ============================================================
COMMIT;
-- ============================================================
-- 9. Entities (erstatter actors + topics)
-- ============================================================
-- Alt som kan nevnes med # er en entitet: personer, organisasjoner,
-- steder, temaer, konsepter. Én tabell, én autocomplete.
--
-- ALTER TYPE ADD VALUE kan ikke kjøres i en transaksjon (PG < 16),
-- derfor utenfor BEGIN/COMMIT-blokken.
ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'entitet';
BEGIN;
CREATE TABLE entities (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'person', 'organisasjon', 'sted', 'tema', 'konsept'
aliases TEXT[] DEFAULT '{}', -- Forkortelser, kallenavn: {'JGS', 'Støre'}
avatar_url TEXT
);
CREATE INDEX idx_entities_name ON entities(name);
CREATE INDEX idx_entities_aliases ON entities USING GIN(aliases);
-- Migrer actors → entities
INSERT INTO entities (id, name, type)
SELECT id, name, COALESCE(type, 'person')
FROM actors;
UPDATE nodes SET node_type = 'entitet'
WHERE id IN (SELECT id FROM actors);
-- Migrer topics → entities
INSERT INTO entities (id, name, type)
SELECT id, name, 'tema'
FROM topics;
UPDATE nodes SET node_type = 'entitet'
WHERE id IN (SELECT id FROM topics);
DROP TABLE actors;
DROP TABLE topics;
-- ============================================================
-- RLS: Ikke nødvendig på messages/view-config/entities.
-- Workspace-isolasjon arves via FK til nodes (samme mønster
-- som episodes, segments).
-- ============================================================
COMMIT;

View file

@ -1,5 +1,5 @@
-- Utviklingsdata for lokalt testmiljø.
-- Kjøres etter 0001_initial_schema.sql.
-- Kjøres etter ALLE migrasjoner (00010005).
-- IKKE bruk i produksjon.
BEGIN;
@ -66,11 +66,11 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES
INSERT INTO calendars (id, parent_id, name, color) VALUES
('b0000000-0000-0000-0000-000000000030', 'b0000000-0000-0000-0000-000000000010', 'Foreningskalender', '#f59e0b');
-- Notat for Liberalistene
-- Notat for Liberalistene (meldingsboks)
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('b0000000-0000-0000-0000-000000000040', 'b0000000-0000-0000-0000-000000000001', 'note');
INSERT INTO notes (id, parent_id, title, content) VALUES
('b0000000-0000-0000-0000-000000000040', 'b0000000-0000-0000-0000-000000000010', 'Møtenotater', '');
('b0000000-0000-0000-0000-000000000040', 'b0000000-0000-0000-0000-000000000001', 'melding');
INSERT INTO messages (id, author_id, message_type, title, body) VALUES
('b0000000-0000-0000-0000-000000000040', 'dev-user-1', 'text', 'Møtenotater', '');
-- Kanban-brett for Liberalistene
INSERT INTO nodes (id, workspace_id, node_type) VALUES
@ -129,11 +129,32 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES
INSERT INTO calendars (id, parent_id, name, color) VALUES
('a0000000-0000-0000-0000-000000000030', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonskalender', '#3b82f6');
-- Notat for Sidelinja
-- Notat for Sidelinja (meldingsboks)
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('a0000000-0000-0000-0000-000000000040', 'a0000000-0000-0000-0000-000000000001', 'note');
INSERT INTO notes (id, parent_id, title, content) VALUES
('a0000000-0000-0000-0000-000000000040', 'a0000000-0000-0000-0000-000000000010', 'Show notes', '');
('a0000000-0000-0000-0000-000000000040', 'a0000000-0000-0000-0000-000000000001', 'melding');
INSERT INTO messages (id, author_id, message_type, title, body) VALUES
('a0000000-0000-0000-0000-000000000040', 'dev-user-1', 'text', 'Show notes', '');
-- =============================================
-- Entiteter for Sidelinja (kunnskapsgraf testdata)
-- =============================================
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('a0000000-0000-0000-0000-000000000050', 'a0000000-0000-0000-0000-000000000001', 'entitet'),
('a0000000-0000-0000-0000-000000000051', 'a0000000-0000-0000-0000-000000000001', 'entitet'),
('a0000000-0000-0000-0000-000000000052', 'a0000000-0000-0000-0000-000000000001', 'entitet'),
('a0000000-0000-0000-0000-000000000053', 'a0000000-0000-0000-0000-000000000001', 'entitet'),
('a0000000-0000-0000-0000-000000000054', 'a0000000-0000-0000-0000-000000000001', 'entitet');
INSERT INTO entities (id, name, type, aliases) VALUES
('a0000000-0000-0000-0000-000000000050', 'Jonas Gahr Støre', 'person', ARRAY['JGS', 'Støre']),
('a0000000-0000-0000-0000-000000000051', 'Arbeiderpartiet', 'organisasjon', ARRAY['AP', 'Ap', 'DNA']),
('a0000000-0000-0000-0000-000000000052', 'Skolepolitikk', 'tema', ARRAY[]::text[]),
('a0000000-0000-0000-0000-000000000053', 'Lørenskog', 'sted', ARRAY['Lørenskog kommune']),
('a0000000-0000-0000-0000-000000000054', 'Hans Petter Sjøli', 'person', ARRAY['Sjøli']);
-- Test-edges (graf-relasjoner)
INSERT INTO graph_edges (workspace_id, source_id, target_id, relation_type, origin) VALUES
('a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000050', 'a0000000-0000-0000-0000-000000000051', 'WORKS_FOR', 'user'),
('a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000053', 'a0000000-0000-0000-0000-000000000052', 'PART_OF', 'user');
-- Kanban-brett for redaksjonen
INSERT INTO nodes (id, workspace_id, node_type) VALUES

View file

@ -21,23 +21,25 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
let events;
if (from && to) {
events = await sql`
SELECT e.id, e.calendar_id, e.title, e.description,
e.starts_at, e.ends_at, e.all_day, e.color,
e.linked_node, e.created_by, e.created_at
FROM calendar_events e
WHERE e.calendar_id = ${calendarId}
AND e.starts_at < ${to}
AND (e.ends_at IS NULL OR e.ends_at > ${from} OR e.starts_at >= ${from})
ORDER BY e.starts_at ASC
SELECT m.id, ev.calendar_id, m.title, m.body AS description,
ev.starts_at, ev.ends_at, ev.all_day, ev.color,
m.author_id AS created_by, m.created_at
FROM calendar_event_view ev
JOIN messages m ON m.id = ev.message_id
WHERE ev.calendar_id = ${calendarId}
AND ev.starts_at < ${to}
AND (ev.ends_at IS NULL OR ev.ends_at > ${from} OR ev.starts_at >= ${from})
ORDER BY ev.starts_at ASC
`;
} else {
events = await sql`
SELECT e.id, e.calendar_id, e.title, e.description,
e.starts_at, e.ends_at, e.all_day, e.color,
e.linked_node, e.created_by, e.created_at
FROM calendar_events e
WHERE e.calendar_id = ${calendarId}
ORDER BY e.starts_at ASC
SELECT m.id, ev.calendar_id, m.title, m.body AS description,
ev.starts_at, ev.ends_at, ev.all_day, ev.color,
m.author_id AS created_by, m.created_at
FROM calendar_event_view ev
JOIN messages m ON m.id = ev.message_id
WHERE ev.calendar_id = ${calendarId}
ORDER BY ev.starts_at ASC
`;
}

View file

@ -21,26 +21,34 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
`;
if (!calendar) error(404, 'Kalender ikke funnet');
// Opprett node + melding + calendar view i én transaksjon
const [event] = await sql`
WITH new_node AS (
INSERT INTO nodes (workspace_id, node_type)
VALUES (${locals.workspace.id}, 'calendar_event')
VALUES (${locals.workspace.id}, 'melding')
RETURNING id
),
new_message AS (
INSERT INTO messages (id, author_id, message_type, title, body)
SELECT new_node.id, ${locals.user.id}, 'text', ${body.title.trim()}, ${body.description?.trim() || ''}
FROM new_node
RETURNING id
)
INSERT INTO calendar_events (id, calendar_id, title, description, starts_at, ends_at, all_day, color, created_by)
SELECT
new_node.id,
${calendarId},
${body.title.trim()},
${body.description?.trim() || null},
${body.starts_at},
${body.ends_at || null},
${body.all_day ?? false},
${body.color || null},
${locals.user.id}
FROM new_node
RETURNING *
INSERT INTO calendar_event_view (message_id, calendar_id, starts_at, ends_at, all_day, color)
SELECT new_message.id, ${calendarId}, ${body.starts_at}, ${body.ends_at || null}, ${body.all_day ?? false}, ${body.color || null}
FROM new_message
RETURNING message_id AS id, calendar_id, starts_at, ends_at, all_day, color
`;
return json(event, { status: 201 });
// Hent komplett hendelse-data
const [fullEvent] = await sql`
SELECT m.id, ev.calendar_id, m.title, m.body AS description,
ev.starts_at, ev.ends_at, ev.all_day, ev.color,
m.author_id AS created_by, m.created_at
FROM messages m
JOIN calendar_event_view ev ON ev.message_id = m.id
WHERE m.id = ${event.id}
`;
return json(fullEvent, { status: 201 });
};

View file

@ -11,23 +11,37 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
// Verifiser tilgang
const [event] = await sql`
SELECT e.id FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
JOIN nodes n ON n.id = c.id
WHERE e.id = ${eventId} AND c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
SELECT ev.message_id FROM calendar_event_view ev
WHERE ev.message_id = ${eventId} AND ev.calendar_id = ${calendarId}
`;
if (!event) error(404, 'Hendelse ikke funnet');
const [updated] = await sql`
UPDATE calendar_events SET
// Oppdater melding (title, body/description)
await sql`
UPDATE messages SET
title = COALESCE(${updates.title ?? null}, title),
description = CASE WHEN ${updates.description !== undefined} THEN ${updates.description ?? null} ELSE description END,
body = CASE WHEN ${updates.description !== undefined} THEN ${updates.description ?? ''} ELSE body END
WHERE id = ${eventId}
`;
// Oppdater calendar view-metadata
await sql`
UPDATE calendar_event_view SET
starts_at = COALESCE(${updates.starts_at ?? null}, starts_at),
ends_at = CASE WHEN ${updates.ends_at !== undefined} THEN ${updates.ends_at ?? null} ELSE ends_at END,
all_day = COALESCE(${updates.all_day ?? null}, all_day),
color = CASE WHEN ${updates.color !== undefined} THEN ${updates.color ?? null} ELSE color END
WHERE id = ${eventId}
RETURNING *
WHERE message_id = ${eventId}
`;
// Hent komplett oppdatert data
const [updated] = await sql`
SELECT m.id, ev.calendar_id, m.title, m.body AS description,
ev.starts_at, ev.ends_at, ev.all_day, ev.color,
m.author_id AS created_by, m.created_at
FROM messages m
JOIN calendar_event_view ev ON ev.message_id = m.id
WHERE m.id = ${eventId}
`;
return json(updated);
@ -40,10 +54,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
const { calendarId, eventId } = params;
const [event] = await sql`
SELECT e.id FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
JOIN nodes n ON n.id = c.id
WHERE e.id = ${eventId} AND c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
SELECT ev.message_id FROM calendar_event_view ev
WHERE ev.message_id = ${eventId} AND ev.calendar_id = ${calendarId}
`;
if (!event) error(404, 'Hendelse ikke funnet');

View file

@ -24,14 +24,15 @@ export const GET: RequestHandler = async ({ params, locals }) => {
ORDER BY position ASC
`;
// Hent alle kort for brettet
// Hent alle kort for brettet (messages + kanban_card_view)
const cards = await sql`
SELECT c.id, c.column_id, c.title, c.description,
c.assignee_id, c.position, c.created_by, c.created_at
FROM kanban_cards c
JOIN kanban_columns col ON col.id = c.column_id
SELECT m.id, kv.column_id, m.title, m.body AS description,
kv.assignee_id, kv.position, m.author_id AS created_by, m.created_at
FROM kanban_card_view kv
JOIN messages m ON m.id = kv.message_id
JOIN kanban_columns col ON col.id = kv.column_id
WHERE col.board_id = ${boardId}
ORDER BY c.position ASC
ORDER BY kv.position ASC
`;
// Grupper kort per kolonne

View file

@ -26,21 +26,36 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
// Finn høyeste posisjon i kolonnen
const [maxPos] = await sql`
SELECT COALESCE(MAX(position), 0) + 1 as next_pos
FROM kanban_cards WHERE column_id = ${columnId}
FROM kanban_card_view WHERE column_id = ${columnId}
`;
// Opprett node + kort i én transaksjon
// Opprett node + melding + kanban view i én transaksjon
const [card] = await sql`
WITH new_node AS (
INSERT INTO nodes (workspace_id, node_type)
VALUES (${locals.workspace.id}, 'kanban_card')
VALUES (${locals.workspace.id}, 'melding')
RETURNING id
)
INSERT INTO kanban_cards (id, column_id, title, position, created_by)
SELECT new_node.id, ${columnId}, ${title.trim()}, ${maxPos.next_pos}, ${locals.user.id}
),
new_message AS (
INSERT INTO messages (id, author_id, message_type, title, body)
SELECT new_node.id, ${locals.user.id}, 'text', ${title.trim()}, ''
FROM new_node
RETURNING id, column_id, title, description, assignee_id, position, created_by, created_at
RETURNING id, title, body, author_id, created_at
)
INSERT INTO kanban_card_view (message_id, column_id, position)
SELECT new_message.id, ${columnId}, ${maxPos.next_pos}
FROM new_message
RETURNING message_id AS id, column_id, position
`;
return json(card, { status: 201 });
// Hent komplett kort-data
const [fullCard] = await sql`
SELECT m.id, kv.column_id, m.title, m.body AS description,
kv.assignee_id, kv.position, m.author_id AS created_by, m.created_at
FROM messages m
JOIN kanban_card_view kv ON kv.message_id = m.id
WHERE m.id = ${card.id}
`;
return json(fullCard, { status: 201 });
};

View file

@ -11,23 +11,33 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
// Verifiser tilgang
const [card] = await sql`
SELECT c.id FROM kanban_cards c
JOIN kanban_columns col ON col.id = c.column_id
SELECT kv.message_id FROM kanban_card_view kv
JOIN kanban_columns col ON col.id = kv.column_id
JOIN kanban_boards b ON b.id = col.board_id
JOIN nodes n ON n.id = b.id
WHERE c.id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
WHERE kv.message_id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!card) error(404, 'Kort ikke funnet');
// Oppdater melding (title, body/description)
const [updated] = await sql`
UPDATE kanban_cards SET
UPDATE messages SET
title = COALESCE(${updates.title ?? null}, title),
description = CASE WHEN ${updates.description !== undefined} THEN ${updates.description ?? null} ELSE description END
body = CASE WHEN ${updates.description !== undefined} THEN ${updates.description ?? ''} ELSE body END
WHERE id = ${cardId}
RETURNING id, column_id, title, description, assignee_id, position, created_by, created_at
RETURNING id
`;
return json(updated);
// Hent komplett kort-data
const [fullCard] = await sql`
SELECT m.id, kv.column_id, m.title, m.body AS description,
kv.assignee_id, kv.position, m.author_id AS created_by, m.created_at
FROM messages m
JOIN kanban_card_view kv ON kv.message_id = m.id
WHERE m.id = ${updated.id}
`;
return json(fullCard);
};
/** DELETE /api/kanban/:boardId/cards/:cardId — Slett kort */
@ -38,15 +48,15 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
// Verifiser tilgang
const [card] = await sql`
SELECT c.id FROM kanban_cards c
JOIN kanban_columns col ON col.id = c.column_id
SELECT kv.message_id FROM kanban_card_view kv
JOIN kanban_columns col ON col.id = kv.column_id
JOIN kanban_boards b ON b.id = col.board_id
JOIN nodes n ON n.id = b.id
WHERE c.id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
WHERE kv.message_id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!card) error(404, 'Kort ikke funnet');
// Slett node (cascader til kanban_cards)
// Slett node (cascader til messages → kanban_card_view)
await sql`DELETE FROM nodes WHERE id = ${cardId}`;
return new Response(null, { status: 204 });

View file

@ -15,20 +15,20 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
// Verifiser at kort og målkolonne tilhører dette brettet og workspace
const [valid] = await sql`
SELECT 1 FROM kanban_cards c
JOIN kanban_columns src_col ON src_col.id = c.column_id
SELECT 1 FROM kanban_card_view kv
JOIN kanban_columns src_col ON src_col.id = kv.column_id
JOIN kanban_boards b ON b.id = src_col.board_id
JOIN nodes n ON n.id = b.id
JOIN kanban_columns dst_col ON dst_col.board_id = b.id AND dst_col.id = ${columnId}
WHERE c.id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
WHERE kv.message_id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!valid) error(404, 'Kort eller kolonne ikke funnet');
const [updated] = await sql`
UPDATE kanban_cards
UPDATE kanban_card_view
SET column_id = ${columnId}, position = ${position}
WHERE id = ${cardId}
RETURNING id, column_id, title, position
WHERE message_id = ${cardId}
RETURNING message_id AS id, column_id, position
`;
return json(updated);

View file

@ -7,10 +7,10 @@ export const GET: RequestHandler = async ({ params, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const [note] = await sql`
SELECT n.id, n.title, n.content, n.updated_at
FROM notes n
JOIN nodes nd ON nd.id = n.id
WHERE n.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id}
SELECT m.id, m.title, m.body AS content, m.updated_at
FROM messages m
JOIN nodes nd ON nd.id = m.id
WHERE m.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id}
`;
if (!note) error(404, 'Notat ikke funnet');
@ -24,18 +24,18 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const updates = await request.json();
const [note] = await sql`
SELECT n.id FROM notes n
JOIN nodes nd ON nd.id = n.id
WHERE n.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id}
SELECT m.id FROM messages m
JOIN nodes nd ON nd.id = m.id
WHERE m.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id}
`;
if (!note) error(404, 'Notat ikke funnet');
const [updated] = await sql`
UPDATE notes SET
UPDATE messages SET
title = COALESCE(${updates.title ?? null}, title),
content = CASE WHEN ${updates.content !== undefined} THEN ${updates.content ?? ''} ELSE content END
body = CASE WHEN ${updates.content !== undefined} THEN ${updates.content ?? ''} ELSE body END
WHERE id = ${params.noteId}
RETURNING id, title, content, updated_at
RETURNING id, title, body AS content, updated_at
`;
return json(updated);