diff --git a/docs/features/universell_overfoering.md b/docs/features/universell_overfoering.md index 33cd98e..4a754d5 100644 --- a/docs/features/universell_overfoering.md +++ b/docs/features/universell_overfoering.md @@ -43,7 +43,7 @@ Når en melding vises i en kontekst (chat, kanban, storyboard, kalender), trenge ```sql CREATE TABLE message_placements ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + message_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, context_type TEXT NOT NULL, -- 'chat', 'kanban', 'storyboard', 'calendar', 'notes' context_id UUID NOT NULL, -- channel_id, board_id, episode_id, calendar_id, note_id entered_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- når objektet ankom denne konteksten diff --git a/maskinrommet/src/stdb.rs b/maskinrommet/src/stdb.rs index ed3d8f9..b07552d 100644 --- a/maskinrommet/src/stdb.rs +++ b/maskinrommet/src/stdb.rs @@ -346,6 +346,75 @@ impl StdbClient { self.call_reducer("close_live_room", &Args { room_id }).await } + // ========================================================================= + // Placement-operasjoner (message_placements) + // ========================================================================= + + /// Plasser en melding i en kontekst. Idempotent (upsert). + pub async fn place_message( + &self, + id: &str, + message_id: &str, + context_type: &str, + context_id: &str, + position_json: &str, + ) -> Result<(), StdbError> { + #[derive(Serialize)] + struct Args<'a> { + id: &'a str, + message_id: &'a str, + context_type: &'a str, + context_id: &'a str, + position_json: &'a str, + } + + self.call_reducer( + "place_message", + &Args { id, message_id, context_type, context_id, position_json }, + ) + .await + } + + /// Fjern en meldings plassering fra en kontekst. + pub async fn remove_placement( + &self, + message_id: &str, + context_type: &str, + context_id: &str, + ) -> Result<(), StdbError> { + #[derive(Serialize)] + struct Args<'a> { + message_id: &'a str, + context_type: &'a str, + context_id: &'a str, + } + + self.call_reducer( + "remove_placement", + &Args { message_id, context_type, context_id }, + ) + .await + } + + /// Flytt en plassering (oppdater posisjon). + pub async fn move_on_canvas( + &self, + placement_id: &str, + new_position_json: &str, + ) -> Result<(), StdbError> { + #[derive(Serialize)] + struct Args<'a> { + placement_id: &'a str, + new_position_json: &'a str, + } + + self.call_reducer( + "move_on_canvas", + &Args { placement_id, new_position_json }, + ) + .await + } + // ========================================================================= // Vedlikehold // ========================================================================= diff --git a/migrations/016_message_placements.sql b/migrations/016_message_placements.sql new file mode 100644 index 0000000..0551a5a --- /dev/null +++ b/migrations/016_message_placements.sql @@ -0,0 +1,33 @@ +-- 016_message_placements.sql +-- Plasseringsrelasjon: sporer hvor meldinger vises på tvers av kontekster. +-- En melding (node) kan ha plasseringer i flere kontekster samtidig +-- (chat, kanban, storyboard, kalender, notes). +-- +-- Ref: docs/features/universell_overfoering.md § 2 + +BEGIN; + +CREATE TABLE message_placements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + context_type TEXT NOT NULL, -- 'chat', 'kanban', 'storyboard', 'calendar', 'notes' + context_id UUID NOT NULL, -- channel_id, board_id, episode_id, calendar_id, note_id + entered_at TIMESTAMPTZ NOT NULL DEFAULT now(), + position JSONB, -- kontekst-spesifikk posisjon (se § 2.2) + UNIQUE (message_id, context_type, context_id) +); + +-- Oppslag: alle meldinger i en gitt kontekst, sortert etter ankomst +CREATE INDEX idx_placements_context ON message_placements(context_type, context_id, entered_at); + +-- Oppslag: alle plasseringer for en gitt melding +CREATE INDEX idx_placements_message ON message_placements(message_id); + +COMMENT ON TABLE message_placements IS 'Plasseringsrelasjon — sporer hvor meldinger vises. Ref: docs/features/universell_overfoering.md § 2'; +COMMENT ON COLUMN message_placements.message_id IS 'Meldingens node-id (node_kind = melding)'; +COMMENT ON COLUMN message_placements.context_type IS 'Konteksttype: chat, kanban, storyboard, calendar, notes'; +COMMENT ON COLUMN message_placements.context_id IS 'ID til kontekstnoden (kanal, brett, episode, etc.)'; +COMMENT ON COLUMN message_placements.entered_at IS 'Når meldingen ankom denne konteksten (ikke created_at)'; +COMMENT ON COLUMN message_placements.position IS 'Kontekst-spesifikk posisjon som JSONB. null=sortert etter entered_at (chat). {x,y}=canvas (storyboard). {column_id,position}=kanban. {date,all_day}=kalender. {position}=notes.'; + +COMMIT; diff --git a/spacetimedb/src/lib.rs b/spacetimedb/src/lib.rs index 6ca036d..c3ebeb6 100644 --- a/spacetimedb/src/lib.rs +++ b/spacetimedb/src/lib.rs @@ -273,6 +273,28 @@ pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> { 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) // ============================================================================= @@ -684,6 +706,87 @@ pub fn set_mixer_role( 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 // ============================================================================= @@ -708,6 +811,10 @@ pub fn clear_all(ctx: &ReducerContext) -> Result<(), String> { for m in all_mixer { ctx.db.mixer_channel().id().delete(&m.id); } - log::info!("Alle noder, edges, node_access og mixer_channels slettet (clear_all)"); + 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(()) } diff --git a/tasks.md b/tasks.md index 1a310e8..74833c7 100644 --- a/tasks.md +++ b/tasks.md @@ -223,8 +223,7 @@ Ref: `docs/retninger/arbeidsflaten.md`, `docs/features/canvas_primitiv.md` Ref: `docs/features/universell_overfoering.md`, `docs/retninger/arbeidsflaten.md` § "Kompatibilitetsmatrise" -- [~] 20.1 message_placements tabell: PG-migrasjon + SpacetimeDB-modul med `place_message`, `remove_placement`, `move_on_canvas` reducers. Synk STDB→PG. Ref: `docs/features/universell_overfoering.md` § 2. - > Påbegynt: 2026-03-18T07:54 +- [x] 20.1 message_placements tabell: PG-migrasjon + SpacetimeDB-modul med `place_message`, `remove_placement`, `move_on_canvas` reducers. Synk STDB→PG. Ref: `docs/features/universell_overfoering.md` § 2. - [ ] 20.2 source_material edge-type: legg til i edge-skjema + maskinrommet-validering. Støtt kontekst-metadata (quoted, summarized, referenced) og excerpt-felt. Ref: `docs/retninger/arbeidsflaten.md` § "source_material-edge". - [ ] 20.3 BlockReceiver interface: implementer `canReceive()`, `receive()`, `renderDropZone()` i alle trait-komponenter (Chat, Kanban, Kalender, Editor, Studio). Kompatibilitetsmatrise bestemmer godkjente drops. Ref: `docs/features/universell_overfoering.md` § 4–5. - [ ] 20.4 Transfer service: `innholdstransfer`-modus (ny node + source_material edge) og `lettvekts-triage` (eksisterende node + ny edge/placement). Bestem modus fra verktøy-par. Shift-modifier for override. Ref: `docs/features/universell_overfoering.md` § 1, 3.