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 ## Kjerneflyt
En node reiser fra privat til publisert uten kopiering eller konvertering. En artikkel reiser fra privat til publisert via edges. Kildematerialet —
Den får bare nye edges til samlinger med stadig rikere traits. 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. 1. Privat artikkel Node med metadata.document. Bare din.
↓ (legg til belongs_to → kommunikasjonsnode) ↓ (legg til intended_for → samling)
2. Delt med venner Diskutert, kommentert, forbedret. 2. Under arbeid Tema-forhåndsvisning i editoren.
↓ (legg til belongs_to → bredere gruppe) ↓ (submitted_to erstatter intended_for)
3. Bredere gruppe Flere perspektiver, mer feedback. 3. Innsendt Redaktøren vurderer.
↓ (legg til belongs_to → samling med publishing-trait) ↓ (belongs_to erstatter submitted_to)
4. Publisert Maskinrommet rendrer HTML, genererer URL. 4. Publisert Maskinrommet rendrer HTML, genererer URL.
```
5. Verden leser synops.no/pub/slug/id — Caddy → maskinrommet → CAS.
**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 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 8. Maskinrommet rendrer → HTML til CAS ved publish_at
``` ```
Artikkelen er alltid én node. Den kopieres aldri. Den reiser gjennom Artikkelen er alltid én node. Den reiser gjennom systemet ved at edges
systemet ved at edges endres. endres. Materialet den bygger på er andre noder, sporbare gjennom grafen
via `source_material`-edges.
## Innsending: `submitted_to`-edge ## Innsending: `submitted_to`-edge
@ -517,13 +535,15 @@ Tre plasser med økende automatisering:
sortert på dato. Ingen hero, ingen featured — forsiden er en ren sortert på dato. Ingen hero, ingen featured — forsiden er en ren
kronologisk flyt som fungerer fra dag én uten redaksjonelt arbeid. 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 - 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. - Sett featured → `slot: "featured"`, `slot_order` bestemmer rekkefølge.
Overstiger antallet `featured_max` → eldste featured faller til strøm Overstiger antallet `featured_max` → eldste ikke-pinned featured faller
(FIFO). til strøm (FIFO).
- Pin → artikkel blir stående i slot uavhengig av alder. - 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 ### Forside-administrasjon i frontend

View file

@ -56,7 +56,7 @@ valideres i maskinrommet.
| `admin` | Administrerer noden | — | | `admin` | Administrerer noden | — |
| `member_of` | Er medlem av | — | | `member_of` | Er medlem av | — |
| `reader` | Kan kun lese | — | | `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` | | `alias` | Identitet (systemedge) | `system: true` |
| `mentions` | Refererer til | — | | `mentions` | Refererer til | — |
| `reply_to` | Svar på | — | | `reply_to` | Svar på | — |
@ -73,6 +73,9 @@ valideres i maskinrommet.
| `og_image` | Forsidebilde / OpenGraph-bilde (media → innhold) | `{ "variant": "editorial" }` | | `og_image` | Forsidebilde / OpenGraph-bilde (media → innhold) | `{ "variant": "editorial" }` |
| `show_notes` | Show notes for episode | `{ "variant": "ai" }` | | `show_notes` | Show notes for episode | `{ "variant": "ai" }` |
| `chapter` | Kapittelmarkør for episode | `{ "at": "00:05:23" }` | | `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 Listen vokser organisk. Nye typer legges til ved behov uten
skjemaendring. skjemaendring.

View file

@ -948,6 +948,286 @@ pub async fn update_edge(
Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id })) 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 // create_communication
// ============================================================================= // =============================================================================

View file

@ -151,6 +151,7 @@ async fn main() {
.route("/intentions/update_node", post(intentions::update_node)) .route("/intentions/update_node", post(intentions::update_node))
.route("/intentions/delete_node", post(intentions::delete_node)) .route("/intentions/delete_node", post(intentions::delete_node))
.route("/intentions/update_edge", post(intentions::update_edge)) .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/create_communication", post(intentions::create_communication))
.route("/intentions/upload_media", post(intentions::upload_media)) .route("/intentions/upload_media", post(intentions::upload_media))
.route("/cas/{hash}", get(serving::get_cas_file)) .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.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.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. - [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. - [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.
> Påbegynt: 2026-03-18T01:10
- [ ] 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.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.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.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).