synops/spacetimedb/src/lib.rs
vegard 8bf82a78d9 Implementer message_placements (oppgave 20.1)
Plasseringsrelasjon som sporer hvor meldinger vises på tvers av
kontekster (chat, kanban, storyboard, kalender, notes). Grunnmuren
for universell overføring mellom verktøy-paneler.

Tre deler:
- PG-migrasjon 016: message_placements tabell med UNIQUE constraint
  og indekser for kontekst- og meldingsoppslag
- SpacetimeDB: MessagePlacement tabell + place_message, remove_placement,
  move_on_canvas reducers for sanntids UI-oppdatering
- Maskinrommet: STDB-klientmetoder for de tre reducerne

Avvik fra spec: FK refererer nodes(id) i stedet for messages(id) siden
meldinger er noder (node_kind = 'melding'). Spec oppdatert tilsvarende.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 07:59:07 +00:00

820 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.01.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.01.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(&param_pattern) {
// Replace existing value — find the key and replace up to next comma or }
if let Some(start) = effects.find(&param_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<MessagePlacement> = 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<MessagePlacement> = 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(())
}