Redaksjonell samtale (oppgave 14.13): kommunikasjonsnode knyttet til artikkel

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) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 02:22:17 +00:00
parent 7bdcdafa24
commit 6125302bcb
6 changed files with 177 additions and 4 deletions

View file

@ -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

View file

@ -182,6 +182,8 @@ export interface EditorialCard {
status: string;
submitted_to_edge_id: string;
edge_metadata: Record<string, unknown>;
/** 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<string, unknown>;
/** Kontekst-node (f.eks. artikkel). Gir automatisk belongs_to-edge. */
context_id?: string;
}
export interface CreateCommunicationResponse {

View file

@ -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<string, unknown> | 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<string, unknown>;
createdAt: string;
discussionIds: string[];
}
function parseEdgeMeta(edge: Edge): Record<string, unknown> {
@ -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<string | null>(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 @@
</div>
{/if}
<!-- Redaksjonell samtale -->
{#if card.discussionIds.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each card.discussionIds as discId}
<a
href="/chat/{discId}"
class="inline-flex items-center gap-1 rounded bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700 hover:bg-indigo-100"
>
Samtale
</a>
{/each}
</div>
{:else}
<button
onclick={() => startDiscussion(card)}
disabled={creatingDiscussion === card.nodeId}
class="mt-2 inline-flex items-center gap-1 rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-50 hover:text-gray-700 disabled:opacity-50"
>
{creatingDiscussion === card.nodeId ? 'Oppretter…' : 'Start samtale'}
</button>
{/if}
{#if card.publishAt}
<div class="mt-2 flex items-center gap-1 text-xs text-purple-600">
<span>Publiseres: {formatDateTime(card.publishAt)}</span>

View file

@ -1570,6 +1570,9 @@ pub struct CreateCommunicationRequest {
pub participants: Vec<Uuid>,
/// Synlighet. Default: "hidden" (privat).
pub visibility: Option<String>,
/// Kontekst-node (f.eks. artikkel). Gir automatisk belongs_to-edge
/// fra kommunikasjonsnoden til kontekstnoden.
pub context_id: Option<Uuid>,
}
#[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(),

View file

@ -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<Uuid>,
}
#[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<Uuid> = 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<Uuid, Vec<Uuid>> =
std::collections::HashMap::new();
for (comm_id, article_id) in discussions {
discussion_map.entry(article_id).or_default().push(comm_id);
}
let cards: Vec<EditorialCard> = 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();

View file

@ -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".