Publiseringsflyt i frontend (oppgave 14.7)
Personlig publiseringsflyt for samlinger med publishing-trait der require_approval: false. Bruker kan publisere artikler fra mottak til samlingen, og avpublisere ved å fjerne belongs_to-edge. Backend: - Nytt delete_edge-endepunkt i maskinrommet med tilgangskontroll og automatisk forside-cache-invalidering ved avpublisering Frontend: - PublishDialog: forhåndsvisning, slug-editor, tema-info, bekreftelse - EditorTrait: publiser/avpubliser-knapper på innholdsnoder i publiseringssamlinger, velger for upubliserte artikler - deleteEdge i API-klienten Docs: - Oppdatert api_grensesnitt.md med delete_edge, update_edge, set_slot Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
703dacd6d0
commit
27d0d8db94
8 changed files with 580 additions and 16 deletions
|
|
@ -81,6 +81,20 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
|
|||
Krever tilgang: created_by eller owner/admin-edge.
|
||||
- Body (JSON): `{ node_id }`
|
||||
- Respons: `{ deleted: true }`
|
||||
- `POST /intentions/update_edge` — Oppdater eksisterende edge (partial update).
|
||||
Krever tilgang: created_by eller owner/admin-edge til source-noden.
|
||||
- Body (JSON): `{ edge_id, edge_type?, metadata? }`
|
||||
- Kun oppgitte felter endres, resten beholdes
|
||||
- Respons: `{ edge_id: "<uuid>" }`
|
||||
- `POST /intentions/delete_edge` — Slett en edge. Brukes bl.a. for avpublisering.
|
||||
Krever tilgang: created_by eller owner/admin-edge til source-noden.
|
||||
Ved fjerning av belongs_to-edge til publiseringssamling invalideres forside-cache.
|
||||
- Body (JSON): `{ edge_id }`
|
||||
- Respons: `{ deleted: true }`
|
||||
- `POST /intentions/set_slot` — Sett slot-metadata (hero/featured/strøm) på
|
||||
belongs_to-edge i publiseringssamling. Håndterer hero-erstatning og featured-overflow.
|
||||
- Body (JSON): `{ edge_id, slot, slot_order?, pinned? }`
|
||||
- Respons: `{ edge_id, displaced[] }`
|
||||
|
||||
### LiveKit / Sanntidslyd (oppgave 11.2)
|
||||
- `POST /intentions/join_communication` — Koble til sanntidslyd i en kommunikasjonsnode.
|
||||
|
|
|
|||
|
|
@ -111,6 +111,25 @@ export function updateEdge(
|
|||
return post(accessToken, '/intentions/update_edge', data);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Edge-sletting (avpublisering m.m.)
|
||||
// =============================================================================
|
||||
|
||||
export interface DeleteEdgeRequest {
|
||||
edge_id: string;
|
||||
}
|
||||
|
||||
export interface DeleteEdgeResponse {
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export function deleteEdge(
|
||||
accessToken: string,
|
||||
data: DeleteEdgeRequest
|
||||
): Promise<DeleteEdgeResponse> {
|
||||
return post(accessToken, '/intentions/delete_edge', data);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Board / Kanban
|
||||
// =============================================================================
|
||||
|
|
|
|||
183
frontend/src/lib/components/PublishDialog.svelte
Normal file
183
frontend/src/lib/components/PublishDialog.svelte
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<script lang="ts">
|
||||
import type { Node } from '$lib/spacetime';
|
||||
import { createEdge } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
/** The content node to publish */
|
||||
node: Node;
|
||||
/** The target publishing collection */
|
||||
collection: Node;
|
||||
/** Publishing trait config */
|
||||
pubConfig: Record<string, unknown>;
|
||||
/** Auth token */
|
||||
accessToken: string;
|
||||
/** Close callback */
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { node, collection, pubConfig, accessToken, onclose }: Props = $props();
|
||||
|
||||
const collectionSlug = $derived((pubConfig.slug as string) ?? '');
|
||||
const theme = $derived((pubConfig.theme as string) ?? 'default');
|
||||
|
||||
/** Auto-generate slug from title */
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[æ]/g, 'ae')
|
||||
.replace(/[ø]/g, 'oe')
|
||||
.replace(/[å]/g, 'aa')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
let articleSlug = $state(slugify(node.title ?? 'uten-tittel'));
|
||||
let isPublishing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let showPreview = $state(false);
|
||||
|
||||
const shortId = $derived(node.id.slice(0, 8));
|
||||
const publicUrl = $derived(
|
||||
collectionSlug ? `/api/pub/${collectionSlug}/${shortId}` : ''
|
||||
);
|
||||
const previewUrl = $derived(
|
||||
collectionSlug ? `/api/pub/${collectionSlug}/preview/${theme}` : ''
|
||||
);
|
||||
|
||||
async function handlePublish() {
|
||||
if (isPublishing) return;
|
||||
isPublishing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await createEdge(accessToken, {
|
||||
source_id: node.id,
|
||||
target_id: collection.id,
|
||||
edge_type: 'belongs_to',
|
||||
metadata: {}
|
||||
});
|
||||
onclose();
|
||||
} catch (err) {
|
||||
console.error('Publiseringsfeil:', err);
|
||||
error = err instanceof Error ? err.message : 'Ukjent feil ved publisering';
|
||||
} finally {
|
||||
isPublishing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="w-full max-w-lg rounded-xl bg-white shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label="Publiser artikkel"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-base font-semibold text-gray-900">Publiser artikkel</h2>
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Lukk"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Article info -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900">{node.title || 'Uten tittel'}</h3>
|
||||
{#if node.content}
|
||||
<p class="mt-1 text-xs text-gray-500">{node.content.slice(0, 200)}{node.content.length > 200 ? '...' : ''}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Target collection -->
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500">Publiseres i</dt>
|
||||
<dd class="mt-0.5 text-sm text-gray-900">{collection.title ?? 'Samling'}</dd>
|
||||
</div>
|
||||
|
||||
<!-- Slug editor -->
|
||||
<div>
|
||||
<label for="article-slug" class="block text-xs font-medium text-gray-500">
|
||||
Artikkel-slug
|
||||
</label>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
id="article-slug"
|
||||
type="text"
|
||||
bind:value={articleSlug}
|
||||
class="block w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-mono text-gray-900 focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none"
|
||||
placeholder="artikkel-slug"
|
||||
/>
|
||||
</div>
|
||||
{#if collectionSlug}
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
URL: synops.no/pub/{collectionSlug}/{shortId}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Theme info -->
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>Tema: <span class="font-medium text-gray-700">{theme}</span></span>
|
||||
{#if previewUrl}
|
||||
<button
|
||||
onclick={() => { showPreview = !showPreview; }}
|
||||
class="text-emerald-600 hover:underline"
|
||||
>
|
||||
{showPreview ? 'Skjul forhåndsvisning' : 'Vis forhåndsvisning'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview iframe -->
|
||||
{#if showPreview && previewUrl}
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200">
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
title="Tema-forhåndsvisning"
|
||||
class="h-[300px] w-full border-0"
|
||||
sandbox="allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 border-t border-gray-100 px-5 py-4">
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100"
|
||||
disabled={isPublishing}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
<button
|
||||
onclick={handlePublish}
|
||||
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
disabled={isPublishing}
|
||||
>
|
||||
{isPublishing ? 'Publiserer...' : 'Publiser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,25 +1,69 @@
|
|||
<script lang="ts">
|
||||
import type { Node } from '$lib/spacetime';
|
||||
import type { Node, Edge } from '$lib/spacetime';
|
||||
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
|
||||
import { deleteEdge } from '$lib/api';
|
||||
import TraitPanel from './TraitPanel.svelte';
|
||||
import PublishDialog from '$lib/components/PublishDialog.svelte';
|
||||
|
||||
interface Props {
|
||||
collection: Node;
|
||||
config: Record<string, unknown>;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
/** Full collection metadata (for reading traits) */
|
||||
collectionMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
let { collection, config, userId }: Props = $props();
|
||||
let { collection, config, userId, accessToken, collectionMetadata }: Props = $props();
|
||||
|
||||
const preset = $derived((config.preset as string) ?? 'longform');
|
||||
|
||||
/** Content nodes belonging to this collection */
|
||||
const contentNodes = $derived.by(() => {
|
||||
const nodes: Node[] = [];
|
||||
/** Check if this collection has a personal publishing trait */
|
||||
const pubConfig = $derived.by((): Record<string, unknown> | null => {
|
||||
if (!collectionMetadata) return null;
|
||||
const traits = collectionMetadata.traits as Record<string, Record<string, unknown>> | undefined;
|
||||
if (!traits?.publishing) return null;
|
||||
const pub = traits.publishing;
|
||||
if (pub.require_approval === true) return null;
|
||||
return pub;
|
||||
});
|
||||
|
||||
const isPublishingCollection = $derived(pubConfig !== null);
|
||||
const pubSlug = $derived((pubConfig?.slug as string) ?? '');
|
||||
|
||||
/** Content nodes belonging to this collection, with their edges */
|
||||
interface ContentItem {
|
||||
node: Node;
|
||||
edge: Edge;
|
||||
}
|
||||
|
||||
const contentItems = $derived.by((): ContentItem[] => {
|
||||
const items: ContentItem[] = [];
|
||||
for (const edge of edgeStore.byTarget(collection.id)) {
|
||||
if (edge.edgeType !== 'belongs_to') continue;
|
||||
const node = nodeStore.get(edge.sourceId);
|
||||
if (node && node.nodeKind === 'content' && nodeVisibility(node, userId) !== 'hidden') {
|
||||
items.push({ node, edge });
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => {
|
||||
const ta = a.node.createdAt?.microsSinceUnixEpoch ?? 0n;
|
||||
const tb = b.node.createdAt?.microsSinceUnixEpoch ?? 0n;
|
||||
return tb > ta ? 1 : tb < ta ? -1 : 0;
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
/** User's content nodes that are NOT already in this collection (for publishing picker) */
|
||||
const publishableNodes = $derived.by((): Node[] => {
|
||||
if (!isPublishingCollection || !userId) return [];
|
||||
const inCollection = new Set(contentItems.map(i => i.node.id));
|
||||
const nodes: Node[] = [];
|
||||
// Find content nodes owned by user that aren't in this collection
|
||||
for (const edge of edgeStore.bySource(userId)) {
|
||||
if (edge.edgeType !== 'owner') continue;
|
||||
const node = nodeStore.get(edge.targetId);
|
||||
if (node && node.nodeKind === 'content' && !inCollection.has(node.id)) {
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +74,29 @@
|
|||
});
|
||||
return nodes;
|
||||
});
|
||||
|
||||
// State
|
||||
let showPublishPicker = $state(false);
|
||||
let publishTarget = $state<Node | null>(null);
|
||||
let unpublishing = $state<string | null>(null);
|
||||
let unpublishError = $state<string | null>(null);
|
||||
let confirmUnpublish = $state<ContentItem | null>(null);
|
||||
|
||||
async function handleUnpublish(item: ContentItem) {
|
||||
if (!accessToken || unpublishing) return;
|
||||
unpublishing = item.node.id;
|
||||
unpublishError = null;
|
||||
|
||||
try {
|
||||
await deleteEdge(accessToken, { edge_id: item.edge.id });
|
||||
confirmUnpublish = null;
|
||||
} catch (err) {
|
||||
console.error('Avpubliseringsfeil:', err);
|
||||
unpublishError = err instanceof Error ? err.message : 'Ukjent feil';
|
||||
} finally {
|
||||
unpublishing = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<TraitPanel name="editor" label="Innhold" icon="📝">
|
||||
|
|
@ -37,22 +104,152 @@
|
|||
<p class="mb-3 text-xs text-gray-500">
|
||||
Preset: <span class="font-medium">{preset}</span>
|
||||
{#if config.allow_collaborators}
|
||||
· Samarbeid aktivert
|
||||
· Samarbeid aktivert
|
||||
{/if}
|
||||
{#if isPublishingCollection}
|
||||
· Publisering aktiv
|
||||
{/if}
|
||||
</p>
|
||||
{#if contentNodes.length === 0}
|
||||
|
||||
{#if unpublishError}
|
||||
<div class="mb-3 rounded-lg border border-red-200 bg-red-50 p-2 text-xs text-red-700">
|
||||
{unpublishError}
|
||||
<button onclick={() => { unpublishError = null; }} class="ml-1 underline">Lukk</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contentItems.length === 0}
|
||||
<p class="text-sm text-gray-400">Ingen innholdsnoder ennå.</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each contentNodes as node (node.id)}
|
||||
<li class="rounded border border-gray-100 px-3 py-2">
|
||||
<h4 class="text-sm font-medium text-gray-900">{node.title || 'Uten tittel'}</h4>
|
||||
{#if node.content}
|
||||
<p class="mt-0.5 text-xs text-gray-500 line-clamp-2">{node.content.slice(0, 140)}</p>
|
||||
{/if}
|
||||
{#each contentItems as item (item.node.id)}
|
||||
<li class="group rounded border border-gray-100 px-3 py-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-sm font-medium text-gray-900">{item.node.title || 'Uten tittel'}</h4>
|
||||
{#if item.node.content}
|
||||
<p class="mt-0.5 text-xs text-gray-500 line-clamp-2">{item.node.content.slice(0, 140)}</p>
|
||||
{/if}
|
||||
{#if isPublishingCollection && pubSlug}
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
/pub/{pubSlug}/{item.node.id.slice(0, 8)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isPublishingCollection && accessToken}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if pubSlug}
|
||||
<a
|
||||
href="/api/pub/{pubSlug}/{item.node.id.slice(0, 8)}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="rounded px-2 py-1 text-xs text-gray-500 opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:text-gray-700"
|
||||
title="Se publisert artikkel"
|
||||
>
|
||||
Se
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => { confirmUnpublish = item; }}
|
||||
class="rounded px-2 py-1 text-xs text-red-500 opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-700"
|
||||
title="Fjern fra publisering"
|
||||
disabled={unpublishing === item.node.id}
|
||||
>
|
||||
{unpublishing === item.node.id ? '...' : 'Avpubliser'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Publish new article button -->
|
||||
{#if isPublishingCollection && accessToken}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
onclick={() => { showPublishPicker = !showPublishPicker; }}
|
||||
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
Publiser artikkel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Publishable nodes picker -->
|
||||
{#if showPublishPicker}
|
||||
<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3">
|
||||
<h4 class="mb-2 text-xs font-semibold text-emerald-800">Velg artikkel å publisere</h4>
|
||||
{#if publishableNodes.length === 0}
|
||||
<p class="text-xs text-emerald-600">Ingen upubliserte artikler funnet. Opprett innhold via mottaket først.</p>
|
||||
{:else}
|
||||
<ul class="max-h-48 space-y-1 overflow-y-auto">
|
||||
{#each publishableNodes as pNode (pNode.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => { publishTarget = pNode; showPublishPicker = false; }}
|
||||
class="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-emerald-100"
|
||||
>
|
||||
<span class="font-medium text-gray-900">{pNode.title || 'Uten tittel'}</span>
|
||||
{#if pNode.content}
|
||||
<span class="ml-1 text-xs text-gray-500">{pNode.content.slice(0, 60)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TraitPanel>
|
||||
|
||||
<!-- Publish dialog -->
|
||||
{#if publishTarget && accessToken && pubConfig}
|
||||
<PublishDialog
|
||||
node={publishTarget}
|
||||
{collection}
|
||||
pubConfig={pubConfig}
|
||||
{accessToken}
|
||||
onclose={() => { publishTarget = null; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Unpublish confirmation -->
|
||||
{#if confirmUnpublish}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') confirmUnpublish = null; }}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="w-full max-w-sm rounded-xl bg-white p-5 shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label="Bekreft avpublisering"
|
||||
>
|
||||
<h3 class="text-base font-semibold text-gray-900">Avpubliser artikkel?</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
<strong>{confirmUnpublish.node.title || 'Uten tittel'}</strong> vil bli fjernet fra
|
||||
publiseringen. Artikkelen slettes ikke, men er ikke lenger offentlig tilgjengelig.
|
||||
</p>
|
||||
<div class="mt-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onclick={() => { confirmUnpublish = null; }}
|
||||
class="rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { if (confirmUnpublish) handleUnpublish(confirmUnpublish); }}
|
||||
class="rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
disabled={unpublishing !== null}
|
||||
>
|
||||
{unpublishing ? 'Fjerner...' : 'Avpubliser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@
|
|||
<div class="space-y-4">
|
||||
{#each renderedTraits as trait (trait)}
|
||||
{#if trait === 'editor'}
|
||||
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
|
||||
{:else if trait === 'chat'}
|
||||
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'kanban'}
|
||||
|
|
|
|||
|
|
@ -948,6 +948,157 @@ pub async fn update_edge(
|
|||
Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id }))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// delete_edge
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteEdgeRequest {
|
||||
/// ID til edgen som skal slettes.
|
||||
pub edge_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DeleteEdgeResponse {
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FullEdgeRow {
|
||||
source_id: Uuid,
|
||||
target_id: Uuid,
|
||||
edge_type: String,
|
||||
}
|
||||
|
||||
/// POST /intentions/delete_edge
|
||||
///
|
||||
/// Sletter en edge. Brukes bl.a. for avpublisering (fjerner belongs_to-edge).
|
||||
/// Krever at brukeren har opprettet edgen, eller har owner/admin-edge
|
||||
/// til source-noden.
|
||||
pub async fn delete_edge(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(req): Json<DeleteEdgeRequest>,
|
||||
) -> Result<Json<DeleteEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
// -- Tilgangskontroll --
|
||||
let can_modify = user_can_modify_edge(&state.db, user.node_id, req.edge_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||||
internal_error("Databasefeil ved tilgangssjekk")
|
||||
})?;
|
||||
|
||||
if !can_modify {
|
||||
return Err(forbidden("Ingen tilgang til å slette denne edgen"));
|
||||
}
|
||||
|
||||
// Hent edge-info for logging og publiserings-invalidering
|
||||
let edge_info = sqlx::query_as::<_, FullEdgeRow>(
|
||||
"SELECT source_id, target_id, edge_type FROM edges WHERE id = $1",
|
||||
)
|
||||
.bind(req.edge_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("PG-feil ved henting av edge: {e}");
|
||||
internal_error("Databasefeil ved henting av edge")
|
||||
})?
|
||||
.ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;
|
||||
|
||||
let edge_id_str = req.edge_id.to_string();
|
||||
|
||||
// -- Slett fra SpacetimeDB (instant) --
|
||||
state
|
||||
.stdb
|
||||
.delete_edge(&edge_id_str)
|
||||
.await
|
||||
.map_err(|e| stdb_error("delete_edge", e))?;
|
||||
|
||||
tracing::info!(
|
||||
edge_id = %req.edge_id,
|
||||
edge_type = %edge_info.edge_type,
|
||||
deleted_by = %user.node_id,
|
||||
"Edge slettet fra STDB"
|
||||
);
|
||||
|
||||
// -- Spawn async PG-sletting + publiserings-invalidering --
|
||||
spawn_pg_delete_edge(
|
||||
state.db.clone(),
|
||||
state.index_cache.clone(),
|
||||
req.edge_id,
|
||||
edge_info.source_id,
|
||||
edge_info.target_id,
|
||||
edge_info.edge_type,
|
||||
);
|
||||
|
||||
Ok(Json(DeleteEdgeResponse { deleted: true }))
|
||||
}
|
||||
|
||||
/// Spawner en tokio-task som sletter edgen fra PostgreSQL
|
||||
/// og invaliderer publiserings-cache ved behov.
|
||||
fn spawn_pg_delete_edge(
|
||||
db: PgPool,
|
||||
index_cache: crate::publishing::IndexCache,
|
||||
edge_id: Uuid,
|
||||
_source_id: Uuid,
|
||||
target_id: Uuid,
|
||||
edge_type: String,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let result = sqlx::query("DELETE FROM edges WHERE id = $1")
|
||||
.bind(edge_id)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
tracing::info!(edge_id = %edge_id, "Edge slettet fra PostgreSQL");
|
||||
|
||||
// Ved fjerning av belongs_to til publiseringssamling: invalider forside-cache
|
||||
if edge_type == "belongs_to" {
|
||||
trigger_index_invalidation_if_publishing(&db, &index_cache, target_id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke slette edge fra PostgreSQL");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Invaliderer forside-cache (dynamisk modus) eller legger render_index-jobb i køen
|
||||
/// (statisk modus) når en edge fjernes fra en publiseringssamling.
|
||||
async fn trigger_index_invalidation_if_publishing(
|
||||
db: &PgPool,
|
||||
index_cache: &crate::publishing::IndexCache,
|
||||
collection_id: Uuid,
|
||||
) {
|
||||
match crate::publishing::find_publishing_collection_by_id(db, collection_id).await {
|
||||
Ok(Some(config)) => {
|
||||
let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
|
||||
if index_mode == "static" {
|
||||
let index_payload = serde_json::json!({
|
||||
"collection_id": collection_id.to_string(),
|
||||
});
|
||||
match crate::jobs::enqueue(db, "render_index", index_payload, Some(collection_id), 4).await {
|
||||
Ok(job_id) => {
|
||||
tracing::info!(job_id = %job_id, collection_id = %collection_id, "render_index-jobb lagt i kø etter avpublisering");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(collection_id = %collection_id, error = %e, "Kunne ikke legge render_index-jobb i kø");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::publishing::invalidate_index_cache(index_cache, collection_id).await;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::error!(collection_id = %collection_id, error = %e, "Feil ved sjekk av publiseringssamling for cache-invalidering");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// set_slot — Redaksjonell slot-håndtering for publiseringssamlinger
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ async fn main() {
|
|||
.route("/intentions/update_node", post(intentions::update_node))
|
||||
.route("/intentions/delete_node", post(intentions::delete_node))
|
||||
.route("/intentions/update_edge", post(intentions::update_edge))
|
||||
.route("/intentions/delete_edge", post(intentions::delete_edge))
|
||||
.route("/intentions/set_slot", post(intentions::set_slot))
|
||||
.route("/intentions/create_communication", post(intentions::create_communication))
|
||||
.route("/intentions/upload_media", post(intentions::upload_media))
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -148,8 +148,7 @@ Uavhengige faser kan fortsatt plukkes.
|
|||
- [x] 14.4 Caddy-ruting for synops.no/pub: Caddy reverse-proxyer til maskinrommet som gjør slug→hash-oppslag og streamer CAS-fil. `Cache-Control: immutable` for artikler. Kategori/arkiv/søk serveres dynamisk av maskinrommet med kortere cache-TTL.
|
||||
- [x] 14.5 Slot-håndtering i maskinrommet: `slot` og `slot_order` i `belongs_to`-edge metadata. Ved ny hero → gammel hero flyttes til strøm. Ved featured over `featured_max` → FIFO tilbake til strøm. `pinned`-flagg forhindrer automatisk fjerning.
|
||||
- [x] 14.6 Forside-admin i frontend: visuell editor for hero/featured/strøm. Drag-and-drop mellom plasser. Pin-knapp. Forhåndsvisning. Oppdaterer edge-metadata via maskinrommet.
|
||||
- [~] 14.7 Publiseringsflyt i frontend (personlig): publiseringsknapp på noder i samlinger med `publishing`-trait der `require_approval: false`. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge.
|
||||
> Påbegynt: 2026-03-18T01:25
|
||||
- [x] 14.7 Publiseringsflyt i frontend (personlig): publiseringsknapp på noder i samlinger med `publishing`-trait der `require_approval: false`. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge.
|
||||
- [ ] 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).
|
||||
- [ ] 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.
|
||||
- [ ] 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".
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue