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>
820 lines
24 KiB
Rust
820 lines
24 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(())
|
||
}
|
||
|
||
// =============================================================================
|
||
// 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<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(())
|
||
}
|