Redaksjonell innsending (oppgave 14.10): submitted_to-edge med rollevalidering

Maskinrommet håndhever nå publiseringsregler for samlinger med
require_approval: true i publishing-traiten:

- submitted_to-edge: kun roller i submission_roles (+ owner/admin) kan
  opprette. Metadata settes automatisk: status=pending, submitted_at=now.
- belongs_to-edge til require_approval-samling: kun owner/admin.
- Status-endring på submitted_to: kun owner/admin av samlingen.

PublishingConfig utvidet med require_approval (default false) og
submission_roles (default ["member"]).

Nye hjelpefunksjoner: get_publishing_config, get_user_role_for_node,
user_is_owner_or_admin. EdgeRow utvidet med source_id/target_id.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 02:01:11 +00:00
parent be1b6caa29
commit f593b1e320
5 changed files with 155 additions and 11 deletions

View file

@ -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 `belongs_to`-edge til kommunikasjonsnoden. Når artikkelen er publisert
ligger samtalehistorikken igjen som arkiv — nyttig for revisjonshistorikk. ligger samtalehistorikken igjen som arkiv — nyttig for revisjonshistorikk.
## Håndhevelse i maskinrommet ## Håndhevelse i maskinrommet (implementert)
Maskinrommet validerer alle edge-operasjoner. For publiseringssamlinger Maskinrommet validerer alle edge-operasjoner i `create_edge` og
med `require_approval: true`: `update_edge`. For publiseringssamlinger med `require_approval: true`:
- **`belongs_to`-edge til samlingen:** Kun owner/admin kan opprette. - **`belongs_to`-edge til samlingen:** Kun owner/admin kan opprette.
Forsøk fra member/reader avvises. Forsøk fra member/reader avvises.

View file

@ -66,7 +66,7 @@ valideres i maskinrommet.
| `host_of` | Vert i | — | | `host_of` | Vert i | — |
| `has_media` | Har mediefil (innhold → CAS-node) | — | | `has_media` | Har mediefil (innhold → CAS-node) | — |
| `intended_for` | Ment for publisering i (arbeidsfase) | — | | `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" }` | | `title` | Publisert overskrift (presentasjonsnode → innhold) | `{ "variant": "editorial" }` |
| `subtitle` | Undertittel | `{ "variant": "editorial" }` | | `subtitle` | Undertittel | `{ "variant": "editorial" }` |
| `summary` | Ingress / forhåndsvisning | `{ "variant": "ai" }` | | `summary` | Ingress / forhåndsvisning | `{ "variant": "ai" }` |
@ -87,6 +87,9 @@ JSONB-feltet bærer kontekstspesifikk data om relasjonen:
- `status`-edge: `{ "value": "in_progress" }` - `status`-edge: `{ "value": "in_progress" }`
- `scheduled`-edge: `{ "at": "2026-03-20T14:00Z" }` - `scheduled`-edge: `{ "at": "2026-03-20T14:00Z" }`
- `tagged`-edge: `{ "tag": "urgent", "color": "#ff0000" }` - `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. Metadata er fleksibelt og spørrbart uten migrering.

View file

@ -272,6 +272,77 @@ async fn node_exists(db: &PgPool, node_id: Uuid) -> Result<bool, sqlx::Error> {
.await .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<Option<crate::publishing::PublishingConfig>, (StatusCode, Json<ErrorResponse>)> {
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<Option<String>, (StatusCode, Json<ErrorResponse>)> {
// Sjekk i prioritetsrekkefølge: owner > admin > member > reader
let role: Option<String> = 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<bool, (StatusCode, Json<ErrorResponse>)> {
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 // create_node
// ============================================================================= // =============================================================================
@ -606,10 +677,50 @@ pub async fn create_edge(
return Err(bad_request(&format!("target_id {} finnes ikke", req.target_id))); return Err(bad_request(&format!("target_id {} finnes ikke", req.target_id)));
} }
let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({})); let mut metadata = req.metadata.unwrap_or_else(|| serde_json::json!({}));
let metadata_str = metadata.to_string();
let system = req.system.unwrap_or(false); 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 -- // -- Generer UUIDv7 --
let edge_id = Uuid::now_v7(); let edge_id = Uuid::now_v7();
let edge_id_str = edge_id.to_string(); let edge_id_str = edge_id.to_string();
@ -905,7 +1016,7 @@ pub struct UpdateEdgeResponse {
/// Henter en edge fra PG. /// Henter en edge fra PG.
async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result<Option<EdgeRow>, sqlx::Error> { async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result<Option<EdgeRow>, sqlx::Error> {
sqlx::query_as::<_, EdgeRow>( 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) .bind(edge_id)
.fetch_optional(db) .fetch_optional(db)
@ -913,7 +1024,10 @@ async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result<Option<EdgeRow>, sqlx::E
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct EdgeRow { struct EdgeRow {
source_id: Uuid,
target_id: Uuid,
edge_type: String, edge_type: String,
metadata: serde_json::Value, 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)))?; .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() { if edge_type.is_empty() {
return Err(bad_request("edge_type kan ikke være tom")); 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 metadata_str = metadata.to_string();
let edge_id_str = req.edge_id.to_string(); let edge_id_str = req.edge_id.to_string();

View file

@ -48,6 +48,18 @@ pub struct PublishingConfig {
pub index_cache_ttl: Option<u64>, pub index_cache_ttl: Option<u64>,
pub featured_max: Option<i64>, pub featured_max: Option<i64>,
pub stream_page_size: Option<i64>, pub stream_page_size: Option<i64>,
/// 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<String>,
}
fn default_submission_roles() -> Vec<String> {
vec!["member".to_string()]
} }
#[derive(Deserialize, Default, Debug, Clone, Serialize)] #[derive(Deserialize, Default, Debug, Clone, Serialize)]

View file

@ -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.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.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. - [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". - [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".
> Påbegynt: 2026-03-18T01:53
- [ ] 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.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.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.13 Redaksjonell samtale: ved innsending kan redaktør opprette kommunikasjonsnode knyttet til artikkel + forfatter for diskusjon/feedback utover kort notat i edge-metadata.