Vaktmesteren kan nå sende epost-varsler og WebSocket-push til brukere via synops-notify, med respekt for brukerens preferanser. Endringer: - jobs.rs: send_notification jobbtype som delegerer til synops-notify CLI - synops-notify: preferansesjekk fra metadata.preferences.notifications (opt-out-modell, per-kanal og per-type bryter, --skip-preferences) - intentions.rs: POST /intentions/send_notification (admin-only) - Dokumentasjon: docs/features/varsler.md Preferanseskjema (i brukernodens metadata): preferences.notifications.email: bool (global epost-bryter) preferences.notifications.ws: bool (global WS-bryter) preferences.notifications.<type>: bool (per-type, f.eks. task_assigned)
5460 lines
180 KiB
Rust
5460 lines
180 KiB
Rust
// 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<ErrorResponse>) {
|
||
(
|
||
StatusCode::BAD_REQUEST,
|
||
Json(ErrorResponse {
|
||
error: msg.to_string(),
|
||
}),
|
||
)
|
||
}
|
||
|
||
fn forbidden(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||
(
|
||
StatusCode::FORBIDDEN,
|
||
Json(ErrorResponse {
|
||
error: msg.to_string(),
|
||
}),
|
||
)
|
||
}
|
||
|
||
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(ErrorResponse {
|
||
error: msg.to_string(),
|
||
}),
|
||
)
|
||
}
|
||
|
||
|
||
// =============================================================================
|
||
// Tilgangskontroll og kontekstbasert identitet
|
||
// =============================================================================
|
||
|
||
/// Løser brukerens identitet i en kommunikasjonskontekst.
|
||
///
|
||
/// Hvis brukeren har et alias som er deltaker (owner, member_of, host_of)
|
||
/// i den gitte kommunikasjonsnoden, returneres alias-nodens ID.
|
||
/// Ellers returneres brukerens hoved-node_id.
|
||
///
|
||
/// Dette gjør at meldinger i en kommunikasjon automatisk krediteres
|
||
/// aliaset — f.eks. "Bjørn" i en podcast-samtale i stedet for "Vegard".
|
||
///
|
||
/// Ref: docs/primitiver/nodes.md (created_by), docs/primitiver/edges.md (alias)
|
||
async fn resolve_context_identity(
|
||
db: &PgPool,
|
||
user_id: Uuid,
|
||
context_id: Uuid,
|
||
) -> Result<Uuid, sqlx::Error> {
|
||
// Finn brukerens alias som er deltaker i kommunikasjonsnoden.
|
||
// Alias-edge: user_id --alias(system=true)--> alias_id
|
||
// Deltaker-edge: alias_id --owner/member_of/host_of--> context_id
|
||
let alias_id = sqlx::query_scalar::<_, Uuid>(
|
||
r#"
|
||
SELECT e_alias.target_id
|
||
FROM edges e_alias
|
||
JOIN edges e_participant
|
||
ON e_participant.source_id = e_alias.target_id
|
||
WHERE e_alias.source_id = $1
|
||
AND e_alias.edge_type = 'alias'
|
||
AND e_alias.system = true
|
||
AND e_participant.target_id = $2
|
||
AND e_participant.edge_type IN ('owner', 'member_of', 'host_of')
|
||
LIMIT 1
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.bind(context_id)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
Ok(alias_id.unwrap_or(user_id))
|
||
}
|
||
|
||
/// Henter alle alias-IDer for en bruker (via system alias-edges).
|
||
#[allow(dead_code)]
|
||
async fn user_alias_ids(db: &PgPool, user_id: Uuid) -> Result<Vec<Uuid>, sqlx::Error> {
|
||
let ids = sqlx::query_scalar::<_, Uuid>(
|
||
r#"
|
||
SELECT target_id FROM edges
|
||
WHERE source_id = $1 AND edge_type = 'alias' AND system = true
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.fetch_all(db)
|
||
.await?;
|
||
|
||
Ok(ids)
|
||
}
|
||
|
||
/// Sjekker om brukeren har skrivetilgang til en node.
|
||
/// Returnerer true hvis brukeren (eller et av brukerens aliaser) er created_by,
|
||
/// eller har owner/admin-edge til noden.
|
||
async fn user_can_modify_node(db: &PgPool, user_id: Uuid, node_id: Uuid) -> Result<bool, sqlx::Error> {
|
||
// Sjekk direkte eierskap, alias-eierskap, eller admin/owner-edge
|
||
let row = sqlx::query_scalar::<_, bool>(
|
||
r#"
|
||
SELECT EXISTS(
|
||
SELECT 1 FROM nodes WHERE id = $1 AND created_by = $2
|
||
) OR EXISTS(
|
||
SELECT 1 FROM edges
|
||
WHERE source_id = $2 AND target_id = $1
|
||
AND edge_type IN ('owner', 'admin')
|
||
) OR EXISTS(
|
||
-- Sjekk om created_by er et av brukerens aliaser
|
||
SELECT 1 FROM nodes n
|
||
JOIN edges e_alias ON e_alias.target_id = n.created_by
|
||
WHERE n.id = $1
|
||
AND e_alias.source_id = $2
|
||
AND e_alias.edge_type = 'alias'
|
||
AND e_alias.system = true
|
||
)
|
||
"#,
|
||
)
|
||
.bind(node_id)
|
||
.bind(user_id)
|
||
.fetch_one(db)
|
||
.await?;
|
||
|
||
Ok(row)
|
||
}
|
||
|
||
/// Sjekker om brukeren har skrivetilgang til en edge.
|
||
/// Brukeren må ha opprettet edgen (direkte eller via alias),
|
||
/// eller ha owner/admin-edge til source-noden.
|
||
async fn user_can_modify_edge(db: &PgPool, user_id: Uuid, edge_id: Uuid) -> Result<bool, sqlx::Error> {
|
||
let row = sqlx::query_scalar::<_, bool>(
|
||
r#"
|
||
SELECT EXISTS(
|
||
SELECT 1 FROM edges WHERE id = $1 AND created_by = $2
|
||
) OR EXISTS(
|
||
SELECT 1 FROM edges e
|
||
JOIN edges access_edge ON access_edge.source_id = $2
|
||
AND access_edge.target_id = e.source_id
|
||
AND access_edge.edge_type IN ('owner', 'admin')
|
||
WHERE e.id = $1
|
||
) OR EXISTS(
|
||
-- Sjekk om created_by er et av brukerens aliaser
|
||
SELECT 1 FROM edges e
|
||
JOIN edges e_alias ON e_alias.target_id = e.created_by
|
||
WHERE e.id = $1
|
||
AND e_alias.source_id = $2
|
||
AND e_alias.edge_type = 'alias'
|
||
AND e_alias.system = true
|
||
)
|
||
"#,
|
||
)
|
||
.bind(edge_id)
|
||
.bind(user_id)
|
||
.fetch_one(db)
|
||
.await?;
|
||
|
||
Ok(row)
|
||
}
|
||
|
||
/// Sjekker om en node eksisterer i PG.
|
||
async fn node_exists(db: &PgPool, node_id: Uuid) -> Result<bool, sqlx::Error> {
|
||
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)")
|
||
.bind(node_id)
|
||
.fetch_one(db)
|
||
.await
|
||
}
|
||
|
||
/// Henter publishing-trait-konfig for en node (hvis den er en samling med publishing-trait).
|
||
/// Wrapper rundt publishing::find_publishing_collection_by_id med feil-mapping for handlers.
|
||
async fn get_publishing_config(
|
||
db: &PgPool,
|
||
node_id: Uuid,
|
||
) -> Result<Option<crate::publishing::PublishingConfig>, (StatusCode, Json<ErrorResponse>)> {
|
||
crate::publishing::find_publishing_collection_by_id(db, node_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved publishing-config-oppslag: {e}");
|
||
internal_error("Databasefeil ved publiseringssjekk")
|
||
})
|
||
}
|
||
|
||
/// Returnerer brukerens høyeste rolle-edge til en node.
|
||
/// Sjekker owner, admin, member_of, reader-edges.
|
||
/// Returnerer "owner", "admin", "member", "reader", eller None.
|
||
async fn get_user_role_for_node(
|
||
db: &PgPool,
|
||
user_id: Uuid,
|
||
node_id: Uuid,
|
||
) -> Result<Option<String>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Sjekk i prioritetsrekkefølge: owner > admin > member > reader
|
||
let role: Option<String> = sqlx::query_scalar(
|
||
r#"
|
||
SELECT CASE
|
||
WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'owner') THEN 'owner'
|
||
WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'admin') THEN 'admin'
|
||
WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'member_of') THEN 'member'
|
||
WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'reader') THEN 'reader'
|
||
ELSE NULL
|
||
END
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.bind(node_id)
|
||
.fetch_one(db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved rolle-oppslag: {e}");
|
||
internal_error("Databasefeil ved rollesjekk")
|
||
})?;
|
||
|
||
Ok(role)
|
||
}
|
||
|
||
/// Sjekker om brukeren er owner eller admin av en node.
|
||
async fn user_is_owner_or_admin(
|
||
db: &PgPool,
|
||
user_id: Uuid,
|
||
node_id: Uuid,
|
||
) -> Result<bool, (StatusCode, Json<ErrorResponse>)> {
|
||
sqlx::query_scalar::<_, bool>(
|
||
r#"
|
||
SELECT EXISTS(
|
||
SELECT 1 FROM edges
|
||
WHERE source_id = $1 AND target_id = $2
|
||
AND edge_type IN ('owner', 'admin')
|
||
)
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.bind(node_id)
|
||
.fetch_one(db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved owner/admin-sjekk: {e}");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})
|
||
}
|
||
|
||
// =============================================================================
|
||
// create_node
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateNodeRequest {
|
||
/// Hint om hva noden er. Default: "content".
|
||
pub node_kind: Option<String>,
|
||
/// Visningstittel. Kan være null (f.eks. chatmeldinger).
|
||
pub title: Option<String>,
|
||
/// Ren tekst-innhold.
|
||
pub content: Option<String>,
|
||
/// Synlighet. Default: "hidden" (privat).
|
||
pub visibility: Option<String>,
|
||
/// Typespesifikk metadata (JSON-objekt).
|
||
pub metadata: Option<serde_json::Value>,
|
||
/// Kontekst-node (f.eks. kommunikasjonsnode). Hvis satt, opprettes
|
||
/// automatisk en `belongs_to`-edge fra den nye noden til kontekstnoden.
|
||
/// Ref: docs/retninger/universell_input.md (kontekst-arv).
|
||
pub context_id: Option<Uuid>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CreateNodeResponse {
|
||
pub node_id: Uuid,
|
||
/// Edge-ID for automatisk opprettet `belongs_to`-edge (kun ved context_id).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub belongs_to_edge_id: Option<Uuid>,
|
||
}
|
||
|
||
/// POST /intentions/create_node
|
||
///
|
||
/// Validerer input, skriver til PG. NOTIFY-triggere sender sanntidsoppdateringer.
|
||
///
|
||
/// Hvis `context_id` er satt, opprettes automatisk en `belongs_to`-edge
|
||
/// fra den nye noden til kontekstnoden. Kontekstnoden må eksistere og
|
||
/// være en kommunikasjonsnode. Ref: docs/retninger/universell_input.md
|
||
pub async fn create_node(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<CreateNodeRequest>,
|
||
) -> Result<Json<CreateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// -- Valider input --
|
||
let node_kind = req.node_kind.unwrap_or_else(|| "content".to_string());
|
||
if node_kind.is_empty() {
|
||
return Err(bad_request("node_kind kan ikke være tom"));
|
||
}
|
||
|
||
let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string());
|
||
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
|
||
return Err(bad_request(&format!(
|
||
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
|
||
)));
|
||
}
|
||
|
||
// -- Valider context_id hvis satt --
|
||
if let Some(ctx_id) = req.context_id {
|
||
let ctx_node = sqlx::query_as::<_, NodeKindRow>(
|
||
"SELECT node_kind FROM nodes WHERE id = $1",
|
||
)
|
||
.bind(ctx_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved context_id-sjekk: {e}");
|
||
internal_error("Databasefeil ved validering av context_id")
|
||
})?;
|
||
|
||
match ctx_node {
|
||
None => return Err(bad_request(&format!("context_id {} finnes ikke", ctx_id))),
|
||
Some(row) if row.node_kind != "communication" => {
|
||
return Err(bad_request(&format!(
|
||
"context_id {} er en '{}'-node, ikke en kommunikasjonsnode",
|
||
ctx_id, row.node_kind
|
||
)));
|
||
}
|
||
_ => {} // OK — kommunikasjonsnode
|
||
}
|
||
}
|
||
|
||
let title = req.title.unwrap_or_default();
|
||
let content = req.content.unwrap_or_default();
|
||
let metadata = req
|
||
.metadata
|
||
.unwrap_or_else(|| serde_json::json!({}));
|
||
|
||
// -- Valider traits for samlingsnoder (oppgave 13.1) --
|
||
validate_collection_traits(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
|
||
|
||
// -- Valider metadata for AI-presets (oppgave 18.1) --
|
||
validate_ai_preset_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
|
||
|
||
// -- Valider metadata for orchestration-noder (oppgave 24.1) --
|
||
validate_orchestration_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
|
||
|
||
// -- Kontekstbasert identitet (oppgave 8.2) --
|
||
// Hvis context_id er satt, sjekk om brukeren har et alias som er
|
||
// deltaker i kommunikasjonsnoden. I så fall brukes aliaset som created_by.
|
||
let effective_identity = if let Some(ctx_id) = req.context_id {
|
||
resolve_context_identity(&state.db, user.node_id, ctx_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved identitetsoppslag: {e}");
|
||
internal_error("Databasefeil ved identitetsoppslag")
|
||
})?
|
||
} else {
|
||
user.node_id
|
||
};
|
||
|
||
// -- Generer UUIDv7 (tidssortert) --
|
||
let node_id = Uuid::now_v7();
|
||
|
||
// Fang verdier for AI-trigger før de brukes i PG-insert
|
||
let is_content_node = node_kind == "content";
|
||
let has_enough_text = content.len() >= 20 || title.len() >= 20;
|
||
|
||
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
|
||
sqlx::query(
|
||
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
|
||
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5::visibility, $6, $7)"#,
|
||
)
|
||
.bind(node_id)
|
||
.bind(&node_kind)
|
||
.bind(&title)
|
||
.bind(&content)
|
||
.bind(&visibility)
|
||
.bind(&metadata)
|
||
.bind(effective_identity)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved opprettelse av node: {e}");
|
||
internal_error("Databasefeil ved opprettelse av node")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
node_id = %node_id,
|
||
node_kind = %node_kind,
|
||
created_by = %effective_identity,
|
||
auth_user = %user.node_id,
|
||
context_id = ?req.context_id,
|
||
alias_used = %(effective_identity != user.node_id),
|
||
"Node opprettet"
|
||
);
|
||
|
||
// -- Kontekst-arv: automatisk belongs_to-edge --
|
||
let belongs_to_edge_id = if let Some(ctx_id) = req.context_id {
|
||
let edge_id = Uuid::now_v7();
|
||
let bt_metadata = serde_json::json!({});
|
||
|
||
sqlx::query(
|
||
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
||
VALUES ($1, $2, $3, 'belongs_to', $4, false, $5)"#,
|
||
)
|
||
.bind(edge_id)
|
||
.bind(node_id)
|
||
.bind(ctx_id)
|
||
.bind(&bt_metadata)
|
||
.bind(effective_identity)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved opprettelse av belongs_to-edge: {e}");
|
||
internal_error("Databasefeil ved opprettelse av edge")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
edge_id = %edge_id,
|
||
node_id = %node_id,
|
||
context_id = %ctx_id,
|
||
"belongs_to-edge opprettet (kontekst-arv)"
|
||
);
|
||
|
||
// Propager tilgang: alle som har tilgang til ctx_id får tilgang til node_id
|
||
if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
|
||
.bind(node_id)
|
||
.bind(ctx_id)
|
||
.execute(&state.db)
|
||
.await
|
||
{
|
||
tracing::error!("propagate_belongs_to_access feilet: {e}");
|
||
}
|
||
|
||
Some(edge_id)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// -- Agent-trigger: sjekk om kommunikasjonsnoden har en agent-deltaker --
|
||
if let Some(ctx_id) = req.context_id {
|
||
let db_clone = state.db.clone();
|
||
let user_node_id = user.node_id;
|
||
let created_node_id = node_id;
|
||
tokio::spawn(async move {
|
||
match crate::agent::find_agent_participant(&db_clone, ctx_id).await {
|
||
Ok(Some(agent_id)) if agent_id != effective_identity => {
|
||
// Agent funnet, og melding er ikke fra agenten selv
|
||
let payload = serde_json::json!({
|
||
"communication_id": ctx_id.to_string(),
|
||
"message_id": created_node_id.to_string(),
|
||
"agent_node_id": agent_id.to_string(),
|
||
"sender_node_id": user_node_id.to_string()
|
||
});
|
||
match crate::jobs::enqueue(&db_clone, "agent_respond", payload, None, 8).await {
|
||
Ok(job_id) => {
|
||
tracing::info!(
|
||
job_id = %job_id,
|
||
communication_id = %ctx_id,
|
||
agent_node_id = %agent_id,
|
||
"agent_respond-jobb lagt i kø"
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
communication_id = %ctx_id,
|
||
error = %e,
|
||
"Kunne ikke legge agent_respond-jobb i kø"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Ok(_) => {} // Ingen agent, eller melding fra agenten selv
|
||
Err(e) => {
|
||
tracing::error!(
|
||
communication_id = %ctx_id,
|
||
error = %e,
|
||
"Feil ved agent-deltaker-sjekk"
|
||
);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// -- AI edge-forslag: analyser innholdet for topics og mentions --
|
||
// Trigges for content-noder med nok tekst. Lav prioritet (bakgrunnsjobb).
|
||
// NB: node_kind, title og content er flyttet inn i enqueue_insert_node over,
|
||
// så vi sjekker på kopi av verdiene tatt før move.
|
||
if is_content_node && has_enough_text {
|
||
let db_clone = state.db.clone();
|
||
let created_node_id = node_id;
|
||
tokio::spawn(async move {
|
||
let payload = serde_json::json!({
|
||
"node_id": created_node_id.to_string()
|
||
});
|
||
match crate::jobs::enqueue(&db_clone, "suggest_edges", payload, None, 2).await {
|
||
Ok(job_id) => {
|
||
tracing::info!(
|
||
job_id = %job_id,
|
||
node_id = %created_node_id,
|
||
"suggest_edges-jobb lagt i kø"
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
node_id = %created_node_id,
|
||
error = %e,
|
||
"Kunne ikke legge suggest_edges-jobb i kø"
|
||
);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
Ok(Json(CreateNodeResponse { node_id, belongs_to_edge_id }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// create_edge
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateEdgeRequest {
|
||
/// Kilde-node (fra).
|
||
pub source_id: Uuid,
|
||
/// Mål-node (til).
|
||
pub target_id: Uuid,
|
||
/// Relasjontype (freeform streng). Ref: docs/primitiver/edges.md
|
||
pub edge_type: String,
|
||
/// Typespesifikk metadata (JSON-objekt).
|
||
pub metadata: Option<serde_json::Value>,
|
||
/// Systemedge — usynlig ved traversering. Default: false.
|
||
pub system: Option<bool>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CreateEdgeResponse {
|
||
pub edge_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/create_edge
|
||
///
|
||
/// Oppretter en retningsbestemt edge mellom to noder.
|
||
/// Krever at begge nodene eksisterer. Brukeren trenger ikke spesiell
|
||
/// tilgang for å opprette edges — tilgangskontroll på edges håndheves
|
||
/// ved lesing (node_access-matrisen, fase 4).
|
||
pub async fn create_edge(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<CreateEdgeRequest>,
|
||
) -> Result<Json<CreateEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// -- Valider input --
|
||
if req.edge_type.is_empty() {
|
||
return Err(bad_request("edge_type kan ikke være tom"));
|
||
}
|
||
|
||
// Sjekk at begge nodene eksisterer
|
||
let (source_exists, target_exists) = tokio::try_join!(
|
||
node_exists(&state.db, req.source_id),
|
||
node_exists(&state.db, req.target_id),
|
||
)
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved nodesjekk: {e}");
|
||
internal_error("Databasefeil ved validering")
|
||
})?;
|
||
|
||
if !source_exists {
|
||
return Err(bad_request(&format!("source_id {} finnes ikke", req.source_id)));
|
||
}
|
||
if !target_exists {
|
||
return Err(bad_request(&format!("target_id {} finnes ikke", req.target_id)));
|
||
}
|
||
|
||
let mut metadata = req.metadata.unwrap_or_else(|| serde_json::json!({}));
|
||
let system = req.system.unwrap_or(false);
|
||
|
||
// -- Publiseringsvalidering for submitted_to og belongs_to --
|
||
if req.edge_type == "submitted_to" || req.edge_type == "belongs_to" {
|
||
if let Some(config) = get_publishing_config(&state.db, req.target_id).await? {
|
||
if config.require_approval {
|
||
if req.edge_type == "submitted_to" {
|
||
// Kun roller i submission_roles (eller owner/admin) kan opprette submitted_to
|
||
let user_role = get_user_role_for_node(&state.db, user.node_id, req.target_id).await?;
|
||
let allowed = match &user_role {
|
||
Some(role) if role == "owner" || role == "admin" => true,
|
||
Some(role) => config.submission_roles.contains(role),
|
||
None => false,
|
||
};
|
||
if !allowed {
|
||
return Err(forbidden(
|
||
"Du har ikke riktig rolle til å sende inn til denne samlingen",
|
||
));
|
||
}
|
||
// Sett status og submitted_at automatisk
|
||
if let Some(obj) = metadata.as_object_mut() {
|
||
obj.insert("status".to_string(), serde_json::json!("pending"));
|
||
obj.insert(
|
||
"submitted_at".to_string(),
|
||
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
||
);
|
||
}
|
||
} else {
|
||
// belongs_to til require_approval-samling: kun owner/admin
|
||
let is_owner_admin =
|
||
user_is_owner_or_admin(&state.db, user.node_id, req.target_id).await?;
|
||
if !is_owner_admin {
|
||
return Err(forbidden(
|
||
"Kun owner/admin kan publisere direkte til denne samlingen (require_approval er aktiv)",
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// -- Validering av source_material-metadata --
|
||
if req.edge_type == "source_material" {
|
||
if let Some(obj) = metadata.as_object() {
|
||
// context er påkrevd og må være en av tre gyldige verdier
|
||
match obj.get("context").and_then(|v| v.as_str()) {
|
||
Some("quoted" | "summarized" | "referenced") => {}
|
||
Some(other) => {
|
||
return Err(bad_request(&format!(
|
||
"source_material context må være 'quoted', 'summarized' eller 'referenced', fikk '{other}'"
|
||
)));
|
||
}
|
||
None => {
|
||
return Err(bad_request(
|
||
"source_material krever 'context'-felt (quoted, summarized eller referenced)",
|
||
));
|
||
}
|
||
}
|
||
// excerpt er påkrevd og må være en ikke-tom streng
|
||
match obj.get("excerpt").and_then(|v| v.as_str()) {
|
||
Some(s) if !s.trim().is_empty() => {}
|
||
_ => {
|
||
return Err(bad_request(
|
||
"source_material krever et ikke-tomt 'excerpt'-felt",
|
||
));
|
||
}
|
||
}
|
||
} else {
|
||
return Err(bad_request(
|
||
"source_material krever metadata med 'context' og 'excerpt'",
|
||
));
|
||
}
|
||
}
|
||
|
||
// -- Generer UUIDv7 --
|
||
let edge_id = Uuid::now_v7();
|
||
|
||
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
|
||
let access_level = match req.edge_type.as_str() {
|
||
"owner" => Some("owner"),
|
||
"admin" => Some("admin"),
|
||
"member_of" => Some("member"),
|
||
"reader" => Some("reader"),
|
||
_ => None,
|
||
};
|
||
|
||
if let Some(level) = access_level {
|
||
// Tilgangsgivende edge: transaksjon med recompute_access
|
||
let mut tx = state.db.begin().await.map_err(|e| {
|
||
tracing::error!("PG begin: {e}");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
sqlx::query(
|
||
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
|
||
)
|
||
.bind(edge_id)
|
||
.bind(req.source_id)
|
||
.bind(req.target_id)
|
||
.bind(&req.edge_type)
|
||
.bind(&metadata)
|
||
.bind(system)
|
||
.bind(user.node_id)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG insert edge: {e}");
|
||
internal_error("Databasefeil ved opprettelse av edge")
|
||
})?;
|
||
|
||
sqlx::query("SELECT recompute_access($1, $2, $3::access_level, $4)")
|
||
.bind(req.source_id)
|
||
.bind(req.target_id)
|
||
.bind(level)
|
||
.bind(edge_id)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("recompute_access: {e}");
|
||
internal_error("Databasefeil ved tilgangsberegning")
|
||
})?;
|
||
|
||
tx.commit().await.map_err(|e| {
|
||
tracing::error!("PG commit: {e}");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
} else {
|
||
sqlx::query(
|
||
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
|
||
)
|
||
.bind(edge_id)
|
||
.bind(req.source_id)
|
||
.bind(req.target_id)
|
||
.bind(&req.edge_type)
|
||
.bind(&metadata)
|
||
.bind(system)
|
||
.bind(user.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG insert edge: {e}");
|
||
internal_error("Databasefeil ved opprettelse av edge")
|
||
})?;
|
||
|
||
// Propager tilgang ved belongs_to: alle som har tilgang til target (forelder) får tilgang til source (barn)
|
||
if req.edge_type == "belongs_to" {
|
||
if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
|
||
.bind(req.source_id)
|
||
.bind(req.target_id)
|
||
.execute(&state.db)
|
||
.await
|
||
{
|
||
tracing::error!("propagate_belongs_to_access feilet: {e}");
|
||
}
|
||
}
|
||
|
||
// Trigger rendering ved belongs_to
|
||
if req.edge_type == "belongs_to" {
|
||
if let Ok(Some(config)) = crate::publishing::find_publishing_collection_by_id(&state.db, req.target_id).await {
|
||
let article_payload = serde_json::json!({
|
||
"node_id": req.source_id.to_string(),
|
||
"collection_id": req.target_id.to_string(),
|
||
});
|
||
let _ = crate::jobs::enqueue(&state.db, "render_article", article_payload, Some(req.target_id), 5).await;
|
||
|
||
let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
|
||
if index_mode == "static" {
|
||
let index_payload = serde_json::json!({ "collection_id": req.target_id.to_string() });
|
||
let _ = crate::jobs::enqueue(&state.db, "render_index", index_payload, Some(req.target_id), 4).await;
|
||
} else {
|
||
crate::publishing::invalidate_index_cache(&state.index_cache, req.target_id).await;
|
||
}
|
||
}
|
||
}
|
||
|
||
// A/B-test for presentasjonselement-edges
|
||
if matches!(req.edge_type.as_str(), "title" | "subtitle" | "summary" | "og_image") {
|
||
crate::publishing::maybe_start_ab_test(&state.db, req.target_id, &req.edge_type).await;
|
||
}
|
||
}
|
||
|
||
tracing::info!(
|
||
edge_id = %edge_id,
|
||
source_id = %req.source_id,
|
||
target_id = %req.target_id,
|
||
edge_type = %req.edge_type,
|
||
created_by = %user.node_id,
|
||
"Edge opprettet"
|
||
);
|
||
|
||
Ok(Json(CreateEdgeResponse { edge_id }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// update_node
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UpdateNodeRequest {
|
||
/// ID til noden som skal oppdateres.
|
||
pub node_id: Uuid,
|
||
/// Ny node_kind. Beholder eksisterende hvis None.
|
||
pub node_kind: Option<String>,
|
||
/// Ny tittel. Beholder eksisterende hvis None.
|
||
pub title: Option<String>,
|
||
/// Nytt innhold. Beholder eksisterende hvis None.
|
||
pub content: Option<String>,
|
||
/// Ny synlighet. Beholder eksisterende hvis None.
|
||
pub visibility: Option<String>,
|
||
/// Ny metadata. Beholder eksisterende hvis None.
|
||
pub metadata: Option<serde_json::Value>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct UpdateNodeResponse {
|
||
pub node_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/update_node
|
||
///
|
||
/// Oppdaterer en eksisterende node. Krever at brukeren er created_by
|
||
/// eller har owner/admin-edge til noden.
|
||
pub async fn update_node(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<UpdateNodeRequest>,
|
||
) -> Result<Json<UpdateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// -- Tilgangskontroll --
|
||
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !can_modify {
|
||
return Err(forbidden("Ingen tilgang til å endre denne noden"));
|
||
}
|
||
|
||
// -- Hent eksisterende node fra PG for å fylle inn manglende felt --
|
||
let existing = sqlx::query_as::<_, NodeRow>(
|
||
"SELECT node_kind, title, content, visibility::text, metadata FROM nodes WHERE id = $1",
|
||
)
|
||
.bind(req.node_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved henting av node: {e}");
|
||
internal_error("Databasefeil ved henting av node")
|
||
})?
|
||
.ok_or_else(|| bad_request(&format!("Node {} finnes ikke", req.node_id)))?;
|
||
|
||
let node_kind = req.node_kind.unwrap_or(existing.node_kind);
|
||
if node_kind.is_empty() {
|
||
return Err(bad_request("node_kind kan ikke være tom"));
|
||
}
|
||
|
||
let visibility = req.visibility.unwrap_or(existing.visibility);
|
||
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
|
||
return Err(bad_request(&format!(
|
||
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
|
||
)));
|
||
}
|
||
|
||
let title = req.title.unwrap_or(existing.title.unwrap_or_default());
|
||
let content = req.content.unwrap_or(existing.content.unwrap_or_default());
|
||
|
||
// Hent gamle publishing-verdier før existing.metadata flyttes
|
||
let old_publishing = existing.metadata
|
||
.get("traits")
|
||
.and_then(|t| t.get("publishing"));
|
||
|
||
let old_domain = old_publishing
|
||
.and_then(|p| p.get("custom_domain"))
|
||
.and_then(|d| d.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
|
||
let old_theme = old_publishing
|
||
.and_then(|p| p.get("theme"))
|
||
.cloned();
|
||
|
||
let old_theme_config = old_publishing
|
||
.and_then(|p| p.get("theme_config"))
|
||
.cloned();
|
||
|
||
// Hent gamle AI-preset-verdier før existing.metadata flyttes (oppgave 18.6)
|
||
let old_model_profile = existing.metadata
|
||
.get("model_profile")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
let old_category = existing.metadata
|
||
.get("category")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
|
||
let metadata = req.metadata.unwrap_or(existing.metadata);
|
||
|
||
// -- Valider traits for samlingsnoder (oppgave 13.1) --
|
||
validate_collection_traits(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
|
||
|
||
// -- Valider metadata for AI-presets (oppgave 18.1) --
|
||
validate_ai_preset_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
|
||
|
||
// -- Valider metadata for orchestration-noder (oppgave 24.1) --
|
||
validate_orchestration_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;
|
||
|
||
// -- Beskytt model_profile for egendefinerte AI-presets (oppgave 18.6) --
|
||
// Kun admin/owner på en samling kan endre model_profile. Vanlige brukere
|
||
// som eier et custom preset kan redigere alt annet (prompt, icon, farge osv.)
|
||
if node_kind == "ai_preset" {
|
||
let new_profile = metadata.get("model_profile").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
|
||
if new_profile != old_model_profile {
|
||
// Sjekk om brukeren er admin (har owner-edge til en samling)
|
||
let is_admin_user = sqlx::query_scalar::<_, bool>(
|
||
r#"
|
||
SELECT EXISTS(
|
||
SELECT 1 FROM edges
|
||
WHERE source_id = $1
|
||
AND edge_type IN ('owner', 'admin')
|
||
AND target_id IN (SELECT id FROM nodes WHERE node_kind = 'collection')
|
||
)
|
||
"#,
|
||
)
|
||
.bind(user.node_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG-feil ved admin-sjekk for model_profile");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
if !is_admin_user {
|
||
return Err(forbidden(
|
||
"Kun admin kan endre modellprofil for AI-presets",
|
||
));
|
||
}
|
||
}
|
||
|
||
// Forhindre at vanlige brukere endrer category fra custom til standard
|
||
let new_cat = metadata.get("category").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
if new_cat != old_category && new_cat.as_deref() == Some("standard") {
|
||
return Err(forbidden(
|
||
"Kan ikke endre kategori til 'standard' — det er reservert for systempresets",
|
||
));
|
||
}
|
||
}
|
||
|
||
// -- Sjekk om custom_domain er endret (for re-rendering) --
|
||
let new_domain = metadata
|
||
.get("traits")
|
||
.and_then(|t| t.get("publishing"))
|
||
.and_then(|p| p.get("custom_domain"))
|
||
.and_then(|d| d.as_str())
|
||
.unwrap_or("");
|
||
let domain_changed = old_domain != new_domain && node_kind == "collection";
|
||
|
||
// -- Sjekk om theme eller theme_config er endret (for bulk re-rendering, oppgave 14.14) --
|
||
let theme_changed = if node_kind == "collection" {
|
||
let new_publishing = metadata
|
||
.get("traits")
|
||
.and_then(|t| t.get("publishing"));
|
||
|
||
let new_theme = new_publishing.and_then(|p| p.get("theme")).cloned();
|
||
let new_theme_config = new_publishing.and_then(|p| p.get("theme_config")).cloned();
|
||
|
||
old_theme != new_theme || old_theme_config != new_theme_config
|
||
} else {
|
||
false
|
||
};
|
||
|
||
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
|
||
sqlx::query(
|
||
r#"UPDATE nodes SET node_kind = $2, title = NULLIF($3, ''), content = NULLIF($4, ''),
|
||
visibility = $5::visibility, metadata = $6 WHERE id = $1"#,
|
||
)
|
||
.bind(req.node_id)
|
||
.bind(&node_kind)
|
||
.bind(&title)
|
||
.bind(&content)
|
||
.bind(&visibility)
|
||
.bind(&metadata)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved oppdatering av node: {e}");
|
||
internal_error("Databasefeil ved oppdatering av node")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
node_id = %req.node_id,
|
||
updated_by = %user.node_id,
|
||
"Node oppdatert"
|
||
);
|
||
|
||
// -- Re-render alle artikler hvis custom_domain endret (canonical URL) --
|
||
if domain_changed {
|
||
let db = state.db.clone();
|
||
let collection_id = req.node_id;
|
||
tokio::spawn(async move {
|
||
match crate::custom_domain::rerender_collection_articles(&db, collection_id).await {
|
||
Ok(count) => tracing::info!(
|
||
collection_id = %collection_id,
|
||
articles = count,
|
||
"Re-rendering trigget etter domeneendring"
|
||
),
|
||
Err(e) => tracing::error!(
|
||
collection_id = %collection_id,
|
||
error = %e,
|
||
"Feil ved re-rendering etter domeneendring"
|
||
),
|
||
}
|
||
});
|
||
}
|
||
|
||
// -- Bulk re-rendering hvis theme/theme_config endret (oppgave 14.14) --
|
||
if theme_changed {
|
||
let db = state.db.clone();
|
||
let collection_id = req.node_id;
|
||
tokio::spawn(async move {
|
||
match crate::publishing::trigger_bulk_rerender(&db, collection_id).await {
|
||
Ok(count) => tracing::info!(
|
||
collection_id = %collection_id,
|
||
articles = count,
|
||
"Bulk re-rendering trigget etter temaendring"
|
||
),
|
||
Err(e) => tracing::error!(
|
||
collection_id = %collection_id,
|
||
error = %e,
|
||
"Feil ved bulk re-rendering etter temaendring"
|
||
),
|
||
}
|
||
});
|
||
}
|
||
|
||
Ok(Json(UpdateNodeResponse { node_id: req.node_id }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// delete_node
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct DeleteNodeRequest {
|
||
/// ID til noden som skal slettes.
|
||
pub node_id: Uuid,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct DeleteNodeResponse {
|
||
pub deleted: bool,
|
||
}
|
||
|
||
/// POST /intentions/delete_node
|
||
///
|
||
/// Sletter en node og alle dens edges (CASCADE i PG).
|
||
/// Krever at brukeren er created_by eller har owner/admin-edge til noden.
|
||
pub async fn delete_node(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<DeleteNodeRequest>,
|
||
) -> Result<Json<DeleteNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// -- Tilgangskontroll --
|
||
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !can_modify {
|
||
return Err(forbidden("Ingen tilgang til å slette denne noden"));
|
||
}
|
||
|
||
// Sjekk at noden eksisterer
|
||
let exists = node_exists(&state.db, req.node_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved nodesjekk: {e}");
|
||
internal_error("Databasefeil ved validering")
|
||
})?;
|
||
|
||
if !exists {
|
||
return Err(bad_request(&format!("Node {} finnes ikke", req.node_id)));
|
||
}
|
||
|
||
// -- Slett fra PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
|
||
sqlx::query("DELETE FROM nodes WHERE id = $1")
|
||
.bind(req.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved sletting av node: {e}");
|
||
internal_error("Databasefeil ved sletting av node")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
node_id = %req.node_id,
|
||
deleted_by = %user.node_id,
|
||
"Node slettet"
|
||
);
|
||
|
||
Ok(Json(DeleteNodeResponse { deleted: true }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// update_edge
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UpdateEdgeRequest {
|
||
/// ID til edgen som skal oppdateres.
|
||
pub edge_id: Uuid,
|
||
/// Ny edge_type. Beholder eksisterende hvis None.
|
||
pub edge_type: Option<String>,
|
||
/// Ny metadata. Beholder eksisterende hvis None.
|
||
pub metadata: Option<serde_json::Value>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct UpdateEdgeResponse {
|
||
pub edge_id: Uuid,
|
||
}
|
||
|
||
/// Henter en edge fra PG.
|
||
async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result<Option<EdgeRow>, sqlx::Error> {
|
||
sqlx::query_as::<_, EdgeRow>(
|
||
"SELECT source_id, target_id, edge_type, metadata FROM edges WHERE id = $1",
|
||
)
|
||
.bind(edge_id)
|
||
.fetch_optional(db)
|
||
.await
|
||
}
|
||
|
||
#[derive(sqlx::FromRow)]
|
||
#[allow(dead_code)]
|
||
struct EdgeRow {
|
||
source_id: Uuid,
|
||
target_id: Uuid,
|
||
edge_type: String,
|
||
metadata: serde_json::Value,
|
||
}
|
||
|
||
/// POST /intentions/update_edge
|
||
///
|
||
/// Oppdaterer en eksisterende edge (type og/eller metadata).
|
||
/// Krever at brukeren har opprettet edgen, eller har owner/admin-edge
|
||
/// til source-noden.
|
||
pub async fn update_edge(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<UpdateEdgeRequest>,
|
||
) -> Result<Json<UpdateEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// -- Tilgangskontroll --
|
||
let can_modify = user_can_modify_edge(&state.db, user.node_id, req.edge_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !can_modify {
|
||
return Err(forbidden("Ingen tilgang til å endre denne edgen"));
|
||
}
|
||
|
||
// -- Hent eksisterende edge --
|
||
let existing = get_edge(&state.db, req.edge_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved henting av edge: {e}");
|
||
internal_error("Databasefeil ved henting av edge")
|
||
})?
|
||
.ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;
|
||
|
||
let edge_type = req.edge_type.unwrap_or(existing.edge_type.clone());
|
||
if edge_type.is_empty() {
|
||
return Err(bad_request("edge_type kan ikke være tom"));
|
||
}
|
||
|
||
let metadata = req.metadata.unwrap_or(existing.metadata.clone());
|
||
|
||
// -- submitted_to status-endring: kun owner/admin av samlingen --
|
||
if existing.edge_type == "submitted_to" {
|
||
let new_status = metadata.get("status").and_then(|v| v.as_str());
|
||
let old_status = existing.metadata.get("status").and_then(|v| v.as_str());
|
||
if new_status != old_status {
|
||
let is_owner_admin =
|
||
user_is_owner_or_admin(&state.db, user.node_id, existing.target_id).await?;
|
||
if !is_owner_admin {
|
||
return Err(forbidden(
|
||
"Kun owner/admin av samlingen kan endre status på innsendte artikler",
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// -- Validering av source_material-metadata ved oppdatering --
|
||
if edge_type == "source_material" {
|
||
if let Some(obj) = metadata.as_object() {
|
||
match obj.get("context").and_then(|v| v.as_str()) {
|
||
Some("quoted" | "summarized" | "referenced") => {}
|
||
Some(other) => {
|
||
return Err(bad_request(&format!(
|
||
"source_material context må være 'quoted', 'summarized' eller 'referenced', fikk '{other}'"
|
||
)));
|
||
}
|
||
None => {
|
||
return Err(bad_request(
|
||
"source_material krever 'context'-felt (quoted, summarized eller referenced)",
|
||
));
|
||
}
|
||
}
|
||
match obj.get("excerpt").and_then(|v| v.as_str()) {
|
||
Some(s) if !s.trim().is_empty() => {}
|
||
_ => {
|
||
return Err(bad_request(
|
||
"source_material krever et ikke-tomt 'excerpt'-felt",
|
||
));
|
||
}
|
||
}
|
||
} else {
|
||
return Err(bad_request(
|
||
"source_material krever metadata med 'context' og 'excerpt'",
|
||
));
|
||
}
|
||
}
|
||
|
||
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
|
||
sqlx::query("UPDATE edges SET edge_type = $1, metadata = $2 WHERE id = $3")
|
||
.bind(&edge_type)
|
||
.bind(&metadata)
|
||
.bind(req.edge_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved oppdatering av edge: {e}");
|
||
internal_error("Databasefeil ved oppdatering av edge")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
edge_id = %req.edge_id,
|
||
edge_type = %edge_type,
|
||
updated_by = %user.node_id,
|
||
"Edge oppdatert"
|
||
);
|
||
|
||
Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// delete_edge
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct DeleteEdgeRequest {
|
||
/// ID til edgen som skal slettes.
|
||
pub edge_id: Uuid,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct DeleteEdgeResponse {
|
||
pub deleted: bool,
|
||
}
|
||
|
||
#[derive(sqlx::FromRow)]
|
||
#[allow(dead_code)]
|
||
struct FullEdgeRow {
|
||
source_id: Uuid,
|
||
target_id: Uuid,
|
||
edge_type: String,
|
||
}
|
||
|
||
/// POST /intentions/delete_edge
|
||
///
|
||
/// Sletter en edge. Brukes bl.a. for avpublisering (fjerner belongs_to-edge).
|
||
/// Krever at brukeren har opprettet edgen, eller har owner/admin-edge
|
||
/// til source-noden.
|
||
pub async fn delete_edge(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<DeleteEdgeRequest>,
|
||
) -> Result<Json<DeleteEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// -- Tilgangskontroll --
|
||
let can_modify = user_can_modify_edge(&state.db, user.node_id, req.edge_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !can_modify {
|
||
return Err(forbidden("Ingen tilgang til å slette denne edgen"));
|
||
}
|
||
|
||
// Hent edge-info for logging og publiserings-invalidering
|
||
let edge_info = sqlx::query_as::<_, FullEdgeRow>(
|
||
"SELECT source_id, target_id, edge_type FROM edges WHERE id = $1",
|
||
)
|
||
.bind(req.edge_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved henting av edge: {e}");
|
||
internal_error("Databasefeil ved henting av edge")
|
||
})?
|
||
.ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;
|
||
|
||
// -- Slett fra PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
|
||
sqlx::query("DELETE FROM edges WHERE id = $1")
|
||
.bind(req.edge_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved sletting av edge: {e}");
|
||
internal_error("Databasefeil ved sletting av edge")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
edge_id = %req.edge_id,
|
||
edge_type = %edge_info.edge_type,
|
||
deleted_by = %user.node_id,
|
||
"Edge slettet"
|
||
);
|
||
|
||
// Invalider publiserings-cache ved fjerning av belongs_to
|
||
if edge_info.edge_type == "belongs_to" {
|
||
if let Ok(Some(config)) = crate::publishing::find_publishing_collection_by_id(&state.db, edge_info.target_id).await {
|
||
let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
|
||
if index_mode == "static" {
|
||
let index_payload = serde_json::json!({ "collection_id": edge_info.target_id.to_string() });
|
||
let _ = crate::jobs::enqueue(&state.db, "render_index", index_payload, Some(edge_info.target_id), 4).await;
|
||
} else {
|
||
crate::publishing::invalidate_index_cache(&state.index_cache, edge_info.target_id).await;
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(Json(DeleteEdgeResponse { deleted: true }))
|
||
}
|
||
|
||
|
||
// =============================================================================
|
||
// set_slot — Redaksjonell slot-håndtering for publiseringssamlinger
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct SetSlotRequest {
|
||
/// ID til belongs_to-edgen mellom artikkel og samling.
|
||
pub edge_id: Uuid,
|
||
/// Slot: "hero", "featured", eller null/tom for strøm.
|
||
pub slot: Option<String>,
|
||
/// Rekkefølge innen featured-slot (ignoreres for hero/strøm).
|
||
pub slot_order: Option<i64>,
|
||
/// Forhindrer automatisk fjerning fra slot (FIFO/hero-erstatning).
|
||
pub pinned: Option<bool>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct SetSlotResponse {
|
||
/// Edgen som ble oppdatert.
|
||
pub edge_id: Uuid,
|
||
/// Edges som ble flyttet til strøm pga. hero-erstatning eller featured-overflow.
|
||
pub displaced: Vec<Uuid>,
|
||
}
|
||
|
||
/// POST /intentions/set_slot
|
||
///
|
||
/// Setter slot-metadata på en belongs_to-edge i en publiseringssamling.
|
||
/// Håndhever:
|
||
/// - Maks 1 hero: gammel (ikke-pinned) hero flyttes til strøm.
|
||
/// - featured_max: eldste (ikke-pinned) featured flyttes til strøm (FIFO).
|
||
/// - pinned-flagg beskytter mot automatisk fjerning.
|
||
///
|
||
/// Krever owner/admin-tilgang til samlingen.
|
||
pub async fn set_slot(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<SetSlotRequest>,
|
||
) -> Result<Json<SetSlotResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Valider slot-verdi
|
||
let slot = req.slot.as_deref().unwrap_or("");
|
||
if !matches!(slot, "" | "hero" | "featured") {
|
||
return Err(bad_request("slot må være \"hero\", \"featured\", eller null/tom for strøm"));
|
||
}
|
||
|
||
// Hent edgen og valider at det er en belongs_to-edge
|
||
#[derive(sqlx::FromRow)]
|
||
struct FullEdgeRow {
|
||
source_id: Uuid,
|
||
target_id: Uuid,
|
||
edge_type: String,
|
||
metadata: serde_json::Value,
|
||
}
|
||
|
||
let edge = sqlx::query_as::<_, FullEdgeRow>(
|
||
"SELECT source_id, target_id, edge_type, metadata FROM edges WHERE id = $1",
|
||
)
|
||
.bind(req.edge_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved henting av edge: {e}");
|
||
internal_error("Databasefeil ved henting av edge")
|
||
})?
|
||
.ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;
|
||
|
||
if edge.edge_type != "belongs_to" {
|
||
return Err(bad_request("set_slot kan kun brukes på belongs_to-edges"));
|
||
}
|
||
|
||
let collection_id = edge.target_id;
|
||
|
||
// Sjekk at target er en publiseringssamling
|
||
let pub_config = crate::publishing::find_publishing_collection_by_id(&state.db, collection_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved sjekk av publiseringssamling: {e}");
|
||
internal_error("Databasefeil")
|
||
})?
|
||
.ok_or_else(|| bad_request("Samlingen har ikke publishing-trait"))?;
|
||
|
||
// Sjekk at brukeren er owner/admin på samlingen
|
||
let is_admin = sqlx::query_scalar::<_, bool>(
|
||
r#"
|
||
SELECT EXISTS(
|
||
SELECT 1 FROM edges
|
||
WHERE source_id = $1 AND target_id = $2
|
||
AND edge_type IN ('owner', 'admin')
|
||
)
|
||
"#,
|
||
)
|
||
.bind(user.node_id)
|
||
.bind(collection_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !is_admin {
|
||
return Err(forbidden("Kun owner/admin kan endre slots"));
|
||
}
|
||
|
||
let featured_max = pub_config.featured_max.unwrap_or(4);
|
||
let mut displaced: Vec<Uuid> = Vec::new();
|
||
|
||
// -- Slot-logikk: håndter hero-erstatning og featured-overflow --
|
||
|
||
if slot == "hero" {
|
||
// Finn eksisterende hero-edges (som ikke er denne edgen)
|
||
let existing_heroes: Vec<(Uuid, serde_json::Value)> = sqlx::query_as(
|
||
r#"
|
||
SELECT id, metadata FROM edges
|
||
WHERE target_id = $1
|
||
AND edge_type = 'belongs_to'
|
||
AND metadata->>'slot' = 'hero'
|
||
AND id != $2
|
||
"#,
|
||
)
|
||
.bind(collection_id)
|
||
.bind(req.edge_id)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved hero-sjekk: {e}");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
// Flytt ikke-pinned heroes tilbake til strøm
|
||
for (hero_edge_id, hero_meta) in &existing_heroes {
|
||
let pinned = hero_meta.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false);
|
||
if !pinned {
|
||
displace_to_stream(&state.db, *hero_edge_id, hero_meta).await?;
|
||
displaced.push(*hero_edge_id);
|
||
}
|
||
}
|
||
} else if slot == "featured" {
|
||
// Tell nåværende featured-edges (ekskluder denne edgen om den allerede er featured)
|
||
let current_featured: Vec<(Uuid, serde_json::Value, Option<i64>)> = sqlx::query_as(
|
||
r#"
|
||
SELECT id, metadata,
|
||
(metadata->>'slot_order')::bigint as slot_order
|
||
FROM edges
|
||
WHERE target_id = $1
|
||
AND edge_type = 'belongs_to'
|
||
AND metadata->>'slot' = 'featured'
|
||
AND id != $2
|
||
ORDER BY (metadata->>'slot_order')::int ASC NULLS LAST,
|
||
created_at ASC
|
||
"#,
|
||
)
|
||
.bind(collection_id)
|
||
.bind(req.edge_id)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved featured-sjekk: {e}");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
// Hvis vi legger til en ny featured og det overstiger featured_max,
|
||
// fjern eldste (FIFO) ikke-pinned featured
|
||
let new_count = current_featured.len() as i64 + 1; // +1 for den vi setter nå
|
||
if new_count > featured_max {
|
||
let overflow = (new_count - featured_max) as usize;
|
||
// Finn de eldste ikke-pinned featured-edges å fjerne (FIFO = sist i listen)
|
||
let removable: Vec<&(Uuid, serde_json::Value, Option<i64>)> = current_featured
|
||
.iter()
|
||
.rev() // Høyest slot_order / eldst først for FIFO
|
||
.filter(|(_, meta, _)| {
|
||
!meta.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false)
|
||
})
|
||
.take(overflow)
|
||
.collect();
|
||
|
||
for (feat_edge_id, feat_meta, _) in removable {
|
||
displace_to_stream(&state.db, *feat_edge_id, feat_meta).await?;
|
||
displaced.push(*feat_edge_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// -- Oppdater edgen med ny slot-metadata --
|
||
let mut new_meta = edge.metadata.clone();
|
||
if let Some(obj) = new_meta.as_object_mut() {
|
||
if slot.is_empty() {
|
||
obj.remove("slot");
|
||
obj.remove("slot_order");
|
||
} else {
|
||
obj.insert("slot".to_string(), serde_json::json!(slot));
|
||
if slot == "featured" {
|
||
if let Some(order) = req.slot_order {
|
||
obj.insert("slot_order".to_string(), serde_json::json!(order));
|
||
}
|
||
} else {
|
||
obj.remove("slot_order");
|
||
}
|
||
}
|
||
// Sett/fjern pinned
|
||
match req.pinned {
|
||
Some(true) => { obj.insert("pinned".to_string(), serde_json::json!(true)); }
|
||
Some(false) => { obj.remove("pinned"); }
|
||
None => {} // Behold eksisterende
|
||
}
|
||
}
|
||
|
||
// Skriv til PG (NOTIFY-trigger sender sanntidsoppdatering)
|
||
sqlx::query("UPDATE edges SET metadata = $1 WHERE id = $2")
|
||
.bind(&new_meta)
|
||
.bind(req.edge_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved slot-oppdatering: {e}");
|
||
internal_error("Databasefeil ved slot-oppdatering")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
edge_id = %req.edge_id,
|
||
slot = %slot,
|
||
displaced_count = displaced.len(),
|
||
"Slot oppdatert"
|
||
);
|
||
|
||
// Trigger forside-rerendering
|
||
trigger_render_if_publishing(&state.db, &state.index_cache, edge.source_id, collection_id).await;
|
||
|
||
Ok(Json(SetSlotResponse {
|
||
edge_id: req.edge_id,
|
||
displaced,
|
||
}))
|
||
}
|
||
|
||
/// Flytter en edge fra sin nåværende slot tilbake til strøm (slot=null).
|
||
/// Beholder annen metadata (publish_at, approved_by, etc.).
|
||
async fn displace_to_stream(
|
||
db: &PgPool,
|
||
edge_id: Uuid,
|
||
current_meta: &serde_json::Value,
|
||
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
|
||
let mut meta = current_meta.clone();
|
||
if let Some(obj) = meta.as_object_mut() {
|
||
obj.remove("slot");
|
||
obj.remove("slot_order");
|
||
// pinned er irrelevant i strøm, fjern det
|
||
obj.remove("pinned");
|
||
}
|
||
|
||
sqlx::query("UPDATE edges SET metadata = $1 WHERE id = $2")
|
||
.bind(&meta)
|
||
.bind(edge_id)
|
||
.execute(db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved displace_to_stream: {e}");
|
||
internal_error("Databasefeil ved fjerning fra slot")
|
||
})?;
|
||
|
||
tracing::info!(edge_id = %edge_id, "Edge fjernet fra slot → strøm");
|
||
Ok(())
|
||
}
|
||
|
||
// =============================================================================
|
||
// create_communication
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateCommunicationRequest {
|
||
/// Visningstittel for kommunikasjonsnoden (f.eks. "Redaksjonsmøte").
|
||
pub title: Option<String>,
|
||
/// Deltakere — liste med node_id-er (person-noder).
|
||
/// Innlogget bruker legges automatisk til som owner.
|
||
pub participants: Vec<Uuid>,
|
||
/// Synlighet. Default: "hidden" (privat).
|
||
pub visibility: Option<String>,
|
||
/// Kontekst-node (f.eks. artikkel). Gir automatisk belongs_to-edge
|
||
/// fra kommunikasjonsnoden til kontekstnoden.
|
||
pub context_id: Option<Uuid>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CreateCommunicationResponse {
|
||
pub node_id: Uuid,
|
||
/// Edge-IDer for opprettede deltaker-edges (owner + member_of).
|
||
pub edge_ids: Vec<Uuid>,
|
||
}
|
||
|
||
/// POST /intentions/create_communication
|
||
///
|
||
/// Oppretter en kommunikasjonsnode med deltaker-edges.
|
||
/// Innlogget bruker blir automatisk owner. Andre deltakere får member_of-edge.
|
||
/// Metadata inneholder started_at-tidsstempel.
|
||
///
|
||
/// Ref: docs/primitiver/nodes.md (communication), docs/retninger/universell_input.md
|
||
pub async fn create_communication(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<CreateCommunicationRequest>,
|
||
) -> Result<Json<CreateCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string());
|
||
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
|
||
return Err(bad_request(&format!(
|
||
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
|
||
)));
|
||
}
|
||
|
||
// Valider at alle deltakere eksisterer
|
||
for participant_id in &req.participants {
|
||
let exists = node_exists(&state.db, *participant_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved nodesjekk: {e}");
|
||
internal_error("Databasefeil ved validering")
|
||
})?;
|
||
if !exists {
|
||
return Err(bad_request(&format!(
|
||
"Deltaker-node {} finnes ikke",
|
||
participant_id
|
||
)));
|
||
}
|
||
}
|
||
|
||
let title = req.title.unwrap_or_default();
|
||
let now = chrono::Utc::now();
|
||
let metadata = serde_json::json!({ "started_at": now.to_rfc3339() });
|
||
|
||
// -- Opprett kommunikasjonsnoden --
|
||
let node_id = Uuid::now_v7();
|
||
|
||
sqlx::query(
|
||
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
|
||
VALUES ($1, 'communication', NULLIF($2, ''), '', $3::visibility, $4, $5)"#,
|
||
)
|
||
.bind(node_id)
|
||
.bind(&title)
|
||
.bind(&visibility)
|
||
.bind(&metadata)
|
||
.bind(user.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved opprettelse av kommunikasjonsnode: {e}");
|
||
internal_error("Databasefeil ved opprettelse av kommunikasjonsnode")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
node_id = %node_id,
|
||
created_by = %user.node_id,
|
||
participants = ?req.participants,
|
||
"Kommunikasjonsnode opprettet"
|
||
);
|
||
|
||
// -- Opprett deltaker-edges --
|
||
let mut edge_ids = Vec::new();
|
||
|
||
// Owner-edge for innlogget bruker (med recompute_access)
|
||
let owner_edge_id = Uuid::now_v7();
|
||
edge_ids.push(owner_edge_id);
|
||
|
||
{
|
||
let mut tx = state.db.begin().await.map_err(|e| {
|
||
tracing::error!("PG begin: {e}");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
sqlx::query(
|
||
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
||
VALUES ($1, $2, $3, 'owner', '{}', false, $4)"#,
|
||
)
|
||
.bind(owner_edge_id)
|
||
.bind(user.node_id)
|
||
.bind(node_id)
|
||
.bind(user.node_id)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG insert owner edge: {e}");
|
||
internal_error("Databasefeil ved opprettelse av owner-edge")
|
||
})?;
|
||
|
||
sqlx::query("SELECT recompute_access($1, $2, 'owner'::access_level, $3)")
|
||
.bind(user.node_id)
|
||
.bind(node_id)
|
||
.bind(owner_edge_id)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("recompute_access: {e}");
|
||
internal_error("Databasefeil ved tilgangsberegning")
|
||
})?;
|
||
|
||
tx.commit().await.map_err(|e| {
|
||
tracing::error!("PG commit: {e}");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
}
|
||
|
||
// member_of-edges for øvrige deltakere
|
||
for participant_id in &req.participants {
|
||
// Hopp over innlogget bruker — allerede owner
|
||
if *participant_id == user.node_id {
|
||
continue;
|
||
}
|
||
|
||
let edge_id = Uuid::now_v7();
|
||
edge_ids.push(edge_id);
|
||
|
||
let mut tx = state.db.begin().await.map_err(|e| {
|
||
tracing::error!("PG begin: {e}");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
sqlx::query(
|
||
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
||
VALUES ($1, $2, $3, 'member_of', '{}', false, $4)"#,
|
||
)
|
||
.bind(edge_id)
|
||
.bind(*participant_id)
|
||
.bind(node_id)
|
||
.bind(user.node_id)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG insert member_of edge: {e}");
|
||
internal_error("Databasefeil ved opprettelse av member_of-edge")
|
||
})?;
|
||
|
||
sqlx::query("SELECT recompute_access($1, $2, 'member'::access_level, $3)")
|
||
.bind(*participant_id)
|
||
.bind(node_id)
|
||
.bind(edge_id)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("recompute_access: {e}");
|
||
internal_error("Databasefeil ved tilgangsberegning")
|
||
})?;
|
||
|
||
tx.commit().await.map_err(|e| {
|
||
tracing::error!("PG commit: {e}");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
}
|
||
|
||
// -- Opprett belongs_to-edge til kontekstnode (f.eks. artikkel) --
|
||
if let Some(context_id) = req.context_id {
|
||
let ctx_exists = node_exists(&state.db, context_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved kontekstnode-sjekk: {e}");
|
||
internal_error("Databasefeil ved validering")
|
||
})?;
|
||
if !ctx_exists {
|
||
return Err(bad_request(&format!(
|
||
"Kontekst-node {} finnes ikke",
|
||
context_id
|
||
)));
|
||
}
|
||
|
||
let ctx_edge_id = Uuid::now_v7();
|
||
edge_ids.push(ctx_edge_id);
|
||
|
||
sqlx::query(
|
||
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
||
VALUES ($1, $2, $3, 'belongs_to', '{}', false, $4)"#,
|
||
)
|
||
.bind(ctx_edge_id)
|
||
.bind(node_id)
|
||
.bind(context_id)
|
||
.bind(user.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG insert belongs_to edge: {e}");
|
||
internal_error("Databasefeil ved opprettelse av edge")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
communication_id = %node_id,
|
||
context_id = %context_id,
|
||
"belongs_to-edge opprettet til kontekstnode"
|
||
);
|
||
|
||
// Propager tilgang fra kontekstnoden
|
||
if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
|
||
.bind(node_id)
|
||
.bind(context_id)
|
||
.execute(&state.db)
|
||
.await
|
||
{
|
||
tracing::error!("propagate_belongs_to_access feilet: {e}");
|
||
}
|
||
}
|
||
|
||
tracing::info!(
|
||
node_id = %node_id,
|
||
edge_count = edge_ids.len(),
|
||
"Kommunikasjonsnode med deltaker-edges opprettet"
|
||
);
|
||
|
||
Ok(Json(CreateCommunicationResponse { node_id, edge_ids }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// upload_media
|
||
// =============================================================================
|
||
|
||
#[derive(Serialize)]
|
||
pub struct UploadMediaResponse {
|
||
/// ID til den opprettede media-noden.
|
||
pub media_node_id: Uuid,
|
||
/// SHA-256 hash (CAS-nøkkel).
|
||
pub cas_hash: String,
|
||
/// Filstørrelse i bytes.
|
||
pub size_bytes: u64,
|
||
/// `true` hvis filen allerede fantes i CAS (deduplisert).
|
||
pub already_existed: bool,
|
||
/// Edge-ID for `has_media`-edge (kun hvis source_id ble oppgitt).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub has_media_edge_id: Option<Uuid>,
|
||
}
|
||
|
||
/// POST /intentions/upload_media
|
||
///
|
||
/// Mottar en fil via multipart form data, lagrer i CAS, oppretter en
|
||
/// media-node med CAS-metadata. Hvis `source_id` er oppgitt, opprettes
|
||
/// en `has_media`-edge fra kildenoden til den nye media-noden.
|
||
///
|
||
/// Multipart-felter:
|
||
/// - `file` (påkrevd): Binærfilen som skal lastes opp.
|
||
/// - `source_id` (valgfritt): Node-ID å koble media til via `has_media`-edge.
|
||
/// - `visibility` (valgfritt): Synlighet for media-noden. Default: "hidden".
|
||
/// - `title` (valgfritt): Tittel for media-noden (default: filnavn).
|
||
/// - `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<AppState>,
|
||
user: AuthUser,
|
||
mut multipart: Multipart,
|
||
) -> Result<Json<UploadMediaResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let mut file_data: Option<Vec<u8>> = None;
|
||
let mut file_name: Option<String> = None;
|
||
let mut content_type: Option<String> = None;
|
||
let mut source_id: Option<Uuid> = None;
|
||
let mut visibility = "hidden".to_string();
|
||
let mut title: Option<String> = None;
|
||
let mut metadata_extra: Option<serde_json::Value> = 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<Option<Uuid>, sqlx::Error> {
|
||
let row = sqlx::query_scalar::<_, Uuid>(
|
||
r#"
|
||
SELECT e.target_id
|
||
FROM edges e
|
||
JOIN nodes n ON n.id = e.target_id
|
||
WHERE e.source_id = $1
|
||
AND e.edge_type = 'belongs_to'
|
||
AND n.node_kind = 'collection'
|
||
LIMIT 1
|
||
"#,
|
||
)
|
||
.bind(node_id)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
Ok(row)
|
||
}
|
||
|
||
// =============================================================================
|
||
// create_alias
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateAliasRequest {
|
||
/// Visningsnavn for aliaset (f.eks. "Bjørn" for en podcastvert-identitet).
|
||
pub title: String,
|
||
/// Valgfri metadata (f.eks. display_name, bio, avatar).
|
||
pub metadata: Option<serde_json::Value>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CreateAliasResponse {
|
||
/// ID til den nye alias-noden.
|
||
pub alias_node_id: Uuid,
|
||
/// ID til alias-edgen (system=true, usynlig for traversering).
|
||
pub alias_edge_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/create_alias
|
||
///
|
||
/// Oppretter en alias-node (node_kind='person') og en `alias`-edge
|
||
/// (system=true) fra brukerens hovednode til aliasnoden. Alias-edgen
|
||
/// er usynlig for traversering — RLS-policyen filtrerer system-edges.
|
||
///
|
||
/// Bruksområde: en bruker kan ha flere identiteter (f.eks. Vegard
|
||
/// som seg selv og "Bjørn" som podcastvert). Oppgave 8.2 vil bruke
|
||
/// aliaset til å sette created_by kontekstbasert.
|
||
///
|
||
/// Ref: docs/primitiver/edges.md (systemedges), docs/primitiver/nodes.md
|
||
pub async fn create_alias(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<CreateAliasRequest>,
|
||
) -> Result<Json<CreateAliasResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let title = req.title.trim().to_string();
|
||
if title.is_empty() {
|
||
return Err(bad_request("Alias-tittel kan ikke være tom"));
|
||
}
|
||
|
||
let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({}));
|
||
|
||
// -- Generer IDer --
|
||
let alias_node_id = Uuid::now_v7();
|
||
let alias_edge_id = Uuid::now_v7();
|
||
|
||
// -- Skriv alias-node og alias-edge til PG --
|
||
sqlx::query(
|
||
r#"INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by)
|
||
VALUES ($1, 'person', $2, 'hidden'::visibility, $3, $4)"#,
|
||
)
|
||
.bind(alias_node_id)
|
||
.bind(&title)
|
||
.bind(&metadata)
|
||
.bind(user.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved opprettelse av alias-node: {e}");
|
||
internal_error("Databasefeil ved opprettelse av alias")
|
||
})?;
|
||
|
||
sqlx::query(
|
||
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
||
VALUES ($1, $2, $3, 'alias', '{}', true, $4)"#,
|
||
)
|
||
.bind(alias_edge_id)
|
||
.bind(user.node_id)
|
||
.bind(alias_node_id)
|
||
.bind(user.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved opprettelse av alias-edge: {e}");
|
||
internal_error("Databasefeil ved opprettelse av alias-edge")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
alias_node_id = %alias_node_id,
|
||
alias_edge_id = %alias_edge_id,
|
||
user_node_id = %user.node_id,
|
||
title = %title,
|
||
"Alias opprettet"
|
||
);
|
||
|
||
Ok(Json(CreateAliasResponse {
|
||
alias_node_id,
|
||
alias_edge_id,
|
||
}))
|
||
}
|
||
|
||
// =============================================================================
|
||
// Bakgrunns-PG-operasjoner
|
||
// =============================================================================
|
||
|
||
#[derive(sqlx::FromRow)]
|
||
struct NodeRow {
|
||
node_kind: String,
|
||
title: Option<String>,
|
||
content: Option<String>,
|
||
visibility: String,
|
||
metadata: serde_json::Value,
|
||
}
|
||
|
||
/// Enkel rad for å sjekke node_kind (brukes ved context_id-validering).
|
||
#[derive(sqlx::FromRow)]
|
||
struct NodeKindRow {
|
||
node_kind: String,
|
||
}
|
||
|
||
|
||
|
||
/// Sjekker om target er en samling med publishing-trait, og legger i så fall
|
||
/// en `render_article`-jobb i køen. For statisk modus legges også en
|
||
/// `render_index`-jobb. For dynamisk modus invalideres in-memory-cachen.
|
||
async fn trigger_render_if_publishing(
|
||
db: &PgPool,
|
||
index_cache: &crate::publishing::IndexCache,
|
||
source_id: Uuid,
|
||
target_id: Uuid,
|
||
) {
|
||
match crate::publishing::find_publishing_collection_by_id(db, target_id).await {
|
||
Ok(Some(config)) => {
|
||
// Render artikkelen
|
||
let article_payload = serde_json::json!({
|
||
"node_id": source_id.to_string(),
|
||
"collection_id": target_id.to_string(),
|
||
});
|
||
|
||
match crate::jobs::enqueue(db, "render_article", article_payload, Some(target_id), 5).await {
|
||
Ok(job_id) => {
|
||
tracing::info!(
|
||
job_id = %job_id,
|
||
node_id = %source_id,
|
||
collection_id = %target_id,
|
||
"render_article-jobb lagt i kø"
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
node_id = %source_id,
|
||
collection_id = %target_id,
|
||
error = %e,
|
||
"Kunne ikke legge render_article-jobb i kø"
|
||
);
|
||
}
|
||
}
|
||
|
||
// Re-render forsiden
|
||
let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
|
||
if index_mode == "static" {
|
||
// Statisk modus: legg render_index-jobb i køen
|
||
let index_payload = serde_json::json!({
|
||
"collection_id": target_id.to_string(),
|
||
});
|
||
|
||
match crate::jobs::enqueue(db, "render_index", index_payload, Some(target_id), 4).await {
|
||
Ok(job_id) => {
|
||
tracing::info!(
|
||
job_id = %job_id,
|
||
collection_id = %target_id,
|
||
"render_index-jobb lagt i kø (statisk modus)"
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
collection_id = %target_id,
|
||
error = %e,
|
||
"Kunne ikke legge render_index-jobb i kø"
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
// Dynamisk modus: invalider in-memory-cache
|
||
crate::publishing::invalidate_index_cache(index_cache, target_id).await;
|
||
}
|
||
}
|
||
Ok(None) => {
|
||
// Target er ikke en publiseringssamling — ingen rendering nødvendig
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
target_id = %target_id,
|
||
error = %e,
|
||
"Feil ved sjekk av publiseringssamling for rendering-trigger"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// =============================================================================
|
||
// POST /intentions/update_segment — rediger transkripsjons-segment
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UpdateSegmentRequest {
|
||
/// Segment-ID (primary key i transcription_segments).
|
||
pub segment_id: i64,
|
||
/// Ny tekst for segmentet.
|
||
pub content: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct UpdateSegmentResponse {
|
||
pub segment_id: i64,
|
||
pub edited: bool,
|
||
}
|
||
|
||
/// POST /intentions/update_segment
|
||
///
|
||
/// Oppdaterer teksten i et transkripsjons-segment og setter `edited = true`.
|
||
/// Krever at brukeren har skrivetilgang til noden segmentet tilhører.
|
||
pub async fn update_segment(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<UpdateSegmentRequest>,
|
||
) -> Result<Json<UpdateSegmentResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let content = req.content.trim().to_string();
|
||
if content.is_empty() {
|
||
return Err(bad_request("Innhold kan ikke være tomt"));
|
||
}
|
||
|
||
// Finn noden dette segmentet tilhører
|
||
let segment_node: Option<(Uuid,)> = sqlx::query_as(
|
||
"SELECT node_id FROM transcription_segments WHERE id = $1",
|
||
)
|
||
.bind(req.segment_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Feil ved oppslag av segment");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
let Some((node_id,)) = segment_node else {
|
||
return Err(bad_request("Segment finnes ikke"));
|
||
};
|
||
|
||
// Verifiser skrivetilgang
|
||
let can_modify = user_can_modify_node(&state.db, user.node_id, node_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Tilgangssjekk feilet");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !can_modify {
|
||
return Err(forbidden("Ikke tilgang til å redigere dette segmentet"));
|
||
}
|
||
|
||
// Oppdater segmentet
|
||
sqlx::query(
|
||
"UPDATE transcription_segments SET content = $1, edited = true WHERE id = $2",
|
||
)
|
||
.bind(&content)
|
||
.bind(req.segment_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Kunne ikke oppdatere segment");
|
||
internal_error("Databasefeil ved oppdatering")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
segment_id = req.segment_id,
|
||
node_id = %node_id,
|
||
user = %user.node_id,
|
||
"Segment redigert"
|
||
);
|
||
|
||
Ok(Json(UpdateSegmentResponse {
|
||
segment_id: req.segment_id,
|
||
edited: true,
|
||
}))
|
||
}
|
||
|
||
// =============================================================================
|
||
// POST /intentions/retranscribe — trigger re-transkripsjon for eksisterende media-node
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct RetranscribeRequest {
|
||
/// Media-node-ID å re-transkribere.
|
||
pub node_id: Uuid,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct RetranscribeResponse {
|
||
pub job_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/retranscribe
|
||
///
|
||
/// Trigger en ny transkripsjons-jobb for en eksisterende media-node.
|
||
/// Henter CAS-hash og MIME fra nodens metadata. Krever skrivetilgang.
|
||
pub async fn retranscribe(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<RetranscribeRequest>,
|
||
) -> Result<Json<RetranscribeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Verifiser skrivetilgang
|
||
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Tilgangssjekk feilet");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !can_modify {
|
||
return Err(forbidden("Ikke tilgang til å re-transkribere denne noden"));
|
||
}
|
||
|
||
// Hent metadata for CAS-hash og MIME
|
||
let node: Option<(serde_json::Value,)> = sqlx::query_as(
|
||
"SELECT metadata FROM nodes WHERE id = $1 AND node_kind = 'media'",
|
||
)
|
||
.bind(req.node_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Feil ved henting av node");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
let Some((metadata,)) = node else {
|
||
return Err(bad_request("Noden finnes ikke eller er ikke en media-node"));
|
||
};
|
||
|
||
let cas_hash = metadata["cas_hash"]
|
||
.as_str()
|
||
.ok_or_else(|| bad_request("Noden mangler cas_hash i metadata"))?;
|
||
let mime = metadata["mime"]
|
||
.as_str()
|
||
.unwrap_or("audio/mpeg");
|
||
|
||
// Finn collection fra eier-kjede
|
||
let collection_id = find_collection_for_node(&state.db, req.node_id)
|
||
.await
|
||
.ok()
|
||
.flatten();
|
||
|
||
let payload = serde_json::json!({
|
||
"media_node_id": req.node_id,
|
||
"cas_hash": cas_hash,
|
||
"mime": mime,
|
||
"language": "no",
|
||
});
|
||
|
||
let job_id = crate::jobs::enqueue(&state.db, "whisper_transcribe", payload, collection_id, 5)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Kunne ikke opprette re-transkripsjons-jobb");
|
||
internal_error("Kunne ikke starte re-transkripsjon")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
job_id = %job_id,
|
||
node_id = %req.node_id,
|
||
user = %user.node_id,
|
||
"Re-transkripsjons-jobb opprettet"
|
||
);
|
||
|
||
Ok(Json(RetranscribeResponse { job_id }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// POST /intentions/resolve_retranscription — anvend brukerens segment-valg
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct SegmentChoice {
|
||
/// Sekvensnummer i den nye transkripsjonen.
|
||
pub seq: i32,
|
||
/// "new" = behold ny versjon, "old" = behold gammel versjon.
|
||
pub choice: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct ResolveRetranscriptionRequest {
|
||
/// Media-node-ID.
|
||
pub node_id: Uuid,
|
||
/// `transcribed_at` for den nye versjonen.
|
||
pub new_version: String,
|
||
/// `transcribed_at` for den gamle versjonen.
|
||
pub old_version: String,
|
||
/// Per-segment-valg.
|
||
pub choices: Vec<SegmentChoice>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct ResolveRetranscriptionResponse {
|
||
pub resolved: bool,
|
||
pub kept_old: i32,
|
||
pub kept_new: i32,
|
||
}
|
||
|
||
/// POST /intentions/resolve_retranscription
|
||
///
|
||
/// Anvender brukerens per-segment-valg etter re-transkripsjon.
|
||
/// For segmenter der brukeren velger "old", kopieres innholdet fra
|
||
/// den gamle versjonen til den nye. Gamle versjoner slettes etterpå.
|
||
pub async fn resolve_retranscription(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<ResolveRetranscriptionRequest>,
|
||
) -> Result<Json<ResolveRetranscriptionResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Verifiser skrivetilgang
|
||
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Tilgangssjekk feilet");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !can_modify {
|
||
return Err(forbidden("Ikke tilgang til å endre segmenter"));
|
||
}
|
||
|
||
let new_ts: chrono::DateTime<chrono::Utc> = req.new_version.parse()
|
||
.map_err(|_| bad_request("Ugyldig new_version-tidsstempel"))?;
|
||
let old_ts: chrono::DateTime<chrono::Utc> = req.old_version.parse()
|
||
.map_err(|_| bad_request("Ugyldig old_version-tidsstempel"))?;
|
||
|
||
// Hent gamle segmenter (indeksert på seq)
|
||
let old_segments: Vec<(i32, String, bool)> = sqlx::query_as(
|
||
"SELECT seq, content, edited FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq",
|
||
)
|
||
.bind(req.node_id)
|
||
.bind(old_ts)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Feil ved henting av gamle segmenter");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
let old_by_seq: std::collections::HashMap<i32, (String, bool)> = old_segments
|
||
.into_iter()
|
||
.map(|(seq, content, edited)| (seq, (content, edited)))
|
||
.collect();
|
||
|
||
let mut kept_old = 0i32;
|
||
let mut kept_new = 0i32;
|
||
|
||
let mut tx = state.db.begin().await.map_err(|e| {
|
||
tracing::error!(error = %e, "Transaksjon feilet");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
for choice in &req.choices {
|
||
if choice.choice == "old" {
|
||
if let Some((old_content, old_edited)) = old_by_seq.get(&choice.seq) {
|
||
// Kopier gammel tekst til nytt segment, bevar edited-flagg
|
||
sqlx::query(
|
||
"UPDATE transcription_segments SET content = $1, edited = $2 WHERE node_id = $3 AND transcribed_at = $4 AND seq = $5",
|
||
)
|
||
.bind(old_content)
|
||
.bind(*old_edited)
|
||
.bind(req.node_id)
|
||
.bind(new_ts)
|
||
.bind(choice.seq)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Feil ved oppdatering av segment");
|
||
internal_error("Databasefeil ved oppdatering")
|
||
})?;
|
||
kept_old += 1;
|
||
}
|
||
} else {
|
||
kept_new += 1;
|
||
}
|
||
}
|
||
|
||
// Slett alle gamle versjoner (ikke bare den valgte — rydd opp)
|
||
sqlx::query(
|
||
"DELETE FROM transcription_segments WHERE node_id = $1 AND transcribed_at < $2",
|
||
)
|
||
.bind(req.node_id)
|
||
.bind(new_ts)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Feil ved sletting av gamle segmenter");
|
||
internal_error("Databasefeil ved opprydding")
|
||
})?;
|
||
|
||
// Oppdater nodens content med den endelige transkripsjonen
|
||
let final_segments: Vec<(String,)> = sqlx::query_as(
|
||
"SELECT content FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq",
|
||
)
|
||
.bind(req.node_id)
|
||
.bind(new_ts)
|
||
.fetch_all(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Feil ved henting av endelige segmenter");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
let transcript_text: String = final_segments
|
||
.iter()
|
||
.map(|(c,)| c.trim())
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
|
||
sqlx::query("UPDATE nodes SET content = $1 WHERE id = $2")
|
||
.bind(&transcript_text)
|
||
.bind(req.node_id)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Feil ved oppdatering av node-innhold");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
tx.commit().await.map_err(|e| {
|
||
tracing::error!(error = %e, "Commit feilet");
|
||
internal_error("Databasefeil ved commit")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
node_id = %req.node_id,
|
||
kept_old = kept_old,
|
||
kept_new = kept_new,
|
||
"Re-transkripsjon løst"
|
||
);
|
||
|
||
Ok(Json(ResolveRetranscriptionResponse {
|
||
resolved: true,
|
||
kept_old,
|
||
kept_new,
|
||
}))
|
||
}
|
||
|
||
// =============================================================================
|
||
// POST /intentions/summarize — generer AI-sammendrag av kommunikasjonsnode
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct SummarizeRequest {
|
||
/// Kommunikasjonsnode-ID som skal oppsummeres.
|
||
pub communication_id: Uuid,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct SummarizeResponse {
|
||
pub job_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/summarize
|
||
///
|
||
/// Legger en `summarize_communication`-jobb i køen.
|
||
/// Sammendraget opprettes asynkront som en ny content-node
|
||
/// med summary-edge tilbake til kommunikasjonsnoden.
|
||
pub async fn summarize(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<SummarizeRequest>,
|
||
) -> Result<Json<SummarizeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Verifiser at kommunikasjonsnoden finnes og brukeren har tilgang
|
||
let exists: bool = sqlx::query_scalar::<_, bool>(
|
||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'communication')",
|
||
)
|
||
.bind(req.communication_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG-feil ved kommunikasjonssjekk");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
if !exists {
|
||
return Err(bad_request("Kommunikasjonsnode finnes ikke"));
|
||
}
|
||
|
||
// Sjekk at brukeren er deltaker (owner eller member_of)
|
||
let is_participant: bool = sqlx::query_scalar::<_, bool>(
|
||
"SELECT EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type IN ('owner', 'member_of'))",
|
||
)
|
||
.bind(user.node_id)
|
||
.bind(req.communication_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG-feil ved deltagersjekk");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
if !is_participant {
|
||
return Err(forbidden("Ikke deltaker i samtalen"));
|
||
}
|
||
|
||
let payload = serde_json::json!({
|
||
"communication_id": req.communication_id.to_string(),
|
||
"requested_by": user.node_id.to_string()
|
||
});
|
||
|
||
let job_id = crate::jobs::enqueue(
|
||
&state.db,
|
||
"summarize_communication",
|
||
payload,
|
||
None,
|
||
3, // Lav prioritet — ikke tidskritisk
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Kunne ikke legge oppsummerings-jobb i kø");
|
||
internal_error("Kunne ikke starte oppsummering")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
job_id = %job_id,
|
||
communication_id = %req.communication_id,
|
||
user = %user.node_id,
|
||
"Oppsummerings-jobb lagt i kø"
|
||
);
|
||
|
||
Ok(Json(SummarizeResponse { job_id }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// POST /intentions/ai_process — AI-prosessering via AI Gateway
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct AiProcessRequest {
|
||
/// Kilde-noden som skal prosesseres.
|
||
pub source_node_id: Uuid,
|
||
/// AI-preset som definerer prompt og modellprofil.
|
||
pub ai_preset_id: Uuid,
|
||
/// Retning: "node_to_tool" (opprett ny node) eller "tool_to_node" (modifiser in-place).
|
||
pub direction: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct AiProcessResponse {
|
||
pub job_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/ai_process
|
||
///
|
||
/// Legger en `ai_process`-jobb i køen.
|
||
/// AI-prosesseringen skjer asynkront — kilde-content sendes til AI Gateway
|
||
/// med preset-prompt, og forbruk logges i ai_usage_log.
|
||
///
|
||
/// Direction-logikk (opprett ny node vs. oppdater eksisterende) implementeres
|
||
/// i oppgave 18.3.
|
||
///
|
||
/// Ref: docs/features/ai_verktoy.md § 6.1
|
||
pub async fn ai_process(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<AiProcessRequest>,
|
||
) -> Result<Json<AiProcessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Valider direction
|
||
if req.direction != "node_to_tool" && req.direction != "tool_to_node" {
|
||
return Err(bad_request(
|
||
"direction må være 'node_to_tool' eller 'tool_to_node'",
|
||
));
|
||
}
|
||
|
||
// Sjekk at kilde-noden finnes
|
||
let source_exists: bool = sqlx::query_scalar::<_, bool>(
|
||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
|
||
)
|
||
.bind(req.source_node_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG-feil ved kilde-node-sjekk");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
if !source_exists {
|
||
return Err(bad_request("Kilde-node finnes ikke"));
|
||
}
|
||
|
||
// Sjekk at AI-preset finnes
|
||
let preset_exists: bool = sqlx::query_scalar::<_, bool>(
|
||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'ai_preset')",
|
||
)
|
||
.bind(req.ai_preset_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG-feil ved preset-sjekk");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
if !preset_exists {
|
||
return Err(bad_request("AI-preset finnes ikke"));
|
||
}
|
||
|
||
// For tool_to_node-retning trengs skrivetilgang til kilde-noden
|
||
if req.direction == "tool_to_node" {
|
||
let can_modify = user_can_modify_node(&state.db, user.node_id, req.source_node_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG-feil ved tilgangssjekk");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
if !can_modify {
|
||
return Err(forbidden(
|
||
"Ingen tilgang til å endre kilde-noden (tool_to_node krever skrivetilgang)",
|
||
));
|
||
}
|
||
}
|
||
|
||
// Finn samlings-ID for kilde-noden (for prioritering)
|
||
let collection_id = crate::resource_usage::find_collection_for_node(
|
||
&state.db,
|
||
req.source_node_id,
|
||
)
|
||
.await;
|
||
|
||
let payload = serde_json::json!({
|
||
"source_node_id": req.source_node_id.to_string(),
|
||
"ai_preset_id": req.ai_preset_id.to_string(),
|
||
"direction": req.direction,
|
||
"requested_by": user.node_id.to_string()
|
||
});
|
||
|
||
let job_id = crate::jobs::enqueue(
|
||
&state.db,
|
||
"ai_process",
|
||
payload,
|
||
collection_id,
|
||
5, // Medium prioritet
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Kunne ikke legge ai_process-jobb i kø");
|
||
internal_error("Kunne ikke starte AI-prosessering")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
job_id = %job_id,
|
||
source_node_id = %req.source_node_id,
|
||
ai_preset_id = %req.ai_preset_id,
|
||
direction = %req.direction,
|
||
user = %user.node_id,
|
||
"ai_process-jobb lagt i kø"
|
||
);
|
||
|
||
Ok(Json(AiProcessResponse { job_id }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// POST /intentions/create_ai_preset — opprett egendefinert AI-preset
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateAiPresetRequest {
|
||
/// Visningstittel for presetet.
|
||
pub title: String,
|
||
/// Systemprompt som brukes ved AI-prosessering.
|
||
pub prompt: String,
|
||
/// Standard retning: "node_to_tool", "tool_to_node" eller "both".
|
||
pub default_direction: String,
|
||
/// Ikon-nøkkel (f.eks. "sparkles", "pencil_square").
|
||
pub icon: String,
|
||
/// Hex-farge (#RRGGBB).
|
||
pub color: String,
|
||
/// Valgfri: samlings-ID å dele presetet med (oppretter shared_with-edge).
|
||
pub share_with_collection_id: Option<Uuid>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CreateAiPresetResponse {
|
||
pub node_id: Uuid,
|
||
/// Edge-ID for shared_with-edge (kun når share_with_collection_id er satt).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub shared_edge_id: Option<Uuid>,
|
||
}
|
||
|
||
/// POST /intentions/create_ai_preset
|
||
///
|
||
/// Oppretter en egendefinert AI-preset-node. Setter alltid `category: "custom"`
|
||
/// og `model_profile: "flash"`. Kun admin kan endre modellprofil etterpå
|
||
/// (via update_node).
|
||
///
|
||
/// Kan valgfritt dele med en samling via `share_with_collection_id`.
|
||
///
|
||
/// Ref: docs/features/ai_verktoy.md § "Fase C: Egendefinerte prompter"
|
||
pub async fn create_ai_preset(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<CreateAiPresetRequest>,
|
||
) -> Result<Json<CreateAiPresetResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Valider input
|
||
if req.title.trim().is_empty() {
|
||
return Err(bad_request("Tittel kan ikke være tom"));
|
||
}
|
||
if req.prompt.trim().is_empty() {
|
||
return Err(bad_request("Prompt kan ikke være tom"));
|
||
}
|
||
|
||
// Bygg metadata — alltid custom kategori og flash modellprofil
|
||
let metadata = serde_json::json!({
|
||
"prompt": req.prompt.trim(),
|
||
"model_profile": "flash",
|
||
"category": "custom",
|
||
"default_direction": req.default_direction,
|
||
"icon": req.icon,
|
||
"color": req.color
|
||
});
|
||
|
||
// Valider metadata med eksisterende validator
|
||
validate_ai_preset_metadata("ai_preset", &metadata).map_err(|e| bad_request(&e))?;
|
||
|
||
// Valider share_with_collection_id hvis satt
|
||
if let Some(col_id) = req.share_with_collection_id {
|
||
let col_exists: bool = sqlx::query_scalar::<_, bool>(
|
||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'collection')",
|
||
)
|
||
.bind(col_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG-feil ved sjekk av samling");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
if !col_exists {
|
||
return Err(bad_request("Samlingen finnes ikke"));
|
||
}
|
||
}
|
||
|
||
let node_id = Uuid::now_v7();
|
||
|
||
// Skriv til PostgreSQL
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
|
||
VALUES ($1, 'ai_preset', $2, '', 'discoverable'::visibility, $3, $4)
|
||
"#,
|
||
)
|
||
.bind(node_id)
|
||
.bind(req.title.trim())
|
||
.bind(&metadata)
|
||
.bind(user.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG insert ai_preset feilet");
|
||
internal_error("Kunne ikke opprette AI-preset")
|
||
})?;
|
||
|
||
// Opprett shared_with-edge hvis samlings-ID er satt
|
||
let shared_edge_id = if let Some(col_id) = req.share_with_collection_id {
|
||
let edge_id = Uuid::now_v7();
|
||
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
||
VALUES ($1, $2, $3, 'shared_with', '{}', false, $4)
|
||
"#,
|
||
)
|
||
.bind(edge_id)
|
||
.bind(node_id)
|
||
.bind(col_id)
|
||
.bind(user.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG insert shared_with-edge feilet");
|
||
internal_error("Kunne ikke dele AI-preset med samling")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
preset_id = %node_id,
|
||
collection_id = %col_id,
|
||
edge_id = %edge_id,
|
||
"AI-preset delt med samling"
|
||
);
|
||
|
||
Some(edge_id)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
tracing::info!(
|
||
node_id = %node_id,
|
||
title = %req.title,
|
||
user = %user.node_id,
|
||
shared = ?req.share_with_collection_id,
|
||
"Egendefinert AI-preset opprettet"
|
||
);
|
||
|
||
Ok(Json(CreateAiPresetResponse {
|
||
node_id,
|
||
shared_edge_id,
|
||
}))
|
||
}
|
||
|
||
// =============================================================================
|
||
// POST /intentions/generate_tts — tekst-til-tale via ElevenLabs
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct GenerateTtsRequest {
|
||
/// Teksten som skal leses opp (maks 5000 tegn).
|
||
pub text: String,
|
||
/// Valgfri ElevenLabs voice_id. Faller tilbake på node-preferanse eller default.
|
||
pub voice_id: Option<String>,
|
||
/// Valgfri kilde-node som TTS-lyden knyttes til med has_media-edge.
|
||
/// Hvis noden har metadata.voice_preference, brukes den som fallback for voice_id.
|
||
pub source_node_id: Option<Uuid>,
|
||
/// Språk (default: "no").
|
||
pub language: Option<String>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct GenerateTtsResponse {
|
||
pub job_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/generate_tts
|
||
///
|
||
/// Legger en `tts_generate`-jobb i køen.
|
||
/// Lydfilen opprettes asynkront som en ny content-node i CAS.
|
||
pub async fn generate_tts(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<GenerateTtsRequest>,
|
||
) -> Result<Json<GenerateTtsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
if req.text.is_empty() {
|
||
return Err(bad_request("Tekst kan ikke være tom"));
|
||
}
|
||
if req.text.len() > 5000 {
|
||
return Err(bad_request("Tekst for lang (maks 5000 tegn)"));
|
||
}
|
||
|
||
// Hvis source_node_id er oppgitt, verifiser at noden finnes
|
||
if let Some(source_id) = req.source_node_id {
|
||
let exists: bool = sqlx::query_scalar::<_, bool>(
|
||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
|
||
)
|
||
.bind(source_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "PG-feil ved node-sjekk");
|
||
internal_error("Databasefeil")
|
||
})?;
|
||
|
||
if !exists {
|
||
return Err(bad_request("Kildenode finnes ikke"));
|
||
}
|
||
}
|
||
|
||
let mut payload = serde_json::json!({
|
||
"text": req.text,
|
||
"requested_by": user.node_id.to_string(),
|
||
"language": req.language.as_deref().unwrap_or("no"),
|
||
});
|
||
|
||
if let Some(ref vid) = req.voice_id {
|
||
payload["voice_id"] = serde_json::Value::String(vid.clone());
|
||
}
|
||
if let Some(source_id) = req.source_node_id {
|
||
payload["source_node_id"] = serde_json::Value::String(source_id.to_string());
|
||
}
|
||
|
||
let job_id = crate::jobs::enqueue(
|
||
&state.db,
|
||
"tts_generate",
|
||
payload,
|
||
None,
|
||
5, // Middels prioritet
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Kunne ikke legge TTS-jobb i kø");
|
||
internal_error("Kunne ikke starte TTS-generering")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
job_id = %job_id,
|
||
text_len = req.text.len(),
|
||
user = %user.node_id,
|
||
"TTS-jobb lagt i kø"
|
||
);
|
||
|
||
Ok(Json(GenerateTtsResponse { job_id }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// LiveKit — Join/Leave Communication
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct JoinCommunicationRequest {
|
||
pub communication_id: Uuid,
|
||
/// "publisher" (kan sende lyd) eller "subscriber" (bare lytte).
|
||
/// Default: "publisher".
|
||
pub role: Option<String>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct JoinCommunicationResponse {
|
||
pub livekit_room_name: String,
|
||
pub livekit_token: String,
|
||
pub livekit_url: String,
|
||
pub identity: String,
|
||
pub participants: Vec<RoomParticipantInfo>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct RoomParticipantInfo {
|
||
pub user_id: String,
|
||
pub display_name: String,
|
||
pub role: String,
|
||
}
|
||
|
||
/// POST /intentions/join_communication
|
||
///
|
||
/// Kobler en bruker til sanntidslyd i en kommunikasjonsnode.
|
||
/// Validerer tilgang (bruker må ha member_of/owner/host_of-edge),
|
||
/// genererer LiveKit-token, oppdaterer PG med live-status.
|
||
pub async fn join_communication(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<JoinCommunicationRequest>,
|
||
) -> Result<Json<JoinCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let comm_id = req.communication_id;
|
||
|
||
// Sjekk at kommunikasjonsnoden eksisterer og er riktig type
|
||
let node_row = sqlx::query_as::<_, (String, String)>(
|
||
"SELECT node_kind, title FROM nodes WHERE id = $1",
|
||
)
|
||
.bind(comm_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved nodesjekk: {e}");
|
||
internal_error("Databasefeil ved validering")
|
||
})?;
|
||
|
||
let (node_kind, _title) = match node_row {
|
||
Some(row) => row,
|
||
None => return Err(bad_request("Kommunikasjonsnode finnes ikke")),
|
||
};
|
||
|
||
if node_kind != "communication" {
|
||
return Err(bad_request(&format!(
|
||
"Node er type '{node_kind}', ikke 'communication'"
|
||
)));
|
||
}
|
||
|
||
// Sjekk at brukeren har tilgang (direkte eller via alias)
|
||
let has_access = sqlx::query_scalar::<_, bool>(
|
||
r#"
|
||
SELECT EXISTS(
|
||
-- Direkte edge: bruker → kommunikasjon
|
||
SELECT 1 FROM edges
|
||
WHERE source_id = $2 AND target_id = $1
|
||
AND edge_type IN ('owner', 'member_of', 'host_of')
|
||
) OR EXISTS(
|
||
-- Via alias: bruker --alias--> alias --member_of/etc--> kommunikasjon
|
||
SELECT 1 FROM edges e_alias
|
||
JOIN edges e_member ON e_member.source_id = e_alias.target_id
|
||
WHERE e_alias.source_id = $2
|
||
AND e_alias.edge_type = 'alias'
|
||
AND e_alias.system = true
|
||
AND e_member.target_id = $1
|
||
AND e_member.edge_type IN ('owner', 'member_of', 'host_of')
|
||
)
|
||
"#,
|
||
)
|
||
.bind(comm_id)
|
||
.bind(user.node_id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !has_access {
|
||
return Err(forbidden("Ingen tilgang til denne kommunikasjonsnoden"));
|
||
}
|
||
|
||
// Resolve display name (alias or user title)
|
||
let context_identity = resolve_context_identity(&state.db, user.node_id, comm_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("Kunne ikke resolve context identity: {e}");
|
||
internal_error("Kunne ikke hente brukeridentitet")
|
||
})?;
|
||
|
||
let display_name = sqlx::query_scalar::<_, String>(
|
||
"SELECT title FROM nodes WHERE id = $1",
|
||
)
|
||
.bind(context_identity)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved navnoppslag: {e}");
|
||
internal_error("Databasefeil")
|
||
})?
|
||
.unwrap_or_else(|| "Ukjent".to_string());
|
||
|
||
// Blokkér nye LiveKit-rom under vedlikehold (oppgave 15.2)
|
||
if state.maintenance.is_active() {
|
||
return Err(bad_request("Nye rom er blokkert — vedlikehold pågår"));
|
||
}
|
||
|
||
// Bestem rolle
|
||
let role_str = req.role.as_deref().unwrap_or("publisher");
|
||
let lk_role = match role_str {
|
||
"subscriber" => livekit::RoomRole::Subscriber,
|
||
_ => livekit::RoomRole::Publisher,
|
||
};
|
||
|
||
// Generer LiveKit-token
|
||
let token_result = livekit::generate_token(
|
||
comm_id,
|
||
user.node_id,
|
||
&display_name,
|
||
lk_role,
|
||
3600, // 1 time
|
||
)
|
||
.map_err(|e| {
|
||
tracing::error!("LiveKit token-generering feilet: {e}");
|
||
internal_error(&e)
|
||
})?;
|
||
|
||
let room_name = token_result.room_name.clone();
|
||
|
||
// Oppdater kommunikasjonsnodens metadata med live_status (asynkront)
|
||
let db = state.db.clone();
|
||
let comm_id_clone = comm_id;
|
||
let room_name_clone = room_name.clone();
|
||
tokio::spawn(async move {
|
||
// Les eksisterende metadata, legg til live_status
|
||
let result = sqlx::query_scalar::<_, serde_json::Value>(
|
||
"SELECT metadata FROM nodes WHERE id = $1",
|
||
)
|
||
.bind(comm_id_clone)
|
||
.fetch_optional(&db)
|
||
.await;
|
||
|
||
if let Ok(Some(mut metadata)) = result {
|
||
if let Some(obj) = metadata.as_object_mut() {
|
||
obj.insert("live_status".into(), "active".into());
|
||
obj.insert("livekit_room_name".into(), room_name_clone.clone().into());
|
||
}
|
||
|
||
if let Err(e) = sqlx::query(
|
||
"UPDATE nodes SET metadata = $2 WHERE id = $1",
|
||
)
|
||
.bind(comm_id_clone)
|
||
.bind(&metadata)
|
||
.execute(&db)
|
||
.await
|
||
{
|
||
tracing::error!("Kunne ikke oppdatere node metadata: {e}");
|
||
}
|
||
}
|
||
});
|
||
|
||
// Hent nåværende deltakere fra PG edges (for respons)
|
||
let participants = sqlx::query_as::<_, (String, String)>(
|
||
r#"
|
||
SELECT e.source_id::text, COALESCE(n.title, 'Ukjent')
|
||
FROM edges e
|
||
LEFT JOIN nodes n ON n.id = e.source_id
|
||
WHERE e.target_id = $1
|
||
AND e.edge_type IN ('owner', 'member_of', 'host_of')
|
||
"#,
|
||
)
|
||
.bind(comm_id)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved deltakerhenting: {e}");
|
||
internal_error("Databasefeil")
|
||
})?
|
||
.into_iter()
|
||
.map(|(uid, name)| RoomParticipantInfo {
|
||
user_id: uid,
|
||
display_name: name,
|
||
role: "publisher".to_string(),
|
||
})
|
||
.collect();
|
||
|
||
let livekit_url = std::env::var("LIVEKIT_WS_URL")
|
||
.unwrap_or_else(|_| {
|
||
// Fallback: bruk domene med wss
|
||
"wss://sidelinja.org/livekit".to_string()
|
||
});
|
||
|
||
tracing::info!(
|
||
communication_id = %comm_id,
|
||
user = %user.node_id,
|
||
room = %room_name,
|
||
role = %role_str,
|
||
"Bruker koblet til LiveKit-rom"
|
||
);
|
||
|
||
// Logg LiveKit-ressursforbruk (registrering av deltaker-join)
|
||
let lk_collection_id = crate::resource_usage::find_collection_for_node(&state.db, comm_id).await;
|
||
if let Err(e) = crate::resource_usage::log(
|
||
&state.db,
|
||
comm_id,
|
||
Some(user.node_id),
|
||
lk_collection_id,
|
||
"livekit",
|
||
serde_json::json!({
|
||
"room_id": room_name,
|
||
"participant_minutes": 0,
|
||
"tracks": 0,
|
||
"event": "join"
|
||
}),
|
||
)
|
||
.await
|
||
{
|
||
tracing::warn!(error = %e, "Kunne ikke logge LiveKit-ressursforbruk");
|
||
}
|
||
|
||
Ok(Json(JoinCommunicationResponse {
|
||
livekit_room_name: room_name,
|
||
livekit_token: token_result.token,
|
||
livekit_url,
|
||
identity: token_result.identity,
|
||
participants,
|
||
}))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct LeaveCommunicationRequest {
|
||
pub communication_id: Uuid,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct LeaveCommunicationResponse {
|
||
pub status: String,
|
||
}
|
||
|
||
/// POST /intentions/leave_communication
|
||
///
|
||
/// Fjerner brukerens sanntidslyd-tilkobling fra en kommunikasjonsnode.
|
||
pub async fn leave_communication(
|
||
State(_state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<LeaveCommunicationRequest>,
|
||
) -> Result<Json<LeaveCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let comm_id = req.communication_id;
|
||
let room_name = format!("communication_{comm_id}");
|
||
|
||
tracing::info!(
|
||
communication_id = %comm_id,
|
||
user = %user.node_id,
|
||
room = %room_name,
|
||
"Bruker forlot LiveKit-rom"
|
||
);
|
||
|
||
Ok(Json(LeaveCommunicationResponse {
|
||
status: "left".to_string(),
|
||
}))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CloseCommunicationRequest {
|
||
pub communication_id: Uuid,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CloseCommunicationResponse {
|
||
pub status: String,
|
||
}
|
||
|
||
/// POST /intentions/close_communication
|
||
///
|
||
/// Stenger et sanntidsrom. Krever owner/admin-tilgang.
|
||
/// Oppdaterer metadata til "ended".
|
||
pub async fn close_communication(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<CloseCommunicationRequest>,
|
||
) -> Result<Json<CloseCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let comm_id = req.communication_id;
|
||
|
||
// Bare owner/admin kan stenge
|
||
if !user_can_modify_node(&state.db, user.node_id, comm_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||
internal_error("Databasefeil")
|
||
})?
|
||
{
|
||
return Err(forbidden("Bare eier kan stenge kommunikasjonsrom"));
|
||
}
|
||
|
||
// Oppdater metadata i PG (NOTIFY-trigger sender sanntidsoppdatering)
|
||
let db = state.db.clone();
|
||
tokio::spawn(async move {
|
||
let result = sqlx::query_scalar::<_, serde_json::Value>(
|
||
"SELECT metadata FROM nodes WHERE id = $1",
|
||
)
|
||
.bind(comm_id)
|
||
.fetch_optional(&db)
|
||
.await;
|
||
|
||
if let Ok(Some(mut metadata)) = result {
|
||
if let Some(obj) = metadata.as_object_mut() {
|
||
obj.insert("live_status".into(), "ended".into());
|
||
obj.insert(
|
||
"ended_at".into(),
|
||
chrono::Utc::now().to_rfc3339().into(),
|
||
);
|
||
}
|
||
|
||
let _ = sqlx::query("UPDATE nodes SET metadata = $2 WHERE id = $1")
|
||
.bind(comm_id)
|
||
.bind(&metadata)
|
||
.execute(&db)
|
||
.await;
|
||
}
|
||
});
|
||
|
||
tracing::info!(
|
||
communication_id = %comm_id,
|
||
user = %user.node_id,
|
||
"Kommunikasjonsrom stengt"
|
||
);
|
||
|
||
Ok(Json(CloseCommunicationResponse {
|
||
status: "closed".to_string(),
|
||
}))
|
||
}
|
||
|
||
// =============================================================================
|
||
// Lydstudio
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct AudioAnalyzeRequest {
|
||
pub cas_hash: String,
|
||
pub silence_threshold_db: Option<f32>,
|
||
pub silence_min_duration_ms: Option<u32>,
|
||
}
|
||
|
||
/// POST /intentions/audio_analyze
|
||
///
|
||
/// Synkron analyse av en lydfil: loudness (LUFS), silence-regioner, og metadata.
|
||
/// Brukes av studioet for å vise nåværende tilstand før redigering.
|
||
pub async fn audio_analyze(
|
||
State(state): State<AppState>,
|
||
_user: AuthUser,
|
||
Json(req): Json<AudioAnalyzeRequest>,
|
||
) -> Result<Json<crate::audio::AnalyzeResult>, (StatusCode, Json<ErrorResponse>)> {
|
||
let cas = &state.cas;
|
||
|
||
if !cas.exists(&req.cas_hash) {
|
||
return Err(bad_request("Filen finnes ikke i CAS"));
|
||
}
|
||
|
||
let info = crate::audio::get_audio_info(cas, &req.cas_hash)
|
||
.await
|
||
.map_err(|e| internal_error(&e))?;
|
||
|
||
let loudness = crate::audio::analyze_loudness(cas, &req.cas_hash)
|
||
.await
|
||
.map_err(|e| internal_error(&e))?;
|
||
|
||
let threshold = req.silence_threshold_db.unwrap_or(-30.0);
|
||
let min_dur = req.silence_min_duration_ms.unwrap_or(500);
|
||
|
||
let silence_regions = crate::audio::detect_silence(cas, &req.cas_hash, threshold, min_dur)
|
||
.await
|
||
.map_err(|e| internal_error(&e))?;
|
||
|
||
Ok(Json(crate::audio::AnalyzeResult {
|
||
loudness,
|
||
silence_regions,
|
||
info,
|
||
}))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct AudioProcessRequest {
|
||
pub media_node_id: Uuid,
|
||
pub edl: crate::audio::EdlDocument,
|
||
pub output_format: Option<String>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct AudioProcessResponse {
|
||
pub job_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/audio_process
|
||
///
|
||
/// Køer en audio-prosessering-jobb. Resultatet blir en ny medienode
|
||
/// med derived_from-edge til originalen.
|
||
pub async fn audio_process(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<AudioProcessRequest>,
|
||
) -> Result<Json<AudioProcessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Sjekk at medienoden eksisterer
|
||
if !node_exists(&state.db, req.media_node_id).await.map_err(|e| {
|
||
tracing::error!("DB-feil: {e}");
|
||
internal_error("Databasefeil")
|
||
})? {
|
||
return Err(bad_request("media_node_id finnes ikke"));
|
||
}
|
||
|
||
// Sjekk at kildefilen finnes i CAS
|
||
if !state.cas.exists(&req.edl.source_hash) {
|
||
return Err(bad_request("source_hash finnes ikke i CAS"));
|
||
}
|
||
|
||
let output_format = req.output_format.unwrap_or_else(|| "mp3".to_string());
|
||
|
||
let payload = serde_json::json!({
|
||
"media_node_id": req.media_node_id.to_string(),
|
||
"edl": req.edl,
|
||
"output_format": output_format,
|
||
"requested_by": user.node_id.to_string(),
|
||
});
|
||
|
||
let job_id = crate::jobs::enqueue(&state.db, "audio_process", payload, None, 5)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("Kunne ikke køe audio_process-jobb: {e}");
|
||
internal_error("Kunne ikke køe jobb")
|
||
})?;
|
||
|
||
Ok(Json(AudioProcessResponse { job_id }))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct AudioInfoQuery {
|
||
pub hash: String,
|
||
}
|
||
|
||
/// GET /query/audio_info?hash=...
|
||
///
|
||
/// Hent metadata om en lydfil (varighet, sample rate, kanaler, codec).
|
||
pub async fn audio_info(
|
||
State(state): State<AppState>,
|
||
_user: AuthUser,
|
||
axum::extract::Query(query): axum::extract::Query<AudioInfoQuery>,
|
||
) -> Result<Json<crate::audio::AudioInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||
if !state.cas.exists(&query.hash) {
|
||
return Err(bad_request("Filen finnes ikke i CAS"));
|
||
}
|
||
|
||
let info = crate::audio::get_audio_info(&state.cas, &query.hash)
|
||
.await
|
||
.map_err(|e| internal_error(&e))?;
|
||
|
||
Ok(Json(info))
|
||
}
|
||
|
||
// =============================================================================
|
||
// Systemvarsler (oppgave 15.1)
|
||
// =============================================================================
|
||
|
||
/// Gyldige varslingstyper for system_announcement-noder.
|
||
const VALID_ANNOUNCEMENT_TYPES: &[&str] = &["info", "warning", "critical"];
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateAnnouncementRequest {
|
||
/// Tittel på varselet.
|
||
pub title: String,
|
||
/// Innhold/meldingstekst.
|
||
pub content: String,
|
||
/// Type varsel: info, warning, critical.
|
||
pub announcement_type: String,
|
||
/// Tidspunkt varselet gjelder (f.eks. vedlikeholdstidspunkt). Valgfritt.
|
||
pub scheduled_at: Option<String>,
|
||
/// Når varselet automatisk utløper (ISO 8601). Valgfritt.
|
||
pub expires_at: Option<String>,
|
||
/// Om nye sesjoner skal blokkeres (vedlikeholdsmodus). Default: false.
|
||
pub blocks_new_sessions: Option<bool>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CreateAnnouncementResponse {
|
||
pub node_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/create_announcement
|
||
///
|
||
/// Oppretter en systemvarslingsnode med `visibility: open` slik at alle
|
||
/// aktive klienter ser varselet via WebSocket umiddelbart.
|
||
///
|
||
/// Kun autentiserte brukere kan opprette varsler (MVP — full admin-sjekk
|
||
/// legges til når admin-rollesystemet er på plass).
|
||
///
|
||
/// Ref: docs/concepts/adminpanelet.md § "Systemvarsler og vedlikeholdsmodus"
|
||
pub async fn create_announcement(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<CreateAnnouncementRequest>,
|
||
) -> Result<Json<CreateAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// -- Valider announcement_type --
|
||
if !VALID_ANNOUNCEMENT_TYPES.contains(&req.announcement_type.as_str()) {
|
||
return Err(bad_request(&format!(
|
||
"Ugyldig announcement_type: '{}'. Gyldige verdier: {:?}",
|
||
req.announcement_type, VALID_ANNOUNCEMENT_TYPES
|
||
)));
|
||
}
|
||
|
||
if req.title.trim().is_empty() {
|
||
return Err(bad_request("title kan ikke være tom"));
|
||
}
|
||
|
||
// -- Valider datoer hvis satt --
|
||
if let Some(ref s) = req.scheduled_at {
|
||
chrono::DateTime::parse_from_rfc3339(s)
|
||
.map_err(|_| bad_request("scheduled_at må være gyldig ISO 8601 (RFC 3339)"))?;
|
||
}
|
||
if let Some(ref s) = req.expires_at {
|
||
chrono::DateTime::parse_from_rfc3339(s)
|
||
.map_err(|_| bad_request("expires_at må være gyldig ISO 8601 (RFC 3339)"))?;
|
||
}
|
||
|
||
// -- Bygg metadata --
|
||
let metadata = serde_json::json!({
|
||
"announcement_type": req.announcement_type,
|
||
"scheduled_at": req.scheduled_at,
|
||
"expires_at": req.expires_at,
|
||
"blocks_new_sessions": req.blocks_new_sessions.unwrap_or(false),
|
||
});
|
||
|
||
let node_id = Uuid::now_v7();
|
||
|
||
// -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
|
||
sqlx::query(
|
||
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
|
||
VALUES ($1, 'system_announcement', $2, $3, 'open'::visibility, $4, $5)"#,
|
||
)
|
||
.bind(node_id)
|
||
.bind(&req.title)
|
||
.bind(&req.content)
|
||
.bind(&metadata)
|
||
.bind(user.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved opprettelse av systemvarsel: {e}");
|
||
internal_error("Databasefeil ved opprettelse av systemvarsel")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
node_id = %node_id,
|
||
announcement_type = %req.announcement_type,
|
||
created_by = %user.node_id,
|
||
"Systemvarsel opprettet"
|
||
);
|
||
|
||
Ok(Json(CreateAnnouncementResponse { node_id }))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct ExpireAnnouncementRequest {
|
||
/// ID-en til varslingsnoden som skal utløpe/fjernes.
|
||
pub node_id: Uuid,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct ExpireAnnouncementResponse {
|
||
pub expired: bool,
|
||
}
|
||
|
||
/// POST /intentions/expire_announcement
|
||
///
|
||
/// Fjerner (sletter) en systemvarslingsnode fra PG.
|
||
/// WebSocket NOTIFY sørger for umiddelbar fjerning fra alle klienter.
|
||
///
|
||
/// Kun eier (created_by) eller admin kan fjerne varsler.
|
||
pub async fn expire_announcement(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<ExpireAnnouncementRequest>,
|
||
) -> Result<Json<ExpireAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// -- Sjekk at noden eksisterer og er en system_announcement --
|
||
let node = sqlx::query_as::<_, NodeRow>(
|
||
"SELECT node_kind, title, content, visibility, metadata FROM nodes WHERE id = $1",
|
||
)
|
||
.bind(req.node_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved oppslag: {e}");
|
||
internal_error("Databasefeil")
|
||
})?
|
||
.ok_or_else(|| bad_request("Noden finnes ikke"))?;
|
||
|
||
if node.node_kind != "system_announcement" {
|
||
return Err(bad_request("Noden er ikke en systemvarslingsnode"));
|
||
}
|
||
|
||
// -- Tilgangskontroll --
|
||
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||
internal_error("Databasefeil ved tilgangssjekk")
|
||
})?;
|
||
|
||
if !can_modify {
|
||
return Err(forbidden("Kun eier kan fjerne systemvarsler"));
|
||
}
|
||
|
||
// -- Slett fra PG (NOTIFY-trigger sender sanntidsoppdatering) --
|
||
sqlx::query("DELETE FROM nodes WHERE id = $1")
|
||
.bind(req.node_id)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("PG-feil ved sletting av varsel: {e}");
|
||
internal_error("Databasefeil ved sletting av varsel")
|
||
})?;
|
||
|
||
tracing::info!(node_id = %req.node_id, "Systemvarsel slettet");
|
||
|
||
Ok(Json(ExpireAnnouncementResponse { expired: true }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// Vedlikeholdsmodus (oppgave 15.2)
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct InitiateMaintenanceRequest {
|
||
/// Vedlikeholdstidspunkt (ISO 8601 / RFC 3339).
|
||
pub scheduled_at: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct InitiateMaintenanceResponse {
|
||
pub announcement_node_id: Uuid,
|
||
pub scheduled_at: String,
|
||
}
|
||
|
||
/// POST /intentions/initiate_maintenance
|
||
///
|
||
/// Starter nedtellingen til vedlikehold. Oppretter et critical-varsel
|
||
/// som vises for alle klienter, og starter bakgrunnskoordinatoren som
|
||
/// blokkerer nye jobber/LiveKit-rom og til slutt restarter prosessen.
|
||
///
|
||
/// Kall GET /admin/maintenance_status først for å se aktive sesjoner.
|
||
pub async fn initiate_maintenance(
|
||
State(state): State<AppState>,
|
||
admin: AdminUser,
|
||
Json(req): Json<InitiateMaintenanceRequest>,
|
||
) -> Result<Json<InitiateMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let scheduled_at = chrono::DateTime::parse_from_rfc3339(&req.scheduled_at)
|
||
.map_err(|_| bad_request("scheduled_at må være gyldig ISO 8601 (RFC 3339)"))?
|
||
.with_timezone(&chrono::Utc);
|
||
|
||
if scheduled_at < chrono::Utc::now() {
|
||
return Err(bad_request("scheduled_at kan ikke være i fortiden"));
|
||
}
|
||
|
||
let user_id = admin.node_id;
|
||
|
||
let announcement_id = state
|
||
.maintenance
|
||
.initiate(&state.db, scheduled_at, user_id)
|
||
.await
|
||
.map_err(|e| bad_request(&e))?;
|
||
|
||
Ok(Json(InitiateMaintenanceResponse {
|
||
announcement_node_id: announcement_id,
|
||
scheduled_at: scheduled_at.to_rfc3339(),
|
||
}))
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CancelMaintenanceResponse {
|
||
pub cancelled: bool,
|
||
}
|
||
|
||
/// POST /intentions/cancel_maintenance
|
||
///
|
||
/// Avbryter planlagt vedlikehold. Fjerner varselet og stopper
|
||
/// nedtellingstasken.
|
||
pub async fn cancel_maintenance(
|
||
State(state): State<AppState>,
|
||
_admin: AdminUser,
|
||
) -> Result<Json<CancelMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
state
|
||
.maintenance
|
||
.cancel(&state.db)
|
||
.await
|
||
.map_err(|e| bad_request(&e))?;
|
||
|
||
Ok(Json(CancelMaintenanceResponse { cancelled: true }))
|
||
}
|
||
|
||
/// GET /admin/maintenance_status
|
||
///
|
||
/// Returnerer vedlikeholdsstatus inkludert kjørende jobber.
|
||
/// Brukes av admin-panelet for å vise aktive sesjoner før bekreftelse.
|
||
pub async fn maintenance_status(
|
||
State(state): State<AppState>,
|
||
_admin: AdminUser,
|
||
) -> Result<Json<crate::maintenance::MaintenanceStatus>, (StatusCode, Json<ErrorResponse>)> {
|
||
let status = state
|
||
.maintenance
|
||
.status(&state.db)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("Feil ved henting av vedlikeholdsstatus: {e}")))?;
|
||
|
||
Ok(Json(status))
|
||
}
|
||
|
||
// =============================================================================
|
||
// Jobbkø-oversikt (oppgave 15.3)
|
||
// =============================================================================
|
||
|
||
/// GET /admin/jobs
|
||
///
|
||
/// Hent jobbliste med valgfrie filtre: status, type, collection_id.
|
||
/// Query-params: ?status=error&type=whisper_transcribe&collection_id=...&limit=50&offset=0
|
||
pub async fn list_jobs(
|
||
State(state): State<AppState>,
|
||
_admin: AdminUser,
|
||
axum::extract::Query(params): axum::extract::Query<ListJobsParams>,
|
||
) -> Result<Json<ListJobsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let limit = params.limit.unwrap_or(50).min(200);
|
||
let offset = params.offset.unwrap_or(0);
|
||
|
||
let jobs = crate::jobs::list_jobs(
|
||
&state.db,
|
||
params.status.as_deref(),
|
||
params.r#type.as_deref(),
|
||
params.collection_id,
|
||
limit,
|
||
offset,
|
||
)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("Feil ved henting av jobber: {e}")))?;
|
||
|
||
let counts = crate::jobs::count_by_status(&state.db)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("Feil ved telling av jobber: {e}")))?;
|
||
|
||
let job_types = crate::jobs::distinct_job_types(&state.db)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("Feil ved henting av jobbtyper: {e}")))?;
|
||
|
||
Ok(Json(ListJobsResponse { jobs, counts, job_types }))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct ListJobsParams {
|
||
pub status: Option<String>,
|
||
pub r#type: Option<String>,
|
||
pub collection_id: Option<Uuid>,
|
||
pub limit: Option<i64>,
|
||
pub offset: Option<i64>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct ListJobsResponse {
|
||
pub jobs: Vec<crate::jobs::JobDetail>,
|
||
pub counts: Vec<crate::jobs::JobCountByStatus>,
|
||
pub job_types: Vec<String>,
|
||
}
|
||
|
||
/// POST /intentions/retry_job
|
||
///
|
||
/// Sett en feilet jobb tilbake til 'pending' for nytt forsøk.
|
||
pub async fn retry_job(
|
||
State(state): State<AppState>,
|
||
_admin: AdminUser,
|
||
Json(req): Json<JobIdRequest>,
|
||
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let retried = crate::jobs::retry_job(&state.db, req.job_id)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("Feil ved retry: {e}")))?;
|
||
|
||
if !retried {
|
||
return Err(bad_request("Jobben finnes ikke eller har feil status for retry"));
|
||
}
|
||
|
||
tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb restartet");
|
||
Ok(Json(JobActionResponse { success: true }))
|
||
}
|
||
|
||
/// POST /intentions/cancel_job
|
||
///
|
||
/// Avbryt en ventende jobb.
|
||
pub async fn cancel_job(
|
||
State(state): State<AppState>,
|
||
_admin: AdminUser,
|
||
Json(req): Json<JobIdRequest>,
|
||
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let cancelled = crate::jobs::cancel_job(&state.db, req.job_id)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("Feil ved avbryt: {e}")))?;
|
||
|
||
if !cancelled {
|
||
return Err(bad_request("Jobben finnes ikke eller har feil status for avbryt"));
|
||
}
|
||
|
||
tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb avbrutt");
|
||
Ok(Json(JobActionResponse { success: true }))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct JobIdRequest {
|
||
pub job_id: Uuid,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct JobActionResponse {
|
||
pub success: bool,
|
||
}
|
||
|
||
// =============================================================================
|
||
// Ressursstyring: admin-endepunkter (oppgave 15.5)
|
||
// =============================================================================
|
||
|
||
/// GET /admin/resources — samlet ressursstatus.
|
||
pub async fn resource_status(
|
||
State(state): State<AppState>,
|
||
_admin: AdminUser,
|
||
) -> Result<Json<crate::resources::ResourceStatus>, (StatusCode, Json<ErrorResponse>)> {
|
||
let cas_root = std::env::var("CAS_ROOT")
|
||
.unwrap_or_else(|_| "/srv/synops/media/cas".to_string());
|
||
|
||
let disk = crate::resources::check_disk_usage(&cas_root)
|
||
.unwrap_or_else(|_| crate::resources::check_disk_usage("/").unwrap_or(
|
||
crate::resources::DiskStatus {
|
||
mount_point: "/".to_string(),
|
||
total_bytes: 0,
|
||
used_bytes: 0,
|
||
available_bytes: 0,
|
||
usage_percent: 0.0,
|
||
alert_level: None,
|
||
}
|
||
));
|
||
|
||
let livekit_active = crate::resources::has_active_livekit_rooms(&state.db).await;
|
||
let total_weight = crate::resources::total_running_weight(&state.db, &state.priority_rules).await;
|
||
let rules = state.priority_rules.all().await;
|
||
let running = crate::resources::running_jobs_by_type(&state.db)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
|
||
|
||
Ok(Json(crate::resources::ResourceStatus {
|
||
disk,
|
||
livekit_active,
|
||
total_running_weight: total_weight,
|
||
max_weight: 8, // MAX_TOTAL_WEIGHT fra jobs.rs
|
||
priority_rules: rules,
|
||
running_jobs_by_type: running,
|
||
}))
|
||
}
|
||
|
||
/// GET /admin/resources/disk — disk-status med historikk.
|
||
pub async fn resource_disk(
|
||
State(state): State<AppState>,
|
||
_admin: AdminUser,
|
||
) -> Result<Json<DiskOverview>, (StatusCode, Json<ErrorResponse>)> {
|
||
let current = crate::resources::latest_disk_status(&state.db)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
|
||
let history = crate::resources::disk_status_history(&state.db, 60)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
|
||
|
||
Ok(Json(DiskOverview { current, history }))
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct DiskOverview {
|
||
pub current: Option<crate::resources::DiskStatus>,
|
||
pub history: Vec<crate::resources::DiskStatusHistoryRow>,
|
||
}
|
||
|
||
/// POST /admin/resources/update_rule — oppdater en prioritetsregel.
|
||
pub async fn update_priority_rule(
|
||
State(state): State<AppState>,
|
||
_admin: AdminUser,
|
||
Json(req): Json<UpdatePriorityRuleRequest>,
|
||
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
sqlx::query(
|
||
r#"INSERT INTO job_priority_rules (job_type, base_priority, livekit_priority_adj, cpu_weight, max_concurrent, timeout_seconds, block_during_livekit)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
ON CONFLICT (job_type) DO UPDATE SET
|
||
base_priority = EXCLUDED.base_priority,
|
||
livekit_priority_adj = EXCLUDED.livekit_priority_adj,
|
||
cpu_weight = EXCLUDED.cpu_weight,
|
||
max_concurrent = EXCLUDED.max_concurrent,
|
||
timeout_seconds = EXCLUDED.timeout_seconds,
|
||
block_during_livekit = EXCLUDED.block_during_livekit"#,
|
||
)
|
||
.bind(&req.job_type)
|
||
.bind(req.base_priority)
|
||
.bind(req.livekit_priority_adj)
|
||
.bind(req.cpu_weight)
|
||
.bind(req.max_concurrent)
|
||
.bind(req.timeout_seconds)
|
||
.bind(req.block_during_livekit)
|
||
.execute(&state.db)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
|
||
|
||
// Oppdater cache
|
||
if let Err(e) = state.priority_rules.refresh(&state.db).await {
|
||
tracing::warn!(error = %e, "Kunne ikke refreshe prioritetsregler-cache");
|
||
}
|
||
|
||
tracing::info!(
|
||
job_type = %req.job_type,
|
||
user = %_admin.node_id,
|
||
"Admin: prioritetsregel oppdatert"
|
||
);
|
||
|
||
Ok(Json(JobActionResponse { success: true }))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UpdatePriorityRuleRequest {
|
||
pub job_type: String,
|
||
pub base_priority: i16,
|
||
pub livekit_priority_adj: i16,
|
||
pub cpu_weight: i16,
|
||
pub max_concurrent: i16,
|
||
pub timeout_seconds: i32,
|
||
pub block_during_livekit: bool,
|
||
}
|
||
|
||
// =============================================================================
|
||
// Orkestrering: kompilering og testkjøring (oppgave 24.6)
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CompileScriptRequest {
|
||
pub script: String,
|
||
}
|
||
|
||
/// Kompiler et orkestreringsscript og returner diagnostikk + kompilert resultat.
|
||
/// Brukes av frontend-editoren for sanntids kompileringsfeedback.
|
||
pub async fn compile_script(
|
||
State(state): State<AppState>,
|
||
_user: AuthUser,
|
||
Json(req): Json<CompileScriptRequest>,
|
||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||
use crate::script_compiler;
|
||
|
||
let result = script_compiler::compile_script(&state.db, &req.script)
|
||
.await
|
||
.map_err(|e| bad_request(&e))?;
|
||
|
||
// Serialiser CompileResult til JSON
|
||
let json = serde_json::to_value(&result)
|
||
.map_err(|e| internal_error(&format!("Serialiseringsfeil: {e}")))?;
|
||
|
||
Ok(Json(json))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct TestOrchestrationRequest {
|
||
pub orchestration_id: Uuid,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct TestOrchestrationResponse {
|
||
pub job_id: String,
|
||
}
|
||
|
||
/// Kjør en orkestrering manuelt (testkjøring).
|
||
/// Oppretter en "orchestrate"-jobb med trigger_event = "manual".
|
||
pub async fn test_orchestration(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<TestOrchestrationRequest>,
|
||
) -> Result<Json<TestOrchestrationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Verifiser at noden er en orchestration-node
|
||
let node = sqlx::query_as::<_, (String, Option<String>)>(
|
||
"SELECT node_kind, content FROM nodes WHERE id = $1",
|
||
)
|
||
.bind(req.orchestration_id)
|
||
.fetch_optional(&state.db)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?
|
||
.ok_or_else(|| bad_request("Orkestreringsnode ikke funnet"))?;
|
||
|
||
if node.0 != "orchestration" {
|
||
return Err(bad_request("Noden er ikke en orchestration-node"));
|
||
}
|
||
|
||
// Opprett en jobb i køen
|
||
let job_id = sqlx::query_scalar::<_, Uuid>(
|
||
r#"
|
||
INSERT INTO job_queue (job_type, collection_node_id, payload, status, priority)
|
||
VALUES ('orchestrate', NULL, $1, 'pending', 5)
|
||
RETURNING id
|
||
"#,
|
||
)
|
||
.bind(serde_json::json!({
|
||
"orchestration_id": req.orchestration_id.to_string(),
|
||
"trigger_event": "manual",
|
||
"trigger_context": {},
|
||
"test_run": true,
|
||
"initiated_by": user.node_id.to_string(),
|
||
}))
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("Kunne ikke opprette testjobb: {e}")))?;
|
||
|
||
tracing::info!(
|
||
orchestration_id = %req.orchestration_id,
|
||
job_id = %job_id,
|
||
user = %user.node_id,
|
||
"Manuell testkjøring av orkestrering startet"
|
||
);
|
||
|
||
Ok(Json(TestOrchestrationResponse {
|
||
job_id: job_id.to_string(),
|
||
}))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct OrchestrationLogParams {
|
||
pub orchestration_id: Uuid,
|
||
pub limit: Option<i64>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct OrchestrationLogEntry {
|
||
pub id: String,
|
||
pub job_id: Option<String>,
|
||
pub step_number: i16,
|
||
pub tool_binary: String,
|
||
pub args: serde_json::Value,
|
||
pub is_fallback: bool,
|
||
pub status: String,
|
||
pub exit_code: Option<i16>,
|
||
pub error_msg: Option<String>,
|
||
pub duration_ms: Option<i32>,
|
||
pub created_at: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct OrchestrationLogResponse {
|
||
pub entries: Vec<OrchestrationLogEntry>,
|
||
}
|
||
|
||
/// Hent kjørehistorikk for en orkestrering.
|
||
pub async fn orchestration_log(
|
||
State(state): State<AppState>,
|
||
_user: AuthUser,
|
||
axum::extract::Query(params): axum::extract::Query<OrchestrationLogParams>,
|
||
) -> Result<Json<OrchestrationLogResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
let limit = params.limit.unwrap_or(50).min(200);
|
||
|
||
let rows = sqlx::query_as::<_, (
|
||
Uuid,
|
||
Option<Uuid>,
|
||
i16,
|
||
String,
|
||
serde_json::Value,
|
||
bool,
|
||
String,
|
||
Option<i16>,
|
||
Option<String>,
|
||
Option<i32>,
|
||
chrono::DateTime<chrono::Utc>,
|
||
)>(
|
||
r#"
|
||
SELECT id, job_id, step_number, tool_binary, args, is_fallback,
|
||
status, exit_code, error_msg, duration_ms, created_at
|
||
FROM orchestration_log
|
||
WHERE orchestration_id = $1
|
||
ORDER BY created_at DESC
|
||
LIMIT $2
|
||
"#,
|
||
)
|
||
.bind(params.orchestration_id)
|
||
.bind(limit)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
|
||
|
||
let entries = rows
|
||
.into_iter()
|
||
.map(|r| OrchestrationLogEntry {
|
||
id: r.0.to_string(),
|
||
job_id: r.1.map(|u| u.to_string()),
|
||
step_number: r.2,
|
||
tool_binary: r.3,
|
||
args: r.4,
|
||
is_fallback: r.5,
|
||
status: r.6,
|
||
exit_code: r.7,
|
||
error_msg: r.8,
|
||
duration_ms: r.9,
|
||
created_at: r.10.to_rfc3339(),
|
||
})
|
||
.collect();
|
||
|
||
Ok(Json(OrchestrationLogResponse { entries }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// AI-assistert script-generering (oppgave 24.7)
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct AiSuggestScriptRequest {
|
||
/// Fritekst-beskrivelse av ønsket orkestrering
|
||
pub description: String,
|
||
/// Trigger-event (valgfri, f.eks. "communication.ended")
|
||
pub trigger_event: Option<String>,
|
||
/// Trigger-betingelser som JSON (valgfri)
|
||
pub trigger_conditions: Option<serde_json::Value>,
|
||
/// Eventually-modus: lagre som work_item i stedet for synkront LLM-kall
|
||
#[serde(default)]
|
||
pub eventually: bool,
|
||
/// Samlings-ID å knytte til (valgfri)
|
||
pub collection_id: Option<Uuid>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct AiSuggestScriptResponse {
|
||
pub status: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub script: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub compile_result: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub work_item_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub message: Option<String>,
|
||
}
|
||
|
||
/// AI-assistert generering av orkestreringsscript.
|
||
///
|
||
/// Kaller `synops-ai` CLI-verktøyet for å generere et script fra
|
||
/// fritekst-beskrivelse, og validerer resultatet via script-kompilatoren.
|
||
/// I eventually-modus lagres forespørselen som work_item for Claude Code.
|
||
///
|
||
/// Ref: docs/concepts/orkestrering.md § "Nivå 2: AI-assistert oppretting"
|
||
pub async fn ai_suggest_script(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<AiSuggestScriptRequest>,
|
||
) -> Result<Json<AiSuggestScriptResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
use crate::cli_dispatch;
|
||
use crate::script_compiler;
|
||
|
||
if req.description.trim().is_empty() {
|
||
return Err(bad_request("description kan ikke være tom"));
|
||
}
|
||
|
||
// Eventually-modus: kall synops-ai --eventually
|
||
if req.eventually {
|
||
let mut cmd = tokio::process::Command::new("synops-ai");
|
||
cmd.arg("--description").arg(&req.description);
|
||
cmd.arg("--eventually");
|
||
cmd.arg("--requested-by").arg(user.node_id.to_string());
|
||
|
||
if let Some(ref event) = req.trigger_event {
|
||
cmd.arg("--trigger-event").arg(event);
|
||
}
|
||
if let Some(ref conditions) = req.trigger_conditions {
|
||
cmd.arg("--trigger-conditions").arg(conditions.to_string());
|
||
}
|
||
if let Some(ref coll_id) = req.collection_id {
|
||
cmd.arg("--collection-id").arg(coll_id.to_string());
|
||
}
|
||
|
||
cli_dispatch::set_database_url(&mut cmd)
|
||
.map_err(|e| internal_error(&e))?;
|
||
cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
|
||
cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
|
||
cli_dispatch::forward_env(&mut cmd, "AI_SCRIPT_MODEL");
|
||
|
||
let result = cli_dispatch::run_cli_tool("synops-ai", &mut cmd)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("synops-ai feilet: {e}")))?;
|
||
|
||
let work_item_id = result.get("work_item_id")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
|
||
return Ok(Json(AiSuggestScriptResponse {
|
||
status: "deferred".to_string(),
|
||
script: None,
|
||
compile_result: None,
|
||
work_item_id,
|
||
message: Some("Forespørselen er lagret. Scriptet genereres i bakgrunnen.".to_string()),
|
||
}));
|
||
}
|
||
|
||
// Synkron modus: kall synops-ai for generering
|
||
let mut cmd = tokio::process::Command::new("synops-ai");
|
||
cmd.arg("--description").arg(&req.description);
|
||
|
||
if let Some(ref event) = req.trigger_event {
|
||
cmd.arg("--trigger-event").arg(event);
|
||
}
|
||
if let Some(ref conditions) = req.trigger_conditions {
|
||
cmd.arg("--trigger-conditions").arg(conditions.to_string());
|
||
}
|
||
if let Some(ref coll_id) = req.collection_id {
|
||
cmd.arg("--collection-id").arg(coll_id.to_string());
|
||
}
|
||
cmd.arg("--requested-by").arg(user.node_id.to_string());
|
||
|
||
cli_dispatch::set_database_url(&mut cmd)
|
||
.map_err(|e| internal_error(&e))?;
|
||
cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
|
||
cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
|
||
cli_dispatch::forward_env(&mut cmd, "AI_SCRIPT_MODEL");
|
||
|
||
let ai_result = cli_dispatch::run_cli_tool("synops-ai", &mut cmd)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("synops-ai feilet: {e}")))?;
|
||
|
||
let generated_script = ai_result
|
||
.get("script")
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| internal_error("synops-ai returnerte ingen script"))?
|
||
.to_string();
|
||
|
||
// Valider scriptet med kompilatoren
|
||
let compile_result = script_compiler::compile_script(&state.db, &generated_script)
|
||
.await
|
||
.map_err(|e| internal_error(&format!("Kompileringsfeil: {e}")))?;
|
||
|
||
let compile_json = serde_json::to_value(&compile_result)
|
||
.map_err(|e| internal_error(&format!("Serialisering: {e}")))?;
|
||
|
||
tracing::info!(
|
||
user = %user.node_id,
|
||
has_errors = compile_result.has_errors(),
|
||
"AI-script generert og validert"
|
||
);
|
||
|
||
Ok(Json(AiSuggestScriptResponse {
|
||
status: if compile_result.has_errors() {
|
||
"generated_with_errors".to_string()
|
||
} else {
|
||
"completed".to_string()
|
||
},
|
||
script: Some(generated_script),
|
||
compile_result: Some(compile_json),
|
||
work_item_id: None,
|
||
message: None,
|
||
}))
|
||
}
|
||
|
||
// =============================================================================
|
||
// POST /intentions/clip_url — klipp URL og opprett content-node
|
||
// =============================================================================
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct ClipUrlRequest {
|
||
/// URL som skal klippes.
|
||
pub url: String,
|
||
/// Skriv resultat til database (default: true).
|
||
#[serde(default = "default_true")]
|
||
pub write: bool,
|
||
/// Tving bruk av Playwright (headless browser).
|
||
#[serde(default)]
|
||
pub playwright: bool,
|
||
}
|
||
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct ClipUrlResponse {
|
||
pub job_id: Uuid,
|
||
}
|
||
|
||
/// POST /intentions/clip_url
|
||
///
|
||
/// Legger en `clip_url`-jobb i køen.
|
||
/// synops-clip henter artikkelen, parser med Readability,
|
||
/// og oppretter content-node med AI-oppsummering.
|
||
pub async fn clip_url(
|
||
State(state): State<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<ClipUrlRequest>,
|
||
) -> Result<Json<ClipUrlResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Enkel URL-validering
|
||
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
|
||
return Err(bad_request("URL må starte med http:// eller https://"));
|
||
}
|
||
|
||
let payload = serde_json::json!({
|
||
"url": req.url,
|
||
"created_by": user.node_id.to_string(),
|
||
"write": req.write,
|
||
"playwright": req.playwright,
|
||
});
|
||
|
||
let job_id = crate::jobs::enqueue(
|
||
&state.db,
|
||
"clip_url",
|
||
payload,
|
||
None,
|
||
3, // Lav prioritet — ikke tidskritisk
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!(error = %e, "Kunne ikke legge clip_url-jobb i kø");
|
||
internal_error("Kunne ikke starte URL-klipping")
|
||
})?;
|
||
|
||
tracing::info!(
|
||
job_id = %job_id,
|
||
url = %req.url,
|
||
user = %user.node_id,
|
||
"clip_url-jobb lagt i kø"
|
||
);
|
||
|
||
Ok(Json(ClipUrlResponse { job_id }))
|
||
}
|
||
|
||
// =============================================================================
|
||
// 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<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<ConfigureFeedSubscriptionRequest>,
|
||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||
// 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<crate::feed_poller::FeedSubscription> = 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<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<RemoveFeedSubscriptionRequest>,
|
||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||
// Les eksisterende abonnementer
|
||
let metadata: Option<serde_json::Value> = 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<crate::feed_poller::FeedSubscription> = 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<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<ConfigureCalendarSubscriptionRequest>,
|
||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||
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<crate::calendar_poller::CalendarSubscription> = 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<AppState>,
|
||
user: AuthUser,
|
||
Json(req): Json<RemoveCalendarSubscriptionRequest>,
|
||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||
let metadata: Option<serde_json::Value> = 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<crate::calendar_poller::CalendarSubscription> = 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<String>,
|
||
}
|
||
|
||
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<AppState>,
|
||
_admin: AdminUser,
|
||
Json(req): Json<SendNotificationRequest>,
|
||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||
// 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<String> = 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());
|
||
}
|
||
}
|