diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e8f4362..c5aa1e2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index c058f9b..e2a4cda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/concepts/den_asynkrone_gjesten.md b/docs/concepts/den_asynkrone_gjesten.md index 29d3802..27b700f 100644 --- a/docs/concepts/den_asynkrone_gjesten.md +++ b/docs/concepts/den_asynkrone_gjesten.md @@ -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 diff --git a/docs/concepts/podcastfabrikken.md b/docs/concepts/podcastfabrikken.md index e47209d..22c62ce 100644 --- a/docs/concepts/podcastfabrikken.md +++ b/docs/concepts/podcastfabrikken.md @@ -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. diff --git a/docs/concepts/valgomaten.md b/docs/concepts/valgomaten.md index 1776d8c..ef35dc0 100644 --- a/docs/concepts/valgomaten.md +++ b/docs/concepts/valgomaten.md @@ -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. diff --git a/docs/features/kunnskapsgraf_og_relasjoner.md b/docs/features/kunnskapsgraf_og_relasjoner.md index 91aa77a..07906e9 100644 --- a/docs/features/kunnskapsgraf_og_relasjoner.md +++ b/docs/features/kunnskapsgraf_og_relasjoner.md @@ -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(), - node_type node_type NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + 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() ); ``` +**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 ( - id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, - name TEXT NOT NULL, - type TEXT -- 'person', 'organisasjon', etc. +CREATE TABLE entities ( + id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, + 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, diff --git a/docs/features/meldingsboks.md b/docs/features/meldingsboks.md new file mode 100644 index 0000000..d09ebbc --- /dev/null +++ b/docs/features/meldingsboks.md @@ -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` på `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. `` 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. diff --git a/docs/infra/jobbkø.md b/docs/infra/jobbkø.md index bb27bc6..daeaf50 100644 --- a/docs/infra/jobbkø.md +++ b/docs/infra/jobbkø.md @@ -39,32 +39,66 @@ 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) │ -│ │ -│ Loop: │ -│ 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 │ -│ 4a. OK: UPDATE status = 'completed' │ -│ 4b. Feil: attempts += 1 │ -│ Hvis attempts < max_attempts: │ -│ status = 'retry' │ -│ scheduled_for = now() │ -│ + backoff(attempts) │ -│ Ellers: status = 'error' │ -│ │ -│ Poll-intervall: 1 sekund (konfigurerbart) │ -└─────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────┐ +│ Rust Worker (sidelinja-worker) │ +│ │ +│ 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 scheduled_for <= now() │ +│ ORDER BY priority DESC, scheduled_for │ +│ LIMIT 1 │ +│ │ +│ 2. UPDATE status = 'running' │ +│ 3. Dispatch til handler basert på job_type │ +│ 4a. OK: UPDATE status = 'completed' │ +│ 4b. Feil: attempts += 1 │ +│ Hvis attempts < max_attempts: │ +│ status = 'retry' │ +│ scheduled_for = now() │ +│ + backoff(attempts) │ +│ Ellers: status = 'error' │ +│ │ +└──────────────────────────────────────────────────┘ ``` +### 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` +- 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 diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 00767ca..46bacf0 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -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) | Lav–Middels | 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. diff --git a/docs/proposals/personlig_workspace.md b/docs/proposals/personlig_workspace.md new file mode 100644 index 0000000..568d562 --- /dev/null +++ b/docs/proposals/personlig_workspace.md @@ -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 diff --git a/migrations/0005_meldingsboks.sql b/migrations/0005_meldingsboks.sql new file mode 100644 index 0000000..9f6a739 --- /dev/null +++ b/migrations/0005_meldingsboks.sql @@ -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; diff --git a/migrations/seed_dev.sql b/migrations/seed_dev.sql index b2e217c..56c27f1 100644 --- a/migrations/seed_dev.sql +++ b/migrations/seed_dev.sql @@ -1,5 +1,5 @@ -- Utviklingsdata for lokalt testmiljø. --- Kjøres etter 0001_initial_schema.sql. +-- Kjøres etter ALLE migrasjoner (0001–0005). -- 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 diff --git a/web/src/routes/api/calendar/[calendarId]/+server.ts b/web/src/routes/api/calendar/[calendarId]/+server.ts index 2c0dd8e..a8acea4 100644 --- a/web/src/routes/api/calendar/[calendarId]/+server.ts +++ b/web/src/routes/api/calendar/[calendarId]/+server.ts @@ -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 `; } diff --git a/web/src/routes/api/calendar/[calendarId]/events/+server.ts b/web/src/routes/api/calendar/[calendarId]/events/+server.ts index f832de0..5a69cf3 100644 --- a/web/src/routes/api/calendar/[calendarId]/events/+server.ts +++ b/web/src/routes/api/calendar/[calendarId]/events/+server.ts @@ -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 }); }; diff --git a/web/src/routes/api/calendar/[calendarId]/events/[eventId]/+server.ts b/web/src/routes/api/calendar/[calendarId]/events/[eventId]/+server.ts index 5528437..ca17744 100644 --- a/web/src/routes/api/calendar/[calendarId]/events/[eventId]/+server.ts +++ b/web/src/routes/api/calendar/[calendarId]/events/[eventId]/+server.ts @@ -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'); diff --git a/web/src/routes/api/kanban/[boardId]/+server.ts b/web/src/routes/api/kanban/[boardId]/+server.ts index 4453aed..bb4978f 100644 --- a/web/src/routes/api/kanban/[boardId]/+server.ts +++ b/web/src/routes/api/kanban/[boardId]/+server.ts @@ -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 diff --git a/web/src/routes/api/kanban/[boardId]/cards/+server.ts b/web/src/routes/api/kanban/[boardId]/cards/+server.ts index cb20039..bf90901 100644 --- a/web/src/routes/api/kanban/[boardId]/cards/+server.ts +++ b/web/src/routes/api/kanban/[boardId]/cards/+server.ts @@ -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 + ), + 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, title, body, author_id, created_at ) - INSERT INTO kanban_cards (id, column_id, title, position, created_by) - SELECT new_node.id, ${columnId}, ${title.trim()}, ${maxPos.next_pos}, ${locals.user.id} - FROM new_node - RETURNING id, column_id, title, description, assignee_id, position, created_by, 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 }); }; diff --git a/web/src/routes/api/kanban/[boardId]/cards/[cardId]/+server.ts b/web/src/routes/api/kanban/[boardId]/cards/[cardId]/+server.ts index 43b7b49..a1607d7 100644 --- a/web/src/routes/api/kanban/[boardId]/cards/[cardId]/+server.ts +++ b/web/src/routes/api/kanban/[boardId]/cards/[cardId]/+server.ts @@ -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 }); diff --git a/web/src/routes/api/kanban/[boardId]/cards/[cardId]/move/+server.ts b/web/src/routes/api/kanban/[boardId]/cards/[cardId]/move/+server.ts index 1dd90b4..399871c 100644 --- a/web/src/routes/api/kanban/[boardId]/cards/[cardId]/move/+server.ts +++ b/web/src/routes/api/kanban/[boardId]/cards/[cardId]/move/+server.ts @@ -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); diff --git a/web/src/routes/api/notes/[noteId]/+server.ts b/web/src/routes/api/notes/[noteId]/+server.ts index f4e21c9..91beeca 100644 --- a/web/src/routes/api/notes/[noteId]/+server.ts +++ b/web/src/routes/api/notes/[noteId]/+server.ts @@ -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);