// Tunge spørringer — lesestien via PostgreSQL med RLS. // // For søk, statistikk, og graf-traversering brukes PG direkte (ikke STDB). // Alle spørringer kjøres med SET LOCAL ROLE synops_reader, som er underlagt // RLS-policies. Brukerens node_id settes som sesjonsvariabel. // // Ref: docs/retninger/datalaget.md (tunge spørringer-seksjonen) use axum::{extract::State, http::StatusCode, Json}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use crate::auth::AuthUser; use crate::AppState; use crate::intentions::ErrorResponse; // ============================================================================= // GET /query/segments — transkripsjons-segmenter for en node // ============================================================================= #[derive(Deserialize)] pub struct QuerySegmentsRequest { /// Node-ID for media-noden. pub node_id: Uuid, } #[derive(Serialize, sqlx::FromRow)] pub struct SegmentResult { pub id: i64, pub seq: i32, pub start_ms: i32, pub end_ms: i32, pub content: String, pub edited: bool, } #[derive(Serialize)] pub struct QuerySegmentsResponse { pub segments: Vec, pub transcribed_at: Option, } /// GET /query/segments?node_id=... /// /// Henter nyeste transkripsjons-segmenter for en media-node. /// Verifiserer tilgang via RLS på nodes-tabellen først. pub async fn query_segments( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let result = run_query_segments(&state.db, user.node_id, params.node_id).await; match result { Ok(resp) => Ok(Json(resp)), Err(e) => { tracing::error!(error = %e, "query_segments feilet"); Err(internal_error("Databasefeil ved henting av segmenter")) } } } async fn run_query_segments( db: &PgPool, user_node_id: Uuid, node_id: Uuid, ) -> Result { // Verifiser tilgang: sjekk at brukeren kan se noden via RLS let mut tx = db.begin().await?; set_rls_context(&mut tx, user_node_id).await?; let exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)", ) .bind(node_id) .fetch_one(&mut *tx) .await?; tx.commit().await?; if !exists { return Ok(QuerySegmentsResponse { segments: vec![], transcribed_at: None, }); } // Hent nyeste transcribed_at for denne noden let latest: Option<(chrono::DateTime,)> = sqlx::query_as( "SELECT transcribed_at FROM transcription_segments WHERE node_id = $1 ORDER BY transcribed_at DESC LIMIT 1", ) .bind(node_id) .fetch_optional(db) .await?; let Some((transcribed_at,)) = latest else { return Ok(QuerySegmentsResponse { segments: vec![], transcribed_at: None, }); }; // Hent alle segmenter for nyeste kjøring let segments = sqlx::query_as::<_, SegmentResult>( r#" SELECT id, seq, start_ms, end_ms, content, edited FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq "#, ) .bind(node_id) .bind(transcribed_at) .fetch_all(db) .await?; Ok(QuerySegmentsResponse { segments, transcribed_at: Some(transcribed_at.to_rfc3339()), }) } // ============================================================================= // RLS-kontekst // ============================================================================= /// Setter opp RLS-kontekst for en transaksjon. /// Etter dette kallet er alle SELECT-spørringer filtrert via node_access. /// /// MÅ kalles innenfor en transaksjon (SET LOCAL gjelder kun innenfor tx). async fn set_rls_context( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, user_node_id: Uuid, ) -> Result<(), sqlx::Error> { // Sett brukerens node_id som sesjonsvariabel sqlx::query(&format!( "SET LOCAL app.current_node_id = '{}'", user_node_id )) .execute(&mut **tx) .await?; // Bytt til synops_reader-rollen (underlagt RLS) sqlx::query("SET LOCAL ROLE synops_reader") .execute(&mut **tx) .await?; Ok(()) } // ============================================================================= // GET /query/nodes — søk og filtrering av noder // ============================================================================= #[derive(Deserialize)] pub struct QueryNodesRequest { /// Fritekst-søk i tittel og innhold. Valgfritt. pub q: Option, /// Filtrer på node_kind. Valgfritt. pub kind: Option, /// Maks antall resultater. Default: 50. pub limit: Option, /// Offset for paginering. Default: 0. pub offset: Option, } #[derive(Serialize, sqlx::FromRow)] pub struct QueryNodeResult { pub id: Uuid, pub node_kind: String, pub title: Option, pub content: Option, pub visibility: String, pub metadata: serde_json::Value, pub created_at: chrono::DateTime, pub created_by: Option, } #[derive(Serialize)] pub struct QueryNodesResponse { pub nodes: Vec, pub total: i64, } fn internal_error(msg: &str) -> (StatusCode, Json) { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string(), }), ) } /// GET /query/nodes?q=...&kind=...&limit=...&offset=... /// /// Søk og filtrering av noder. Bruker RLS — returnerer kun noder /// brukeren har tilgang til. pub async fn query_nodes( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let limit = params.limit.unwrap_or(50).min(200); let offset = params.offset.unwrap_or(0).max(0); let result = run_query_nodes(&state.db, user.node_id, ¶ms.q, ¶ms.kind, limit, offset).await; match result { Ok(resp) => Ok(Json(resp)), Err(e) => { tracing::error!(error = %e, "query_nodes feilet"); Err(internal_error("Databasefeil ved søk")) } } } async fn run_query_nodes( db: &PgPool, user_node_id: Uuid, q: &Option, kind: &Option, limit: i64, offset: i64, ) -> Result { let mut tx = db.begin().await?; set_rls_context(&mut tx, user_node_id).await?; // Bygg spørring basert på filtre let (nodes, total) = if let Some(search) = q.as_deref().filter(|s| !s.is_empty()) { let search_pattern = format!("%{}%", search.replace('%', "\\%").replace('_', "\\_")); let nodes = sqlx::query_as::<_, QueryNodeResult>( r#" SELECT id, node_kind, title, content, visibility::text as visibility, metadata, created_at, created_by FROM nodes WHERE (title ILIKE $1 OR content ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(&search_pattern) .bind(limit) .bind(offset) .fetch_all(&mut *tx) .await?; let total: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM nodes WHERE (title ILIKE $1 OR content ILIKE $1)", ) .bind(&search_pattern) .fetch_one(&mut *tx) .await?; (nodes, total.0) } else if let Some(kind) = kind.as_deref().filter(|s| !s.is_empty()) { let nodes = sqlx::query_as::<_, QueryNodeResult>( r#" SELECT id, node_kind, title, content, visibility::text as visibility, metadata, created_at, created_by FROM nodes WHERE node_kind = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(kind) .bind(limit) .bind(offset) .fetch_all(&mut *tx) .await?; let total: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM nodes WHERE node_kind = $1", ) .bind(kind) .fetch_one(&mut *tx) .await?; (nodes, total.0) } else { let nodes = sqlx::query_as::<_, QueryNodeResult>( r#" SELECT id, node_kind, title, content, visibility::text as visibility, metadata, created_at, created_by FROM nodes ORDER BY created_at DESC LIMIT $1 OFFSET $2 "#, ) .bind(limit) .bind(offset) .fetch_all(&mut *tx) .await?; let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM nodes") .fetch_one(&mut *tx) .await?; (nodes, total.0) }; // Transaksjon avsluttes — SET LOCAL tilbakestilles automatisk tx.commit().await?; Ok(QueryNodesResponse { nodes, total }) }