Flere intensjoner: create_edge, update_node, delete_node (oppgave 2.5)
Implementerer tre nye skrivestier i maskinrommet med tilgangskontroll: - POST /intentions/create_edge — opprett retningsbestemt edge mellom to noder. Validerer at begge noder eksisterer og edge_type er satt. - POST /intentions/update_node — partial update av eksisterende node. Kun oppgitte felter endres, resten beholdes fra PG. - POST /intentions/delete_node — slett node med cascade av edges. Tilgangskontroll for update/delete: brukeren må enten være created_by på noden, eller ha en owner/admin-edge til den. Sjekkes mot PG som autoritativ kilde. Alle endepunkter følger samme mønster som create_node: STDB-skriving (instant) → async PG-persistering → umiddelbar respons. Verifisert på server med 10 testcaser: 1. /me med gyldig token → 200 2. create_node → 200 med node_id 3. create_edge (gyldig) → 200 med edge_id 4. create_edge (ugyldig source) → 400 5. create_edge (tom edge_type) → 400 6. update_node (partial, eier) → 200 7. update_node (ingen tilgang) → 403 8. delete_node (eier) → 200 9. delete_node (ingen tilgang) → 403 10. update via owner-edge (Sidelinja) → 200 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f7a1a36824
commit
1e9a4c83c3
4 changed files with 524 additions and 38 deletions
|
|
@ -67,6 +67,20 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
|
||||||
- Body (JSON): `{ node_kind?, title?, content?, visibility?, metadata? }`
|
- Body (JSON): `{ node_kind?, title?, content?, visibility?, metadata? }`
|
||||||
- Defaults: `node_kind="content"`, `visibility="hidden"`, andre felter tomme
|
- Defaults: `node_kind="content"`, `visibility="hidden"`, andre felter tomme
|
||||||
- Respons: `{ node_id: "<uuid>" }`
|
- Respons: `{ node_id: "<uuid>" }`
|
||||||
|
- `POST /intentions/create_edge` — Opprett edge mellom to noder.
|
||||||
|
Validerer at begge nodene eksisterer og at edge_type ikke er tom.
|
||||||
|
- Body (JSON): `{ source_id, target_id, edge_type, metadata?, system? }`
|
||||||
|
- Defaults: `metadata={}`, `system=false`
|
||||||
|
- Respons: `{ edge_id: "<uuid>" }`
|
||||||
|
- `POST /intentions/update_node` — Oppdater eksisterende node (partial update).
|
||||||
|
Krever tilgang: created_by eller owner/admin-edge.
|
||||||
|
- Body (JSON): `{ node_id, node_kind?, title?, content?, visibility?, metadata? }`
|
||||||
|
- Kun oppgitte felter endres, resten beholdes
|
||||||
|
- Respons: `{ node_id: "<uuid>" }`
|
||||||
|
- `POST /intentions/delete_node` — Slett node og tilhørende edges.
|
||||||
|
Krever tilgang: created_by eller owner/admin-edge.
|
||||||
|
- Body (JSON): `{ node_id }`
|
||||||
|
- Respons: `{ deleted: true }`
|
||||||
|
|
||||||
## 6. Instruks for Claude Code
|
## 6. Instruks for Claude Code
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
// skriver til SpacetimeDB først (instant feedback via WebSocket),
|
// skriver til SpacetimeDB først (instant feedback via WebSocket),
|
||||||
// deretter persisterer til PostgreSQL asynkront.
|
// 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
|
// Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, Json};
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
|
|
@ -15,12 +18,112 @@ use crate::auth::AuthUser;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// create_node
|
// Felles
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/// Gyldige visibility-verdier (speiler PG enum).
|
/// Gyldige visibility-verdier (speiler PG enum).
|
||||||
const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"];
|
const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"];
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateNodeRequest {
|
pub struct CreateNodeRequest {
|
||||||
/// Hint om hva noden er. Default: "content".
|
/// Hint om hva noden er. Default: "content".
|
||||||
|
|
@ -40,11 +143,6 @@ pub struct CreateNodeResponse {
|
||||||
pub node_id: Uuid,
|
pub node_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ErrorResponse {
|
|
||||||
error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /intentions/create_node
|
/// POST /intentions/create_node
|
||||||
///
|
///
|
||||||
/// Validerer input, skriver til STDB (instant), spawner async PG-skriving.
|
/// Validerer input, skriver til STDB (instant), spawner async PG-skriving.
|
||||||
|
|
@ -92,15 +190,7 @@ pub async fn create_node(
|
||||||
&created_by_str,
|
&created_by_str,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| stdb_error("create_node", e))?;
|
||||||
tracing::error!("STDB create_node feilet: {e}");
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: format!("Kunne ikke skrive til SpacetimeDB: {e}"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
node_id = %node_id,
|
node_id = %node_id,
|
||||||
|
|
@ -110,7 +200,7 @@ pub async fn create_node(
|
||||||
);
|
);
|
||||||
|
|
||||||
// -- Spawn async PG-skriving --
|
// -- Spawn async PG-skriving --
|
||||||
spawn_pg_insert(
|
spawn_pg_insert_node(
|
||||||
state.db.clone(),
|
state.db.clone(),
|
||||||
node_id,
|
node_id,
|
||||||
node_kind,
|
node_kind,
|
||||||
|
|
@ -121,13 +211,312 @@ pub async fn create_node(
|
||||||
user.node_id,
|
user.node_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
// -- Returner node_id umiddelbart --
|
|
||||||
Ok(Json(CreateNodeResponse { 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(),
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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.
|
/// Spawner en tokio-task som skriver noden til PostgreSQL i bakgrunnen.
|
||||||
/// Frontend får oppdatering via STDB WebSocket uavhengig av denne.
|
fn spawn_pg_insert_node(
|
||||||
fn spawn_pg_insert(
|
|
||||||
db: PgPool,
|
db: PgPool,
|
||||||
node_id: Uuid,
|
node_id: Uuid,
|
||||||
node_kind: String,
|
node_kind: String,
|
||||||
|
|
@ -156,28 +545,109 @@ fn spawn_pg_insert(
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!(
|
tracing::info!(node_id = %node_id, "Node persistert til PostgreSQL");
|
||||||
node_id = %node_id,
|
|
||||||
"Node persistert til PostgreSQL"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Logg feilen. I fremtiden: dead letter queue (fase 12.3).
|
tracing::error!(node_id = %node_id, error = %e, "Kunne ikke persistere node til PostgreSQL");
|
||||||
tracing::error!(
|
|
||||||
node_id = %node_id,
|
|
||||||
error = %e,
|
|
||||||
"Kunne ikke persistere node til PostgreSQL"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
/// Spawner en tokio-task som skriver edgen til PostgreSQL i bakgrunnen.
|
||||||
(
|
fn spawn_pg_insert_edge(
|
||||||
StatusCode::BAD_REQUEST,
|
db: PgPool,
|
||||||
Json(ErrorResponse {
|
edge_id: Uuid,
|
||||||
error: msg.to_string(),
|
source_id: Uuid,
|
||||||
}),
|
target_id: Uuid,
|
||||||
|
edge_type: String,
|
||||||
|
metadata: serde_json::Value,
|
||||||
|
system: bool,
|
||||||
|
created_by: Uuid,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,9 @@ async fn main() {
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.route("/me", get(me))
|
.route("/me", get(me))
|
||||||
.route("/intentions/create_node", post(intentions::create_node))
|
.route("/intentions/create_node", post(intentions::create_node))
|
||||||
|
.route("/intentions/create_edge", post(intentions::create_edge))
|
||||||
|
.route("/intentions/update_node", post(intentions::update_node))
|
||||||
|
.route("/intentions/delete_node", post(intentions::delete_node))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -55,8 +55,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
- [x] 2.2 Auth-middleware: valider Authentik JWT-tokens, slå opp `auth_identities` → node_id. Returner 401 for ugyldige tokens.
|
- [x] 2.2 Auth-middleware: valider Authentik JWT-tokens, slå opp `auth_identities` → node_id. Returner 401 for ugyldige tokens.
|
||||||
- [x] 2.3 SpacetimeDB-klient i maskinrommet: koble til STDB, skriv noder og edges via reducers.
|
- [x] 2.3 SpacetimeDB-klient i maskinrommet: koble til STDB, skriv noder og edges via reducers.
|
||||||
- [x] 2.4 Skrivestien: `POST /intentions/create_node` — valider, skriv STDB (instant), spawn async PG-skriving. Returner node_id umiddelbart.
|
- [x] 2.4 Skrivestien: `POST /intentions/create_node` — valider, skriv STDB (instant), spawn async PG-skriving. Returner node_id umiddelbart.
|
||||||
- [~] 2.5 Flere intensjoner: `create_edge`, `update_node`, `delete_node`. Validering av tilgang (created_by eller owner/admin-edge).
|
- [x] 2.5 Flere intensjoner: `create_edge`, `update_node`, `delete_node`. Validering av tilgang (created_by eller owner/admin-edge).
|
||||||
> Påbegynt: 2026-03-17T13:11
|
|
||||||
- [ ] 2.6 Docker Compose: legg maskinrommet inn i server-stacken. Intern nettverkstilgang til PG og STDB.
|
- [ ] 2.6 Docker Compose: legg maskinrommet inn i server-stacken. Intern nettverkstilgang til PG og STDB.
|
||||||
|
|
||||||
## Fase 3: Frontend — skjelett
|
## Fase 3: Frontend — skjelett
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue