From a93ffc6de5f59df7440cd5b45c99669262400914 Mon Sep 17 00:00:00 2001 From: vegard Date: Sun, 15 Mar 2026 02:29:46 +0100 Subject: [PATCH] Kanban-brett: PG-adapter, API-ruter, drag & drop UI + multi-workspace seed - Migrering 0002: kanban_boards, kanban_columns, kanban_cards (REAL-posisjon) - REST API: CRUD for kolonner, kort, flytt-kort - PG polling-adapter med optimistisk UI-oppdatering - KanbanBlock: drag & drop, redigeringsmodal, enkelt kort-input - WorkspaceSwitcher: data-sveltekit-reload for korrekt workspace-bytte - Seed: Foreningen Liberalistene som andre test-workspace Co-Authored-By: Claude Opus 4.6 --- migrations/0002_kanban.sql | 63 ++ migrations/seed_dev.sql | 80 ++- web/src/lib/blocks/KanbanBlock.svelte | 549 +++++++++++++++++- .../lib/components/WorkspaceSwitcher.svelte | 1 + web/src/lib/kanban/create.svelte.ts | 10 + web/src/lib/kanban/index.ts | 2 + web/src/lib/kanban/pg.svelte.ts | 135 +++++ web/src/lib/kanban/types.ts | 36 ++ .../routes/api/kanban/[boardId]/+server.ts | 51 ++ .../api/kanban/[boardId]/cards/+server.ts | 46 ++ .../[boardId]/cards/[cardId]/+server.ts | 53 ++ .../[boardId]/cards/[cardId]/move/+server.ts | 35 ++ .../api/kanban/[boardId]/columns/+server.ts | 37 ++ 13 files changed, 1086 insertions(+), 12 deletions(-) create mode 100644 migrations/0002_kanban.sql create mode 100644 web/src/lib/kanban/create.svelte.ts create mode 100644 web/src/lib/kanban/index.ts create mode 100644 web/src/lib/kanban/pg.svelte.ts create mode 100644 web/src/lib/kanban/types.ts create mode 100644 web/src/routes/api/kanban/[boardId]/+server.ts create mode 100644 web/src/routes/api/kanban/[boardId]/cards/+server.ts create mode 100644 web/src/routes/api/kanban/[boardId]/cards/[cardId]/+server.ts create mode 100644 web/src/routes/api/kanban/[boardId]/cards/[cardId]/move/+server.ts create mode 100644 web/src/routes/api/kanban/[boardId]/columns/+server.ts 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} +

Laster...

+{: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} + + {/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 }); +};