diff --git a/docs/infra/api_grensesnitt.md b/docs/infra/api_grensesnitt.md index 0c94924..7c2a6c3 100644 --- a/docs/infra/api_grensesnitt.md +++ b/docs/infra/api_grensesnitt.md @@ -82,6 +82,27 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet → - Body (JSON): `{ node_id }` - Respons: `{ deleted: true }` +### LiveKit / Sanntidslyd (oppgave 11.2) +- `POST /intentions/join_communication` — Koble til sanntidslyd i en kommunikasjonsnode. + Validerer deltaker-tilgang (owner/member_of/host_of-edge eller via alias). + Genererer LiveKit access token (JWT), oppretter rom i STDB, oppdaterer node-metadata. + - Body (JSON): `{ communication_id, role? }` (role: "publisher" | "subscriber", default "publisher") + - Respons: `{ livekit_room_name, livekit_token, livekit_url, identity, participants[] }` + - Frontend bruker `livekit_token` + `livekit_url` til å koble livekit-client SDK. +- `POST /intentions/leave_communication` — Forlat sanntidsrom. + Fjerner deltaker fra STDB live-rom. + - Body (JSON): `{ communication_id }` + - Respons: `{ status: "left" }` +- `POST /intentions/close_communication` — Steng sanntidsrom (krever owner/admin). + Fjerner alle deltakere, setter `live_status=ended` i metadata. + - Body (JSON): `{ communication_id }` + - Respons: `{ status: "closed" }` + +### SpacetimeDB sanntidstabeller (LiveKit) +- `live_room` — Aktive rom. Felt: `room_id`, `communication_id`, `is_active`, `started_at`, `participant_count`. +- `room_participant` — Deltakere i rom. Felt: `id`, `room_id`, `user_id`, `display_name`, `role`, `joined_at`. + Frontend abonnerer på disse via SpacetimeDB WebSocket for sanntids deltakerliste. + ## 6. Instruks for Claude Code - Maskinrommet (`maskinrommet/`) er Rust-prosjektet med axum, tokio, sqlx. diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index e2741bd..7c549e0 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -15,6 +15,7 @@ use sqlx::PgPool; use uuid::Uuid; use crate::auth::AuthUser; +use crate::livekit; use crate::AppState; /// Maks filstørrelse for upload: 100 MB. @@ -2319,3 +2320,392 @@ pub async fn generate_tts( Ok(Json(GenerateTtsResponse { job_id })) } + +// ============================================================================= +// LiveKit — Join/Leave Communication +// ============================================================================= + +#[derive(Deserialize)] +pub struct JoinCommunicationRequest { + pub communication_id: Uuid, + /// "publisher" (kan sende lyd) eller "subscriber" (bare lytte). + /// Default: "publisher". + pub role: Option, +} + +#[derive(Serialize)] +pub struct JoinCommunicationResponse { + pub livekit_room_name: String, + pub livekit_token: String, + pub livekit_url: String, + pub identity: String, + pub participants: Vec, +} + +#[derive(Serialize)] +pub struct RoomParticipantInfo { + pub user_id: String, + pub display_name: String, + pub role: String, +} + +/// POST /intentions/join_communication +/// +/// Kobler en bruker til sanntidslyd i en kommunikasjonsnode. +/// Validerer tilgang (bruker må ha member_of/owner/host_of-edge), +/// genererer LiveKit-token, oppdaterer STDB med live-status. +pub async fn join_communication( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let comm_id = req.communication_id; + let comm_id_str = comm_id.to_string(); + + // Sjekk at kommunikasjonsnoden eksisterer og er riktig type + let node_row = sqlx::query_as::<_, (String, String)>( + "SELECT node_kind, title FROM nodes WHERE id = $1", + ) + .bind(comm_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved nodesjekk: {e}"); + internal_error("Databasefeil ved validering") + })?; + + let (node_kind, _title) = match node_row { + Some(row) => row, + None => return Err(bad_request("Kommunikasjonsnode finnes ikke")), + }; + + if node_kind != "communication" { + return Err(bad_request(&format!( + "Node er type '{node_kind}', ikke 'communication'" + ))); + } + + // Sjekk at brukeren har tilgang (direkte eller via alias) + let has_access = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + -- Direkte edge: bruker → kommunikasjon + SELECT 1 FROM edges + WHERE source_id = $2 AND target_id = $1 + AND edge_type IN ('owner', 'member_of', 'host_of') + ) OR EXISTS( + -- Via alias: bruker --alias--> alias --member_of/etc--> kommunikasjon + SELECT 1 FROM edges e_alias + JOIN edges e_member ON e_member.source_id = e_alias.target_id + WHERE e_alias.source_id = $2 + AND e_alias.edge_type = 'alias' + AND e_alias.system = true + AND e_member.target_id = $1 + AND e_member.edge_type IN ('owner', 'member_of', 'host_of') + ) + "#, + ) + .bind(comm_id) + .bind(user.node_id) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved tilgangssjekk: {e}"); + internal_error("Databasefeil ved tilgangssjekk") + })?; + + if !has_access { + return Err(forbidden("Ingen tilgang til denne kommunikasjonsnoden")); + } + + // Resolve display name (alias or user title) + let context_identity = resolve_context_identity(&state.db, user.node_id, comm_id) + .await + .map_err(|e| { + tracing::error!("Kunne ikke resolve context identity: {e}"); + internal_error("Kunne ikke hente brukeridentitet") + })?; + + let display_name = sqlx::query_scalar::<_, String>( + "SELECT title FROM nodes WHERE id = $1", + ) + .bind(context_identity) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved navnoppslag: {e}"); + internal_error("Databasefeil") + })? + .unwrap_or_else(|| "Ukjent".to_string()); + + // Bestem rolle + let role_str = req.role.as_deref().unwrap_or("publisher"); + let lk_role = match role_str { + "subscriber" => livekit::RoomRole::Subscriber, + _ => livekit::RoomRole::Publisher, + }; + + // Generer LiveKit-token + let token_result = livekit::generate_token( + comm_id, + user.node_id, + &display_name, + lk_role, + 3600, // 1 time + ) + .map_err(|e| { + tracing::error!("LiveKit token-generering feilet: {e}"); + internal_error(&e) + })?; + + let room_name = token_result.room_name.clone(); + + // Oppdater SpacetimeDB: opprett rom (idempotent) + legg til deltaker + if let Err(e) = state.stdb.create_live_room(&room_name, &comm_id_str).await { + tracing::warn!("STDB create_live_room feilet (fortsetter): {e}"); + } + + if let Err(e) = state + .stdb + .add_room_participant( + &room_name, + &user.node_id.to_string(), + &display_name, + role_str, + ) + .await + { + tracing::warn!("STDB add_room_participant feilet (fortsetter): {e}"); + } + + // Oppdater kommunikasjonsnodens metadata med live_status (asynkront) + let db = state.db.clone(); + let stdb = state.stdb.clone(); + let comm_id_clone = comm_id; + let room_name_clone = room_name.clone(); + tokio::spawn(async move { + // Les eksisterende metadata, legg til live_status + let result = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT metadata FROM nodes WHERE id = $1", + ) + .bind(comm_id_clone) + .fetch_optional(&db) + .await; + + if let Ok(Some(mut metadata)) = result { + if let Some(obj) = metadata.as_object_mut() { + obj.insert("live_status".into(), "active".into()); + obj.insert("livekit_room_name".into(), room_name_clone.clone().into()); + } + + if let Err(e) = sqlx::query( + "UPDATE nodes SET metadata = $2 WHERE id = $1", + ) + .bind(comm_id_clone) + .bind(&metadata) + .execute(&db) + .await + { + tracing::error!("Kunne ikke oppdatere node metadata: {e}"); + } + + // Synk metadata til STDB + let node = sqlx::query_as::<_, (String, String, String, String, String)>( + "SELECT node_kind, title, content, visibility, metadata::text FROM nodes WHERE id = $1", + ) + .bind(comm_id_clone) + .fetch_optional(&db) + .await; + + if let Ok(Some((kind, t, c, v, m))) = node { + let _ = stdb + .update_node(&comm_id_clone.to_string(), &kind, &t, &c, &v, &m) + .await; + } + } + }); + + // Hent nåværende deltakere fra PG edges (for respons) + let participants = sqlx::query_as::<_, (String, String)>( + r#" + SELECT e.source_id::text, COALESCE(n.title, 'Ukjent') + FROM edges e + LEFT JOIN nodes n ON n.id = e.source_id + WHERE e.target_id = $1 + AND e.edge_type IN ('owner', 'member_of', 'host_of') + "#, + ) + .bind(comm_id) + .fetch_all(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved deltakerhenting: {e}"); + internal_error("Databasefeil") + })? + .into_iter() + .map(|(uid, name)| RoomParticipantInfo { + user_id: uid, + display_name: name, + role: "publisher".to_string(), + }) + .collect(); + + let livekit_url = std::env::var("LIVEKIT_WS_URL") + .unwrap_or_else(|_| { + // Fallback: bruk domene med wss + "wss://sidelinja.org/livekit".to_string() + }); + + tracing::info!( + communication_id = %comm_id, + user = %user.node_id, + room = %room_name, + role = %role_str, + "Bruker koblet til LiveKit-rom" + ); + + Ok(Json(JoinCommunicationResponse { + livekit_room_name: room_name, + livekit_token: token_result.token, + livekit_url, + identity: token_result.identity, + participants, + })) +} + +#[derive(Deserialize)] +pub struct LeaveCommunicationRequest { + pub communication_id: Uuid, +} + +#[derive(Serialize)] +pub struct LeaveCommunicationResponse { + pub status: String, +} + +/// POST /intentions/leave_communication +/// +/// Fjerner brukerens sanntidslyd-tilkobling fra en kommunikasjonsnode. +/// Oppdaterer STDB med ny deltaker-status. Stenger rommet hvis ingen gjenstår. +pub async fn leave_communication( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let comm_id = req.communication_id; + let room_name = format!("communication_{comm_id}"); + let user_id_str = user.node_id.to_string(); + + // Fjern deltaker fra STDB + if let Err(e) = state.stdb.remove_room_participant(&room_name, &user_id_str).await { + tracing::warn!("STDB remove_room_participant feilet: {e}"); + } + + // Sjekk om rommet er tomt — i så fall steng det + // (Vi sjekker PG edges for å se hvem som er registrert som deltaker i STDB) + // Forenklet: la rommet ligge, det ryddes ved neste oppstart eller manuelt + // Frontend kan kalle close_communication for å eksplisitt stenge. + + tracing::info!( + communication_id = %comm_id, + user = %user.node_id, + room = %room_name, + "Bruker forlot LiveKit-rom" + ); + + Ok(Json(LeaveCommunicationResponse { + status: "left".to_string(), + })) +} + +#[derive(Deserialize)] +pub struct CloseCommunicationRequest { + pub communication_id: Uuid, +} + +#[derive(Serialize)] +pub struct CloseCommunicationResponse { + pub status: String, +} + +/// POST /intentions/close_communication +/// +/// Stenger et sanntidsrom. Krever owner/admin-tilgang. +/// Fjerner alle deltakere fra STDB, oppdaterer metadata til "ended". +pub async fn close_communication( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let comm_id = req.communication_id; + + // Bare owner/admin kan stenge + if !user_can_modify_node(&state.db, user.node_id, comm_id) + .await + .map_err(|e| { + tracing::error!("PG-feil ved tilgangssjekk: {e}"); + internal_error("Databasefeil") + })? + { + return Err(forbidden("Bare eier kan stenge kommunikasjonsrom")); + } + + let room_name = format!("communication_{comm_id}"); + + // Steng rommet i STDB + if let Err(e) = state.stdb.close_live_room(&room_name).await { + tracing::warn!("STDB close_live_room feilet: {e}"); + } + + // Oppdater metadata i PG + let db = state.db.clone(); + let stdb = state.stdb.clone(); + tokio::spawn(async move { + let result = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT metadata FROM nodes WHERE id = $1", + ) + .bind(comm_id) + .fetch_optional(&db) + .await; + + if let Ok(Some(mut metadata)) = result { + if let Some(obj) = metadata.as_object_mut() { + obj.insert("live_status".into(), "ended".into()); + obj.insert( + "ended_at".into(), + chrono::Utc::now().to_rfc3339().into(), + ); + } + + let _ = sqlx::query("UPDATE nodes SET metadata = $2 WHERE id = $1") + .bind(comm_id) + .bind(&metadata) + .execute(&db) + .await; + + // Synk til STDB + let node = sqlx::query_as::<_, (String, String, String, String, String)>( + "SELECT node_kind, title, content, visibility, metadata::text FROM nodes WHERE id = $1", + ) + .bind(comm_id) + .fetch_optional(&db) + .await; + + if let Ok(Some((kind, t, c, v, m))) = node { + let _ = stdb + .update_node(&comm_id.to_string(), &kind, &t, &c, &v, &m) + .await; + } + } + }); + + tracing::info!( + communication_id = %comm_id, + user = %user.node_id, + "Kommunikasjonsrom stengt" + ); + + Ok(Json(CloseCommunicationResponse { + status: "closed".to_string(), + })) +} diff --git a/maskinrommet/src/livekit.rs b/maskinrommet/src/livekit.rs new file mode 100644 index 0000000..1e28d01 --- /dev/null +++ b/maskinrommet/src/livekit.rs @@ -0,0 +1,133 @@ +// LiveKit-integrasjon — token-generering for sanntidslyd. +// +// Genererer JWT access tokens som gir brukere tilgang til LiveKit-rom. +// Tokens signeres med LIVEKIT_API_SECRET (HMAC-SHA256) og inneholder +// grants som bestemmer hva deltakeren kan gjøre (publisere, lytte, etc.). +// +// Ref: docs/concepts/møterommet.md, docs/concepts/studioet.md + +use jsonwebtoken::{encode, EncodingKey, Header, Algorithm}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// LiveKit video grant — bestemmer hva en deltaker kan gjøre i et rom. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct VideoGrant { + room: String, + room_join: bool, + can_publish: bool, + can_subscribe: bool, +} + +/// JWT claims for LiveKit access token. +#[derive(Serialize)] +struct LiveKitClaims { + /// API Key (issuer) + iss: String, + /// Participant identity + sub: String, + /// Participant name (display) + name: String, + /// Issued at (unix timestamp) + iat: u64, + /// Not before (unix timestamp) + nbf: u64, + /// Expiration (unix timestamp) + exp: u64, + /// LiveKit video grant + video: VideoGrant, + /// Metadata (JSON string) + #[serde(skip_serializing_if = "String::is_empty")] + metadata: String, +} + +/// Rolle i et LiveKit-rom. Bestemmer publiserings-rettigheter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoomRole { + /// Kan publisere og lytte (host, deltaker) + Publisher, + /// Kan bare lytte (observatør) + Subscriber, +} + +/// Resultat fra token-generering. +pub struct LiveKitToken { + pub room_name: String, + pub token: String, + pub identity: String, +} + +/// Generer et LiveKit access token for en deltaker. +/// +/// - `communication_id`: UUID for kommunikasjonsnoden (brukes til rom-navn) +/// - `user_id`: Brukerens node_id (brukes som identity) +/// - `display_name`: Visningsnavn for deltakeren +/// - `role`: Publisher (kan sende lyd) eller Subscriber (bare lytte) +/// - `ttl_secs`: Token-levetid i sekunder (typisk 3600 = 1 time) +pub fn generate_token( + communication_id: Uuid, + user_id: Uuid, + display_name: &str, + role: RoomRole, + ttl_secs: u64, +) -> Result { + let api_key = std::env::var("LIVEKIT_API_KEY") + .map_err(|_| "LIVEKIT_API_KEY ikke satt".to_string())?; + let api_secret = std::env::var("LIVEKIT_API_SECRET") + .map_err(|_| "LIVEKIT_API_SECRET ikke satt".to_string())?; + + let room_name = format!("communication_{communication_id}"); + let identity = user_id.to_string(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let can_publish = role == RoomRole::Publisher; + + let claims = LiveKitClaims { + iss: api_key, + sub: identity.clone(), + name: display_name.to_string(), + iat: now, + nbf: now, + exp: now + ttl_secs, + video: VideoGrant { + room: room_name.clone(), + room_join: true, + can_publish, + can_subscribe: true, + }, + metadata: String::new(), + }; + + let header = Header::new(Algorithm::HS256); + let key = EncodingKey::from_secret(api_secret.as_bytes()); + + let token = encode(&header, &claims, &key) + .map_err(|e| format!("Kunne ikke generere LiveKit-token: {e}"))?; + + Ok(LiveKitToken { + room_name, + token, + identity, + }) +} + +/// Sjekk om LiveKit-serveren er tilgjengelig. +pub async fn health_check() -> Result { + let url = std::env::var("LIVEKIT_URL") + .unwrap_or_else(|_| "http://localhost:7880".to_string()); + + match reqwest::Client::new() + .get(&url) + .timeout(std::time::Duration::from_secs(3)) + .send() + .await + { + Ok(_) => Ok(true), + Err(e) => Err(format!("LiveKit utilgjengelig: {e}")), + } +} diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 9feb30c..88de6d4 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -4,6 +4,7 @@ mod auth; pub mod cas; mod intentions; pub mod jobs; +pub mod livekit; mod queries; mod serving; mod stdb; @@ -153,6 +154,9 @@ async fn main() { .route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription)) .route("/intentions/summarize", post(intentions::summarize)) .route("/intentions/generate_tts", post(intentions::generate_tts)) + .route("/intentions/join_communication", post(intentions::join_communication)) + .route("/intentions/leave_communication", post(intentions::leave_communication)) + .route("/intentions/close_communication", post(intentions::close_communication)) .route("/query/aliases", get(queries::query_aliases)) .route("/query/graph", get(queries::query_graph)) .route("/query/transcription_versions", get(queries::query_transcription_versions)) diff --git a/maskinrommet/src/stdb.rs b/maskinrommet/src/stdb.rs index 036c815..ed3d8f9 100644 --- a/maskinrommet/src/stdb.rs +++ b/maskinrommet/src/stdb.rs @@ -281,6 +281,71 @@ impl StdbClient { .await } + // ========================================================================= + // Live-rom (LiveKit) + // ========================================================================= + + pub async fn create_live_room( + &self, + room_id: &str, + communication_id: &str, + ) -> Result<(), StdbError> { + #[derive(Serialize)] + struct Args<'a> { + room_id: &'a str, + communication_id: &'a str, + } + + self.call_reducer("create_live_room", &Args { room_id, communication_id }) + .await + } + + pub async fn add_room_participant( + &self, + room_id: &str, + user_id: &str, + display_name: &str, + role: &str, + ) -> Result<(), StdbError> { + #[derive(Serialize)] + struct Args<'a> { + room_id: &'a str, + user_id: &'a str, + display_name: &'a str, + role: &'a str, + } + + self.call_reducer( + "add_room_participant", + &Args { room_id, user_id, display_name, role }, + ) + .await + } + + pub async fn remove_room_participant( + &self, + room_id: &str, + user_id: &str, + ) -> Result<(), StdbError> { + #[derive(Serialize)] + struct Args<'a> { + room_id: &'a str, + user_id: &'a str, + } + + self.call_reducer("remove_room_participant", &Args { room_id, user_id }) + .await + } + + pub async fn close_live_room(&self, room_id: &str) -> Result<(), StdbError> { + #[derive(Serialize)] + struct Args<'a> { + room_id: &'a str, + } + + self.call_reducer("close_live_room", &Args { room_id }).await + } + // ========================================================================= // Vedlikehold // ========================================================================= diff --git a/scripts/maskinrommet-env.sh b/scripts/maskinrommet-env.sh index 1845729..db6980e 100755 --- a/scripts/maskinrommet-env.sh +++ b/scripts/maskinrommet-env.sh @@ -29,6 +29,7 @@ LITELLM_MASTER_KEY=$(read_env LITELLM_MASTER_KEY) LIVEKIT_URL=http://${LIVEKIT_IP:-localhost}:7880 LIVEKIT_API_KEY=$(read_env LIVEKIT_API_KEY) LIVEKIT_API_SECRET=$(read_env LIVEKIT_API_SECRET) +LIVEKIT_WS_URL=$(read_env LIVEKIT_WS_URL) ELEVENLABS_API_KEY=$(read_env ELEVENLABS_API_KEY) ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE) ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL) diff --git a/spacetimedb/src/lib.rs b/spacetimedb/src/lib.rs index 92e64c6..03b29db 100644 --- a/spacetimedb/src/lib.rs +++ b/spacetimedb/src/lib.rs @@ -273,6 +273,147 @@ pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> { Ok(()) } +// ============================================================================= +// Live-rom (sanntidslyd via LiveKit) +// ============================================================================= + +/// Aktive LiveKit-rom knyttet til kommunikasjonsnoder. +/// Transient — finnes bare mens rommet er aktivt. +#[spacetimedb::table(accessor = live_room, public)] +pub struct LiveRoom { + #[primary_key] + pub room_id: String, // "communication_{uuid}" + + #[index(btree)] + pub communication_id: String, + pub is_active: bool, + pub started_at: Timestamp, + pub participant_count: u32, +} + +/// Deltakere i aktive LiveKit-rom. +#[spacetimedb::table(accessor = room_participant, public)] +pub struct RoomParticipant { + #[primary_key] + pub id: String, // "{room_id}:{user_id}" + + #[index(btree)] + pub room_id: String, + #[index(btree)] + pub user_id: String, + pub display_name: String, + pub role: String, // "publisher" | "subscriber" + pub joined_at: Timestamp, +} + +#[reducer] +pub fn create_live_room( + ctx: &ReducerContext, + room_id: String, + communication_id: String, +) -> Result<(), String> { + if room_id.is_empty() { + return Err("room_id kan ikke være tom".into()); + } + // Idempotent — hvis rommet allerede finnes, oppdater + if let Some(existing) = ctx.db.live_room().room_id().find(&room_id) { + ctx.db.live_room().room_id().update(LiveRoom { + is_active: true, + ..existing + }); + return Ok(()); + } + ctx.db.live_room().insert(LiveRoom { + room_id, + communication_id, + is_active: true, + started_at: ctx.timestamp, + participant_count: 0, + }); + Ok(()) +} + +#[reducer] +pub fn add_room_participant( + ctx: &ReducerContext, + room_id: String, + user_id: String, + display_name: String, + role: String, +) -> Result<(), String> { + let id = format!("{room_id}:{user_id}"); + + // Idempotent — oppdater hvis allerede finnes + if let Some(existing) = ctx.db.room_participant().id().find(&id) { + ctx.db.room_participant().id().update(RoomParticipant { + display_name, + role, + ..existing + }); + return Ok(()); + } + + ctx.db.room_participant().insert(RoomParticipant { + id, + room_id: room_id.clone(), + user_id, + display_name, + role, + joined_at: ctx.timestamp, + }); + + // Oppdater deltakertelling + if let Some(room) = ctx.db.live_room().room_id().find(&room_id) { + ctx.db.live_room().room_id().update(LiveRoom { + participant_count: room.participant_count + 1, + ..room + }); + } + Ok(()) +} + +#[reducer] +pub fn remove_room_participant( + ctx: &ReducerContext, + room_id: String, + user_id: String, +) -> Result<(), String> { + let id = format!("{room_id}:{user_id}"); + ctx.db.room_participant().id().delete(&id); + + // Oppdater deltakertelling + if let Some(room) = ctx.db.live_room().room_id().find(&room_id) { + let new_count = room.participant_count.saturating_sub(1); + ctx.db.live_room().room_id().update(LiveRoom { + participant_count: new_count, + ..room + }); + } + Ok(()) +} + +#[reducer] +pub fn close_live_room( + ctx: &ReducerContext, + room_id: String, +) -> Result<(), String> { + // Fjern alle deltakere + let participants: Vec<_> = ctx.db.room_participant().room_id().filter(&room_id).collect(); + for p in participants { + ctx.db.room_participant().id().delete(&p.id); + } + + // Marker rommet som inaktivt + if let Some(room) = ctx.db.live_room().room_id().find(&room_id) { + ctx.db.live_room().room_id().update(LiveRoom { + is_active: false, + participant_count: 0, + ..room + }); + } + Ok(()) +} + // ============================================================================= // Warmup/vedlikehold // ============================================================================= diff --git a/tasks.md b/tasks.md index 4cd173e..12d7bd3 100644 --- a/tasks.md +++ b/tasks.md @@ -124,8 +124,7 @@ Uavhengige faser kan fortsatt plukkes. ## Fase 11: Produksjons-pipeline - [x] 11.1 LiveKit oppsett: Docker-container for WebRTC. Ref: `docs/setup/produksjon.md`. -- [~] 11.2 Sanntidslyd: kommunikasjonsnode med live-status → LiveKit-rom for deltakere. - > Påbegynt: 2026-03-17T23:42 +- [x] 11.2 Sanntidslyd: kommunikasjonsnode med live-status → LiveKit-rom for deltakere. - [ ] 11.3 Pruning-logikk: TTL per modalitet, signaler som forlenger levetid, disk-nødventil. - [ ] 11.4 Podcast-RSS: samlings-node med publiserings-edges → generert RSS-feed.