synops/maskinrommet/src/queries.rs
vegard 0967e43af8 Implementer transkripsjonsvisning med segmenter (oppgave 7.6)
Backend:
- GET /query/segments?node_id=... — henter nyeste segmenter for en media-node
  med RLS-basert tilgangssjekk via nodes-tabellen
- POST /intentions/update_segment — redigerer segmenttekst, setter edited=true

Frontend:
- TranscriptionView.svelte: universell komponent for segment-visning med
  tidsstempler, avspillingsknapp per segment, og redigerbare tekstfelt
- AudioPlayer: integrert med TranscriptionView når segmenter finnes,
  faller tilbake til flat tekst ellers
- Mottak og chat-sider oppdatert med nodeId/accessToken for segment-lasting
- Fikser duration_ms → sekunder-konvertering i metadata-oppslag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:29:43 +01:00

307 lines
9 KiB
Rust

// 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<SegmentResult>,
pub transcribed_at: Option<String>,
}
/// 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<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<QuerySegmentsRequest>,
) -> Result<Json<QuerySegmentsResponse>, (StatusCode, Json<ErrorResponse>)> {
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<QuerySegmentsResponse, sqlx::Error> {
// 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<chrono::Utc>,)> = 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<String>,
/// Filtrer på node_kind. Valgfritt.
pub kind: Option<String>,
/// Maks antall resultater. Default: 50.
pub limit: Option<i64>,
/// Offset for paginering. Default: 0.
pub offset: Option<i64>,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct QueryNodeResult {
pub id: Uuid,
pub node_kind: String,
pub title: Option<String>,
pub content: Option<String>,
pub visibility: String,
pub metadata: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
pub created_by: Option<Uuid>,
}
#[derive(Serialize)]
pub struct QueryNodesResponse {
pub nodes: Vec<QueryNodeResult>,
pub total: i64,
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
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<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<QueryNodesRequest>,
) -> Result<Json<QueryNodesResponse>, (StatusCode, Json<ErrorResponse>)> {
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, &params.q, &params.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<String>,
kind: &Option<String>,
limit: i64,
offset: i64,
) -> Result<QueryNodesResponse, sqlx::Error> {
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 })
}