// 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) { ( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string(), }), ) } fn forbidden(msg: &str) -> (StatusCode, Json) { ( StatusCode::FORBIDDEN, Json(ErrorResponse { error: msg.to_string(), }), ) } fn internal_error(msg: &str) -> (StatusCode, Json) { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string(), }), ) } fn stdb_error(op: &str, e: crate::stdb::StdbError) -> (StatusCode, Json) { 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 { 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 { 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 { 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, /// Visningstittel. Kan være null (f.eks. chatmeldinger). pub title: Option, /// Ren tekst-innhold. pub content: Option, /// Synlighet. Default: "hidden" (privat). pub visibility: Option, /// Typespesifikk metadata (JSON-objekt). pub metadata: Option, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, /// Systemedge — usynlig ved traversering. Default: false. pub system: Option, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, /// Ny tittel. Beholder eksisterende hvis None. pub title: Option, /// Nytt innhold. Beholder eksisterende hvis None. pub content: Option, /// Ny synlighet. Beholder eksisterende hvis None. pub visibility: Option, /// Ny metadata. Beholder eksisterende hvis None. pub metadata: Option, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, /// Deltakere — liste med node_id-er (person-noder). /// Innlogget bruker legges automatisk til som owner. pub participants: Vec, /// Synlighet. Default: "hidden" (privat). pub visibility: Option, } #[derive(Serialize)] pub struct CreateCommunicationResponse { pub node_id: Uuid, /// Edge-IDer for opprettede deltaker-edges (owner + member_of). pub edge_ids: Vec, } /// 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, content: Option, 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"); } } }); }