Ny intention `POST /intentions/set_slot` for redaksjonell kontroll over forside-slots i publiseringssamlinger. Håndhever: - Maks 1 hero: gammel ikke-pinned hero flyttes til strøm - featured_max: eldste ikke-pinned featured FIFO til strøm - pinned-flagg beskytter mot automatisk fjerning - Krever owner/admin-tilgang til samlingen - Trigger forside-rerendering etter slot-endring Returnerer liste over displaced edges slik at frontend kan vise hva som ble flyttet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3354 lines
107 KiB
Rust
3354 lines
107 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::livekit;
|
|
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"];
|
|
|
|
/// Gyldige trait-navn for samlingsnoder.
|
|
/// Lukket katalog — ref: docs/primitiver/traits.md § "Trait-katalog"
|
|
const VALID_TRAITS: &[&str] = &[
|
|
// Innhold & redigering
|
|
"editor", "versioning", "collaboration", "translation", "templates",
|
|
// Publisering & distribusjon
|
|
"publishing", "rss", "newsletter", "custom_domain", "analytics", "embed", "api",
|
|
// Lyd & video
|
|
"podcast", "recording", "transcription", "tts", "clips", "playlist", "studio",
|
|
// Kommunikasjon
|
|
"chat", "forum", "comments", "guest_input", "announcements", "polls", "qa",
|
|
// Organisering
|
|
"kanban", "calendar", "timeline", "table", "gallery", "bookmarks", "tags",
|
|
// Kunnskap
|
|
"knowledge_graph", "wiki", "glossary", "faq", "bibliography",
|
|
// Automatisering & AI
|
|
"auto_tag", "auto_summarize", "digest", "bridge", "moderation",
|
|
// Tilgang & fellesskap
|
|
"membership", "roles", "invites", "paywall", "directory",
|
|
// Ekstern integrasjon
|
|
"webhook", "import", "export", "ical_sync",
|
|
];
|
|
|
|
/// Validerer `metadata.traits`-objektet for samlingsnoder.
|
|
///
|
|
/// Regler:
|
|
/// - Kun samlingsnoder (`node_kind == "collection"`) valideres.
|
|
/// - `traits` må være et objekt (ikke array, string, etc.).
|
|
/// - Hvert nøkkelnavn må finnes i VALID_TRAITS.
|
|
/// - Verdien per trait er fri JSONB (åpen konfigurasjon).
|
|
///
|
|
/// Ref: docs/primitiver/traits.md § "Lukket katalog, åpen konfigurasjon"
|
|
fn validate_collection_traits(
|
|
node_kind: &str,
|
|
metadata: &serde_json::Value,
|
|
) -> Result<(), String> {
|
|
if node_kind != "collection" {
|
|
return Ok(());
|
|
}
|
|
|
|
let traits = match metadata.get("traits") {
|
|
None => return Ok(()), // Ingen traits er OK — samling uten funksjonalitet
|
|
Some(t) => t,
|
|
};
|
|
|
|
let traits_obj = traits.as_object().ok_or(
|
|
"metadata.traits må være et objekt".to_string(),
|
|
)?;
|
|
|
|
let unknown: Vec<&String> = traits_obj
|
|
.keys()
|
|
.filter(|k| !VALID_TRAITS.contains(&k.as_str()))
|
|
.collect();
|
|
|
|
if !unknown.is_empty() {
|
|
let unknown_str: Vec<&str> = unknown.iter().map(|s| s.as_str()).collect();
|
|
return Err(format!(
|
|
"Ukjente traits: {:?}. Gyldige traits: se docs/primitiver/traits.md",
|
|
unknown_str,
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[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.
|
|
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!({}));
|
|
|
|
// -- Valider traits for samlingsnoder (oppgave 13.1) --
|
|
validate_collection_traits(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
|
|
|
|
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"
|
|
);
|
|
|
|
// Fang verdier for AI-trigger før de flyttes inn i spawn_pg_insert_node
|
|
let is_content_node = node_kind == "content";
|
|
let has_enough_text = content.len() >= 20 || title.len() >= 20;
|
|
|
|
// -- 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(),
|
|
state.index_cache.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"
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// -- AI edge-forslag: analyser innholdet for topics og mentions --
|
|
// Trigges for content-noder med nok tekst. Lav prioritet (bakgrunnsjobb).
|
|
// NB: node_kind, title og content er flyttet inn i spawn_pg_insert_node over,
|
|
// så vi sjekker på kopi av verdiene tatt før move.
|
|
if is_content_node && has_enough_text {
|
|
let db_clone = state.db.clone();
|
|
let created_node_id = node_id;
|
|
tokio::spawn(async move {
|
|
let payload = serde_json::json!({
|
|
"node_id": created_node_id.to_string()
|
|
});
|
|
match crate::jobs::enqueue(&db_clone, "suggest_edges", payload, None, 2).await {
|
|
Ok(job_id) => {
|
|
tracing::info!(
|
|
job_id = %job_id,
|
|
node_id = %created_node_id,
|
|
"suggest_edges-jobb lagt i kø"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
node_id = %created_node_id,
|
|
error = %e,
|
|
"Kunne ikke legge suggest_edges-jobb i kø"
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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(),
|
|
state.index_cache.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);
|
|
|
|
// -- Valider traits for samlingsnoder (oppgave 13.1) --
|
|
validate_collection_traits(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
|
|
|
|
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 }))
|
|
}
|
|
|
|
// =============================================================================
|
|
// update_edge
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateEdgeRequest {
|
|
/// ID til edgen som skal oppdateres.
|
|
pub edge_id: Uuid,
|
|
/// Ny edge_type. Beholder eksisterende hvis None.
|
|
pub edge_type: Option<String>,
|
|
/// Ny metadata. Beholder eksisterende hvis None.
|
|
pub metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct UpdateEdgeResponse {
|
|
pub edge_id: Uuid,
|
|
}
|
|
|
|
/// Henter en edge fra PG.
|
|
async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result<Option<EdgeRow>, sqlx::Error> {
|
|
sqlx::query_as::<_, EdgeRow>(
|
|
"SELECT edge_type, metadata FROM edges WHERE id = $1",
|
|
)
|
|
.bind(edge_id)
|
|
.fetch_optional(db)
|
|
.await
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct EdgeRow {
|
|
edge_type: String,
|
|
metadata: serde_json::Value,
|
|
}
|
|
|
|
/// POST /intentions/update_edge
|
|
///
|
|
/// Oppdaterer en eksisterende edge (type og/eller metadata).
|
|
/// Krever at brukeren har opprettet edgen, eller har owner/admin-edge
|
|
/// til source-noden.
|
|
pub async fn update_edge(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
Json(req): Json<UpdateEdgeRequest>,
|
|
) -> Result<Json<UpdateEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
// -- Tilgangskontroll --
|
|
let can_modify = user_can_modify_edge(&state.db, user.node_id, req.edge_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 edgen"));
|
|
}
|
|
|
|
// -- Hent eksisterende edge --
|
|
let existing = get_edge(&state.db, req.edge_id)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved henting av edge: {e}");
|
|
internal_error("Databasefeil ved henting av edge")
|
|
})?
|
|
.ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;
|
|
|
|
let edge_type = req.edge_type.unwrap_or(existing.edge_type);
|
|
if edge_type.is_empty() {
|
|
return Err(bad_request("edge_type kan ikke være tom"));
|
|
}
|
|
|
|
let metadata = req.metadata.unwrap_or(existing.metadata);
|
|
let metadata_str = metadata.to_string();
|
|
let edge_id_str = req.edge_id.to_string();
|
|
|
|
// -- Skriv til SpacetimeDB (instant) --
|
|
state
|
|
.stdb
|
|
.update_edge(&edge_id_str, &edge_type, &metadata_str)
|
|
.await
|
|
.map_err(|e| stdb_error("update_edge", e))?;
|
|
|
|
tracing::info!(
|
|
edge_id = %req.edge_id,
|
|
edge_type = %edge_type,
|
|
updated_by = %user.node_id,
|
|
"Edge oppdatert i STDB"
|
|
);
|
|
|
|
// -- Spawn async PG-skriving --
|
|
let db = state.db.clone();
|
|
let eid = req.edge_id;
|
|
let et = edge_type.clone();
|
|
let md = metadata.clone();
|
|
tokio::spawn(async move {
|
|
let result = sqlx::query(
|
|
"UPDATE edges SET edge_type = $1, metadata = $2 WHERE id = $3",
|
|
)
|
|
.bind(&et)
|
|
.bind(&md)
|
|
.bind(eid)
|
|
.execute(&db)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(_) => tracing::info!(edge_id = %eid, "Edge oppdatert i PostgreSQL"),
|
|
Err(e) => tracing::error!(edge_id = %eid, error = %e, "Kunne ikke oppdatere edge i PostgreSQL"),
|
|
}
|
|
});
|
|
|
|
Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id }))
|
|
}
|
|
|
|
// =============================================================================
|
|
// set_slot — Redaksjonell slot-håndtering for publiseringssamlinger
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SetSlotRequest {
|
|
/// ID til belongs_to-edgen mellom artikkel og samling.
|
|
pub edge_id: Uuid,
|
|
/// Slot: "hero", "featured", eller null/tom for strøm.
|
|
pub slot: Option<String>,
|
|
/// Rekkefølge innen featured-slot (ignoreres for hero/strøm).
|
|
pub slot_order: Option<i64>,
|
|
/// Forhindrer automatisk fjerning fra slot (FIFO/hero-erstatning).
|
|
pub pinned: Option<bool>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SetSlotResponse {
|
|
/// Edgen som ble oppdatert.
|
|
pub edge_id: Uuid,
|
|
/// Edges som ble flyttet til strøm pga. hero-erstatning eller featured-overflow.
|
|
pub displaced: Vec<Uuid>,
|
|
}
|
|
|
|
/// POST /intentions/set_slot
|
|
///
|
|
/// Setter slot-metadata på en belongs_to-edge i en publiseringssamling.
|
|
/// Håndhever:
|
|
/// - Maks 1 hero: gammel (ikke-pinned) hero flyttes til strøm.
|
|
/// - featured_max: eldste (ikke-pinned) featured flyttes til strøm (FIFO).
|
|
/// - pinned-flagg beskytter mot automatisk fjerning.
|
|
///
|
|
/// Krever owner/admin-tilgang til samlingen.
|
|
pub async fn set_slot(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
Json(req): Json<SetSlotRequest>,
|
|
) -> Result<Json<SetSlotResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
// Valider slot-verdi
|
|
let slot = req.slot.as_deref().unwrap_or("");
|
|
if !matches!(slot, "" | "hero" | "featured") {
|
|
return Err(bad_request("slot må være \"hero\", \"featured\", eller null/tom for strøm"));
|
|
}
|
|
|
|
// Hent edgen og valider at det er en belongs_to-edge
|
|
#[derive(sqlx::FromRow)]
|
|
struct FullEdgeRow {
|
|
source_id: Uuid,
|
|
target_id: Uuid,
|
|
edge_type: String,
|
|
metadata: serde_json::Value,
|
|
}
|
|
|
|
let edge = sqlx::query_as::<_, FullEdgeRow>(
|
|
"SELECT source_id, target_id, edge_type, metadata FROM edges WHERE id = $1",
|
|
)
|
|
.bind(req.edge_id)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved henting av edge: {e}");
|
|
internal_error("Databasefeil ved henting av edge")
|
|
})?
|
|
.ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;
|
|
|
|
if edge.edge_type != "belongs_to" {
|
|
return Err(bad_request("set_slot kan kun brukes på belongs_to-edges"));
|
|
}
|
|
|
|
let collection_id = edge.target_id;
|
|
|
|
// Sjekk at target er en publiseringssamling
|
|
let pub_config = crate::publishing::find_publishing_collection_by_id(&state.db, collection_id)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved sjekk av publiseringssamling: {e}");
|
|
internal_error("Databasefeil")
|
|
})?
|
|
.ok_or_else(|| bad_request("Samlingen har ikke publishing-trait"))?;
|
|
|
|
// Sjekk at brukeren er owner/admin på samlingen
|
|
let is_admin = sqlx::query_scalar::<_, bool>(
|
|
r#"
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM edges
|
|
WHERE source_id = $1 AND target_id = $2
|
|
AND edge_type IN ('owner', 'admin')
|
|
)
|
|
"#,
|
|
)
|
|
.bind(user.node_id)
|
|
.bind(collection_id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
|
internal_error("Databasefeil ved tilgangssjekk")
|
|
})?;
|
|
|
|
if !is_admin {
|
|
return Err(forbidden("Kun owner/admin kan endre slots"));
|
|
}
|
|
|
|
let featured_max = pub_config.featured_max.unwrap_or(4);
|
|
let mut displaced: Vec<Uuid> = Vec::new();
|
|
|
|
// -- Slot-logikk: håndter hero-erstatning og featured-overflow --
|
|
|
|
if slot == "hero" {
|
|
// Finn eksisterende hero-edges (som ikke er denne edgen)
|
|
let existing_heroes: Vec<(Uuid, serde_json::Value)> = sqlx::query_as(
|
|
r#"
|
|
SELECT id, metadata FROM edges
|
|
WHERE target_id = $1
|
|
AND edge_type = 'belongs_to'
|
|
AND metadata->>'slot' = 'hero'
|
|
AND id != $2
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(req.edge_id)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved hero-sjekk: {e}");
|
|
internal_error("Databasefeil")
|
|
})?;
|
|
|
|
// Flytt ikke-pinned heroes tilbake til strøm
|
|
for (hero_edge_id, hero_meta) in &existing_heroes {
|
|
let pinned = hero_meta.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false);
|
|
if !pinned {
|
|
displace_to_stream(&state.db, &state.stdb, *hero_edge_id, hero_meta).await?;
|
|
displaced.push(*hero_edge_id);
|
|
}
|
|
}
|
|
} else if slot == "featured" {
|
|
// Tell nåværende featured-edges (ekskluder denne edgen om den allerede er featured)
|
|
let current_featured: Vec<(Uuid, serde_json::Value, Option<i64>)> = sqlx::query_as(
|
|
r#"
|
|
SELECT id, metadata,
|
|
(metadata->>'slot_order')::bigint as slot_order
|
|
FROM edges
|
|
WHERE target_id = $1
|
|
AND edge_type = 'belongs_to'
|
|
AND metadata->>'slot' = 'featured'
|
|
AND id != $2
|
|
ORDER BY (metadata->>'slot_order')::int ASC NULLS LAST,
|
|
created_at ASC
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(req.edge_id)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved featured-sjekk: {e}");
|
|
internal_error("Databasefeil")
|
|
})?;
|
|
|
|
// Hvis vi legger til en ny featured og det overstiger featured_max,
|
|
// fjern eldste (FIFO) ikke-pinned featured
|
|
let new_count = current_featured.len() as i64 + 1; // +1 for den vi setter nå
|
|
if new_count > featured_max {
|
|
let overflow = (new_count - featured_max) as usize;
|
|
// Finn de eldste ikke-pinned featured-edges å fjerne (FIFO = sist i listen)
|
|
let removable: Vec<&(Uuid, serde_json::Value, Option<i64>)> = current_featured
|
|
.iter()
|
|
.rev() // Høyest slot_order / eldst først for FIFO
|
|
.filter(|(_, meta, _)| {
|
|
!meta.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false)
|
|
})
|
|
.take(overflow)
|
|
.collect();
|
|
|
|
for (feat_edge_id, feat_meta, _) in removable {
|
|
displace_to_stream(&state.db, &state.stdb, *feat_edge_id, feat_meta).await?;
|
|
displaced.push(*feat_edge_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- Oppdater edgen med ny slot-metadata --
|
|
let mut new_meta = edge.metadata.clone();
|
|
if let Some(obj) = new_meta.as_object_mut() {
|
|
if slot.is_empty() {
|
|
obj.remove("slot");
|
|
obj.remove("slot_order");
|
|
} else {
|
|
obj.insert("slot".to_string(), serde_json::json!(slot));
|
|
if slot == "featured" {
|
|
if let Some(order) = req.slot_order {
|
|
obj.insert("slot_order".to_string(), serde_json::json!(order));
|
|
}
|
|
} else {
|
|
obj.remove("slot_order");
|
|
}
|
|
}
|
|
// Sett/fjern pinned
|
|
match req.pinned {
|
|
Some(true) => { obj.insert("pinned".to_string(), serde_json::json!(true)); }
|
|
Some(false) => { obj.remove("pinned"); }
|
|
None => {} // Behold eksisterende
|
|
}
|
|
}
|
|
|
|
// Skriv til STDB (instant)
|
|
let edge_id_str = req.edge_id.to_string();
|
|
let meta_str = new_meta.to_string();
|
|
state
|
|
.stdb
|
|
.update_edge(&edge_id_str, "belongs_to", &meta_str)
|
|
.await
|
|
.map_err(|e| stdb_error("update_edge", e))?;
|
|
|
|
// Skriv til PG
|
|
sqlx::query("UPDATE edges SET metadata = $1 WHERE id = $2")
|
|
.bind(&new_meta)
|
|
.bind(req.edge_id)
|
|
.execute(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved slot-oppdatering: {e}");
|
|
internal_error("Databasefeil ved slot-oppdatering")
|
|
})?;
|
|
|
|
tracing::info!(
|
|
edge_id = %req.edge_id,
|
|
slot = %slot,
|
|
displaced_count = displaced.len(),
|
|
"Slot oppdatert"
|
|
);
|
|
|
|
// Trigger forside-rerendering
|
|
trigger_render_if_publishing(&state.db, &state.index_cache, edge.source_id, collection_id).await;
|
|
|
|
Ok(Json(SetSlotResponse {
|
|
edge_id: req.edge_id,
|
|
displaced,
|
|
}))
|
|
}
|
|
|
|
/// Flytter en edge fra sin nåværende slot tilbake til strøm (slot=null).
|
|
/// Beholder annen metadata (publish_at, approved_by, etc.).
|
|
async fn displace_to_stream(
|
|
db: &PgPool,
|
|
stdb: &crate::stdb::StdbClient,
|
|
edge_id: Uuid,
|
|
current_meta: &serde_json::Value,
|
|
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
|
|
let mut meta = current_meta.clone();
|
|
if let Some(obj) = meta.as_object_mut() {
|
|
obj.remove("slot");
|
|
obj.remove("slot_order");
|
|
// pinned er irrelevant i strøm, fjern det
|
|
obj.remove("pinned");
|
|
}
|
|
|
|
// STDB (instant)
|
|
let edge_id_str = edge_id.to_string();
|
|
let meta_str = meta.to_string();
|
|
stdb.update_edge(&edge_id_str, "belongs_to", &meta_str)
|
|
.await
|
|
.map_err(|e| stdb_error("update_edge (displace)", e))?;
|
|
|
|
// PG
|
|
sqlx::query("UPDATE edges SET metadata = $1 WHERE id = $2")
|
|
.bind(&meta)
|
|
.bind(edge_id)
|
|
.execute(db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved displace_to_stream: {e}");
|
|
internal_error("Databasefeil ved fjerning fra slot")
|
|
})?;
|
|
|
|
tracing::info!(edge_id = %edge_id, "Edge fjernet fra slot → strøm");
|
|
Ok(())
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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(),
|
|
state.index_cache.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(),
|
|
state.index_cache.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(),
|
|
state.index_cache.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,
|
|
index_cache: crate::publishing::IndexCache,
|
|
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");
|
|
|
|
// Trigger artikkelrendering ved belongs_to til publiseringssamling
|
|
if edge_type == "belongs_to" {
|
|
trigger_render_if_publishing(&db, &index_cache, source_id, target_id).await;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke persistere edge til PostgreSQL");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Sjekker om target er en samling med publishing-trait, og legger i så fall
|
|
/// en `render_article`-jobb i køen. For statisk modus legges også en
|
|
/// `render_index`-jobb. For dynamisk modus invalideres in-memory-cachen.
|
|
async fn trigger_render_if_publishing(
|
|
db: &PgPool,
|
|
index_cache: &crate::publishing::IndexCache,
|
|
source_id: Uuid,
|
|
target_id: Uuid,
|
|
) {
|
|
match crate::publishing::find_publishing_collection_by_id(db, target_id).await {
|
|
Ok(Some(config)) => {
|
|
// Render artikkelen
|
|
let article_payload = serde_json::json!({
|
|
"node_id": source_id.to_string(),
|
|
"collection_id": target_id.to_string(),
|
|
});
|
|
|
|
match crate::jobs::enqueue(db, "render_article", article_payload, Some(target_id), 5).await {
|
|
Ok(job_id) => {
|
|
tracing::info!(
|
|
job_id = %job_id,
|
|
node_id = %source_id,
|
|
collection_id = %target_id,
|
|
"render_article-jobb lagt i kø"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
node_id = %source_id,
|
|
collection_id = %target_id,
|
|
error = %e,
|
|
"Kunne ikke legge render_article-jobb i kø"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Re-render forsiden
|
|
let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
|
|
if index_mode == "static" {
|
|
// Statisk modus: legg render_index-jobb i køen
|
|
let index_payload = serde_json::json!({
|
|
"collection_id": target_id.to_string(),
|
|
});
|
|
|
|
match crate::jobs::enqueue(db, "render_index", index_payload, Some(target_id), 4).await {
|
|
Ok(job_id) => {
|
|
tracing::info!(
|
|
job_id = %job_id,
|
|
collection_id = %target_id,
|
|
"render_index-jobb lagt i kø (statisk modus)"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
collection_id = %target_id,
|
|
error = %e,
|
|
"Kunne ikke legge render_index-jobb i kø"
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// Dynamisk modus: invalider in-memory-cache
|
|
crate::publishing::invalidate_index_cache(index_cache, target_id).await;
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
// Target er ikke en publiseringssamling — ingen rendering nødvendig
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
target_id = %target_id,
|
|
error = %e,
|
|
"Feil ved sjekk av publiseringssamling for rendering-trigger"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}))
|
|
}
|
|
|
|
// =============================================================================
|
|
// POST /intentions/summarize — generer AI-sammendrag av kommunikasjonsnode
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SummarizeRequest {
|
|
/// Kommunikasjonsnode-ID som skal oppsummeres.
|
|
pub communication_id: Uuid,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SummarizeResponse {
|
|
pub job_id: Uuid,
|
|
}
|
|
|
|
/// POST /intentions/summarize
|
|
///
|
|
/// Legger en `summarize_communication`-jobb i køen.
|
|
/// Sammendraget opprettes asynkront som en ny content-node
|
|
/// med summary-edge tilbake til kommunikasjonsnoden.
|
|
pub async fn summarize(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
Json(req): Json<SummarizeRequest>,
|
|
) -> Result<Json<SummarizeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
// Verifiser at kommunikasjonsnoden finnes og brukeren har tilgang
|
|
let exists: bool = sqlx::query_scalar::<_, bool>(
|
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'communication')",
|
|
)
|
|
.bind(req.communication_id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "PG-feil ved kommunikasjonssjekk");
|
|
internal_error("Databasefeil")
|
|
})?;
|
|
|
|
if !exists {
|
|
return Err(bad_request("Kommunikasjonsnode finnes ikke"));
|
|
}
|
|
|
|
// Sjekk at brukeren er deltaker (owner eller member_of)
|
|
let is_participant: bool = sqlx::query_scalar::<_, bool>(
|
|
"SELECT EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type IN ('owner', 'member_of'))",
|
|
)
|
|
.bind(user.node_id)
|
|
.bind(req.communication_id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "PG-feil ved deltagersjekk");
|
|
internal_error("Databasefeil")
|
|
})?;
|
|
|
|
if !is_participant {
|
|
return Err(forbidden("Ikke deltaker i samtalen"));
|
|
}
|
|
|
|
let payload = serde_json::json!({
|
|
"communication_id": req.communication_id.to_string(),
|
|
"requested_by": user.node_id.to_string()
|
|
});
|
|
|
|
let job_id = crate::jobs::enqueue(
|
|
&state.db,
|
|
"summarize_communication",
|
|
payload,
|
|
None,
|
|
3, // Lav prioritet — ikke tidskritisk
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "Kunne ikke legge oppsummerings-jobb i kø");
|
|
internal_error("Kunne ikke starte oppsummering")
|
|
})?;
|
|
|
|
tracing::info!(
|
|
job_id = %job_id,
|
|
communication_id = %req.communication_id,
|
|
user = %user.node_id,
|
|
"Oppsummerings-jobb lagt i kø"
|
|
);
|
|
|
|
Ok(Json(SummarizeResponse { job_id }))
|
|
}
|
|
|
|
// =============================================================================
|
|
// POST /intentions/generate_tts — tekst-til-tale via ElevenLabs
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct GenerateTtsRequest {
|
|
/// Teksten som skal leses opp (maks 5000 tegn).
|
|
pub text: String,
|
|
/// Valgfri ElevenLabs voice_id. Faller tilbake på node-preferanse eller default.
|
|
pub voice_id: Option<String>,
|
|
/// Valgfri kilde-node som TTS-lyden knyttes til med has_media-edge.
|
|
/// Hvis noden har metadata.voice_preference, brukes den som fallback for voice_id.
|
|
pub source_node_id: Option<Uuid>,
|
|
/// Språk (default: "no").
|
|
pub language: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct GenerateTtsResponse {
|
|
pub job_id: Uuid,
|
|
}
|
|
|
|
/// POST /intentions/generate_tts
|
|
///
|
|
/// Legger en `tts_generate`-jobb i køen.
|
|
/// Lydfilen opprettes asynkront som en ny content-node i CAS.
|
|
pub async fn generate_tts(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
Json(req): Json<GenerateTtsRequest>,
|
|
) -> Result<Json<GenerateTtsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
if req.text.is_empty() {
|
|
return Err(bad_request("Tekst kan ikke være tom"));
|
|
}
|
|
if req.text.len() > 5000 {
|
|
return Err(bad_request("Tekst for lang (maks 5000 tegn)"));
|
|
}
|
|
|
|
// Hvis source_node_id er oppgitt, verifiser at noden finnes
|
|
if let Some(source_id) = req.source_node_id {
|
|
let exists: bool = sqlx::query_scalar::<_, bool>(
|
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
|
|
)
|
|
.bind(source_id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "PG-feil ved node-sjekk");
|
|
internal_error("Databasefeil")
|
|
})?;
|
|
|
|
if !exists {
|
|
return Err(bad_request("Kildenode finnes ikke"));
|
|
}
|
|
}
|
|
|
|
let mut payload = serde_json::json!({
|
|
"text": req.text,
|
|
"requested_by": user.node_id.to_string(),
|
|
"language": req.language.as_deref().unwrap_or("no"),
|
|
});
|
|
|
|
if let Some(ref vid) = req.voice_id {
|
|
payload["voice_id"] = serde_json::Value::String(vid.clone());
|
|
}
|
|
if let Some(source_id) = req.source_node_id {
|
|
payload["source_node_id"] = serde_json::Value::String(source_id.to_string());
|
|
}
|
|
|
|
let job_id = crate::jobs::enqueue(
|
|
&state.db,
|
|
"tts_generate",
|
|
payload,
|
|
None,
|
|
5, // Middels prioritet
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "Kunne ikke legge TTS-jobb i kø");
|
|
internal_error("Kunne ikke starte TTS-generering")
|
|
})?;
|
|
|
|
tracing::info!(
|
|
job_id = %job_id,
|
|
text_len = req.text.len(),
|
|
user = %user.node_id,
|
|
"TTS-jobb lagt i kø"
|
|
);
|
|
|
|
Ok(Json(GenerateTtsResponse { job_id }))
|
|
}
|
|
|
|
// =============================================================================
|
|
// LiveKit — Join/Leave Communication
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct JoinCommunicationRequest {
|
|
pub communication_id: Uuid,
|
|
/// "publisher" (kan sende lyd) eller "subscriber" (bare lytte).
|
|
/// Default: "publisher".
|
|
pub role: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct JoinCommunicationResponse {
|
|
pub livekit_room_name: String,
|
|
pub livekit_token: String,
|
|
pub livekit_url: String,
|
|
pub identity: String,
|
|
pub participants: Vec<RoomParticipantInfo>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct RoomParticipantInfo {
|
|
pub user_id: String,
|
|
pub display_name: String,
|
|
pub role: String,
|
|
}
|
|
|
|
/// POST /intentions/join_communication
|
|
///
|
|
/// Kobler en bruker til sanntidslyd i en kommunikasjonsnode.
|
|
/// Validerer tilgang (bruker må ha member_of/owner/host_of-edge),
|
|
/// genererer LiveKit-token, oppdaterer STDB med live-status.
|
|
pub async fn join_communication(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
Json(req): Json<JoinCommunicationRequest>,
|
|
) -> Result<Json<JoinCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
let comm_id = req.communication_id;
|
|
let comm_id_str = comm_id.to_string();
|
|
|
|
// Sjekk at kommunikasjonsnoden eksisterer og er riktig type
|
|
let node_row = sqlx::query_as::<_, (String, String)>(
|
|
"SELECT node_kind, title FROM nodes WHERE id = $1",
|
|
)
|
|
.bind(comm_id)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved nodesjekk: {e}");
|
|
internal_error("Databasefeil ved validering")
|
|
})?;
|
|
|
|
let (node_kind, _title) = match node_row {
|
|
Some(row) => row,
|
|
None => return Err(bad_request("Kommunikasjonsnode finnes ikke")),
|
|
};
|
|
|
|
if node_kind != "communication" {
|
|
return Err(bad_request(&format!(
|
|
"Node er type '{node_kind}', ikke 'communication'"
|
|
)));
|
|
}
|
|
|
|
// Sjekk at brukeren har tilgang (direkte eller via alias)
|
|
let has_access = sqlx::query_scalar::<_, bool>(
|
|
r#"
|
|
SELECT EXISTS(
|
|
-- Direkte edge: bruker → kommunikasjon
|
|
SELECT 1 FROM edges
|
|
WHERE source_id = $2 AND target_id = $1
|
|
AND edge_type IN ('owner', 'member_of', 'host_of')
|
|
) OR EXISTS(
|
|
-- Via alias: bruker --alias--> alias --member_of/etc--> kommunikasjon
|
|
SELECT 1 FROM edges e_alias
|
|
JOIN edges e_member ON e_member.source_id = e_alias.target_id
|
|
WHERE e_alias.source_id = $2
|
|
AND e_alias.edge_type = 'alias'
|
|
AND e_alias.system = true
|
|
AND e_member.target_id = $1
|
|
AND e_member.edge_type IN ('owner', 'member_of', 'host_of')
|
|
)
|
|
"#,
|
|
)
|
|
.bind(comm_id)
|
|
.bind(user.node_id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
|
internal_error("Databasefeil ved tilgangssjekk")
|
|
})?;
|
|
|
|
if !has_access {
|
|
return Err(forbidden("Ingen tilgang til denne kommunikasjonsnoden"));
|
|
}
|
|
|
|
// Resolve display name (alias or user title)
|
|
let context_identity = resolve_context_identity(&state.db, user.node_id, comm_id)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("Kunne ikke resolve context identity: {e}");
|
|
internal_error("Kunne ikke hente brukeridentitet")
|
|
})?;
|
|
|
|
let display_name = sqlx::query_scalar::<_, String>(
|
|
"SELECT title FROM nodes WHERE id = $1",
|
|
)
|
|
.bind(context_identity)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved navnoppslag: {e}");
|
|
internal_error("Databasefeil")
|
|
})?
|
|
.unwrap_or_else(|| "Ukjent".to_string());
|
|
|
|
// Bestem rolle
|
|
let role_str = req.role.as_deref().unwrap_or("publisher");
|
|
let lk_role = match role_str {
|
|
"subscriber" => livekit::RoomRole::Subscriber,
|
|
_ => livekit::RoomRole::Publisher,
|
|
};
|
|
|
|
// Generer LiveKit-token
|
|
let token_result = livekit::generate_token(
|
|
comm_id,
|
|
user.node_id,
|
|
&display_name,
|
|
lk_role,
|
|
3600, // 1 time
|
|
)
|
|
.map_err(|e| {
|
|
tracing::error!("LiveKit token-generering feilet: {e}");
|
|
internal_error(&e)
|
|
})?;
|
|
|
|
let room_name = token_result.room_name.clone();
|
|
|
|
// Oppdater SpacetimeDB: opprett rom (idempotent) + legg til deltaker
|
|
if let Err(e) = state.stdb.create_live_room(&room_name, &comm_id_str).await {
|
|
tracing::warn!("STDB create_live_room feilet (fortsetter): {e}");
|
|
}
|
|
|
|
if let Err(e) = state
|
|
.stdb
|
|
.add_room_participant(
|
|
&room_name,
|
|
&user.node_id.to_string(),
|
|
&display_name,
|
|
role_str,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!("STDB add_room_participant feilet (fortsetter): {e}");
|
|
}
|
|
|
|
// Oppdater kommunikasjonsnodens metadata med live_status (asynkront)
|
|
let db = state.db.clone();
|
|
let stdb = state.stdb.clone();
|
|
let comm_id_clone = comm_id;
|
|
let room_name_clone = room_name.clone();
|
|
tokio::spawn(async move {
|
|
// Les eksisterende metadata, legg til live_status
|
|
let result = sqlx::query_scalar::<_, serde_json::Value>(
|
|
"SELECT metadata FROM nodes WHERE id = $1",
|
|
)
|
|
.bind(comm_id_clone)
|
|
.fetch_optional(&db)
|
|
.await;
|
|
|
|
if let Ok(Some(mut metadata)) = result {
|
|
if let Some(obj) = metadata.as_object_mut() {
|
|
obj.insert("live_status".into(), "active".into());
|
|
obj.insert("livekit_room_name".into(), room_name_clone.clone().into());
|
|
}
|
|
|
|
if let Err(e) = sqlx::query(
|
|
"UPDATE nodes SET metadata = $2 WHERE id = $1",
|
|
)
|
|
.bind(comm_id_clone)
|
|
.bind(&metadata)
|
|
.execute(&db)
|
|
.await
|
|
{
|
|
tracing::error!("Kunne ikke oppdatere node metadata: {e}");
|
|
}
|
|
|
|
// Synk metadata til STDB
|
|
let node = sqlx::query_as::<_, (String, String, String, String, String)>(
|
|
"SELECT node_kind, title, content, visibility, metadata::text FROM nodes WHERE id = $1",
|
|
)
|
|
.bind(comm_id_clone)
|
|
.fetch_optional(&db)
|
|
.await;
|
|
|
|
if let Ok(Some((kind, t, c, v, m))) = node {
|
|
let _ = stdb
|
|
.update_node(&comm_id_clone.to_string(), &kind, &t, &c, &v, &m)
|
|
.await;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Hent nåværende deltakere fra PG edges (for respons)
|
|
let participants = sqlx::query_as::<_, (String, String)>(
|
|
r#"
|
|
SELECT e.source_id::text, COALESCE(n.title, 'Ukjent')
|
|
FROM edges e
|
|
LEFT JOIN nodes n ON n.id = e.source_id
|
|
WHERE e.target_id = $1
|
|
AND e.edge_type IN ('owner', 'member_of', 'host_of')
|
|
"#,
|
|
)
|
|
.bind(comm_id)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved deltakerhenting: {e}");
|
|
internal_error("Databasefeil")
|
|
})?
|
|
.into_iter()
|
|
.map(|(uid, name)| RoomParticipantInfo {
|
|
user_id: uid,
|
|
display_name: name,
|
|
role: "publisher".to_string(),
|
|
})
|
|
.collect();
|
|
|
|
let livekit_url = std::env::var("LIVEKIT_WS_URL")
|
|
.unwrap_or_else(|_| {
|
|
// Fallback: bruk domene med wss
|
|
"wss://sidelinja.org/livekit".to_string()
|
|
});
|
|
|
|
tracing::info!(
|
|
communication_id = %comm_id,
|
|
user = %user.node_id,
|
|
room = %room_name,
|
|
role = %role_str,
|
|
"Bruker koblet til LiveKit-rom"
|
|
);
|
|
|
|
Ok(Json(JoinCommunicationResponse {
|
|
livekit_room_name: room_name,
|
|
livekit_token: token_result.token,
|
|
livekit_url,
|
|
identity: token_result.identity,
|
|
participants,
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LeaveCommunicationRequest {
|
|
pub communication_id: Uuid,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct LeaveCommunicationResponse {
|
|
pub status: String,
|
|
}
|
|
|
|
/// POST /intentions/leave_communication
|
|
///
|
|
/// Fjerner brukerens sanntidslyd-tilkobling fra en kommunikasjonsnode.
|
|
/// Oppdaterer STDB med ny deltaker-status. Stenger rommet hvis ingen gjenstår.
|
|
pub async fn leave_communication(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
Json(req): Json<LeaveCommunicationRequest>,
|
|
) -> Result<Json<LeaveCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
let comm_id = req.communication_id;
|
|
let room_name = format!("communication_{comm_id}");
|
|
let user_id_str = user.node_id.to_string();
|
|
|
|
// Fjern deltaker fra STDB
|
|
if let Err(e) = state.stdb.remove_room_participant(&room_name, &user_id_str).await {
|
|
tracing::warn!("STDB remove_room_participant feilet: {e}");
|
|
}
|
|
|
|
// Sjekk om rommet er tomt — i så fall steng det
|
|
// (Vi sjekker PG edges for å se hvem som er registrert som deltaker i STDB)
|
|
// Forenklet: la rommet ligge, det ryddes ved neste oppstart eller manuelt
|
|
// Frontend kan kalle close_communication for å eksplisitt stenge.
|
|
|
|
tracing::info!(
|
|
communication_id = %comm_id,
|
|
user = %user.node_id,
|
|
room = %room_name,
|
|
"Bruker forlot LiveKit-rom"
|
|
);
|
|
|
|
Ok(Json(LeaveCommunicationResponse {
|
|
status: "left".to_string(),
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CloseCommunicationRequest {
|
|
pub communication_id: Uuid,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct CloseCommunicationResponse {
|
|
pub status: String,
|
|
}
|
|
|
|
/// POST /intentions/close_communication
|
|
///
|
|
/// Stenger et sanntidsrom. Krever owner/admin-tilgang.
|
|
/// Fjerner alle deltakere fra STDB, oppdaterer metadata til "ended".
|
|
pub async fn close_communication(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
Json(req): Json<CloseCommunicationRequest>,
|
|
) -> Result<Json<CloseCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
let comm_id = req.communication_id;
|
|
|
|
// Bare owner/admin kan stenge
|
|
if !user_can_modify_node(&state.db, user.node_id, comm_id)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
|
internal_error("Databasefeil")
|
|
})?
|
|
{
|
|
return Err(forbidden("Bare eier kan stenge kommunikasjonsrom"));
|
|
}
|
|
|
|
let room_name = format!("communication_{comm_id}");
|
|
|
|
// Steng rommet i STDB
|
|
if let Err(e) = state.stdb.close_live_room(&room_name).await {
|
|
tracing::warn!("STDB close_live_room feilet: {e}");
|
|
}
|
|
|
|
// Oppdater metadata i PG
|
|
let db = state.db.clone();
|
|
let stdb = state.stdb.clone();
|
|
tokio::spawn(async move {
|
|
let result = sqlx::query_scalar::<_, serde_json::Value>(
|
|
"SELECT metadata FROM nodes WHERE id = $1",
|
|
)
|
|
.bind(comm_id)
|
|
.fetch_optional(&db)
|
|
.await;
|
|
|
|
if let Ok(Some(mut metadata)) = result {
|
|
if let Some(obj) = metadata.as_object_mut() {
|
|
obj.insert("live_status".into(), "ended".into());
|
|
obj.insert(
|
|
"ended_at".into(),
|
|
chrono::Utc::now().to_rfc3339().into(),
|
|
);
|
|
}
|
|
|
|
let _ = sqlx::query("UPDATE nodes SET metadata = $2 WHERE id = $1")
|
|
.bind(comm_id)
|
|
.bind(&metadata)
|
|
.execute(&db)
|
|
.await;
|
|
|
|
// Synk til STDB
|
|
let node = sqlx::query_as::<_, (String, String, String, String, String)>(
|
|
"SELECT node_kind, title, content, visibility, metadata::text FROM nodes WHERE id = $1",
|
|
)
|
|
.bind(comm_id)
|
|
.fetch_optional(&db)
|
|
.await;
|
|
|
|
if let Ok(Some((kind, t, c, v, m))) = node {
|
|
let _ = stdb
|
|
.update_node(&comm_id.to_string(), &kind, &t, &c, &v, &m)
|
|
.await;
|
|
}
|
|
}
|
|
});
|
|
|
|
tracing::info!(
|
|
communication_id = %comm_id,
|
|
user = %user.node_id,
|
|
"Kommunikasjonsrom stengt"
|
|
);
|
|
|
|
Ok(Json(CloseCommunicationResponse {
|
|
status: "closed".to_string(),
|
|
}))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Lydstudio
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AudioAnalyzeRequest {
|
|
pub cas_hash: String,
|
|
pub silence_threshold_db: Option<f32>,
|
|
pub silence_min_duration_ms: Option<u32>,
|
|
}
|
|
|
|
/// POST /intentions/audio_analyze
|
|
///
|
|
/// Synkron analyse av en lydfil: loudness (LUFS), silence-regioner, og metadata.
|
|
/// Brukes av studioet for å vise nåværende tilstand før redigering.
|
|
pub async fn audio_analyze(
|
|
State(state): State<AppState>,
|
|
_user: AuthUser,
|
|
Json(req): Json<AudioAnalyzeRequest>,
|
|
) -> Result<Json<crate::audio::AnalyzeResult>, (StatusCode, Json<ErrorResponse>)> {
|
|
let cas = &state.cas;
|
|
|
|
if !cas.exists(&req.cas_hash) {
|
|
return Err(bad_request("Filen finnes ikke i CAS"));
|
|
}
|
|
|
|
let info = crate::audio::get_audio_info(cas, &req.cas_hash)
|
|
.await
|
|
.map_err(|e| internal_error(&e))?;
|
|
|
|
let loudness = crate::audio::analyze_loudness(cas, &req.cas_hash)
|
|
.await
|
|
.map_err(|e| internal_error(&e))?;
|
|
|
|
let threshold = req.silence_threshold_db.unwrap_or(-30.0);
|
|
let min_dur = req.silence_min_duration_ms.unwrap_or(500);
|
|
|
|
let silence_regions = crate::audio::detect_silence(cas, &req.cas_hash, threshold, min_dur)
|
|
.await
|
|
.map_err(|e| internal_error(&e))?;
|
|
|
|
Ok(Json(crate::audio::AnalyzeResult {
|
|
loudness,
|
|
silence_regions,
|
|
info,
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AudioProcessRequest {
|
|
pub media_node_id: Uuid,
|
|
pub edl: crate::audio::EdlDocument,
|
|
pub output_format: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AudioProcessResponse {
|
|
pub job_id: Uuid,
|
|
}
|
|
|
|
/// POST /intentions/audio_process
|
|
///
|
|
/// Køer en audio-prosessering-jobb. Resultatet blir en ny medienode
|
|
/// med derived_from-edge til originalen.
|
|
pub async fn audio_process(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
Json(req): Json<AudioProcessRequest>,
|
|
) -> Result<Json<AudioProcessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
// Sjekk at medienoden eksisterer
|
|
if !node_exists(&state.db, req.media_node_id).await.map_err(|e| {
|
|
tracing::error!("DB-feil: {e}");
|
|
internal_error("Databasefeil")
|
|
})? {
|
|
return Err(bad_request("media_node_id finnes ikke"));
|
|
}
|
|
|
|
// Sjekk at kildefilen finnes i CAS
|
|
if !state.cas.exists(&req.edl.source_hash) {
|
|
return Err(bad_request("source_hash finnes ikke i CAS"));
|
|
}
|
|
|
|
let output_format = req.output_format.unwrap_or_else(|| "mp3".to_string());
|
|
|
|
let payload = serde_json::json!({
|
|
"media_node_id": req.media_node_id.to_string(),
|
|
"edl": req.edl,
|
|
"output_format": output_format,
|
|
"requested_by": user.node_id.to_string(),
|
|
});
|
|
|
|
let job_id = crate::jobs::enqueue(&state.db, "audio_process", payload, None, 5)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("Kunne ikke køe audio_process-jobb: {e}");
|
|
internal_error("Kunne ikke køe jobb")
|
|
})?;
|
|
|
|
Ok(Json(AudioProcessResponse { job_id }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AudioInfoQuery {
|
|
pub hash: String,
|
|
}
|
|
|
|
/// GET /query/audio_info?hash=...
|
|
///
|
|
/// Hent metadata om en lydfil (varighet, sample rate, kanaler, codec).
|
|
pub async fn audio_info(
|
|
State(state): State<AppState>,
|
|
_user: AuthUser,
|
|
axum::extract::Query(query): axum::extract::Query<AudioInfoQuery>,
|
|
) -> Result<Json<crate::audio::AudioInfo>, (StatusCode, Json<ErrorResponse>)> {
|
|
if !state.cas.exists(&query.hash) {
|
|
return Err(bad_request("Filen finnes ikke i CAS"));
|
|
}
|
|
|
|
let info = crate::audio::get_audio_info(&state.cas, &query.hash)
|
|
.await
|
|
.map_err(|e| internal_error(&e))?;
|
|
|
|
Ok(Json(info))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tester
|
|
// =============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn test_validate_traits_ok_empty() {
|
|
let meta = json!({});
|
|
assert!(validate_collection_traits("collection", &meta).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_traits_ok_known() {
|
|
let meta = json!({
|
|
"traits": {
|
|
"publishing": { "slug": "test" },
|
|
"rss": { "format": "atom" },
|
|
"editor": { "preset": "longform" }
|
|
}
|
|
});
|
|
assert!(validate_collection_traits("collection", &meta).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_traits_rejects_unknown() {
|
|
let meta = json!({
|
|
"traits": {
|
|
"publishing": {},
|
|
"banana": {}
|
|
}
|
|
});
|
|
let err = validate_collection_traits("collection", &meta).unwrap_err();
|
|
assert!(err.contains("banana"), "Feilmelding skal nevne ukjent trait: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_traits_rejects_non_object() {
|
|
let meta = json!({ "traits": ["publishing"] });
|
|
let err = validate_collection_traits("collection", &meta).unwrap_err();
|
|
assert!(err.contains("objekt"), "Feilmelding: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_traits_skips_non_collection() {
|
|
let meta = json!({ "traits": { "totally_invalid": {} } });
|
|
assert!(validate_collection_traits("content", &meta).is_ok());
|
|
assert!(validate_collection_traits("person", &meta).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_traits_all_known() {
|
|
// Verifiser at alle traits fra katalogen er gyldige
|
|
let all_traits = vec![
|
|
"editor", "versioning", "collaboration", "translation", "templates",
|
|
"publishing", "rss", "newsletter", "custom_domain", "analytics", "embed", "api",
|
|
"podcast", "recording", "transcription", "tts", "clips", "playlist", "studio",
|
|
"chat", "forum", "comments", "guest_input", "announcements", "polls", "qa",
|
|
"kanban", "calendar", "timeline", "table", "gallery", "bookmarks", "tags",
|
|
"knowledge_graph", "wiki", "glossary", "faq", "bibliography",
|
|
"auto_tag", "auto_summarize", "digest", "bridge", "moderation",
|
|
"membership", "roles", "invites", "paywall", "directory",
|
|
"webhook", "import", "export", "ical_sync",
|
|
];
|
|
let mut traits_obj = serde_json::Map::new();
|
|
for t in &all_traits {
|
|
traits_obj.insert(t.to_string(), json!({}));
|
|
}
|
|
let meta = json!({ "traits": traits_obj });
|
|
assert!(validate_collection_traits("collection", &meta).is_ok());
|
|
}
|
|
}
|