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:
parent
6e6e4912c1
commit
141cac9292
5 changed files with 324 additions and 21 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
3
tasks.md
3
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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue