diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 68f8c1d..b4f11ac 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -128,3 +128,50 @@ export async function uploadMedia( export function casUrl(hash: string): string { return `${BASE_URL}/cas/${hash}`; } + +// ============================================================================= +// Transkripsjons-segmenter +// ============================================================================= + +export interface Segment { + id: number; + seq: number; + start_ms: number; + end_ms: number; + content: string; + edited: boolean; +} + +export interface SegmentsResponse { + segments: Segment[]; + transcribed_at: string | null; +} + +/** Hent transkripsjons-segmenter for en media-node. */ +export async function fetchSegments( + accessToken: string, + nodeId: string +): Promise { + const res = await fetch(`${BASE_URL}/query/segments?node_id=${encodeURIComponent(nodeId)}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`segments failed (${res.status}): ${body}`); + } + + return res.json(); +} + +/** Oppdater teksten i et transkripsjons-segment. */ +export function updateSegment( + accessToken: string, + segmentId: number, + content: string +): Promise<{ segment_id: number; edited: boolean }> { + return post(accessToken, '/intentions/update_segment', { + segment_id: segmentId, + content + }); +} diff --git a/frontend/src/lib/components/AudioPlayer.svelte b/frontend/src/lib/components/AudioPlayer.svelte index 10ea107..fa86e60 100644 --- a/frontend/src/lib/components/AudioPlayer.svelte +++ b/frontend/src/lib/components/AudioPlayer.svelte @@ -1,19 +1,24 @@ + +{#if loading} +

Laster segmenter...

+{:else if error} +

{error}

+{:else if segments.length === 0} +

Ingen segmenter

+{:else} +
+ {#each segments as seg (seg.id)} + {@const isActive = activeSegmentId === seg.id} + {@const isEditing = editingId === seg.id} +
+ + + + + {#if isEditing} +
+ +
+ + +
+
+ {:else} + + {/if} +
+ {/each} +
+{/if} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 16af23d..509072c 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -105,14 +105,15 @@ } /** Check if a node is an audio media node and extract its metadata */ - function audioMeta(node: Node): { src: string; duration?: number } | null { + function audioMeta(node: Node): { src: string; duration?: number; hasSegments: boolean } | null { if (node.nodeKind !== 'media') return null; try { const meta = JSON.parse(node.metadata ?? '{}'); if (typeof meta.mime === 'string' && meta.mime.startsWith('audio/') && meta.cas_hash) { return { src: casUrl(meta.cas_hash), - duration: meta.transcription?.duration, + duration: meta.transcription?.duration_ms ? meta.transcription.duration_ms / 1000 : undefined, + hasSegments: (meta.transcription?.segment_count ?? 0) > 0, }; } } catch { /* ignore */ } @@ -308,7 +309,9 @@ {:else if vis === 'full' && node.content} diff --git a/frontend/src/routes/chat/[id]/+page.svelte b/frontend/src/routes/chat/[id]/+page.svelte index 964e286..b605db6 100644 --- a/frontend/src/routes/chat/[id]/+page.svelte +++ b/frontend/src/routes/chat/[id]/+page.svelte @@ -159,9 +159,18 @@ function audioDuration(node: Node): number | undefined { try { const meta = JSON.parse(node.metadata ?? '{}'); - return meta.transcription?.duration; + const dms = meta.transcription?.duration_ms; + return dms ? dms / 1000 : undefined; } catch { return undefined; } } + + /** Check if an audio node has transcription segments */ + function hasSegments(node: Node): boolean { + try { + const meta = JSON.parse(node.metadata ?? '{}'); + return (meta.transcription?.segment_count ?? 0) > 0; + } catch { return false; } + }
@@ -236,7 +245,9 @@ {:else} diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index e46d745..0732563 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -1364,3 +1364,89 @@ fn spawn_pg_delete_node(db: PgPool, node_id: Uuid) { } }); } + +// ============================================================================= +// 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, + })) +} diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 81c0c60..4453219 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -139,6 +139,8 @@ async fn main() { .route("/intentions/upload_media", post(intentions::upload_media)) .route("/cas/{hash}", get(serving::get_cas_file)) .route("/query/nodes", get(queries::query_nodes)) + .route("/query/segments", get(queries::query_segments)) + .route("/intentions/update_segment", post(intentions::update_segment)) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/maskinrommet/src/queries.rs b/maskinrommet/src/queries.rs index 66223e7..3f3fa89 100644 --- a/maskinrommet/src/queries.rs +++ b/maskinrommet/src/queries.rs @@ -15,6 +15,111 @@ 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 // =============================================================================