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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -272,6 +272,77 @@ async fn node_exists(db: &PgPool, node_id: Uuid) -> Result<bool, sqlx::Error> {
|
|||
.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
|
||||
// =============================================================================
|
||||
|
|
@ -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<Option<EdgeRow>, 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<Option<EdgeRow>, 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();
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,18 @@ pub struct PublishingConfig {
|
|||
pub index_cache_ttl: Option<u64>,
|
||||
pub featured_max: 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)]
|
||||
|
|
|
|||
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.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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue