26.7 ferdig: utgående varsler med brukerpreferanser
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)
This commit is contained in:
parent
7be810b994
commit
6370b02cc7
7 changed files with 434 additions and 56 deletions
132
docs/features/varsler.md
Normal file
132
docs/features/varsler.md
Normal file
|
|
@ -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 <node_id> --message "Tekst" --channel ws
|
||||
|
||||
# Epost-varsel
|
||||
synops-notify --to <node_id> --message "Tekst" --subject "Emne" --channel email
|
||||
|
||||
# Begge kanaler med type-sjekk
|
||||
synops-notify --to <node_id> --message "Ny oppgave" --channel both \
|
||||
--notification-type task_assigned
|
||||
|
||||
# Ignorer preferanser (systemkritisk)
|
||||
synops-notify --to <node_id> --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.
|
||||
|
|
@ -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<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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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<serde_json::Value, String> {
|
||||
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)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
3
tasks.md
3
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.<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>,
|
||||
|
|
@ -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::<Uuid>().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::<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,
|
||||
)
|
||||
};
|
||||
(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<String> = 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<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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue