diff --git a/docs/features/varsler.md b/docs/features/varsler.md new file mode 100644 index 0000000..12eb497 --- /dev/null +++ b/docs/features/varsler.md @@ -0,0 +1,132 @@ +# Varsler (notifications) + +Vaktmesteren kan sende varsler til brukere via epost og/eller +WebSocket-push. Konfigurerbart per bruker i metadata.preferences. + +## Arkitektur + +``` +Trigger (orkestreringsscript, intention, manuelt) + │ + ▼ +Jobbkø: send_notification + │ + ▼ +synops-notify CLI + │ + ├── Sjekk metadata.preferences.notifications + │ + ├── Epost: synops-mail --send → msmtp → Brevo SMTP + └── WebSocket: notification-node i PG → NOTIFY → portvokteren → klient +``` + +## Kanaler + +| Kanal | Beskrivelse | +|-------|-------------| +| `email` | Epost via msmtp/Brevo. Slår opp adresse i `auth_identities`. | +| `ws` | Oppretter `notification`-node med `node_access` → PG NOTIFY → WebSocket. | +| `both` | Begge kanaler. | + +## Brukerpreferanser + +Lagres i brukerens node (`node_kind='person'`), i `metadata.preferences.notifications`. + +**Opt-out-modell:** Alt er aktivert som default. Brukere skrur av det de +ikke vil ha. Manglende felt = aktivert. + +### Skjema + +```json +{ + "preferences": { + "notifications": { + "email": true, // global epost-bryter + "ws": true, // global WS-bryter + "task_assigned": true, // per-type: ny oppgave tildelt + "article_approved": true, // per-type: artikkel godkjent + "comment_reply": true // per-type: svar på kommentar + } + } +} +``` + +### Evalueringslogikk + +1. Ingen preferanser satt → alle kanaler aktivert +2. Per-type bryter (`task_assigned: false`) → begge kanaler deaktivert for den typen +3. Global kanal-bryter (`email: false`) → epost deaktivert for alle typer +4. `--skip-preferences` → ignorer preferanser (for systemkritiske varsler) + +## Bruk + +### CLI (direkte) + +```bash +# WebSocket-varsel (default) +synops-notify --to --message "Tekst" --channel ws + +# Epost-varsel +synops-notify --to --message "Tekst" --subject "Emne" --channel email + +# Begge kanaler med type-sjekk +synops-notify --to --message "Ny oppgave" --channel both \ + --notification-type task_assigned + +# Ignorer preferanser (systemkritisk) +synops-notify --to --message "Viktig" --channel email --skip-preferences +``` + +### Via jobbkø + +```json +{ + "job_type": "send_notification", + "payload": { + "to": "a0eebc99-...", + "message": "Du har fått en ny oppgave", + "subject": "Ny oppgave", + "channel": "both", + "notification_type": "task_assigned" + } +} +``` + +### Via API (intention) + +``` +POST /intentions/send_notification +{ + "to": "a0eebc99-...", + "message": "Artikkelen din er godkjent", + "subject": "Artikkel godkjent", + "channel": "both", + "notification_type": "article_approved" +} +``` + +Krever admin-rolle. Legger jobben i køen med prioritet 10. + +## Orkestreringsscript + +Varsler kan trigges fra orkestreringsscript: + +``` +NÅR edge opprettet OG type = "member_of" + KJØR synops-notify --to {event.target_id} --message "Ny oppgave tildelt" --channel both --notification-type task_assigned +``` + +## Varslingstyper + +Konvensjon for `notification_type`-feltet: + +| Type | Beskrivelse | +|------|-------------| +| `task_assigned` | Ny oppgave tildelt | +| `article_approved` | Innsendt artikkel godkjent | +| `article_rejected` | Innsendt artikkel avvist | +| `comment_reply` | Svar på kommentar | +| `communication_ended` | Møte/samtale avsluttet | +| `mention` | Nevnt i en node | + +Nye typer legges til etter behov — ingen hardkodet liste i koden. diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index c697774..7058afa 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -5197,6 +5197,117 @@ pub async fn remove_calendar_subscription( }))) } +// ============================================================================= +// Utgående varsler (oppgave 26.7) +// ============================================================================= + +#[derive(Deserialize)] +pub struct SendNotificationRequest { + /// Mottaker (node_id for bruker). + pub to: Uuid, + /// Varseltekst. + pub message: String, + /// Emne for epost (valgfritt). + #[serde(default = "default_notification_subject")] + pub subject: String, + /// Kanal: "email", "ws", "both" (default: "both"). + #[serde(default = "default_notification_channel")] + pub channel: String, + /// Varslingstype for preferansesjekk (f.eks. "task_assigned", "article_approved"). + #[serde(default)] + pub notification_type: Option, +} + +fn default_notification_subject() -> String { + "Varsel fra Synops".to_string() +} + +fn default_notification_channel() -> String { + "both".to_string() +} + +/// POST /intentions/send_notification +/// +/// Legger en `send_notification`-jobb i køen. Jobbkøen delegerer til +/// `synops-notify` som sjekker brukerens preferanser og sender via +/// valgt kanal (epost, WebSocket, eller begge). +/// +/// Admin-only: kun administratorer kan sende varsler til vilkårlige brukere. +pub async fn send_notification( + State(state): State, + _admin: AdminUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Valider kanal + if !["email", "ws", "both"].contains(&req.channel.as_str()) { + return Err(bad_request(&format!( + "Ugyldig kanal: '{}'. Gyldige verdier: email, ws, both", + req.channel + ))); + } + + if req.message.trim().is_empty() { + return Err(bad_request("message kan ikke være tom")); + } + + // Verifiser at mottaker eksisterer og er person/agent + let node_kind: Option = sqlx::query_scalar( + "SELECT node_kind::text FROM nodes WHERE id = $1", + ) + .bind(req.to) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "PG-feil ved oppslag av mottaker"); + internal_error("Databasefeil") + })?; + + let node_kind = node_kind.ok_or_else(|| bad_request("Mottaker-node finnes ikke"))?; + if !["person", "agent"].contains(&node_kind.as_str()) { + return Err(bad_request(&format!( + "Mottaker er {node_kind}, ikke person/agent" + ))); + } + + // Bygg payload og legg i jobbkø + let mut payload = serde_json::json!({ + "to": req.to.to_string(), + "message": req.message, + "subject": req.subject, + "channel": req.channel, + }); + if let Some(ref ntype) = req.notification_type { + payload["notification_type"] = serde_json::Value::String(ntype.clone()); + } + + let job_id = crate::jobs::enqueue( + &state.db, + "send_notification", + payload, + None, + 10, // Høy prioritet — brukervendt varsel + ) + .await + .map_err(|e| { + tracing::error!(error = %e, "Kunne ikke legge varsel-jobb i kø"); + internal_error("Kunne ikke opprette varsel-jobb") + })?; + + tracing::info!( + job_id = %job_id, + to = %req.to, + channel = %req.channel, + "Varsel lagt i jobbkø" + ); + + Ok(Json(serde_json::json!({ + "status": "queued", + "job_id": job_id, + "to": req.to, + "channel": req.channel, + }))) +} + // ============================================================================= // Tester // ============================================================================= diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index 7d40565..0bda051 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -244,6 +244,10 @@ async fn dispatch( "import_podcast" => { crate::podcast_import::handle_import_podcast(job).await } + // Utgående varsler (oppgave 26.7): delegerer til synops-notify CLI + "send_notification" => { + handle_send_notification(job).await + } other => Err(format!("Ukjent jobbtype: {other}")), } } @@ -541,6 +545,31 @@ async fn handle_render_index( Ok(result) } +/// Handler for `send_notification`-jobb — delegerer til synops-notify CLI. +/// +/// Payload: `{ "to": "uuid", "message": "...", "subject": "...", "channel": "email|ws|both" }` +/// synops-notify sjekker brukerens metadata.preferences.notifications før sending. +async fn handle_send_notification( + job: &JobRow, +) -> Result { + let payload_str = serde_json::to_string(&job.payload) + .map_err(|e| format!("Kunne ikke serialisere payload: {e}"))?; + + let mut cmd = tokio::process::Command::new("synops-notify"); + cmd.arg("--payload-json").arg(&payload_str); + + cli_dispatch::set_database_url(&mut cmd)?; + + tracing::info!( + job_id = %job.id, + to = job.payload["to"].as_str().unwrap_or("?"), + channel = job.payload["channel"].as_str().unwrap_or("ws"), + "Sender varsel via synops-notify" + ); + + cli_dispatch::run_cli_tool("synops-notify", &mut cmd).await +} + // ============================================================================= // Admin-API: spørring, retry og avbryt (oppgave 15.3) // ============================================================================= diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 034f4be..d5247ba 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -307,6 +307,8 @@ async fn main() { .route("/intentions/test_orchestration", post(intentions::test_orchestration)) .route("/intentions/ai_suggest_script", post(intentions::ai_suggest_script)) .route("/query/orchestration_log", get(intentions::orchestration_log)) + // Utgående varsler (oppgave 26.7) + .route("/intentions/send_notification", post(intentions::send_notification)) // Mixer-kanaler .route("/intentions/create_mixer_channel", post(mixer::create_mixer_channel)) .route("/intentions/set_gain", post(mixer::set_gain)) diff --git a/tasks.md b/tasks.md index bcae5d3..0643918 100644 --- a/tasks.md +++ b/tasks.md @@ -351,8 +351,7 @@ sidelinja.org, vegard.info) ruter til samme bruker basert på username. - [x] 26.4 Postfix minimal: installer Postfix som lokal MTA kun for mottak. Ingen relay, ingen kø for utgående. Pipe innkommende epost til `synops-mail --receive`. - [x] 26.5 `synops-mail --receive`: Rust CLI som leser raw epost fra stdin. Sjekk 1: avsender-epost matcher `auth_identities.email`? Sjekk 2: innhold starter med "Kjære vaktmester" (eller konfigurerbar frase)? Begge må matche. Opprett `content`-node i brukerens innboks med epostinnholdet. Alt annet → /dev/null, ingen bounce. - [x] 26.6 Domene-alias: `vegard@synops.no`, `vegard@sidelinja.org`, `vegard@vegard.info` ruter alle til samme bruker via username-oppslag i PG. Domenet er irrelevant. -- [~] 26.7 Utgående varsler: vaktmesteren kan sende epost-varsler til brukere (ny oppgave tildelt, innsendt artikkel godkjent, etc.) via `synops-mail --send`. Konfigurerbart per bruker i metadata.preferences. - > Påbegynt: 2026-03-19T01:57 +- [x] 26.7 Utgående varsler: vaktmesteren kan sende epost-varsler til brukere (ny oppgave tildelt, innsendt artikkel godkjent, etc.) via `synops-mail --send`. Konfigurerbart per bruker i metadata.preferences. ## Fase 27: Tankekart — radial grafvisning diff --git a/tools/README.md b/tools/README.md index dbeb1f3..e617e2a 100644 --- a/tools/README.md +++ b/tools/README.md @@ -24,7 +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, motta via Postfix pipe (`--send` / `--receive`) | Ferdig | -| `synops-notify` | Send varsel via epost, WebSocket-push, eller begge | Ferdig | +| `synops-notify` | Send varsel via epost, WebSocket-push, eller begge. Sjekker brukerens preferanser (`metadata.preferences.notifications`) | Ferdig | | `synops-validate` | Valider at en node matcher forventet skjema for sin node_kind | Ferdig | | `synops-backup` | PG-dump + CAS-filiste + metadata-snapshot (`--full` / `--incremental`) | Ferdig | | `synops-health` | Sjekk status for alle tjenester (PG, Caddy, Maskinrommet, LiteLLM, Whisper, LiveKit, Authentik) | Ferdig | diff --git a/tools/synops-notify/src/main.rs b/tools/synops-notify/src/main.rs index bf71c1a..49071b7 100644 --- a/tools/synops-notify/src/main.rs +++ b/tools/synops-notify/src/main.rs @@ -53,6 +53,15 @@ struct Cli { #[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, @@ -83,47 +92,59 @@ async fn main() { 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"); + 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 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 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, + ) }; - (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", + // 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) @@ -133,7 +154,7 @@ async fn main() { std::process::exit(1); }); - let node_kind = node_kind.unwrap_or_else(|| { + let (node_kind, metadata) = row.unwrap_or_else(|| { eprintln!("Mottaker-node {to} finnes ikke"); std::process::exit(1); }); @@ -143,37 +164,65 @@ async fn main() { 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) { - let result = send_email(&db, to, &subject, &message).await; - let ok = result.is_ok(); - if !ok { - all_ok = false; + 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, + }); } - 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; + 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, + }); } - 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 { @@ -189,6 +238,62 @@ async fn main() { } } +/// 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(