From 6125302bcb02b2951021a7bce57fffd200be9115 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 02:22:17 +0000 Subject: [PATCH] Redaksjonell samtale (oppgave 14.13): kommunikasjonsnode knyttet til artikkel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redaktør kan nå opprette en diskusjonstråd direkte fra den redaksjonelle arbeidsflaten, knyttet til en innsendt artikkel og dens forfatter. Backend: - create_communication utvides med context_id som oppretter belongs_to-edge fra kommunikasjonsnoden til kontekstnoden (artikkelen) - editorial_board-spørringen returnerer discussion_ids per kort (kommunikasjonsnoder med belongs_to til artikkelen) Frontend: - "Start samtale"-knapp på hvert redaksjonelt kort oppretter kommunikasjonsnode med redaktør som owner og forfatter som member, og navigerer til chatten - Eksisterende samtaler vises som lenker på kortet Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/concepts/publisering.md | 5 ++ frontend/src/lib/api.ts | 4 ++ .../src/routes/editorial/[id]/+page.svelte | 72 ++++++++++++++++++- maskinrommet/src/intentions.rs | 60 ++++++++++++++++ maskinrommet/src/queries.rs | 37 ++++++++++ tasks.md | 3 +- 6 files changed, 177 insertions(+), 4 deletions(-) diff --git a/docs/concepts/publisering.md b/docs/concepts/publisering.md index daacc2f..aae3a7b 100644 --- a/docs/concepts/publisering.md +++ b/docs/concepts/publisering.md @@ -202,6 +202,11 @@ Samtalen lever som en vanlig tråd. Meldinger er noder med `belongs_to`-edge til kommunikasjonsnoden. Når artikkelen er publisert ligger samtalehistorikken igjen som arkiv — nyttig for revisjonshistorikk. +**Implementert:** `create_communication` aksepterer `context_id` som +oppretter `belongs_to`-edge automatisk. Redaksjonell arbeidsflate viser +"Start samtale"-knapp på hver innsending og lenker til eksisterende +samtaler. Redaktøren er owner, forfatteren member. + ## Håndhevelse i maskinrommet (implementert) Maskinrommet validerer alle edge-operasjoner i `create_edge` og diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 97ec137..1aab86b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -182,6 +182,8 @@ export interface EditorialCard { status: string; submitted_to_edge_id: string; edge_metadata: Record; + /** Kommunikasjonsnoder knyttet til artikkelen (redaksjonelle samtaler) */ + discussion_ids: string[]; } export interface EditorialBoardResponse { @@ -216,6 +218,8 @@ export interface CreateCommunicationRequest { participants?: string[]; visibility?: string; metadata?: Record; + /** Kontekst-node (f.eks. artikkel). Gir automatisk belongs_to-edge. */ + context_id?: string; } export interface CreateCommunicationResponse { diff --git a/frontend/src/routes/editorial/[id]/+page.svelte b/frontend/src/routes/editorial/[id]/+page.svelte index 73596ed..312edda 100644 --- a/frontend/src/routes/editorial/[id]/+page.svelte +++ b/frontend/src/routes/editorial/[id]/+page.svelte @@ -2,7 +2,7 @@ import { page } from '$app/stores'; import { connectionState, nodeStore, edgeStore } from '$lib/spacetime'; import type { Edge } from '$lib/spacetime'; - import { updateEdge } from '$lib/api'; + import { updateEdge, createCommunication } from '$lib/api'; const session = $derived($page.data.session as Record | undefined); const nodeId = $derived(session?.nodeId as string | undefined); @@ -48,6 +48,7 @@ nodeId: string; title: string; content: string | null; + authorId: string | null; authorName: string | null; status: Column; submittedAt: string | null; @@ -56,6 +57,7 @@ edgeId: string; edgeMeta: Record; createdAt: string; + discussionIds: string[]; } function parseEdgeMeta(edge: Edge): Record { @@ -92,15 +94,27 @@ // Resolve author name let authorName: string | null = null; + const authorId = node.createdBy ?? null; if (node.createdBy) { const authorNode = nodeStore.get(node.createdBy); if (authorNode) authorName = authorNode.title; } + // Find discussion nodes (communication nodes with belongs_to to this article) + const discussionIds: string[] = []; + for (const btEdge of edgeStore.byTarget(node.id)) { + if (btEdge.edgeType !== 'belongs_to') continue; + const srcNode = nodeStore.get(btEdge.sourceId); + if (srcNode && srcNode.nodeKind === 'communication') { + discussionIds.push(srcNode.id); + } + } + result.push({ nodeId: node.id, title: node.title || 'Uten tittel', content: node.content, + authorId, authorName, status, submittedAt: (meta.submitted_at as string) ?? null, @@ -108,7 +122,8 @@ feedback: (meta.feedback as string) ?? null, edgeId: edge.id, edgeMeta: meta, - createdAt: node.createdAt ?? '' + createdAt: node.createdAt ?? '', + discussionIds }); } @@ -249,6 +264,37 @@ scheduledDate = ''; } + // ========================================================================= + // Redaksjonell samtale — opprett kommunikasjonsnode + // ========================================================================= + + let creatingDiscussion = $state(null); + + async function startDiscussion(card: CardData) { + if (!accessToken || !nodeId || creatingDiscussion) return; + + creatingDiscussion = card.nodeId; + try { + const participants: string[] = []; + if (card.authorId && card.authorId !== nodeId) { + participants.push(card.authorId); + } + + const result = await createCommunication(accessToken, { + title: `Diskusjon: ${card.title}`, + participants, + context_id: card.nodeId + }); + + // Navigate to the new discussion + window.location.href = `/chat/${result.node_id}`; + } catch (err) { + console.error('Feil ved opprettelse av samtale:', err); + } finally { + creatingDiscussion = null; + } + } + // ========================================================================= // Helpers // ========================================================================= @@ -368,6 +414,28 @@ {/if} + + {#if card.discussionIds.length > 0} +
+ {#each card.discussionIds as discId} + + Samtale + + {/each} +
+ {:else} + + {/if} + {#if card.publishAt}
Publiseres: {formatDateTime(card.publishAt)} diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 681f660..ae70463 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -1570,6 +1570,9 @@ pub struct CreateCommunicationRequest { pub participants: Vec, /// Synlighet. Default: "hidden" (privat). pub visibility: Option, + /// Kontekst-node (f.eks. artikkel). Gir automatisk belongs_to-edge + /// fra kommunikasjonsnoden til kontekstnoden. + pub context_id: Option, } #[derive(Serialize)] @@ -1739,6 +1742,63 @@ pub async fn create_communication( ); } + // -- Opprett belongs_to-edge til kontekstnode (f.eks. artikkel) -- + if let Some(context_id) = req.context_id { + let ctx_exists = node_exists(&state.db, context_id) + .await + .map_err(|e| { + tracing::error!("PG-feil ved kontekstnode-sjekk: {e}"); + internal_error("Databasefeil ved validering") + })?; + if !ctx_exists { + return Err(bad_request(&format!( + "Kontekst-node {} finnes ikke", + context_id + ))); + } + + let ctx_edge_id = Uuid::now_v7(); + edge_ids.push(ctx_edge_id); + + let ctx_edge_id_str = ctx_edge_id.to_string(); + let context_id_str = context_id.to_string(); + let ctx_metadata = serde_json::json!({}); + let ctx_metadata_str = ctx_metadata.to_string(); + + state + .stdb + .create_edge( + &ctx_edge_id_str, + &node_id_str, + &context_id_str, + "belongs_to", + &ctx_metadata_str, + false, + &created_by_str, + ) + .await + .map_err(|e| stdb_error("create_edge (belongs_to context)", e))?; + + spawn_pg_insert_edge( + state.db.clone(), + state.stdb.clone(), + state.index_cache.clone(), + ctx_edge_id, + node_id, + context_id, + "belongs_to".to_string(), + ctx_metadata, + false, + user.node_id, + ); + + tracing::info!( + communication_id = %node_id, + context_id = %context_id, + "belongs_to-edge opprettet til kontekstnode" + ); + } + tracing::info!( node_id = %node_id, edge_count = edge_ids.len(), diff --git a/maskinrommet/src/queries.rs b/maskinrommet/src/queries.rs index 3f8804e..85d31c2 100644 --- a/maskinrommet/src/queries.rs +++ b/maskinrommet/src/queries.rs @@ -802,6 +802,8 @@ pub struct EditorialCard { pub submitted_to_edge_id: Uuid, /// Full metadata fra submitted_to-edge (inkl. feedback, publish_at, etc.) pub edge_metadata: serde_json::Value, + /// ID til kommunikasjonsnode(r) knyttet til artikkelen (redaksjonell samtale) + pub discussion_ids: Vec, } #[derive(Serialize)] @@ -899,6 +901,35 @@ pub async fn query_editorial_board( internal_error("Databasefeil ved henting av innsendinger") })?; + // Samle alle artikkel-IDer for å hente diskusjoner i én spørring + let article_ids: Vec = rows.iter().map(|r| r.0).collect(); + + // Hent kommunikasjonsnoder som har belongs_to-edge til artiklene + let discussions: Vec<(Uuid, Uuid)> = if !article_ids.is_empty() { + sqlx::query_as::<_, (Uuid, Uuid)>( + r#" + SELECT e.source_id AS communication_id, e.target_id AS article_id + FROM edges e + JOIN nodes n ON n.id = e.source_id AND n.node_kind = 'communication' + WHERE e.edge_type = 'belongs_to' + AND e.target_id = ANY($1) + "#, + ) + .bind(&article_ids) + .fetch_all(&state.db) + .await + .unwrap_or_default() + } else { + vec![] + }; + + // Bygg oppslag: artikkel_id → liste med diskusjons-IDer + let mut discussion_map: std::collections::HashMap> = + std::collections::HashMap::new(); + for (comm_id, article_id) in discussions { + discussion_map.entry(article_id).or_default().push(comm_id); + } + let cards: Vec = rows .into_iter() .map(|(node_id, node_kind, title, content, metadata, created_at, created_by, @@ -909,6 +940,11 @@ pub async fn query_editorial_board( .unwrap_or("pending") .to_string(); + let discussion_ids = discussion_map + .get(&node_id) + .cloned() + .unwrap_or_default(); + EditorialCard { node_id, title, @@ -921,6 +957,7 @@ pub async fn query_editorial_board( status, submitted_to_edge_id: edge_id, edge_metadata, + discussion_ids, } }) .collect(); diff --git a/tasks.md b/tasks.md index 0d2a250..e475bec 100644 --- a/tasks.md +++ b/tasks.md @@ -155,8 +155,7 @@ Uavhengige faser kan fortsatt plukkes. - [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". - [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. - [x] 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. - > Påbegynt: 2026-03-18T02:15 +- [x] 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. - [ ] 14.15 Dynamiske sider: kategori-sider (filtrert på tag-edges), arkiv (kronologisk med månedsgruppering), søk (PG fulltekst). Alle paginerte, cachet i maskinrommet. Om-side som statisk CAS-node. - [ ] 14.16 Presentasjonselementer som noder: publisert tittel, ingress, OG-bilde, undertittel er egne noder med `title`/`summary`/`og_image`-edges til artikkelen. Frontend for å opprette/redigere varianter. Ref: `docs/concepts/publisering.md` § "Presentasjonselementer".