// 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 axum::response::{IntoResponse, Response}; 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()), }) } // ============================================================================= // GET /query/transcription_versions — alle transkripsjonsversjoner for en node // ============================================================================= #[derive(Deserialize)] pub struct QueryVersionsRequest { pub node_id: Uuid, } #[derive(Serialize)] pub struct TranscriptionVersion { pub transcribed_at: String, pub segment_count: i64, pub edited_count: i64, } #[derive(Serialize)] pub struct QueryVersionsResponse { pub versions: Vec, } /// GET /query/transcription_versions?node_id=... /// /// Lister alle transkripsjonsversjoner for en node, sortert nyeste først. pub async fn query_transcription_versions( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { // Verifiser tilgang let mut tx = state.db.begin().await.map_err(|e| { tracing::error!(error = %e, "Transaksjon feilet"); internal_error("Databasefeil") })?; set_rls_context(&mut tx, user.node_id).await.map_err(|e| { tracing::error!(error = %e, "RLS-kontekst feilet"); internal_error("Databasefeil") })?; let exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)", ) .bind(params.node_id) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!(error = %e, "Tilgangssjekk feilet"); internal_error("Databasefeil") })?; tx.commit().await.map_err(|e| { tracing::error!(error = %e, "Commit feilet"); internal_error("Databasefeil") })?; if !exists { return Ok(Json(QueryVersionsResponse { versions: vec![] })); } let rows: Vec<(chrono::DateTime, i64, i64)> = sqlx::query_as( r#" SELECT transcribed_at, COUNT(*) as segment_count, COUNT(*) FILTER (WHERE edited) as edited_count FROM transcription_segments WHERE node_id = $1 GROUP BY transcribed_at ORDER BY transcribed_at DESC "#, ) .bind(params.node_id) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av versjoner"); internal_error("Databasefeil") })?; let versions = rows .into_iter() .map(|(ts, count, edited)| TranscriptionVersion { transcribed_at: ts.to_rfc3339(), segment_count: count, edited_count: edited, }) .collect(); Ok(Json(QueryVersionsResponse { versions })) } // ============================================================================= // GET /query/segments_version — segmenter for en spesifikk versjon // ============================================================================= #[derive(Deserialize)] pub struct QuerySegmentsVersionRequest { pub node_id: Uuid, pub transcribed_at: String, } /// GET /query/segments_version?node_id=...&transcribed_at=... /// /// Henter segmenter for en spesifikk transkripsjonsversjon. pub async fn query_segments_version( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { // Verifiser tilgang let mut tx = state.db.begin().await.map_err(|e| { tracing::error!(error = %e, "Transaksjon feilet"); internal_error("Databasefeil") })?; set_rls_context(&mut tx, user.node_id).await.map_err(|e| { tracing::error!(error = %e, "RLS-kontekst feilet"); internal_error("Databasefeil") })?; let exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)", ) .bind(params.node_id) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!(error = %e, "Tilgangssjekk feilet"); internal_error("Databasefeil") })?; tx.commit().await.map_err(|e| { tracing::error!(error = %e, "Commit feilet"); internal_error("Databasefeil") })?; if !exists { return Ok(Json(QuerySegmentsResponse { segments: vec![], transcribed_at: None, })); } let ts: chrono::DateTime = params.transcribed_at.parse() .map_err(|_| { (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Ugyldig transcribed_at-tidsstempel".to_string(), })) })?; 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(params.node_id) .bind(ts) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av segmenter"); internal_error("Databasefeil") })?; Ok(Json(QuerySegmentsResponse { segments, transcribed_at: Some(ts.to_rfc3339()), })) } // ============================================================================= // GET /query/segments/srt — eksporter segmenter som nedlastbar SRT-fil // ============================================================================= /// GET /query/segments/srt?node_id=... /// /// Genererer en SRT-fil fra nyeste transkripsjons-segmenter. /// Returnerer filen med Content-Disposition: attachment for nedlasting. pub async fn export_srt( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result)> { let resp = run_query_segments(&state.db, user.node_id, params.node_id) .await .map_err(|e| { tracing::error!(error = %e, "export_srt feilet"); internal_error("Databasefeil ved henting av segmenter") })?; if resp.segments.is_empty() { return Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Ingen transkripsjons-segmenter funnet".to_string(), }), )); } let srt = segments_to_srt(&resp.segments); Ok(( StatusCode::OK, [ (axum::http::header::CONTENT_TYPE, "application/x-subrip; charset=utf-8"), (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"transcription.srt\""), ], srt, ) .into_response()) } /// Konverterer segmenter til SRT-format. /// /// SRT-format: /// ```text /// 1 /// 00:00:00,000 --> 00:00:05,230 /// Hei og velkommen. /// /// 2 /// 00:00:05,230 --> 00:00:10,500 /// I dag snakker vi om... /// ``` fn segments_to_srt(segments: &[SegmentResult]) -> String { let mut srt = String::new(); for (i, seg) in segments.iter().enumerate() { if i > 0 { srt.push('\n'); } srt.push_str(&format!( "{}\n{} --> {}\n{}\n", seg.seq, format_srt_timestamp(seg.start_ms), format_srt_timestamp(seg.end_ms), seg.content, )); } srt } /// Formaterer millisekunder til SRT-tidsstempel: HH:MM:SS,mmm fn format_srt_timestamp(ms: i32) -> String { let total_seconds = ms / 1000; let millis = ms % 1000; let hours = total_seconds / 3600; let minutes = (total_seconds % 3600) / 60; let seconds = total_seconds % 60; format!("{:02}:{:02}:{:02},{:03}", hours, minutes, seconds, millis) } // ============================================================================= // 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 }) } // ============================================================================= // GET /query/aliases — brukerens alias-noder // ============================================================================= #[derive(Serialize, sqlx::FromRow)] pub struct AliasResult { pub node_id: Uuid, pub title: Option, pub metadata: serde_json::Value, pub created_at: chrono::DateTime, } #[derive(Serialize)] pub struct QueryAliasesResponse { pub aliases: Vec, } /// GET /query/aliases /// /// Returnerer alle alias-noder for den autentiserte brukeren. /// Henter via alias-edges (system=true) fra brukerens hovednode. /// Ingen RLS nødvendig — vi spør direkte med brukerens node_id. pub async fn query_aliases( State(state): State, user: AuthUser, ) -> Result, (StatusCode, Json)> { let aliases = sqlx::query_as::<_, AliasResult>( r#" SELECT n.id AS node_id, n.title, n.metadata, n.created_at FROM edges e JOIN nodes n ON n.id = e.target_id WHERE e.source_id = $1 AND e.edge_type = 'alias' AND e.system = true ORDER BY n.created_at "#, ) .bind(user.node_id) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "query_aliases feilet"); internal_error("Databasefeil ved henting av aliaser") })?; Ok(Json(QueryAliasesResponse { aliases })) } // ============================================================================= // GET /query/board — kanban-brett: noder med belongs_to-edge, gruppert på status // ============================================================================= #[derive(Deserialize)] pub struct QueryBoardRequest { /// Board-nodens ID. pub board_id: Uuid, } #[derive(Serialize)] pub struct BoardCard { pub node_id: Uuid, pub title: Option, pub content: Option, pub node_kind: String, pub metadata: serde_json::Value, pub created_at: chrono::DateTime, pub created_by: Option, /// Status-verdi fra status-edge metadata (null hvis ingen status-edge) pub status: Option, /// Position fra belongs_to-edge metadata (default 0) pub position: f64, /// belongs_to-edge ID (for referanse) pub belongs_to_edge_id: Uuid, /// status-edge ID (null hvis ingen) pub status_edge_id: Option, } #[derive(Serialize)] pub struct QueryBoardResponse { pub board_id: Uuid, pub board_title: Option, /// Kolonne-definisjoner fra board-nodens metadata pub columns: Vec, pub cards: Vec, } /// GET /query/board?board_id=... /// /// Henter alle kort (noder med belongs_to-edge) på et kanban-brett, /// inkludert status-edges og posisjonsdata. pub async fn query_board( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { // Verifiser tilgang til board-noden via RLS let mut tx = state.db.begin().await.map_err(|e| { tracing::error!(error = %e, "Transaksjon feilet"); internal_error("Databasefeil") })?; set_rls_context(&mut tx, user.node_id).await.map_err(|e| { tracing::error!(error = %e, "RLS-kontekst feilet"); internal_error("Databasefeil") })?; // Hent board-noden let board = sqlx::query_as::<_, (Option, serde_json::Value)>( "SELECT title, metadata FROM nodes WHERE id = $1", ) .bind(params.board_id) .fetch_optional(&mut *tx) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av board-node"); internal_error("Databasefeil") })?; tx.commit().await.map_err(|e| { tracing::error!(error = %e, "Commit feilet"); internal_error("Databasefeil") })?; let Some((board_title, board_metadata)) = board else { return Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: format!("Board {} finnes ikke eller du har ikke tilgang", params.board_id), }), )); }; // Hent kolonner fra board-metadata, fallback til standard let columns: Vec = board_metadata .get("columns") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_else(|| vec!["todo".to_string(), "in_progress".to_string(), "done".to_string()]); // Hent alle kort: noder med belongs_to-edge til dette boardet let cards = sqlx::query_as::<_, ( Uuid, // n.id String, // n.node_kind Option, // n.title Option, // n.content serde_json::Value, // n.metadata chrono::DateTime, // n.created_at Option, // n.created_by Uuid, // bt.id (belongs_to edge) serde_json::Value, // bt.metadata Option, // st.id (status edge) Option, // st.metadata )>( r#" SELECT n.id, n.node_kind, n.title, n.content, n.metadata, n.created_at, n.created_by, bt.id AS belongs_to_edge_id, bt.metadata AS bt_metadata, st.id AS status_edge_id, st.metadata AS st_metadata FROM edges bt JOIN nodes n ON n.id = bt.source_id LEFT JOIN edges st ON st.source_id = n.id AND st.target_id = $1 AND st.edge_type = 'status' WHERE bt.target_id = $1 AND bt.edge_type = 'belongs_to' ORDER BY n.created_at "#, ) .bind(params.board_id) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av board-kort"); internal_error("Databasefeil ved henting av kort") })?; let board_cards: Vec = cards .into_iter() .map(|(node_id, node_kind, title, content, metadata, created_at, created_by, belongs_to_edge_id, bt_metadata, status_edge_id, st_metadata)| { let status = st_metadata .as_ref() .and_then(|m| m.get("value")) .and_then(|v| v.as_str()) .map(|s| s.to_string()); let position = bt_metadata .get("position") .and_then(|v| v.as_f64()) .unwrap_or(0.0); BoardCard { node_id, title, content, node_kind, metadata, created_at, created_by, status, position, belongs_to_edge_id, status_edge_id, } }) .collect(); Ok(Json(QueryBoardResponse { board_id: params.board_id, board_title, columns, cards: board_cards, })) } // ============================================================================= // GET /query/editorial_board — redaktørens arbeidsflate // ============================================================================= // // Viser noder med submitted_to-edge til en samling, gruppert på status. // Brukes av frontend for Kanban-visning av redaksjonelle innsendinger. #[derive(Deserialize)] pub struct QueryEditorialBoardRequest { /// Samlings-nodens ID (target for submitted_to-edges). pub collection_id: Uuid, } #[derive(Serialize)] pub struct EditorialCard { pub node_id: Uuid, pub title: Option, pub content: Option, pub node_kind: String, pub metadata: serde_json::Value, pub created_at: chrono::DateTime, pub created_by: Option, /// Forfatterens navn (title fra person-noden) pub author_name: Option, /// Status fra submitted_to-edge metadata pub status: String, /// submitted_to-edge ID pub submitted_to_edge_id: Uuid, /// Full metadata fra submitted_to-edge (inkl. feedback, publish_at, etc.) pub edge_metadata: serde_json::Value, /// ID til kommunikasjonsnode(r) knyttet til artikkelen (redaksjonell samtale) pub discussion_ids: Vec, } #[derive(Serialize)] pub struct QueryEditorialBoardResponse { pub collection_id: Uuid, pub collection_title: Option, /// Faste kolonner for redaksjonell arbeidsflate pub columns: Vec, /// Visningsnavn for kolonnene pub column_labels: std::collections::HashMap, pub cards: Vec, } /// GET /query/editorial_board?collection_id=... /// /// Henter alle noder med submitted_to-edge til en samling, /// inkludert status, metadata og forfatterinfo. pub async fn query_editorial_board( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { // Verifiser tilgang til samlings-noden via RLS let mut tx = state.db.begin().await.map_err(|e| { tracing::error!(error = %e, "Transaksjon feilet"); internal_error("Databasefeil") })?; set_rls_context(&mut tx, user.node_id).await.map_err(|e| { tracing::error!(error = %e, "RLS-kontekst feilet"); internal_error("Databasefeil") })?; let collection = sqlx::query_as::<_, (Option,)>( "SELECT title FROM nodes WHERE id = $1", ) .bind(params.collection_id) .fetch_optional(&mut *tx) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av samlings-node"); internal_error("Databasefeil") })?; tx.commit().await.map_err(|e| { tracing::error!(error = %e, "Commit feilet"); internal_error("Databasefeil") })?; let Some((collection_title,)) = collection else { return Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: format!( "Samling {} finnes ikke eller du har ikke tilgang", params.collection_id ), }), )); }; // Hent alle noder med submitted_to-edge til denne samlingen, // pluss forfatterens navn via created_by → person-node let rows = sqlx::query_as::<_, ( Uuid, // n.id String, // n.node_kind Option, // n.title Option, // n.content serde_json::Value, // n.metadata chrono::DateTime, // n.created_at Option, // n.created_by Option, // author.title (forfatterens navn) Uuid, // e.id (submitted_to edge) serde_json::Value, // e.metadata )>( r#" SELECT n.id, n.node_kind, n.title, n.content, n.metadata, n.created_at, n.created_by, author.title AS author_name, e.id AS edge_id, e.metadata AS edge_metadata FROM edges e JOIN nodes n ON n.id = e.source_id LEFT JOIN nodes author ON author.id = n.created_by AND author.node_kind = 'person' WHERE e.target_id = $1 AND e.edge_type = 'submitted_to' ORDER BY e.created_at DESC "#, ) .bind(params.collection_id) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av editorial-kort"); internal_error("Databasefeil ved henting av innsendinger") })?; // Samle alle artikkel-IDer for å hente diskusjoner i én spørring let article_ids: Vec = rows.iter().map(|r| r.0).collect(); // Hent kommunikasjonsnoder som har belongs_to-edge til artiklene let discussions: Vec<(Uuid, Uuid)> = if !article_ids.is_empty() { sqlx::query_as::<_, (Uuid, Uuid)>( r#" SELECT e.source_id AS communication_id, e.target_id AS article_id FROM edges e JOIN nodes n ON n.id = e.source_id AND n.node_kind = 'communication' WHERE e.edge_type = 'belongs_to' AND e.target_id = ANY($1) "#, ) .bind(&article_ids) .fetch_all(&state.db) .await .unwrap_or_default() } else { vec![] }; // Bygg oppslag: artikkel_id → liste med diskusjons-IDer let mut discussion_map: std::collections::HashMap> = std::collections::HashMap::new(); for (comm_id, article_id) in discussions { discussion_map.entry(article_id).or_default().push(comm_id); } let cards: Vec = rows .into_iter() .map(|(node_id, node_kind, title, content, metadata, created_at, created_by, author_name, edge_id, edge_metadata)| { let status = edge_metadata .get("status") .and_then(|v| v.as_str()) .unwrap_or("pending") .to_string(); let discussion_ids = discussion_map .get(&node_id) .cloned() .unwrap_or_default(); EditorialCard { node_id, title, content, node_kind, metadata, created_at, created_by, author_name, status, submitted_to_edge_id: edge_id, edge_metadata, discussion_ids, } }) .collect(); let columns = vec![ "pending".to_string(), "in_review".to_string(), "approved".to_string(), "scheduled".to_string(), ]; let mut column_labels = std::collections::HashMap::new(); column_labels.insert("pending".to_string(), "Innkomne".to_string()); column_labels.insert("in_review".to_string(), "Under vurdering".to_string()); column_labels.insert("approved".to_string(), "Godkjent".to_string()); column_labels.insert("scheduled".to_string(), "Planlagt".to_string()); Ok(Json(QueryEditorialBoardResponse { collection_id: params.collection_id, collection_title, columns, column_labels, cards, })) } // ============================================================================= // GET /query/graph — graf-traversering fra en fokusnode // ============================================================================= #[derive(Deserialize)] pub struct QueryGraphRequest { /// Fokusnode å starte traverseringen fra. Valgfritt — uten returneres hele grafen. pub focus_id: Option, /// Maks dybde for traversering (1-3). Default: 2. pub depth: Option, /// Filtrer på edge_type (komma-separert). Valgfritt. pub edge_types: Option, /// Filtrer på node_kind (komma-separert). Valgfritt. pub node_kinds: Option, } #[derive(Serialize)] pub struct GraphNode { pub id: Uuid, pub node_kind: String, pub title: Option, pub visibility: String, pub metadata: serde_json::Value, pub created_at: chrono::DateTime, } #[derive(Serialize)] pub struct GraphEdge { pub id: Uuid, pub source_id: Uuid, pub target_id: Uuid, pub edge_type: String, pub metadata: serde_json::Value, } #[derive(Serialize)] pub struct QueryGraphResponse { pub nodes: Vec, pub edges: Vec, } /// GET /query/graph?focus_id=...&depth=...&edge_types=...&node_kinds=... /// /// Returnerer noder og edges for graf-visualisering. /// Med focus_id: traverserer grafen N ledd ut fra fokusnode. /// Uten focus_id: returnerer alle synlige noder (maks 200) med edges mellom dem. pub async fn query_graph( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let depth = params.depth.unwrap_or(2).clamp(1, 3); let edge_type_filter: Option> = params.edge_types.as_ref().map(|s| { s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect() }); let node_kind_filter: Option> = params.node_kinds.as_ref().map(|s| { s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect() }); let result = run_query_graph( &state.db, user.node_id, params.focus_id, depth, &edge_type_filter, &node_kind_filter, ) .await; match result { Ok(resp) => Ok(Json(resp)), Err(e) => { tracing::error!(error = %e, "query_graph feilet"); Err(internal_error("Databasefeil ved graf-spørring")) } } } async fn run_query_graph( db: &PgPool, user_node_id: Uuid, focus_id: Option, depth: i32, edge_type_filter: &Option>, node_kind_filter: &Option>, ) -> Result { let mut tx = db.begin().await?; set_rls_context(&mut tx, user_node_id).await?; let (nodes, edges) = if let Some(focus) = focus_id { // Traverser fra fokusnode med rekursiv CTE let node_rows = sqlx::query_as::<_, (Uuid, String, Option, String, serde_json::Value, chrono::DateTime)>( r#" WITH RECURSIVE reachable(id, depth) AS ( SELECT $1::uuid, 0 UNION SELECT CASE WHEN e.source_id = r.id THEN e.target_id ELSE e.source_id END, r.depth + 1 FROM reachable r JOIN edges e ON (e.source_id = r.id OR e.target_id = r.id) WHERE r.depth < $2 ) SELECT DISTINCT n.id, n.node_kind, n.title, n.visibility::text, n.metadata, n.created_at FROM reachable r JOIN nodes n ON n.id = r.id ORDER BY n.created_at DESC LIMIT 200 "#, ) .bind(focus) .bind(depth) .fetch_all(&mut *tx) .await?; // Samle opp node-IDer for å filtrere edges let node_ids: Vec = node_rows.iter().map(|r| r.0).collect(); let edge_rows = sqlx::query_as::<_, (Uuid, Uuid, Uuid, String, serde_json::Value)>( r#" SELECT e.id, e.source_id, e.target_id, e.edge_type, e.metadata FROM edges e WHERE e.source_id = ANY($1) AND e.target_id = ANY($1) AND e.system = false "#, ) .bind(&node_ids) .fetch_all(&mut *tx) .await?; (node_rows, edge_rows) } else { // Ingen fokus — returner alle synlige noder (begrenset) let node_rows = sqlx::query_as::<_, (Uuid, String, Option, String, serde_json::Value, chrono::DateTime)>( r#" SELECT n.id, n.node_kind, n.title, n.visibility::text, n.metadata, n.created_at FROM nodes n ORDER BY n.created_at DESC LIMIT 200 "#, ) .fetch_all(&mut *tx) .await?; let node_ids: Vec = node_rows.iter().map(|r| r.0).collect(); let edge_rows = sqlx::query_as::<_, (Uuid, Uuid, Uuid, String, serde_json::Value)>( r#" SELECT e.id, e.source_id, e.target_id, e.edge_type, e.metadata FROM edges e WHERE e.source_id = ANY($1) AND e.target_id = ANY($1) AND e.system = false "#, ) .bind(&node_ids) .fetch_all(&mut *tx) .await?; (node_rows, edge_rows) }; tx.commit().await?; // Appliser klientside-filtre let mut graph_nodes: Vec = nodes .into_iter() .map(|(id, node_kind, title, visibility, metadata, created_at)| GraphNode { id, node_kind, title, visibility, metadata, created_at, }) .collect(); if let Some(kinds) = node_kind_filter { if !kinds.is_empty() { // Behold alltid fokusnode selv om den ikke matcher filteret graph_nodes.retain(|n| kinds.contains(&n.node_kind) || focus_id == Some(n.id)); } } let visible_ids: std::collections::HashSet = graph_nodes.iter().map(|n| n.id).collect(); let mut graph_edges: Vec = edges .into_iter() .map(|(id, source_id, target_id, edge_type, metadata)| GraphEdge { id, source_id, target_id, edge_type, metadata, }) .filter(|e| visible_ids.contains(&e.source_id) && visible_ids.contains(&e.target_id)) .collect(); if let Some(types) = edge_type_filter { if !types.is_empty() { graph_edges.retain(|e| types.contains(&e.edge_type)); } } Ok(QueryGraphResponse { nodes: graph_nodes, edges: graph_edges, }) } // ============================================================================= // GET /query/presentation_elements — presentasjonselementer for en artikkel // ============================================================================= // // Henter noder koblet til en artikkel via title/subtitle/summary/og_image/ // og_description-edges. Disse er separate noder med variantmetadata. // Ref: docs/concepts/publisering.md § "Presentasjonselementer" #[derive(Deserialize)] pub struct QueryPresentationRequest { /// Artikkelens node-ID. pub article_id: Uuid, } #[derive(Serialize)] pub struct PresentationElement { /// Presentasjonselementets node-ID. pub node_id: Uuid, /// Edge-ID (for oppdatering/sletting). pub edge_id: Uuid, /// Edge-type: title, subtitle, summary, og_image, og_description. pub element_type: String, /// Nodens tittel (brukt for title/subtitle). pub title: Option, /// Nodens innhold (brukt for summary/og_description). pub content: Option, /// Node-kind (content eller media). pub node_kind: String, /// Node metadata (inkl. cas_hash for media). pub metadata: serde_json::Value, /// Edge metadata (variant, language, ab_status etc.). pub edge_metadata: serde_json::Value, pub created_at: chrono::DateTime, } #[derive(Serialize)] pub struct QueryPresentationResponse { pub article_id: Uuid, pub elements: Vec, } /// GET /query/presentation_elements?article_id=... /// /// Henter alle presentasjonselementer (tittel, undertittel, ingress, /// OG-bilde, OG-beskrivelse) knyttet til en artikkel. /// Returnerer nodene med edge-metadata (variant, ab_status etc.). pub async fn query_presentation_elements( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { // Verifiser tilgang til artikkelen via RLS let mut tx = state.db.begin().await.map_err(|e| { tracing::error!(error = %e, "Transaksjon feilet"); internal_error("Databasefeil") })?; set_rls_context(&mut tx, user.node_id).await.map_err(|e| { tracing::error!(error = %e, "RLS-kontekst feilet"); internal_error("Databasefeil") })?; let exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)", ) .bind(params.article_id) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!(error = %e, "Tilgangssjekk feilet"); internal_error("Databasefeil") })?; tx.commit().await.map_err(|e| { tracing::error!(error = %e, "Commit feilet"); internal_error("Databasefeil") })?; if !exists { return Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: format!("Artikkel {} finnes ikke eller du har ikke tilgang", params.article_id), }), )); } // Hent presentasjonselementer: noder med title/subtitle/summary/og_image/og_description-edge // til artikkelen (source_id = element-node, target_id = article) let rows = sqlx::query_as::<_, ( Uuid, // n.id Uuid, // e.id String, // e.edge_type Option, // n.title Option, // n.content String, // n.node_kind serde_json::Value, // n.metadata serde_json::Value, // e.metadata chrono::DateTime, // n.created_at )>( r#" SELECT n.id, e.id AS edge_id, e.edge_type, n.title, n.content, n.node_kind, n.metadata, e.metadata AS edge_metadata, n.created_at FROM edges e JOIN nodes n ON n.id = e.source_id WHERE e.target_id = $1 AND e.edge_type IN ('title', 'subtitle', 'summary', 'og_image', 'og_description') ORDER BY e.edge_type, e.created_at "#, ) .bind(params.article_id) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Feil ved henting av presentasjonselementer"); internal_error("Databasefeil ved henting av presentasjonselementer") })?; let elements: Vec = rows .into_iter() .map(|(node_id, edge_id, element_type, title, content, node_kind, metadata, edge_metadata, created_at)| { PresentationElement { node_id, edge_id, element_type, title, content, node_kind, metadata, edge_metadata, created_at, } }) .collect(); Ok(Json(QueryPresentationResponse { article_id: params.article_id, elements, })) } #[cfg(test)] mod tests { use super::*; #[test] fn test_format_srt_timestamp() { assert_eq!(format_srt_timestamp(0), "00:00:00,000"); assert_eq!(format_srt_timestamp(5230), "00:00:05,230"); assert_eq!(format_srt_timestamp(83456), "00:01:23,456"); assert_eq!(format_srt_timestamp(3_600_000), "01:00:00,000"); assert_eq!(format_srt_timestamp(3_723_456), "01:02:03,456"); } #[test] fn test_segments_to_srt() { let segments = vec![ SegmentResult { id: 1, seq: 1, start_ms: 0, end_ms: 5230, content: "Hei og velkommen.".to_string(), edited: false, }, SegmentResult { id: 2, seq: 2, start_ms: 5230, end_ms: 10500, content: "I dag snakker vi om fotball.".to_string(), edited: true, }, ]; let srt = segments_to_srt(&segments); let expected = "\ 1 00:00:00,000 --> 00:00:05,230 Hei og velkommen. 2 00:00:05,230 --> 00:00:10,500 I dag snakker vi om fotball. "; assert_eq!(srt, expected); } #[test] fn test_segments_to_srt_single() { let segments = vec![SegmentResult { id: 1, seq: 1, start_ms: 0, end_ms: 3000, content: "Bare ett segment.".to_string(), edited: false, }]; let srt = segments_to_srt(&segments); assert_eq!(srt, "1\n00:00:00,000 --> 00:00:03,000\nBare ett segment.\n"); } }