From 141cac92922e5b60c75889216619459297647deb Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 01:16:21 +0000 Subject: [PATCH] =?UTF-8?q?Slot-h=C3=A5ndtering=20i=20maskinrommet=20(oppg?= =?UTF-8?q?ave=2014.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ny intention `POST /intentions/set_slot` for redaksjonell kontroll over forside-slots i publiseringssamlinger. Håndhever: - Maks 1 hero: gammel ikke-pinned hero flyttes til strøm - featured_max: eldste ikke-pinned featured FIFO til strøm - pinned-flagg beskytter mot automatisk fjerning - Krever owner/admin-tilgang til samlingen - Trigger forside-rerendering etter slot-endring Returnerer liste over displaced edges slik at frontend kan vise hva som ble flyttet. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/concepts/publisering.md | 56 ++++--- docs/primitiver/edges.md | 5 +- maskinrommet/src/intentions.rs | 280 +++++++++++++++++++++++++++++++++ maskinrommet/src/main.rs | 1 + tasks.md | 3 +- 5 files changed, 324 insertions(+), 21 deletions(-) diff --git a/docs/concepts/publisering.md b/docs/concepts/publisering.md index 8863e0b..69157e1 100644 --- a/docs/concepts/publisering.md +++ b/docs/concepts/publisering.md @@ -2,19 +2,36 @@ ## Kjerneflyt -En node reiser fra privat til publisert uten kopiering eller konvertering. -Den får bare nye edges til samlinger med stadig rikere traits. +En artikkel reiser fra privat til publisert via edges. Kildematerialet — +chatmeldinger, notater, transkripsjoner — er egne noder koblet med +`source_material`-edges. + +### To veier til en artikkel + +**1. Artikkel fra start.** Bruker åpner artikkelverktøyet på +arbeidsflaten og skriver. Artikkelen er en innholdsnode fra fødsel. ``` -1. Privat tanke Node uten edges. Bare din. - ↓ (legg til belongs_to → kommunikasjonsnode) -2. Delt med venner Diskutert, kommentert, forbedret. - ↓ (legg til belongs_to → bredere gruppe) -3. Bredere gruppe Flere perspektiver, mer feedback. - ↓ (legg til belongs_to → samling med publishing-trait) -4. Publisert Maskinrommet rendrer HTML, genererer URL. - ↓ -5. Verden leser synops.no/pub/slug/id — Caddy → maskinrommet → CAS. +1. Privat artikkel Node med metadata.document. Bare din. + ↓ (legg til intended_for → samling) +2. Under arbeid Tema-forhåndsvisning i editoren. + ↓ (submitted_to erstatter intended_for) +3. Innsendt Redaktøren vurderer. + ↓ (belongs_to erstatter submitted_to) +4. Publisert Maskinrommet rendrer HTML, genererer URL. +``` + +**2. Artikkel fra kildemateriale.** Bruker drar chatbobler, notater eller +transkripsjoner til artikkelverktøyet. Ny artikkelnode opprettes med +`source_material`-edges tilbake til kildene. Artikkelen følger deretter +publiseringsreisen over. Se [arbeidsflaten](../retninger/arbeidsflaten.md). + +``` +Chatboble ←source_material── Artikkel-node +Notat ←source_material── ↑ +Transkripsjon ←source_material── │ + ↓ + intended_for → submitted_to → belongs_to → publisert ``` Tilbaketrekking: fjern `belongs_to`-edgen → artikkelen forsvinner fra @@ -72,8 +89,9 @@ Ole er medlem (member-edge til samlingen) 8. Maskinrommet rendrer → HTML til CAS ved publish_at ``` -Artikkelen er alltid én node. Den kopieres aldri. Den reiser gjennom -systemet ved at edges endres. +Artikkelen er alltid én node. Den reiser gjennom systemet ved at edges +endres. Materialet den bygger på er andre noder, sporbare gjennom grafen +via `source_material`-edges. ## Innsending: `submitted_to`-edge @@ -517,13 +535,15 @@ Tre plasser med økende automatisering: sortert på dato. Ingen hero, ingen featured — forsiden er en ren kronologisk flyt som fungerer fra dag én uten redaksjonelt arbeid. -**Når redaktøren griper inn:** +**Når redaktøren griper inn** (via `POST /intentions/set_slot`): - Sett hero → maskinrommet setter `slot: "hero"`. Forrige hero flyttes - automatisk tilbake til strøm. + automatisk tilbake til strøm (med mindre den er pinned). - Sett featured → `slot: "featured"`, `slot_order` bestemmer rekkefølge. - Overstiger antallet `featured_max` → eldste featured faller til strøm - (FIFO). -- Pin → artikkel blir stående i slot uavhengig av alder. + Overstiger antallet `featured_max` → eldste ikke-pinned featured faller + til strøm (FIFO). +- Pin → `pinned: true` i edge-metadata. Artikkel blir stående i slot + uavhengig av alder og automatisk fjerning. +- Krever owner/admin-tilgang til samlingen. ### Forside-administrasjon i frontend diff --git a/docs/primitiver/edges.md b/docs/primitiver/edges.md index 7cf4b82..3012bb9 100644 --- a/docs/primitiver/edges.md +++ b/docs/primitiver/edges.md @@ -56,7 +56,7 @@ valideres i maskinrommet. | `admin` | Administrerer noden | — | | `member_of` | Er medlem av | — | | `reader` | Kan kun lese | — | -| `belongs_to` | Tilhører (innhold → samling) | — | +| `belongs_to` | Tilhører (innhold → samling) | `{ "slot": "hero"/"featured"/null, "slot_order": 1, "pinned": true, "publish_at": "..." }` | | `alias` | Identitet (systemedge) | `system: true` | | `mentions` | Refererer til | — | | `reply_to` | Svar på | — | @@ -73,6 +73,9 @@ valideres i maskinrommet. | `og_image` | Forsidebilde / OpenGraph-bilde (media → innhold) | `{ "variant": "editorial" }` | | `show_notes` | Show notes for episode | `{ "variant": "ai" }` | | `chapter` | Kapittelmarkør for episode | `{ "at": "00:05:23" }` | +| `source_material` | Kildemateriale (avledet node → kilde) | `{ "context": "quoted", "excerpt": "..." }` | +| `derived_from` | Prosessert versjon av (f.eks. lydstudio-output → original) | — | +| `has_studio` | Studio-sesjon (sesjon → medienode) | — | Listen vokser organisk. Nye typer legges til ved behov uten skjemaendring. diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 674be50..44e00d7 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -948,6 +948,286 @@ pub async fn update_edge( Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id })) } +// ============================================================================= +// set_slot — Redaksjonell slot-håndtering for publiseringssamlinger +// ============================================================================= + +#[derive(Deserialize)] +pub struct SetSlotRequest { + /// ID til belongs_to-edgen mellom artikkel og samling. + pub edge_id: Uuid, + /// Slot: "hero", "featured", eller null/tom for strøm. + pub slot: Option, + /// Rekkefølge innen featured-slot (ignoreres for hero/strøm). + pub slot_order: Option, + /// Forhindrer automatisk fjerning fra slot (FIFO/hero-erstatning). + pub pinned: Option, +} + +#[derive(Serialize)] +pub struct SetSlotResponse { + /// Edgen som ble oppdatert. + pub edge_id: Uuid, + /// Edges som ble flyttet til strøm pga. hero-erstatning eller featured-overflow. + pub displaced: Vec, +} + +/// POST /intentions/set_slot +/// +/// Setter slot-metadata på en belongs_to-edge i en publiseringssamling. +/// Håndhever: +/// - Maks 1 hero: gammel (ikke-pinned) hero flyttes til strøm. +/// - featured_max: eldste (ikke-pinned) featured flyttes til strøm (FIFO). +/// - pinned-flagg beskytter mot automatisk fjerning. +/// +/// Krever owner/admin-tilgang til samlingen. +pub async fn set_slot( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Valider slot-verdi + let slot = req.slot.as_deref().unwrap_or(""); + if !matches!(slot, "" | "hero" | "featured") { + return Err(bad_request("slot må være \"hero\", \"featured\", eller null/tom for strøm")); + } + + // Hent edgen og valider at det er en belongs_to-edge + #[derive(sqlx::FromRow)] + struct FullEdgeRow { + source_id: Uuid, + target_id: Uuid, + edge_type: String, + metadata: serde_json::Value, + } + + let edge = sqlx::query_as::<_, FullEdgeRow>( + "SELECT source_id, target_id, edge_type, metadata 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)))?; + + if edge.edge_type != "belongs_to" { + return Err(bad_request("set_slot kan kun brukes på belongs_to-edges")); + } + + let collection_id = edge.target_id; + + // Sjekk at target er en publiseringssamling + let pub_config = crate::publishing::find_publishing_collection_by_id(&state.db, collection_id) + .await + .map_err(|e| { + tracing::error!("PG-feil ved sjekk av publiseringssamling: {e}"); + internal_error("Databasefeil") + })? + .ok_or_else(|| bad_request("Samlingen har ikke publishing-trait"))?; + + // Sjekk at brukeren er owner/admin på samlingen + let is_admin = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM edges + WHERE source_id = $1 AND target_id = $2 + AND edge_type IN ('owner', 'admin') + ) + "#, + ) + .bind(user.node_id) + .bind(collection_id) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved tilgangssjekk: {e}"); + internal_error("Databasefeil ved tilgangssjekk") + })?; + + if !is_admin { + return Err(forbidden("Kun owner/admin kan endre slots")); + } + + let featured_max = pub_config.featured_max.unwrap_or(4); + let mut displaced: Vec = Vec::new(); + + // -- Slot-logikk: håndter hero-erstatning og featured-overflow -- + + if slot == "hero" { + // Finn eksisterende hero-edges (som ikke er denne edgen) + let existing_heroes: Vec<(Uuid, serde_json::Value)> = sqlx::query_as( + r#" + SELECT id, metadata FROM edges + WHERE target_id = $1 + AND edge_type = 'belongs_to' + AND metadata->>'slot' = 'hero' + AND id != $2 + "#, + ) + .bind(collection_id) + .bind(req.edge_id) + .fetch_all(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved hero-sjekk: {e}"); + internal_error("Databasefeil") + })?; + + // Flytt ikke-pinned heroes tilbake til strøm + for (hero_edge_id, hero_meta) in &existing_heroes { + let pinned = hero_meta.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false); + if !pinned { + displace_to_stream(&state.db, &state.stdb, *hero_edge_id, hero_meta).await?; + displaced.push(*hero_edge_id); + } + } + } else if slot == "featured" { + // Tell nåværende featured-edges (ekskluder denne edgen om den allerede er featured) + let current_featured: Vec<(Uuid, serde_json::Value, Option)> = sqlx::query_as( + r#" + SELECT id, metadata, + (metadata->>'slot_order')::bigint as slot_order + FROM edges + WHERE target_id = $1 + AND edge_type = 'belongs_to' + AND metadata->>'slot' = 'featured' + AND id != $2 + ORDER BY (metadata->>'slot_order')::int ASC NULLS LAST, + created_at ASC + "#, + ) + .bind(collection_id) + .bind(req.edge_id) + .fetch_all(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved featured-sjekk: {e}"); + internal_error("Databasefeil") + })?; + + // Hvis vi legger til en ny featured og det overstiger featured_max, + // fjern eldste (FIFO) ikke-pinned featured + let new_count = current_featured.len() as i64 + 1; // +1 for den vi setter nå + if new_count > featured_max { + let overflow = (new_count - featured_max) as usize; + // Finn de eldste ikke-pinned featured-edges å fjerne (FIFO = sist i listen) + let removable: Vec<&(Uuid, serde_json::Value, Option)> = current_featured + .iter() + .rev() // Høyest slot_order / eldst først for FIFO + .filter(|(_, meta, _)| { + !meta.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false) + }) + .take(overflow) + .collect(); + + for (feat_edge_id, feat_meta, _) in removable { + displace_to_stream(&state.db, &state.stdb, *feat_edge_id, feat_meta).await?; + displaced.push(*feat_edge_id); + } + } + } + + // -- Oppdater edgen med ny slot-metadata -- + let mut new_meta = edge.metadata.clone(); + if let Some(obj) = new_meta.as_object_mut() { + if slot.is_empty() { + obj.remove("slot"); + obj.remove("slot_order"); + } else { + obj.insert("slot".to_string(), serde_json::json!(slot)); + if slot == "featured" { + if let Some(order) = req.slot_order { + obj.insert("slot_order".to_string(), serde_json::json!(order)); + } + } else { + obj.remove("slot_order"); + } + } + // Sett/fjern pinned + match req.pinned { + Some(true) => { obj.insert("pinned".to_string(), serde_json::json!(true)); } + Some(false) => { obj.remove("pinned"); } + None => {} // Behold eksisterende + } + } + + // Skriv til STDB (instant) + let edge_id_str = req.edge_id.to_string(); + let meta_str = new_meta.to_string(); + state + .stdb + .update_edge(&edge_id_str, "belongs_to", &meta_str) + .await + .map_err(|e| stdb_error("update_edge", e))?; + + // Skriv til PG + sqlx::query("UPDATE edges SET metadata = $1 WHERE id = $2") + .bind(&new_meta) + .bind(req.edge_id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved slot-oppdatering: {e}"); + internal_error("Databasefeil ved slot-oppdatering") + })?; + + tracing::info!( + edge_id = %req.edge_id, + slot = %slot, + displaced_count = displaced.len(), + "Slot oppdatert" + ); + + // Trigger forside-rerendering + trigger_render_if_publishing(&state.db, &state.index_cache, edge.source_id, collection_id).await; + + Ok(Json(SetSlotResponse { + edge_id: req.edge_id, + displaced, + })) +} + +/// Flytter en edge fra sin nåværende slot tilbake til strøm (slot=null). +/// Beholder annen metadata (publish_at, approved_by, etc.). +async fn displace_to_stream( + db: &PgPool, + stdb: &crate::stdb::StdbClient, + edge_id: Uuid, + current_meta: &serde_json::Value, +) -> Result<(), (StatusCode, Json)> { + let mut meta = current_meta.clone(); + if let Some(obj) = meta.as_object_mut() { + obj.remove("slot"); + obj.remove("slot_order"); + // pinned er irrelevant i strøm, fjern det + obj.remove("pinned"); + } + + // STDB (instant) + let edge_id_str = edge_id.to_string(); + let meta_str = meta.to_string(); + stdb.update_edge(&edge_id_str, "belongs_to", &meta_str) + .await + .map_err(|e| stdb_error("update_edge (displace)", e))?; + + // PG + sqlx::query("UPDATE edges SET metadata = $1 WHERE id = $2") + .bind(&meta) + .bind(edge_id) + .execute(db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved displace_to_stream: {e}"); + internal_error("Databasefeil ved fjerning fra slot") + })?; + + tracing::info!(edge_id = %edge_id, "Edge fjernet fra slot → strøm"); + Ok(()) +} + // ============================================================================= // create_communication // ============================================================================= diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 1a2ebbb..5cc619f 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/set_slot", post(intentions::set_slot)) .route("/intentions/create_communication", post(intentions::create_communication)) .route("/intentions/upload_media", post(intentions::upload_media)) .route("/cas/{hash}", get(serving::get_cas_file)) diff --git a/tasks.md b/tasks.md index bf49f1f..606dc36 100644 --- a/tasks.md +++ b/tasks.md @@ -143,8 +143,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 14.2 HTML-rendering av enkeltartikler: maskinrommet rendrer `metadata.document` til HTML via Tera, lagrer i CAS. Noden får `metadata.rendered.html_hash` + `renderer_version`. SEO-metadata (OG-tags, canonical, JSON-LD). - [x] 14.3 Forside-rendering: maskinrommet spør PG for hero/featured/strøm (tre indekserte spørringer), appliserer tema-template, rendrer til CAS (statisk modus) eller serverer med in-memory cache (dynamisk modus). `index_mode` og `index_cache_ttl` i trait-konfig. - [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. -- [~] 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. - > Påbegynt: 2026-03-18T01:10 +- [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. - [ ] 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. - [ ] 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).