diff --git a/docs/infra/api_grensesnitt.md b/docs/infra/api_grensesnitt.md index 7c2a6c3..29e5f1e 100644 --- a/docs/infra/api_grensesnitt.md +++ b/docs/infra/api_grensesnitt.md @@ -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: "" }` +- `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. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 00bd247..8557575 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { + return post(accessToken, '/intentions/delete_edge', data); +} + // ============================================================================= // Board / Kanban // ============================================================================= diff --git a/frontend/src/lib/components/PublishDialog.svelte b/frontend/src/lib/components/PublishDialog.svelte new file mode 100644 index 0000000..f7596d4 --- /dev/null +++ b/frontend/src/lib/components/PublishDialog.svelte @@ -0,0 +1,183 @@ + + + + +
{ if (e.key === 'Escape') onclose(); }} +> + + +
e.stopPropagation()} + role="dialog" + aria-label="Publiser artikkel" + > + +
+

Publiser artikkel

+ +
+ + +
+ {#if error} +
+ {error} +
+ {/if} + + +
+

{node.title || 'Uten tittel'}

+ {#if node.content} +

{node.content.slice(0, 200)}{node.content.length > 200 ? '...' : ''}

+ {/if} +
+ + +
+
Publiseres i
+
{collection.title ?? 'Samling'}
+
+ + +
+ +
+ +
+ {#if collectionSlug} +

+ URL: synops.no/pub/{collectionSlug}/{shortId} +

+ {/if} +
+ + +
+ Tema: {theme} + {#if previewUrl} + + {/if} +
+ + + {#if showPreview && previewUrl} +
+ +
+ {/if} +
+ + +
+ + +
+
+
diff --git a/frontend/src/lib/components/traits/EditorTrait.svelte b/frontend/src/lib/components/traits/EditorTrait.svelte index e7519df..479f189 100644 --- a/frontend/src/lib/components/traits/EditorTrait.svelte +++ b/frontend/src/lib/components/traits/EditorTrait.svelte @@ -1,25 +1,69 @@ @@ -37,22 +104,152 @@

Preset: {preset} {#if config.allow_collaborators} - · Samarbeid aktivert + · Samarbeid aktivert + {/if} + {#if isPublishingCollection} + · Publisering aktiv {/if}

- {#if contentNodes.length === 0} + + {#if unpublishError} +
+ {unpublishError} + +
+ {/if} + + {#if contentItems.length === 0}

Ingen innholdsnoder ennå.

{:else}
    - {#each contentNodes as node (node.id)} -
  • -

    {node.title || 'Uten tittel'}

    - {#if node.content} -

    {node.content.slice(0, 140)}

    - {/if} + {#each contentItems as item (item.node.id)} +
  • +
    +
    +

    {item.node.title || 'Uten tittel'}

    + {#if item.node.content} +

    {item.node.content.slice(0, 140)}

    + {/if} + {#if isPublishingCollection && pubSlug} +

    + /pub/{pubSlug}/{item.node.id.slice(0, 8)} +

    + {/if} +
    + {#if isPublishingCollection && accessToken} +
    + {#if pubSlug} + + Se + + {/if} + +
    + {/if} +
  • {/each}
{/if} + + + {#if isPublishingCollection && accessToken} +
+ +
+ + + {#if showPublishPicker} +
+

Velg artikkel å publisere

+ {#if publishableNodes.length === 0} +

Ingen upubliserte artikler funnet. Opprett innhold via mottaket først.

+ {:else} +
    + {#each publishableNodes as pNode (pNode.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ {/if} + {/if} {/snippet}
+ + +{#if publishTarget && accessToken && pubConfig} + { publishTarget = null; }} + /> +{/if} + + +{#if confirmUnpublish} + +
{ if (e.key === 'Escape') confirmUnpublish = null; }} + > + +
e.stopPropagation()} + role="dialog" + aria-label="Bekreft avpublisering" + > +

Avpubliser artikkel?

+

+ {confirmUnpublish.node.title || 'Uten tittel'} vil bli fjernet fra + publiseringen. Artikkelen slettes ikke, men er ikke lenger offentlig tilgjengelig. +

+
+ + +
+
+
+{/if} diff --git a/frontend/src/routes/collection/[id]/+page.svelte b/frontend/src/routes/collection/[id]/+page.svelte index 88897e3..20184fa 100644 --- a/frontend/src/routes/collection/[id]/+page.svelte +++ b/frontend/src/routes/collection/[id]/+page.svelte @@ -149,7 +149,7 @@
{#each renderedTraits as trait (trait)} {#if trait === 'editor'} - + {:else if trait === 'chat'} {:else if trait === 'kanban'} diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 44e00d7..e569835 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -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, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // -- 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 // ============================================================================= diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 5cc619f..caac9a3 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -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)) diff --git a/tasks.md b/tasks.md index 9b4b6df..de4bf8e 100644 --- a/tasks.md +++ b/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".