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:
parent
7bdcdafa24
commit
6125302bcb
6 changed files with 177 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
3
tasks.md
3
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".
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue