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>
This commit is contained in:
vegard 2026-03-18 07:59:07 +00:00
parent aa42b0e257
commit 8bf82a78d9
5 changed files with 212 additions and 4 deletions

View file

@ -43,7 +43,7 @@ Når en melding vises i en kontekst (chat, kanban, storyboard, kalender), trenge
```sql ```sql
CREATE TABLE message_placements ( CREATE TABLE message_placements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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_type TEXT NOT NULL, -- 'chat', 'kanban', 'storyboard', 'calendar', 'notes'
context_id UUID NOT NULL, -- channel_id, board_id, episode_id, calendar_id, note_id 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 entered_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- når objektet ankom denne konteksten

View file

@ -346,6 +346,75 @@ impl StdbClient {
self.call_reducer("close_live_room", &Args { room_id }).await 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 // Vedlikehold
// ========================================================================= // =========================================================================

View file

@ -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;

View file

@ -273,6 +273,28 @@ pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> {
Ok(()) 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) // Mixer-kanaler (delt mixer-kontroll via SpacetimeDB)
// ============================================================================= // =============================================================================
@ -684,6 +706,87 @@ pub fn set_mixer_role(
Ok(()) 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 // Warmup/vedlikehold
// ============================================================================= // =============================================================================
@ -708,6 +811,10 @@ pub fn clear_all(ctx: &ReducerContext) -> Result<(), String> {
for m in all_mixer { for m in all_mixer {
ctx.db.mixer_channel().id().delete(&m.id); 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(()) Ok(())
} }

View file

@ -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" 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. - [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.
> Påbegynt: 2026-03-18T07:54
- [ ] 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.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` § 45. - [ ] 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` § 45.
- [ ] 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. - [ ] 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.