Redaktørens arbeidsflate (oppgave 14.11): Kanban-brett for innsendinger

Redaktøren ser alle artikler med submitted_to-edge til samlingen,
gruppert i fire kolonner etter status. Drag-and-drop mellom kolonner
endrer status via update_edge. Siste kolonne ("Planlagt") åpner en
dialog for å sette publish_at i edge-metadata — klar for oppgave 14.12
(planlagt publisering).

Backend: GET /query/editorial_board med forfatterinfo og edge-metadata.
Frontend: /editorial/[id] med sanntidsoppdateringer via SpacetimeDB.
Lenke fra PublishingTrait når require_approval er aktivt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 02:09:03 +00:00
parent 05b5c9e9b7
commit f1752e73f5
7 changed files with 689 additions and 3 deletions

View file

@ -55,11 +55,30 @@ brukere med `owner`/`admin`/`member_of`-edge til board-noden.
| POST | `/intentions/update_node` | Oppdater tittel/beskrivelse | | POST | `/intentions/update_node` | Oppdater tittel/beskrivelse |
| POST | `/intentions/delete_node` | Slett kort (cascader edges) | | 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 | | Konsept | Bruk |
|---|---| |---|---|
| Redaksjonen | Episodeplanlegging — dra Temaer inn i Kjøreplanen | | 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 | | Møterommet | AI-referenten foreslår nye kort basert på action points |
## 6. Instruks for Claude Code ## 6. Instruks for Claude Code

View file

@ -166,6 +166,47 @@ export async function fetchBoard(accessToken: string, boardId: string): Promise<
return res.json(); return res.json();
} }
// =============================================================================
// Redaksjonell arbeidsflate (Editorial Board)
// =============================================================================
export interface EditorialCard {
node_id: string;
title: string | null;
content: string | null;
node_kind: string;
metadata: Record<string, unknown>;
created_at: string;
created_by: string | null;
author_name: string | null;
status: string;
submitted_to_edge_id: string;
edge_metadata: Record<string, unknown>;
}
export interface EditorialBoardResponse {
collection_id: string;
collection_title: string | null;
columns: string[];
column_labels: Record<string, string>;
cards: EditorialCard[];
}
export async function fetchEditorialBoard(
accessToken: string,
collectionId: string
): Promise<EditorialBoardResponse> {
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 // Kommunikasjon
// ============================================================================= // =============================================================================

View file

@ -14,6 +14,7 @@
const customDomain = $derived((config.custom_domain as string) ?? ''); const customDomain = $derived((config.custom_domain as string) ?? '');
const indexMode = $derived((config.index_mode as string) ?? 'dynamic'); const indexMode = $derived((config.index_mode as string) ?? 'dynamic');
const featuredMax = $derived((config.featured_max as number) ?? 4); const featuredMax = $derived((config.featured_max as number) ?? 4);
const requireApproval = $derived((config.require_approval as boolean) ?? false);
</script> </script>
<TraitPanel name="publishing" label="Publisering" icon="🌐"> <TraitPanel name="publishing" label="Publisering" icon="🌐">
@ -42,6 +43,14 @@
</dl> </dl>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
{#if requireApproval}
<a
href="/editorial/{collection.id}"
class="inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
>
Redaksjonell arbeidsflate
</a>
{/if}
<a <a
href="/collection/{collection.id}/forside" href="/collection/{collection.id}/forside"
class="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700" class="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"

View file

@ -0,0 +1,442 @@
<script lang="ts">
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<string, unknown> | 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<Column, string> = {
pending: 'Innkomne',
in_review: 'Under vurdering',
approved: 'Godkjent',
scheduled: 'Planlagt'
};
const columnColors: Record<Column, string> = {
pending: 'bg-amber-50',
in_review: 'bg-blue-50',
approved: 'bg-green-50',
scheduled: 'bg-purple-50'
};
const columnHeaderColors: Record<Column, string> = {
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<string, unknown>;
createdAt: string;
}
function parseEdgeMeta(edge: Edge): Record<string, unknown> {
try {
return JSON.parse(edge.metadata ?? '{}') as Record<string, unknown>;
} 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<Column, CardData[]> = {
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<CardData | null>(null);
let dragOverColumn = $state<Column | null>(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<string, unknown> = { ...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<CardData | null>(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<string, unknown> = {
...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 '';
}
}
</script>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="border-b border-gray-200 bg-white">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/collection/{collectionId}" class="text-sm text-gray-400 hover:text-gray-600">&larr; Samling</a>
<h1 class="text-lg font-semibold text-gray-900">
{collectionNode?.title || 'Redaksjonell arbeidsflate'}
</h1>
<span class="rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-600">Redaksjon</span>
</div>
<div class="flex items-center gap-3">
{#if connected}
<span class="text-xs text-green-600">Tilkoblet</span>
{:else}
<span class="text-xs text-gray-400">{connectionState.current}</span>
{/if}
<span class="text-xs text-gray-400">{cards.length} innsendinger</span>
</div>
</div>
</header>
<!-- Board -->
<main class="overflow-x-auto p-4">
{#if !connected}
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
{:else if !collectionNode}
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
Samling ikke funnet. Sjekk at du har tilgang.
</div>
{:else}
<div class="flex gap-4" style="min-width: max-content;">
{#each columns as column (column)}
{@const colCards = cardsByColumn[column]}
<div
class="w-80 shrink-0 rounded-lg {columnColors[column]} {dragOverColumn === column ? 'ring-2 ring-blue-400' : ''}"
ondragover={(e: DragEvent) => handleDragOver(e, column)}
ondragleave={handleDragLeave}
ondrop={(e: DragEvent) => handleDrop(e, column)}
role="list"
aria-label={columnLabels[column]}
>
<!-- Column header -->
<div class="flex items-center justify-between px-3 py-2">
<h2 class="text-sm font-semibold {columnHeaderColors[column]}">
{columnLabels[column]}
</h2>
<span class="rounded-full bg-white px-2 py-0.5 text-xs text-gray-500 shadow-sm">
{colCards.length}
</span>
</div>
<!-- Cards -->
<div class="space-y-2 px-2 pb-2" style="min-height: 2rem;">
{#each colCards as card (card.edgeId)}
<div
class="cursor-grab rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-shadow hover:shadow-md active:cursor-grabbing {draggedCard?.edgeId === card.edgeId ? 'opacity-50' : ''}"
draggable="true"
ondragstart={(e: DragEvent) => handleDragStart(e, card)}
ondragend={handleDragEnd}
role="listitem"
>
<h3 class="text-sm font-medium text-gray-900">
{card.title}
</h3>
{#if card.content}
<p class="mt-1 text-xs text-gray-500 line-clamp-2">
{card.content}
</p>
{/if}
<div class="mt-2 flex items-center justify-between">
{#if card.authorName}
<span class="text-xs text-gray-400">
{card.authorName}
</span>
{/if}
{#if card.submittedAt}
<span class="text-xs text-gray-400">
{formatDate(card.submittedAt)}
</span>
{/if}
</div>
{#if card.feedback}
<div class="mt-2 rounded border-l-2 border-amber-400 bg-amber-50 px-2 py-1">
<p class="text-xs text-amber-800">{card.feedback}</p>
</div>
{/if}
{#if card.publishAt}
<div class="mt-2 flex items-center gap-1 text-xs text-purple-600">
<span>Publiseres: {formatDateTime(card.publishAt)}</span>
</div>
{/if}
</div>
{/each}
{#if colCards.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-xs text-gray-400">
{#if column === 'pending'}
Ingen ventende innsendinger
{:else if column === 'scheduled'}
Dra godkjente artikler hit for å planlegge publisering
{:else}
Ingen artikler her
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</main>
</div>
<!-- Planleggingsdialog for publish_at -->
{#if schedulingCard}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl">
<h3 class="text-base font-semibold text-gray-900">Planlegg publisering</h3>
<p class="mt-1 text-sm text-gray-500">
{schedulingCard.title}
</p>
<label class="mt-4 block">
<span class="text-sm font-medium text-gray-700">Publiseringstidspunkt</span>
<input
type="datetime-local"
bind:value={scheduledDate}
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
/>
</label>
<div class="mt-6 flex justify-end gap-2">
<button
onclick={cancelSchedule}
class="rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
>
Avbryt
</button>
<button
onclick={confirmSchedule}
disabled={isScheduling || !scheduledDate}
class="rounded-lg bg-purple-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-50"
>
{isScheduling ? 'Planlegger…' : 'Planlegg'}
</button>
</div>
</div>
</div>
{/if}
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -159,6 +159,7 @@ async fn main() {
.route("/cas/{hash}", get(serving::get_cas_file)) .route("/cas/{hash}", get(serving::get_cas_file))
.route("/query/nodes", get(queries::query_nodes)) .route("/query/nodes", get(queries::query_nodes))
.route("/query/board", get(queries::query_board)) .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", get(queries::query_segments))
.route("/query/segments/srt", get(queries::export_srt)) .route("/query/segments/srt", get(queries::export_srt))
.route("/intentions/create_alias", post(intentions::create_alias)) .route("/intentions/create_alias", post(intentions::create_alias))

View file

@ -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<String>,
pub content: Option<String>,
pub node_kind: String,
pub metadata: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
pub created_by: Option<Uuid>,
/// Forfatterens navn (title fra person-noden)
pub author_name: Option<String>,
/// 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<String>,
/// Faste kolonner for redaksjonell arbeidsflate
pub columns: Vec<String>,
/// Visningsnavn for kolonnene
pub column_labels: std::collections::HashMap<String, String>,
pub cards: Vec<EditorialCard>,
}
/// 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<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<QueryEditorialBoardRequest>,
) -> Result<Json<QueryEditorialBoardResponse>, (StatusCode, Json<ErrorResponse>)> {
// 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<String>,)>(
"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<String>, // n.title
Option<String>, // n.content
serde_json::Value, // n.metadata
chrono::DateTime<chrono::Utc>, // n.created_at
Option<Uuid>, // n.created_by
Option<String>, // 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<EditorialCard> = 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 // GET /query/graph — graf-traversering fra en fokusnode
// ============================================================================= // =============================================================================

View file

@ -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.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.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". - [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. - [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.
> Påbegynt: 2026-03-18T02:02
- [ ] 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.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.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.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.