synops/maskinrommet/src/intentions.rs
vegard 7189925d08 Fullfør oppgave 5.1: create_communication-intensjon
Ny intensjon POST /intentions/create_communication som oppretter en
kommunikasjonsnode (node_kind='communication') med:
- metadata.started_at satt til opprettelsestidspunkt
- owner-edge fra innlogget bruker til noden
- member_of-edges for alle angitte deltakere
- Validering av deltaker-noder og visibility
- Samme to-lags skriveflyt som andre intensjoner (STDB instant, PG async)

Testet med curl mot produksjonsserver — node, edges og node_access
opprettes korrekt i både STDB og PG.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 15:44:02 +01:00

986 lines
29 KiB
Rust

// Intensjoner — skrivestien i maskinrommet.
//
// Frontend sender intensjoner (ikke data). Maskinrommet validerer,
// skriver til SpacetimeDB først (instant feedback via WebSocket),
// deretter persisterer til PostgreSQL asynkront.
//
// Tilgangskontroll: Muterende operasjoner (update, delete) krever at
// brukeren er created_by på noden, eller har owner/admin-edge til den.
//
// Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::AppState;
// =============================================================================
// Felles
// =============================================================================
/// Gyldige visibility-verdier (speiler PG enum).
const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"];
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn forbidden(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::FORBIDDEN,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn stdb_error(op: &str, e: crate::stdb::StdbError) -> (StatusCode, Json<ErrorResponse>) {
tracing::error!("STDB {op} feilet: {e}");
internal_error(&format!("Kunne ikke skrive til SpacetimeDB: {e}"))
}
// =============================================================================
// Tilgangskontroll
// =============================================================================
/// Sjekker om brukeren har skrivetilgang til en node.
/// Returnerer true hvis brukeren er created_by, eller har owner/admin-edge.
async fn user_can_modify_node(db: &PgPool, user_id: Uuid, node_id: Uuid) -> Result<bool, sqlx::Error> {
let row = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM nodes WHERE id = $1 AND created_by = $2
) OR EXISTS(
SELECT 1 FROM edges
WHERE source_id = $2 AND target_id = $1
AND edge_type IN ('owner', 'admin')
)
"#,
)
.bind(node_id)
.bind(user_id)
.fetch_one(db)
.await?;
Ok(row)
}
/// Sjekker om brukeren har skrivetilgang til en edge.
/// Brukeren må ha opprettet edgen, eller ha owner/admin-edge til source-noden.
#[allow(dead_code)]
async fn user_can_modify_edge(db: &PgPool, user_id: Uuid, edge_id: Uuid) -> Result<bool, sqlx::Error> {
let row = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM edges WHERE id = $1 AND created_by = $2
) OR EXISTS(
SELECT 1 FROM edges e
JOIN edges access_edge ON access_edge.source_id = $2
AND access_edge.target_id = e.source_id
AND access_edge.edge_type IN ('owner', 'admin')
WHERE e.id = $1
)
"#,
)
.bind(edge_id)
.bind(user_id)
.fetch_one(db)
.await?;
Ok(row)
}
/// Sjekker om en node eksisterer i PG.
async fn node_exists(db: &PgPool, node_id: Uuid) -> Result<bool, sqlx::Error> {
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)")
.bind(node_id)
.fetch_one(db)
.await
}
// =============================================================================
// create_node
// =============================================================================
#[derive(Deserialize)]
pub struct CreateNodeRequest {
/// Hint om hva noden er. Default: "content".
pub node_kind: Option<String>,
/// Visningstittel. Kan være null (f.eks. chatmeldinger).
pub title: Option<String>,
/// Ren tekst-innhold.
pub content: Option<String>,
/// Synlighet. Default: "hidden" (privat).
pub visibility: Option<String>,
/// Typespesifikk metadata (JSON-objekt).
pub metadata: Option<serde_json::Value>,
}
#[derive(Serialize)]
pub struct CreateNodeResponse {
pub node_id: Uuid,
}
/// POST /intentions/create_node
///
/// Validerer input, skriver til STDB (instant), spawner async PG-skriving.
/// Returnerer node_id umiddelbart.
pub async fn create_node(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateNodeRequest>,
) -> Result<Json<CreateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Valider input --
let node_kind = req.node_kind.unwrap_or_else(|| "content".to_string());
if node_kind.is_empty() {
return Err(bad_request("node_kind kan ikke være tom"));
}
let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string());
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
return Err(bad_request(&format!(
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
)));
}
let title = req.title.unwrap_or_default();
let content = req.content.unwrap_or_default();
let metadata = req
.metadata
.unwrap_or_else(|| serde_json::json!({}));
let metadata_str = metadata.to_string();
// -- Generer UUIDv7 (tidssortert) --
let node_id = Uuid::now_v7();
let node_id_str = node_id.to_string();
let created_by_str = user.node_id.to_string();
// -- Skriv til SpacetimeDB (instant) --
state
.stdb
.create_node(
&node_id_str,
&node_kind,
&title,
&content,
&visibility,
&metadata_str,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_node", e))?;
tracing::info!(
node_id = %node_id,
node_kind = %node_kind,
created_by = %user.node_id,
"Node opprettet i STDB"
);
// -- Spawn async PG-skriving --
spawn_pg_insert_node(
state.db.clone(),
node_id,
node_kind,
title,
content,
visibility,
metadata,
user.node_id,
);
Ok(Json(CreateNodeResponse { node_id }))
}
// =============================================================================
// create_edge
// =============================================================================
#[derive(Deserialize)]
pub struct CreateEdgeRequest {
/// Kilde-node (fra).
pub source_id: Uuid,
/// Mål-node (til).
pub target_id: Uuid,
/// Relasjontype (freeform streng). Ref: docs/primitiver/edges.md
pub edge_type: String,
/// Typespesifikk metadata (JSON-objekt).
pub metadata: Option<serde_json::Value>,
/// Systemedge — usynlig ved traversering. Default: false.
pub system: Option<bool>,
}
#[derive(Serialize)]
pub struct CreateEdgeResponse {
pub edge_id: Uuid,
}
/// POST /intentions/create_edge
///
/// Oppretter en retningsbestemt edge mellom to noder.
/// Krever at begge nodene eksisterer. Brukeren trenger ikke spesiell
/// tilgang for å opprette edges — tilgangskontroll på edges håndheves
/// ved lesing (node_access-matrisen, fase 4).
pub async fn create_edge(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateEdgeRequest>,
) -> Result<Json<CreateEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Valider input --
if req.edge_type.is_empty() {
return Err(bad_request("edge_type kan ikke være tom"));
}
// Sjekk at begge nodene eksisterer
let (source_exists, target_exists) = tokio::try_join!(
node_exists(&state.db, req.source_id),
node_exists(&state.db, req.target_id),
)
.map_err(|e| {
tracing::error!("PG-feil ved nodesjekk: {e}");
internal_error("Databasefeil ved validering")
})?;
if !source_exists {
return Err(bad_request(&format!("source_id {} finnes ikke", req.source_id)));
}
if !target_exists {
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 system = req.system.unwrap_or(false);
// -- Generer UUIDv7 --
let edge_id = Uuid::now_v7();
let edge_id_str = edge_id.to_string();
let source_id_str = req.source_id.to_string();
let target_id_str = req.target_id.to_string();
let created_by_str = user.node_id.to_string();
// -- Skriv til SpacetimeDB (instant) --
state
.stdb
.create_edge(
&edge_id_str,
&source_id_str,
&target_id_str,
&req.edge_type,
&metadata_str,
system,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_edge", e))?;
tracing::info!(
edge_id = %edge_id,
source_id = %req.source_id,
target_id = %req.target_id,
edge_type = %req.edge_type,
created_by = %user.node_id,
"Edge opprettet i STDB"
);
// -- Spawn async PG-skriving --
let edge_type = req.edge_type.clone();
spawn_pg_insert_edge(
state.db.clone(),
state.stdb.clone(),
edge_id,
req.source_id,
req.target_id,
edge_type,
metadata,
system,
user.node_id,
);
Ok(Json(CreateEdgeResponse { edge_id }))
}
// =============================================================================
// update_node
// =============================================================================
#[derive(Deserialize)]
pub struct UpdateNodeRequest {
/// ID til noden som skal oppdateres.
pub node_id: Uuid,
/// Ny node_kind. Beholder eksisterende hvis None.
pub node_kind: Option<String>,
/// Ny tittel. Beholder eksisterende hvis None.
pub title: Option<String>,
/// Nytt innhold. Beholder eksisterende hvis None.
pub content: Option<String>,
/// Ny synlighet. Beholder eksisterende hvis None.
pub visibility: Option<String>,
/// Ny metadata. Beholder eksisterende hvis None.
pub metadata: Option<serde_json::Value>,
}
#[derive(Serialize)]
pub struct UpdateNodeResponse {
pub node_id: Uuid,
}
/// POST /intentions/update_node
///
/// Oppdaterer en eksisterende node. Krever at brukeren er created_by
/// eller har owner/admin-edge til noden.
pub async fn update_node(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<UpdateNodeRequest>,
) -> Result<Json<UpdateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Tilgangskontroll --
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved tilgangssjekk: {e}");
internal_error("Databasefeil ved tilgangssjekk")
})?;
if !can_modify {
return Err(forbidden("Ingen tilgang til å endre denne noden"));
}
// -- Hent eksisterende node fra PG for å fylle inn manglende felt --
let existing = sqlx::query_as::<_, NodeRow>(
"SELECT node_kind, title, content, visibility::text, metadata FROM nodes WHERE id = $1",
)
.bind(req.node_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved henting av node: {e}");
internal_error("Databasefeil ved henting av node")
})?
.ok_or_else(|| bad_request(&format!("Node {} finnes ikke", req.node_id)))?;
let node_kind = req.node_kind.unwrap_or(existing.node_kind);
if node_kind.is_empty() {
return Err(bad_request("node_kind kan ikke være tom"));
}
let visibility = req.visibility.unwrap_or(existing.visibility);
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
return Err(bad_request(&format!(
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
)));
}
let title = req.title.unwrap_or(existing.title.unwrap_or_default());
let content = req.content.unwrap_or(existing.content.unwrap_or_default());
let metadata = req.metadata.unwrap_or(existing.metadata);
let metadata_str = metadata.to_string();
let node_id_str = req.node_id.to_string();
// -- Skriv til SpacetimeDB (instant) --
state
.stdb
.update_node(
&node_id_str,
&node_kind,
&title,
&content,
&visibility,
&metadata_str,
)
.await
.map_err(|e| stdb_error("update_node", e))?;
tracing::info!(
node_id = %req.node_id,
updated_by = %user.node_id,
"Node oppdatert i STDB"
);
// -- Spawn async PG-skriving --
spawn_pg_update_node(
state.db.clone(),
req.node_id,
node_kind,
title,
content,
visibility,
metadata,
);
Ok(Json(UpdateNodeResponse { node_id: req.node_id }))
}
// =============================================================================
// delete_node
// =============================================================================
#[derive(Deserialize)]
pub struct DeleteNodeRequest {
/// ID til noden som skal slettes.
pub node_id: Uuid,
}
#[derive(Serialize)]
pub struct DeleteNodeResponse {
pub deleted: bool,
}
/// POST /intentions/delete_node
///
/// Sletter en node og alle dens edges (CASCADE i PG, eksplisitt i STDB).
/// Krever at brukeren er created_by eller har owner/admin-edge til noden.
pub async fn delete_node(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<DeleteNodeRequest>,
) -> Result<Json<DeleteNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Tilgangskontroll --
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved tilgangssjekk: {e}");
internal_error("Databasefeil ved tilgangssjekk")
})?;
if !can_modify {
return Err(forbidden("Ingen tilgang til å slette denne noden"));
}
// Sjekk at noden eksisterer
let exists = node_exists(&state.db, req.node_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved nodesjekk: {e}");
internal_error("Databasefeil ved validering")
})?;
if !exists {
return Err(bad_request(&format!("Node {} finnes ikke", req.node_id)));
}
let node_id_str = req.node_id.to_string();
// -- Slett fra SpacetimeDB (instant) --
state
.stdb
.delete_node(&node_id_str)
.await
.map_err(|e| stdb_error("delete_node", e))?;
tracing::info!(
node_id = %req.node_id,
deleted_by = %user.node_id,
"Node slettet fra STDB"
);
// -- Spawn async PG-sletting --
spawn_pg_delete_node(state.db.clone(), req.node_id);
Ok(Json(DeleteNodeResponse { deleted: true }))
}
// =============================================================================
// create_communication
// =============================================================================
#[derive(Deserialize)]
pub struct CreateCommunicationRequest {
/// Visningstittel for kommunikasjonsnoden (f.eks. "Redaksjonsmøte").
pub title: Option<String>,
/// Deltakere — liste med node_id-er (person-noder).
/// Innlogget bruker legges automatisk til som owner.
pub participants: Vec<Uuid>,
/// Synlighet. Default: "hidden" (privat).
pub visibility: Option<String>,
}
#[derive(Serialize)]
pub struct CreateCommunicationResponse {
pub node_id: Uuid,
/// Edge-IDer for opprettede deltaker-edges (owner + member_of).
pub edge_ids: Vec<Uuid>,
}
/// POST /intentions/create_communication
///
/// Oppretter en kommunikasjonsnode med deltaker-edges.
/// Innlogget bruker blir automatisk owner. Andre deltakere får member_of-edge.
/// Metadata inneholder started_at-tidsstempel.
///
/// Ref: docs/primitiver/nodes.md (communication), docs/retninger/universell_input.md
pub async fn create_communication(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateCommunicationRequest>,
) -> Result<Json<CreateCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string());
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
return Err(bad_request(&format!(
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
)));
}
// Valider at alle deltakere eksisterer
for participant_id in &req.participants {
let exists = node_exists(&state.db, *participant_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved nodesjekk: {e}");
internal_error("Databasefeil ved validering")
})?;
if !exists {
return Err(bad_request(&format!(
"Deltaker-node {} finnes ikke",
participant_id
)));
}
}
let title = req.title.unwrap_or_default();
let now = chrono::Utc::now();
let metadata = serde_json::json!({ "started_at": now.to_rfc3339() });
let metadata_str = metadata.to_string();
// -- Opprett kommunikasjonsnoden --
let node_id = Uuid::now_v7();
let node_id_str = node_id.to_string();
let created_by_str = user.node_id.to_string();
state
.stdb
.create_node(
&node_id_str,
"communication",
&title,
"",
&visibility,
&metadata_str,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_node (communication)", e))?;
tracing::info!(
node_id = %node_id,
created_by = %user.node_id,
participants = ?req.participants,
"Kommunikasjonsnode opprettet i STDB"
);
// Spawn PG-skriving for noden
spawn_pg_insert_node(
state.db.clone(),
node_id,
"communication".to_string(),
title,
String::new(),
visibility,
metadata,
user.node_id,
);
// -- Opprett deltaker-edges --
let mut edge_ids = Vec::new();
// Owner-edge for innlogget bruker
let owner_edge_id = Uuid::now_v7();
edge_ids.push(owner_edge_id);
let owner_edge_id_str = owner_edge_id.to_string();
let owner_metadata = serde_json::json!({});
let owner_metadata_str = owner_metadata.to_string();
state
.stdb
.create_edge(
&owner_edge_id_str,
&created_by_str,
&node_id_str,
"owner",
&owner_metadata_str,
false,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_edge (owner)", e))?;
// Spawn PG-skriving for owner-edge (med access recompute)
spawn_pg_insert_edge(
state.db.clone(),
state.stdb.clone(),
owner_edge_id,
user.node_id,
node_id,
"owner".to_string(),
owner_metadata,
false,
user.node_id,
);
// member_of-edges for øvrige deltakere
for participant_id in &req.participants {
// Hopp over innlogget bruker — allerede owner
if *participant_id == user.node_id {
continue;
}
let edge_id = Uuid::now_v7();
edge_ids.push(edge_id);
let edge_id_str = edge_id.to_string();
let participant_id_str = participant_id.to_string();
let member_metadata = serde_json::json!({});
let member_metadata_str = member_metadata.to_string();
state
.stdb
.create_edge(
&edge_id_str,
&participant_id_str,
&node_id_str,
"member_of",
&member_metadata_str,
false,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_edge (member_of)", e))?;
spawn_pg_insert_edge(
state.db.clone(),
state.stdb.clone(),
edge_id,
*participant_id,
node_id,
"member_of".to_string(),
member_metadata,
false,
user.node_id,
);
}
tracing::info!(
node_id = %node_id,
edge_count = edge_ids.len(),
"Kommunikasjonsnode med deltaker-edges opprettet"
);
Ok(Json(CreateCommunicationResponse { node_id, edge_ids }))
}
// =============================================================================
// Bakgrunns-PG-operasjoner
// =============================================================================
#[derive(sqlx::FromRow)]
struct NodeRow {
node_kind: String,
title: Option<String>,
content: Option<String>,
visibility: String,
metadata: serde_json::Value,
}
/// Spawner en tokio-task som skriver noden til PostgreSQL i bakgrunnen.
fn spawn_pg_insert_node(
db: PgPool,
node_id: Uuid,
node_kind: String,
title: String,
content: String,
visibility: String,
metadata: serde_json::Value,
created_by: Uuid,
) {
tokio::spawn(async move {
let result = sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5::visibility, $6, $7)
"#,
)
.bind(node_id)
.bind(&node_kind)
.bind(&title)
.bind(&content)
.bind(&visibility)
.bind(&metadata)
.bind(created_by)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(node_id = %node_id, "Node persistert til PostgreSQL");
}
Err(e) => {
tracing::error!(node_id = %node_id, error = %e, "Kunne ikke persistere node til PostgreSQL");
}
}
});
}
/// Mapper edge_type til access_level for tilgangsgivende edges.
/// Returnerer None for edges som ikke gir tilgang.
fn edge_type_to_access_level(edge_type: &str) -> Option<&'static str> {
match edge_type {
"owner" => Some("owner"),
"admin" => Some("admin"),
"member_of" => Some("member"),
"reader" => Some("reader"),
_ => None,
}
}
/// Spawner en tokio-task som skriver edgen til PostgreSQL i bakgrunnen.
/// For tilgangsgivende edges (owner, admin, member_of, reader) kalles
/// recompute_access i samme transaksjon — ingen vindu med stale tilgang.
/// Synker også node_access til STDB for visibility-filtrering i frontend.
fn spawn_pg_insert_edge(
db: PgPool,
stdb: crate::stdb::StdbClient,
edge_id: Uuid,
source_id: Uuid,
target_id: Uuid,
edge_type: String,
metadata: serde_json::Value,
system: bool,
created_by: Uuid,
) {
tokio::spawn(async move {
let access_level = edge_type_to_access_level(&edge_type);
if let Some(level) = access_level {
// Tilgangsgivende edge: wrap i transaksjon med recompute_access
let result = insert_edge_with_access(&db, edge_id, source_id, target_id, &edge_type, &metadata, system, created_by, level).await;
match result {
Ok(_) => {
tracing::info!(
edge_id = %edge_id,
edge_type = %edge_type,
access_level = %level,
"Edge + node_access persistert til PostgreSQL"
);
// Synk oppdatert node_access til STDB
sync_node_access_to_stdb(&db, &stdb, source_id).await;
}
Err(e) => {
tracing::error!(
edge_id = %edge_id,
error = %e,
"Kunne ikke persistere edge + node_access til PostgreSQL"
);
}
}
} else {
// Vanlig edge uten tilgangspåvirkning
let result = sqlx::query(
r#"
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
)
.bind(edge_id)
.bind(source_id)
.bind(target_id)
.bind(&edge_type)
.bind(&metadata)
.bind(system)
.bind(created_by)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(edge_id = %edge_id, "Edge persistert til PostgreSQL");
}
Err(e) => {
tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke persistere edge til PostgreSQL");
}
}
}
});
}
/// Synkroniserer node_access-rader for et subject fra PG til STDB.
/// Kalles etter recompute_access for å holde STDB i synk.
async fn sync_node_access_to_stdb(db: &PgPool, stdb: &crate::stdb::StdbClient, subject_id: Uuid) {
let rows = sqlx::query_as::<_, NodeAccessRow>(
"SELECT subject_id, object_id, access::text as access, \
COALESCE(via_edge::text, '') as via_edge \
FROM node_access WHERE subject_id = $1",
)
.bind(subject_id)
.fetch_all(db)
.await;
match rows {
Ok(rows) => {
for row in &rows {
if let Err(e) = stdb
.upsert_node_access(
&row.subject_id.to_string(),
&row.object_id.to_string(),
&row.access,
&row.via_edge,
)
.await
{
tracing::error!(
subject_id = %row.subject_id,
object_id = %row.object_id,
error = %e,
"Kunne ikke synke node_access til STDB"
);
}
}
tracing::info!(
subject_id = %subject_id,
count = rows.len(),
"node_access synket til STDB"
);
}
Err(e) => {
tracing::error!(subject_id = %subject_id, error = %e, "Kunne ikke hente node_access fra PG");
}
}
}
#[derive(sqlx::FromRow)]
struct NodeAccessRow {
subject_id: Uuid,
object_id: Uuid,
access: String,
via_edge: String,
}
/// Inserter en tilgangsgivende edge og oppdaterer node_access i én transaksjon.
/// source_id = subject (bruker/team), target_id = object (noden det gis tilgang til).
async fn insert_edge_with_access(
db: &PgPool,
edge_id: Uuid,
source_id: Uuid,
target_id: Uuid,
edge_type: &str,
metadata: &serde_json::Value,
system: bool,
created_by: Uuid,
access_level: &str,
) -> Result<(), sqlx::Error> {
let mut tx = db.begin().await?;
sqlx::query(
r#"
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
)
.bind(edge_id)
.bind(source_id)
.bind(target_id)
.bind(edge_type)
.bind(metadata)
.bind(system)
.bind(created_by)
.execute(&mut *tx)
.await?;
// Kall recompute_access: subject=source_id, object=target_id
sqlx::query(
"SELECT recompute_access($1, $2, $3::access_level, $4)",
)
.bind(source_id)
.bind(target_id)
.bind(access_level)
.bind(edge_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Spawner en tokio-task som oppdaterer noden i PostgreSQL.
fn spawn_pg_update_node(
db: PgPool,
node_id: Uuid,
node_kind: String,
title: String,
content: String,
visibility: String,
metadata: serde_json::Value,
) {
tokio::spawn(async move {
let result = sqlx::query(
r#"
UPDATE nodes
SET node_kind = $2, title = NULLIF($3, ''), content = NULLIF($4, ''),
visibility = $5::visibility, metadata = $6
WHERE id = $1
"#,
)
.bind(node_id)
.bind(&node_kind)
.bind(&title)
.bind(&content)
.bind(&visibility)
.bind(&metadata)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(node_id = %node_id, "Node oppdatert i PostgreSQL");
}
Err(e) => {
tracing::error!(node_id = %node_id, error = %e, "Kunne ikke oppdatere node i PostgreSQL");
}
}
});
}
/// Spawner en tokio-task som sletter noden fra PostgreSQL.
/// Edges slettes automatisk via ON DELETE CASCADE.
fn spawn_pg_delete_node(db: PgPool, node_id: Uuid) {
tokio::spawn(async move {
let result = sqlx::query("DELETE FROM nodes WHERE id = $1")
.bind(node_id)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(node_id = %node_id, "Node slettet fra PostgreSQL");
}
Err(e) => {
tracing::error!(node_id = %node_id, error = %e, "Kunne ikke slette node fra PostgreSQL");
}
}
});
}