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>
986 lines
29 KiB
Rust
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");
|
|
}
|
|
}
|
|
});
|
|
}
|