Compare commits
3 commits
024a91e1b3
...
3f8ef65c5f
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f8ef65c5f | |||
| 1d47119b1e | |||
| 1faef972dd |
33 changed files with 4691 additions and 141 deletions
|
|
@ -205,17 +205,18 @@ Chat (channels), Kanban, Kalender, Notater/Scratchpad, Whiteboard, Live transkri
|
||||||
- [x] SpacetimeDB grunnoppsett (Docker, Rust WASM-modul, TypeScript-bindings)
|
- [x] SpacetimeDB grunnoppsett (Docker, Rust WASM-modul, TypeScript-bindings)
|
||||||
- [x] SvelteKit skjelett med Authentik-integrasjon + Workspace-switcher
|
- [x] SvelteKit skjelett med Authentik-integrasjon + Workspace-switcher
|
||||||
- [x] AI Gateway (LiteLLM) oppsett + config
|
- [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)
|
### 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)
|
- [ ] Jobbkø-worker (Rust)
|
||||||
- [ ] Kunnskapsgraf CRUD (SvelteKit server-side)
|
- [ ] 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
|
- [ ] 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`)
|
- [ ] **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, sync-worker gjenstår)
|
- [~] Chat med channels (PG-adapter + SpacetimeDB hybrid-adapter ferdig — **refaktoreres til meldingsboks-modell**, sync-worker gjenstår)
|
||||||
- [~] Kanban (PG-adapter ferdig med drag & drop, redigeringsmodal, CRUD API. SpacetimeDB-sync 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, fargekoder, heldags/tidshendelser. 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 med auto-save, debounce, tittel+innhold. Rich text og 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)
|
- [ ] Lydmeldinger & Diktering (opptak + Whisper + AI-opprydding)
|
||||||
- [ ] Prompt-Laboratorium (prompt-testing mot egne data)
|
- [ ] Prompt-Laboratorium (prompt-testing mot egne data)
|
||||||
- [ ] Promptfoo testsett for første jobbtyper (norsk testdata)
|
- [ ] 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)
|
- **Container-status:** Healthcheck-resultater fra Docker (via `docker compose ps` / Docker socket)
|
||||||
- **Jobbkø:** Pending/running/error-count med sparkline-grafer (siste 24t)
|
- **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).
|
- **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)
|
- **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
|
- **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
|
- **SpacetimeDB:** Minnebruk-graf, `sync_outbox`-størrelse (indikerer sync-etterslep), tilkoblede klienter per workspace
|
||||||
|
|
|
||||||
|
|
@ -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
|
- `prompt_lab.md` — Internt verktøy for testing og deploy av LLM-prompts
|
||||||
- `kalender.md` — Redaksjonell kalender med abonnementsmodell og ICS-eksport
|
- `kalender.md` — Redaksjonell kalender med abonnementsmodell og ICS-eksport
|
||||||
- `notater.md` — Scratchpad/notatblokk med auto-save og debounce
|
- `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):
|
- `docs/infra/` — Infrastruktur (ikke brukersynlig):
|
||||||
- `jobbkø.md` — Felles PostgreSQL-basert køsystem for alle bakgrunnsjobber
|
- `jobbkø.md` — Felles PostgreSQL-basert køsystem for alle bakgrunnsjobber
|
||||||
- `synkronisering.md` — PostgreSQL ↔ SpacetimeDB dataflyt og eierskapsmodell
|
- `synkronisering.md` — PostgreSQL ↔ SpacetimeDB dataflyt og eierskapsmodell
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
### 4.3 Flyt (teknisk)
|
||||||
```
|
```
|
||||||
Gjest åpner URL med token
|
Gjest åpner URL med token
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,43 @@ Hver workspace har sin egen podcast-konfigurasjon, lagret i `workspaces.settings
|
||||||
### 5.1 Mediefiler
|
### 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.
|
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.
|
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
|
### 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.
|
* **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.
|
* **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.
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| Aktive sesjoner, live PCA-state | Flyktig (SpacetimeDB) | Tåler tap — bruker svarer på nytt |
|
||||||
| AI-genererte kandidatprofiler | Avledet (PG) | Kan regenereres fra partiprogrammer |
|
| 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.
|
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`.
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -16,31 +16,55 @@ Alle entiteter i systemet arver sin UUID fra én sentral tabell. Dette gir ekte
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TYPE node_type AS ENUM (
|
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 (
|
CREATE TABLE nodes (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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,
|
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
|
### 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
|
```sql
|
||||||
CREATE TABLE actors (
|
CREATE TABLE entities (
|
||||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL, -- Autoritativ skrivemåte
|
||||||
type TEXT -- 'person', 'organisasjon', etc.
|
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 (
|
CREATE INDEX idx_entities_name ON entities(name);
|
||||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
CREATE INDEX idx_entities_aliases ON entities USING GIN(aliases);
|
||||||
name TEXT NOT NULL
|
```
|
||||||
);
|
|
||||||
|
|
||||||
|
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 (
|
CREATE TABLE episodes (
|
||||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
|
|
|
||||||
305
docs/features/meldingsboks.md
Normal file
305
docs/features/meldingsboks.md
Normal 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` 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. `<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.
|
||||||
|
|
@ -39,20 +39,40 @@ CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for AS
|
||||||
|
|
||||||
## 4. Worker-arkitektur (Rust)
|
## 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 │
|
│ 1. SELECT ... FOR UPDATE SKIP LOCKED │
|
||||||
│ WHERE status IN ('pending','retry') │
|
│ WHERE status IN ('pending','retry') │
|
||||||
│ AND job_type = $type │
|
|
||||||
│ AND scheduled_for <= now() │
|
│ AND scheduled_for <= now() │
|
||||||
│ ORDER BY priority DESC, scheduled_for │
|
│ ORDER BY priority DESC, scheduled_for │
|
||||||
│ LIMIT 1 │
|
│ LIMIT 1 │
|
||||||
│ │
|
│ │
|
||||||
│ 2. UPDATE status = 'running' │
|
│ 2. UPDATE status = 'running' │
|
||||||
│ 3. Utfør jobben │
|
│ 3. Dispatch til handler basert på job_type │
|
||||||
│ 4a. OK: UPDATE status = 'completed' │
|
│ 4a. OK: UPDATE status = 'completed' │
|
||||||
│ 4b. Feil: attempts += 1 │
|
│ 4b. Feil: attempts += 1 │
|
||||||
│ Hvis attempts < max_attempts: │
|
│ Hvis attempts < max_attempts: │
|
||||||
|
|
@ -61,10 +81,24 @@ CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for AS
|
||||||
│ + backoff(attempts) │
|
│ + backoff(attempts) │
|
||||||
│ Ellers: status = 'error' │
|
│ 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).
|
**Backoff-strategi:** Eksponentiell: `30s × 2^(attempts-1)` (30s, 60s, 120s).
|
||||||
|
|
||||||
## 5. Jobbtyper
|
## 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`)
|
- 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")
|
- 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
|
## 8. Instruks for Claude Code
|
||||||
- Implementer worker-logikken som et Rust-bibliotek (`sidelinja-jobs`) som de ulike binærene kan bruke
|
- Én binær: `sidelinja-worker`. Én Rust-crate med polling-loop + handler-dispatch
|
||||||
- Hver jobbtype får sin egen handler-funksjon, men deler polling-loopen
|
- Hver jobbtype implementeres som en handler-funksjon som registreres i en `HashMap<String, Handler>`
|
||||||
- Unngå å spinne opp mange tråder — én tokio-task per jobbtype er tilstrekkelig
|
- Bruk `tokio` med semaphore for concurrency-kontroll (`--max-concurrent`)
|
||||||
- Aldri lagre lydfiler i `payload` — bruk filstier
|
- Aldri lagre lydfiler i `payload` — bruk filstier
|
||||||
- Opprett alltid jobber med riktig `workspace_id` — hent fra konteksten (innlogget bruker, webhook, etc.)
|
- 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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| [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 |
|
| [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 |
|
| [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.
|
**Lavthengende frukter** (lav innsats, høy wow): Serendipity Roulette, Podcast Time Machine, Meme Generator, Audience Voice Memo.
|
||||||
|
|
||||||
|
|
|
||||||
47
docs/proposals/personlig_workspace.md
Normal file
47
docs/proposals/personlig_workspace.md
Normal 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
|
||||||
258
migrations/0005_meldingsboks.sql
Normal file
258
migrations/0005_meldingsboks.sql
Normal 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;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
-- Utviklingsdata for lokalt testmiljø.
|
-- Utviklingsdata for lokalt testmiljø.
|
||||||
-- Kjøres etter 0001_initial_schema.sql.
|
-- Kjøres etter ALLE migrasjoner (0001–0005).
|
||||||
-- IKKE bruk i produksjon.
|
-- IKKE bruk i produksjon.
|
||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
@ -66,11 +66,11 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
INSERT INTO calendars (id, parent_id, name, color) VALUES
|
INSERT INTO calendars (id, parent_id, name, color) VALUES
|
||||||
('b0000000-0000-0000-0000-000000000030', 'b0000000-0000-0000-0000-000000000010', 'Foreningskalender', '#f59e0b');
|
('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
|
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
('b0000000-0000-0000-0000-000000000040', 'b0000000-0000-0000-0000-000000000001', 'note');
|
('b0000000-0000-0000-0000-000000000040', 'b0000000-0000-0000-0000-000000000001', 'melding');
|
||||||
INSERT INTO notes (id, parent_id, title, content) VALUES
|
INSERT INTO messages (id, author_id, message_type, title, body) VALUES
|
||||||
('b0000000-0000-0000-0000-000000000040', 'b0000000-0000-0000-0000-000000000010', 'Møtenotater', '');
|
('b0000000-0000-0000-0000-000000000040', 'dev-user-1', 'text', 'Møtenotater', '');
|
||||||
|
|
||||||
-- Kanban-brett for Liberalistene
|
-- Kanban-brett for Liberalistene
|
||||||
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
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
|
INSERT INTO calendars (id, parent_id, name, color) VALUES
|
||||||
('a0000000-0000-0000-0000-000000000030', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonskalender', '#3b82f6');
|
('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
|
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
('a0000000-0000-0000-0000-000000000040', 'a0000000-0000-0000-0000-000000000001', 'note');
|
('a0000000-0000-0000-0000-000000000040', 'a0000000-0000-0000-0000-000000000001', 'melding');
|
||||||
INSERT INTO notes (id, parent_id, title, content) VALUES
|
INSERT INTO messages (id, author_id, message_type, title, body) VALUES
|
||||||
('a0000000-0000-0000-0000-000000000040', 'a0000000-0000-0000-0000-000000000010', 'Show notes', '');
|
('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
|
-- Kanban-brett for redaksjonen
|
||||||
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
|
|
|
||||||
|
|
@ -21,23 +21,25 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
let events;
|
let events;
|
||||||
if (from && to) {
|
if (from && to) {
|
||||||
events = await sql`
|
events = await sql`
|
||||||
SELECT e.id, e.calendar_id, e.title, e.description,
|
SELECT m.id, ev.calendar_id, m.title, m.body AS description,
|
||||||
e.starts_at, e.ends_at, e.all_day, e.color,
|
ev.starts_at, ev.ends_at, ev.all_day, ev.color,
|
||||||
e.linked_node, e.created_by, e.created_at
|
m.author_id AS created_by, m.created_at
|
||||||
FROM calendar_events e
|
FROM calendar_event_view ev
|
||||||
WHERE e.calendar_id = ${calendarId}
|
JOIN messages m ON m.id = ev.message_id
|
||||||
AND e.starts_at < ${to}
|
WHERE ev.calendar_id = ${calendarId}
|
||||||
AND (e.ends_at IS NULL OR e.ends_at > ${from} OR e.starts_at >= ${from})
|
AND ev.starts_at < ${to}
|
||||||
ORDER BY e.starts_at ASC
|
AND (ev.ends_at IS NULL OR ev.ends_at > ${from} OR ev.starts_at >= ${from})
|
||||||
|
ORDER BY ev.starts_at ASC
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
events = await sql`
|
events = await sql`
|
||||||
SELECT e.id, e.calendar_id, e.title, e.description,
|
SELECT m.id, ev.calendar_id, m.title, m.body AS description,
|
||||||
e.starts_at, e.ends_at, e.all_day, e.color,
|
ev.starts_at, ev.ends_at, ev.all_day, ev.color,
|
||||||
e.linked_node, e.created_by, e.created_at
|
m.author_id AS created_by, m.created_at
|
||||||
FROM calendar_events e
|
FROM calendar_event_view ev
|
||||||
WHERE e.calendar_id = ${calendarId}
|
JOIN messages m ON m.id = ev.message_id
|
||||||
ORDER BY e.starts_at ASC
|
WHERE ev.calendar_id = ${calendarId}
|
||||||
|
ORDER BY ev.starts_at ASC
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,26 +21,34 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
`;
|
`;
|
||||||
if (!calendar) error(404, 'Kalender ikke funnet');
|
if (!calendar) error(404, 'Kalender ikke funnet');
|
||||||
|
|
||||||
|
// Opprett node + melding + calendar view i én transaksjon
|
||||||
const [event] = await sql`
|
const [event] = await sql`
|
||||||
WITH new_node AS (
|
WITH new_node AS (
|
||||||
INSERT INTO nodes (workspace_id, node_type)
|
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
|
RETURNING id
|
||||||
)
|
)
|
||||||
INSERT INTO calendar_events (id, calendar_id, title, description, starts_at, ends_at, all_day, color, created_by)
|
INSERT INTO calendar_event_view (message_id, calendar_id, starts_at, ends_at, all_day, color)
|
||||||
SELECT
|
SELECT new_message.id, ${calendarId}, ${body.starts_at}, ${body.ends_at || null}, ${body.all_day ?? false}, ${body.color || null}
|
||||||
new_node.id,
|
FROM new_message
|
||||||
${calendarId},
|
RETURNING message_id AS id, calendar_id, starts_at, ends_at, all_day, color
|
||||||
${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 *
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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 });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,37 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
|
||||||
// Verifiser tilgang
|
// Verifiser tilgang
|
||||||
const [event] = await sql`
|
const [event] = await sql`
|
||||||
SELECT e.id FROM calendar_events e
|
SELECT ev.message_id FROM calendar_event_view ev
|
||||||
JOIN calendars c ON c.id = e.calendar_id
|
WHERE ev.message_id = ${eventId} AND ev.calendar_id = ${calendarId}
|
||||||
JOIN nodes n ON n.id = c.id
|
|
||||||
WHERE e.id = ${eventId} AND c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
|
|
||||||
`;
|
`;
|
||||||
if (!event) error(404, 'Hendelse ikke funnet');
|
if (!event) error(404, 'Hendelse ikke funnet');
|
||||||
|
|
||||||
const [updated] = await sql`
|
// Oppdater melding (title, body/description)
|
||||||
UPDATE calendar_events SET
|
await sql`
|
||||||
|
UPDATE messages SET
|
||||||
title = COALESCE(${updates.title ?? null}, title),
|
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),
|
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,
|
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),
|
all_day = COALESCE(${updates.all_day ?? null}, all_day),
|
||||||
color = CASE WHEN ${updates.color !== undefined} THEN ${updates.color ?? null} ELSE color END
|
color = CASE WHEN ${updates.color !== undefined} THEN ${updates.color ?? null} ELSE color END
|
||||||
WHERE id = ${eventId}
|
WHERE message_id = ${eventId}
|
||||||
RETURNING *
|
`;
|
||||||
|
|
||||||
|
// 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);
|
return json(updated);
|
||||||
|
|
@ -40,10 +54,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
const { calendarId, eventId } = params;
|
const { calendarId, eventId } = params;
|
||||||
|
|
||||||
const [event] = await sql`
|
const [event] = await sql`
|
||||||
SELECT e.id FROM calendar_events e
|
SELECT ev.message_id FROM calendar_event_view ev
|
||||||
JOIN calendars c ON c.id = e.calendar_id
|
WHERE ev.message_id = ${eventId} AND ev.calendar_id = ${calendarId}
|
||||||
JOIN nodes n ON n.id = c.id
|
|
||||||
WHERE e.id = ${eventId} AND c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
|
|
||||||
`;
|
`;
|
||||||
if (!event) error(404, 'Hendelse ikke funnet');
|
if (!event) error(404, 'Hendelse ikke funnet');
|
||||||
|
|
||||||
|
|
|
||||||
83
web/src/routes/api/entities/+server.ts
Normal file
83
web/src/routes/api/entities/+server.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** GET /api/entities?q=...&type=...&limit=... — List/søk entiteter */
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const q = url.searchParams.get('q')?.trim();
|
||||||
|
const type = url.searchParams.get('type');
|
||||||
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 50), 100);
|
||||||
|
|
||||||
|
let entities;
|
||||||
|
if (q) {
|
||||||
|
// Søk i name og aliases
|
||||||
|
entities = await sql`
|
||||||
|
SELECT e.id, e.name, e.type, e.aliases, e.avatar_url, n.created_at, n.updated_at
|
||||||
|
FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE n.workspace_id = ${locals.workspace.id}
|
||||||
|
AND (
|
||||||
|
e.name ILIKE ${'%' + q + '%'}
|
||||||
|
OR EXISTS (SELECT 1 FROM unnest(e.aliases) AS a WHERE a ILIKE ${'%' + q + '%'})
|
||||||
|
)
|
||||||
|
${type ? sql`AND e.type = ${type}` : sql``}
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN e.name ILIKE ${q + '%'} THEN 0 ELSE 1 END,
|
||||||
|
e.name
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
entities = await sql`
|
||||||
|
SELECT e.id, e.name, e.type, e.aliases, e.avatar_url, n.created_at, n.updated_at
|
||||||
|
FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE n.workspace_id = ${locals.workspace.id}
|
||||||
|
${type ? sql`AND e.type = ${type}` : sql``}
|
||||||
|
ORDER BY e.name
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(entities);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** POST /api/entities — Opprett entitet */
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body.name?.trim()) error(400, 'name er påkrevd');
|
||||||
|
if (!body.type?.trim()) error(400, 'type er påkrevd');
|
||||||
|
|
||||||
|
const validTypes = ['person', 'organisasjon', 'sted', 'tema', 'konsept'];
|
||||||
|
if (!validTypes.includes(body.type)) {
|
||||||
|
error(400, `type må være en av: ${validTypes.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [entity] = await sql`
|
||||||
|
WITH new_node AS (
|
||||||
|
INSERT INTO nodes (workspace_id, node_type)
|
||||||
|
VALUES (${locals.workspace.id}, 'entitet')
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
),
|
||||||
|
new_entity AS (
|
||||||
|
INSERT INTO entities (id, name, type, aliases, avatar_url)
|
||||||
|
SELECT
|
||||||
|
new_node.id,
|
||||||
|
${body.name.trim()},
|
||||||
|
${body.type},
|
||||||
|
${body.aliases ?? []},
|
||||||
|
${body.avatar_url ?? null}
|
||||||
|
FROM new_node
|
||||||
|
RETURNING id, name, type, aliases, avatar_url
|
||||||
|
)
|
||||||
|
SELECT ne.*, nn.created_at, nn.updated_at
|
||||||
|
FROM new_entity ne
|
||||||
|
JOIN new_node nn ON nn.id = ne.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json(entity, { status: 201 });
|
||||||
|
};
|
||||||
73
web/src/routes/api/entities/[entityId]/+server.ts
Normal file
73
web/src/routes/api/entities/[entityId]/+server.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** GET /api/entities/:entityId — Hent entitet med edge-count */
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const [entity] = await sql`
|
||||||
|
SELECT e.id, e.name, e.type, e.aliases, e.avatar_url,
|
||||||
|
n.created_at, n.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM graph_edges ge
|
||||||
|
WHERE ge.source_id = e.id OR ge.target_id = e.id) AS edge_count
|
||||||
|
FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE e.id = ${params.entityId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!entity) error(404, 'Entitet ikke funnet');
|
||||||
|
|
||||||
|
return json(entity);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PATCH /api/entities/:entityId — Oppdater entitet */
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const updates = await request.json();
|
||||||
|
|
||||||
|
const [existing] = await sql`
|
||||||
|
SELECT e.id FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE e.id = ${params.entityId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!existing) error(404, 'Entitet ikke funnet');
|
||||||
|
|
||||||
|
if (updates.type) {
|
||||||
|
const validTypes = ['person', 'organisasjon', 'sted', 'tema', 'konsept'];
|
||||||
|
if (!validTypes.includes(updates.type)) {
|
||||||
|
error(400, `type må være en av: ${validTypes.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await sql`
|
||||||
|
UPDATE entities SET
|
||||||
|
name = COALESCE(${updates.name?.trim() ?? null}, name),
|
||||||
|
type = COALESCE(${updates.type ?? null}, type),
|
||||||
|
aliases = CASE WHEN ${updates.aliases !== undefined} THEN ${updates.aliases ?? []} ELSE aliases END,
|
||||||
|
avatar_url = CASE WHEN ${updates.avatar_url !== undefined} THEN ${updates.avatar_url ?? null} ELSE avatar_url END
|
||||||
|
WHERE id = ${params.entityId}
|
||||||
|
RETURNING id, name, type, aliases, avatar_url,
|
||||||
|
(SELECT created_at FROM nodes WHERE id = entities.id) AS created_at,
|
||||||
|
(SELECT updated_at FROM nodes WHERE id = entities.id) AS updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DELETE /api/entities/:entityId — Slett entitet */
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const [entity] = await sql`
|
||||||
|
SELECT e.id FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE e.id = ${params.entityId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!entity) error(404, 'Entitet ikke funnet');
|
||||||
|
|
||||||
|
// Slett node (cascader til entities + graph_edges)
|
||||||
|
await sql`DELETE FROM nodes WHERE id = ${params.entityId}`;
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
58
web/src/routes/api/entities/[entityId]/edges/+server.ts
Normal file
58
web/src/routes/api/entities/[entityId]/edges/+server.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** GET /api/entities/:entityId/edges — Hent alle relasjoner for en entitet */
|
||||||
|
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const entityId = params.entityId;
|
||||||
|
const direction = url.searchParams.get('direction'); // 'outgoing', 'incoming', null=both
|
||||||
|
const relationType = url.searchParams.get('relation_type');
|
||||||
|
|
||||||
|
// Verifiser tilgang
|
||||||
|
const [entity] = await sql`
|
||||||
|
SELECT e.id FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE e.id = ${entityId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!entity) error(404, 'Entitet ikke funnet');
|
||||||
|
|
||||||
|
const edges = await sql`
|
||||||
|
SELECT
|
||||||
|
ge.id AS edge_id,
|
||||||
|
ge.source_id,
|
||||||
|
ge.target_id,
|
||||||
|
ge.relation_type,
|
||||||
|
ge.confidence,
|
||||||
|
ge.origin,
|
||||||
|
ge.created_at,
|
||||||
|
rt.label AS relation_label,
|
||||||
|
CASE
|
||||||
|
WHEN ge.source_id = ${entityId} THEN target_e.name
|
||||||
|
ELSE source_e.name
|
||||||
|
END AS connected_name,
|
||||||
|
CASE
|
||||||
|
WHEN ge.source_id = ${entityId} THEN target_e.type
|
||||||
|
ELSE source_e.type
|
||||||
|
END AS connected_type,
|
||||||
|
CASE
|
||||||
|
WHEN ge.source_id = ${entityId} THEN ge.target_id
|
||||||
|
ELSE ge.source_id
|
||||||
|
END AS connected_id
|
||||||
|
FROM graph_edges ge
|
||||||
|
JOIN relation_types rt ON rt.name = ge.relation_type
|
||||||
|
LEFT JOIN entities source_e ON source_e.id = ge.source_id
|
||||||
|
LEFT JOIN entities target_e ON target_e.id = ge.target_id
|
||||||
|
WHERE ge.workspace_id = ${locals.workspace.id}
|
||||||
|
AND (
|
||||||
|
${direction === 'outgoing' ? sql`ge.source_id = ${entityId}` :
|
||||||
|
direction === 'incoming' ? sql`ge.target_id = ${entityId}` :
|
||||||
|
sql`(ge.source_id = ${entityId} OR ge.target_id = ${entityId})`}
|
||||||
|
)
|
||||||
|
${relationType ? sql`AND ge.relation_type = ${relationType}` : sql``}
|
||||||
|
ORDER BY ge.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json(edges);
|
||||||
|
};
|
||||||
50
web/src/routes/api/graph/edges/+server.ts
Normal file
50
web/src/routes/api/graph/edges/+server.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** POST /api/graph/edges — Opprett graf-relasjon */
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body.source_id || !body.target_id || !body.relation_type) {
|
||||||
|
error(400, 'source_id, target_id og relation_type er påkrevd');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.source_id === body.target_id) {
|
||||||
|
error(400, 'En node kan ikke relateres til seg selv');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifiser at begge noder tilhører workspace
|
||||||
|
const nodes = await sql`
|
||||||
|
SELECT id FROM nodes
|
||||||
|
WHERE id IN (${body.source_id}, ${body.target_id})
|
||||||
|
AND workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (nodes.length !== 2) error(404, 'En eller begge noder ikke funnet i workspace');
|
||||||
|
|
||||||
|
// Verifiser at relation_type er gyldig
|
||||||
|
const [relType] = await sql`
|
||||||
|
SELECT name FROM relation_types WHERE name = ${body.relation_type}
|
||||||
|
`;
|
||||||
|
if (!relType) error(400, `Ugyldig relation_type: ${body.relation_type}`);
|
||||||
|
|
||||||
|
const [edge] = await sql`
|
||||||
|
INSERT INTO graph_edges (workspace_id, source_id, target_id, relation_type, confidence, created_by, origin)
|
||||||
|
VALUES (
|
||||||
|
${locals.workspace.id},
|
||||||
|
${body.source_id},
|
||||||
|
${body.target_id},
|
||||||
|
${body.relation_type},
|
||||||
|
${body.confidence ?? null},
|
||||||
|
${locals.user.id},
|
||||||
|
${body.origin ?? 'user'}
|
||||||
|
)
|
||||||
|
ON CONFLICT (source_id, target_id, relation_type) DO UPDATE SET
|
||||||
|
confidence = COALESCE(EXCLUDED.confidence, graph_edges.confidence)
|
||||||
|
RETURNING id, source_id, target_id, relation_type, confidence, origin, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json(edge, { status: 201 });
|
||||||
|
};
|
||||||
18
web/src/routes/api/graph/edges/[edgeId]/+server.ts
Normal file
18
web/src/routes/api/graph/edges/[edgeId]/+server.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** DELETE /api/graph/edges/:edgeId — Slett graf-relasjon */
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const [edge] = await sql`
|
||||||
|
SELECT id FROM graph_edges
|
||||||
|
WHERE id = ${params.edgeId} AND workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!edge) error(404, 'Relasjon ikke funnet');
|
||||||
|
|
||||||
|
await sql`DELETE FROM graph_edges WHERE id = ${params.edgeId}`;
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
53
web/src/routes/api/graph/search/+server.ts
Normal file
53
web/src/routes/api/graph/search/+server.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/graph/search?q=...&limit=...
|
||||||
|
* Fulltekstsøk på tvers av entiteter og segmenter (transkripsjoner).
|
||||||
|
* Entiteter søkes via ILIKE (name + aliases).
|
||||||
|
* Segmenter søkes via norsk full-text search (to_tsvector).
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const q = url.searchParams.get('q')?.trim();
|
||||||
|
if (!q) error(400, 'q (søkeord) er påkrevd');
|
||||||
|
|
||||||
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 20), 50);
|
||||||
|
|
||||||
|
// Søk entiteter
|
||||||
|
const entities = await sql`
|
||||||
|
SELECT e.id, e.name, e.type, e.aliases, 'entitet' AS result_type,
|
||||||
|
n.created_at
|
||||||
|
FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE n.workspace_id = ${locals.workspace.id}
|
||||||
|
AND (
|
||||||
|
e.name ILIKE ${'%' + q + '%'}
|
||||||
|
OR EXISTS (SELECT 1 FROM unnest(e.aliases) AS a WHERE a ILIKE ${'%' + q + '%'})
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN e.name ILIKE ${q + '%'} THEN 0 ELSE 1 END,
|
||||||
|
e.name
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Søk segmenter (transkripsjoner) med norsk FTS
|
||||||
|
const segments = await sql`
|
||||||
|
SELECT s.id, s.transcript, s.start_time, s.end_time,
|
||||||
|
ep.title AS episode_title, ep.id AS episode_id,
|
||||||
|
'segment' AS result_type,
|
||||||
|
ts_headline('norwegian', s.transcript, plainto_tsquery('norwegian', ${q}),
|
||||||
|
'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS highlight
|
||||||
|
FROM segments s
|
||||||
|
JOIN episodes ep ON ep.id = s.episode_id
|
||||||
|
JOIN nodes n ON n.id = s.id
|
||||||
|
WHERE n.workspace_id = ${locals.workspace.id}
|
||||||
|
AND to_tsvector('norwegian', s.transcript) @@ plainto_tsquery('norwegian', ${q})
|
||||||
|
ORDER BY ts_rank(to_tsvector('norwegian', s.transcript), plainto_tsquery('norwegian', ${q})) DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json({ entities, segments });
|
||||||
|
};
|
||||||
83
web/src/routes/api/graph/traverse/[nodeId]/+server.ts
Normal file
83
web/src/routes/api/graph/traverse/[nodeId]/+server.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/graph/traverse/:nodeId?depth=2
|
||||||
|
* Traverser grafen fra en node og returner nettverket.
|
||||||
|
* Bruker recursive CTE for å følge edges opp til N ledd.
|
||||||
|
* Returformat: { nodes: [...], edges: [...] } (D3.js/Vis.js-kompatibelt)
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const nodeId = params.nodeId;
|
||||||
|
const depth = Math.min(Number(url.searchParams.get('depth') ?? 2), 3);
|
||||||
|
|
||||||
|
// Verifiser at startnoden tilhører workspace
|
||||||
|
const [startNode] = await sql`
|
||||||
|
SELECT id FROM nodes
|
||||||
|
WHERE id = ${nodeId} AND workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!startNode) error(404, 'Node ikke funnet');
|
||||||
|
|
||||||
|
// Recursive CTE: finn alle noder innenfor N ledd
|
||||||
|
const result = await sql`
|
||||||
|
WITH RECURSIVE traversal AS (
|
||||||
|
-- Startnode
|
||||||
|
SELECT ${nodeId}::uuid AS node_id, 0 AS depth
|
||||||
|
UNION
|
||||||
|
-- Følg edges i begge retninger
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN ge.source_id = t.node_id THEN ge.target_id
|
||||||
|
ELSE ge.source_id
|
||||||
|
END AS node_id,
|
||||||
|
t.depth + 1
|
||||||
|
FROM traversal t
|
||||||
|
JOIN graph_edges ge ON (ge.source_id = t.node_id OR ge.target_id = t.node_id)
|
||||||
|
WHERE t.depth < ${depth}
|
||||||
|
AND ge.workspace_id = ${locals.workspace.id}
|
||||||
|
),
|
||||||
|
reachable_nodes AS (
|
||||||
|
SELECT DISTINCT node_id, MIN(depth) AS depth FROM traversal GROUP BY node_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
rn.node_id AS id,
|
||||||
|
rn.depth,
|
||||||
|
n.node_type,
|
||||||
|
e.name,
|
||||||
|
e.type AS entity_type,
|
||||||
|
e.avatar_url
|
||||||
|
FROM reachable_nodes rn
|
||||||
|
JOIN nodes n ON n.id = rn.node_id
|
||||||
|
LEFT JOIN entities e ON e.id = rn.node_id
|
||||||
|
ORDER BY rn.depth, e.name NULLS LAST
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Hent alle edges mellom de nåbare nodene
|
||||||
|
const nodeIds = result.map((r) => r.id);
|
||||||
|
const edges = nodeIds.length > 0
|
||||||
|
? await sql`
|
||||||
|
SELECT ge.id, ge.source_id, ge.target_id, ge.relation_type,
|
||||||
|
ge.confidence, ge.origin, rt.label AS relation_label
|
||||||
|
FROM graph_edges ge
|
||||||
|
JOIN relation_types rt ON rt.name = ge.relation_type
|
||||||
|
WHERE ge.workspace_id = ${locals.workspace.id}
|
||||||
|
AND ge.source_id = ANY(${nodeIds})
|
||||||
|
AND ge.target_id = ANY(${nodeIds})
|
||||||
|
`
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return json({
|
||||||
|
nodes: result.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
depth: r.depth,
|
||||||
|
node_type: r.node_type,
|
||||||
|
name: r.name,
|
||||||
|
entity_type: r.entity_type,
|
||||||
|
avatar_url: r.avatar_url
|
||||||
|
})),
|
||||||
|
edges
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -24,14 +24,15 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
ORDER BY position ASC
|
ORDER BY position ASC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Hent alle kort for brettet
|
// Hent alle kort for brettet (messages + kanban_card_view)
|
||||||
const cards = await sql`
|
const cards = await sql`
|
||||||
SELECT c.id, c.column_id, c.title, c.description,
|
SELECT m.id, kv.column_id, m.title, m.body AS description,
|
||||||
c.assignee_id, c.position, c.created_by, c.created_at
|
kv.assignee_id, kv.position, m.author_id AS created_by, m.created_at
|
||||||
FROM kanban_cards c
|
FROM kanban_card_view kv
|
||||||
JOIN kanban_columns col ON col.id = c.column_id
|
JOIN messages m ON m.id = kv.message_id
|
||||||
|
JOIN kanban_columns col ON col.id = kv.column_id
|
||||||
WHERE col.board_id = ${boardId}
|
WHERE col.board_id = ${boardId}
|
||||||
ORDER BY c.position ASC
|
ORDER BY kv.position ASC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Grupper kort per kolonne
|
// Grupper kort per kolonne
|
||||||
|
|
|
||||||
|
|
@ -26,21 +26,36 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
// Finn høyeste posisjon i kolonnen
|
// Finn høyeste posisjon i kolonnen
|
||||||
const [maxPos] = await sql`
|
const [maxPos] = await sql`
|
||||||
SELECT COALESCE(MAX(position), 0) + 1 as next_pos
|
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`
|
const [card] = await sql`
|
||||||
WITH new_node AS (
|
WITH new_node AS (
|
||||||
INSERT INTO nodes (workspace_id, node_type)
|
INSERT INTO nodes (workspace_id, node_type)
|
||||||
VALUES (${locals.workspace.id}, 'kanban_card')
|
VALUES (${locals.workspace.id}, 'melding')
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
),
|
||||||
INSERT INTO kanban_cards (id, column_id, title, position, created_by)
|
new_message AS (
|
||||||
SELECT new_node.id, ${columnId}, ${title.trim()}, ${maxPos.next_pos}, ${locals.user.id}
|
INSERT INTO messages (id, author_id, message_type, title, body)
|
||||||
|
SELECT new_node.id, ${locals.user.id}, 'text', ${title.trim()}, ''
|
||||||
FROM new_node
|
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 });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,33 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
|
||||||
// Verifiser tilgang
|
// Verifiser tilgang
|
||||||
const [card] = await sql`
|
const [card] = await sql`
|
||||||
SELECT c.id FROM kanban_cards c
|
SELECT kv.message_id FROM kanban_card_view kv
|
||||||
JOIN kanban_columns col ON col.id = c.column_id
|
JOIN kanban_columns col ON col.id = kv.column_id
|
||||||
JOIN kanban_boards b ON b.id = col.board_id
|
JOIN kanban_boards b ON b.id = col.board_id
|
||||||
JOIN nodes n ON n.id = b.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');
|
if (!card) error(404, 'Kort ikke funnet');
|
||||||
|
|
||||||
|
// Oppdater melding (title, body/description)
|
||||||
const [updated] = await sql`
|
const [updated] = await sql`
|
||||||
UPDATE kanban_cards SET
|
UPDATE messages SET
|
||||||
title = COALESCE(${updates.title ?? null}, title),
|
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}
|
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 */
|
/** DELETE /api/kanban/:boardId/cards/:cardId — Slett kort */
|
||||||
|
|
@ -38,15 +48,15 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
|
||||||
// Verifiser tilgang
|
// Verifiser tilgang
|
||||||
const [card] = await sql`
|
const [card] = await sql`
|
||||||
SELECT c.id FROM kanban_cards c
|
SELECT kv.message_id FROM kanban_card_view kv
|
||||||
JOIN kanban_columns col ON col.id = c.column_id
|
JOIN kanban_columns col ON col.id = kv.column_id
|
||||||
JOIN kanban_boards b ON b.id = col.board_id
|
JOIN kanban_boards b ON b.id = col.board_id
|
||||||
JOIN nodes n ON n.id = b.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');
|
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}`;
|
await sql`DELETE FROM nodes WHERE id = ${cardId}`;
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,20 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
|
||||||
// Verifiser at kort og målkolonne tilhører dette brettet og workspace
|
// Verifiser at kort og målkolonne tilhører dette brettet og workspace
|
||||||
const [valid] = await sql`
|
const [valid] = await sql`
|
||||||
SELECT 1 FROM kanban_cards c
|
SELECT 1 FROM kanban_card_view kv
|
||||||
JOIN kanban_columns src_col ON src_col.id = c.column_id
|
JOIN kanban_columns src_col ON src_col.id = kv.column_id
|
||||||
JOIN kanban_boards b ON b.id = src_col.board_id
|
JOIN kanban_boards b ON b.id = src_col.board_id
|
||||||
JOIN nodes n ON n.id = b.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}
|
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');
|
if (!valid) error(404, 'Kort eller kolonne ikke funnet');
|
||||||
|
|
||||||
const [updated] = await sql`
|
const [updated] = await sql`
|
||||||
UPDATE kanban_cards
|
UPDATE kanban_card_view
|
||||||
SET column_id = ${columnId}, position = ${position}
|
SET column_id = ${columnId}, position = ${position}
|
||||||
WHERE id = ${cardId}
|
WHERE message_id = ${cardId}
|
||||||
RETURNING id, column_id, title, position
|
RETURNING message_id AS id, column_id, position
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return json(updated);
|
return json(updated);
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
if (!locals.workspace || !locals.user) error(401);
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
const [note] = await sql`
|
const [note] = await sql`
|
||||||
SELECT n.id, n.title, n.content, n.updated_at
|
SELECT m.id, m.title, m.body AS content, m.updated_at
|
||||||
FROM notes n
|
FROM messages m
|
||||||
JOIN nodes nd ON nd.id = n.id
|
JOIN nodes nd ON nd.id = m.id
|
||||||
WHERE n.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id}
|
WHERE m.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id}
|
||||||
`;
|
`;
|
||||||
if (!note) error(404, 'Notat ikke funnet');
|
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 updates = await request.json();
|
||||||
|
|
||||||
const [note] = await sql`
|
const [note] = await sql`
|
||||||
SELECT n.id FROM notes n
|
SELECT m.id FROM messages m
|
||||||
JOIN nodes nd ON nd.id = n.id
|
JOIN nodes nd ON nd.id = m.id
|
||||||
WHERE n.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id}
|
WHERE m.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id}
|
||||||
`;
|
`;
|
||||||
if (!note) error(404, 'Notat ikke funnet');
|
if (!note) error(404, 'Notat ikke funnet');
|
||||||
|
|
||||||
const [updated] = await sql`
|
const [updated] = await sql`
|
||||||
UPDATE notes SET
|
UPDATE messages SET
|
||||||
title = COALESCE(${updates.title ?? null}, title),
|
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}
|
WHERE id = ${params.noteId}
|
||||||
RETURNING id, title, content, updated_at
|
RETURNING id, title, body AS content, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return json(updated);
|
return json(updated);
|
||||||
|
|
|
||||||
3010
worker/Cargo.lock
generated
Normal file
3010
worker/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
worker/Cargo.toml
Normal file
18
worker/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "sidelinja-worker"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
anyhow = "1"
|
||||||
22
worker/src/handlers/echo.rs
Normal file
22
worker/src/handlers/echo.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use super::JobHandler;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tracing::info;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Test-handler som returnerer payload tilbake som resultat.
|
||||||
|
/// Brukes for å verifisere at jobbkøen fungerer end-to-end.
|
||||||
|
pub struct EchoHandler;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl JobHandler for EchoHandler {
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
_pool: &PgPool,
|
||||||
|
workspace_id: &Uuid,
|
||||||
|
payload: &Value,
|
||||||
|
) -> anyhow::Result<Option<Value>> {
|
||||||
|
info!(workspace_id = %workspace_id, "Echo-handler kjører");
|
||||||
|
Ok(Some(payload.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
38
worker/src/handlers/mod.rs
Normal file
38
worker/src/handlers/mod.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
mod echo;
|
||||||
|
|
||||||
|
/// Trait for jobbhandlere.
|
||||||
|
/// Hver jobbtype implementerer dette.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait JobHandler: Send + Sync {
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: &Uuid,
|
||||||
|
payload: &Value,
|
||||||
|
) -> anyhow::Result<Option<Value>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type HandlerRegistry = HashMap<String, Box<dyn JobHandler>>;
|
||||||
|
|
||||||
|
/// Bygg registeret med alle tilgjengelige handlers.
|
||||||
|
pub fn build_registry(http: reqwest::Client, ai_gateway_url: String) -> HandlerRegistry {
|
||||||
|
let _ = (&http, &ai_gateway_url); // brukes av fremtidige handlers
|
||||||
|
|
||||||
|
let mut registry: HandlerRegistry = HashMap::new();
|
||||||
|
|
||||||
|
// Echo-handler for testing
|
||||||
|
registry.insert("echo".into(), Box::new(echo::EchoHandler));
|
||||||
|
|
||||||
|
// Fremtidige handlers registreres her:
|
||||||
|
// registry.insert("whisper_transcribe".into(), Box::new(whisper::WhisperHandler::new(http.clone())));
|
||||||
|
// registry.insert("openrouter_analyze".into(), Box::new(ai::AnalyzeHandler::new(http.clone(), ai_gateway_url.clone())));
|
||||||
|
// registry.insert("research_clip".into(), Box::new(ai::ResearchClipHandler::new(http.clone(), ai_gateway_url.clone())));
|
||||||
|
// registry.insert("stats_parse".into(), Box::new(stats::StatsHandler));
|
||||||
|
|
||||||
|
registry
|
||||||
|
}
|
||||||
71
worker/src/main.rs
Normal file
71
worker/src/main.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
use clap::Parser;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
mod handlers;
|
||||||
|
mod worker;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "sidelinja-worker", about = "Jobbkø-worker for Sidelinja")]
|
||||||
|
struct Cli {
|
||||||
|
/// PostgreSQL connection string
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "DATABASE_URL",
|
||||||
|
default_value = "postgres://sidelinja:localdev@localhost:5432/sidelinja"
|
||||||
|
)]
|
||||||
|
database_url: String,
|
||||||
|
|
||||||
|
/// AI Gateway base URL
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "AI_GATEWAY_URL",
|
||||||
|
default_value = "http://localhost:4000/v1"
|
||||||
|
)]
|
||||||
|
ai_gateway_url: String,
|
||||||
|
|
||||||
|
/// Maks samtidige jobber
|
||||||
|
#[arg(long, default_value = "3")]
|
||||||
|
max_concurrent: usize,
|
||||||
|
|
||||||
|
/// Polling-intervall i sekunder
|
||||||
|
#[arg(long, default_value = "1")]
|
||||||
|
poll_interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "sidelinja_worker=info,sqlx=warn".into()),
|
||||||
|
)
|
||||||
|
.json()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
max_concurrent = cli.max_concurrent,
|
||||||
|
poll_interval_s = cli.poll_interval,
|
||||||
|
"Starter sidelinja-worker"
|
||||||
|
);
|
||||||
|
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(cli.max_concurrent as u32 + 2)
|
||||||
|
.connect(&cli.database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("Tilkoblet PostgreSQL");
|
||||||
|
|
||||||
|
let registry = Arc::new(handlers::build_registry(
|
||||||
|
reqwest::Client::new(),
|
||||||
|
cli.ai_gateway_url,
|
||||||
|
));
|
||||||
|
|
||||||
|
let registered: Vec<&str> = registry.keys().map(|k| k.as_str()).collect();
|
||||||
|
info!(?registered, "Registrerte jobbtyper");
|
||||||
|
|
||||||
|
worker::run(pool, registry, cli.max_concurrent, cli.poll_interval).await
|
||||||
|
}
|
||||||
178
worker/src/worker.rs
Normal file
178
worker/src/worker.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
use crate::handlers::HandlerRegistry;
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Hent og prosesser jobber i en uendelig loop.
|
||||||
|
/// Semaphore begrenser antall samtidige jobber.
|
||||||
|
pub async fn run(
|
||||||
|
pool: PgPool,
|
||||||
|
registry: Arc<HandlerRegistry>,
|
||||||
|
max_concurrent: usize,
|
||||||
|
poll_interval_secs: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let semaphore = Arc::new(Semaphore::new(max_concurrent));
|
||||||
|
let interval = std::time::Duration::from_secs(poll_interval_secs);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Vent til en slot er ledig
|
||||||
|
let permit = semaphore.clone().acquire_owned().await?;
|
||||||
|
|
||||||
|
// Forsøk å hente en jobb
|
||||||
|
let job = claim_job(&pool).await;
|
||||||
|
|
||||||
|
match job {
|
||||||
|
Ok(Some(job)) => {
|
||||||
|
let pool = pool.clone();
|
||||||
|
let registry = registry.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _permit = permit; // holdes til jobben er ferdig
|
||||||
|
process_job(&pool, ®istry, job).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
drop(permit);
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Feil ved henting av jobb");
|
||||||
|
drop(permit);
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Job {
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
job_type: String,
|
||||||
|
payload: Value,
|
||||||
|
attempts: i16,
|
||||||
|
max_attempts: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim neste jobb med FOR UPDATE SKIP LOCKED.
|
||||||
|
async fn claim_job(pool: &PgPool) -> anyhow::Result<Option<Job>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE job_queue SET
|
||||||
|
status = 'running',
|
||||||
|
started_at = now(),
|
||||||
|
attempts = attempts + 1
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM job_queue
|
||||||
|
WHERE status IN ('pending', 'retry')
|
||||||
|
AND scheduled_for <= now()
|
||||||
|
ORDER BY priority DESC, scheduled_for ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
RETURNING id, workspace_id, job_type, payload, attempts, max_attempts
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row.map(|r| Job {
|
||||||
|
id: r.get("id"),
|
||||||
|
workspace_id: r.get("workspace_id"),
|
||||||
|
job_type: r.get("job_type"),
|
||||||
|
payload: r.get("payload"),
|
||||||
|
attempts: r.get("attempts"),
|
||||||
|
max_attempts: r.get("max_attempts"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prosesser en jobb: dispatch til handler, oppdater status.
|
||||||
|
async fn process_job(pool: &PgPool, registry: &HandlerRegistry, job: Job) {
|
||||||
|
info!(
|
||||||
|
job_id = %job.id,
|
||||||
|
job_type = %job.job_type,
|
||||||
|
workspace_id = %job.workspace_id,
|
||||||
|
attempt = job.attempts,
|
||||||
|
"Starter jobb"
|
||||||
|
);
|
||||||
|
|
||||||
|
let handler = registry.get(&job.job_type);
|
||||||
|
|
||||||
|
let result = match handler {
|
||||||
|
Some(handler) => handler.handle(pool, &job.workspace_id, &job.payload).await,
|
||||||
|
None => {
|
||||||
|
warn!(job_type = %job.job_type, "Ukjent jobbtype — ingen handler registrert");
|
||||||
|
Err(anyhow::anyhow!("Ukjent jobbtype: {}", job.job_type))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(result_data) => {
|
||||||
|
info!(job_id = %job.id, "Jobb fullført");
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE job_queue SET
|
||||||
|
status = 'completed',
|
||||||
|
result = $1,
|
||||||
|
completed_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&result_data)
|
||||||
|
.bind(job.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(job_id = %job.id, error = %e, "Jobb feilet");
|
||||||
|
|
||||||
|
if job.attempts < job.max_attempts {
|
||||||
|
// Retry med eksponentiell backoff: 30s × 2^(attempts-1)
|
||||||
|
let backoff_secs = 30i64 * 2i64.pow((job.attempts - 1) as u32);
|
||||||
|
let scheduled_for = Utc::now() + chrono::Duration::seconds(backoff_secs);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
job_id = %job.id,
|
||||||
|
next_retry = %scheduled_for,
|
||||||
|
backoff_secs,
|
||||||
|
"Setter opp retry"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE job_queue SET
|
||||||
|
status = 'retry',
|
||||||
|
error_msg = $1,
|
||||||
|
scheduled_for = $2
|
||||||
|
WHERE id = $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(format!("{e}"))
|
||||||
|
.bind(scheduled_for)
|
||||||
|
.bind(job.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
warn!(job_id = %job.id, attempts = job.attempts, "Maks forsøk nådd — permanent feil");
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE job_queue SET
|
||||||
|
status = 'error',
|
||||||
|
error_msg = $1,
|
||||||
|
completed_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(format!("{e}"))
|
||||||
|
.bind(job.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue