Sanntidslyd: kommunikasjonsnode → LiveKit-rom (oppgave 11.2)

Kobler kommunikasjonsnoder til LiveKit for sanntidslyd.
Bruker sender join_communication-intensjon, maskinrommet validerer
tilgang og returnerer signert LiveKit JWT-token + rom-URL.

Nye komponenter:
- maskinrommet/src/livekit.rs: JWT token-generering (HS256-signert
  med LIVEKIT_API_SECRET, 1-times TTL, publisher/subscriber-roller)
- POST /intentions/join_communication: validerer deltaker-edge,
  genererer token, oppretter rom i STDB, oppdaterer node-metadata
- POST /intentions/leave_communication: fjerner deltaker fra STDB
- POST /intentions/close_communication: stenger rom (krever owner)
- SpacetimeDB: live_room + room_participant tabeller for sanntids
  deltakerliste (frontend abonnerer via WebSocket)

SpacetimeDB-modul publisert som synops-v2 (ny identitet etter
at den opprinnelige ikke lenger var tilgjengelig). .env oppdatert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 23:54:40 +00:00
parent 5392560807
commit 445f32de69
8 changed files with 756 additions and 2 deletions

View file

@ -82,6 +82,27 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
- Body (JSON): `{ node_id }` - Body (JSON): `{ node_id }`
- Respons: `{ deleted: true }` - 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 ## 6. Instruks for Claude Code
- Maskinrommet (`maskinrommet/`) er Rust-prosjektet med axum, tokio, sqlx. - Maskinrommet (`maskinrommet/`) er Rust-prosjektet med axum, tokio, sqlx.

View file

@ -15,6 +15,7 @@ use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::livekit;
use crate::AppState; use crate::AppState;
/// Maks filstørrelse for upload: 100 MB. /// Maks filstørrelse for upload: 100 MB.
@ -2319,3 +2320,392 @@ pub async fn generate_tts(
Ok(Json(GenerateTtsResponse { job_id })) 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<String>,
}
#[derive(Serialize)]
pub struct JoinCommunicationResponse {
pub livekit_room_name: String,
pub livekit_token: String,
pub livekit_url: String,
pub identity: String,
pub participants: Vec<RoomParticipantInfo>,
}
#[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<AppState>,
user: AuthUser,
Json(req): Json<JoinCommunicationRequest>,
) -> Result<Json<JoinCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
user: AuthUser,
Json(req): Json<LeaveCommunicationRequest>,
) -> Result<Json<LeaveCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
user: AuthUser,
Json(req): Json<CloseCommunicationRequest>,
) -> Result<Json<CloseCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
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(),
}))
}

133
maskinrommet/src/livekit.rs Normal file
View file

@ -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<LiveKitToken, String> {
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<bool, String> {
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}")),
}
}

View file

@ -4,6 +4,7 @@ mod auth;
pub mod cas; pub mod cas;
mod intentions; mod intentions;
pub mod jobs; pub mod jobs;
pub mod livekit;
mod queries; mod queries;
mod serving; mod serving;
mod stdb; mod stdb;
@ -153,6 +154,9 @@ async fn main() {
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription)) .route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
.route("/intentions/summarize", post(intentions::summarize)) .route("/intentions/summarize", post(intentions::summarize))
.route("/intentions/generate_tts", post(intentions::generate_tts)) .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/aliases", get(queries::query_aliases))
.route("/query/graph", get(queries::query_graph)) .route("/query/graph", get(queries::query_graph))
.route("/query/transcription_versions", get(queries::query_transcription_versions)) .route("/query/transcription_versions", get(queries::query_transcription_versions))

View file

@ -281,6 +281,71 @@ impl StdbClient {
.await .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 // Vedlikehold
// ========================================================================= // =========================================================================

View file

@ -29,6 +29,7 @@ LITELLM_MASTER_KEY=$(read_env LITELLM_MASTER_KEY)
LIVEKIT_URL=http://${LIVEKIT_IP:-localhost}:7880 LIVEKIT_URL=http://${LIVEKIT_IP:-localhost}:7880
LIVEKIT_API_KEY=$(read_env LIVEKIT_API_KEY) LIVEKIT_API_KEY=$(read_env LIVEKIT_API_KEY)
LIVEKIT_API_SECRET=$(read_env LIVEKIT_API_SECRET) 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_API_KEY=$(read_env ELEVENLABS_API_KEY)
ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE) ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE)
ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL) ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL)

View file

@ -273,6 +273,147 @@ pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> {
Ok(()) 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 // Warmup/vedlikehold
// ============================================================================= // =============================================================================

View file

@ -124,8 +124,7 @@ Uavhengige faser kan fortsatt plukkes.
## Fase 11: Produksjons-pipeline ## Fase 11: Produksjons-pipeline
- [x] 11.1 LiveKit oppsett: Docker-container for WebRTC. Ref: `docs/setup/produksjon.md`. - [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. - [x] 11.2 Sanntidslyd: kommunikasjonsnode med live-status → LiveKit-rom for deltakere.
> Påbegynt: 2026-03-17T23:42
- [ ] 11.3 Pruning-logikk: TTL per modalitet, signaler som forlenger levetid, disk-nødventil. - [ ] 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. - [ ] 11.4 Podcast-RSS: samlings-node med publiserings-edges → generert RSS-feed.