synops/maskinrommet/src/intentions.rs
vegard 8af4265b6e synops-clip orkestrering-støtte: cli_tool-registrering + clip_url jobb/API (oppgave 25.4)
Gjør synops-clip tilgjengelig i orkestreringer ved å:

1. Registrere synops-clip som cli_tool-node (migration 026) med norske
   aliases (clip, klipp, hent artikkel) og args_hints for script-kompilatoren.
   Orkestreringer kan nå skrive "1. clip fra event (lagre node, bruker)"
   som kompileres til "synops-clip --url {event.url} --write --created-by ...".

2. Legge til clip_url som jobbtype i jobbkøen (clip.rs) — spawner
   synops-clip med riktige env-variabler (DATABASE_URL, AI_GATEWAY_URL, etc).

3. Legge til POST /intentions/clip_url API-endepunkt slik at frontend
   og andre klienter kan trigge URL-klipping direkte.

4. Utvide trigger-konteksten med event.url og event.created_by slik at
   orkestreringer som reagerer på URL-deling kan videresende URL til
   synops-clip via variabel-substitusjon.
2026-03-18 18:55:11 +00:00

4870 lines
161 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Intensjoner — skrivestien i maskinrommet.
//
// Frontend sender intensjoner (ikke data). Maskinrommet validerer og
// skriver til PostgreSQL. PG NOTIFY-triggere sender sanntidsoppdateringer
// til klienter via WebSocket.
//
// 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::{AdminUser, 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", "mixer", "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", "ai_tool", "orchestration",
// 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,
));
}
// Valider custom_domain DNS hvis satt i publishing-trait
if let Some(publishing) = traits_obj.get("publishing") {
if let Some(domain) = publishing.get("custom_domain").and_then(|v| v.as_str()) {
if !domain.is_empty() {
crate::custom_domain::validate_dns(domain)?;
}
}
}
Ok(())
}
/// Gyldige trigger-events for orchestration-noder.
/// Ref: docs/concepts/orkestrering.md § 5 "Kjente trigger-events"
const VALID_TRIGGER_EVENTS: &[&str] = &[
"node.created",
"edge.created",
"communication.ended",
"node.published",
"scheduled.due",
"manual",
];
/// Gyldige executor-verdier for orchestration-noder.
/// Ref: docs/concepts/orkestrering.md § 4 "Tre utførelsesnivåer"
const VALID_EXECUTORS: &[&str] = &["script", "bot", "dream"];
/// Gyldige modellprofiler for AI-presets.
/// Ref: docs/infra/ai_gateway.md § "Modellprofiler"
const VALID_MODEL_PROFILES: &[&str] = &["flash", "standard"];
/// Gyldige kategorier for AI-presets.
const VALID_AI_PRESET_CATEGORIES: &[&str] = &["standard", "custom"];
/// Gyldige retninger for AI-presets.
const VALID_AI_PRESET_DIRECTIONS: &[&str] = &["tool_to_node", "node_to_tool", "both"];
/// Validerer metadata for `node_kind == "ai_preset"`.
///
/// Påkrevde felter i metadata:
/// - `prompt` (string, ikke tom)
/// - `model_profile` (string, må finnes i VALID_MODEL_PROFILES)
/// - `category` (string, må finnes i VALID_AI_PRESET_CATEGORIES)
/// - `default_direction` (string, må finnes i VALID_AI_PRESET_DIRECTIONS)
/// - `icon` (string, ikke tom)
/// - `color` (string, hex-farge)
///
/// Ref: docs/features/ai_verktoy.md § 5
fn validate_ai_preset_metadata(
node_kind: &str,
metadata: &serde_json::Value,
) -> Result<(), String> {
if node_kind != "ai_preset" {
return Ok(());
}
// Påkrevd: prompt (ikke-tom streng)
match metadata.get("prompt").and_then(|v| v.as_str()) {
None | Some("") => return Err("ai_preset krever metadata.prompt (ikke-tom streng)".into()),
_ => {}
}
// Påkrevd: model_profile
match metadata.get("model_profile").and_then(|v| v.as_str()) {
None => return Err("ai_preset krever metadata.model_profile".into()),
Some(p) if !VALID_MODEL_PROFILES.contains(&p) => {
return Err(format!(
"Ugyldig model_profile: '{p}'. Gyldige verdier: {VALID_MODEL_PROFILES:?}"
));
}
_ => {}
}
// Påkrevd: category
match metadata.get("category").and_then(|v| v.as_str()) {
None => return Err("ai_preset krever metadata.category".into()),
Some(c) if !VALID_AI_PRESET_CATEGORIES.contains(&c) => {
return Err(format!(
"Ugyldig category: '{c}'. Gyldige verdier: {VALID_AI_PRESET_CATEGORIES:?}"
));
}
_ => {}
}
// Påkrevd: default_direction
match metadata.get("default_direction").and_then(|v| v.as_str()) {
None => return Err("ai_preset krever metadata.default_direction".into()),
Some(d) if !VALID_AI_PRESET_DIRECTIONS.contains(&d) => {
return Err(format!(
"Ugyldig default_direction: '{d}'. Gyldige verdier: {VALID_AI_PRESET_DIRECTIONS:?}"
));
}
_ => {}
}
// Påkrevd: icon (ikke-tom streng)
match metadata.get("icon").and_then(|v| v.as_str()) {
None | Some("") => return Err("ai_preset krever metadata.icon (ikke-tom streng)".into()),
_ => {}
}
// Påkrevd: color (hex-farge)
match metadata.get("color").and_then(|v| v.as_str()) {
None | Some("") => return Err("ai_preset krever metadata.color (hex-farge)".into()),
Some(c) if !c.starts_with('#') || c.len() != 7 => {
return Err(format!("Ugyldig color: '{c}'. Forventet hex-farge (#RRGGBB)"));
}
_ => {}
}
Ok(())
}
/// Validerer metadata for `node_kind == "orchestration"`.
///
/// Påkrevde felter i metadata:
/// - `trigger` (objekt med `event` og valgfri `conditions`)
/// - `trigger.event` (string, må finnes i VALID_TRIGGER_EVENTS)
/// - `executor` (string, må finnes i VALID_EXECUTORS)
///
/// Valgfrie felter:
/// - `trigger.conditions` (objekt — fri filtrering)
/// - `intelligence` (heltall 13)
/// - `effort` (heltall 13)
/// - `compiled` (boolean — om scriptet er kompilert fra AI-modus)
/// - `pipeline` (array — sekvens av steg)
///
/// Ref: docs/concepts/orkestrering.md
fn validate_orchestration_metadata(
node_kind: &str,
metadata: &serde_json::Value,
) -> Result<(), String> {
if node_kind != "orchestration" {
return Ok(());
}
// Påkrevd: trigger (objekt)
let trigger = metadata
.get("trigger")
.ok_or("orchestration krever metadata.trigger")?;
let trigger_obj = trigger
.as_object()
.ok_or("metadata.trigger må være et objekt")?;
// Påkrevd: trigger.event
match trigger_obj.get("event").and_then(|v| v.as_str()) {
None | Some("") => {
return Err("orchestration krever metadata.trigger.event (ikke-tom streng)".into())
}
Some(ev) if !VALID_TRIGGER_EVENTS.contains(&ev) => {
return Err(format!(
"Ukjent trigger-event: '{ev}'. Gyldige verdier: {VALID_TRIGGER_EVENTS:?}"
));
}
_ => {}
}
// Valgfri: trigger.conditions (må være objekt hvis satt)
if let Some(conditions) = trigger_obj.get("conditions") {
if !conditions.is_object() {
return Err("metadata.trigger.conditions må være et objekt".into());
}
}
// Påkrevd: executor
match metadata.get("executor").and_then(|v| v.as_str()) {
None => return Err("orchestration krever metadata.executor".into()),
Some(ex) if !VALID_EXECUTORS.contains(&ex) => {
return Err(format!(
"Ugyldig executor: '{ex}'. Gyldige verdier: {VALID_EXECUTORS:?}"
));
}
_ => {}
}
// Valgfri: intelligence (13)
if let Some(val) = metadata.get("intelligence") {
match val.as_i64() {
Some(n) if (1..=3).contains(&n) => {}
_ => return Err("metadata.intelligence må være et heltall 13".into()),
}
}
// Valgfri: effort (13)
if let Some(val) = metadata.get("effort") {
match val.as_i64() {
Some(n) if (1..=3).contains(&n) => {}
_ => return Err("metadata.effort må være et heltall 13".into()),
}
}
// Valgfri: compiled (boolean)
if let Some(val) = metadata.get("compiled") {
if !val.is_boolean() {
return Err("metadata.compiled må være en boolean".into());
}
}
// Valgfri: pipeline (array)
if let Some(val) = metadata.get("pipeline") {
if !val.is_array() {
return Err("metadata.pipeline må være et array".into());
}
}
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(),
}),
)
}
// =============================================================================
// 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
}
/// Henter publishing-trait-konfig for en node (hvis den er en samling med publishing-trait).
/// Wrapper rundt publishing::find_publishing_collection_by_id med feil-mapping for handlers.
async fn get_publishing_config(
db: &PgPool,
node_id: Uuid,
) -> Result<Option<crate::publishing::PublishingConfig>, (StatusCode, Json<ErrorResponse>)> {
crate::publishing::find_publishing_collection_by_id(db, node_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved publishing-config-oppslag: {e}");
internal_error("Databasefeil ved publiseringssjekk")
})
}
/// Returnerer brukerens høyeste rolle-edge til en node.
/// Sjekker owner, admin, member_of, reader-edges.
/// Returnerer "owner", "admin", "member", "reader", eller None.
async fn get_user_role_for_node(
db: &PgPool,
user_id: Uuid,
node_id: Uuid,
) -> Result<Option<String>, (StatusCode, Json<ErrorResponse>)> {
// Sjekk i prioritetsrekkefølge: owner > admin > member > reader
let role: Option<String> = sqlx::query_scalar(
r#"
SELECT CASE
WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'owner') THEN 'owner'
WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'admin') THEN 'admin'
WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'member_of') THEN 'member'
WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'reader') THEN 'reader'
ELSE NULL
END
"#,
)
.bind(user_id)
.bind(node_id)
.fetch_one(db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved rolle-oppslag: {e}");
internal_error("Databasefeil ved rollesjekk")
})?;
Ok(role)
}
/// Sjekker om brukeren er owner eller admin av en node.
async fn user_is_owner_or_admin(
db: &PgPool,
user_id: Uuid,
node_id: Uuid,
) -> Result<bool, (StatusCode, Json<ErrorResponse>)> {
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_id)
.bind(node_id)
.fetch_one(db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved owner/admin-sjekk: {e}");
internal_error("Databasefeil ved tilgangssjekk")
})
}
// =============================================================================
// 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 PG. NOTIFY-triggere sender sanntidsoppdateringer.
///
/// 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))?;
// -- Valider metadata for AI-presets (oppgave 18.1) --
validate_ai_preset_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
// -- Valider metadata for orchestration-noder (oppgave 24.1) --
validate_orchestration_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
// -- 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();
// Fang verdier for AI-trigger før de brukes i PG-insert
let is_content_node = node_kind == "content";
let has_enough_text = content.len() >= 20 || title.len() >= 20;
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
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(effective_identity)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved opprettelse av node: {e}");
internal_error("Databasefeil ved opprettelse av node")
})?;
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"
);
// -- 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 bt_metadata = serde_json::json!({});
sqlx::query(
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'belongs_to', $4, false, $5)"#,
)
.bind(edge_id)
.bind(node_id)
.bind(ctx_id)
.bind(&bt_metadata)
.bind(effective_identity)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved opprettelse av belongs_to-edge: {e}");
internal_error("Databasefeil ved opprettelse av edge")
})?;
tracing::info!(
edge_id = %edge_id,
node_id = %node_id,
context_id = %ctx_id,
"belongs_to-edge opprettet (kontekst-arv)"
);
// Propager tilgang: alle som har tilgang til ctx_id får tilgang til node_id
if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
.bind(node_id)
.bind(ctx_id)
.execute(&state.db)
.await
{
tracing::error!("propagate_belongs_to_access feilet: {e}");
}
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 enqueue_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 mut metadata = req.metadata.unwrap_or_else(|| serde_json::json!({}));
let system = req.system.unwrap_or(false);
// -- Publiseringsvalidering for submitted_to og belongs_to --
if req.edge_type == "submitted_to" || req.edge_type == "belongs_to" {
if let Some(config) = get_publishing_config(&state.db, req.target_id).await? {
if config.require_approval {
if req.edge_type == "submitted_to" {
// Kun roller i submission_roles (eller owner/admin) kan opprette submitted_to
let user_role = get_user_role_for_node(&state.db, user.node_id, req.target_id).await?;
let allowed = match &user_role {
Some(role) if role == "owner" || role == "admin" => true,
Some(role) => config.submission_roles.contains(role),
None => false,
};
if !allowed {
return Err(forbidden(
"Du har ikke riktig rolle til å sende inn til denne samlingen",
));
}
// Sett status og submitted_at automatisk
if let Some(obj) = metadata.as_object_mut() {
obj.insert("status".to_string(), serde_json::json!("pending"));
obj.insert(
"submitted_at".to_string(),
serde_json::json!(chrono::Utc::now().to_rfc3339()),
);
}
} else {
// belongs_to til require_approval-samling: kun owner/admin
let is_owner_admin =
user_is_owner_or_admin(&state.db, user.node_id, req.target_id).await?;
if !is_owner_admin {
return Err(forbidden(
"Kun owner/admin kan publisere direkte til denne samlingen (require_approval er aktiv)",
));
}
}
}
}
}
// -- Validering av source_material-metadata --
if req.edge_type == "source_material" {
if let Some(obj) = metadata.as_object() {
// context er påkrevd og må være en av tre gyldige verdier
match obj.get("context").and_then(|v| v.as_str()) {
Some("quoted" | "summarized" | "referenced") => {}
Some(other) => {
return Err(bad_request(&format!(
"source_material context må være 'quoted', 'summarized' eller 'referenced', fikk '{other}'"
)));
}
None => {
return Err(bad_request(
"source_material krever 'context'-felt (quoted, summarized eller referenced)",
));
}
}
// excerpt er påkrevd og må være en ikke-tom streng
match obj.get("excerpt").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => {}
_ => {
return Err(bad_request(
"source_material krever et ikke-tomt 'excerpt'-felt",
));
}
}
} else {
return Err(bad_request(
"source_material krever metadata med 'context' og 'excerpt'",
));
}
}
// -- Generer UUIDv7 --
let edge_id = Uuid::now_v7();
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
let access_level = match req.edge_type.as_str() {
"owner" => Some("owner"),
"admin" => Some("admin"),
"member_of" => Some("member"),
"reader" => Some("reader"),
_ => None,
};
if let Some(level) = access_level {
// Tilgangsgivende edge: transaksjon med recompute_access
let mut tx = state.db.begin().await.map_err(|e| {
tracing::error!("PG begin: {e}");
internal_error("Databasefeil")
})?;
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(req.source_id)
.bind(req.target_id)
.bind(&req.edge_type)
.bind(&metadata)
.bind(system)
.bind(user.node_id)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("PG insert edge: {e}");
internal_error("Databasefeil ved opprettelse av edge")
})?;
sqlx::query("SELECT recompute_access($1, $2, $3::access_level, $4)")
.bind(req.source_id)
.bind(req.target_id)
.bind(level)
.bind(edge_id)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("recompute_access: {e}");
internal_error("Databasefeil ved tilgangsberegning")
})?;
tx.commit().await.map_err(|e| {
tracing::error!("PG commit: {e}");
internal_error("Databasefeil")
})?;
} else {
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(req.source_id)
.bind(req.target_id)
.bind(&req.edge_type)
.bind(&metadata)
.bind(system)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG insert edge: {e}");
internal_error("Databasefeil ved opprettelse av edge")
})?;
// Propager tilgang ved belongs_to: alle som har tilgang til target (forelder) får tilgang til source (barn)
if req.edge_type == "belongs_to" {
if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
.bind(req.source_id)
.bind(req.target_id)
.execute(&state.db)
.await
{
tracing::error!("propagate_belongs_to_access feilet: {e}");
}
}
// Trigger rendering ved belongs_to
if req.edge_type == "belongs_to" {
if let Ok(Some(config)) = crate::publishing::find_publishing_collection_by_id(&state.db, req.target_id).await {
let article_payload = serde_json::json!({
"node_id": req.source_id.to_string(),
"collection_id": req.target_id.to_string(),
});
let _ = crate::jobs::enqueue(&state.db, "render_article", article_payload, Some(req.target_id), 5).await;
let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
if index_mode == "static" {
let index_payload = serde_json::json!({ "collection_id": req.target_id.to_string() });
let _ = crate::jobs::enqueue(&state.db, "render_index", index_payload, Some(req.target_id), 4).await;
} else {
crate::publishing::invalidate_index_cache(&state.index_cache, req.target_id).await;
}
}
}
// A/B-test for presentasjonselement-edges
if matches!(req.edge_type.as_str(), "title" | "subtitle" | "summary" | "og_image") {
crate::publishing::maybe_start_ab_test(&state.db, req.target_id, &req.edge_type).await;
}
}
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"
);
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());
// Hent gamle publishing-verdier før existing.metadata flyttes
let old_publishing = existing.metadata
.get("traits")
.and_then(|t| t.get("publishing"));
let old_domain = old_publishing
.and_then(|p| p.get("custom_domain"))
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
let old_theme = old_publishing
.and_then(|p| p.get("theme"))
.cloned();
let old_theme_config = old_publishing
.and_then(|p| p.get("theme_config"))
.cloned();
// Hent gamle AI-preset-verdier før existing.metadata flyttes (oppgave 18.6)
let old_model_profile = existing.metadata
.get("model_profile")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let old_category = existing.metadata
.get("category")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
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))?;
// -- Valider metadata for AI-presets (oppgave 18.1) --
validate_ai_preset_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
// -- Valider metadata for orchestration-noder (oppgave 24.1) --
validate_orchestration_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
// -- Beskytt model_profile for egendefinerte AI-presets (oppgave 18.6) --
// Kun admin/owner på en samling kan endre model_profile. Vanlige brukere
// som eier et custom preset kan redigere alt annet (prompt, icon, farge osv.)
if node_kind == "ai_preset" {
let new_profile = metadata.get("model_profile").and_then(|v| v.as_str()).map(|s| s.to_string());
if new_profile != old_model_profile {
// Sjekk om brukeren er admin (har owner-edge til en samling)
let is_admin_user = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM edges
WHERE source_id = $1
AND edge_type IN ('owner', 'admin')
AND target_id IN (SELECT id FROM nodes WHERE node_kind = 'collection')
)
"#,
)
.bind(user.node_id)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG-feil ved admin-sjekk for model_profile");
internal_error("Databasefeil")
})?;
if !is_admin_user {
return Err(forbidden(
"Kun admin kan endre modellprofil for AI-presets",
));
}
}
// Forhindre at vanlige brukere endrer category fra custom til standard
let new_cat = metadata.get("category").and_then(|v| v.as_str()).map(|s| s.to_string());
if new_cat != old_category && new_cat.as_deref() == Some("standard") {
return Err(forbidden(
"Kan ikke endre kategori til 'standard' — det er reservert for systempresets",
));
}
}
// -- Sjekk om custom_domain er endret (for re-rendering) --
let new_domain = metadata
.get("traits")
.and_then(|t| t.get("publishing"))
.and_then(|p| p.get("custom_domain"))
.and_then(|d| d.as_str())
.unwrap_or("");
let domain_changed = old_domain != new_domain && node_kind == "collection";
// -- Sjekk om theme eller theme_config er endret (for bulk re-rendering, oppgave 14.14) --
let theme_changed = if node_kind == "collection" {
let new_publishing = metadata
.get("traits")
.and_then(|t| t.get("publishing"));
let new_theme = new_publishing.and_then(|p| p.get("theme")).cloned();
let new_theme_config = new_publishing.and_then(|p| p.get("theme_config")).cloned();
old_theme != new_theme || old_theme_config != new_theme_config
} else {
false
};
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
sqlx::query(
r#"UPDATE nodes SET node_kind = $2, title = NULLIF($3, ''), content = NULLIF($4, ''),
visibility = $5::visibility, metadata = $6 WHERE id = $1"#,
)
.bind(req.node_id)
.bind(&node_kind)
.bind(&title)
.bind(&content)
.bind(&visibility)
.bind(&metadata)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved oppdatering av node: {e}");
internal_error("Databasefeil ved oppdatering av node")
})?;
tracing::info!(
node_id = %req.node_id,
updated_by = %user.node_id,
"Node oppdatert"
);
// -- Re-render alle artikler hvis custom_domain endret (canonical URL) --
if domain_changed {
let db = state.db.clone();
let collection_id = req.node_id;
tokio::spawn(async move {
match crate::custom_domain::rerender_collection_articles(&db, collection_id).await {
Ok(count) => tracing::info!(
collection_id = %collection_id,
articles = count,
"Re-rendering trigget etter domeneendring"
),
Err(e) => tracing::error!(
collection_id = %collection_id,
error = %e,
"Feil ved re-rendering etter domeneendring"
),
}
});
}
// -- Bulk re-rendering hvis theme/theme_config endret (oppgave 14.14) --
if theme_changed {
let db = state.db.clone();
let collection_id = req.node_id;
tokio::spawn(async move {
match crate::publishing::trigger_bulk_rerender(&db, collection_id).await {
Ok(count) => tracing::info!(
collection_id = %collection_id,
articles = count,
"Bulk re-rendering trigget etter temaendring"
),
Err(e) => tracing::error!(
collection_id = %collection_id,
error = %e,
"Feil ved bulk re-rendering etter temaendring"
),
}
});
}
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).
/// 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)));
}
// -- Slett fra PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
sqlx::query("DELETE FROM nodes WHERE id = $1")
.bind(req.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved sletting av node: {e}");
internal_error("Databasefeil ved sletting av node")
})?;
tracing::info!(
node_id = %req.node_id,
deleted_by = %user.node_id,
"Node slettet"
);
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 source_id, target_id, edge_type, metadata FROM edges WHERE id = $1",
)
.bind(edge_id)
.fetch_optional(db)
.await
}
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct EdgeRow {
source_id: Uuid,
target_id: Uuid,
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.clone());
if edge_type.is_empty() {
return Err(bad_request("edge_type kan ikke være tom"));
}
let metadata = req.metadata.unwrap_or(existing.metadata.clone());
// -- submitted_to status-endring: kun owner/admin av samlingen --
if existing.edge_type == "submitted_to" {
let new_status = metadata.get("status").and_then(|v| v.as_str());
let old_status = existing.metadata.get("status").and_then(|v| v.as_str());
if new_status != old_status {
let is_owner_admin =
user_is_owner_or_admin(&state.db, user.node_id, existing.target_id).await?;
if !is_owner_admin {
return Err(forbidden(
"Kun owner/admin av samlingen kan endre status på innsendte artikler",
));
}
}
}
// -- Validering av source_material-metadata ved oppdatering --
if edge_type == "source_material" {
if let Some(obj) = metadata.as_object() {
match obj.get("context").and_then(|v| v.as_str()) {
Some("quoted" | "summarized" | "referenced") => {}
Some(other) => {
return Err(bad_request(&format!(
"source_material context må være 'quoted', 'summarized' eller 'referenced', fikk '{other}'"
)));
}
None => {
return Err(bad_request(
"source_material krever 'context'-felt (quoted, summarized eller referenced)",
));
}
}
match obj.get("excerpt").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => {}
_ => {
return Err(bad_request(
"source_material krever et ikke-tomt 'excerpt'-felt",
));
}
}
} else {
return Err(bad_request(
"source_material krever metadata med 'context' og 'excerpt'",
));
}
}
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
sqlx::query("UPDATE edges SET edge_type = $1, metadata = $2 WHERE id = $3")
.bind(&edge_type)
.bind(&metadata)
.bind(req.edge_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved oppdatering av edge: {e}");
internal_error("Databasefeil ved oppdatering av edge")
})?;
tracing::info!(
edge_id = %req.edge_id,
edge_type = %edge_type,
updated_by = %user.node_id,
"Edge oppdatert"
);
Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id }))
}
// =============================================================================
// delete_edge
// =============================================================================
#[derive(Deserialize)]
pub struct DeleteEdgeRequest {
/// ID til edgen som skal slettes.
pub edge_id: Uuid,
}
#[derive(Serialize)]
pub struct DeleteEdgeResponse {
pub deleted: bool,
}
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct FullEdgeRow {
source_id: Uuid,
target_id: Uuid,
edge_type: String,
}
/// POST /intentions/delete_edge
///
/// Sletter en edge. Brukes bl.a. for avpublisering (fjerner belongs_to-edge).
/// Krever at brukeren har opprettet edgen, eller har owner/admin-edge
/// til source-noden.
pub async fn delete_edge(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<DeleteEdgeRequest>,
) -> Result<Json<DeleteEdgeResponse>, (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 å slette denne edgen"));
}
// Hent edge-info for logging og publiserings-invalidering
let edge_info = sqlx::query_as::<_, FullEdgeRow>(
"SELECT source_id, target_id, edge_type 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)))?;
// -- Slett fra PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
sqlx::query("DELETE FROM edges WHERE id = $1")
.bind(req.edge_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved sletting av edge: {e}");
internal_error("Databasefeil ved sletting av edge")
})?;
tracing::info!(
edge_id = %req.edge_id,
edge_type = %edge_info.edge_type,
deleted_by = %user.node_id,
"Edge slettet"
);
// Invalider publiserings-cache ved fjerning av belongs_to
if edge_info.edge_type == "belongs_to" {
if let Ok(Some(config)) = crate::publishing::find_publishing_collection_by_id(&state.db, edge_info.target_id).await {
let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
if index_mode == "static" {
let index_payload = serde_json::json!({ "collection_id": edge_info.target_id.to_string() });
let _ = crate::jobs::enqueue(&state.db, "render_index", index_payload, Some(edge_info.target_id), 4).await;
} else {
crate::publishing::invalidate_index_cache(&state.index_cache, edge_info.target_id).await;
}
}
}
Ok(Json(DeleteEdgeResponse { deleted: true }))
}
// =============================================================================
// 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, *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, *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 PG (NOTIFY-trigger sender sanntidsoppdatering)
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,
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");
}
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>,
/// Kontekst-node (f.eks. artikkel). Gir automatisk belongs_to-edge
/// fra kommunikasjonsnoden til kontekstnoden.
pub context_id: Option<Uuid>,
}
#[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() });
// -- Opprett kommunikasjonsnoden --
let node_id = Uuid::now_v7();
sqlx::query(
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'communication', NULLIF($2, ''), '', $3::visibility, $4, $5)"#,
)
.bind(node_id)
.bind(&title)
.bind(&visibility)
.bind(&metadata)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved opprettelse av kommunikasjonsnode: {e}");
internal_error("Databasefeil ved opprettelse av kommunikasjonsnode")
})?;
tracing::info!(
node_id = %node_id,
created_by = %user.node_id,
participants = ?req.participants,
"Kommunikasjonsnode opprettet"
);
// -- Opprett deltaker-edges --
let mut edge_ids = Vec::new();
// Owner-edge for innlogget bruker (med recompute_access)
let owner_edge_id = Uuid::now_v7();
edge_ids.push(owner_edge_id);
{
let mut tx = state.db.begin().await.map_err(|e| {
tracing::error!("PG begin: {e}");
internal_error("Databasefeil")
})?;
sqlx::query(
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'owner', '{}', false, $4)"#,
)
.bind(owner_edge_id)
.bind(user.node_id)
.bind(node_id)
.bind(user.node_id)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("PG insert owner edge: {e}");
internal_error("Databasefeil ved opprettelse av owner-edge")
})?;
sqlx::query("SELECT recompute_access($1, $2, 'owner'::access_level, $3)")
.bind(user.node_id)
.bind(node_id)
.bind(owner_edge_id)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("recompute_access: {e}");
internal_error("Databasefeil ved tilgangsberegning")
})?;
tx.commit().await.map_err(|e| {
tracing::error!("PG commit: {e}");
internal_error("Databasefeil")
})?;
}
// 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 mut tx = state.db.begin().await.map_err(|e| {
tracing::error!("PG begin: {e}");
internal_error("Databasefeil")
})?;
sqlx::query(
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'member_of', '{}', false, $4)"#,
)
.bind(edge_id)
.bind(*participant_id)
.bind(node_id)
.bind(user.node_id)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("PG insert member_of edge: {e}");
internal_error("Databasefeil ved opprettelse av member_of-edge")
})?;
sqlx::query("SELECT recompute_access($1, $2, 'member'::access_level, $3)")
.bind(*participant_id)
.bind(node_id)
.bind(edge_id)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("recompute_access: {e}");
internal_error("Databasefeil ved tilgangsberegning")
})?;
tx.commit().await.map_err(|e| {
tracing::error!("PG commit: {e}");
internal_error("Databasefeil")
})?;
}
// -- Opprett belongs_to-edge til kontekstnode (f.eks. artikkel) --
if let Some(context_id) = req.context_id {
let ctx_exists = node_exists(&state.db, context_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved kontekstnode-sjekk: {e}");
internal_error("Databasefeil ved validering")
})?;
if !ctx_exists {
return Err(bad_request(&format!(
"Kontekst-node {} finnes ikke",
context_id
)));
}
let ctx_edge_id = Uuid::now_v7();
edge_ids.push(ctx_edge_id);
sqlx::query(
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'belongs_to', '{}', false, $4)"#,
)
.bind(ctx_edge_id)
.bind(node_id)
.bind(context_id)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG insert belongs_to edge: {e}");
internal_error("Databasefeil ved opprettelse av edge")
})?;
tracing::info!(
communication_id = %node_id,
context_id = %context_id,
"belongs_to-edge opprettet til kontekstnode"
);
// Propager tilgang fra kontekstnoden
if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
.bind(node_id)
.bind(context_id)
.execute(&state.db)
.await
{
tracing::error!("propagate_belongs_to_access feilet: {e}");
}
}
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 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,
});
// Skriv media-node til PG
sqlx::query(
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'media', NULLIF($2, ''), '', $3::visibility, $4, $5)"#,
)
.bind(media_node_id)
.bind(&node_title)
.bind(&visibility)
.bind(&metadata)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved opprettelse av media-node: {e}");
internal_error("Databasefeil ved opprettelse av media-node")
})?;
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"
);
// -- 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();
sqlx::query(
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'has_media', '{}', false, $4)"#,
)
.bind(edge_id)
.bind(src_id)
.bind(media_node_id)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG insert has_media edge: {e}");
internal_error("Databasefeil ved opprettelse av has_media-edge")
})?;
tracing::info!(
edge_id = %edge_id,
source_id = %src_id,
media_node_id = %media_node_id,
"has_media-edge opprettet"
);
Some(edge_id)
} else {
None
};
// -- Logg CAS-ressursforbruk (kun nye filer, ikke dedup) --
if !cas_result.already_existed {
let cas_collection_id = if let Some(src_id) = source_id {
crate::resource_usage::find_collection_for_node(&state.db, src_id).await
} else {
None
};
if let Err(e) = crate::resource_usage::log(
&state.db,
media_node_id,
Some(user.node_id),
cas_collection_id,
"cas",
serde_json::json!({
"hash": cas_result.hash,
"size_bytes": cas_result.size,
"mime": mime,
"operation": "store"
}),
)
.await
{
tracing::warn!(error = %e, "Kunne ikke logge CAS-ressursforbruk");
}
}
// -- 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();
// -- Skriv alias-node og alias-edge til PG --
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(&title)
.bind(&metadata)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved opprettelse av alias-node: {e}");
internal_error("Databasefeil ved opprettelse av alias")
})?;
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(user.node_id)
.bind(alias_node_id)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved opprettelse av alias-edge: {e}");
internal_error("Databasefeil ved opprettelse av alias-edge")
})?;
tracing::info!(
alias_node_id = %alias_node_id,
alias_edge_id = %alias_edge_id,
user_node_id = %user.node_id,
title = %title,
"Alias opprettet"
);
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,
}
/// 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"
);
}
}
}
// =============================================================================
// 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/ai_process — AI-prosessering via AI Gateway
// =============================================================================
#[derive(Deserialize)]
pub struct AiProcessRequest {
/// Kilde-noden som skal prosesseres.
pub source_node_id: Uuid,
/// AI-preset som definerer prompt og modellprofil.
pub ai_preset_id: Uuid,
/// Retning: "node_to_tool" (opprett ny node) eller "tool_to_node" (modifiser in-place).
pub direction: String,
}
#[derive(Serialize)]
pub struct AiProcessResponse {
pub job_id: Uuid,
}
/// POST /intentions/ai_process
///
/// Legger en `ai_process`-jobb i køen.
/// AI-prosesseringen skjer asynkront — kilde-content sendes til AI Gateway
/// med preset-prompt, og forbruk logges i ai_usage_log.
///
/// Direction-logikk (opprett ny node vs. oppdater eksisterende) implementeres
/// i oppgave 18.3.
///
/// Ref: docs/features/ai_verktoy.md § 6.1
pub async fn ai_process(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<AiProcessRequest>,
) -> Result<Json<AiProcessResponse>, (StatusCode, Json<ErrorResponse>)> {
// Valider direction
if req.direction != "node_to_tool" && req.direction != "tool_to_node" {
return Err(bad_request(
"direction må være 'node_to_tool' eller 'tool_to_node'",
));
}
// Sjekk at kilde-noden finnes
let source_exists: bool = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
)
.bind(req.source_node_id)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG-feil ved kilde-node-sjekk");
internal_error("Databasefeil")
})?;
if !source_exists {
return Err(bad_request("Kilde-node finnes ikke"));
}
// Sjekk at AI-preset finnes
let preset_exists: bool = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'ai_preset')",
)
.bind(req.ai_preset_id)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG-feil ved preset-sjekk");
internal_error("Databasefeil")
})?;
if !preset_exists {
return Err(bad_request("AI-preset finnes ikke"));
}
// For tool_to_node-retning trengs skrivetilgang til kilde-noden
if req.direction == "tool_to_node" {
let can_modify = user_can_modify_node(&state.db, user.node_id, req.source_node_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG-feil ved tilgangssjekk");
internal_error("Databasefeil")
})?;
if !can_modify {
return Err(forbidden(
"Ingen tilgang til å endre kilde-noden (tool_to_node krever skrivetilgang)",
));
}
}
// Finn samlings-ID for kilde-noden (for prioritering)
let collection_id = crate::resource_usage::find_collection_for_node(
&state.db,
req.source_node_id,
)
.await;
let payload = serde_json::json!({
"source_node_id": req.source_node_id.to_string(),
"ai_preset_id": req.ai_preset_id.to_string(),
"direction": req.direction,
"requested_by": user.node_id.to_string()
});
let job_id = crate::jobs::enqueue(
&state.db,
"ai_process",
payload,
collection_id,
5, // Medium prioritet
)
.await
.map_err(|e| {
tracing::error!(error = %e, "Kunne ikke legge ai_process-jobb i kø");
internal_error("Kunne ikke starte AI-prosessering")
})?;
tracing::info!(
job_id = %job_id,
source_node_id = %req.source_node_id,
ai_preset_id = %req.ai_preset_id,
direction = %req.direction,
user = %user.node_id,
"ai_process-jobb lagt i kø"
);
Ok(Json(AiProcessResponse { job_id }))
}
// =============================================================================
// POST /intentions/create_ai_preset — opprett egendefinert AI-preset
// =============================================================================
#[derive(Deserialize)]
pub struct CreateAiPresetRequest {
/// Visningstittel for presetet.
pub title: String,
/// Systemprompt som brukes ved AI-prosessering.
pub prompt: String,
/// Standard retning: "node_to_tool", "tool_to_node" eller "both".
pub default_direction: String,
/// Ikon-nøkkel (f.eks. "sparkles", "pencil_square").
pub icon: String,
/// Hex-farge (#RRGGBB).
pub color: String,
/// Valgfri: samlings-ID å dele presetet med (oppretter shared_with-edge).
pub share_with_collection_id: Option<Uuid>,
}
#[derive(Serialize)]
pub struct CreateAiPresetResponse {
pub node_id: Uuid,
/// Edge-ID for shared_with-edge (kun når share_with_collection_id er satt).
#[serde(skip_serializing_if = "Option::is_none")]
pub shared_edge_id: Option<Uuid>,
}
/// POST /intentions/create_ai_preset
///
/// Oppretter en egendefinert AI-preset-node. Setter alltid `category: "custom"`
/// og `model_profile: "flash"`. Kun admin kan endre modellprofil etterpå
/// (via update_node).
///
/// Kan valgfritt dele med en samling via `share_with_collection_id`.
///
/// Ref: docs/features/ai_verktoy.md § "Fase C: Egendefinerte prompter"
pub async fn create_ai_preset(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateAiPresetRequest>,
) -> Result<Json<CreateAiPresetResponse>, (StatusCode, Json<ErrorResponse>)> {
// Valider input
if req.title.trim().is_empty() {
return Err(bad_request("Tittel kan ikke være tom"));
}
if req.prompt.trim().is_empty() {
return Err(bad_request("Prompt kan ikke være tom"));
}
// Bygg metadata — alltid custom kategori og flash modellprofil
let metadata = serde_json::json!({
"prompt": req.prompt.trim(),
"model_profile": "flash",
"category": "custom",
"default_direction": req.default_direction,
"icon": req.icon,
"color": req.color
});
// Valider metadata med eksisterende validator
validate_ai_preset_metadata("ai_preset", &metadata).map_err(|e| bad_request(&e))?;
// Valider share_with_collection_id hvis satt
if let Some(col_id) = req.share_with_collection_id {
let col_exists: bool = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'collection')",
)
.bind(col_id)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG-feil ved sjekk av samling");
internal_error("Databasefeil")
})?;
if !col_exists {
return Err(bad_request("Samlingen finnes ikke"));
}
}
let node_id = Uuid::now_v7();
// Skriv til PostgreSQL
sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'ai_preset', $2, '', 'discoverable'::visibility, $3, $4)
"#,
)
.bind(node_id)
.bind(req.title.trim())
.bind(&metadata)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG insert ai_preset feilet");
internal_error("Kunne ikke opprette AI-preset")
})?;
// Opprett shared_with-edge hvis samlings-ID er satt
let shared_edge_id = if let Some(col_id) = req.share_with_collection_id {
let edge_id = Uuid::now_v7();
sqlx::query(
r#"
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'shared_with', '{}', false, $4)
"#,
)
.bind(edge_id)
.bind(node_id)
.bind(col_id)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG insert shared_with-edge feilet");
internal_error("Kunne ikke dele AI-preset med samling")
})?;
tracing::info!(
preset_id = %node_id,
collection_id = %col_id,
edge_id = %edge_id,
"AI-preset delt med samling"
);
Some(edge_id)
} else {
None
};
tracing::info!(
node_id = %node_id,
title = %req.title,
user = %user.node_id,
shared = ?req.share_with_collection_id,
"Egendefinert AI-preset opprettet"
);
Ok(Json(CreateAiPresetResponse {
node_id,
shared_edge_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 PG 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;
// 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());
// Blokkér nye LiveKit-rom under vedlikehold (oppgave 15.2)
if state.maintenance.is_active() {
return Err(bad_request("Nye rom er blokkert — vedlikehold pågår"));
}
// 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 kommunikasjonsnodens metadata med live_status (asynkront)
let db = state.db.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}");
}
}
});
// 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"
);
// Logg LiveKit-ressursforbruk (registrering av deltaker-join)
let lk_collection_id = crate::resource_usage::find_collection_for_node(&state.db, comm_id).await;
if let Err(e) = crate::resource_usage::log(
&state.db,
comm_id,
Some(user.node_id),
lk_collection_id,
"livekit",
serde_json::json!({
"room_id": room_name,
"participant_minutes": 0,
"tracks": 0,
"event": "join"
}),
)
.await
{
tracing::warn!(error = %e, "Kunne ikke logge LiveKit-ressursforbruk");
}
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.
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}");
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.
/// 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"));
}
// Oppdater metadata i PG (NOTIFY-trigger sender sanntidsoppdatering)
let db = state.db.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;
}
});
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))
}
// =============================================================================
// Systemvarsler (oppgave 15.1)
// =============================================================================
/// Gyldige varslingstyper for system_announcement-noder.
const VALID_ANNOUNCEMENT_TYPES: &[&str] = &["info", "warning", "critical"];
#[derive(Deserialize)]
pub struct CreateAnnouncementRequest {
/// Tittel på varselet.
pub title: String,
/// Innhold/meldingstekst.
pub content: String,
/// Type varsel: info, warning, critical.
pub announcement_type: String,
/// Tidspunkt varselet gjelder (f.eks. vedlikeholdstidspunkt). Valgfritt.
pub scheduled_at: Option<String>,
/// Når varselet automatisk utløper (ISO 8601). Valgfritt.
pub expires_at: Option<String>,
/// Om nye sesjoner skal blokkeres (vedlikeholdsmodus). Default: false.
pub blocks_new_sessions: Option<bool>,
}
#[derive(Serialize)]
pub struct CreateAnnouncementResponse {
pub node_id: Uuid,
}
/// POST /intentions/create_announcement
///
/// Oppretter en systemvarslingsnode med `visibility: open` slik at alle
/// aktive klienter ser varselet via WebSocket umiddelbart.
///
/// Kun autentiserte brukere kan opprette varsler (MVP — full admin-sjekk
/// legges til når admin-rollesystemet er på plass).
///
/// Ref: docs/concepts/adminpanelet.md § "Systemvarsler og vedlikeholdsmodus"
pub async fn create_announcement(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateAnnouncementRequest>,
) -> Result<Json<CreateAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Valider announcement_type --
if !VALID_ANNOUNCEMENT_TYPES.contains(&req.announcement_type.as_str()) {
return Err(bad_request(&format!(
"Ugyldig announcement_type: '{}'. Gyldige verdier: {:?}",
req.announcement_type, VALID_ANNOUNCEMENT_TYPES
)));
}
if req.title.trim().is_empty() {
return Err(bad_request("title kan ikke være tom"));
}
// -- Valider datoer hvis satt --
if let Some(ref s) = req.scheduled_at {
chrono::DateTime::parse_from_rfc3339(s)
.map_err(|_| bad_request("scheduled_at må være gyldig ISO 8601 (RFC 3339)"))?;
}
if let Some(ref s) = req.expires_at {
chrono::DateTime::parse_from_rfc3339(s)
.map_err(|_| bad_request("expires_at må være gyldig ISO 8601 (RFC 3339)"))?;
}
// -- Bygg metadata --
let metadata = serde_json::json!({
"announcement_type": req.announcement_type,
"scheduled_at": req.scheduled_at,
"expires_at": req.expires_at,
"blocks_new_sessions": req.blocks_new_sessions.unwrap_or(false),
});
let node_id = Uuid::now_v7();
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
sqlx::query(
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'system_announcement', $2, $3, 'open'::visibility, $4, $5)"#,
)
.bind(node_id)
.bind(&req.title)
.bind(&req.content)
.bind(&metadata)
.bind(user.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved opprettelse av systemvarsel: {e}");
internal_error("Databasefeil ved opprettelse av systemvarsel")
})?;
tracing::info!(
node_id = %node_id,
announcement_type = %req.announcement_type,
created_by = %user.node_id,
"Systemvarsel opprettet"
);
Ok(Json(CreateAnnouncementResponse { node_id }))
}
#[derive(Deserialize)]
pub struct ExpireAnnouncementRequest {
/// ID-en til varslingsnoden som skal utløpe/fjernes.
pub node_id: Uuid,
}
#[derive(Serialize)]
pub struct ExpireAnnouncementResponse {
pub expired: bool,
}
/// POST /intentions/expire_announcement
///
/// Fjerner (sletter) en systemvarslingsnode fra PG.
/// WebSocket NOTIFY sørger for umiddelbar fjerning fra alle klienter.
///
/// Kun eier (created_by) eller admin kan fjerne varsler.
pub async fn expire_announcement(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<ExpireAnnouncementRequest>,
) -> Result<Json<ExpireAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Sjekk at noden eksisterer og er en system_announcement --
let node = sqlx::query_as::<_, NodeRow>(
"SELECT node_kind, title, content, visibility, metadata FROM nodes WHERE id = $1",
)
.bind(req.node_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved oppslag: {e}");
internal_error("Databasefeil")
})?
.ok_or_else(|| bad_request("Noden finnes ikke"))?;
if node.node_kind != "system_announcement" {
return Err(bad_request("Noden er ikke en systemvarslingsnode"));
}
// -- 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("Kun eier kan fjerne systemvarsler"));
}
// -- Slett fra PG (NOTIFY-trigger sender sanntidsoppdatering) --
sqlx::query("DELETE FROM nodes WHERE id = $1")
.bind(req.node_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved sletting av varsel: {e}");
internal_error("Databasefeil ved sletting av varsel")
})?;
tracing::info!(node_id = %req.node_id, "Systemvarsel slettet");
Ok(Json(ExpireAnnouncementResponse { expired: true }))
}
// =============================================================================
// Vedlikeholdsmodus (oppgave 15.2)
// =============================================================================
#[derive(Deserialize)]
pub struct InitiateMaintenanceRequest {
/// Vedlikeholdstidspunkt (ISO 8601 / RFC 3339).
pub scheduled_at: String,
}
#[derive(Serialize)]
pub struct InitiateMaintenanceResponse {
pub announcement_node_id: Uuid,
pub scheduled_at: String,
}
/// POST /intentions/initiate_maintenance
///
/// Starter nedtellingen til vedlikehold. Oppretter et critical-varsel
/// som vises for alle klienter, og starter bakgrunnskoordinatoren som
/// blokkerer nye jobber/LiveKit-rom og til slutt restarter prosessen.
///
/// Kall GET /admin/maintenance_status først for å se aktive sesjoner.
pub async fn initiate_maintenance(
State(state): State<AppState>,
admin: AdminUser,
Json(req): Json<InitiateMaintenanceRequest>,
) -> Result<Json<InitiateMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> {
let scheduled_at = chrono::DateTime::parse_from_rfc3339(&req.scheduled_at)
.map_err(|_| bad_request("scheduled_at må være gyldig ISO 8601 (RFC 3339)"))?
.with_timezone(&chrono::Utc);
if scheduled_at < chrono::Utc::now() {
return Err(bad_request("scheduled_at kan ikke være i fortiden"));
}
let user_id = admin.node_id;
let announcement_id = state
.maintenance
.initiate(&state.db, scheduled_at, user_id)
.await
.map_err(|e| bad_request(&e))?;
Ok(Json(InitiateMaintenanceResponse {
announcement_node_id: announcement_id,
scheduled_at: scheduled_at.to_rfc3339(),
}))
}
#[derive(Serialize)]
pub struct CancelMaintenanceResponse {
pub cancelled: bool,
}
/// POST /intentions/cancel_maintenance
///
/// Avbryter planlagt vedlikehold. Fjerner varselet og stopper
/// nedtellingstasken.
pub async fn cancel_maintenance(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<CancelMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> {
state
.maintenance
.cancel(&state.db)
.await
.map_err(|e| bad_request(&e))?;
Ok(Json(CancelMaintenanceResponse { cancelled: true }))
}
/// GET /admin/maintenance_status
///
/// Returnerer vedlikeholdsstatus inkludert kjørende jobber.
/// Brukes av admin-panelet for å vise aktive sesjoner før bekreftelse.
pub async fn maintenance_status(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<crate::maintenance::MaintenanceStatus>, (StatusCode, Json<ErrorResponse>)> {
let status = state
.maintenance
.status(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved henting av vedlikeholdsstatus: {e}")))?;
Ok(Json(status))
}
// =============================================================================
// Jobbkø-oversikt (oppgave 15.3)
// =============================================================================
/// GET /admin/jobs
///
/// Hent jobbliste med valgfrie filtre: status, type, collection_id.
/// Query-params: ?status=error&type=whisper_transcribe&collection_id=...&limit=50&offset=0
pub async fn list_jobs(
State(state): State<AppState>,
_admin: AdminUser,
axum::extract::Query(params): axum::extract::Query<ListJobsParams>,
) -> Result<Json<ListJobsResponse>, (StatusCode, Json<ErrorResponse>)> {
let limit = params.limit.unwrap_or(50).min(200);
let offset = params.offset.unwrap_or(0);
let jobs = crate::jobs::list_jobs(
&state.db,
params.status.as_deref(),
params.r#type.as_deref(),
params.collection_id,
limit,
offset,
)
.await
.map_err(|e| internal_error(&format!("Feil ved henting av jobber: {e}")))?;
let counts = crate::jobs::count_by_status(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved telling av jobber: {e}")))?;
let job_types = crate::jobs::distinct_job_types(&state.db)
.await
.map_err(|e| internal_error(&format!("Feil ved henting av jobbtyper: {e}")))?;
Ok(Json(ListJobsResponse { jobs, counts, job_types }))
}
#[derive(Deserialize)]
pub struct ListJobsParams {
pub status: Option<String>,
pub r#type: Option<String>,
pub collection_id: Option<Uuid>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Serialize)]
pub struct ListJobsResponse {
pub jobs: Vec<crate::jobs::JobDetail>,
pub counts: Vec<crate::jobs::JobCountByStatus>,
pub job_types: Vec<String>,
}
/// POST /intentions/retry_job
///
/// Sett en feilet jobb tilbake til 'pending' for nytt forsøk.
pub async fn retry_job(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<JobIdRequest>,
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
let retried = crate::jobs::retry_job(&state.db, req.job_id)
.await
.map_err(|e| internal_error(&format!("Feil ved retry: {e}")))?;
if !retried {
return Err(bad_request("Jobben finnes ikke eller har feil status for retry"));
}
tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb restartet");
Ok(Json(JobActionResponse { success: true }))
}
/// POST /intentions/cancel_job
///
/// Avbryt en ventende jobb.
pub async fn cancel_job(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<JobIdRequest>,
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
let cancelled = crate::jobs::cancel_job(&state.db, req.job_id)
.await
.map_err(|e| internal_error(&format!("Feil ved avbryt: {e}")))?;
if !cancelled {
return Err(bad_request("Jobben finnes ikke eller har feil status for avbryt"));
}
tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb avbrutt");
Ok(Json(JobActionResponse { success: true }))
}
#[derive(Deserialize)]
pub struct JobIdRequest {
pub job_id: Uuid,
}
#[derive(Serialize)]
pub struct JobActionResponse {
pub success: bool,
}
// =============================================================================
// Ressursstyring: admin-endepunkter (oppgave 15.5)
// =============================================================================
/// GET /admin/resources — samlet ressursstatus.
pub async fn resource_status(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<crate::resources::ResourceStatus>, (StatusCode, Json<ErrorResponse>)> {
let cas_root = std::env::var("CAS_ROOT")
.unwrap_or_else(|_| "/srv/synops/media/cas".to_string());
let disk = crate::resources::check_disk_usage(&cas_root)
.unwrap_or_else(|_| crate::resources::check_disk_usage("/").unwrap_or(
crate::resources::DiskStatus {
mount_point: "/".to_string(),
total_bytes: 0,
used_bytes: 0,
available_bytes: 0,
usage_percent: 0.0,
alert_level: None,
}
));
let livekit_active = crate::resources::has_active_livekit_rooms(&state.db).await;
let total_weight = crate::resources::total_running_weight(&state.db, &state.priority_rules).await;
let rules = state.priority_rules.all().await;
let running = crate::resources::running_jobs_by_type(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
Ok(Json(crate::resources::ResourceStatus {
disk,
livekit_active,
total_running_weight: total_weight,
max_weight: 8, // MAX_TOTAL_WEIGHT fra jobs.rs
priority_rules: rules,
running_jobs_by_type: running,
}))
}
/// GET /admin/resources/disk — disk-status med historikk.
pub async fn resource_disk(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<DiskOverview>, (StatusCode, Json<ErrorResponse>)> {
let current = crate::resources::latest_disk_status(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
let history = crate::resources::disk_status_history(&state.db, 60)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
Ok(Json(DiskOverview { current, history }))
}
#[derive(Serialize)]
pub struct DiskOverview {
pub current: Option<crate::resources::DiskStatus>,
pub history: Vec<crate::resources::DiskStatusHistoryRow>,
}
/// POST /admin/resources/update_rule — oppdater en prioritetsregel.
pub async fn update_priority_rule(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<UpdatePriorityRuleRequest>,
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
sqlx::query(
r#"INSERT INTO job_priority_rules (job_type, base_priority, livekit_priority_adj, cpu_weight, max_concurrent, timeout_seconds, block_during_livekit)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (job_type) DO UPDATE SET
base_priority = EXCLUDED.base_priority,
livekit_priority_adj = EXCLUDED.livekit_priority_adj,
cpu_weight = EXCLUDED.cpu_weight,
max_concurrent = EXCLUDED.max_concurrent,
timeout_seconds = EXCLUDED.timeout_seconds,
block_during_livekit = EXCLUDED.block_during_livekit"#,
)
.bind(&req.job_type)
.bind(req.base_priority)
.bind(req.livekit_priority_adj)
.bind(req.cpu_weight)
.bind(req.max_concurrent)
.bind(req.timeout_seconds)
.bind(req.block_during_livekit)
.execute(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
// Oppdater cache
if let Err(e) = state.priority_rules.refresh(&state.db).await {
tracing::warn!(error = %e, "Kunne ikke refreshe prioritetsregler-cache");
}
tracing::info!(
job_type = %req.job_type,
user = %_admin.node_id,
"Admin: prioritetsregel oppdatert"
);
Ok(Json(JobActionResponse { success: true }))
}
#[derive(Deserialize)]
pub struct UpdatePriorityRuleRequest {
pub job_type: String,
pub base_priority: i16,
pub livekit_priority_adj: i16,
pub cpu_weight: i16,
pub max_concurrent: i16,
pub timeout_seconds: i32,
pub block_during_livekit: bool,
}
// =============================================================================
// Orkestrering: kompilering og testkjøring (oppgave 24.6)
// =============================================================================
#[derive(Deserialize)]
pub struct CompileScriptRequest {
pub script: String,
}
/// Kompiler et orkestreringsscript og returner diagnostikk + kompilert resultat.
/// Brukes av frontend-editoren for sanntids kompileringsfeedback.
pub async fn compile_script(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<CompileScriptRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
use crate::script_compiler;
let result = script_compiler::compile_script(&state.db, &req.script)
.await
.map_err(|e| bad_request(&e))?;
// Serialiser CompileResult til JSON
let json = serde_json::to_value(&result)
.map_err(|e| internal_error(&format!("Serialiseringsfeil: {e}")))?;
Ok(Json(json))
}
#[derive(Deserialize)]
pub struct TestOrchestrationRequest {
pub orchestration_id: Uuid,
}
#[derive(Serialize)]
pub struct TestOrchestrationResponse {
pub job_id: String,
}
/// Kjør en orkestrering manuelt (testkjøring).
/// Oppretter en "orchestrate"-jobb med trigger_event = "manual".
pub async fn test_orchestration(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<TestOrchestrationRequest>,
) -> Result<Json<TestOrchestrationResponse>, (StatusCode, Json<ErrorResponse>)> {
// Verifiser at noden er en orchestration-node
let node = sqlx::query_as::<_, (String, Option<String>)>(
"SELECT node_kind, content FROM nodes WHERE id = $1",
)
.bind(req.orchestration_id)
.fetch_optional(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?
.ok_or_else(|| bad_request("Orkestreringsnode ikke funnet"))?;
if node.0 != "orchestration" {
return Err(bad_request("Noden er ikke en orchestration-node"));
}
// Opprett en jobb i køen
let job_id = sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO job_queue (job_type, collection_node_id, payload, status, priority)
VALUES ('orchestrate', NULL, $1, 'pending', 5)
RETURNING id
"#,
)
.bind(serde_json::json!({
"orchestration_id": req.orchestration_id.to_string(),
"trigger_event": "manual",
"trigger_context": {},
"test_run": true,
"initiated_by": user.node_id.to_string(),
}))
.fetch_one(&state.db)
.await
.map_err(|e| internal_error(&format!("Kunne ikke opprette testjobb: {e}")))?;
tracing::info!(
orchestration_id = %req.orchestration_id,
job_id = %job_id,
user = %user.node_id,
"Manuell testkjøring av orkestrering startet"
);
Ok(Json(TestOrchestrationResponse {
job_id: job_id.to_string(),
}))
}
#[derive(Deserialize)]
pub struct OrchestrationLogParams {
pub orchestration_id: Uuid,
pub limit: Option<i64>,
}
#[derive(Serialize)]
pub struct OrchestrationLogEntry {
pub id: String,
pub job_id: Option<String>,
pub step_number: i16,
pub tool_binary: String,
pub args: serde_json::Value,
pub is_fallback: bool,
pub status: String,
pub exit_code: Option<i16>,
pub error_msg: Option<String>,
pub duration_ms: Option<i32>,
pub created_at: String,
}
#[derive(Serialize)]
pub struct OrchestrationLogResponse {
pub entries: Vec<OrchestrationLogEntry>,
}
/// Hent kjørehistorikk for en orkestrering.
pub async fn orchestration_log(
State(state): State<AppState>,
_user: AuthUser,
axum::extract::Query(params): axum::extract::Query<OrchestrationLogParams>,
) -> Result<Json<OrchestrationLogResponse>, (StatusCode, Json<ErrorResponse>)> {
let limit = params.limit.unwrap_or(50).min(200);
let rows = sqlx::query_as::<_, (
Uuid,
Option<Uuid>,
i16,
String,
serde_json::Value,
bool,
String,
Option<i16>,
Option<String>,
Option<i32>,
chrono::DateTime<chrono::Utc>,
)>(
r#"
SELECT id, job_id, step_number, tool_binary, args, is_fallback,
status, exit_code, error_msg, duration_ms, created_at
FROM orchestration_log
WHERE orchestration_id = $1
ORDER BY created_at DESC
LIMIT $2
"#,
)
.bind(params.orchestration_id)
.bind(limit)
.fetch_all(&state.db)
.await
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
let entries = rows
.into_iter()
.map(|r| OrchestrationLogEntry {
id: r.0.to_string(),
job_id: r.1.map(|u| u.to_string()),
step_number: r.2,
tool_binary: r.3,
args: r.4,
is_fallback: r.5,
status: r.6,
exit_code: r.7,
error_msg: r.8,
duration_ms: r.9,
created_at: r.10.to_rfc3339(),
})
.collect();
Ok(Json(OrchestrationLogResponse { entries }))
}
// =============================================================================
// AI-assistert script-generering (oppgave 24.7)
// =============================================================================
#[derive(Deserialize)]
pub struct AiSuggestScriptRequest {
/// Fritekst-beskrivelse av ønsket orkestrering
pub description: String,
/// Trigger-event (valgfri, f.eks. "communication.ended")
pub trigger_event: Option<String>,
/// Trigger-betingelser som JSON (valgfri)
pub trigger_conditions: Option<serde_json::Value>,
/// Eventually-modus: lagre som work_item i stedet for synkront LLM-kall
#[serde(default)]
pub eventually: bool,
/// Samlings-ID å knytte til (valgfri)
pub collection_id: Option<Uuid>,
}
#[derive(Serialize)]
pub struct AiSuggestScriptResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compile_result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub work_item_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
/// AI-assistert generering av orkestreringsscript.
///
/// Kaller `synops-ai` CLI-verktøyet for å generere et script fra
/// fritekst-beskrivelse, og validerer resultatet via script-kompilatoren.
/// I eventually-modus lagres forespørselen som work_item for Claude Code.
///
/// Ref: docs/concepts/orkestrering.md § "Nivå 2: AI-assistert oppretting"
pub async fn ai_suggest_script(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<AiSuggestScriptRequest>,
) -> Result<Json<AiSuggestScriptResponse>, (StatusCode, Json<ErrorResponse>)> {
use crate::cli_dispatch;
use crate::script_compiler;
if req.description.trim().is_empty() {
return Err(bad_request("description kan ikke være tom"));
}
// Eventually-modus: kall synops-ai --eventually
if req.eventually {
let mut cmd = tokio::process::Command::new("synops-ai");
cmd.arg("--description").arg(&req.description);
cmd.arg("--eventually");
cmd.arg("--requested-by").arg(user.node_id.to_string());
if let Some(ref event) = req.trigger_event {
cmd.arg("--trigger-event").arg(event);
}
if let Some(ref conditions) = req.trigger_conditions {
cmd.arg("--trigger-conditions").arg(conditions.to_string());
}
if let Some(ref coll_id) = req.collection_id {
cmd.arg("--collection-id").arg(coll_id.to_string());
}
cli_dispatch::set_database_url(&mut cmd)
.map_err(|e| internal_error(&e))?;
cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
cli_dispatch::forward_env(&mut cmd, "AI_SCRIPT_MODEL");
let result = cli_dispatch::run_cli_tool("synops-ai", &mut cmd)
.await
.map_err(|e| internal_error(&format!("synops-ai feilet: {e}")))?;
let work_item_id = result.get("work_item_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
return Ok(Json(AiSuggestScriptResponse {
status: "deferred".to_string(),
script: None,
compile_result: None,
work_item_id,
message: Some("Forespørselen er lagret. Scriptet genereres i bakgrunnen.".to_string()),
}));
}
// Synkron modus: kall synops-ai for generering
let mut cmd = tokio::process::Command::new("synops-ai");
cmd.arg("--description").arg(&req.description);
if let Some(ref event) = req.trigger_event {
cmd.arg("--trigger-event").arg(event);
}
if let Some(ref conditions) = req.trigger_conditions {
cmd.arg("--trigger-conditions").arg(conditions.to_string());
}
if let Some(ref coll_id) = req.collection_id {
cmd.arg("--collection-id").arg(coll_id.to_string());
}
cmd.arg("--requested-by").arg(user.node_id.to_string());
cli_dispatch::set_database_url(&mut cmd)
.map_err(|e| internal_error(&e))?;
cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
cli_dispatch::forward_env(&mut cmd, "AI_SCRIPT_MODEL");
let ai_result = cli_dispatch::run_cli_tool("synops-ai", &mut cmd)
.await
.map_err(|e| internal_error(&format!("synops-ai feilet: {e}")))?;
let generated_script = ai_result
.get("script")
.and_then(|v| v.as_str())
.ok_or_else(|| internal_error("synops-ai returnerte ingen script"))?
.to_string();
// Valider scriptet med kompilatoren
let compile_result = script_compiler::compile_script(&state.db, &generated_script)
.await
.map_err(|e| internal_error(&format!("Kompileringsfeil: {e}")))?;
let compile_json = serde_json::to_value(&compile_result)
.map_err(|e| internal_error(&format!("Serialisering: {e}")))?;
tracing::info!(
user = %user.node_id,
has_errors = compile_result.has_errors(),
"AI-script generert og validert"
);
Ok(Json(AiSuggestScriptResponse {
status: if compile_result.has_errors() {
"generated_with_errors".to_string()
} else {
"completed".to_string()
},
script: Some(generated_script),
compile_result: Some(compile_json),
work_item_id: None,
message: None,
}))
}
// =============================================================================
// POST /intentions/clip_url — klipp URL og opprett content-node
// =============================================================================
#[derive(Deserialize)]
pub struct ClipUrlRequest {
/// URL som skal klippes.
pub url: String,
/// Skriv resultat til database (default: true).
#[serde(default = "default_true")]
pub write: bool,
/// Tving bruk av Playwright (headless browser).
#[serde(default)]
pub playwright: bool,
}
fn default_true() -> bool {
true
}
#[derive(Serialize)]
pub struct ClipUrlResponse {
pub job_id: Uuid,
}
/// POST /intentions/clip_url
///
/// Legger en `clip_url`-jobb i køen.
/// synops-clip henter artikkelen, parser med Readability,
/// og oppretter content-node med AI-oppsummering.
pub async fn clip_url(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<ClipUrlRequest>,
) -> Result<Json<ClipUrlResponse>, (StatusCode, Json<ErrorResponse>)> {
// Enkel URL-validering
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
return Err(bad_request("URL må starte med http:// eller https://"));
}
let payload = serde_json::json!({
"url": req.url,
"created_by": user.node_id.to_string(),
"write": req.write,
"playwright": req.playwright,
});
let job_id = crate::jobs::enqueue(
&state.db,
"clip_url",
payload,
None,
3, // Lav prioritet — ikke tidskritisk
)
.await
.map_err(|e| {
tracing::error!(error = %e, "Kunne ikke legge clip_url-jobb i kø");
internal_error("Kunne ikke starte URL-klipping")
})?;
tracing::info!(
job_id = %job_id,
url = %req.url,
user = %user.node_id,
"clip_url-jobb lagt i kø"
);
Ok(Json(ClipUrlResponse { job_id }))
}
// =============================================================================
// 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());
}
// -- AI-preset validering (oppgave 18.1) --
fn valid_ai_preset_meta() -> serde_json::Value {
json!({
"prompt": "Fiks teksten",
"model_profile": "flash",
"category": "standard",
"default_direction": "tool_to_node",
"icon": "sparkles",
"color": "#8B5CF6"
})
}
#[test]
fn test_validate_ai_preset_ok() {
let meta = valid_ai_preset_meta();
assert!(validate_ai_preset_metadata("ai_preset", &meta).is_ok());
}
#[test]
fn test_validate_ai_preset_skips_other_kinds() {
// Ugyldig metadata skal ignoreres for andre node_kinds
let meta = json!({});
assert!(validate_ai_preset_metadata("content", &meta).is_ok());
assert!(validate_ai_preset_metadata("collection", &meta).is_ok());
}
#[test]
fn test_validate_ai_preset_missing_prompt() {
let mut meta = valid_ai_preset_meta();
meta.as_object_mut().unwrap().remove("prompt");
let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
assert!(err.contains("prompt"), "Feilmelding: {err}");
}
#[test]
fn test_validate_ai_preset_empty_prompt() {
let mut meta = valid_ai_preset_meta();
meta["prompt"] = json!("");
let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
assert!(err.contains("prompt"), "Feilmelding: {err}");
}
#[test]
fn test_validate_ai_preset_invalid_model_profile() {
let mut meta = valid_ai_preset_meta();
meta["model_profile"] = json!("ultra");
let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
assert!(err.contains("model_profile"), "Feilmelding: {err}");
}
#[test]
fn test_validate_ai_preset_invalid_category() {
let mut meta = valid_ai_preset_meta();
meta["category"] = json!("premium");
let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
assert!(err.contains("category"), "Feilmelding: {err}");
}
#[test]
fn test_validate_ai_preset_invalid_direction() {
let mut meta = valid_ai_preset_meta();
meta["default_direction"] = json!("left");
let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
assert!(err.contains("default_direction"), "Feilmelding: {err}");
}
#[test]
fn test_validate_ai_preset_invalid_color() {
let mut meta = valid_ai_preset_meta();
meta["color"] = json!("red");
let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
assert!(err.contains("color"), "Feilmelding: {err}");
}
#[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", "orchestration",
"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());
}
}