// 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::{Multipart, State}, http::StatusCode, Json}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use crate::auth::AuthUser; use crate::AppState; /// Maks filstørrelse for upload: 100 MB. const MAX_UPLOAD_SIZE: usize = 100 * 1024 * 1024; // ============================================================================= // 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 og kontekstbasert identitet // ============================================================================= /// Løser brukerens identitet i en kommunikasjonskontekst. /// /// Hvis brukeren har et alias som er deltaker (owner, member_of, host_of) /// i den gitte kommunikasjonsnoden, returneres alias-nodens ID. /// Ellers returneres brukerens hoved-node_id. /// /// Dette gjør at meldinger i en kommunikasjon automatisk krediteres /// aliaset — f.eks. "Bjørn" i en podcast-samtale i stedet for "Vegard". /// /// Ref: docs/primitiver/nodes.md (created_by), docs/primitiver/edges.md (alias) async fn resolve_context_identity( db: &PgPool, user_id: Uuid, context_id: Uuid, ) -> Result { // Finn brukerens alias som er deltaker i kommunikasjonsnoden. // Alias-edge: user_id --alias(system=true)--> alias_id // Deltaker-edge: alias_id --owner/member_of/host_of--> context_id let alias_id = sqlx::query_scalar::<_, Uuid>( r#" SELECT e_alias.target_id FROM edges e_alias JOIN edges e_participant ON e_participant.source_id = e_alias.target_id WHERE e_alias.source_id = $1 AND e_alias.edge_type = 'alias' AND e_alias.system = true AND e_participant.target_id = $2 AND e_participant.edge_type IN ('owner', 'member_of', 'host_of') LIMIT 1 "#, ) .bind(user_id) .bind(context_id) .fetch_optional(db) .await?; Ok(alias_id.unwrap_or(user_id)) } /// Henter alle alias-IDer for en bruker (via system alias-edges). #[allow(dead_code)] async fn user_alias_ids(db: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { let ids = sqlx::query_scalar::<_, Uuid>( r#" SELECT target_id FROM edges WHERE source_id = $1 AND edge_type = 'alias' AND system = true "#, ) .bind(user_id) .fetch_all(db) .await?; Ok(ids) } /// Sjekker om brukeren har skrivetilgang til en node. /// Returnerer true hvis brukeren (eller et av brukerens aliaser) er created_by, /// eller har owner/admin-edge til noden. async fn user_can_modify_node(db: &PgPool, user_id: Uuid, node_id: Uuid) -> Result { // Sjekk direkte eierskap, alias-eierskap, eller admin/owner-edge 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') ) OR EXISTS( -- Sjekk om created_by er et av brukerens aliaser SELECT 1 FROM nodes n JOIN edges e_alias ON e_alias.target_id = n.created_by WHERE n.id = $1 AND e_alias.source_id = $2 AND e_alias.edge_type = 'alias' AND e_alias.system = true ) "#, ) .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 (direkte eller via alias), /// 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 ) OR EXISTS( -- Sjekk om created_by er et av brukerens aliaser SELECT 1 FROM edges e JOIN edges e_alias ON e_alias.target_id = e.created_by WHERE e.id = $1 AND e_alias.source_id = $2 AND e_alias.edge_type = 'alias' AND e_alias.system = true ) "#, ) .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, /// Kontekst-node (f.eks. kommunikasjonsnode). Hvis satt, opprettes /// automatisk en `belongs_to`-edge fra den nye noden til kontekstnoden. /// Ref: docs/retninger/universell_input.md (kontekst-arv). pub context_id: Option, } #[derive(Serialize)] pub struct CreateNodeResponse { pub node_id: Uuid, /// Edge-ID for automatisk opprettet `belongs_to`-edge (kun ved context_id). #[serde(skip_serializing_if = "Option::is_none")] pub belongs_to_edge_id: Option, } /// POST /intentions/create_node /// /// Validerer input, skriver til STDB (instant), spawner async PG-skriving. /// Returnerer node_id umiddelbart. /// /// Hvis `context_id` er satt, opprettes automatisk en `belongs_to`-edge /// fra den nye noden til kontekstnoden. Kontekstnoden må eksistere og /// være en kommunikasjonsnode. Ref: docs/retninger/universell_input.md 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:?}" ))); } // -- Valider context_id hvis satt -- if let Some(ctx_id) = req.context_id { let ctx_node = sqlx::query_as::<_, NodeKindRow>( "SELECT node_kind FROM nodes WHERE id = $1", ) .bind(ctx_id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!("PG-feil ved context_id-sjekk: {e}"); internal_error("Databasefeil ved validering av context_id") })?; match ctx_node { None => return Err(bad_request(&format!("context_id {} finnes ikke", ctx_id))), Some(row) if row.node_kind != "communication" => { return Err(bad_request(&format!( "context_id {} er en '{}'-node, ikke en kommunikasjonsnode", ctx_id, row.node_kind ))); } _ => {} // OK — kommunikasjonsnode } } 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(); // -- Kontekstbasert identitet (oppgave 8.2) -- // Hvis context_id er satt, sjekk om brukeren har et alias som er // deltaker i kommunikasjonsnoden. I så fall brukes aliaset som created_by. let effective_identity = if let Some(ctx_id) = req.context_id { resolve_context_identity(&state.db, user.node_id, ctx_id) .await .map_err(|e| { tracing::error!("PG-feil ved identitetsoppslag: {e}"); internal_error("Databasefeil ved identitetsoppslag") })? } else { user.node_id }; // -- Generer UUIDv7 (tidssortert) -- let node_id = Uuid::now_v7(); let node_id_str = node_id.to_string(); let created_by_str = effective_identity.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 = %effective_identity, auth_user = %user.node_id, context_id = ?req.context_id, alias_used = %(effective_identity != 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, effective_identity, ); // -- Kontekst-arv: automatisk belongs_to-edge -- let belongs_to_edge_id = if let Some(ctx_id) = req.context_id { let edge_id = Uuid::now_v7(); let edge_id_str = edge_id.to_string(); let ctx_id_str = ctx_id.to_string(); let bt_metadata = serde_json::json!({}); let bt_metadata_str = bt_metadata.to_string(); state .stdb .create_edge( &edge_id_str, &node_id_str, // source = ny node &ctx_id_str, // target = kommunikasjonsnoden "belongs_to", &bt_metadata_str, false, &created_by_str, ) .await .map_err(|e| stdb_error("create_edge (belongs_to)", e))?; tracing::info!( edge_id = %edge_id, node_id = %node_id, context_id = %ctx_id, "belongs_to-edge opprettet i STDB (kontekst-arv)" ); // belongs_to er ikke tilgangsgivende — enkel PG-insert spawn_pg_insert_edge( state.db.clone(), state.stdb.clone(), edge_id, node_id, ctx_id, "belongs_to".to_string(), bt_metadata, false, effective_identity, ); Some(edge_id) } else { None }; // -- Agent-trigger: sjekk om kommunikasjonsnoden har en agent-deltaker -- if let Some(ctx_id) = req.context_id { let db_clone = state.db.clone(); let user_node_id = user.node_id; let created_node_id = node_id; tokio::spawn(async move { match crate::agent::find_agent_participant(&db_clone, ctx_id).await { Ok(Some(agent_id)) if agent_id != effective_identity => { // Agent funnet, og melding er ikke fra agenten selv let payload = serde_json::json!({ "communication_id": ctx_id.to_string(), "message_id": created_node_id.to_string(), "agent_node_id": agent_id.to_string(), "sender_node_id": user_node_id.to_string() }); match crate::jobs::enqueue(&db_clone, "agent_respond", payload, None, 8).await { Ok(job_id) => { tracing::info!( job_id = %job_id, communication_id = %ctx_id, agent_node_id = %agent_id, "agent_respond-jobb lagt i kø" ); } Err(e) => { tracing::error!( communication_id = %ctx_id, error = %e, "Kunne ikke legge agent_respond-jobb i kø" ); } } } Ok(_) => {} // Ingen agent, eller melding fra agenten selv Err(e) => { tracing::error!( communication_id = %ctx_id, error = %e, "Feil ved agent-deltaker-sjekk" ); } } }); } Ok(Json(CreateNodeResponse { node_id, belongs_to_edge_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 })) } // ============================================================================= // upload_media // ============================================================================= #[derive(Serialize)] pub struct UploadMediaResponse { /// ID til den opprettede media-noden. pub media_node_id: Uuid, /// SHA-256 hash (CAS-nøkkel). pub cas_hash: String, /// Filstørrelse i bytes. pub size_bytes: u64, /// `true` hvis filen allerede fantes i CAS (deduplisert). pub already_existed: bool, /// Edge-ID for `has_media`-edge (kun hvis source_id ble oppgitt). #[serde(skip_serializing_if = "Option::is_none")] pub has_media_edge_id: Option, } /// POST /intentions/upload_media /// /// Mottar en fil via multipart form data, lagrer i CAS, oppretter en /// media-node med CAS-metadata. Hvis `source_id` er oppgitt, opprettes /// en `has_media`-edge fra kildenoden til den nye media-noden. /// /// Multipart-felter: /// - `file` (påkrevd): Binærfilen som skal lastes opp. /// - `source_id` (valgfritt): Node-ID å koble media til via `has_media`-edge. /// - `visibility` (valgfritt): Synlighet for media-noden. Default: "hidden". /// - `title` (valgfritt): Tittel for media-noden (default: filnavn). /// /// Ref: docs/primitiver/nodes.md (media), docs/retninger/universell_input.md pub async fn upload_media( State(state): State, user: AuthUser, mut multipart: Multipart, ) -> Result, (StatusCode, Json)> { let mut file_data: Option> = None; let mut file_name: Option = None; let mut content_type: Option = None; let mut source_id: Option = None; let mut visibility = "hidden".to_string(); let mut title: Option = None; // -- Parse multipart-felter -- while let Some(field) = multipart.next_field().await.map_err(|e| { bad_request(&format!("Ugyldig multipart-data: {e}")) })? { let field_name = field.name().unwrap_or("").to_string(); match field_name.as_str() { "file" => { file_name = field.file_name().map(|s| s.to_string()); content_type = field.content_type().map(|s| s.to_string()); let bytes = field.bytes().await.map_err(|e| { bad_request(&format!("Kunne ikke lese fil: {e}")) })?; if bytes.len() > MAX_UPLOAD_SIZE { return Err(bad_request(&format!( "Filen er for stor: {} bytes (maks {} bytes)", bytes.len(), MAX_UPLOAD_SIZE ))); } if bytes.is_empty() { return Err(bad_request("Filen er tom")); } file_data = Some(bytes.to_vec()); } "source_id" => { let text = field.text().await.map_err(|e| { bad_request(&format!("Kunne ikke lese source_id: {e}")) })?; let id = Uuid::parse_str(&text).map_err(|_| { bad_request(&format!("Ugyldig source_id UUID: '{text}'")) })?; source_id = Some(id); } "visibility" => { let text = field.text().await.map_err(|e| { bad_request(&format!("Kunne ikke lese visibility: {e}")) })?; if !VALID_VISIBILITIES.contains(&text.as_str()) { return Err(bad_request(&format!( "Ugyldig visibility: '{text}'. Gyldige verdier: {VALID_VISIBILITIES:?}" ))); } visibility = text; } "title" => { let text = field.text().await.map_err(|e| { bad_request(&format!("Kunne ikke lese title: {e}")) })?; title = Some(text); } _ => { // Ignorer ukjente felter } } } let data = file_data.ok_or_else(|| bad_request("Mangler 'file'-felt i multipart-data"))?; // -- Valider source_id hvis oppgitt -- if let Some(src_id) = source_id { let exists = node_exists(&state.db, src_id).await.map_err(|e| { tracing::error!("PG-feil ved nodesjekk: {e}"); internal_error("Databasefeil ved validering av source_id") })?; if !exists { return Err(bad_request(&format!("source_id {} finnes ikke", src_id))); } } // -- Lagre i CAS -- let cas_result = state.cas.store(&data).await.map_err(|e| { tracing::error!("CAS-lagring feilet: {e}"); internal_error(&format!("Kunne ikke lagre fil i CAS: {e}")) })?; // -- Opprett media-node -- let media_node_id = Uuid::now_v7(); let media_node_id_str = media_node_id.to_string(); let created_by_str = user.node_id.to_string(); let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string()); let node_title = title.unwrap_or_else(|| file_name.unwrap_or_default()); let metadata = serde_json::json!({ "cas_hash": cas_result.hash, "mime": mime, "size_bytes": cas_result.size, }); let metadata_str = metadata.to_string(); // Skriv til SpacetimeDB (instant) state .stdb .create_node( &media_node_id_str, "media", &node_title, "", &visibility, &metadata_str, &created_by_str, ) .await .map_err(|e| stdb_error("create_node (media)", e))?; tracing::info!( media_node_id = %media_node_id, cas_hash = %cas_result.hash, size = cas_result.size, mime = %mime, already_existed = cas_result.already_existed, created_by = %user.node_id, "Media-node opprettet i STDB" ); // Spawn async PG-skriving for media-noden spawn_pg_insert_node( state.db.clone(), media_node_id, "media".to_string(), node_title, String::new(), visibility, metadata, user.node_id, ); // -- Opprett has_media-edge hvis source_id er oppgitt -- let has_media_edge_id = if let Some(src_id) = source_id { let edge_id = Uuid::now_v7(); let edge_id_str = edge_id.to_string(); let src_id_str = src_id.to_string(); let edge_metadata = serde_json::json!({}); let edge_metadata_str = edge_metadata.to_string(); state .stdb .create_edge( &edge_id_str, &src_id_str, // source = innholdsnoden &media_node_id_str, // target = media-noden "has_media", &edge_metadata_str, false, &created_by_str, ) .await .map_err(|e| stdb_error("create_edge (has_media)", e))?; tracing::info!( edge_id = %edge_id, source_id = %src_id, media_node_id = %media_node_id, "has_media-edge opprettet i STDB" ); // has_media er ikke tilgangsgivende — enkel PG-insert spawn_pg_insert_edge( state.db.clone(), state.stdb.clone(), edge_id, src_id, media_node_id, "has_media".to_string(), edge_metadata, false, user.node_id, ); Some(edge_id) } else { None }; // -- Enqueue transkripsjons-jobb for lydfiler -- if is_audio_mime(&mime) { let payload = serde_json::json!({ "media_node_id": media_node_id, "cas_hash": cas_result.hash, "mime": mime, "language": "no", }); // Finn collection_node_id fra source_id sin eier-kjede (valgfritt) let collection_id = if let Some(src_id) = source_id { find_collection_for_node(&state.db, src_id).await.ok().flatten() } else { None }; match crate::jobs::enqueue(&state.db, "whisper_transcribe", payload, collection_id, 5).await { Ok(job_id) => { tracing::info!( job_id = %job_id, media_node_id = %media_node_id, "Transkripsjons-jobb opprettet" ); } Err(e) => { // Ikke feil ut hele uploaden — logg og fortsett tracing::error!( media_node_id = %media_node_id, error = %e, "Kunne ikke opprette transkripsjons-jobb" ); } } } Ok(Json(UploadMediaResponse { media_node_id, cas_hash: cas_result.hash, size_bytes: cas_result.size, already_existed: cas_result.already_existed, has_media_edge_id, })) } /// Sjekker om en MIME-type er en lydtype som Whisper kan transkribere. fn is_audio_mime(mime: &str) -> bool { mime.starts_with("audio/") } /// Forsøker å finne collection_node_id for en node via belongs_to-edges. async fn find_collection_for_node(db: &PgPool, node_id: Uuid) -> Result, sqlx::Error> { let row = sqlx::query_scalar::<_, Uuid>( r#" SELECT e.target_id FROM edges e JOIN nodes n ON n.id = e.target_id WHERE e.source_id = $1 AND e.edge_type = 'belongs_to' AND n.node_kind = 'collection' LIMIT 1 "#, ) .bind(node_id) .fetch_optional(db) .await?; Ok(row) } // ============================================================================= // create_alias // ============================================================================= #[derive(Deserialize)] pub struct CreateAliasRequest { /// Visningsnavn for aliaset (f.eks. "Bjørn" for en podcastvert-identitet). pub title: String, /// Valgfri metadata (f.eks. display_name, bio, avatar). pub metadata: Option, } #[derive(Serialize)] pub struct CreateAliasResponse { /// ID til den nye alias-noden. pub alias_node_id: Uuid, /// ID til alias-edgen (system=true, usynlig for traversering). pub alias_edge_id: Uuid, } /// POST /intentions/create_alias /// /// Oppretter en alias-node (node_kind='person') og en `alias`-edge /// (system=true) fra brukerens hovednode til aliasnoden. Alias-edgen /// er usynlig for traversering — RLS-policyen filtrerer system-edges. /// /// Bruksområde: en bruker kan ha flere identiteter (f.eks. Vegard /// som seg selv og "Bjørn" som podcastvert). Oppgave 8.2 vil bruke /// aliaset til å sette created_by kontekstbasert. /// /// Ref: docs/primitiver/edges.md (systemedges), docs/primitiver/nodes.md pub async fn create_alias( State(state): State, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let title = req.title.trim().to_string(); if title.is_empty() { return Err(bad_request("Alias-tittel kan ikke være tom")); } let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({})); // -- Generer IDer -- let alias_node_id = Uuid::now_v7(); let alias_edge_id = Uuid::now_v7(); let alias_node_id_str = alias_node_id.to_string(); let alias_edge_id_str = alias_edge_id.to_string(); let user_node_id_str = user.node_id.to_string(); let metadata_str = metadata.to_string(); // -- Skriv alias-node til STDB -- state .stdb .create_node( &alias_node_id_str, "person", &title, "", "hidden", &metadata_str, &user_node_id_str, ) .await .map_err(|e| stdb_error("create_node (alias)", e))?; // -- Skriv alias-edge til STDB (system=true) -- state .stdb .create_edge( &alias_edge_id_str, &user_node_id_str, &alias_node_id_str, "alias", "{}", true, &user_node_id_str, ) .await .map_err(|e| stdb_error("create_edge (alias)", e))?; tracing::info!( alias_node_id = %alias_node_id, alias_edge_id = %alias_edge_id, user_node_id = %user.node_id, title = %title, "Alias opprettet i STDB" ); // -- Spawn async PG-skriving: node + edge -- let pg_db = state.db.clone(); let pg_title = title.clone(); let pg_metadata = metadata.clone(); let pg_user_node_id = user.node_id; tokio::spawn(async move { // 1. Skriv alias-noden let node_result = sqlx::query( r#" INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by) VALUES ($1, 'person', $2, 'hidden'::visibility, $3, $4) "#, ) .bind(alias_node_id) .bind(&pg_title) .bind(&pg_metadata) .bind(pg_user_node_id) .execute(&pg_db) .await; match node_result { Ok(_) => { tracing::info!(alias_node_id = %alias_node_id, "Alias-node persistert til PostgreSQL"); } Err(e) => { tracing::error!(alias_node_id = %alias_node_id, error = %e, "Kunne ikke persistere alias-node til PostgreSQL"); return; } } // 2. Skriv alias-edge (system=true) let edge_result = sqlx::query( r#" INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) VALUES ($1, $2, $3, 'alias', '{}', true, $4) "#, ) .bind(alias_edge_id) .bind(pg_user_node_id) .bind(alias_node_id) .bind(pg_user_node_id) .execute(&pg_db) .await; match edge_result { Ok(_) => { tracing::info!(alias_edge_id = %alias_edge_id, "Alias-edge persistert til PostgreSQL"); } Err(e) => { tracing::error!(alias_edge_id = %alias_edge_id, error = %e, "Kunne ikke persistere alias-edge til PostgreSQL"); } } }); Ok(Json(CreateAliasResponse { alias_node_id, alias_edge_id, })) } // ============================================================================= // Bakgrunns-PG-operasjoner // ============================================================================= #[derive(sqlx::FromRow)] struct NodeRow { node_kind: String, title: Option, content: Option, visibility: String, metadata: serde_json::Value, } /// Enkel rad for å sjekke node_kind (brukes ved context_id-validering). #[derive(sqlx::FromRow)] struct NodeKindRow { node_kind: String, } /// 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"); } } }); } // ============================================================================= // POST /intentions/update_segment — rediger transkripsjons-segment // ============================================================================= #[derive(Deserialize)] pub struct UpdateSegmentRequest { /// Segment-ID (primary key i transcription_segments). pub segment_id: i64, /// Ny tekst for segmentet. pub content: String, } #[derive(Serialize)] pub struct UpdateSegmentResponse { pub segment_id: i64, pub edited: bool, } /// POST /intentions/update_segment /// /// Oppdaterer teksten i et transkripsjons-segment og setter `edited = true`. /// Krever at brukeren har skrivetilgang til noden segmentet tilhører. pub async fn update_segment( State(state): State, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let content = req.content.trim().to_string(); if content.is_empty() { return Err(bad_request("Innhold kan ikke være tomt")); } // Finn noden dette segmentet tilhører let segment_node: Option<(Uuid,)> = sqlx::query_as( "SELECT node_id FROM transcription_segments WHERE id = $1", ) .bind(req.segment_id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved oppslag av segment"); internal_error("Databasefeil") })?; let Some((node_id,)) = segment_node else { return Err(bad_request("Segment finnes ikke")); }; // Verifiser skrivetilgang let can_modify = user_can_modify_node(&state.db, user.node_id, node_id) .await .map_err(|e| { tracing::error!(error = %e, "Tilgangssjekk feilet"); internal_error("Databasefeil ved tilgangssjekk") })?; if !can_modify { return Err(forbidden("Ikke tilgang til å redigere dette segmentet")); } // Oppdater segmentet sqlx::query( "UPDATE transcription_segments SET content = $1, edited = true WHERE id = $2", ) .bind(&content) .bind(req.segment_id) .execute(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Kunne ikke oppdatere segment"); internal_error("Databasefeil ved oppdatering") })?; tracing::info!( segment_id = req.segment_id, node_id = %node_id, user = %user.node_id, "Segment redigert" ); Ok(Json(UpdateSegmentResponse { segment_id: req.segment_id, edited: true, })) } // ============================================================================= // POST /intentions/retranscribe — trigger re-transkripsjon for eksisterende media-node // ============================================================================= #[derive(Deserialize)] pub struct RetranscribeRequest { /// Media-node-ID å re-transkribere. pub node_id: Uuid, } #[derive(Serialize)] pub struct RetranscribeResponse { pub job_id: Uuid, } /// POST /intentions/retranscribe /// /// Trigger en ny transkripsjons-jobb for en eksisterende media-node. /// Henter CAS-hash og MIME fra nodens metadata. Krever skrivetilgang. pub async fn retranscribe( State(state): State, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // Verifiser skrivetilgang let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id) .await .map_err(|e| { tracing::error!(error = %e, "Tilgangssjekk feilet"); internal_error("Databasefeil ved tilgangssjekk") })?; if !can_modify { return Err(forbidden("Ikke tilgang til å re-transkribere denne noden")); } // Hent metadata for CAS-hash og MIME let node: Option<(serde_json::Value,)> = sqlx::query_as( "SELECT metadata FROM nodes WHERE id = $1 AND node_kind = 'media'", ) .bind(req.node_id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av node"); internal_error("Databasefeil") })?; let Some((metadata,)) = node else { return Err(bad_request("Noden finnes ikke eller er ikke en media-node")); }; let cas_hash = metadata["cas_hash"] .as_str() .ok_or_else(|| bad_request("Noden mangler cas_hash i metadata"))?; let mime = metadata["mime"] .as_str() .unwrap_or("audio/mpeg"); // Finn collection fra eier-kjede let collection_id = find_collection_for_node(&state.db, req.node_id) .await .ok() .flatten(); let payload = serde_json::json!({ "media_node_id": req.node_id, "cas_hash": cas_hash, "mime": mime, "language": "no", }); let job_id = crate::jobs::enqueue(&state.db, "whisper_transcribe", payload, collection_id, 5) .await .map_err(|e| { tracing::error!(error = %e, "Kunne ikke opprette re-transkripsjons-jobb"); internal_error("Kunne ikke starte re-transkripsjon") })?; tracing::info!( job_id = %job_id, node_id = %req.node_id, user = %user.node_id, "Re-transkripsjons-jobb opprettet" ); Ok(Json(RetranscribeResponse { job_id })) } // ============================================================================= // POST /intentions/resolve_retranscription — anvend brukerens segment-valg // ============================================================================= #[derive(Deserialize)] pub struct SegmentChoice { /// Sekvensnummer i den nye transkripsjonen. pub seq: i32, /// "new" = behold ny versjon, "old" = behold gammel versjon. pub choice: String, } #[derive(Deserialize)] pub struct ResolveRetranscriptionRequest { /// Media-node-ID. pub node_id: Uuid, /// `transcribed_at` for den nye versjonen. pub new_version: String, /// `transcribed_at` for den gamle versjonen. pub old_version: String, /// Per-segment-valg. pub choices: Vec, } #[derive(Serialize)] pub struct ResolveRetranscriptionResponse { pub resolved: bool, pub kept_old: i32, pub kept_new: i32, } /// POST /intentions/resolve_retranscription /// /// Anvender brukerens per-segment-valg etter re-transkripsjon. /// For segmenter der brukeren velger "old", kopieres innholdet fra /// den gamle versjonen til den nye. Gamle versjoner slettes etterpå. pub async fn resolve_retranscription( State(state): State, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // Verifiser skrivetilgang let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id) .await .map_err(|e| { tracing::error!(error = %e, "Tilgangssjekk feilet"); internal_error("Databasefeil ved tilgangssjekk") })?; if !can_modify { return Err(forbidden("Ikke tilgang til å endre segmenter")); } let new_ts: chrono::DateTime = req.new_version.parse() .map_err(|_| bad_request("Ugyldig new_version-tidsstempel"))?; let old_ts: chrono::DateTime = req.old_version.parse() .map_err(|_| bad_request("Ugyldig old_version-tidsstempel"))?; // Hent gamle segmenter (indeksert på seq) let old_segments: Vec<(i32, String, bool)> = sqlx::query_as( "SELECT seq, content, edited FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq", ) .bind(req.node_id) .bind(old_ts) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av gamle segmenter"); internal_error("Databasefeil") })?; let old_by_seq: std::collections::HashMap = old_segments .into_iter() .map(|(seq, content, edited)| (seq, (content, edited))) .collect(); let mut kept_old = 0i32; let mut kept_new = 0i32; let mut tx = state.db.begin().await.map_err(|e| { tracing::error!(error = %e, "Transaksjon feilet"); internal_error("Databasefeil") })?; for choice in &req.choices { if choice.choice == "old" { if let Some((old_content, old_edited)) = old_by_seq.get(&choice.seq) { // Kopier gammel tekst til nytt segment, bevar edited-flagg sqlx::query( "UPDATE transcription_segments SET content = $1, edited = $2 WHERE node_id = $3 AND transcribed_at = $4 AND seq = $5", ) .bind(old_content) .bind(*old_edited) .bind(req.node_id) .bind(new_ts) .bind(choice.seq) .execute(&mut *tx) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved oppdatering av segment"); internal_error("Databasefeil ved oppdatering") })?; kept_old += 1; } } else { kept_new += 1; } } // Slett alle gamle versjoner (ikke bare den valgte — rydd opp) sqlx::query( "DELETE FROM transcription_segments WHERE node_id = $1 AND transcribed_at < $2", ) .bind(req.node_id) .bind(new_ts) .execute(&mut *tx) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved sletting av gamle segmenter"); internal_error("Databasefeil ved opprydding") })?; // Oppdater nodens content med den endelige transkripsjonen let final_segments: Vec<(String,)> = sqlx::query_as( "SELECT content FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq", ) .bind(req.node_id) .bind(new_ts) .fetch_all(&mut *tx) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av endelige segmenter"); internal_error("Databasefeil") })?; let transcript_text: String = final_segments .iter() .map(|(c,)| c.trim()) .collect::>() .join(" "); sqlx::query("UPDATE nodes SET content = $1 WHERE id = $2") .bind(&transcript_text) .bind(req.node_id) .execute(&mut *tx) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved oppdatering av node-innhold"); internal_error("Databasefeil") })?; tx.commit().await.map_err(|e| { tracing::error!(error = %e, "Commit feilet"); internal_error("Databasefeil ved commit") })?; tracing::info!( node_id = %req.node_id, kept_old = kept_old, kept_new = kept_new, "Re-transkripsjon løst" ); Ok(Json(ResolveRetranscriptionResponse { resolved: true, kept_old, kept_new, })) }