Kanban-visning: board med drag-and-drop statusendring (oppgave 9.1)

Implementerer kanban som noder+edges uten separate tabeller:
- Board = collection-node med metadata.board og metadata.columns
- Kort = content-noder med belongs_to-edge til board
- Status via status-edge (kort→board) med metadata.value
- Posisjon via belongs_to-edge metadata.position

Backend:
- POST /intentions/update_edge — oppdater edge-type/metadata
- GET /query/board?board_id= — hent kort med status og posisjon

Frontend:
- /board/[id] route med kolonner, drag-and-drop, kortoppretting
- Sanntid via SpacetimeDB edge-subscriptions
- Board-oppretting og navigasjon fra mottak-siden

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 22:13:22 +00:00
parent 26e730e758
commit d8c0cceb89
8 changed files with 859 additions and 69 deletions

View file

@ -1,59 +1,71 @@
# Feature: Kanban (Planlegging)
**Filsti:** `docs/features/kanban.md`
## 1. Konsept
Et drag-and-drop Kanban-brett for planlegging. Primært brukt til episodeplanlegging i Redaksjonen, men også mottaker av AI-genererte action points fra Møterommet.
## 2. Status
**PG-adapter ferdig og deployet (mars 2025).** SpacetimeDB-sync gjenstår.
### Implementert
- Migrering `0002_kanban.sql`: `kanban_boards`, `kanban_columns`, `kanban_cards`
- Kanban-kort er nodes i kunnskapsgrafen (tilgangsstyrt via `node_access`-matrise)
- REAL-posisjon for midpoint-innsetting (`(1.0 + 2.0) / 2 = 1.5`) — ingen re-nummerering
- REST API: GET brett, POST kolonne/kort, PATCH kort/flytt, DELETE kort
- PG polling-adapter (`pg.svelte.ts`) med 5 sek intervall og optimistisk UI
- Adapter-factory (`create.svelte.ts`) — klar for SpacetimeDB-hybrid
- KanbanBlock.svelte: drag & drop, redigeringsmodal (tittel/beskrivelse/slett), enkelt kort-input som legger til i første kolonne
### Gjenstår
- SpacetimeDB-modul + hybrid-adapter for sanntidsoppdatering
- Reposisjonering ved dra innad i kolonne (sortert rekkefølge)
- Tildeling (assignee) UI
- Fargekoder/labels på kort
- AI-integrasjon: møtereferent → nye kort
## 3. Datamodell
```
kanban_boards (id FK→nodes, parent_id FK→nodes, name)
kanban_columns (id, board_id FK→kanban_boards, name, color, position REAL)
kanban_cards (id FK→nodes, column_id FK→kanban_columns, title, description, assignee_id, position REAL, created_by, created_at)
```
Kort og brett er nodes — tilgang styres via `node_access`-matrisen.
## 4. API-endepunkter
| Metode | Sti | Beskrivelse |
|---|---|---|
| GET | `/api/kanban/[boardId]` | Hent brett med kolonner og kort |
| POST | `/api/kanban/[boardId]/columns` | Opprett kolonne |
| POST | `/api/kanban/[boardId]/cards` | Opprett kort (oppretter node + kort) |
| PATCH | `/api/kanban/[boardId]/cards/[cardId]` | Oppdater tittel/beskrivelse |
| PATCH | `/api/kanban/[boardId]/cards/[cardId]/move` | Flytt kort til kolonne/posisjon |
| DELETE | `/api/kanban/[boardId]/cards/[cardId]` | Slett kort (cascader fra node) |
## 5. Brukes av
| Konsept | Bruk |
|---|---|
| Redaksjonen | Episodeplanlegging — dra Temaer inn i Kjøreplanen |
| Møterommet | AI-referenten foreslår nye kort basert på action points |
| Foreningen Liberalistene | Styreoppgaver (Å gjøre / Pågår / Ferdig) |
## 6. Instruks for Claude Code
* Bruk native HTML5 Drag and Drop i SvelteKit, unngå tunge biblioteker.
* PG-adapter er autoritativ inntil SpacetimeDB-sync er på plass.
* Tilgang styres via `node_access`-matrisen.
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi.
# Feature: Kanban (Planlegging)
**Filsti:** `docs/features/kanban.md`
## 1. Konsept
Et drag-and-drop Kanban-brett for planlegging. Primært brukt til
episodeplanlegging i Redaksjonen, men også mottaker av AI-genererte
action points fra Møterommet.
## 2. Status
**Implementert med nodes+edges (mars 2026).** Sanntid via SpacetimeDB.
### Implementert
- Board = samlings-node (`node_kind: 'collection'`, `metadata.board: true`)
- Kolonner definert i board-metadata: `metadata.columns: ["todo", "in_progress", "done"]`
- Kort = content-noder med `belongs_to`-edge til board
- Status via `status`-edge (kort → board) med `metadata.value`
- Posisjon via `belongs_to`-edge `metadata.position` (REAL for midpoint-innsetting)
- Backend: `POST /intentions/update_edge` for statusendring
- Backend: `GET /query/board?board_id=...` for board-spørring
- Frontend: `/board/[id]` route med HTML5 drag-and-drop
- Sanntid via SpacetimeDB edge-subscriptions (ingen polling)
- Opprett kort direkte i kolonne (tittel-input)
- Oppretting av nye brett fra mottak-siden
### Gjenstår
- Reposisjonering ved dra innad i kolonne (sortert rekkefølge)
- Redigeringsmodal for kort (tittel/beskrivelse)
- Tildeling (assignee) UI
- Fargekoder/labels på kort
- AI-integrasjon: møtereferent → nye kort
- Tilpassbare kolonnenavn
## 3. Datamodell
Ingen separate kanban-tabeller. Alt er noder og edges (kjerneprimitivene):
```
Board = collection-node (metadata.board: true, metadata.columns: [...])
Kort = content-node
+ belongs_to-edge → board (metadata.position: REAL)
+ status-edge → board (metadata.value: "todo"|"in_progress"|"done")
```
Tilgang styres via `node_access`-matrisen. Brett er synlige for
brukere med `owner`/`admin`/`member_of`-edge til board-noden.
## 4. API-endepunkter
| Metode | Sti | Beskrivelse |
|---|---|---|
| GET | `/query/board?board_id=...` | Hent brett med kort, status og posisjon |
| POST | `/intentions/create_node` | Opprett kort (content-node) |
| POST | `/intentions/create_edge` | Koble kort til brett (belongs_to + status) |
| POST | `/intentions/update_edge` | Endre status ved drag-and-drop |
| POST | `/intentions/update_node` | Oppdater tittel/beskrivelse |
| POST | `/intentions/delete_node` | Slett kort (cascader edges) |
## 5. Brukes av
| Konsept | Bruk |
|---|---|
| Redaksjonen | Episodeplanlegging — dra Temaer inn i Kjøreplanen |
| Møterommet | AI-referenten foreslår nye kort basert på action points |
## 6. Instruks for Claude Code
* Alt er noder og edges — ingen separate kanban-tabeller.
* Board er en collection-node med `metadata.board: true`.
* Status er en `status`-edge (kort → board) med `metadata.value`.
* Bruk native HTML5 Drag and Drop, unngå tunge biblioteker.
* Sanntid via SpacetimeDB edge-subscriptions.
* Tilgang styres via `node_access`-matrisen.

View file

@ -66,6 +66,67 @@ export function createEdge(
return post(accessToken, '/intentions/create_edge', data);
}
// =============================================================================
// Edge-oppdatering
// =============================================================================
export interface UpdateEdgeRequest {
edge_id: string;
edge_type?: string;
metadata?: Record<string, unknown>;
}
export interface UpdateEdgeResponse {
edge_id: string;
}
export function updateEdge(
accessToken: string,
data: UpdateEdgeRequest
): Promise<UpdateEdgeResponse> {
return post(accessToken, '/intentions/update_edge', data);
}
// =============================================================================
// Board / Kanban
// =============================================================================
export interface BoardCard {
node_id: string;
title: string | null;
content: string | null;
node_kind: string;
metadata: Record<string, unknown>;
created_at: string;
created_by: string | null;
status: string | null;
position: number;
belongs_to_edge_id: string;
status_edge_id: string | null;
}
export interface BoardResponse {
board_id: string;
board_title: string | null;
columns: string[];
cards: BoardCard[];
}
export async function fetchBoard(accessToken: string, boardId: string): Promise<BoardResponse> {
const res = await fetch(`${BASE_URL}/query/board?board_id=${encodeURIComponent(boardId)}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`board failed (${res.status}): ${body}`);
}
return res.json();
}
// =============================================================================
// Kommunikasjon
// =============================================================================
export interface CreateCommunicationRequest {
title?: string;
participants?: string[];

View file

@ -120,6 +120,46 @@
return null;
}
/** Check if a node is a kanban board (collection with board metadata) */
function isBoard(node: Node): boolean {
if (node.nodeKind !== 'collection') return false;
try {
const meta = JSON.parse(node.metadata ?? '{}');
return meta.board === true;
} catch { return false; }
}
let isCreatingBoard = $state(false);
/** Create a new kanban board */
async function handleNewBoard() {
if (!accessToken || !nodeId || isCreatingBoard) return;
isCreatingBoard = true;
try {
const { node_id } = await createNode(accessToken, {
node_kind: 'collection',
title: 'Nytt brett',
visibility: 'hidden',
metadata: {
board: true,
columns: ['todo', 'in_progress', 'done']
}
});
await createEdge(accessToken, {
source_id: nodeId,
target_id: node_id,
edge_type: 'owner'
});
window.location.href = `/board/${node_id}`;
} catch (e) {
console.error('Feil ved oppretting av brett:', e);
} finally {
isCreatingBoard = false;
}
}
let showNewChatDialog = $state(false);
/** Open the new chat dialog to pick a participant */
@ -238,12 +278,21 @@
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
{#if connected && accessToken}
<button
onclick={handleNewChat}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Ny samtale
</button>
<div class="flex gap-2">
<button
onclick={handleNewBoard}
disabled={isCreatingBoard}
class="rounded-lg bg-gray-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
>
{isCreatingBoard ? 'Oppretter…' : 'Nytt brett'}
</button>
<button
onclick={handleNewChat}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Ny samtale
</button>
</div>
{/if}
</div>
@ -268,7 +317,30 @@
<ul class="space-y-2">
{#each mottaksnoder as node (node.id)}
{@const vis = nodeVisibility(node, nodeId)}
{#if node.nodeKind === 'communication'}
{#if isBoard(node)}
<li class="rounded-lg border border-gray-200 bg-white shadow-sm transition-colors hover:border-gray-400">
<a href="/board/{node.id}" class="block p-4">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="font-medium text-gray-900">
{node.title || 'Uten tittel'}
<span class="ml-1 text-xs text-gray-500">→ Åpne brett</span>
</h3>
{#if vis === 'full' && node.content}
<p class="mt-1 text-sm text-gray-500">
{excerpt(node.content)}
</p>
{/if}
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
Brett
</span>
</div>
</div>
</a>
</li>
{:else if node.nodeKind === 'communication'}
<li class="rounded-lg border border-gray-200 bg-white shadow-sm transition-colors hover:border-blue-300">
<a href="/chat/{node.id}" class="block p-4">
<div class="flex items-start justify-between gap-2">

View file

@ -0,0 +1,366 @@
<script lang="ts">
import { page } from '$app/stores';
import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime';
import type { Node, Edge } from '$lib/spacetime';
import { createNode, createEdge, updateEdge } from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
const connected = $derived(connectionState.current === 'connected');
const boardId = $derived($page.params.id ?? '');
const boardNode = $derived(connected ? nodeStore.get(boardId) : undefined);
/** Column definitions from board metadata, fallback to defaults */
const columns = $derived.by(() => {
if (!boardNode) return ['todo', 'in_progress', 'done'];
try {
const meta = JSON.parse(boardNode.metadata ?? '{}');
if (Array.isArray(meta.columns) && meta.columns.length > 0) return meta.columns as string[];
} catch { /* ignore */ }
return ['todo', 'in_progress', 'done'];
});
/** Column display labels */
const columnLabels: Record<string, string> = {
todo: 'Å gjøre',
in_progress: 'I gang',
done: 'Ferdig'
};
function columnLabel(col: string): string {
return columnLabels[col] ?? col;
}
/** Column colors for visual distinction */
const columnColors: Record<string, string> = {
todo: 'bg-gray-100',
in_progress: 'bg-blue-50',
done: 'bg-green-50'
};
function columnColor(col: string): string {
return columnColors[col] ?? 'bg-gray-50';
}
/** All cards on this board: nodes with belongs_to edge to board */
interface CardData {
node: Node;
status: string;
position: number;
belongsToEdge: Edge;
statusEdge: Edge | undefined;
}
const cards = $derived.by((): CardData[] => {
if (!connected || !boardId) return [];
const result: CardData[] = [];
// Find all nodes with belongs_to edge pointing to this board
for (const edge of edgeStore.byTarget(boardId)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
if (!node || nodeVisibility(node, nodeId) === 'hidden') continue;
// Find status edge: card --status--> board
let statusEdge: Edge | undefined;
let status = columns[0] ?? 'todo'; // default to first column
for (const e of edgeStore.bySource(node.id)) {
if (e.edgeType === 'status' && e.targetId === boardId) {
statusEdge = e;
try {
const meta = JSON.parse(e.metadata ?? '{}');
if (meta.value) status = meta.value;
} catch { /* ignore */ }
break;
}
}
// Position from belongs_to edge metadata
let position = 0;
try {
const meta = JSON.parse(edge.metadata ?? '{}');
if (typeof meta.position === 'number') position = meta.position;
} catch { /* ignore */ }
result.push({ node, status, position, belongsToEdge: edge, statusEdge });
}
return result;
});
/** Cards grouped by column, sorted by position */
const cardsByColumn = $derived.by(() => {
const grouped: Record<string, CardData[]> = {};
for (const col of columns) {
grouped[col] = [];
}
for (const card of cards) {
const col = columns.includes(card.status) ? card.status : columns[0] ?? 'todo';
if (!grouped[col]) grouped[col] = [];
grouped[col].push(card);
}
// Sort each column by position
for (const col of Object.keys(grouped)) {
grouped[col].sort((a, b) => a.position - b.position);
}
return grouped;
});
// =========================================================================
// Drag and drop state
// =========================================================================
let draggedCard = $state<CardData | null>(null);
let dragOverColumn = $state<string | null>(null);
function handleDragStart(e: DragEvent, card: CardData) {
draggedCard = card;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', card.node.id);
}
}
function handleDragOver(e: DragEvent, column: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverColumn = column;
}
function handleDragLeave() {
dragOverColumn = null;
}
async function handleDrop(e: DragEvent, targetColumn: string) {
e.preventDefault();
dragOverColumn = null;
if (!draggedCard || !accessToken) return;
if (draggedCard.status === targetColumn) {
draggedCard = null;
return;
}
const card = draggedCard;
draggedCard = null;
try {
if (card.statusEdge) {
// Update existing status edge
await updateEdge(accessToken, {
edge_id: card.statusEdge.id,
metadata: { value: targetColumn }
});
} else {
// Create new status edge
await createEdge(accessToken, {
source_id: card.node.id,
target_id: boardId,
edge_type: 'status',
metadata: { value: targetColumn }
});
}
} catch (err) {
console.error('Feil ved statusendring:', err);
}
}
function handleDragEnd() {
draggedCard = null;
dragOverColumn = null;
}
// =========================================================================
// Card creation
// =========================================================================
let newCardTitle = $state('');
let addingToColumn = $state<string | null>(null);
let isCreating = $state(false);
async function handleCreateCard(column: string) {
if (!accessToken || !newCardTitle.trim() || isCreating) return;
isCreating = true;
try {
// Calculate position: after last card in column
const colCards = cardsByColumn[column] ?? [];
const maxPos = colCards.length > 0
? Math.max(...colCards.map(c => c.position))
: 0;
const position = maxPos + 1;
// Create the node
const { node_id } = await createNode(accessToken, {
node_kind: 'content',
title: newCardTitle.trim(),
visibility: 'hidden'
});
// Create belongs_to edge with position
await createEdge(accessToken, {
source_id: node_id,
target_id: boardId,
edge_type: 'belongs_to',
metadata: { position }
});
// Create status edge
await createEdge(accessToken, {
source_id: node_id,
target_id: boardId,
edge_type: 'status',
metadata: { value: column }
});
newCardTitle = '';
addingToColumn = null;
} catch (err) {
console.error('Feil ved oppretting av kort:', err);
} finally {
isCreating = false;
}
}
function handleCardKeydown(e: KeyboardEvent, column: string) {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateCard(column);
} else if (e.key === 'Escape') {
addingToColumn = null;
newCardTitle = '';
}
}
</script>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="border-b border-gray-200 bg-white">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a>
<h1 class="text-lg font-semibold text-gray-900">
{boardNode?.title || 'Kanban-brett'}
</h1>
</div>
<div class="flex items-center gap-3">
{#if connected}
<span class="text-xs text-green-600">Tilkoblet</span>
{:else}
<span class="text-xs text-gray-400">{connectionState.current}</span>
{/if}
<span class="text-xs text-gray-400">{cards.length} kort</span>
</div>
</div>
</header>
<!-- Board -->
<main class="overflow-x-auto p-4">
{#if !connected}
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
{:else if !boardNode}
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
Brett ikke funnet. Sjekk at du har tilgang.
</div>
{:else}
<div class="flex gap-4" style="min-width: max-content;">
{#each columns as column (column)}
{@const colCards = cardsByColumn[column] ?? []}
<div
class="w-72 shrink-0 rounded-lg {columnColor(column)} {dragOverColumn === column ? 'ring-2 ring-blue-400' : ''}"
ondragover={(e: DragEvent) => handleDragOver(e, column)}
ondragleave={handleDragLeave}
ondrop={(e: DragEvent) => handleDrop(e, column)}
role="list"
aria-label={columnLabel(column)}
>
<!-- Column header -->
<div class="flex items-center justify-between px-3 py-2">
<h2 class="text-sm font-semibold text-gray-700">
{columnLabel(column)}
</h2>
<span class="rounded-full bg-white px-2 py-0.5 text-xs text-gray-500 shadow-sm">
{colCards.length}
</span>
</div>
<!-- Cards -->
<div class="space-y-2 px-2 pb-2" style="min-height: 2rem;">
{#each colCards as card (card.node.id)}
<div
class="cursor-grab rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-shadow hover:shadow-md active:cursor-grabbing {draggedCard?.node.id === card.node.id ? 'opacity-50' : ''}"
draggable="true"
ondragstart={(e: DragEvent) => handleDragStart(e, card)}
ondragend={handleDragEnd}
role="listitem"
>
<h3 class="text-sm font-medium text-gray-900">
{card.node.title || 'Uten tittel'}
</h3>
{#if card.node.content}
<p class="mt-1 text-xs text-gray-500 line-clamp-2">
{card.node.content}
</p>
{/if}
</div>
{/each}
<!-- Add card button/input -->
{#if addingToColumn === column}
<div class="rounded-lg border border-blue-300 bg-white p-2">
<input
type="text"
bind:value={newCardTitle}
onkeydown={(e: KeyboardEvent) => handleCardKeydown(e, column)}
placeholder="Tittel på nytt kort…"
class="w-full rounded border border-gray-200 px-2 py-1 text-sm focus:border-blue-400 focus:outline-none"
disabled={isCreating}
/>
<div class="mt-2 flex gap-1">
<button
onclick={() => handleCreateCard(column)}
disabled={isCreating || !newCardTitle.trim()}
class="rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isCreating ? 'Oppretter…' : 'Legg til'}
</button>
<button
onclick={() => { addingToColumn = null; newCardTitle = ''; }}
class="rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-100"
>
Avbryt
</button>
</div>
</div>
{:else if accessToken}
<button
onclick={() => { addingToColumn = column; newCardTitle = ''; }}
class="w-full rounded-lg p-2 text-left text-sm text-gray-400 hover:bg-white hover:text-gray-600"
>
+ Legg til kort
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</main>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -159,7 +159,6 @@ async fn user_can_modify_node(db: &PgPool, user_id: Uuid, node_id: Uuid) -> Resu
/// Sjekker om brukeren har skrivetilgang til en edge.
/// Brukeren må ha opprettet edgen (direkte eller via alias),
/// eller ha owner/admin-edge til source-noden.
#[allow(dead_code)]
async fn user_can_modify_edge(db: &PgPool, user_id: Uuid, edge_id: Uuid) -> Result<bool, sqlx::Error> {
let row = sqlx::query_scalar::<_, bool>(
r#"
@ -726,6 +725,119 @@ pub async fn delete_node(
Ok(Json(DeleteNodeResponse { deleted: true }))
}
// =============================================================================
// update_edge
// =============================================================================
#[derive(Deserialize)]
pub struct UpdateEdgeRequest {
/// ID til edgen som skal oppdateres.
pub edge_id: Uuid,
/// Ny edge_type. Beholder eksisterende hvis None.
pub edge_type: Option<String>,
/// Ny metadata. Beholder eksisterende hvis None.
pub metadata: Option<serde_json::Value>,
}
#[derive(Serialize)]
pub struct UpdateEdgeResponse {
pub edge_id: Uuid,
}
/// Henter en edge fra PG.
async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result<Option<EdgeRow>, sqlx::Error> {
sqlx::query_as::<_, EdgeRow>(
"SELECT edge_type, metadata FROM edges WHERE id = $1",
)
.bind(edge_id)
.fetch_optional(db)
.await
}
#[derive(sqlx::FromRow)]
struct EdgeRow {
edge_type: String,
metadata: serde_json::Value,
}
/// POST /intentions/update_edge
///
/// Oppdaterer en eksisterende edge (type og/eller metadata).
/// Krever at brukeren har opprettet edgen, eller har owner/admin-edge
/// til source-noden.
pub async fn update_edge(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<UpdateEdgeRequest>,
) -> Result<Json<UpdateEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Tilgangskontroll --
let can_modify = user_can_modify_edge(&state.db, user.node_id, req.edge_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved tilgangssjekk: {e}");
internal_error("Databasefeil ved tilgangssjekk")
})?;
if !can_modify {
return Err(forbidden("Ingen tilgang til å endre denne edgen"));
}
// -- Hent eksisterende edge --
let existing = get_edge(&state.db, req.edge_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved henting av edge: {e}");
internal_error("Databasefeil ved henting av edge")
})?
.ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;
let edge_type = req.edge_type.unwrap_or(existing.edge_type);
if edge_type.is_empty() {
return Err(bad_request("edge_type kan ikke være tom"));
}
let metadata = req.metadata.unwrap_or(existing.metadata);
let metadata_str = metadata.to_string();
let edge_id_str = req.edge_id.to_string();
// -- Skriv til SpacetimeDB (instant) --
state
.stdb
.update_edge(&edge_id_str, &edge_type, &metadata_str)
.await
.map_err(|e| stdb_error("update_edge", e))?;
tracing::info!(
edge_id = %req.edge_id,
edge_type = %edge_type,
updated_by = %user.node_id,
"Edge oppdatert i STDB"
);
// -- Spawn async PG-skriving --
let db = state.db.clone();
let eid = req.edge_id;
let et = edge_type.clone();
let md = metadata.clone();
tokio::spawn(async move {
let result = sqlx::query(
"UPDATE edges SET edge_type = $1, metadata = $2 WHERE id = $3",
)
.bind(&et)
.bind(&md)
.bind(eid)
.execute(&db)
.await;
match result {
Ok(_) => tracing::info!(edge_id = %eid, "Edge oppdatert i PostgreSQL"),
Err(e) => tracing::error!(edge_id = %eid, error = %e, "Kunne ikke oppdatere edge i PostgreSQL"),
}
});
Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id }))
}
// =============================================================================
// create_communication
// =============================================================================

View file

@ -136,10 +136,12 @@ async fn main() {
.route("/intentions/create_edge", post(intentions::create_edge))
.route("/intentions/update_node", post(intentions::update_node))
.route("/intentions/delete_node", post(intentions::delete_node))
.route("/intentions/update_edge", post(intentions::update_edge))
.route("/intentions/create_communication", post(intentions::create_communication))
.route("/intentions/upload_media", post(intentions::upload_media))
.route("/cas/{hash}", get(serving::get_cas_file))
.route("/query/nodes", get(queries::query_nodes))
.route("/query/board", get(queries::query_board))
.route("/query/segments", get(queries::query_segments))
.route("/query/segments/srt", get(queries::export_srt))
.route("/intentions/create_alias", post(intentions::create_alias))

View file

@ -606,6 +606,172 @@ pub async fn query_aliases(
Ok(Json(QueryAliasesResponse { aliases }))
}
// =============================================================================
// GET /query/board — kanban-brett: noder med belongs_to-edge, gruppert på status
// =============================================================================
#[derive(Deserialize)]
pub struct QueryBoardRequest {
/// Board-nodens ID.
pub board_id: Uuid,
}
#[derive(Serialize)]
pub struct BoardCard {
pub node_id: Uuid,
pub title: Option<String>,
pub content: Option<String>,
pub node_kind: String,
pub metadata: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
pub created_by: Option<Uuid>,
/// Status-verdi fra status-edge metadata (null hvis ingen status-edge)
pub status: Option<String>,
/// Position fra belongs_to-edge metadata (default 0)
pub position: f64,
/// belongs_to-edge ID (for referanse)
pub belongs_to_edge_id: Uuid,
/// status-edge ID (null hvis ingen)
pub status_edge_id: Option<Uuid>,
}
#[derive(Serialize)]
pub struct QueryBoardResponse {
pub board_id: Uuid,
pub board_title: Option<String>,
/// Kolonne-definisjoner fra board-nodens metadata
pub columns: Vec<String>,
pub cards: Vec<BoardCard>,
}
/// GET /query/board?board_id=...
///
/// Henter alle kort (noder med belongs_to-edge) på et kanban-brett,
/// inkludert status-edges og posisjonsdata.
pub async fn query_board(
State(state): State<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<QueryBoardRequest>,
) -> Result<Json<QueryBoardResponse>, (StatusCode, Json<ErrorResponse>)> {
// Verifiser tilgang til board-noden via RLS
let mut tx = state.db.begin().await.map_err(|e| {
tracing::error!(error = %e, "Transaksjon feilet");
internal_error("Databasefeil")
})?;
set_rls_context(&mut tx, user.node_id).await.map_err(|e| {
tracing::error!(error = %e, "RLS-kontekst feilet");
internal_error("Databasefeil")
})?;
// Hent board-noden
let board = sqlx::query_as::<_, (Option<String>, serde_json::Value)>(
"SELECT title, metadata FROM nodes WHERE id = $1",
)
.bind(params.board_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved henting av board-node");
internal_error("Databasefeil")
})?;
tx.commit().await.map_err(|e| {
tracing::error!(error = %e, "Commit feilet");
internal_error("Databasefeil")
})?;
let Some((board_title, board_metadata)) = board else {
return Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Board {} finnes ikke eller du har ikke tilgang", params.board_id),
}),
));
};
// Hent kolonner fra board-metadata, fallback til standard
let columns: Vec<String> = board_metadata
.get("columns")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| vec!["todo".to_string(), "in_progress".to_string(), "done".to_string()]);
// Hent alle kort: noder med belongs_to-edge til dette boardet
let cards = sqlx::query_as::<_, (
Uuid, // n.id
String, // n.node_kind
Option<String>, // n.title
Option<String>, // n.content
serde_json::Value, // n.metadata
chrono::DateTime<chrono::Utc>, // n.created_at
Option<Uuid>, // n.created_by
Uuid, // bt.id (belongs_to edge)
serde_json::Value, // bt.metadata
Option<Uuid>, // st.id (status edge)
Option<serde_json::Value>, // st.metadata
)>(
r#"
SELECT
n.id, n.node_kind, n.title, n.content, n.metadata,
n.created_at, n.created_by,
bt.id AS belongs_to_edge_id, bt.metadata AS bt_metadata,
st.id AS status_edge_id, st.metadata AS st_metadata
FROM edges bt
JOIN nodes n ON n.id = bt.source_id
LEFT JOIN edges st ON st.source_id = n.id
AND st.target_id = $1
AND st.edge_type = 'status'
WHERE bt.target_id = $1
AND bt.edge_type = 'belongs_to'
ORDER BY n.created_at
"#,
)
.bind(params.board_id)
.fetch_all(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved henting av board-kort");
internal_error("Databasefeil ved henting av kort")
})?;
let board_cards: Vec<BoardCard> = cards
.into_iter()
.map(|(node_id, node_kind, title, content, metadata, created_at, created_by,
belongs_to_edge_id, bt_metadata, status_edge_id, st_metadata)| {
let status = st_metadata
.as_ref()
.and_then(|m| m.get("value"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let position = bt_metadata
.get("position")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
BoardCard {
node_id,
title,
content,
node_kind,
metadata,
created_at,
created_by,
status,
position,
belongs_to_edge_id,
status_edge_id,
}
})
.collect();
Ok(Json(QueryBoardResponse {
board_id: params.board_id,
board_title,
columns,
cards: board_cards,
}))
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -109,8 +109,7 @@ Uavhengige faser kan fortsatt plukkes.
## Fase 9: Flere visninger
- [~] 9.1 Kanban-visning: noder med board-edge, gruppert på status-edge. Drag-and-drop for statusendring.
> Påbegynt: 2026-03-17T22:05
- [x] 9.1 Kanban-visning: noder med board-edge, gruppert på status-edge. Drag-and-drop for statusendring.
- [ ] 9.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje.
- [ ] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid.
- [ ] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.