diff --git a/docs/features/kanban.md b/docs/features/kanban.md index 173aee3..21a9895 100644 --- a/docs/features/kanban.md +++ b/docs/features/kanban.md @@ -55,11 +55,30 @@ brukere med `owner`/`admin`/`member_of`-edge til board-noden. | POST | `/intentions/update_node` | Oppdater tittel/beskrivelse | | POST | `/intentions/delete_node` | Slett kort (cascader edges) | -## 5. Brukes av +## 5. Redaksjonell arbeidsflate (Editorial Board) + +Kanban-mønsteret gjenbrukes for redaktørens arbeidsflate (`/editorial/[id]`), +men med en viktig forskjell: den bruker `submitted_to`-edges i stedet for +`belongs_to` + `status`-edges. Status lever direkte i `submitted_to`-edge-metadata. + +**Kolonner:** Innkomne (pending), Under vurdering (in_review), Godkjent (approved), Planlagt (scheduled) + +**Planlagt-kolonnen:** Når en artikkel dras til "Planlagt", åpnes en dialog +for å sette `publish_at` i edge-metadata. Statusen i edge er fortsatt `approved`, +men `publish_at` skiller planlagte fra godkjente. + +**Backend:** `GET /query/editorial_board?collection_id=...` — henter alle noder +med `submitted_to`-edge til samlingen, inkludert forfatterinfo. + +**Tilgang:** Kun `owner`/`admin` av samlingen kan dra kort mellom kolonner +(statusendring). Tilgang kontrollert i `update_edge`. + +## 6. Brukes av | Konsept | Bruk | |---|---| | Redaksjonen | Episodeplanlegging — dra Temaer inn i Kjøreplanen | +| Redaksjonen | Redaksjonell arbeidsflate — innsendinger gruppert på status | | Møterommet | AI-referenten foreslår nye kort basert på action points | ## 6. Instruks for Claude Code diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8557575..97ec137 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -166,6 +166,47 @@ export async function fetchBoard(accessToken: string, boardId: string): Promise< return res.json(); } +// ============================================================================= +// Redaksjonell arbeidsflate (Editorial Board) +// ============================================================================= + +export interface EditorialCard { + node_id: string; + title: string | null; + content: string | null; + node_kind: string; + metadata: Record; + created_at: string; + created_by: string | null; + author_name: string | null; + status: string; + submitted_to_edge_id: string; + edge_metadata: Record; +} + +export interface EditorialBoardResponse { + collection_id: string; + collection_title: string | null; + columns: string[]; + column_labels: Record; + cards: EditorialCard[]; +} + +export async function fetchEditorialBoard( + accessToken: string, + collectionId: string +): Promise { + const res = await fetch( + `${BASE_URL}/query/editorial_board?collection_id=${encodeURIComponent(collectionId)}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + if (!res.ok) { + const body = await res.text(); + throw new Error(`editorial_board failed (${res.status}): ${body}`); + } + return res.json(); +} + // ============================================================================= // Kommunikasjon // ============================================================================= diff --git a/frontend/src/lib/components/traits/PublishingTrait.svelte b/frontend/src/lib/components/traits/PublishingTrait.svelte index f26a6c2..545a441 100644 --- a/frontend/src/lib/components/traits/PublishingTrait.svelte +++ b/frontend/src/lib/components/traits/PublishingTrait.svelte @@ -14,6 +14,7 @@ const customDomain = $derived((config.custom_domain as string) ?? ''); const indexMode = $derived((config.index_mode as string) ?? 'dynamic'); const featuredMax = $derived((config.featured_max as number) ?? 4); + const requireApproval = $derived((config.require_approval as boolean) ?? false); @@ -42,6 +43,14 @@
+ {#if requireApproval} + + Redaksjonell arbeidsflate + + {/if} + import { page } from '$app/stores'; + import { connectionState, nodeStore, edgeStore } from '$lib/spacetime'; + import type { Edge } from '$lib/spacetime'; + import { updateEdge } from '$lib/api'; + + const session = $derived($page.data.session as Record | undefined); + const nodeId = $derived(session?.nodeId as string | undefined); + const accessToken = $derived(session?.accessToken as string | undefined); + const connected = $derived(connectionState.current === 'connected'); + const collectionId = $derived($page.params.id ?? ''); + + const collectionNode = $derived(connected ? nodeStore.get(collectionId) : undefined); + + // ========================================================================= + // Kolonner — faste for redaksjonell arbeidsflate + // ========================================================================= + + const columns = ['pending', 'in_review', 'approved', 'scheduled'] as const; + type Column = typeof columns[number]; + + const columnLabels: Record = { + pending: 'Innkomne', + in_review: 'Under vurdering', + approved: 'Godkjent', + scheduled: 'Planlagt' + }; + + const columnColors: Record = { + pending: 'bg-amber-50', + in_review: 'bg-blue-50', + approved: 'bg-green-50', + scheduled: 'bg-purple-50' + }; + + const columnHeaderColors: Record = { + pending: 'text-amber-700', + in_review: 'text-blue-700', + approved: 'text-green-700', + scheduled: 'text-purple-700' + }; + + // ========================================================================= + // Kort fra SpacetimeDB — noder med submitted_to-edge til samlingen + // ========================================================================= + + interface CardData { + nodeId: string; + title: string; + content: string | null; + authorName: string | null; + status: Column; + submittedAt: string | null; + publishAt: string | null; + feedback: string | null; + edgeId: string; + edgeMeta: Record; + createdAt: string; + } + + function parseEdgeMeta(edge: Edge): Record { + try { + return JSON.parse(edge.metadata ?? '{}') as Record; + } catch { + return {}; + } + } + + const cards = $derived.by((): CardData[] => { + if (!connected || !collectionId) return []; + + const result: CardData[] = []; + + for (const edge of edgeStore.byTarget(collectionId)) { + if (edge.edgeType !== 'submitted_to') continue; + + const node = nodeStore.get(edge.sourceId); + if (!node) continue; + + const meta = parseEdgeMeta(edge); + const rawStatus = (meta.status as string) ?? 'pending'; + + // Map "approved" with publish_at to "scheduled" + let status: Column; + if (rawStatus === 'approved' && meta.publish_at) { + status = 'scheduled'; + } else if (columns.includes(rawStatus as Column)) { + status = rawStatus as Column; + } else { + status = 'pending'; + } + + // Resolve author name + let authorName: string | null = null; + if (node.createdBy) { + const authorNode = nodeStore.get(node.createdBy); + if (authorNode) authorName = authorNode.title; + } + + result.push({ + nodeId: node.id, + title: node.title || 'Uten tittel', + content: node.content, + authorName, + status, + submittedAt: (meta.submitted_at as string) ?? null, + publishAt: (meta.publish_at as string) ?? null, + feedback: (meta.feedback as string) ?? null, + edgeId: edge.id, + edgeMeta: meta, + createdAt: node.createdAt ?? '' + }); + } + + return result; + }); + + const cardsByColumn = $derived.by(() => { + const grouped: Record = { + pending: [], + in_review: [], + approved: [], + scheduled: [] + }; + + for (const card of cards) { + grouped[card.status].push(card); + } + + // Sort each column: newest first for pending, oldest first for others + grouped.pending.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + for (const col of ['in_review', 'approved', 'scheduled'] as Column[]) { + grouped[col].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } + + return grouped; + }); + + // ========================================================================= + // Drag and drop + // ========================================================================= + + let draggedCard = $state(null); + let dragOverColumn = $state(null); + + function handleDragStart(e: DragEvent, card: CardData) { + draggedCard = card; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', card.nodeId); + } + } + + function handleDragOver(e: DragEvent, column: Column) { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + dragOverColumn = column; + } + + function handleDragLeave() { + dragOverColumn = null; + } + + async function handleDrop(e: DragEvent, targetColumn: Column) { + e.preventDefault(); + dragOverColumn = null; + + if (!draggedCard || !accessToken) return; + if (draggedCard.status === targetColumn) { + draggedCard = null; + return; + } + + const card = draggedCard; + draggedCard = null; + + // "Planlagt" kolonne → vis publish_at-dialog + if (targetColumn === 'scheduled') { + schedulingCard = card; + // Pre-fill with tomorrow at 08:00 + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(8, 0, 0, 0); + scheduledDate = tomorrow.toISOString().slice(0, 16); + return; + } + + try { + // Map column back to actual status for the edge + const newStatus = targetColumn === 'scheduled' ? 'approved' : targetColumn; + + const newMeta: Record = { ...card.edgeMeta, status: newStatus }; + + // If moving away from scheduled, remove publish_at + if (card.status === 'scheduled' && targetColumn !== 'scheduled') { + delete newMeta.publish_at; + } + + await updateEdge(accessToken, { + edge_id: card.edgeId, + metadata: newMeta + }); + } catch (err) { + console.error('Feil ved statusendring:', err); + } + } + + function handleDragEnd() { + draggedCard = null; + dragOverColumn = null; + } + + // ========================================================================= + // Planlagt publisering — publish_at dialog + // ========================================================================= + + let schedulingCard = $state(null); + let scheduledDate = $state(''); + let isScheduling = $state(false); + + async function confirmSchedule() { + if (!schedulingCard || !accessToken || !scheduledDate) return; + + isScheduling = true; + try { + const publishAt = new Date(scheduledDate).toISOString(); + const newMeta: Record = { + ...schedulingCard.edgeMeta, + status: 'approved', + publish_at: publishAt + }; + + await updateEdge(accessToken, { + edge_id: schedulingCard.edgeId, + metadata: newMeta + }); + + schedulingCard = null; + scheduledDate = ''; + } catch (err) { + console.error('Feil ved planlegging:', err); + } finally { + isScheduling = false; + } + } + + function cancelSchedule() { + schedulingCard = null; + scheduledDate = ''; + } + + // ========================================================================= + // Helpers + // ========================================================================= + + function formatDate(iso: string | null): string { + if (!iso) return ''; + try { + const d = new Date(iso); + return d.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short', year: 'numeric' }); + } catch { + return ''; + } + } + + function formatDateTime(iso: string | null): string { + if (!iso) return ''; + try { + const d = new Date(iso); + return d.toLocaleDateString('nb-NO', { + day: 'numeric', month: 'short', year: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + } catch { + return ''; + } + } + + +
+ +
+
+
+ ← Samling +

+ {collectionNode?.title || 'Redaksjonell arbeidsflate'} +

+ Redaksjon +
+
+ {#if connected} + Tilkoblet + {:else} + {connectionState.current} + {/if} + {cards.length} innsendinger +
+
+
+ + +
+ {#if !connected} +

Venter på tilkobling…

+ {:else if !collectionNode} +
+ Samling ikke funnet. Sjekk at du har tilgang. +
+ {:else} +
+ {#each columns as column (column)} + {@const colCards = cardsByColumn[column]} +
handleDragOver(e, column)} + ondragleave={handleDragLeave} + ondrop={(e: DragEvent) => handleDrop(e, column)} + role="list" + aria-label={columnLabels[column]} + > + +
+

+ {columnLabels[column]} +

+ + {colCards.length} + +
+ + +
+ {#each colCards as card (card.edgeId)} +
handleDragStart(e, card)} + ondragend={handleDragEnd} + role="listitem" + > +

+ {card.title} +

+ + {#if card.content} +

+ {card.content} +

+ {/if} + +
+ {#if card.authorName} + + {card.authorName} + + {/if} + {#if card.submittedAt} + + {formatDate(card.submittedAt)} + + {/if} +
+ + {#if card.feedback} +
+

{card.feedback}

+
+ {/if} + + {#if card.publishAt} +
+ Publiseres: {formatDateTime(card.publishAt)} +
+ {/if} +
+ {/each} + + {#if colCards.length === 0} +
+ {#if column === 'pending'} + Ingen ventende innsendinger + {:else if column === 'scheduled'} + Dra godkjente artikler hit for å planlegge publisering + {:else} + Ingen artikler her + {/if} +
+ {/if} +
+
+ {/each} +
+ {/if} +
+
+ + +{#if schedulingCard} +
+
+

Planlegg publisering

+

+ {schedulingCard.title} +

+ + + +
+ + +
+
+
+{/if} + + diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 2cc0f67..3cc543d 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -159,6 +159,7 @@ async fn main() { .route("/cas/{hash}", get(serving::get_cas_file)) .route("/query/nodes", get(queries::query_nodes)) .route("/query/board", get(queries::query_board)) + .route("/query/editorial_board", get(queries::query_editorial_board)) .route("/query/segments", get(queries::query_segments)) .route("/query/segments/srt", get(queries::export_srt)) .route("/intentions/create_alias", post(intentions::create_alias)) diff --git a/maskinrommet/src/queries.rs b/maskinrommet/src/queries.rs index ce5149e..3f8804e 100644 --- a/maskinrommet/src/queries.rs +++ b/maskinrommet/src/queries.rs @@ -772,6 +772,181 @@ pub async fn query_board( })) } +// ============================================================================= +// GET /query/editorial_board — redaktørens arbeidsflate +// ============================================================================= +// +// Viser noder med submitted_to-edge til en samling, gruppert på status. +// Brukes av frontend for Kanban-visning av redaksjonelle innsendinger. + +#[derive(Deserialize)] +pub struct QueryEditorialBoardRequest { + /// Samlings-nodens ID (target for submitted_to-edges). + pub collection_id: Uuid, +} + +#[derive(Serialize)] +pub struct EditorialCard { + pub node_id: Uuid, + pub title: Option, + pub content: Option, + pub node_kind: String, + pub metadata: serde_json::Value, + pub created_at: chrono::DateTime, + pub created_by: Option, + /// Forfatterens navn (title fra person-noden) + pub author_name: Option, + /// Status fra submitted_to-edge metadata + pub status: String, + /// submitted_to-edge ID + pub submitted_to_edge_id: Uuid, + /// Full metadata fra submitted_to-edge (inkl. feedback, publish_at, etc.) + pub edge_metadata: serde_json::Value, +} + +#[derive(Serialize)] +pub struct QueryEditorialBoardResponse { + pub collection_id: Uuid, + pub collection_title: Option, + /// Faste kolonner for redaksjonell arbeidsflate + pub columns: Vec, + /// Visningsnavn for kolonnene + pub column_labels: std::collections::HashMap, + pub cards: Vec, +} + +/// GET /query/editorial_board?collection_id=... +/// +/// Henter alle noder med submitted_to-edge til en samling, +/// inkludert status, metadata og forfatterinfo. +pub async fn query_editorial_board( + State(state): State, + user: AuthUser, + axum::extract::Query(params): axum::extract::Query, +) -> Result, (StatusCode, Json)> { + // Verifiser tilgang til samlings-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") + })?; + + let collection = sqlx::query_as::<_, (Option,)>( + "SELECT title FROM nodes WHERE id = $1", + ) + .bind(params.collection_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| { + tracing::error!(error = %e, "Feil ved henting av samlings-node"); + internal_error("Databasefeil") + })?; + + tx.commit().await.map_err(|e| { + tracing::error!(error = %e, "Commit feilet"); + internal_error("Databasefeil") + })?; + + let Some((collection_title,)) = collection else { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!( + "Samling {} finnes ikke eller du har ikke tilgang", + params.collection_id + ), + }), + )); + }; + + // Hent alle noder med submitted_to-edge til denne samlingen, + // pluss forfatterens navn via created_by → person-node + let rows = sqlx::query_as::<_, ( + Uuid, // n.id + String, // n.node_kind + Option, // n.title + Option, // n.content + serde_json::Value, // n.metadata + chrono::DateTime, // n.created_at + Option, // n.created_by + Option, // author.title (forfatterens navn) + Uuid, // e.id (submitted_to edge) + serde_json::Value, // e.metadata + )>( + r#" + SELECT + n.id, n.node_kind, n.title, n.content, n.metadata, + n.created_at, n.created_by, + author.title AS author_name, + e.id AS edge_id, e.metadata AS edge_metadata + FROM edges e + JOIN nodes n ON n.id = e.source_id + LEFT JOIN nodes author ON author.id = n.created_by + AND author.node_kind = 'person' + WHERE e.target_id = $1 + AND e.edge_type = 'submitted_to' + ORDER BY e.created_at DESC + "#, + ) + .bind(params.collection_id) + .fetch_all(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Feil ved henting av editorial-kort"); + internal_error("Databasefeil ved henting av innsendinger") + })?; + + let cards: Vec = rows + .into_iter() + .map(|(node_id, node_kind, title, content, metadata, created_at, created_by, + author_name, edge_id, edge_metadata)| { + let status = edge_metadata + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending") + .to_string(); + + EditorialCard { + node_id, + title, + content, + node_kind, + metadata, + created_at, + created_by, + author_name, + status, + submitted_to_edge_id: edge_id, + edge_metadata, + } + }) + .collect(); + + let columns = vec![ + "pending".to_string(), + "in_review".to_string(), + "approved".to_string(), + "scheduled".to_string(), + ]; + + let mut column_labels = std::collections::HashMap::new(); + column_labels.insert("pending".to_string(), "Innkomne".to_string()); + column_labels.insert("in_review".to_string(), "Under vurdering".to_string()); + column_labels.insert("approved".to_string(), "Godkjent".to_string()); + column_labels.insert("scheduled".to_string(), "Planlagt".to_string()); + + Ok(Json(QueryEditorialBoardResponse { + collection_id: params.collection_id, + collection_title, + columns, + column_labels, + cards, + })) +} + // ============================================================================= // GET /query/graph — graf-traversering fra en fokusnode // ============================================================================= diff --git a/tasks.md b/tasks.md index 3bafb30..147a665 100644 --- a/tasks.md +++ b/tasks.md @@ -153,8 +153,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 14.8 RSS/Atom-feed: samling med `rss`-trait genererer feed automatisk ved publisering/avpublisering. `synops.no/pub/{slug}/feed.xml`. Maks `rss_max_items` (default 50). - [x] 14.9 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL. - [x] 14.10 Redaksjonell innsending: `submitted_to`-edge med status-metadata (`pending`, `in_review`, `revision_requested`, `rejected`, `approved`). Maskinrommet validerer at kun roller i `submission_roles` kan opprette `submitted_to`, og kun owner/admin kan endre status eller opprette `belongs_to`. Ref: `docs/concepts/publisering.md` § "Innsending". -- [~] 14.11 Redaktørens arbeidsflate: frontend-visning av noder med `submitted_to`-edge til samling, gruppert på status. Kanban-stil drag-and-drop for statusendring. Siste kolonne ("Planlagt") setter `publish_at` i edge-metadata. - > Påbegynt: 2026-03-18T02:02 +- [x] 14.11 Redaktørens arbeidsflate: frontend-visning av noder med `submitted_to`-edge til samling, gruppert på status. Kanban-stil drag-and-drop for statusendring. Siste kolonne ("Planlagt") setter `publish_at` i edge-metadata. - [ ] 14.12 Planlagt publisering: maskinrommet sjekker periodisk (cron/intervall) for `belongs_to`-edges med `publish_at` i fortiden som ikke er rendret. Ved treff: render HTML → CAS → oppdater RSS. - [ ] 14.13 Redaksjonell samtale: ved innsending kan redaktør opprette kommunikasjonsnode knyttet til artikkel + forfatter for diskusjon/feedback utover kort notat i edge-metadata. - [ ] 14.14 Bulk re-rendering: batch-jobb via jobbkø ved temaendring. Paginert (100 artikler om gangen), oppdaterer `renderer_version`. Artikler serveres med gammelt tema til re-rendret.