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>
438 lines
12 KiB
Rust
438 lines
12 KiB
Rust
// 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(())
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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
|
|
// =============================================================================
|
|
|
|
/// Tøm alle noder, edges og node_access (brukes ved restart/warmup for å unngå duplikater)
|
|
#[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);
|
|
}
|
|
log::info!("Alle noder, edges og node_access slettet (clear_all)");
|
|
Ok(())
|
|
}
|