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:
parent
05b5c9e9b7
commit
f1752e73f5
7 changed files with 689 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
</script>
|
||||
|
||||
<TraitPanel name="publishing" label="Publisering" icon="🌐">
|
||||
|
|
@ -42,6 +43,14 @@
|
|||
</dl>
|
||||
|
||||
<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
|
||||
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"
|
||||
|
|
|
|||
442
frontend/src/routes/editorial/[id]/+page.svelte
Normal file
442
frontend/src/routes/editorial/[id]/+page.svelte
Normal 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">← 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>
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
3
tasks.md
3
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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue