diff --git a/tasks.md b/tasks.md index 827af7c..8db076a 100644 --- a/tasks.md +++ b/tasks.md @@ -378,8 +378,7 @@ modell som brukes til hva. ### Øvrige manglende verktøy -- [~] 28.4 `synops-notify`: send varsel via epost (synops-mail), WebSocket-push, eller begge. Input: `--to --message [--channel email|ws|both]`. Brukes av orkestreringer og vaktmesteren. - > Påbegynt: 2026-03-18T20:20 +- [x] 28.4 `synops-notify`: send varsel via epost (synops-mail), WebSocket-push, eller begge. Input: `--to --message [--channel email|ws|both]`. Brukes av orkestreringer og vaktmesteren. - [ ] 28.5 `synops-validate`: sjekk at en node matcher forventet skjema for sin node_kind. Input: `--node-id `. Output: liste av avvik. Brukes av valideringsfasen og som pre-commit sjekk. - [ ] 28.6 `synops-backup`: PG-dump + CAS-filiste + metadata-snapshot. Input: `[--full | --incremental]`. Output: backup-sti. Erstatter cron-scriptet fra 12.2. - [ ] 28.7 `synops-health`: sjekk status for alle tjenester (PG, Caddy, vaktmesteren, LiteLLM, Whisper, LiveKit). Output: JSON med status per tjeneste. Brukes av admin-dashboard og overvåking. diff --git a/tools/README.md b/tools/README.md index 84b8123..76eeab3 100644 --- a/tools/README.md +++ b/tools/README.md @@ -24,6 +24,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. | `synops-ai` | LLM-verktøy: `prompt` (direkte LLM-kall) + `script` (orkestreringsscript fra fritekst) | Ferdig | | `synops-clip` | Hent og parse webartikler (Readability + Playwright-fallback, paywall-deteksjon) | Ferdig | | `synops-mail` | Send epost via msmtp (vaktmester@synops.no) | Ferdig (venter SMTP-credentials) | +| `synops-notify` | Send varsel via epost, WebSocket-push, eller begge | Ferdig | ## Delt bibliotek diff --git a/tools/synops-notify/Cargo.toml b/tools/synops-notify/Cargo.toml new file mode 100644 index 0000000..a852f96 --- /dev/null +++ b/tools/synops-notify/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "synops-notify" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "synops-notify" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive", "env"] } +synops-common = { path = "../synops-common" } +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] } +uuid = { version = "1", features = ["v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" diff --git a/tools/synops-notify/src/main.rs b/tools/synops-notify/src/main.rs new file mode 100644 index 0000000..bf71c1a --- /dev/null +++ b/tools/synops-notify/src/main.rs @@ -0,0 +1,273 @@ +// synops-notify — Send varsel via epost, WebSocket-push, eller begge. +// +// Løser opp mottaker fra node_id, og sender varsel via valgt kanal: +// - email: Slår opp epost fra auth_identities, kaller synops-mail +// - ws: Oppretter notification-node i PG med tilgang for mottaker. +// PG NOTIFY-trigger sender sanntidsoppdatering via portvokteren. +// - both: Begge kanaler +// +// Bruk: +// synops-notify --to --message "Tekst" --channel email +// synops-notify --to --message "Tekst" --channel ws +// synops-notify --to --message "Tekst" --channel both +// synops-notify --to --message "Tekst" (default: ws) +// +// Brukes av orkestreringer og vaktmesteren. +// +// Output: JSON til stdout med resultat per kanal. +// Feil: stderr + exit code != 0. +// +// Ref: docs/retninger/unix_filosofi.md, tools/README.md + +use clap::{Parser, ValueEnum}; +use serde::Serialize; +use sqlx::PgPool; +use std::process::{Command, Stdio}; +use uuid::Uuid; + +/// Varslingskanal. +#[derive(Debug, Clone, Copy, ValueEnum)] +enum Channel { + Email, + Ws, + Both, +} + +/// Send varsel via epost, WebSocket-push, eller begge. +#[derive(Parser)] +#[command(name = "synops-notify", about = "Send varsel til bruker via epost og/eller WebSocket")] +struct Cli { + /// Mottaker (node_id for bruker) + #[arg(long)] + to: Uuid, + + /// Varseltekst + #[arg(long)] + message: String, + + /// Emne for epost (valgfritt, default: "Varsel fra Synops") + #[arg(long, default_value = "Varsel fra Synops")] + subject: String, + + /// Kanal: email, ws, both (default: ws) + #[arg(long, value_enum, default_value = "ws")] + channel: Channel, + + /// Payload fra jobbkø (JSON). Overstyrer andre argumenter. + #[arg(long)] + payload_json: Option, +} + +/// Resultat fra notifikasjonen. +#[derive(Serialize)] +struct NotifyResult { + ok: bool, + to: String, + channels: Vec, +} + +#[derive(Serialize)] +struct ChannelResult { + channel: String, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[tokio::main] +async fn main() { + synops_common::logging::init("synops_notify"); + + let cli = Cli::parse(); + + // Støtte for --payload-json (jobbkø-dispatch) + let (to, message, subject, channel) = if let Some(ref json_str) = cli.payload_json { + let payload: serde_json::Value = serde_json::from_str(json_str).unwrap_or_else(|e| { + eprintln!("Ugyldig --payload-json: {e}"); + std::process::exit(1); + }); + let to = payload["to"] + .as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| { + eprintln!("Mangler 'to' (UUID) i payload"); + std::process::exit(1); + }); + let message = payload["message"] + .as_str() + .unwrap_or_else(|| { + eprintln!("Mangler 'message' i payload"); + std::process::exit(1); + }) + .to_string(); + let subject = payload["subject"] + .as_str() + .unwrap_or("Varsel fra Synops") + .to_string(); + let channel = match payload["channel"].as_str().unwrap_or("ws") { + "email" => Channel::Email, + "both" => Channel::Both, + _ => Channel::Ws, + }; + (to, message, subject, channel) + } else { + (cli.to, cli.message, cli.subject, cli.channel) + }; + + let db = synops_common::db::connect().await.unwrap_or_else(|e| { + eprintln!("{e}"); + std::process::exit(1); + }); + + // Verifiser at mottaker-noden eksisterer og er en bruker/agent + let node_kind: Option = sqlx::query_scalar( + "SELECT node_kind::text FROM nodes WHERE id = $1", + ) + .bind(to) + .fetch_optional(&db) + .await + .unwrap_or_else(|e| { + eprintln!("DB-feil ved oppslag av mottaker: {e}"); + std::process::exit(1); + }); + + let node_kind = node_kind.unwrap_or_else(|| { + eprintln!("Mottaker-node {to} finnes ikke"); + std::process::exit(1); + }); + + if !["person", "agent"].contains(&node_kind.as_str()) { + eprintln!("Mottaker {to} er {node_kind}, ikke person/agent — kan ikke sende varsel"); + std::process::exit(1); + } + + let mut channels = Vec::new(); + let mut all_ok = true; + + // E-post + if matches!(channel, Channel::Email | Channel::Both) { + let result = send_email(&db, to, &subject, &message).await; + let ok = result.is_ok(); + if !ok { + all_ok = false; + } + channels.push(ChannelResult { + channel: "email".to_string(), + ok, + detail: result.as_ref().ok().cloned(), + error: result.err(), + }); + } + + // WebSocket (via notification-node i PG) + if matches!(channel, Channel::Ws | Channel::Both) { + let result = send_ws(&db, to, &subject, &message).await; + let ok = result.is_ok(); + if !ok { + all_ok = false; + } + channels.push(ChannelResult { + channel: "ws".to_string(), + ok, + detail: result.as_ref().ok().map(|id| format!("notification_node={id}")), + error: result.err(), + }); + } + + let output = NotifyResult { + ok: all_ok, + to: to.to_string(), + channels, + }; + + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + + if !all_ok { + std::process::exit(1); + } +} + +/// Send epost via synops-mail. Slår opp mottakers epostadresse fra auth_identities. +async fn send_email(db: &PgPool, to: Uuid, subject: &str, message: &str) -> Result { + let email: Option = sqlx::query_scalar( + "SELECT email FROM auth_identities WHERE node_id = $1", + ) + .bind(to) + .fetch_optional(db) + .await + .map_err(|e| format!("DB-feil ved e-post-oppslag: {e}"))?; + + let email = email.ok_or_else(|| format!("Ingen epost registrert for node {to}"))?; + + tracing::info!(to = %email, subject, "Sender epost via synops-mail"); + + let output = Command::new("synops-mail") + .args(["--send", "--to", &email, "--subject", subject, "--body", message]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("Kunne ikke starte synops-mail: {e}"))?; + + if output.status.success() { + Ok(format!("Sendt til {email}")) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("synops-mail feilet: {stderr}")) + } +} + +/// Send WebSocket-varsel ved å opprette en notification-node i PG. +/// +/// Steg: +/// 1. Opprett node med node_kind='notification', visibility='private' +/// 2. Opprett node_access-rad for mottaker → noden blir synlig via portvokteren +/// +/// PG NOTIFY-triggere (fra migrasjon 018) sender sanntidsoppdatering +/// til portvokteren, som leverer til mottakerens WebSocket-tilkobling. +async fn send_ws(db: &PgPool, to: Uuid, subject: &str, message: &str) -> Result { + let node_id = Uuid::now_v7(); + + let metadata = serde_json::json!({ + "notification_type": "general", + "recipient": to.to_string(), + }); + + // Opprett notification-node og tilgang i én transaksjon + let mut tx = db.begin().await.map_err(|e| format!("Transaksjon feilet: {e}"))?; + + sqlx::query( + r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata) + VALUES ($1, 'notification', $2, $3, 'hidden', $4)"#, + ) + .bind(node_id) + .bind(subject) + .bind(message) + .bind(&metadata) + .execute(&mut *tx) + .await + .map_err(|e| format!("Kunne ikke opprette notification-node: {e}"))?; + + // Gi mottaker lesetilgang — dette trigger access_changed NOTIFY + sqlx::query( + r#"INSERT INTO node_access (subject_id, object_id, access) + VALUES ($1, $2, 'reader')"#, + ) + .bind(to) + .bind(node_id) + .execute(&mut *tx) + .await + .map_err(|e| format!("Kunne ikke gi mottaker tilgang: {e}"))?; + + tx.commit().await.map_err(|e| format!("Commit feilet: {e}"))?; + + tracing::info!( + notification_id = %node_id, + to = %to, + "Notification-node opprettet — WebSocket-push via PG NOTIFY" + ); + + Ok(node_id) +}