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:
parent
be1b6caa29
commit
f593b1e320
5 changed files with 155 additions and 11 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
3
tasks.md
3
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.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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue