Slot-håndtering i maskinrommet (oppgave 14.5)

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) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 01:16:21 +00:00
parent 6e6e4912c1
commit 141cac9292
5 changed files with 324 additions and 21 deletions

View file

@ -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)
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── │
5. Verden leser synops.no/pub/slug/id — Caddy → maskinrommet → CAS.
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

View file

@ -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.

View file

@ -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<String>,
/// Rekkefølge innen featured-slot (ignoreres for hero/strøm).
pub slot_order: Option<i64>,
/// Forhindrer automatisk fjerning fra slot (FIFO/hero-erstatning).
pub pinned: Option<bool>,
}
#[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<Uuid>,
}
/// 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<AppState>,
user: AuthUser,
Json(req): Json<SetSlotRequest>,
) -> Result<Json<SetSlotResponse>, (StatusCode, Json<ErrorResponse>)> {
// 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<Uuid> = 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<i64>)> = 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<i64>)> = 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<ErrorResponse>)> {
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
// =============================================================================

View file

@ -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))

View file

@ -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).