diff --git a/migrations/0002_kanban.sql b/migrations/0002_kanban.sql
new file mode 100644
index 0000000..f0402b3
--- /dev/null
+++ b/migrations/0002_kanban.sql
@@ -0,0 +1,63 @@
+-- Kanban: brett, kolonner og kort
+-- Kort er nodes i kunnskapsgrafen (kan kobles til temaer/aktører senere).
+-- Kolonner er IKKE nodes — de er intern struktur for brettet.
+-- Posisjon bruker REAL for midtpunkts-innsetting uten re-nummerering.
+
+BEGIN;
+
+-- ============================================================
+-- Kanban boards (er nodes → arver workspace via nodes-tabellen)
+-- ============================================================
+
+CREATE TABLE kanban_boards (
+ id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
+ parent_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
+ name TEXT NOT NULL DEFAULT 'Kanban'
+);
+
+CREATE INDEX idx_kanban_boards_parent ON kanban_boards(parent_id);
+
+-- ============================================================
+-- Kolonner
+-- ============================================================
+
+CREATE TABLE kanban_columns (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ board_id UUID NOT NULL REFERENCES kanban_boards(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ color TEXT, -- valgfri hex-fargekode (#ff6b6b)
+ position REAL NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_kanban_columns_board ON kanban_columns(board_id, position);
+
+-- ============================================================
+-- Kort (er nodes → kan kobles i grafen, arver workspace)
+-- ============================================================
+
+CREATE TABLE kanban_cards (
+ id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
+ column_id UUID NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ description TEXT,
+ assignee_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
+ position REAL NOT NULL DEFAULT 0,
+ created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_kanban_cards_column ON kanban_cards(column_id, position);
+
+CREATE TRIGGER trg_kanban_cards_updated_at BEFORE UPDATE ON kanban_cards
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- ============================================================
+-- Utvid node_type enum med 'kanban_board' og 'kanban_card'
+-- ============================================================
+
+ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'kanban_board';
+ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'kanban_card';
+
+COMMIT;
diff --git a/migrations/seed_dev.sql b/migrations/seed_dev.sql
index 1514f3f..d7a5327 100644
--- a/migrations/seed_dev.sql
+++ b/migrations/seed_dev.sql
@@ -34,6 +34,84 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES
INSERT INTO channels (id, parent_id, name) VALUES
('a0000000-0000-0000-0000-000000000012', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonen');
+-- =============================================
+-- Workspace 2: Foreningen Liberalistene
+-- =============================================
+INSERT INTO workspaces (id, name, slug) VALUES
+ ('b0000000-0000-0000-0000-000000000001', 'Foreningen Liberalistene', 'forlib');
+
+-- Vegard er medlem av begge workspaces
+INSERT INTO workspace_members (workspace_id, user_id, role) VALUES
+ ('b0000000-0000-0000-0000-000000000001', '6af61f43c6647a237cbb381ee7788376a9bc20299c2c06281d9954d763e854f0', 'owner'),
+ ('b0000000-0000-0000-0000-000000000001', 'dev-user-1', 'owner');
+
+-- Rot-node for Liberalistene
+INSERT INTO nodes (id, workspace_id, node_type) VALUES
+ ('b0000000-0000-0000-0000-000000000010', 'b0000000-0000-0000-0000-000000000001', 'channel');
+
+-- Chat-kanaler
+INSERT INTO nodes (id, workspace_id, node_type) VALUES
+ ('b0000000-0000-0000-0000-000000000011', 'b0000000-0000-0000-0000-000000000001', 'channel');
+INSERT INTO channels (id, parent_id, name) VALUES
+ ('b0000000-0000-0000-0000-000000000011', 'b0000000-0000-0000-0000-000000000010', 'Generelt');
+
+INSERT INTO nodes (id, workspace_id, node_type) VALUES
+ ('b0000000-0000-0000-0000-000000000012', 'b0000000-0000-0000-0000-000000000001', 'channel');
+INSERT INTO channels (id, parent_id, name) VALUES
+ ('b0000000-0000-0000-0000-000000000012', 'b0000000-0000-0000-0000-000000000010', 'Styret');
+
+-- Kanban-brett for Liberalistene
+INSERT INTO nodes (id, workspace_id, node_type) VALUES
+ ('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000001', 'kanban_board');
+INSERT INTO kanban_boards (id, parent_id, name) VALUES
+ ('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000010', 'Oppgaver');
+INSERT INTO kanban_columns (id, board_id, name, color, position) VALUES
+ ('b0000000-0000-0000-0000-000000000021', 'b0000000-0000-0000-0000-000000000020', 'Å gjøre', '#8b92a5', 1),
+ ('b0000000-0000-0000-0000-000000000022', 'b0000000-0000-0000-0000-000000000020', 'Pågår', '#f59e0b', 2),
+ ('b0000000-0000-0000-0000-000000000023', 'b0000000-0000-0000-0000-000000000020', 'Ferdig', '#10b981', 3);
+
+-- Sider for Liberalistene
+UPDATE workspaces SET settings = jsonb_set(
+ COALESCE(settings, '{}'::jsonb),
+ '{pages}',
+ '[
+ {
+ "slug": "styrearbeid",
+ "title": "Styrearbeid",
+ "icon": "🏛️",
+ "layout": "2-1",
+ "blocks": [
+ {"id": "chat-lib-1", "type": "chat", "title": "Styrechat", "props": {"channelId": "b0000000-0000-0000-0000-000000000012"}},
+ {"id": "kanban-lib-1", "type": "kanban", "title": "Oppgaver", "props": {"boardId": "b0000000-0000-0000-0000-000000000020"}}
+ ]
+ },
+ {
+ "slug": "generelt",
+ "title": "Generelt",
+ "icon": "💬",
+ "layout": "single",
+ "blocks": [
+ {"id": "chat-lib-2", "type": "chat", "title": "Generell diskusjon", "props": {"channelId": "b0000000-0000-0000-0000-000000000011"}}
+ ]
+ }
+ ]'::jsonb
+) WHERE slug = 'forlib';
+
+-- =============================================
+-- Sidelinja: Kanban-brett for redaksjonen
+-- =============================================
+
+-- Kanban-brett for redaksjonen
+INSERT INTO nodes (id, workspace_id, node_type) VALUES
+ ('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000001', 'kanban_board');
+INSERT INTO kanban_boards (id, parent_id, name) VALUES
+ ('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000010', 'Episodeplanlegging');
+INSERT INTO kanban_columns (id, board_id, name, color, position) VALUES
+ ('a0000000-0000-0000-0000-000000000021', 'a0000000-0000-0000-0000-000000000020', 'Ideer', '#8b92a5', 1),
+ ('a0000000-0000-0000-0000-000000000022', 'a0000000-0000-0000-0000-000000000020', 'Planlagt', '#f59e0b', 2),
+ ('a0000000-0000-0000-0000-000000000023', 'a0000000-0000-0000-0000-000000000020', 'Innspilt', '#3b82f6', 3),
+ ('a0000000-0000-0000-0000-000000000024', 'a0000000-0000-0000-0000-000000000020', 'Publisert', '#10b981', 4);
+
-- Default-sider for workspace
UPDATE workspaces SET settings = jsonb_set(
COALESCE(settings, '{}'::jsonb),
@@ -46,7 +124,7 @@ UPDATE workspaces SET settings = jsonb_set(
"layout": "2-1",
"blocks": [
{"id": "chat-1", "type": "chat", "title": "Redaksjonschat", "props": {"channelId": "a0000000-0000-0000-0000-000000000012"}},
- {"id": "kanban-1", "type": "kanban", "title": "Planlegging"}
+ {"id": "kanban-1", "type": "kanban", "title": "Planlegging", "props": {"boardId": "a0000000-0000-0000-0000-000000000020"}}
]
},
{
diff --git a/web/src/lib/blocks/KanbanBlock.svelte b/web/src/lib/blocks/KanbanBlock.svelte
index 704e774..d9070a3 100644
--- a/web/src/lib/blocks/KanbanBlock.svelte
+++ b/web/src/lib/blocks/KanbanBlock.svelte
@@ -1,25 +1,552 @@
-
-
📋
-
Kanban
-
Kommer snart
-
+{#if !boardId}
+
+
Ingen kanban-brett konfigurert for denne blokken.
+
+{:else if kanban?.loading}
+
+{:else}
+
+
+ {#each columns as column (column.id)}
+
handleDragOver(e, column.id)}
+ ondragleave={handleDragLeave}
+ ondrop={(e) => handleDrop(e, column)}
+ role="list"
+ >
+
+
+ {column.name}
+
+ {column.cards.length}
+
+
+
+ {#each column.cards as card (card.id)}
+
+ {/each}
+
+
+ {/each}
+
+
+ {#if addingColumn}
+
+
+
+
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if editingCard}
+
+
e.stopPropagation()} onkeydown={handleEditKeydown} role="dialog">
+
+
+
+
+
+
+
+
+
+ {/if}
+
+ {#if kanban?.error}
+ {kanban.error}
+ {/if}
+{/if}
diff --git a/web/src/lib/components/WorkspaceSwitcher.svelte b/web/src/lib/components/WorkspaceSwitcher.svelte
index 8983225..6aad9d9 100644
--- a/web/src/lib/components/WorkspaceSwitcher.svelte
+++ b/web/src/lib/components/WorkspaceSwitcher.svelte
@@ -19,6 +19,7 @@
(open = false)}
>
{ws.name}
diff --git a/web/src/lib/kanban/create.svelte.ts b/web/src/lib/kanban/create.svelte.ts
new file mode 100644
index 0000000..625f554
--- /dev/null
+++ b/web/src/lib/kanban/create.svelte.ts
@@ -0,0 +1,10 @@
+import type { KanbanConnection } from './types';
+import { createPgKanban } from './pg.svelte';
+
+/**
+ * Factory for kanban-adapter.
+ * PG-adapter for nå, SpacetimeDB hybrid-adapter senere.
+ */
+export function createKanban(boardId: string): KanbanConnection {
+ return createPgKanban(boardId);
+}
diff --git a/web/src/lib/kanban/index.ts b/web/src/lib/kanban/index.ts
new file mode 100644
index 0000000..2a45b23
--- /dev/null
+++ b/web/src/lib/kanban/index.ts
@@ -0,0 +1,2 @@
+export type { KanbanBoard, KanbanColumn, KanbanCard, KanbanConnection } from './types';
+export { createKanban } from './create.svelte';
diff --git a/web/src/lib/kanban/pg.svelte.ts b/web/src/lib/kanban/pg.svelte.ts
new file mode 100644
index 0000000..6007ba3
--- /dev/null
+++ b/web/src/lib/kanban/pg.svelte.ts
@@ -0,0 +1,135 @@
+import type { KanbanBoard, KanbanConnection } from './types';
+
+/**
+ * Kanban PG-adapter.
+ * Henter brett med kolonner og kort via REST, poller for oppdateringer.
+ */
+export function createPgKanban(boardId: string): KanbanConnection {
+ let board = $state(null);
+ let error = $state('');
+ let loading = $state(true);
+ let timer: ReturnType | null = null;
+ let destroyed = false;
+
+ async function refresh() {
+ if (destroyed) return;
+ try {
+ const res = await fetch(`/api/kanban/${boardId}`);
+ if (!res.ok) throw new Error('Feil ved lasting');
+ board = await res.json();
+ error = '';
+ } catch {
+ error = 'Kunne ikke laste kanban-brett';
+ } finally {
+ loading = false;
+ }
+ }
+
+ async function addColumn(name: string) {
+ try {
+ const res = await fetch(`/api/kanban/${boardId}/columns`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name })
+ });
+ if (!res.ok) throw new Error('Feil ved opprettelse');
+ await refresh();
+ } catch {
+ error = 'Kunne ikke opprette kolonne';
+ }
+ }
+
+ async function addCard(columnId: string, title: string) {
+ try {
+ const res = await fetch(`/api/kanban/${boardId}/cards`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ columnId, title })
+ });
+ if (!res.ok) throw new Error('Feil ved opprettelse');
+ await refresh();
+ } catch {
+ error = 'Kunne ikke opprette kort';
+ }
+ }
+
+ async function moveCard(cardId: string, toColumnId: string, position: number) {
+ // Optimistisk oppdatering — flytt kortet lokalt først
+ if (board) {
+ const updatedColumns = board.columns.map(col => ({
+ ...col,
+ cards: col.cards.filter(c => c.id !== cardId)
+ }));
+ const card = board.columns.flatMap(c => c.cards).find(c => c.id === cardId);
+ if (card) {
+ const targetCol = updatedColumns.find(c => c.id === toColumnId);
+ if (targetCol) {
+ const movedCard = { ...card, column_id: toColumnId, position };
+ targetCol.cards.push(movedCard);
+ targetCol.cards.sort((a, b) => a.position - b.position);
+ }
+ }
+ board = { ...board, columns: updatedColumns };
+ }
+
+ try {
+ const res = await fetch(`/api/kanban/${boardId}/cards/${cardId}/move`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ columnId: toColumnId, position })
+ });
+ if (!res.ok) throw new Error('Feil ved flytting');
+ await refresh();
+ } catch {
+ error = 'Kunne ikke flytte kort';
+ await refresh(); // Reverser optimistisk oppdatering
+ }
+ }
+
+ async function updateCard(cardId: string, updates: { title?: string; description?: string | null }) {
+ try {
+ const res = await fetch(`/api/kanban/${boardId}/cards/${cardId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updates)
+ });
+ if (!res.ok) throw new Error('Feil ved oppdatering');
+ await refresh();
+ } catch {
+ error = 'Kunne ikke oppdatere kort';
+ }
+ }
+
+ async function deleteCard(cardId: string) {
+ try {
+ const res = await fetch(`/api/kanban/${boardId}/cards/${cardId}`, {
+ method: 'DELETE'
+ });
+ if (!res.ok) throw new Error('Feil ved sletting');
+ await refresh();
+ } catch {
+ error = 'Kunne ikke slette kort';
+ }
+ }
+
+ function destroy() {
+ destroyed = true;
+ if (timer) clearInterval(timer);
+ }
+
+ // Start polling
+ refresh();
+ timer = setInterval(refresh, 5000);
+
+ return {
+ get board() { return board; },
+ get error() { return error; },
+ get loading() { return loading; },
+ addColumn,
+ addCard,
+ moveCard,
+ updateCard,
+ deleteCard,
+ destroy
+ };
+}
diff --git a/web/src/lib/kanban/types.ts b/web/src/lib/kanban/types.ts
new file mode 100644
index 0000000..e8b615d
--- /dev/null
+++ b/web/src/lib/kanban/types.ts
@@ -0,0 +1,36 @@
+export interface KanbanCard {
+ id: string;
+ column_id: string;
+ title: string;
+ description: string | null;
+ assignee_id: string | null;
+ position: number;
+ created_by: string | null;
+ created_at: string;
+}
+
+export interface KanbanColumn {
+ id: string;
+ name: string;
+ color: string | null;
+ position: number;
+ cards: KanbanCard[];
+}
+
+export interface KanbanBoard {
+ id: string;
+ name: string;
+ columns: KanbanColumn[];
+}
+
+export interface KanbanConnection {
+ readonly board: KanbanBoard | null;
+ readonly error: string;
+ readonly loading: boolean;
+ addColumn(name: string): Promise;
+ addCard(columnId: string, title: string): Promise;
+ moveCard(cardId: string, toColumnId: string, position: number): Promise;
+ updateCard(cardId: string, updates: { title?: string; description?: string | null }): Promise;
+ deleteCard(cardId: string): Promise;
+ destroy(): void;
+}
diff --git a/web/src/routes/api/kanban/[boardId]/+server.ts b/web/src/routes/api/kanban/[boardId]/+server.ts
new file mode 100644
index 0000000..4453aed
--- /dev/null
+++ b/web/src/routes/api/kanban/[boardId]/+server.ts
@@ -0,0 +1,51 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { sql } from '$lib/server/db';
+
+/** GET /api/kanban/:boardId — Hent brett med kolonner og kort */
+export const GET: RequestHandler = async ({ params, locals }) => {
+ if (!locals.workspace || !locals.user) error(401);
+
+ const boardId = params.boardId;
+
+ // Verifiser at brettet tilhører workspace
+ const [board] = await sql`
+ SELECT b.id, b.name FROM kanban_boards b
+ JOIN nodes n ON n.id = b.id
+ WHERE b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
+ `;
+ if (!board) error(404, 'Brett ikke funnet');
+
+ // Hent kolonner
+ const columns = await sql`
+ SELECT id, name, color, position
+ FROM kanban_columns
+ WHERE board_id = ${boardId}
+ ORDER BY position ASC
+ `;
+
+ // Hent alle kort for brettet
+ const cards = await sql`
+ SELECT c.id, c.column_id, c.title, c.description,
+ c.assignee_id, c.position, c.created_by, c.created_at
+ FROM kanban_cards c
+ JOIN kanban_columns col ON col.id = c.column_id
+ WHERE col.board_id = ${boardId}
+ ORDER BY c.position ASC
+ `;
+
+ // Grupper kort per kolonne
+ const cardsByColumn: Record = {};
+ for (const card of cards) {
+ (cardsByColumn[card.column_id] ??= []).push(card);
+ }
+
+ return json({
+ id: board.id,
+ name: board.name,
+ columns: columns.map(col => ({
+ ...col,
+ cards: cardsByColumn[col.id] ?? []
+ }))
+ });
+};
diff --git a/web/src/routes/api/kanban/[boardId]/cards/+server.ts b/web/src/routes/api/kanban/[boardId]/cards/+server.ts
new file mode 100644
index 0000000..cb20039
--- /dev/null
+++ b/web/src/routes/api/kanban/[boardId]/cards/+server.ts
@@ -0,0 +1,46 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { sql } from '$lib/server/db';
+
+/** POST /api/kanban/:boardId/cards — Opprett nytt kort */
+export const POST: RequestHandler = async ({ params, request, locals }) => {
+ if (!locals.workspace || !locals.user) error(401);
+
+ const boardId = params.boardId;
+ const { columnId, title } = await request.json();
+
+ if (!title || typeof title !== 'string' || title.trim().length === 0) {
+ error(400, 'Korttittel kan ikke være tom');
+ }
+ if (!columnId) error(400, 'columnId er påkrevd');
+
+ // Verifiser at kolonnen tilhører dette brettet og workspace
+ const [col] = await sql`
+ SELECT c.id FROM kanban_columns c
+ JOIN kanban_boards b ON b.id = c.board_id
+ JOIN nodes n ON n.id = b.id
+ WHERE c.id = ${columnId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
+ `;
+ if (!col) error(404, 'Kolonne ikke funnet');
+
+ // Finn høyeste posisjon i kolonnen
+ const [maxPos] = await sql`
+ SELECT COALESCE(MAX(position), 0) + 1 as next_pos
+ FROM kanban_cards WHERE column_id = ${columnId}
+ `;
+
+ // Opprett node + kort i én transaksjon
+ const [card] = await sql`
+ WITH new_node AS (
+ INSERT INTO nodes (workspace_id, node_type)
+ VALUES (${locals.workspace.id}, 'kanban_card')
+ RETURNING id
+ )
+ INSERT INTO kanban_cards (id, column_id, title, position, created_by)
+ SELECT new_node.id, ${columnId}, ${title.trim()}, ${maxPos.next_pos}, ${locals.user.id}
+ FROM new_node
+ RETURNING id, column_id, title, description, assignee_id, position, created_by, created_at
+ `;
+
+ return json(card, { status: 201 });
+};
diff --git a/web/src/routes/api/kanban/[boardId]/cards/[cardId]/+server.ts b/web/src/routes/api/kanban/[boardId]/cards/[cardId]/+server.ts
new file mode 100644
index 0000000..43b7b49
--- /dev/null
+++ b/web/src/routes/api/kanban/[boardId]/cards/[cardId]/+server.ts
@@ -0,0 +1,53 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { sql } from '$lib/server/db';
+
+/** PATCH /api/kanban/:boardId/cards/:cardId — Oppdater kort */
+export const PATCH: RequestHandler = async ({ params, request, locals }) => {
+ if (!locals.workspace || !locals.user) error(401);
+
+ const { boardId, cardId } = params;
+ const updates = await request.json();
+
+ // Verifiser tilgang
+ const [card] = await sql`
+ SELECT c.id FROM kanban_cards c
+ JOIN kanban_columns col ON col.id = c.column_id
+ JOIN kanban_boards b ON b.id = col.board_id
+ JOIN nodes n ON n.id = b.id
+ WHERE c.id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
+ `;
+ if (!card) error(404, 'Kort ikke funnet');
+
+ const [updated] = await sql`
+ UPDATE kanban_cards SET
+ title = COALESCE(${updates.title ?? null}, title),
+ description = CASE WHEN ${updates.description !== undefined} THEN ${updates.description ?? null} ELSE description END
+ WHERE id = ${cardId}
+ RETURNING id, column_id, title, description, assignee_id, position, created_by, created_at
+ `;
+
+ return json(updated);
+};
+
+/** DELETE /api/kanban/:boardId/cards/:cardId — Slett kort */
+export const DELETE: RequestHandler = async ({ params, locals }) => {
+ if (!locals.workspace || !locals.user) error(401);
+
+ const { boardId, cardId } = params;
+
+ // Verifiser tilgang
+ const [card] = await sql`
+ SELECT c.id FROM kanban_cards c
+ JOIN kanban_columns col ON col.id = c.column_id
+ JOIN kanban_boards b ON b.id = col.board_id
+ JOIN nodes n ON n.id = b.id
+ WHERE c.id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
+ `;
+ if (!card) error(404, 'Kort ikke funnet');
+
+ // Slett node (cascader til kanban_cards)
+ await sql`DELETE FROM nodes WHERE id = ${cardId}`;
+
+ return new Response(null, { status: 204 });
+};
diff --git a/web/src/routes/api/kanban/[boardId]/cards/[cardId]/move/+server.ts b/web/src/routes/api/kanban/[boardId]/cards/[cardId]/move/+server.ts
new file mode 100644
index 0000000..1dd90b4
--- /dev/null
+++ b/web/src/routes/api/kanban/[boardId]/cards/[cardId]/move/+server.ts
@@ -0,0 +1,35 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { sql } from '$lib/server/db';
+
+/** PATCH /api/kanban/:boardId/cards/:cardId/move — Flytt kort til kolonne/posisjon */
+export const PATCH: RequestHandler = async ({ params, request, locals }) => {
+ if (!locals.workspace || !locals.user) error(401);
+
+ const { boardId, cardId } = params;
+ const { columnId, position } = await request.json();
+
+ if (!columnId || typeof position !== 'number') {
+ error(400, 'columnId og position er påkrevd');
+ }
+
+ // Verifiser at kort og målkolonne tilhører dette brettet og workspace
+ const [valid] = await sql`
+ SELECT 1 FROM kanban_cards c
+ JOIN kanban_columns src_col ON src_col.id = c.column_id
+ JOIN kanban_boards b ON b.id = src_col.board_id
+ JOIN nodes n ON n.id = b.id
+ JOIN kanban_columns dst_col ON dst_col.board_id = b.id AND dst_col.id = ${columnId}
+ WHERE c.id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
+ `;
+ if (!valid) error(404, 'Kort eller kolonne ikke funnet');
+
+ const [updated] = await sql`
+ UPDATE kanban_cards
+ SET column_id = ${columnId}, position = ${position}
+ WHERE id = ${cardId}
+ RETURNING id, column_id, title, position
+ `;
+
+ return json(updated);
+};
diff --git a/web/src/routes/api/kanban/[boardId]/columns/+server.ts b/web/src/routes/api/kanban/[boardId]/columns/+server.ts
new file mode 100644
index 0000000..57f0599
--- /dev/null
+++ b/web/src/routes/api/kanban/[boardId]/columns/+server.ts
@@ -0,0 +1,37 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { sql } from '$lib/server/db';
+
+/** POST /api/kanban/:boardId/columns — Opprett ny kolonne */
+export const POST: RequestHandler = async ({ params, request, locals }) => {
+ if (!locals.workspace || !locals.user) error(401);
+
+ const boardId = params.boardId;
+ const { name, color } = await request.json();
+
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
+ error(400, 'Kolonnenavn kan ikke være tomt');
+ }
+
+ // Verifiser at brettet tilhører workspace
+ const [board] = await sql`
+ SELECT b.id FROM kanban_boards b
+ JOIN nodes n ON n.id = b.id
+ WHERE b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
+ `;
+ if (!board) error(404, 'Brett ikke funnet');
+
+ // Finn høyeste posisjon og legg til etter
+ const [maxPos] = await sql`
+ SELECT COALESCE(MAX(position), 0) + 1 as next_pos
+ FROM kanban_columns WHERE board_id = ${boardId}
+ `;
+
+ const [column] = await sql`
+ INSERT INTO kanban_columns (board_id, name, color, position)
+ VALUES (${boardId}, ${name.trim()}, ${color ?? null}, ${maxPos.next_pos})
+ RETURNING id, name, color, position
+ `;
+
+ return json(column, { status: 201 });
+};