// 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", "mindmap", "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 mindmap-konfigurasjon: dybde 1-3, layout radial/tree if let Some(mindmap) = traits_obj.get("mindmap") { if let Some(depth) = mindmap.get("default_depth") { if let Some(d) = depth.as_i64() { if !(1..=3).contains(&d) { return Err("mindmap.default_depth må være 1, 2 eller 3".to_string()); } } } if let Some(layout) = mindmap.get("layout").and_then(|v| v.as_str()) { if layout != "radial" && layout != "tree" { return Err(format!( "mindmap.layout må være \"radial\" eller \"tree\", fikk \"{}\"", layout )); } } } // 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 1–3) /// - `effort` (heltall 1–3) /// - `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 (1–3) 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 1–3".into()), } } // Valgfri: effort (1–3) 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 1–3".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) { ( StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string(), }), ) } fn forbidden(msg: &str) -> (StatusCode, Json) { ( StatusCode::FORBIDDEN, Json(ErrorResponse { error: msg.to_string(), }), ) } fn internal_error(msg: &str) -> (StatusCode, Json) { ( 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 { // 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, 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 { // 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 { 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 { 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, (StatusCode, Json)> { 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, (StatusCode, Json)> { // Sjekk i prioritetsrekkefølge: owner > admin > member > reader let role: Option = 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)> { 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, /// Visningstittel. Kan være null (f.eks. chatmeldinger). pub title: Option, /// Ren tekst-innhold. pub content: Option, /// Synlighet. Default: "hidden" (privat). pub visibility: Option, /// Typespesifikk metadata (JSON-objekt). pub metadata: Option, /// 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, } #[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, } /// 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, /// Systemedge — usynlig ved traversering. Default: false. pub system: Option, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, /// Ny tittel. Beholder eksisterende hvis None. pub title: Option, /// Nytt innhold. Beholder eksisterende hvis None. pub content: Option, /// Ny synlighet. Beholder eksisterende hvis None. pub visibility: Option, /// Ny metadata. Beholder eksisterende hvis None. pub metadata: Option, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, /// Ny metadata. Beholder eksisterende hvis None. pub metadata: Option, } #[derive(Serialize)] pub struct UpdateEdgeResponse { pub edge_id: Uuid, } /// Henter en edge fra PG. async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result, 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, /// Rekkefølge innen featured-slot (ignoreres for hero/strøm). pub slot_order: Option, /// Forhindrer automatisk fjerning fra slot (FIFO/hero-erstatning). pub pinned: Option, } #[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, } /// 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // 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 = 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)> = 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)> = 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)> { 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, /// Deltakere — liste med node_id-er (person-noder). /// Innlogget bruker legges automatisk til som owner. pub participants: Vec, /// Synlighet. Default: "hidden" (privat). pub visibility: Option, /// Kontekst-node (f.eks. artikkel). Gir automatisk belongs_to-edge /// fra kommunikasjonsnoden til kontekstnoden. pub context_id: Option, } #[derive(Serialize)] pub struct CreateCommunicationResponse { pub node_id: Uuid, /// Edge-IDer for opprettede deltaker-edges (owner + member_of). pub edge_ids: Vec, } /// 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, } /// 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). /// - `metadata_extra` (valgfritt): JSON-objekt med ekstra metadata som merges /// inn i media-nodens metadata (f.eks. `{"source":"screenshot"}`). /// /// Ref: docs/primitiver/nodes.md (media), docs/retninger/universell_input.md pub async fn upload_media( State(state): State, user: AuthUser, mut multipart: Multipart, ) -> Result, (StatusCode, Json)> { let mut file_data: Option> = None; let mut file_name: Option = None; let mut content_type: Option = None; let mut source_id: Option = None; let mut visibility = "hidden".to_string(); let mut title: Option = None; let mut metadata_extra: Option = 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); } "metadata_extra" => { let text = field.text().await.map_err(|e| { bad_request(&format!("Kunne ikke lese metadata_extra: {e}")) })?; let parsed: serde_json::Value = serde_json::from_str(&text).map_err(|e| { bad_request(&format!("Ugyldig JSON i metadata_extra: {e}")) })?; if !parsed.is_object() { return Err(bad_request("metadata_extra må være et JSON-objekt")); } metadata_extra = Some(parsed); } _ => { // 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 mut metadata = serde_json::json!({ "cas_hash": cas_result.hash, "mime": mime, "size_bytes": cas_result.size, }); // Merge ekstra metadata fra klienten (f.eks. source: "screenshot") if let Some(extra) = &metadata_extra { if let Some(obj) = extra.as_object() { for (k, v) in obj { metadata[k] = v.clone(); } } } // 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 AI-beskrivelse for skjermklipp -- let is_screenshot = metadata_extra .as_ref() .and_then(|v| v.get("source")) .and_then(|v| v.as_str()) == Some("screenshot"); let is_image = mime.starts_with("image/"); if is_screenshot && is_image { let payload = serde_json::json!({ "media_node_id": media_node_id, "cas_hash": cas_result.hash, "mime": mime, }); 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, "describe_image", payload, collection_id, 5).await { Ok(job_id) => { tracing::info!( job_id = %job_id, media_node_id = %media_node_id, "describe_image-jobb opprettet for skjermklipp" ); } Err(e) => { tracing::error!( media_node_id = %media_node_id, error = %e, "Kunne ikke opprette describe_image-jobb" ); } } } // -- 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, 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, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, content: Option, 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // 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, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // 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 = req.new_version.parse() .map_err(|_| bad_request("Ugyldig new_version-tidsstempel"))?; let old_ts: chrono::DateTime = 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 = 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::>() .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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // 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, } #[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, } /// 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // 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, /// 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, /// Språk (default: "no"). pub language: Option, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, } #[derive(Serialize)] pub struct JoinCommunicationResponse { pub livekit_room_name: String, pub livekit_token: String, pub livekit_url: String, pub identity: String, pub participants: Vec, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, pub silence_min_duration_ms: Option, } /// 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, _user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // 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, _user: AuthUser, axum::extract::Query(query): axum::extract::Query, ) -> Result, (StatusCode, Json)> { 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, /// Når varselet automatisk utløper (ISO 8601). Valgfritt. pub expires_at: Option, /// Om nye sesjoner skal blokkeres (vedlikeholdsmodus). Default: false. pub blocks_new_sessions: Option, } #[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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // -- 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, admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, _admin: AdminUser, ) -> Result, (StatusCode, Json)> { 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, _admin: AdminUser, ) -> Result, (StatusCode, Json)> { 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, _admin: AdminUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { 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, pub r#type: Option, pub collection_id: Option, pub limit: Option, pub offset: Option, } #[derive(Serialize)] pub struct ListJobsResponse { pub jobs: Vec, pub counts: Vec, pub job_types: Vec, } /// POST /intentions/retry_job /// /// Sett en feilet jobb tilbake til 'pending' for nytt forsøk. pub async fn retry_job( State(state): State, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, _admin: AdminUser, ) -> Result, (StatusCode, Json)> { 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, _admin: AdminUser, ) -> Result, (StatusCode, Json)> { 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, pub history: Vec, } /// POST /admin/resources/update_rule — oppdater en prioritetsregel. pub async fn update_priority_rule( State(state): State, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, _user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // Verifiser at noden er en orchestration-node let node = sqlx::query_as::<_, (String, Option)>( "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, } #[derive(Serialize)] pub struct OrchestrationLogEntry { pub id: String, pub job_id: Option, pub step_number: i16, pub tool_binary: String, pub args: serde_json::Value, pub is_fallback: bool, pub status: String, pub exit_code: Option, pub error_msg: Option, pub duration_ms: Option, pub created_at: String, } #[derive(Serialize)] pub struct OrchestrationLogResponse { pub entries: Vec, } /// Hent kjørehistorikk for en orkestrering. pub async fn orchestration_log( State(state): State, _user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let limit = params.limit.unwrap_or(50).min(200); let rows = sqlx::query_as::<_, ( Uuid, Option, i16, String, serde_json::Value, bool, String, Option, Option, Option, chrono::DateTime, )>( 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, /// Trigger-betingelser som JSON (valgfri) pub trigger_conditions: Option, /// 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, } #[derive(Serialize)] pub struct AiSuggestScriptResponse { pub status: String, #[serde(skip_serializing_if = "Option::is_none")] pub script: Option, #[serde(skip_serializing_if = "Option::is_none")] pub compile_result: Option, #[serde(skip_serializing_if = "Option::is_none")] pub work_item_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, } /// 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { 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, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // 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 })) } // ============================================================================= // Feed-abonnement (oppgave 29.3) // ============================================================================= #[derive(Deserialize)] pub struct ConfigureFeedSubscriptionRequest { /// Samlings-ID pub collection_id: Uuid, /// Feed-URL (RSS/Atom) pub url: String, /// Poll-intervall i minutter (default: 30) #[serde(default = "default_feed_interval")] pub interval_minutes: u32, /// Mål: "inbox" eller "channel" (default: "inbox") #[serde(default = "default_feed_target")] pub target: String, /// Aktivert (default: true) #[serde(default = "default_true")] pub enabled: bool, } fn default_feed_interval() -> u32 { 30 } fn default_feed_target() -> String { "inbox".to_string() } #[derive(Deserialize)] pub struct RemoveFeedSubscriptionRequest { /// Samlings-ID pub collection_id: Uuid, /// Feed-URL å fjerne pub url: String, } /// POST /intentions/configure_feed_subscription /// /// Legger til eller oppdaterer et feed-abonnement på en samling. /// Lagres i samlingens `metadata.feed_subscriptions[]`. pub async fn configure_feed_subscription( State(state): State, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // Valider URL if !req.url.starts_with("http://") && !req.url.starts_with("https://") { return Err(bad_request("Feed-URL må starte med http:// eller https://")); } if req.interval_minutes < 5 { return Err(bad_request("Intervall må være minst 5 minutter")); } if req.target != "inbox" && req.target != "channel" { return Err(bad_request("Target må være 'inbox' eller 'channel'")); } // Sjekk at samlingen eksisterer og brukeren har tilgang let collection: Option<(String, serde_json::Value)> = sqlx::query_as( "SELECT node_kind, COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1", ) .bind(req.collection_id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "PG-feil ved oppslag av samling"); internal_error("Kunne ikke slå opp samling") })?; let (kind, metadata) = collection.ok_or_else(|| bad_request("Samling ikke funnet"))?; if kind != "collection" { return Err(bad_request("Noden er ikke en samling")); } // Les eksisterende abonnementer let mut subs: Vec = metadata .get("feed_subscriptions") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); // Oppdater eksisterende eller legg til ny let new_sub = crate::feed_poller::FeedSubscription { url: req.url.clone(), interval_minutes: req.interval_minutes, target: req.target.clone(), last_polled_at: None, enabled: Some(req.enabled), }; if let Some(existing) = subs.iter_mut().find(|s| s.url == req.url) { existing.interval_minutes = req.interval_minutes; existing.target = req.target.clone(); existing.enabled = Some(req.enabled); tracing::info!(url = %req.url, "Feed-abonnement oppdatert"); } else { subs.push(new_sub); tracing::info!(url = %req.url, "Feed-abonnement lagt til"); } // Skriv tilbake til metadata sqlx::query( r#" UPDATE nodes SET metadata = jsonb_set( COALESCE(metadata, '{}'::jsonb), '{feed_subscriptions}', $2 ) WHERE id = $1 "#, ) .bind(req.collection_id) .bind(serde_json::to_value(&subs).unwrap()) .execute(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Kunne ikke oppdatere feed_subscriptions"); internal_error("Kunne ikke lagre feed-abonnement") })?; tracing::info!( collection_id = %req.collection_id, url = %req.url, user = %user.node_id, interval = req.interval_minutes, target = %req.target, "Feed-abonnement konfigurert" ); Ok(Json(serde_json::json!({ "status": "ok", "collection_id": req.collection_id, "url": req.url, "interval_minutes": req.interval_minutes, "target": req.target, "enabled": req.enabled, "subscriptions_count": subs.len(), }))) } /// POST /intentions/remove_feed_subscription /// /// Fjerner et feed-abonnement fra en samling. pub async fn remove_feed_subscription( State(state): State, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // Les eksisterende abonnementer let metadata: Option = sqlx::query_scalar( "SELECT COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1 AND node_kind = 'collection'", ) .bind(req.collection_id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "PG-feil"); internal_error("Kunne ikke hente samling") })?; let metadata = metadata.ok_or_else(|| bad_request("Samling ikke funnet"))?; let mut subs: Vec = metadata .get("feed_subscriptions") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); let before = subs.len(); subs.retain(|s| s.url != req.url); if subs.len() == before { return Err(bad_request("Feed-abonnement ikke funnet")); } // Skriv tilbake sqlx::query( r#" UPDATE nodes SET metadata = jsonb_set( COALESCE(metadata, '{}'::jsonb), '{feed_subscriptions}', $2 ) WHERE id = $1 "#, ) .bind(req.collection_id) .bind(serde_json::to_value(&subs).unwrap()) .execute(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Kunne ikke oppdatere feed_subscriptions"); internal_error("Kunne ikke fjerne feed-abonnement") })?; tracing::info!( collection_id = %req.collection_id, url = %req.url, user = %user.node_id, "Feed-abonnement fjernet" ); Ok(Json(serde_json::json!({ "status": "ok", "collection_id": req.collection_id, "url": req.url, "removed": true, "subscriptions_count": subs.len(), }))) } // ============================================================================= // Kalender-abonnement (oppgave 29.12) // ============================================================================= #[derive(Deserialize)] pub struct ConfigureCalendarSubscriptionRequest { /// Samlings-ID pub collection_id: Uuid, /// Kalender-URL (ICS/CalDAV) pub url: String, /// Poll-intervall i minutter (default: 60) #[serde(default = "default_calendar_interval")] pub interval_minutes: u32, /// Aktivert (default: true) #[serde(default = "default_true")] pub enabled: bool, } fn default_calendar_interval() -> u32 { 60 } #[derive(Deserialize)] pub struct RemoveCalendarSubscriptionRequest { /// Samlings-ID pub collection_id: Uuid, /// Kalender-URL å fjerne pub url: String, } /// POST /intentions/configure_calendar_subscription /// /// Legger til eller oppdaterer et kalender-abonnement på en samling. /// Lagres i samlingens `metadata.calendar_subscriptions[]`. pub async fn configure_calendar_subscription( State(state): State, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { if !req.url.starts_with("http://") && !req.url.starts_with("https://") { return Err(bad_request("Kalender-URL må starte med http:// eller https://")); } if req.interval_minutes < 15 { return Err(bad_request("Intervall må være minst 15 minutter")); } // Sjekk at samlingen eksisterer og er en samling let collection: Option<(String, serde_json::Value)> = sqlx::query_as( "SELECT node_kind, COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1", ) .bind(req.collection_id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "PG-feil ved oppslag av samling"); internal_error("Kunne ikke slå opp samling") })?; let (kind, metadata) = collection.ok_or_else(|| bad_request("Samling ikke funnet"))?; if kind != "collection" { return Err(bad_request("Noden er ikke en samling")); } // Les eksisterende abonnementer let mut subs: Vec = metadata .get("calendar_subscriptions") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); let new_sub = crate::calendar_poller::CalendarSubscription { url: req.url.clone(), interval_minutes: req.interval_minutes, last_polled_at: None, enabled: Some(req.enabled), }; if let Some(existing) = subs.iter_mut().find(|s| s.url == req.url) { existing.interval_minutes = req.interval_minutes; existing.enabled = Some(req.enabled); tracing::info!(url = %req.url, "Kalender-abonnement oppdatert"); } else { subs.push(new_sub); tracing::info!(url = %req.url, "Kalender-abonnement lagt til"); } // Skriv tilbake til metadata sqlx::query( r#" UPDATE nodes SET metadata = jsonb_set( COALESCE(metadata, '{}'::jsonb), '{calendar_subscriptions}', $2 ) WHERE id = $1 "#, ) .bind(req.collection_id) .bind(serde_json::to_value(&subs).unwrap()) .execute(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Kunne ikke oppdatere calendar_subscriptions"); internal_error("Kunne ikke lagre kalender-abonnement") })?; tracing::info!( collection_id = %req.collection_id, url = %req.url, user = %user.node_id, interval = req.interval_minutes, "Kalender-abonnement konfigurert" ); Ok(Json(serde_json::json!({ "status": "ok", "collection_id": req.collection_id, "url": req.url, "interval_minutes": req.interval_minutes, "enabled": req.enabled, "subscriptions_count": subs.len(), }))) } /// POST /intentions/remove_calendar_subscription /// /// Fjerner et kalender-abonnement fra en samling. pub async fn remove_calendar_subscription( State(state): State, user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let metadata: Option = sqlx::query_scalar( "SELECT COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1 AND node_kind = 'collection'", ) .bind(req.collection_id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "PG-feil"); internal_error("Kunne ikke hente samling") })?; let metadata = metadata.ok_or_else(|| bad_request("Samling ikke funnet"))?; let mut subs: Vec = metadata .get("calendar_subscriptions") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); let before = subs.len(); subs.retain(|s| s.url != req.url); if subs.len() == before { return Err(bad_request("Kalender-abonnement ikke funnet")); } sqlx::query( r#" UPDATE nodes SET metadata = jsonb_set( COALESCE(metadata, '{}'::jsonb), '{calendar_subscriptions}', $2 ) WHERE id = $1 "#, ) .bind(req.collection_id) .bind(serde_json::to_value(&subs).unwrap()) .execute(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "Kunne ikke oppdatere calendar_subscriptions"); internal_error("Kunne ikke fjerne kalender-abonnement") })?; tracing::info!( collection_id = %req.collection_id, url = %req.url, user = %user.node_id, "Kalender-abonnement fjernet" ); Ok(Json(serde_json::json!({ "status": "ok", "collection_id": req.collection_id, "url": req.url, "removed": true, "subscriptions_count": subs.len(), }))) } // ============================================================================= // Utgående varsler (oppgave 26.7) // ============================================================================= #[derive(Deserialize)] pub struct SendNotificationRequest { /// Mottaker (node_id for bruker). pub to: Uuid, /// Varseltekst. pub message: String, /// Emne for epost (valgfritt). #[serde(default = "default_notification_subject")] pub subject: String, /// Kanal: "email", "ws", "both" (default: "both"). #[serde(default = "default_notification_channel")] pub channel: String, /// Varslingstype for preferansesjekk (f.eks. "task_assigned", "article_approved"). #[serde(default)] pub notification_type: Option, } fn default_notification_subject() -> String { "Varsel fra Synops".to_string() } fn default_notification_channel() -> String { "both".to_string() } /// POST /intentions/send_notification /// /// Legger en `send_notification`-jobb i køen. Jobbkøen delegerer til /// `synops-notify` som sjekker brukerens preferanser og sender via /// valgt kanal (epost, WebSocket, eller begge). /// /// Admin-only: kun administratorer kan sende varsler til vilkårlige brukere. pub async fn send_notification( State(state): State, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // Valider kanal if !["email", "ws", "both"].contains(&req.channel.as_str()) { return Err(bad_request(&format!( "Ugyldig kanal: '{}'. Gyldige verdier: email, ws, both", req.channel ))); } if req.message.trim().is_empty() { return Err(bad_request("message kan ikke være tom")); } // Verifiser at mottaker eksisterer og er person/agent let node_kind: Option = sqlx::query_scalar( "SELECT node_kind::text FROM nodes WHERE id = $1", ) .bind(req.to) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "PG-feil ved oppslag av mottaker"); internal_error("Databasefeil") })?; let node_kind = node_kind.ok_or_else(|| bad_request("Mottaker-node finnes ikke"))?; if !["person", "agent"].contains(&node_kind.as_str()) { return Err(bad_request(&format!( "Mottaker er {node_kind}, ikke person/agent" ))); } // Bygg payload og legg i jobbkø let mut payload = serde_json::json!({ "to": req.to.to_string(), "message": req.message, "subject": req.subject, "channel": req.channel, }); if let Some(ref ntype) = req.notification_type { payload["notification_type"] = serde_json::Value::String(ntype.clone()); } let job_id = crate::jobs::enqueue( &state.db, "send_notification", payload, None, 10, // Høy prioritet — brukervendt varsel ) .await .map_err(|e| { tracing::error!(error = %e, "Kunne ikke legge varsel-jobb i kø"); internal_error("Kunne ikke opprette varsel-jobb") })?; tracing::info!( job_id = %job_id, to = %req.to, channel = %req.channel, "Varsel lagt i jobbkø" ); Ok(Json(serde_json::json!({ "status": "queued", "job_id": job_id, "to": req.to, "channel": req.channel, }))) } // ============================================================================= // 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()); } }