// 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, /// Varslingstype for preferansesjekk (f.eks. "task_assigned", "article_approved"). /// Hvis satt, sjekkes brukerens metadata.preferences.notifications.. #[arg(long)] notification_type: Option, /// Hopp over preferansesjekk — send uansett brukerens innstillinger. #[arg(long)] skip_preferences: bool, /// 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, notification_type, skip_preferences) = 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, }; let notification_type = payload["notification_type"] .as_str() .map(|s| s.to_string()); let skip_preferences = payload["skip_preferences"].as_bool().unwrap_or(false); (to, message, subject, channel, notification_type, skip_preferences) } else { ( cli.to, cli.message, cli.subject, cli.channel, cli.notification_type, cli.skip_preferences, ) }; let db = synops_common::db::connect().await.unwrap_or_else(|e| { eprintln!("{e}"); std::process::exit(1); }); // Verifiser at mottaker-noden eksisterer, er en bruker/agent, og hent metadata let row: Option<(String, serde_json::Value)> = sqlx::query_as( "SELECT node_kind::text, metadata 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, metadata) = row.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); } // Sjekk brukerens varslingspreferanser (metadata.preferences.notifications) // Default: alle kanaler aktivert. Bruker kan skru av per kanal og per type. let prefs = if skip_preferences { None } else { get_notification_prefs(&metadata) }; let mut channels = Vec::new(); let mut all_ok = true; // E-post if matches!(channel, Channel::Email | Channel::Both) { if is_channel_enabled(&prefs, "email", notification_type.as_deref()) { 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(), }); } else { tracing::info!(to = %to, "Epost-varsel hoppet over — deaktivert av bruker"); channels.push(ChannelResult { channel: "email".to_string(), ok: true, detail: Some("Hoppet over — deaktivert i preferanser".to_string()), error: None, }); } } // WebSocket (via notification-node i PG) if matches!(channel, Channel::Ws | Channel::Both) { if is_channel_enabled(&prefs, "ws", notification_type.as_deref()) { 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(), }); } else { tracing::info!(to = %to, "WS-varsel hoppet over — deaktivert av bruker"); channels.push(ChannelResult { channel: "ws".to_string(), ok: true, detail: Some("Hoppet over — deaktivert i preferanser".to_string()), error: None, }); } } 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); } } /// Hent brukerens varslingspreferanser fra metadata. /// /// Metadata-struktur (opt-out — alt er aktivert som default): /// ```json /// { /// "preferences": { /// "notifications": { /// "email": true, // global epost-bryter (default: true) /// "ws": true, // global ws-bryter (default: true) /// "task_assigned": true, // per-type overstyring (default: true) /// "article_approved": true, /// "...": true /// } /// } /// } /// ``` fn get_notification_prefs(metadata: &serde_json::Value) -> Option { metadata .get("preferences") .and_then(|p| p.get("notifications")) .cloned() } /// Sjekk om en spesifikk kanal er aktivert for brukeren. /// /// Logikk: /// 1. Ingen preferanser → aktivert (default) /// 2. Per-type bryter sjekkes først: `prefs.` = false → deaktivert /// 3. Global kanal-bryter: `prefs.` = false → deaktivert /// 4. Alt annet → aktivert fn is_channel_enabled( prefs: &Option, channel_name: &str, notification_type: Option<&str>, ) -> bool { let prefs = match prefs { Some(p) => p, None => return true, // Ingen preferanser = alt aktivert }; // Sjekk per-type bryter (overstyrer kanal-bryter) if let Some(ntype) = notification_type { if let Some(enabled) = prefs.get(ntype).and_then(|v| v.as_bool()) { if !enabled { return false; } } } // Sjekk global kanal-bryter prefs .get(channel_name) .and_then(|v| v.as_bool()) .unwrap_or(true) } /// 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) }