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:
parent
26e730e758
commit
d8c0cceb89
8 changed files with 859 additions and 69 deletions
|
|
@ -2,47 +2,58 @@
|
||||||
**Filsti:** `docs/features/kanban.md`
|
**Filsti:** `docs/features/kanban.md`
|
||||||
|
|
||||||
## 1. Konsept
|
## 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.
|
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
|
## 2. Status
|
||||||
**PG-adapter ferdig og deployet (mars 2025).** SpacetimeDB-sync gjenstår.
|
**Implementert med nodes+edges (mars 2026).** Sanntid via SpacetimeDB.
|
||||||
|
|
||||||
### Implementert
|
### Implementert
|
||||||
- Migrering `0002_kanban.sql`: `kanban_boards`, `kanban_columns`, `kanban_cards`
|
- Board = samlings-node (`node_kind: 'collection'`, `metadata.board: true`)
|
||||||
- Kanban-kort er nodes i kunnskapsgrafen (tilgangsstyrt via `node_access`-matrise)
|
- Kolonner definert i board-metadata: `metadata.columns: ["todo", "in_progress", "done"]`
|
||||||
- REAL-posisjon for midpoint-innsetting (`(1.0 + 2.0) / 2 = 1.5`) — ingen re-nummerering
|
- Kort = content-noder med `belongs_to`-edge til board
|
||||||
- REST API: GET brett, POST kolonne/kort, PATCH kort/flytt, DELETE kort
|
- Status via `status`-edge (kort → board) med `metadata.value`
|
||||||
- PG polling-adapter (`pg.svelte.ts`) med 5 sek intervall og optimistisk UI
|
- Posisjon via `belongs_to`-edge `metadata.position` (REAL for midpoint-innsetting)
|
||||||
- Adapter-factory (`create.svelte.ts`) — klar for SpacetimeDB-hybrid
|
- Backend: `POST /intentions/update_edge` for statusendring
|
||||||
- KanbanBlock.svelte: drag & drop, redigeringsmodal (tittel/beskrivelse/slett), enkelt kort-input som legger til i første kolonne
|
- 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
|
### Gjenstår
|
||||||
- SpacetimeDB-modul + hybrid-adapter for sanntidsoppdatering
|
|
||||||
- Reposisjonering ved dra innad i kolonne (sortert rekkefølge)
|
- Reposisjonering ved dra innad i kolonne (sortert rekkefølge)
|
||||||
|
- Redigeringsmodal for kort (tittel/beskrivelse)
|
||||||
- Tildeling (assignee) UI
|
- Tildeling (assignee) UI
|
||||||
- Fargekoder/labels på kort
|
- Fargekoder/labels på kort
|
||||||
- AI-integrasjon: møtereferent → nye kort
|
- AI-integrasjon: møtereferent → nye kort
|
||||||
|
- Tilpassbare kolonnenavn
|
||||||
|
|
||||||
## 3. Datamodell
|
## 3. Datamodell
|
||||||
|
|
||||||
|
Ingen separate kanban-tabeller. Alt er noder og edges (kjerneprimitivene):
|
||||||
|
|
||||||
```
|
```
|
||||||
kanban_boards (id FK→nodes, parent_id FK→nodes, name)
|
Board = collection-node (metadata.board: true, metadata.columns: [...])
|
||||||
kanban_columns (id, board_id FK→kanban_boards, name, color, position REAL)
|
Kort = content-node
|
||||||
kanban_cards (id FK→nodes, column_id FK→kanban_columns, title, description, assignee_id, position REAL, created_by, created_at)
|
+ belongs_to-edge → board (metadata.position: REAL)
|
||||||
|
+ status-edge → board (metadata.value: "todo"|"in_progress"|"done")
|
||||||
```
|
```
|
||||||
|
|
||||||
Kort og brett er nodes — tilgang styres via `node_access`-matrisen.
|
Tilgang styres via `node_access`-matrisen. Brett er synlige for
|
||||||
|
brukere med `owner`/`admin`/`member_of`-edge til board-noden.
|
||||||
|
|
||||||
## 4. API-endepunkter
|
## 4. API-endepunkter
|
||||||
|
|
||||||
| Metode | Sti | Beskrivelse |
|
| Metode | Sti | Beskrivelse |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/kanban/[boardId]` | Hent brett med kolonner og kort |
|
| GET | `/query/board?board_id=...` | Hent brett med kort, status og posisjon |
|
||||||
| POST | `/api/kanban/[boardId]/columns` | Opprett kolonne |
|
| POST | `/intentions/create_node` | Opprett kort (content-node) |
|
||||||
| POST | `/api/kanban/[boardId]/cards` | Opprett kort (oppretter node + kort) |
|
| POST | `/intentions/create_edge` | Koble kort til brett (belongs_to + status) |
|
||||||
| PATCH | `/api/kanban/[boardId]/cards/[cardId]` | Oppdater tittel/beskrivelse |
|
| POST | `/intentions/update_edge` | Endre status ved drag-and-drop |
|
||||||
| PATCH | `/api/kanban/[boardId]/cards/[cardId]/move` | Flytt kort til kolonne/posisjon |
|
| POST | `/intentions/update_node` | Oppdater tittel/beskrivelse |
|
||||||
| DELETE | `/api/kanban/[boardId]/cards/[cardId]` | Slett kort (cascader fra node) |
|
| POST | `/intentions/delete_node` | Slett kort (cascader edges) |
|
||||||
|
|
||||||
## 5. Brukes av
|
## 5. Brukes av
|
||||||
|
|
||||||
|
|
@ -50,10 +61,11 @@ Kort og brett er nodes — tilgang styres via `node_access`-matrisen.
|
||||||
|---|---|
|
|---|---|
|
||||||
| Redaksjonen | Episodeplanlegging — dra Temaer inn i Kjøreplanen |
|
| Redaksjonen | Episodeplanlegging — dra Temaer inn i Kjøreplanen |
|
||||||
| Møterommet | AI-referenten foreslår nye kort basert på action points |
|
| 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
|
## 6. Instruks for Claude Code
|
||||||
* Bruk native HTML5 Drag and Drop i SvelteKit, unngå tunge biblioteker.
|
* Alt er noder og edges — ingen separate kanban-tabeller.
|
||||||
* PG-adapter er autoritativ inntil SpacetimeDB-sync er på plass.
|
* 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.
|
* Tilgang styres via `node_access`-matrisen.
|
||||||
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi.
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,67 @@ export function createEdge(
|
||||||
return post(accessToken, '/intentions/create_edge', data);
|
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 {
|
export interface CreateCommunicationRequest {
|
||||||
title?: string;
|
title?: string;
|
||||||
participants?: string[];
|
participants?: string[];
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,46 @@
|
||||||
return null;
|
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);
|
let showNewChatDialog = $state(false);
|
||||||
|
|
||||||
/** Open the new chat dialog to pick a participant */
|
/** Open the new chat dialog to pick a participant */
|
||||||
|
|
@ -238,12 +278,21 @@
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
||||||
{#if connected && accessToken}
|
{#if connected && accessToken}
|
||||||
|
<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
|
<button
|
||||||
onclick={handleNewChat}
|
onclick={handleNewChat}
|
||||||
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Ny samtale
|
Ny samtale
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -268,7 +317,30 @@
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
{#each mottaksnoder as node (node.id)}
|
{#each mottaksnoder as node (node.id)}
|
||||||
{@const vis = nodeVisibility(node, nodeId)}
|
{@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">
|
<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">
|
<a href="/chat/{node.id}" class="block p-4">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
|
|
||||||
366
frontend/src/routes/board/[id]/+page.svelte
Normal file
366
frontend/src/routes/board/[id]/+page.svelte
Normal 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">← 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>
|
||||||
|
|
@ -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.
|
/// Sjekker om brukeren har skrivetilgang til en edge.
|
||||||
/// Brukeren må ha opprettet edgen (direkte eller via alias),
|
/// Brukeren må ha opprettet edgen (direkte eller via alias),
|
||||||
/// eller ha owner/admin-edge til source-noden.
|
/// 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> {
|
async fn user_can_modify_edge(db: &PgPool, user_id: Uuid, edge_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||||
let row = sqlx::query_scalar::<_, bool>(
|
let row = sqlx::query_scalar::<_, bool>(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -726,6 +725,119 @@ pub async fn delete_node(
|
||||||
Ok(Json(DeleteNodeResponse { deleted: true }))
|
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
|
// create_communication
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -136,10 +136,12 @@ async fn main() {
|
||||||
.route("/intentions/create_edge", post(intentions::create_edge))
|
.route("/intentions/create_edge", post(intentions::create_edge))
|
||||||
.route("/intentions/update_node", post(intentions::update_node))
|
.route("/intentions/update_node", post(intentions::update_node))
|
||||||
.route("/intentions/delete_node", post(intentions::delete_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/create_communication", post(intentions::create_communication))
|
||||||
.route("/intentions/upload_media", post(intentions::upload_media))
|
.route("/intentions/upload_media", post(intentions::upload_media))
|
||||||
.route("/cas/{hash}", get(serving::get_cas_file))
|
.route("/cas/{hash}", get(serving::get_cas_file))
|
||||||
.route("/query/nodes", get(queries::query_nodes))
|
.route("/query/nodes", get(queries::query_nodes))
|
||||||
|
.route("/query/board", get(queries::query_board))
|
||||||
.route("/query/segments", get(queries::query_segments))
|
.route("/query/segments", get(queries::query_segments))
|
||||||
.route("/query/segments/srt", get(queries::export_srt))
|
.route("/query/segments/srt", get(queries::export_srt))
|
||||||
.route("/intentions/create_alias", post(intentions::create_alias))
|
.route("/intentions/create_alias", post(intentions::create_alias))
|
||||||
|
|
|
||||||
|
|
@ -606,6 +606,172 @@ pub async fn query_aliases(
|
||||||
Ok(Json(QueryAliasesResponse { 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -109,8 +109,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
|
|
||||||
## Fase 9: Flere visninger
|
## Fase 9: Flere visninger
|
||||||
|
|
||||||
- [~] 9.1 Kanban-visning: noder med board-edge, gruppert på status-edge. Drag-and-drop for statusendring.
|
- [x] 9.1 Kanban-visning: noder med board-edge, gruppert på status-edge. Drag-and-drop for statusendring.
|
||||||
> Påbegynt: 2026-03-17T22:05
|
|
||||||
- [ ] 9.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje.
|
- [ ] 9.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje.
|
||||||
- [ ] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid.
|
- [ ] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid.
|
||||||
- [ ] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
|
- [ ] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue