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)
378 lines
12 KiB
Rust
378 lines
12 KiB
Rust
// 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 <node_id> --message "Tekst" --channel email
|
|
// synops-notify --to <node_id> --message "Tekst" --channel ws
|
|
// synops-notify --to <node_id> --message "Tekst" --channel both
|
|
// synops-notify --to <node_id> --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.<type>.
|
|
#[arg(long)]
|
|
notification_type: Option<String>,
|
|
|
|
/// Hopp over preferansesjekk — send uansett brukerens innstillinger.
|
|
#[arg(long)]
|
|
skip_preferences: bool,
|
|
|
|
/// Payload fra jobbkø (JSON). Overstyrer andre argumenter.
|
|
#[arg(long)]
|
|
payload_json: Option<String>,
|
|
}
|
|
|
|
/// Resultat fra notifikasjonen.
|
|
#[derive(Serialize)]
|
|
struct NotifyResult {
|
|
ok: bool,
|
|
to: String,
|
|
channels: Vec<ChannelResult>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ChannelResult {
|
|
channel: String,
|
|
ok: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
detail: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[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::<Uuid>().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<serde_json::Value> {
|
|
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.<notification_type>` = false → deaktivert
|
|
/// 3. Global kanal-bryter: `prefs.<channel>` = false → deaktivert
|
|
/// 4. Alt annet → aktivert
|
|
fn is_channel_enabled(
|
|
prefs: &Option<serde_json::Value>,
|
|
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<String, String> {
|
|
let email: Option<String> = 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<Uuid, String> {
|
|
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)
|
|
}
|