synops/maskinrommet/src/intentions.rs
vegard 33a1b44946 Implementer Claude som chat-deltaker (Fase A: MVP)
Claude er nå en agent-node i grafen som kan delta i samtaler.
Når en bruker sender melding i en kommunikasjonsnode der Claude
er deltaker, enqueues en agent_respond-jobb som kaller claude CLI
direkte og skriver svaret tilbake til chatten.

Nye filer:
- migrations/007_agent_system.sql: agent_identities, agent_permissions, ai_usage_log
- maskinrommet/src/agent.rs: agent_respond job handler
- scripts/maskinrommet.service: systemd-tjeneste for native kjøring
- scripts/maskinrommet-env.sh: genererer env med Docker container-IPs

Endringer:
- intentions.rs: trigger agent_respond ved melding i agent-chat
- jobs.rs: dispatch agent_respond til agent-handler
- frontend chat: bot-badge (🤖) og amber-farge på agent-meldinger
- LiteLLM config: resonering-modellalias via OpenRouter

Maskinrommet kjører nå direkte på hosten (ikke i Docker) for å
ha tilgang til claude CLI. Caddy peker til host.docker.internal.

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

1997 lines
62 KiB
Rust

// Intensjoner — skrivestien i maskinrommet.
//
// Frontend sender intensjoner (ikke data). Maskinrommet validerer,
// skriver til SpacetimeDB først (instant feedback via WebSocket),
// deretter persisterer til PostgreSQL asynkront.
//
// Tilgangskontroll: Muterende operasjoner (update, delete) krever at
// brukeren er created_by på noden, eller har owner/admin-edge til den.
//
// Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md
use axum::{extract::{Multipart, State}, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::AppState;
/// Maks filstørrelse for upload: 100 MB.
const MAX_UPLOAD_SIZE: usize = 100 * 1024 * 1024;
// =============================================================================
// Felles
// =============================================================================
/// Gyldige visibility-verdier (speiler PG enum).
const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"];
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn forbidden(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::FORBIDDEN,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn stdb_error(op: &str, e: crate::stdb::StdbError) -> (StatusCode, Json<ErrorResponse>) {
tracing::error!("STDB {op} feilet: {e}");
internal_error(&format!("Kunne ikke skrive til SpacetimeDB: {e}"))
}
// =============================================================================
// Tilgangskontroll og kontekstbasert identitet
// =============================================================================
/// Løser brukerens identitet i en kommunikasjonskontekst.
///
/// Hvis brukeren har et alias som er deltaker (owner, member_of, host_of)
/// i den gitte kommunikasjonsnoden, returneres alias-nodens ID.
/// Ellers returneres brukerens hoved-node_id.
///
/// Dette gjør at meldinger i en kommunikasjon automatisk krediteres
/// aliaset — f.eks. "Bjørn" i en podcast-samtale i stedet for "Vegard".
///
/// Ref: docs/primitiver/nodes.md (created_by), docs/primitiver/edges.md (alias)
async fn resolve_context_identity(
db: &PgPool,
user_id: Uuid,
context_id: Uuid,
) -> Result<Uuid, sqlx::Error> {
// Finn brukerens alias som er deltaker i kommunikasjonsnoden.
// Alias-edge: user_id --alias(system=true)--> alias_id
// Deltaker-edge: alias_id --owner/member_of/host_of--> context_id
let alias_id = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT e_alias.target_id
FROM edges e_alias
JOIN edges e_participant
ON e_participant.source_id = e_alias.target_id
WHERE e_alias.source_id = $1
AND e_alias.edge_type = 'alias'
AND e_alias.system = true
AND e_participant.target_id = $2
AND e_participant.edge_type IN ('owner', 'member_of', 'host_of')
LIMIT 1
"#,
)
.bind(user_id)
.bind(context_id)
.fetch_optional(db)
.await?;
Ok(alias_id.unwrap_or(user_id))
}
/// Henter alle alias-IDer for en bruker (via system alias-edges).
#[allow(dead_code)]
async fn user_alias_ids(db: &PgPool, user_id: Uuid) -> Result<Vec<Uuid>, sqlx::Error> {
let ids = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT target_id FROM edges
WHERE source_id = $1 AND edge_type = 'alias' AND system = true
"#,
)
.bind(user_id)
.fetch_all(db)
.await?;
Ok(ids)
}
/// Sjekker om brukeren har skrivetilgang til en node.
/// Returnerer true hvis brukeren (eller et av brukerens aliaser) er created_by,
/// eller har owner/admin-edge til noden.
async fn user_can_modify_node(db: &PgPool, user_id: Uuid, node_id: Uuid) -> Result<bool, sqlx::Error> {
// Sjekk direkte eierskap, alias-eierskap, eller admin/owner-edge
let row = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM nodes WHERE id = $1 AND created_by = $2
) OR EXISTS(
SELECT 1 FROM edges
WHERE source_id = $2 AND target_id = $1
AND edge_type IN ('owner', 'admin')
) OR EXISTS(
-- Sjekk om created_by er et av brukerens aliaser
SELECT 1 FROM nodes n
JOIN edges e_alias ON e_alias.target_id = n.created_by
WHERE n.id = $1
AND e_alias.source_id = $2
AND e_alias.edge_type = 'alias'
AND e_alias.system = true
)
"#,
)
.bind(node_id)
.bind(user_id)
.fetch_one(db)
.await?;
Ok(row)
}
/// Sjekker om brukeren har skrivetilgang til en edge.
/// Brukeren må ha opprettet edgen (direkte eller via alias),
/// eller ha owner/admin-edge til source-noden.
#[allow(dead_code)]
async fn user_can_modify_edge(db: &PgPool, user_id: Uuid, edge_id: Uuid) -> Result<bool, sqlx::Error> {
let row = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM edges WHERE id = $1 AND created_by = $2
) OR EXISTS(
SELECT 1 FROM edges e
JOIN edges access_edge ON access_edge.source_id = $2
AND access_edge.target_id = e.source_id
AND access_edge.edge_type IN ('owner', 'admin')
WHERE e.id = $1
) OR EXISTS(
-- Sjekk om created_by er et av brukerens aliaser
SELECT 1 FROM edges e
JOIN edges e_alias ON e_alias.target_id = e.created_by
WHERE e.id = $1
AND e_alias.source_id = $2
AND e_alias.edge_type = 'alias'
AND e_alias.system = true
)
"#,
)
.bind(edge_id)
.bind(user_id)
.fetch_one(db)
.await?;
Ok(row)
}
/// Sjekker om en node eksisterer i PG.
async fn node_exists(db: &PgPool, node_id: Uuid) -> Result<bool, sqlx::Error> {
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)")
.bind(node_id)
.fetch_one(db)
.await
}
// =============================================================================
// create_node
// =============================================================================
#[derive(Deserialize)]
pub struct CreateNodeRequest {
/// Hint om hva noden er. Default: "content".
pub node_kind: Option<String>,
/// Visningstittel. Kan være null (f.eks. chatmeldinger).
pub title: Option<String>,
/// Ren tekst-innhold.
pub content: Option<String>,
/// Synlighet. Default: "hidden" (privat).
pub visibility: Option<String>,
/// Typespesifikk metadata (JSON-objekt).
pub metadata: Option<serde_json::Value>,
/// Kontekst-node (f.eks. kommunikasjonsnode). Hvis satt, opprettes
/// automatisk en `belongs_to`-edge fra den nye noden til kontekstnoden.
/// Ref: docs/retninger/universell_input.md (kontekst-arv).
pub context_id: Option<Uuid>,
}
#[derive(Serialize)]
pub struct CreateNodeResponse {
pub node_id: Uuid,
/// Edge-ID for automatisk opprettet `belongs_to`-edge (kun ved context_id).
#[serde(skip_serializing_if = "Option::is_none")]
pub belongs_to_edge_id: Option<Uuid>,
}
/// POST /intentions/create_node
///
/// Validerer input, skriver til STDB (instant), spawner async PG-skriving.
/// Returnerer node_id umiddelbart.
///
/// Hvis `context_id` er satt, opprettes automatisk en `belongs_to`-edge
/// fra den nye noden til kontekstnoden. Kontekstnoden må eksistere og
/// være en kommunikasjonsnode. Ref: docs/retninger/universell_input.md
pub async fn create_node(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateNodeRequest>,
) -> Result<Json<CreateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Valider input --
let node_kind = req.node_kind.unwrap_or_else(|| "content".to_string());
if node_kind.is_empty() {
return Err(bad_request("node_kind kan ikke være tom"));
}
let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string());
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
return Err(bad_request(&format!(
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
)));
}
// -- Valider context_id hvis satt --
if let Some(ctx_id) = req.context_id {
let ctx_node = sqlx::query_as::<_, NodeKindRow>(
"SELECT node_kind FROM nodes WHERE id = $1",
)
.bind(ctx_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved context_id-sjekk: {e}");
internal_error("Databasefeil ved validering av context_id")
})?;
match ctx_node {
None => return Err(bad_request(&format!("context_id {} finnes ikke", ctx_id))),
Some(row) if row.node_kind != "communication" => {
return Err(bad_request(&format!(
"context_id {} er en '{}'-node, ikke en kommunikasjonsnode",
ctx_id, row.node_kind
)));
}
_ => {} // OK — kommunikasjonsnode
}
}
let title = req.title.unwrap_or_default();
let content = req.content.unwrap_or_default();
let metadata = req
.metadata
.unwrap_or_else(|| serde_json::json!({}));
let metadata_str = metadata.to_string();
// -- Kontekstbasert identitet (oppgave 8.2) --
// Hvis context_id er satt, sjekk om brukeren har et alias som er
// deltaker i kommunikasjonsnoden. I så fall brukes aliaset som created_by.
let effective_identity = if let Some(ctx_id) = req.context_id {
resolve_context_identity(&state.db, user.node_id, ctx_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved identitetsoppslag: {e}");
internal_error("Databasefeil ved identitetsoppslag")
})?
} else {
user.node_id
};
// -- Generer UUIDv7 (tidssortert) --
let node_id = Uuid::now_v7();
let node_id_str = node_id.to_string();
let created_by_str = effective_identity.to_string();
// -- Skriv til SpacetimeDB (instant) --
state
.stdb
.create_node(
&node_id_str,
&node_kind,
&title,
&content,
&visibility,
&metadata_str,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_node", e))?;
tracing::info!(
node_id = %node_id,
node_kind = %node_kind,
created_by = %effective_identity,
auth_user = %user.node_id,
context_id = ?req.context_id,
alias_used = %(effective_identity != user.node_id),
"Node opprettet i STDB"
);
// -- Spawn async PG-skriving --
spawn_pg_insert_node(
state.db.clone(),
node_id,
node_kind,
title,
content,
visibility,
metadata,
effective_identity,
);
// -- Kontekst-arv: automatisk belongs_to-edge --
let belongs_to_edge_id = if let Some(ctx_id) = req.context_id {
let edge_id = Uuid::now_v7();
let edge_id_str = edge_id.to_string();
let ctx_id_str = ctx_id.to_string();
let bt_metadata = serde_json::json!({});
let bt_metadata_str = bt_metadata.to_string();
state
.stdb
.create_edge(
&edge_id_str,
&node_id_str, // source = ny node
&ctx_id_str, // target = kommunikasjonsnoden
"belongs_to",
&bt_metadata_str,
false,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_edge (belongs_to)", e))?;
tracing::info!(
edge_id = %edge_id,
node_id = %node_id,
context_id = %ctx_id,
"belongs_to-edge opprettet i STDB (kontekst-arv)"
);
// belongs_to er ikke tilgangsgivende — enkel PG-insert
spawn_pg_insert_edge(
state.db.clone(),
state.stdb.clone(),
edge_id,
node_id,
ctx_id,
"belongs_to".to_string(),
bt_metadata,
false,
effective_identity,
);
Some(edge_id)
} else {
None
};
// -- Agent-trigger: sjekk om kommunikasjonsnoden har en agent-deltaker --
if let Some(ctx_id) = req.context_id {
let db_clone = state.db.clone();
let user_node_id = user.node_id;
let created_node_id = node_id;
tokio::spawn(async move {
match crate::agent::find_agent_participant(&db_clone, ctx_id).await {
Ok(Some(agent_id)) if agent_id != effective_identity => {
// Agent funnet, og melding er ikke fra agenten selv
let payload = serde_json::json!({
"communication_id": ctx_id.to_string(),
"message_id": created_node_id.to_string(),
"agent_node_id": agent_id.to_string(),
"sender_node_id": user_node_id.to_string()
});
match crate::jobs::enqueue(&db_clone, "agent_respond", payload, None, 8).await {
Ok(job_id) => {
tracing::info!(
job_id = %job_id,
communication_id = %ctx_id,
agent_node_id = %agent_id,
"agent_respond-jobb lagt i kø"
);
}
Err(e) => {
tracing::error!(
communication_id = %ctx_id,
error = %e,
"Kunne ikke legge agent_respond-jobb i kø"
);
}
}
}
Ok(_) => {} // Ingen agent, eller melding fra agenten selv
Err(e) => {
tracing::error!(
communication_id = %ctx_id,
error = %e,
"Feil ved agent-deltaker-sjekk"
);
}
}
});
}
Ok(Json(CreateNodeResponse { node_id, belongs_to_edge_id }))
}
// =============================================================================
// create_edge
// =============================================================================
#[derive(Deserialize)]
pub struct CreateEdgeRequest {
/// Kilde-node (fra).
pub source_id: Uuid,
/// Mål-node (til).
pub target_id: Uuid,
/// Relasjontype (freeform streng). Ref: docs/primitiver/edges.md
pub edge_type: String,
/// Typespesifikk metadata (JSON-objekt).
pub metadata: Option<serde_json::Value>,
/// Systemedge — usynlig ved traversering. Default: false.
pub system: Option<bool>,
}
#[derive(Serialize)]
pub struct CreateEdgeResponse {
pub edge_id: Uuid,
}
/// POST /intentions/create_edge
///
/// Oppretter en retningsbestemt edge mellom to noder.
/// Krever at begge nodene eksisterer. Brukeren trenger ikke spesiell
/// tilgang for å opprette edges — tilgangskontroll på edges håndheves
/// ved lesing (node_access-matrisen, fase 4).
pub async fn create_edge(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateEdgeRequest>,
) -> Result<Json<CreateEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Valider input --
if req.edge_type.is_empty() {
return Err(bad_request("edge_type kan ikke være tom"));
}
// Sjekk at begge nodene eksisterer
let (source_exists, target_exists) = tokio::try_join!(
node_exists(&state.db, req.source_id),
node_exists(&state.db, req.target_id),
)
.map_err(|e| {
tracing::error!("PG-feil ved nodesjekk: {e}");
internal_error("Databasefeil ved validering")
})?;
if !source_exists {
return Err(bad_request(&format!("source_id {} finnes ikke", req.source_id)));
}
if !target_exists {
return Err(bad_request(&format!("target_id {} finnes ikke", req.target_id)));
}
let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({}));
let metadata_str = metadata.to_string();
let system = req.system.unwrap_or(false);
// -- Generer UUIDv7 --
let edge_id = Uuid::now_v7();
let edge_id_str = edge_id.to_string();
let source_id_str = req.source_id.to_string();
let target_id_str = req.target_id.to_string();
let created_by_str = user.node_id.to_string();
// -- Skriv til SpacetimeDB (instant) --
state
.stdb
.create_edge(
&edge_id_str,
&source_id_str,
&target_id_str,
&req.edge_type,
&metadata_str,
system,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_edge", e))?;
tracing::info!(
edge_id = %edge_id,
source_id = %req.source_id,
target_id = %req.target_id,
edge_type = %req.edge_type,
created_by = %user.node_id,
"Edge opprettet i STDB"
);
// -- Spawn async PG-skriving --
let edge_type = req.edge_type.clone();
spawn_pg_insert_edge(
state.db.clone(),
state.stdb.clone(),
edge_id,
req.source_id,
req.target_id,
edge_type,
metadata,
system,
user.node_id,
);
Ok(Json(CreateEdgeResponse { edge_id }))
}
// =============================================================================
// update_node
// =============================================================================
#[derive(Deserialize)]
pub struct UpdateNodeRequest {
/// ID til noden som skal oppdateres.
pub node_id: Uuid,
/// Ny node_kind. Beholder eksisterende hvis None.
pub node_kind: Option<String>,
/// Ny tittel. Beholder eksisterende hvis None.
pub title: Option<String>,
/// Nytt innhold. Beholder eksisterende hvis None.
pub content: Option<String>,
/// Ny synlighet. Beholder eksisterende hvis None.
pub visibility: Option<String>,
/// Ny metadata. Beholder eksisterende hvis None.
pub metadata: Option<serde_json::Value>,
}
#[derive(Serialize)]
pub struct UpdateNodeResponse {
pub node_id: Uuid,
}
/// POST /intentions/update_node
///
/// Oppdaterer en eksisterende node. Krever at brukeren er created_by
/// eller har owner/admin-edge til noden.
pub async fn update_node(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<UpdateNodeRequest>,
) -> Result<Json<UpdateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Tilgangskontroll --
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved tilgangssjekk: {e}");
internal_error("Databasefeil ved tilgangssjekk")
})?;
if !can_modify {
return Err(forbidden("Ingen tilgang til å endre denne noden"));
}
// -- Hent eksisterende node fra PG for å fylle inn manglende felt --
let existing = sqlx::query_as::<_, NodeRow>(
"SELECT node_kind, title, content, visibility::text, metadata FROM nodes WHERE id = $1",
)
.bind(req.node_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved henting av node: {e}");
internal_error("Databasefeil ved henting av node")
})?
.ok_or_else(|| bad_request(&format!("Node {} finnes ikke", req.node_id)))?;
let node_kind = req.node_kind.unwrap_or(existing.node_kind);
if node_kind.is_empty() {
return Err(bad_request("node_kind kan ikke være tom"));
}
let visibility = req.visibility.unwrap_or(existing.visibility);
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
return Err(bad_request(&format!(
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
)));
}
let title = req.title.unwrap_or(existing.title.unwrap_or_default());
let content = req.content.unwrap_or(existing.content.unwrap_or_default());
let metadata = req.metadata.unwrap_or(existing.metadata);
let metadata_str = metadata.to_string();
let node_id_str = req.node_id.to_string();
// -- Skriv til SpacetimeDB (instant) --
state
.stdb
.update_node(
&node_id_str,
&node_kind,
&title,
&content,
&visibility,
&metadata_str,
)
.await
.map_err(|e| stdb_error("update_node", e))?;
tracing::info!(
node_id = %req.node_id,
updated_by = %user.node_id,
"Node oppdatert i STDB"
);
// -- Spawn async PG-skriving --
spawn_pg_update_node(
state.db.clone(),
req.node_id,
node_kind,
title,
content,
visibility,
metadata,
);
Ok(Json(UpdateNodeResponse { node_id: req.node_id }))
}
// =============================================================================
// delete_node
// =============================================================================
#[derive(Deserialize)]
pub struct DeleteNodeRequest {
/// ID til noden som skal slettes.
pub node_id: Uuid,
}
#[derive(Serialize)]
pub struct DeleteNodeResponse {
pub deleted: bool,
}
/// POST /intentions/delete_node
///
/// Sletter en node og alle dens edges (CASCADE i PG, eksplisitt i STDB).
/// Krever at brukeren er created_by eller har owner/admin-edge til noden.
pub async fn delete_node(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<DeleteNodeRequest>,
) -> Result<Json<DeleteNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Tilgangskontroll --
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved tilgangssjekk: {e}");
internal_error("Databasefeil ved tilgangssjekk")
})?;
if !can_modify {
return Err(forbidden("Ingen tilgang til å slette denne noden"));
}
// Sjekk at noden eksisterer
let exists = node_exists(&state.db, req.node_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved nodesjekk: {e}");
internal_error("Databasefeil ved validering")
})?;
if !exists {
return Err(bad_request(&format!("Node {} finnes ikke", req.node_id)));
}
let node_id_str = req.node_id.to_string();
// -- Slett fra SpacetimeDB (instant) --
state
.stdb
.delete_node(&node_id_str)
.await
.map_err(|e| stdb_error("delete_node", e))?;
tracing::info!(
node_id = %req.node_id,
deleted_by = %user.node_id,
"Node slettet fra STDB"
);
// -- Spawn async PG-sletting --
spawn_pg_delete_node(state.db.clone(), req.node_id);
Ok(Json(DeleteNodeResponse { deleted: true }))
}
// =============================================================================
// create_communication
// =============================================================================
#[derive(Deserialize)]
pub struct CreateCommunicationRequest {
/// Visningstittel for kommunikasjonsnoden (f.eks. "Redaksjonsmøte").
pub title: Option<String>,
/// Deltakere — liste med node_id-er (person-noder).
/// Innlogget bruker legges automatisk til som owner.
pub participants: Vec<Uuid>,
/// Synlighet. Default: "hidden" (privat).
pub visibility: Option<String>,
}
#[derive(Serialize)]
pub struct CreateCommunicationResponse {
pub node_id: Uuid,
/// Edge-IDer for opprettede deltaker-edges (owner + member_of).
pub edge_ids: Vec<Uuid>,
}
/// POST /intentions/create_communication
///
/// Oppretter en kommunikasjonsnode med deltaker-edges.
/// Innlogget bruker blir automatisk owner. Andre deltakere får member_of-edge.
/// Metadata inneholder started_at-tidsstempel.
///
/// Ref: docs/primitiver/nodes.md (communication), docs/retninger/universell_input.md
pub async fn create_communication(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateCommunicationRequest>,
) -> Result<Json<CreateCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string());
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
return Err(bad_request(&format!(
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
)));
}
// Valider at alle deltakere eksisterer
for participant_id in &req.participants {
let exists = node_exists(&state.db, *participant_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved nodesjekk: {e}");
internal_error("Databasefeil ved validering")
})?;
if !exists {
return Err(bad_request(&format!(
"Deltaker-node {} finnes ikke",
participant_id
)));
}
}
let title = req.title.unwrap_or_default();
let now = chrono::Utc::now();
let metadata = serde_json::json!({ "started_at": now.to_rfc3339() });
let metadata_str = metadata.to_string();
// -- Opprett kommunikasjonsnoden --
let node_id = Uuid::now_v7();
let node_id_str = node_id.to_string();
let created_by_str = user.node_id.to_string();
state
.stdb
.create_node(
&node_id_str,
"communication",
&title,
"",
&visibility,
&metadata_str,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_node (communication)", e))?;
tracing::info!(
node_id = %node_id,
created_by = %user.node_id,
participants = ?req.participants,
"Kommunikasjonsnode opprettet i STDB"
);
// Spawn PG-skriving for noden
spawn_pg_insert_node(
state.db.clone(),
node_id,
"communication".to_string(),
title,
String::new(),
visibility,
metadata,
user.node_id,
);
// -- Opprett deltaker-edges --
let mut edge_ids = Vec::new();
// Owner-edge for innlogget bruker
let owner_edge_id = Uuid::now_v7();
edge_ids.push(owner_edge_id);
let owner_edge_id_str = owner_edge_id.to_string();
let owner_metadata = serde_json::json!({});
let owner_metadata_str = owner_metadata.to_string();
state
.stdb
.create_edge(
&owner_edge_id_str,
&created_by_str,
&node_id_str,
"owner",
&owner_metadata_str,
false,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_edge (owner)", e))?;
// Spawn PG-skriving for owner-edge (med access recompute)
spawn_pg_insert_edge(
state.db.clone(),
state.stdb.clone(),
owner_edge_id,
user.node_id,
node_id,
"owner".to_string(),
owner_metadata,
false,
user.node_id,
);
// member_of-edges for øvrige deltakere
for participant_id in &req.participants {
// Hopp over innlogget bruker — allerede owner
if *participant_id == user.node_id {
continue;
}
let edge_id = Uuid::now_v7();
edge_ids.push(edge_id);
let edge_id_str = edge_id.to_string();
let participant_id_str = participant_id.to_string();
let member_metadata = serde_json::json!({});
let member_metadata_str = member_metadata.to_string();
state
.stdb
.create_edge(
&edge_id_str,
&participant_id_str,
&node_id_str,
"member_of",
&member_metadata_str,
false,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_edge (member_of)", e))?;
spawn_pg_insert_edge(
state.db.clone(),
state.stdb.clone(),
edge_id,
*participant_id,
node_id,
"member_of".to_string(),
member_metadata,
false,
user.node_id,
);
}
tracing::info!(
node_id = %node_id,
edge_count = edge_ids.len(),
"Kommunikasjonsnode med deltaker-edges opprettet"
);
Ok(Json(CreateCommunicationResponse { node_id, edge_ids }))
}
// =============================================================================
// upload_media
// =============================================================================
#[derive(Serialize)]
pub struct UploadMediaResponse {
/// ID til den opprettede media-noden.
pub media_node_id: Uuid,
/// SHA-256 hash (CAS-nøkkel).
pub cas_hash: String,
/// Filstørrelse i bytes.
pub size_bytes: u64,
/// `true` hvis filen allerede fantes i CAS (deduplisert).
pub already_existed: bool,
/// Edge-ID for `has_media`-edge (kun hvis source_id ble oppgitt).
#[serde(skip_serializing_if = "Option::is_none")]
pub has_media_edge_id: Option<Uuid>,
}
/// POST /intentions/upload_media
///
/// Mottar en fil via multipart form data, lagrer i CAS, oppretter en
/// media-node med CAS-metadata. Hvis `source_id` er oppgitt, opprettes
/// en `has_media`-edge fra kildenoden til den nye media-noden.
///
/// Multipart-felter:
/// - `file` (påkrevd): Binærfilen som skal lastes opp.
/// - `source_id` (valgfritt): Node-ID å koble media til via `has_media`-edge.
/// - `visibility` (valgfritt): Synlighet for media-noden. Default: "hidden".
/// - `title` (valgfritt): Tittel for media-noden (default: filnavn).
///
/// Ref: docs/primitiver/nodes.md (media), docs/retninger/universell_input.md
pub async fn upload_media(
State(state): State<AppState>,
user: AuthUser,
mut multipart: Multipart,
) -> Result<Json<UploadMediaResponse>, (StatusCode, Json<ErrorResponse>)> {
let mut file_data: Option<Vec<u8>> = None;
let mut file_name: Option<String> = None;
let mut content_type: Option<String> = None;
let mut source_id: Option<Uuid> = None;
let mut visibility = "hidden".to_string();
let mut title: Option<String> = None;
// -- Parse multipart-felter --
while let Some(field) = multipart.next_field().await.map_err(|e| {
bad_request(&format!("Ugyldig multipart-data: {e}"))
})? {
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"file" => {
file_name = field.file_name().map(|s| s.to_string());
content_type = field.content_type().map(|s| s.to_string());
let bytes = field.bytes().await.map_err(|e| {
bad_request(&format!("Kunne ikke lese fil: {e}"))
})?;
if bytes.len() > MAX_UPLOAD_SIZE {
return Err(bad_request(&format!(
"Filen er for stor: {} bytes (maks {} bytes)",
bytes.len(),
MAX_UPLOAD_SIZE
)));
}
if bytes.is_empty() {
return Err(bad_request("Filen er tom"));
}
file_data = Some(bytes.to_vec());
}
"source_id" => {
let text = field.text().await.map_err(|e| {
bad_request(&format!("Kunne ikke lese source_id: {e}"))
})?;
let id = Uuid::parse_str(&text).map_err(|_| {
bad_request(&format!("Ugyldig source_id UUID: '{text}'"))
})?;
source_id = Some(id);
}
"visibility" => {
let text = field.text().await.map_err(|e| {
bad_request(&format!("Kunne ikke lese visibility: {e}"))
})?;
if !VALID_VISIBILITIES.contains(&text.as_str()) {
return Err(bad_request(&format!(
"Ugyldig visibility: '{text}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
)));
}
visibility = text;
}
"title" => {
let text = field.text().await.map_err(|e| {
bad_request(&format!("Kunne ikke lese title: {e}"))
})?;
title = Some(text);
}
_ => {
// Ignorer ukjente felter
}
}
}
let data = file_data.ok_or_else(|| bad_request("Mangler 'file'-felt i multipart-data"))?;
// -- Valider source_id hvis oppgitt --
if let Some(src_id) = source_id {
let exists = node_exists(&state.db, src_id).await.map_err(|e| {
tracing::error!("PG-feil ved nodesjekk: {e}");
internal_error("Databasefeil ved validering av source_id")
})?;
if !exists {
return Err(bad_request(&format!("source_id {} finnes ikke", src_id)));
}
}
// -- Lagre i CAS --
let cas_result = state.cas.store(&data).await.map_err(|e| {
tracing::error!("CAS-lagring feilet: {e}");
internal_error(&format!("Kunne ikke lagre fil i CAS: {e}"))
})?;
// -- Opprett media-node --
let media_node_id = Uuid::now_v7();
let media_node_id_str = media_node_id.to_string();
let created_by_str = user.node_id.to_string();
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
let node_title = title.unwrap_or_else(|| file_name.unwrap_or_default());
let metadata = serde_json::json!({
"cas_hash": cas_result.hash,
"mime": mime,
"size_bytes": cas_result.size,
});
let metadata_str = metadata.to_string();
// Skriv til SpacetimeDB (instant)
state
.stdb
.create_node(
&media_node_id_str,
"media",
&node_title,
"",
&visibility,
&metadata_str,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_node (media)", e))?;
tracing::info!(
media_node_id = %media_node_id,
cas_hash = %cas_result.hash,
size = cas_result.size,
mime = %mime,
already_existed = cas_result.already_existed,
created_by = %user.node_id,
"Media-node opprettet i STDB"
);
// Spawn async PG-skriving for media-noden
spawn_pg_insert_node(
state.db.clone(),
media_node_id,
"media".to_string(),
node_title,
String::new(),
visibility,
metadata,
user.node_id,
);
// -- Opprett has_media-edge hvis source_id er oppgitt --
let has_media_edge_id = if let Some(src_id) = source_id {
let edge_id = Uuid::now_v7();
let edge_id_str = edge_id.to_string();
let src_id_str = src_id.to_string();
let edge_metadata = serde_json::json!({});
let edge_metadata_str = edge_metadata.to_string();
state
.stdb
.create_edge(
&edge_id_str,
&src_id_str, // source = innholdsnoden
&media_node_id_str, // target = media-noden
"has_media",
&edge_metadata_str,
false,
&created_by_str,
)
.await
.map_err(|e| stdb_error("create_edge (has_media)", e))?;
tracing::info!(
edge_id = %edge_id,
source_id = %src_id,
media_node_id = %media_node_id,
"has_media-edge opprettet i STDB"
);
// has_media er ikke tilgangsgivende — enkel PG-insert
spawn_pg_insert_edge(
state.db.clone(),
state.stdb.clone(),
edge_id,
src_id,
media_node_id,
"has_media".to_string(),
edge_metadata,
false,
user.node_id,
);
Some(edge_id)
} else {
None
};
// -- Enqueue transkripsjons-jobb for lydfiler --
if is_audio_mime(&mime) {
let payload = serde_json::json!({
"media_node_id": media_node_id,
"cas_hash": cas_result.hash,
"mime": mime,
"language": "no",
});
// Finn collection_node_id fra source_id sin eier-kjede (valgfritt)
let collection_id = if let Some(src_id) = source_id {
find_collection_for_node(&state.db, src_id).await.ok().flatten()
} else {
None
};
match crate::jobs::enqueue(&state.db, "whisper_transcribe", payload, collection_id, 5).await {
Ok(job_id) => {
tracing::info!(
job_id = %job_id,
media_node_id = %media_node_id,
"Transkripsjons-jobb opprettet"
);
}
Err(e) => {
// Ikke feil ut hele uploaden — logg og fortsett
tracing::error!(
media_node_id = %media_node_id,
error = %e,
"Kunne ikke opprette transkripsjons-jobb"
);
}
}
}
Ok(Json(UploadMediaResponse {
media_node_id,
cas_hash: cas_result.hash,
size_bytes: cas_result.size,
already_existed: cas_result.already_existed,
has_media_edge_id,
}))
}
/// Sjekker om en MIME-type er en lydtype som Whisper kan transkribere.
fn is_audio_mime(mime: &str) -> bool {
mime.starts_with("audio/")
}
/// Forsøker å finne collection_node_id for en node via belongs_to-edges.
async fn find_collection_for_node(db: &PgPool, node_id: Uuid) -> Result<Option<Uuid>, sqlx::Error> {
let row = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT e.target_id
FROM edges e
JOIN nodes n ON n.id = e.target_id
WHERE e.source_id = $1
AND e.edge_type = 'belongs_to'
AND n.node_kind = 'collection'
LIMIT 1
"#,
)
.bind(node_id)
.fetch_optional(db)
.await?;
Ok(row)
}
// =============================================================================
// create_alias
// =============================================================================
#[derive(Deserialize)]
pub struct CreateAliasRequest {
/// Visningsnavn for aliaset (f.eks. "Bjørn" for en podcastvert-identitet).
pub title: String,
/// Valgfri metadata (f.eks. display_name, bio, avatar).
pub metadata: Option<serde_json::Value>,
}
#[derive(Serialize)]
pub struct CreateAliasResponse {
/// ID til den nye alias-noden.
pub alias_node_id: Uuid,
/// ID til alias-edgen (system=true, usynlig for traversering).
pub alias_edge_id: Uuid,
}
/// POST /intentions/create_alias
///
/// Oppretter en alias-node (node_kind='person') og en `alias`-edge
/// (system=true) fra brukerens hovednode til aliasnoden. Alias-edgen
/// er usynlig for traversering — RLS-policyen filtrerer system-edges.
///
/// Bruksområde: en bruker kan ha flere identiteter (f.eks. Vegard
/// som seg selv og "Bjørn" som podcastvert). Oppgave 8.2 vil bruke
/// aliaset til å sette created_by kontekstbasert.
///
/// Ref: docs/primitiver/edges.md (systemedges), docs/primitiver/nodes.md
pub async fn create_alias(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateAliasRequest>,
) -> Result<Json<CreateAliasResponse>, (StatusCode, Json<ErrorResponse>)> {
let title = req.title.trim().to_string();
if title.is_empty() {
return Err(bad_request("Alias-tittel kan ikke være tom"));
}
let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({}));
// -- Generer IDer --
let alias_node_id = Uuid::now_v7();
let alias_edge_id = Uuid::now_v7();
let alias_node_id_str = alias_node_id.to_string();
let alias_edge_id_str = alias_edge_id.to_string();
let user_node_id_str = user.node_id.to_string();
let metadata_str = metadata.to_string();
// -- Skriv alias-node til STDB --
state
.stdb
.create_node(
&alias_node_id_str,
"person",
&title,
"",
"hidden",
&metadata_str,
&user_node_id_str,
)
.await
.map_err(|e| stdb_error("create_node (alias)", e))?;
// -- Skriv alias-edge til STDB (system=true) --
state
.stdb
.create_edge(
&alias_edge_id_str,
&user_node_id_str,
&alias_node_id_str,
"alias",
"{}",
true,
&user_node_id_str,
)
.await
.map_err(|e| stdb_error("create_edge (alias)", e))?;
tracing::info!(
alias_node_id = %alias_node_id,
alias_edge_id = %alias_edge_id,
user_node_id = %user.node_id,
title = %title,
"Alias opprettet i STDB"
);
// -- Spawn async PG-skriving: node + edge --
let pg_db = state.db.clone();
let pg_title = title.clone();
let pg_metadata = metadata.clone();
let pg_user_node_id = user.node_id;
tokio::spawn(async move {
// 1. Skriv alias-noden
let node_result = sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by)
VALUES ($1, 'person', $2, 'hidden'::visibility, $3, $4)
"#,
)
.bind(alias_node_id)
.bind(&pg_title)
.bind(&pg_metadata)
.bind(pg_user_node_id)
.execute(&pg_db)
.await;
match node_result {
Ok(_) => {
tracing::info!(alias_node_id = %alias_node_id, "Alias-node persistert til PostgreSQL");
}
Err(e) => {
tracing::error!(alias_node_id = %alias_node_id, error = %e, "Kunne ikke persistere alias-node til PostgreSQL");
return;
}
}
// 2. Skriv alias-edge (system=true)
let edge_result = sqlx::query(
r#"
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'alias', '{}', true, $4)
"#,
)
.bind(alias_edge_id)
.bind(pg_user_node_id)
.bind(alias_node_id)
.bind(pg_user_node_id)
.execute(&pg_db)
.await;
match edge_result {
Ok(_) => {
tracing::info!(alias_edge_id = %alias_edge_id, "Alias-edge persistert til PostgreSQL");
}
Err(e) => {
tracing::error!(alias_edge_id = %alias_edge_id, error = %e, "Kunne ikke persistere alias-edge til PostgreSQL");
}
}
});
Ok(Json(CreateAliasResponse {
alias_node_id,
alias_edge_id,
}))
}
// =============================================================================
// Bakgrunns-PG-operasjoner
// =============================================================================
#[derive(sqlx::FromRow)]
struct NodeRow {
node_kind: String,
title: Option<String>,
content: Option<String>,
visibility: String,
metadata: serde_json::Value,
}
/// Enkel rad for å sjekke node_kind (brukes ved context_id-validering).
#[derive(sqlx::FromRow)]
struct NodeKindRow {
node_kind: String,
}
/// Spawner en tokio-task som skriver noden til PostgreSQL i bakgrunnen.
fn spawn_pg_insert_node(
db: PgPool,
node_id: Uuid,
node_kind: String,
title: String,
content: String,
visibility: String,
metadata: serde_json::Value,
created_by: Uuid,
) {
tokio::spawn(async move {
let result = sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5::visibility, $6, $7)
"#,
)
.bind(node_id)
.bind(&node_kind)
.bind(&title)
.bind(&content)
.bind(&visibility)
.bind(&metadata)
.bind(created_by)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(node_id = %node_id, "Node persistert til PostgreSQL");
}
Err(e) => {
tracing::error!(node_id = %node_id, error = %e, "Kunne ikke persistere node til PostgreSQL");
}
}
});
}
/// Mapper edge_type til access_level for tilgangsgivende edges.
/// Returnerer None for edges som ikke gir tilgang.
fn edge_type_to_access_level(edge_type: &str) -> Option<&'static str> {
match edge_type {
"owner" => Some("owner"),
"admin" => Some("admin"),
"member_of" => Some("member"),
"reader" => Some("reader"),
_ => None,
}
}
/// Spawner en tokio-task som skriver edgen til PostgreSQL i bakgrunnen.
/// For tilgangsgivende edges (owner, admin, member_of, reader) kalles
/// recompute_access i samme transaksjon — ingen vindu med stale tilgang.
/// Synker også node_access til STDB for visibility-filtrering i frontend.
fn spawn_pg_insert_edge(
db: PgPool,
stdb: crate::stdb::StdbClient,
edge_id: Uuid,
source_id: Uuid,
target_id: Uuid,
edge_type: String,
metadata: serde_json::Value,
system: bool,
created_by: Uuid,
) {
tokio::spawn(async move {
let access_level = edge_type_to_access_level(&edge_type);
if let Some(level) = access_level {
// Tilgangsgivende edge: wrap i transaksjon med recompute_access
let result = insert_edge_with_access(&db, edge_id, source_id, target_id, &edge_type, &metadata, system, created_by, level).await;
match result {
Ok(_) => {
tracing::info!(
edge_id = %edge_id,
edge_type = %edge_type,
access_level = %level,
"Edge + node_access persistert til PostgreSQL"
);
// Synk oppdatert node_access til STDB
sync_node_access_to_stdb(&db, &stdb, source_id).await;
}
Err(e) => {
tracing::error!(
edge_id = %edge_id,
error = %e,
"Kunne ikke persistere edge + node_access til PostgreSQL"
);
}
}
} else {
// Vanlig edge uten tilgangspåvirkning
let result = sqlx::query(
r#"
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
)
.bind(edge_id)
.bind(source_id)
.bind(target_id)
.bind(&edge_type)
.bind(&metadata)
.bind(system)
.bind(created_by)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(edge_id = %edge_id, "Edge persistert til PostgreSQL");
}
Err(e) => {
tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke persistere edge til PostgreSQL");
}
}
}
});
}
/// Synkroniserer node_access-rader for et subject fra PG til STDB.
/// Kalles etter recompute_access for å holde STDB i synk.
async fn sync_node_access_to_stdb(db: &PgPool, stdb: &crate::stdb::StdbClient, subject_id: Uuid) {
let rows = sqlx::query_as::<_, NodeAccessRow>(
"SELECT subject_id, object_id, access::text as access, \
COALESCE(via_edge::text, '') as via_edge \
FROM node_access WHERE subject_id = $1",
)
.bind(subject_id)
.fetch_all(db)
.await;
match rows {
Ok(rows) => {
for row in &rows {
if let Err(e) = stdb
.upsert_node_access(
&row.subject_id.to_string(),
&row.object_id.to_string(),
&row.access,
&row.via_edge,
)
.await
{
tracing::error!(
subject_id = %row.subject_id,
object_id = %row.object_id,
error = %e,
"Kunne ikke synke node_access til STDB"
);
}
}
tracing::info!(
subject_id = %subject_id,
count = rows.len(),
"node_access synket til STDB"
);
}
Err(e) => {
tracing::error!(subject_id = %subject_id, error = %e, "Kunne ikke hente node_access fra PG");
}
}
}
#[derive(sqlx::FromRow)]
struct NodeAccessRow {
subject_id: Uuid,
object_id: Uuid,
access: String,
via_edge: String,
}
/// Inserter en tilgangsgivende edge og oppdaterer node_access i én transaksjon.
/// source_id = subject (bruker/team), target_id = object (noden det gis tilgang til).
async fn insert_edge_with_access(
db: &PgPool,
edge_id: Uuid,
source_id: Uuid,
target_id: Uuid,
edge_type: &str,
metadata: &serde_json::Value,
system: bool,
created_by: Uuid,
access_level: &str,
) -> Result<(), sqlx::Error> {
let mut tx = db.begin().await?;
sqlx::query(
r#"
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
)
.bind(edge_id)
.bind(source_id)
.bind(target_id)
.bind(edge_type)
.bind(metadata)
.bind(system)
.bind(created_by)
.execute(&mut *tx)
.await?;
// Kall recompute_access: subject=source_id, object=target_id
sqlx::query(
"SELECT recompute_access($1, $2, $3::access_level, $4)",
)
.bind(source_id)
.bind(target_id)
.bind(access_level)
.bind(edge_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Spawner en tokio-task som oppdaterer noden i PostgreSQL.
fn spawn_pg_update_node(
db: PgPool,
node_id: Uuid,
node_kind: String,
title: String,
content: String,
visibility: String,
metadata: serde_json::Value,
) {
tokio::spawn(async move {
let result = sqlx::query(
r#"
UPDATE nodes
SET node_kind = $2, title = NULLIF($3, ''), content = NULLIF($4, ''),
visibility = $5::visibility, metadata = $6
WHERE id = $1
"#,
)
.bind(node_id)
.bind(&node_kind)
.bind(&title)
.bind(&content)
.bind(&visibility)
.bind(&metadata)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(node_id = %node_id, "Node oppdatert i PostgreSQL");
}
Err(e) => {
tracing::error!(node_id = %node_id, error = %e, "Kunne ikke oppdatere node i PostgreSQL");
}
}
});
}
/// Spawner en tokio-task som sletter noden fra PostgreSQL.
/// Edges slettes automatisk via ON DELETE CASCADE.
fn spawn_pg_delete_node(db: PgPool, node_id: Uuid) {
tokio::spawn(async move {
let result = sqlx::query("DELETE FROM nodes WHERE id = $1")
.bind(node_id)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(node_id = %node_id, "Node slettet fra PostgreSQL");
}
Err(e) => {
tracing::error!(node_id = %node_id, error = %e, "Kunne ikke slette node fra PostgreSQL");
}
}
});
}
// =============================================================================
// POST /intentions/update_segment — rediger transkripsjons-segment
// =============================================================================
#[derive(Deserialize)]
pub struct UpdateSegmentRequest {
/// Segment-ID (primary key i transcription_segments).
pub segment_id: i64,
/// Ny tekst for segmentet.
pub content: String,
}
#[derive(Serialize)]
pub struct UpdateSegmentResponse {
pub segment_id: i64,
pub edited: bool,
}
/// POST /intentions/update_segment
///
/// Oppdaterer teksten i et transkripsjons-segment og setter `edited = true`.
/// Krever at brukeren har skrivetilgang til noden segmentet tilhører.
pub async fn update_segment(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<UpdateSegmentRequest>,
) -> Result<Json<UpdateSegmentResponse>, (StatusCode, Json<ErrorResponse>)> {
let content = req.content.trim().to_string();
if content.is_empty() {
return Err(bad_request("Innhold kan ikke være tomt"));
}
// Finn noden dette segmentet tilhører
let segment_node: Option<(Uuid,)> = sqlx::query_as(
"SELECT node_id FROM transcription_segments WHERE id = $1",
)
.bind(req.segment_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved oppslag av segment");
internal_error("Databasefeil")
})?;
let Some((node_id,)) = segment_node else {
return Err(bad_request("Segment finnes ikke"));
};
// Verifiser skrivetilgang
let can_modify = user_can_modify_node(&state.db, user.node_id, node_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "Tilgangssjekk feilet");
internal_error("Databasefeil ved tilgangssjekk")
})?;
if !can_modify {
return Err(forbidden("Ikke tilgang til å redigere dette segmentet"));
}
// Oppdater segmentet
sqlx::query(
"UPDATE transcription_segments SET content = $1, edited = true WHERE id = $2",
)
.bind(&content)
.bind(req.segment_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Kunne ikke oppdatere segment");
internal_error("Databasefeil ved oppdatering")
})?;
tracing::info!(
segment_id = req.segment_id,
node_id = %node_id,
user = %user.node_id,
"Segment redigert"
);
Ok(Json(UpdateSegmentResponse {
segment_id: req.segment_id,
edited: true,
}))
}
// =============================================================================
// POST /intentions/retranscribe — trigger re-transkripsjon for eksisterende media-node
// =============================================================================
#[derive(Deserialize)]
pub struct RetranscribeRequest {
/// Media-node-ID å re-transkribere.
pub node_id: Uuid,
}
#[derive(Serialize)]
pub struct RetranscribeResponse {
pub job_id: Uuid,
}
/// POST /intentions/retranscribe
///
/// Trigger en ny transkripsjons-jobb for en eksisterende media-node.
/// Henter CAS-hash og MIME fra nodens metadata. Krever skrivetilgang.
pub async fn retranscribe(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<RetranscribeRequest>,
) -> Result<Json<RetranscribeResponse>, (StatusCode, Json<ErrorResponse>)> {
// Verifiser skrivetilgang
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "Tilgangssjekk feilet");
internal_error("Databasefeil ved tilgangssjekk")
})?;
if !can_modify {
return Err(forbidden("Ikke tilgang til å re-transkribere denne noden"));
}
// Hent metadata for CAS-hash og MIME
let node: Option<(serde_json::Value,)> = sqlx::query_as(
"SELECT metadata FROM nodes WHERE id = $1 AND node_kind = 'media'",
)
.bind(req.node_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved henting av node");
internal_error("Databasefeil")
})?;
let Some((metadata,)) = node else {
return Err(bad_request("Noden finnes ikke eller er ikke en media-node"));
};
let cas_hash = metadata["cas_hash"]
.as_str()
.ok_or_else(|| bad_request("Noden mangler cas_hash i metadata"))?;
let mime = metadata["mime"]
.as_str()
.unwrap_or("audio/mpeg");
// Finn collection fra eier-kjede
let collection_id = find_collection_for_node(&state.db, req.node_id)
.await
.ok()
.flatten();
let payload = serde_json::json!({
"media_node_id": req.node_id,
"cas_hash": cas_hash,
"mime": mime,
"language": "no",
});
let job_id = crate::jobs::enqueue(&state.db, "whisper_transcribe", payload, collection_id, 5)
.await
.map_err(|e| {
tracing::error!(error = %e, "Kunne ikke opprette re-transkripsjons-jobb");
internal_error("Kunne ikke starte re-transkripsjon")
})?;
tracing::info!(
job_id = %job_id,
node_id = %req.node_id,
user = %user.node_id,
"Re-transkripsjons-jobb opprettet"
);
Ok(Json(RetranscribeResponse { job_id }))
}
// =============================================================================
// POST /intentions/resolve_retranscription — anvend brukerens segment-valg
// =============================================================================
#[derive(Deserialize)]
pub struct SegmentChoice {
/// Sekvensnummer i den nye transkripsjonen.
pub seq: i32,
/// "new" = behold ny versjon, "old" = behold gammel versjon.
pub choice: String,
}
#[derive(Deserialize)]
pub struct ResolveRetranscriptionRequest {
/// Media-node-ID.
pub node_id: Uuid,
/// `transcribed_at` for den nye versjonen.
pub new_version: String,
/// `transcribed_at` for den gamle versjonen.
pub old_version: String,
/// Per-segment-valg.
pub choices: Vec<SegmentChoice>,
}
#[derive(Serialize)]
pub struct ResolveRetranscriptionResponse {
pub resolved: bool,
pub kept_old: i32,
pub kept_new: i32,
}
/// POST /intentions/resolve_retranscription
///
/// Anvender brukerens per-segment-valg etter re-transkripsjon.
/// For segmenter der brukeren velger "old", kopieres innholdet fra
/// den gamle versjonen til den nye. Gamle versjoner slettes etterpå.
pub async fn resolve_retranscription(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<ResolveRetranscriptionRequest>,
) -> Result<Json<ResolveRetranscriptionResponse>, (StatusCode, Json<ErrorResponse>)> {
// Verifiser skrivetilgang
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "Tilgangssjekk feilet");
internal_error("Databasefeil ved tilgangssjekk")
})?;
if !can_modify {
return Err(forbidden("Ikke tilgang til å endre segmenter"));
}
let new_ts: chrono::DateTime<chrono::Utc> = req.new_version.parse()
.map_err(|_| bad_request("Ugyldig new_version-tidsstempel"))?;
let old_ts: chrono::DateTime<chrono::Utc> = req.old_version.parse()
.map_err(|_| bad_request("Ugyldig old_version-tidsstempel"))?;
// Hent gamle segmenter (indeksert på seq)
let old_segments: Vec<(i32, String, bool)> = sqlx::query_as(
"SELECT seq, content, edited FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq",
)
.bind(req.node_id)
.bind(old_ts)
.fetch_all(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved henting av gamle segmenter");
internal_error("Databasefeil")
})?;
let old_by_seq: std::collections::HashMap<i32, (String, bool)> = old_segments
.into_iter()
.map(|(seq, content, edited)| (seq, (content, edited)))
.collect();
let mut kept_old = 0i32;
let mut kept_new = 0i32;
let mut tx = state.db.begin().await.map_err(|e| {
tracing::error!(error = %e, "Transaksjon feilet");
internal_error("Databasefeil")
})?;
for choice in &req.choices {
if choice.choice == "old" {
if let Some((old_content, old_edited)) = old_by_seq.get(&choice.seq) {
// Kopier gammel tekst til nytt segment, bevar edited-flagg
sqlx::query(
"UPDATE transcription_segments SET content = $1, edited = $2 WHERE node_id = $3 AND transcribed_at = $4 AND seq = $5",
)
.bind(old_content)
.bind(*old_edited)
.bind(req.node_id)
.bind(new_ts)
.bind(choice.seq)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved oppdatering av segment");
internal_error("Databasefeil ved oppdatering")
})?;
kept_old += 1;
}
} else {
kept_new += 1;
}
}
// Slett alle gamle versjoner (ikke bare den valgte — rydd opp)
sqlx::query(
"DELETE FROM transcription_segments WHERE node_id = $1 AND transcribed_at < $2",
)
.bind(req.node_id)
.bind(new_ts)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved sletting av gamle segmenter");
internal_error("Databasefeil ved opprydding")
})?;
// Oppdater nodens content med den endelige transkripsjonen
let final_segments: Vec<(String,)> = sqlx::query_as(
"SELECT content FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq",
)
.bind(req.node_id)
.bind(new_ts)
.fetch_all(&mut *tx)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved henting av endelige segmenter");
internal_error("Databasefeil")
})?;
let transcript_text: String = final_segments
.iter()
.map(|(c,)| c.trim())
.collect::<Vec<_>>()
.join(" ");
sqlx::query("UPDATE nodes SET content = $1 WHERE id = $2")
.bind(&transcript_text)
.bind(req.node_id)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved oppdatering av node-innhold");
internal_error("Databasefeil")
})?;
tx.commit().await.map_err(|e| {
tracing::error!(error = %e, "Commit feilet");
internal_error("Databasefeil ved commit")
})?;
tracing::info!(
node_id = %req.node_id,
kept_old = kept_old,
kept_new = kept_new,
"Re-transkripsjon løst"
);
Ok(Json(ResolveRetranscriptionResponse {
resolved: true,
kept_old,
kept_new,
}))
}