diff --git a/docs/concepts/publisering.md b/docs/concepts/publisering.md index 69157e1..daacc2f 100644 --- a/docs/concepts/publisering.md +++ b/docs/concepts/publisering.md @@ -202,10 +202,10 @@ Samtalen lever som en vanlig tråd. Meldinger er noder med `belongs_to`-edge til kommunikasjonsnoden. Når artikkelen er publisert ligger samtalehistorikken igjen som arkiv — nyttig for revisjonshistorikk. -## Håndhevelse i maskinrommet +## Håndhevelse i maskinrommet (implementert) -Maskinrommet validerer alle edge-operasjoner. For publiseringssamlinger -med `require_approval: true`: +Maskinrommet validerer alle edge-operasjoner i `create_edge` og +`update_edge`. For publiseringssamlinger med `require_approval: true`: - **`belongs_to`-edge til samlingen:** Kun owner/admin kan opprette. Forsøk fra member/reader avvises. diff --git a/docs/primitiver/edges.md b/docs/primitiver/edges.md index 3012bb9..9ea892e 100644 --- a/docs/primitiver/edges.md +++ b/docs/primitiver/edges.md @@ -66,7 +66,7 @@ valideres i maskinrommet. | `host_of` | Vert i | — | | `has_media` | Har mediefil (innhold → CAS-node) | — | | `intended_for` | Ment for publisering i (arbeidsfase) | — | -| `submitted_to` | Innsendt til redaksjonell vurdering | `{ "status": "pending" }` | +| `submitted_to` | Innsendt til redaksjonell vurdering | `{ "status": "pending", "submitted_at": "ISO8601" }` | | `title` | Publisert overskrift (presentasjonsnode → innhold) | `{ "variant": "editorial" }` | | `subtitle` | Undertittel | `{ "variant": "editorial" }` | | `summary` | Ingress / forhåndsvisning | `{ "variant": "ai" }` | @@ -87,6 +87,9 @@ JSONB-feltet bærer kontekstspesifikk data om relasjonen: - `status`-edge: `{ "value": "in_progress" }` - `scheduled`-edge: `{ "at": "2026-03-20T14:00Z" }` - `tagged`-edge: `{ "tag": "urgent", "color": "#ff0000" }` +- `submitted_to`-edge: `{ "status": "pending", "submitted_at": "2026-03-17T10:00:00Z" }` + Status-verdier: `pending`, `in_review`, `revision_requested`, `rejected`, `approved`. + Ved tilbakemelding: `feedback`, `feedback_by`, `feedback_at`. Metadata er fleksibelt og spørrbart uten migrering. diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 3870751..681f660 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -272,6 +272,77 @@ async fn node_exists(db: &PgPool, node_id: Uuid) -> Result { .await } +/// Henter publishing-trait-konfig for en node (hvis den er en samling med publishing-trait). +/// Wrapper rundt publishing::find_publishing_collection_by_id med feil-mapping for handlers. +async fn get_publishing_config( + db: &PgPool, + node_id: Uuid, +) -> Result, (StatusCode, Json)> { + crate::publishing::find_publishing_collection_by_id(db, node_id) + .await + .map_err(|e| { + tracing::error!("PG-feil ved publishing-config-oppslag: {e}"); + internal_error("Databasefeil ved publiseringssjekk") + }) +} + +/// Returnerer brukerens høyeste rolle-edge til en node. +/// Sjekker owner, admin, member_of, reader-edges. +/// Returnerer "owner", "admin", "member", "reader", eller None. +async fn get_user_role_for_node( + db: &PgPool, + user_id: Uuid, + node_id: Uuid, +) -> Result, (StatusCode, Json)> { + // Sjekk i prioritetsrekkefølge: owner > admin > member > reader + let role: Option = sqlx::query_scalar( + r#" + SELECT CASE + WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'owner') THEN 'owner' + WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'admin') THEN 'admin' + WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'member_of') THEN 'member' + WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'reader') THEN 'reader' + ELSE NULL + END + "#, + ) + .bind(user_id) + .bind(node_id) + .fetch_one(db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved rolle-oppslag: {e}"); + internal_error("Databasefeil ved rollesjekk") + })?; + + Ok(role) +} + +/// Sjekker om brukeren er owner eller admin av en node. +async fn user_is_owner_or_admin( + db: &PgPool, + user_id: Uuid, + node_id: Uuid, +) -> Result)> { + 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_id) + .bind(node_id) + .fetch_one(db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved owner/admin-sjekk: {e}"); + internal_error("Databasefeil ved tilgangssjekk") + }) +} + // ============================================================================= // create_node // ============================================================================= @@ -606,10 +677,50 @@ pub async fn create_edge( return Err(bad_request(&format!("target_id {} finnes ikke", req.target_id))); } - let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({})); - let metadata_str = metadata.to_string(); + let mut metadata = req.metadata.unwrap_or_else(|| serde_json::json!({})); let system = req.system.unwrap_or(false); + // -- Publiseringsvalidering for submitted_to og belongs_to -- + if req.edge_type == "submitted_to" || req.edge_type == "belongs_to" { + if let Some(config) = get_publishing_config(&state.db, req.target_id).await? { + if config.require_approval { + if req.edge_type == "submitted_to" { + // Kun roller i submission_roles (eller owner/admin) kan opprette submitted_to + let user_role = get_user_role_for_node(&state.db, user.node_id, req.target_id).await?; + let allowed = match &user_role { + Some(role) if role == "owner" || role == "admin" => true, + Some(role) => config.submission_roles.contains(role), + None => false, + }; + if !allowed { + return Err(forbidden( + "Du har ikke riktig rolle til å sende inn til denne samlingen", + )); + } + // Sett status og submitted_at automatisk + if let Some(obj) = metadata.as_object_mut() { + obj.insert("status".to_string(), serde_json::json!("pending")); + obj.insert( + "submitted_at".to_string(), + serde_json::json!(chrono::Utc::now().to_rfc3339()), + ); + } + } else { + // belongs_to til require_approval-samling: kun owner/admin + let is_owner_admin = + user_is_owner_or_admin(&state.db, user.node_id, req.target_id).await?; + if !is_owner_admin { + return Err(forbidden( + "Kun owner/admin kan publisere direkte til denne samlingen (require_approval er aktiv)", + )); + } + } + } + } + } + + let metadata_str = metadata.to_string(); + // -- Generer UUIDv7 -- let edge_id = Uuid::now_v7(); let edge_id_str = edge_id.to_string(); @@ -905,7 +1016,7 @@ pub struct UpdateEdgeResponse { /// Henter en edge fra PG. async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, EdgeRow>( - "SELECT edge_type, metadata FROM edges WHERE id = $1", + "SELECT source_id, target_id, edge_type, metadata FROM edges WHERE id = $1", ) .bind(edge_id) .fetch_optional(db) @@ -913,7 +1024,10 @@ async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result, sqlx::E } #[derive(sqlx::FromRow)] +#[allow(dead_code)] struct EdgeRow { + source_id: Uuid, + target_id: Uuid, edge_type: String, metadata: serde_json::Value, } @@ -949,12 +1063,28 @@ pub async fn update_edge( })? .ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?; - let edge_type = req.edge_type.unwrap_or(existing.edge_type); + let edge_type = req.edge_type.unwrap_or(existing.edge_type.clone()); if edge_type.is_empty() { return Err(bad_request("edge_type kan ikke være tom")); } - let metadata = req.metadata.unwrap_or(existing.metadata); + let metadata = req.metadata.unwrap_or(existing.metadata.clone()); + + // -- submitted_to status-endring: kun owner/admin av samlingen -- + if existing.edge_type == "submitted_to" { + let new_status = metadata.get("status").and_then(|v| v.as_str()); + let old_status = existing.metadata.get("status").and_then(|v| v.as_str()); + if new_status != old_status { + let is_owner_admin = + user_is_owner_or_admin(&state.db, user.node_id, existing.target_id).await?; + if !is_owner_admin { + return Err(forbidden( + "Kun owner/admin av samlingen kan endre status på innsendte artikler", + )); + } + } + } + let metadata_str = metadata.to_string(); let edge_id_str = req.edge_id.to_string(); diff --git a/maskinrommet/src/publishing.rs b/maskinrommet/src/publishing.rs index 81decc5..f3f07e4 100644 --- a/maskinrommet/src/publishing.rs +++ b/maskinrommet/src/publishing.rs @@ -48,6 +48,18 @@ pub struct PublishingConfig { pub index_cache_ttl: Option, pub featured_max: Option, pub stream_page_size: Option, + /// Krever redaksjonell godkjenning for publisering. + /// Når true: members bruker submitted_to-flyten, kun owner/admin kan opprette belongs_to. + #[serde(default)] + pub require_approval: bool, + /// Roller som kan opprette submitted_to-edges til samlingen. + /// Verdier: "owner", "admin", "member", "reader". Default: ["member"]. + #[serde(default = "default_submission_roles")] + pub submission_roles: Vec, +} + +fn default_submission_roles() -> Vec { + vec!["member".to_string()] } #[derive(Deserialize, Default, Debug, Clone, Serialize)] diff --git a/tasks.md b/tasks.md index f266ad0..3c047bc 100644 --- a/tasks.md +++ b/tasks.md @@ -152,8 +152,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 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. - [x] 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). - [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. -- [~] 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". - > Påbegynt: 2026-03-18T01:53 +- [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". - [ ] 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. - [ ] 14.13 Redaksjonell samtale: ved innsending kan redaktør opprette kommunikasjonsnode knyttet til artikkel + forfatter for diskusjon/feedback utover kort notat i edge-metadata.