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>
307 lines
9 KiB
Rust
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, ¶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<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 })
|
|
}
|