diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 3cc543d..e3c7be7 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -140,6 +140,9 @@ async fn main() { // Start periodisk CAS-pruning i bakgrunnen pruning::start_pruning_loop(db.clone(), cas.clone()); + // Start planlagt publisering-scheduler i bakgrunnen + publishing::start_publish_scheduler(db.clone()); + let index_cache = publishing::new_index_cache(); let state = AppState { db, jwks, stdb, cas, index_cache }; diff --git a/maskinrommet/src/publishing.rs b/maskinrommet/src/publishing.rs index f3f07e4..f301bda 100644 --- a/maskinrommet/src/publishing.rs +++ b/maskinrommet/src/publishing.rs @@ -26,6 +26,7 @@ use tokio::sync::RwLock; use uuid::Uuid; use crate::cas::CasStore; +use crate::jobs; use crate::tiptap; use crate::AppState; @@ -1270,6 +1271,146 @@ pub async fn preview_theme( .unwrap()) } +// ============================================================================= +// Planlagt publisering — periodisk scheduler +// ============================================================================= + +/// Rad fra spørring for planlagte artikler som er klare for publisering. +#[derive(sqlx::FromRow, Debug)] +struct ScheduledArticle { + node_id: Uuid, + collection_id: Uuid, +} + +/// Finn belongs_to-edges med publish_at i fortiden der artikkelen +/// ikke er rendret ennå. Returnerer (node_id, collection_id)-par. +async fn find_due_articles(db: &PgPool) -> Result, sqlx::Error> { + // En artikkel er "due" når: + // 1. belongs_to-edge har publish_at <= now() + // 2. Noden mangler metadata.rendered.html_hash (ikke rendret) + // + // Vi sjekker også at det ikke allerede finnes en pending/running + // render_article-jobb for denne noden, for å unngå duplikater. + sqlx::query_as::<_, ScheduledArticle>( + r#" + SELECT + e.source_id AS node_id, + e.target_id AS collection_id + FROM edges e + JOIN nodes n ON n.id = e.source_id + WHERE e.edge_type = 'belongs_to' + AND (e.metadata->>'publish_at')::timestamptz <= now() + AND ( + n.metadata->'rendered'->>'html_hash' IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM job_queue jq + WHERE jq.job_type = 'render_article' + AND jq.status IN ('pending', 'running', 'retry') + AND jq.payload->>'node_id' = n.id::text + ) + "#, + ) + .fetch_all(db) + .await +} + +/// Kjør én runde med planlagt publisering. +/// Returnerer antall artikler som ble lagt i render-kø. +async fn run_publish_scheduler(db: &PgPool) -> Result { + let due = find_due_articles(db) + .await + .map_err(|e| format!("Spørring for planlagte artikler feilet: {e}"))?; + + if due.is_empty() { + return Ok(0); + } + + tracing::info!(count = due.len(), "Fant planlagte artikler klare for publisering"); + + // Samle unike collection_ids for index-oppdatering etterpå + let mut collections_to_reindex: std::collections::HashSet = std::collections::HashSet::new(); + + for article in &due { + let payload = serde_json::json!({ + "node_id": article.node_id.to_string(), + "collection_id": article.collection_id.to_string(), + }); + + match jobs::enqueue(db, "render_article", payload, Some(article.collection_id), 5).await { + Ok(job_id) => { + tracing::info!( + job_id = %job_id, + node_id = %article.node_id, + collection_id = %article.collection_id, + "Render-jobb opprettet for planlagt artikkel" + ); + collections_to_reindex.insert(article.collection_id); + } + Err(e) => { + tracing::error!( + node_id = %article.node_id, + error = %e, + "Kunne ikke opprette render-jobb for planlagt artikkel" + ); + } + } + } + + // Legg inn render_index-jobb for hver berørt samling (lavere prioritet) + for collection_id in &collections_to_reindex { + let payload = serde_json::json!({ + "collection_id": collection_id.to_string(), + }); + + if let Err(e) = jobs::enqueue(db, "render_index", payload, Some(*collection_id), 3).await { + tracing::error!( + collection_id = %collection_id, + error = %e, + "Kunne ikke opprette render_index-jobb" + ); + } + } + + let count = due.len(); + tracing::info!( + articles = count, + collections = collections_to_reindex.len(), + "Planlagt publisering: jobber lagt i kø" + ); + + Ok(count) +} + +/// Start periodisk scheduler for planlagt publisering. +/// Sjekker hvert 60. sekund for artikler med publish_at i fortiden. +pub fn start_publish_scheduler(db: PgPool) { + tokio::spawn(async move { + // Vent 30 sekunder etter oppstart før første sjekk + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + tracing::info!("Planlagt publisering-scheduler startet (intervall: 60s)"); + + loop { + match run_publish_scheduler(&db).await { + Ok(count) => { + if count > 0 { + tracing::info!( + articles = count, + "Planlagt publisering: {} artikler lagt i render-kø", + count, + ); + } + } + Err(e) => { + tracing::error!(error = %e, "Planlagt publisering-scheduler feilet"); + } + } + + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + } + }); +} + // ============================================================================= // Tester // ============================================================================= diff --git a/tasks.md b/tasks.md index 59f876d..991bcfd 100644 --- a/tasks.md +++ b/tasks.md @@ -154,8 +154,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 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. - [x] 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". - [x] 14.11 Redaktørens arbeidsflate: frontend-visning av noder med `submitted_to`-edge til samling, gruppert på status. Kanban-stil drag-and-drop for statusendring. Siste kolonne ("Planlagt") setter `publish_at` i edge-metadata. -- [~] 14.12 Planlagt publisering: maskinrommet sjekker periodisk (cron/intervall) for `belongs_to`-edges med `publish_at` i fortiden som ikke er rendret. Ved treff: render HTML → CAS → oppdater RSS. - > Påbegynt: 2026-03-18T02:10 +- [x] 14.12 Planlagt publisering: maskinrommet sjekker periodisk (cron/intervall) for `belongs_to`-edges med `publish_at` i fortiden som ikke er rendret. Ved treff: render HTML → CAS → oppdater RSS. - [ ] 14.13 Redaksjonell samtale: ved innsending kan redaktør opprette kommunikasjonsnode knyttet til artikkel + forfatter for diskusjon/feedback utover kort notat i edge-metadata. - [ ] 14.14 Bulk re-rendering: batch-jobb via jobbkø ved temaendring. Paginert (100 artikler om gangen), oppdaterer `renderer_version`. Artikler serveres med gammelt tema til re-rendret. - [ ] 14.15 Dynamiske sider: kategori-sider (filtrert på tag-edges), arkiv (kronologisk med månedsgruppering), søk (PG fulltekst). Alle paginerte, cachet i maskinrommet. Om-side som statisk CAS-node.