// Synops SpacetimeDB-modul // // Speiler PG-skjema (nodes + edges) som sanntids-cache. // PG er autoritativ; SpacetimeDB er varm cache for frontend. // Maskinrommet gjør warmup (PG → ST) ved oppstart og toveissynk deretter. // // Ref: docs/retninger/datalaget.md, docs/primitiver/nodes.md, docs/primitiver/edges.md use spacetimedb::{reducer, Table, ReducerContext, Timestamp}; // ============================================================================= // Tabeller // ============================================================================= /// Speiler PG nodes-tabellen. UUID-er representeres som String. /// visibility og node_kind er strenger (PG-enums har ikke STDB-ekvivalent). /// metadata er JSON-streng (PG JSONB). #[spacetimedb::table(accessor = node, public)] pub struct Node { #[primary_key] pub id: String, pub node_kind: String, pub title: String, pub content: String, pub visibility: String, pub metadata: String, pub created_at: Timestamp, pub created_by: String, } /// Speiler PG node_access-tabellen (materialisert tilgangsmatrise). /// subject_id = bruker/team, object_id = noden det gis tilgang til. /// Brukes av frontend for visibility-filtrering. #[spacetimedb::table(accessor = node_access, public)] pub struct NodeAccess { #[primary_key] pub id: String, // "{subject_id}:{object_id}" — kompositt-nøkkel som streng #[index(btree)] pub subject_id: String, #[index(btree)] pub object_id: String, pub access: String, // reader, member, admin, owner pub via_edge: String, } /// Speiler PG edges-tabellen. #[spacetimedb::table(accessor = edge, public)] pub struct Edge { #[primary_key] pub id: String, #[index(btree)] pub source_id: String, #[index(btree)] pub target_id: String, pub edge_type: String, pub metadata: String, pub system: bool, pub created_at: Timestamp, pub created_by: String, } // ============================================================================= // Livssyklus // ============================================================================= #[reducer(init)] pub fn init(_ctx: &ReducerContext) { log::info!("Synops-modul initialisert"); } #[reducer(client_connected)] pub fn client_connected(ctx: &ReducerContext) { log::info!("Klient tilkoblet: {}", ctx.sender().to_hex()); } #[reducer(client_disconnected)] pub fn client_disconnected(ctx: &ReducerContext) { log::info!("Klient frakoblet: {}", ctx.sender().to_hex()); } // ============================================================================= // Node CRUD // ============================================================================= #[reducer] pub fn create_node( ctx: &ReducerContext, id: String, node_kind: String, title: String, content: String, visibility: String, metadata: String, created_by: String, ) -> Result<(), String> { if id.is_empty() { return Err("id kan ikke være tom".into()); } if ctx.db.node().id().find(&id).is_some() { return Err(format!("Node med id {} finnes allerede", id)); } ctx.db.node().insert(Node { id, node_kind, title, content, visibility, metadata, created_at: ctx.timestamp, created_by, }); Ok(()) } #[reducer] pub fn update_node( ctx: &ReducerContext, id: String, node_kind: String, title: String, content: String, visibility: String, metadata: String, ) -> Result<(), String> { let existing = ctx.db.node().id().find(&id) .ok_or_else(|| format!("Node {} ikke funnet", id))?; ctx.db.node().id().update(Node { node_kind, title, content, visibility, metadata, ..existing }); Ok(()) } #[reducer] pub fn delete_node(ctx: &ReducerContext, id: String) -> Result<(), String> { // Slett tilhørende edges først (speiler PG ON DELETE CASCADE) let source_edges: Vec<_> = ctx.db.edge().source_id().filter(&id).collect(); for e in source_edges { ctx.db.edge().id().delete(&e.id); } let target_edges: Vec<_> = ctx.db.edge().target_id().filter(&id).collect(); for e in target_edges { ctx.db.edge().id().delete(&e.id); } ctx.db.node().id().delete(&id); Ok(()) } // ============================================================================= // NodeAccess CRUD // ============================================================================= /// Upsert: opprett eller oppdater tilgang. id = "{subject_id}:{object_id}". #[reducer] pub fn upsert_node_access( ctx: &ReducerContext, subject_id: String, object_id: String, access: String, via_edge: String, ) -> Result<(), String> { let id = format!("{subject_id}:{object_id}"); if let Some(existing) = ctx.db.node_access().id().find(&id) { ctx.db.node_access().id().update(NodeAccess { access, via_edge, ..existing }); } else { ctx.db.node_access().insert(NodeAccess { id, subject_id, object_id, access, via_edge, }); } Ok(()) } /// Slett en spesifikk tilgangsrad. #[reducer] pub fn delete_node_access( ctx: &ReducerContext, subject_id: String, object_id: String, ) -> Result<(), String> { let id = format!("{subject_id}:{object_id}"); ctx.db.node_access().id().delete(&id); Ok(()) } /// Slett all tilgang for et gitt subject (brukes ved fjerning av bruker/team). #[reducer] pub fn delete_node_access_for_subject( ctx: &ReducerContext, subject_id: String, ) -> Result<(), String> { let entries: Vec<_> = ctx.db.node_access().subject_id().filter(&subject_id).collect(); for entry in entries { ctx.db.node_access().id().delete(&entry.id); } Ok(()) } // ============================================================================= // Edge CRUD // ============================================================================= #[reducer] pub fn create_edge( ctx: &ReducerContext, id: String, source_id: String, target_id: String, edge_type: String, metadata: String, system: bool, created_by: String, ) -> Result<(), String> { if id.is_empty() { return Err("id kan ikke være tom".into()); } if ctx.db.edge().id().find(&id).is_some() { return Err(format!("Edge med id {} finnes allerede", id)); } ctx.db.edge().insert(Edge { id, source_id, target_id, edge_type, metadata, system, created_at: ctx.timestamp, created_by, }); Ok(()) } #[reducer] pub fn update_edge( ctx: &ReducerContext, id: String, edge_type: String, metadata: String, ) -> Result<(), String> { let existing = ctx.db.edge().id().find(&id) .ok_or_else(|| format!("Edge {} ikke funnet", id))?; ctx.db.edge().id().update(Edge { edge_type, metadata, ..existing }); Ok(()) } #[reducer] pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> { ctx.db.edge().id().delete(&id); Ok(()) } // ============================================================================= // Plasseringsrelasjon (message_placements) // ============================================================================= /// Sporer hvor meldinger vises på tvers av kontekster (chat, kanban, storyboard, etc.). /// Sanntidskopi — PG er autoritativ, SpacetimeDB gir instant UI-oppdatering. /// Synk: STDB → PG via sync-worker. /// Ref: docs/features/universell_overfoering.md § 2, 5 #[spacetimedb::table(accessor = message_placement, public)] pub struct MessagePlacement { #[primary_key] pub id: String, #[index(btree)] pub message_id: String, #[index(btree)] pub context_id: String, pub context_type: String, // 'chat', 'kanban', 'storyboard', 'calendar', 'notes' pub entered_at: Timestamp, pub position_json: String, // JSON-serialisert posisjon (null for chat, {x,y} for storyboard, etc.) } // ============================================================================= // Mixer-kanaler (delt mixer-kontroll via SpacetimeDB) // ============================================================================= /// Delt mixer-state per kanal i et live-rom. /// Transient — finnes bare mens rommet er aktivt. /// Alle deltakere abonnerer og ser endringer i sanntid. #[spacetimedb::table(accessor = mixer_channel, public)] pub struct MixerChannel { #[primary_key] pub id: String, // "{room_id}:{target_user_id}" #[index(btree)] pub room_id: String, #[index(btree)] pub target_user_id: String, pub gain: f64, // 0.0–1.5 pub is_muted: bool, pub active_effects: String, // JSON: {"fat_bottom": true, "robot": false, ...} pub role: String, // "editor" | "viewer" — tilgangskontroll pub updated_by: String, pub updated_at: Timestamp, } // ============================================================================= // 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); } // Fjern alle mixer-kanaler for dette rommet let mixer_channels: Vec<_> = ctx.db.mixer_channel().room_id().filter(&room_id).collect(); for mc in mixer_channels { ctx.db.mixer_channel().id().delete(&mc.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(()) } // ============================================================================= // Mixer-kontroll reducers // ============================================================================= /// Opprett eller oppdater en mixer-kanal med standardverdier. #[reducer] pub fn create_mixer_channel( ctx: &ReducerContext, room_id: String, target_user_id: String, updated_by: String, ) -> Result<(), String> { let id = format!("{room_id}:{target_user_id}"); // Idempotent — oppdater hvis allerede finnes if let Some(existing) = ctx.db.mixer_channel().id().find(&id) { ctx.db.mixer_channel().id().update(MixerChannel { updated_by, updated_at: ctx.timestamp, ..existing }); return Ok(()); } ctx.db.mixer_channel().insert(MixerChannel { id, room_id, target_user_id, gain: 1.0, is_muted: false, active_effects: "{}".to_string(), role: "editor".to_string(), updated_by, updated_at: ctx.timestamp, }); Ok(()) } /// Sett gain (volum) for en mixer-kanal. Clampes til 0.0–1.5. #[reducer] pub fn set_gain( ctx: &ReducerContext, room_id: String, target_user_id: String, gain: f64, updated_by: String, ) -> Result<(), String> { let id = format!("{room_id}:{target_user_id}"); let clamped = gain.clamp(0.0, 1.5); let existing = ctx.db.mixer_channel().id().find(&id) .ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?; // Tilgangskontroll: viewer kan ikke endre if existing.role == "viewer" && existing.target_user_id != updated_by { return Err("Viewer kan ikke endre mixer-innstillinger".into()); } ctx.db.mixer_channel().id().update(MixerChannel { gain: clamped, updated_by, updated_at: ctx.timestamp, ..existing }); Ok(()) } /// Sett mute-status for en mixer-kanal. #[reducer] pub fn set_mute( ctx: &ReducerContext, room_id: String, target_user_id: String, is_muted: bool, updated_by: String, ) -> Result<(), String> { let id = format!("{room_id}:{target_user_id}"); let existing = ctx.db.mixer_channel().id().find(&id) .ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?; if existing.role == "viewer" && existing.target_user_id != updated_by { return Err("Viewer kan ikke endre mixer-innstillinger".into()); } ctx.db.mixer_channel().id().update(MixerChannel { is_muted, updated_by, updated_at: ctx.timestamp, ..existing }); Ok(()) } /// Toggle en effekt for en mixer-kanal. Effektnavnet slås av/på i active_effects JSON. #[reducer] pub fn toggle_effect( ctx: &ReducerContext, room_id: String, target_user_id: String, effect_name: String, updated_by: String, ) -> Result<(), String> { let id = format!("{room_id}:{target_user_id}"); let existing = ctx.db.mixer_channel().id().find(&id) .ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?; if existing.role == "viewer" && existing.target_user_id != updated_by { return Err("Viewer kan ikke endre mixer-innstillinger".into()); } // Parse, toggle, serialize tilbake // Enkel JSON-håndtering uten serde (STDB-moduler er lette) let mut effects = existing.active_effects.clone(); if effects.contains(&format!("\"{}\":true", effect_name)) { effects = effects.replace( &format!("\"{}\":true", effect_name), &format!("\"{}\":false", effect_name), ); } else if effects.contains(&format!("\"{}\":false", effect_name)) { effects = effects.replace( &format!("\"{}\":false", effect_name), &format!("\"{}\":true", effect_name), ); } else { // Effekten finnes ikke — legg til som aktiv if effects == "{}" { effects = format!("{{\"{}\":true}}", effect_name); } else { // Sett inn før siste } effects = effects.trim_end_matches('}').to_string() + &format!(",\"{}\":true}}", effect_name); } } ctx.db.mixer_channel().id().update(MixerChannel { active_effects: effects, updated_by, updated_at: ctx.timestamp, ..existing }); Ok(()) } /// Sett en numerisk effektparameter i active_effects JSON. /// Brukes for stemmeeffekter (robot_freq, robot_depth, monster_pitch) som har /// parameterverdier, ikke bare av/på. Nøkkelen settes til den gitte verdien. #[reducer] pub fn set_effect_param( ctx: &ReducerContext, room_id: String, target_user_id: String, param_name: String, value: f64, updated_by: String, ) -> Result<(), String> { let id = format!("{room_id}:{target_user_id}"); let existing = ctx.db.mixer_channel().id().find(&id) .ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?; if existing.role == "viewer" && existing.target_user_id != updated_by { return Err("Viewer kan ikke endre mixer-innstillinger".into()); } let mut effects = existing.active_effects.clone(); let param_pattern = format!("\"{}\":", param_name); if effects.contains(¶m_pattern) { // Replace existing value — find the key and replace up to next comma or } if let Some(start) = effects.find(¶m_pattern) { let value_start = start + param_pattern.len(); // Find end of value (next comma or closing brace) let rest = &effects[value_start..]; let value_end = rest.find(',').unwrap_or_else(|| rest.find('}').unwrap_or(rest.len())); effects = format!( "{}{}{}", &effects[..value_start], value, &effects[value_start + value_end..] ); } } else { // Add new param if effects == "{}" { effects = format!("{{\"{}\":{}}}", param_name, value); } else { effects = effects.trim_end_matches('}').to_string() + &format!(",\"{}\":{}}}", param_name, value); } } ctx.db.mixer_channel().id().update(MixerChannel { active_effects: effects, updated_by, updated_at: ctx.timestamp, ..existing }); Ok(()) } /// Slett en mixer-kanal (når deltaker forlater rommet). #[reducer] pub fn delete_mixer_channel( ctx: &ReducerContext, room_id: String, target_user_id: String, ) -> Result<(), String> { let id = format!("{room_id}:{target_user_id}"); ctx.db.mixer_channel().id().delete(&id); Ok(()) } /// Sett rolle (editor/viewer) for en mixer-kanal. /// Bare eier/admin kan endre rolle. #[reducer] pub fn set_mixer_role( ctx: &ReducerContext, room_id: String, target_user_id: String, role: String, _updated_by: String, ) -> Result<(), String> { if role != "editor" && role != "viewer" { return Err(format!("Ugyldig rolle: {}. Bruk 'editor' eller 'viewer'", role)); } let id = format!("{room_id}:{target_user_id}"); let existing = ctx.db.mixer_channel().id().find(&id) .ok_or_else(|| format!("Mixer-kanal {} ikke funnet", id))?; ctx.db.mixer_channel().id().update(MixerChannel { role, updated_at: ctx.timestamp, ..existing }); Ok(()) } // ============================================================================= // Placement reducers // ============================================================================= /// Plasser en melding i en kontekst. Idempotent — oppdaterer hvis /// (message_id, context_type, context_id) allerede finnes. #[reducer] pub fn place_message( ctx: &ReducerContext, id: String, message_id: String, context_type: String, context_id: String, position_json: String, ) -> Result<(), String> { if id.is_empty() { return Err("id kan ikke være tom".into()); } // Sjekk om plassering allerede finnes (upsert-semantikk) // Søk på message_id + context_id (context_type er implisitt i context_id) let existing: Option = ctx.db.message_placement() .message_id() .filter(&message_id) .find(|p| p.context_type == context_type && p.context_id == context_id); if let Some(existing) = existing { ctx.db.message_placement().id().update(MessagePlacement { position_json, ..existing }); } else { ctx.db.message_placement().insert(MessagePlacement { id, message_id, context_type, context_id, entered_at: ctx.timestamp, position_json, }); } Ok(()) } /// Fjern en meldings plassering fra en kontekst. #[reducer] pub fn remove_placement( ctx: &ReducerContext, message_id: String, context_type: String, context_id: String, ) -> Result<(), String> { let existing: Option = ctx.db.message_placement() .message_id() .filter(&message_id) .find(|p| p.context_type == context_type && p.context_id == context_id); if let Some(placement) = existing { ctx.db.message_placement().id().delete(&placement.id); } Ok(()) } /// Flytt en plassering på canvas (oppdater posisjon). /// Brukes for storyboard, kanban-rekkefølge, kalender-dato, etc. #[reducer] pub fn move_on_canvas( ctx: &ReducerContext, placement_id: String, new_position_json: String, ) -> Result<(), String> { let existing = ctx.db.message_placement().id().find(&placement_id) .ok_or_else(|| format!("Plassering {} ikke funnet", placement_id))?; ctx.db.message_placement().id().update(MessagePlacement { position_json: new_position_json, ..existing }); Ok(()) } // ============================================================================= // Warmup/vedlikehold // ============================================================================= /// Tøm alle noder, edges, node_access og transiente data (brukes ved restart/warmup) #[reducer] pub fn clear_all(ctx: &ReducerContext) -> Result<(), String> { let all_access: Vec<_> = ctx.db.node_access().iter().collect(); for a in all_access { ctx.db.node_access().id().delete(&a.id); } let all_edges: Vec<_> = ctx.db.edge().iter().collect(); for e in all_edges { ctx.db.edge().id().delete(&e.id); } let all_nodes: Vec<_> = ctx.db.node().iter().collect(); for n in all_nodes { ctx.db.node().id().delete(&n.id); } // Rydd opp transiente data let all_mixer: Vec<_> = ctx.db.mixer_channel().iter().collect(); for m in all_mixer { ctx.db.mixer_channel().id().delete(&m.id); } let all_placements: Vec<_> = ctx.db.message_placement().iter().collect(); for p in all_placements { ctx.db.message_placement().id().delete(&p.id); } log::info!("Alle noder, edges, node_access, mixer_channels og message_placements slettet (clear_all)"); Ok(()) }