Skip to main content

kanade_shared/ipc/
notifications.rs

1//! `notifications.*` method types — paginated history + ack +
2//! push for incoming notifications.
3//!
4//! The notification lifecycle (SPEC §2.12.8 emergency example):
5//!
6//! 1. Operator publishes via backend HTTP API → backend writes to
7//!    NATS `NOTIFICATIONS` JetStream.
8//! 2. Agent consumes the stream, fans out to connected clients via
9//!    `notifications.new` push.
10//! 3. User clicks "確認" → client sends `notifications.ack` → agent
11//!    writes `notifications_read` KV (keyed by
12//!    `{pc_id}.{user_sid}.{notification_id}`) AND publishes
13//!    `events.notifications.acked.{pc_id}.{user_sid}.{notification_id}`
14//!    so the SPA can show per-user confirmation status.
15//! 4. Past notifications stay queryable via `notifications.list` —
16//!    that's the recovery path when the agent missed a push during
17//!    a network blip.
18
19use serde::{Deserialize, Serialize};
20
21// ---------- shared notification body ----------
22
23/// Notification body — used both for [`NotificationsListResult`]
24/// entries and the [`NotificationNewParams`] push.
25#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
26pub struct Notification {
27    /// Stable id minted by the backend (UUID v7). Identifies the
28    /// notification for ack / history lookups.
29    pub id: String,
30    pub priority: NotificationPriority,
31    /// Whether the user must explicitly click "確認" to dismiss.
32    /// Non-acked notifications stay pinned on the Client App's
33    /// notification panel until clicked; acked ones drop into
34    /// history.
35    #[serde(default)]
36    pub require_ack: bool,
37    pub title: String,
38    pub body: String,
39    /// Whether to surface an OS toast for this notification — decoupled
40    /// from [`priority`](Self::priority). `true` gives the full "make
41    /// sure they see it" treatment (persistent native toast; the agent
42    /// launches the Client App when it isn't running; lands in the lock
43    /// screen / Action Center; re-pops on logon/unlock). `false` shows it
44    /// only in the in-app list. `#[serde(default)]` (⇒ `false`) just so a
45    /// pre-this-field body on the retained stream still decodes — it is
46    /// NOT a priority fallback; toast behaviour is driven solely by this
47    /// flag.
48    #[serde(default)]
49    pub toast: bool,
50    /// When the notification was created (backend wall clock).
51    pub issued_at: chrono::DateTime<chrono::Utc>,
52    /// Optional human-readable label of who created the
53    /// notification (e.g. `"infra-team"` in SPEC §2.12.8). Surfaced
54    /// in the Client App for context.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub issued_by: Option<String>,
57    /// Optional expiry (SPEC §2.4.1 `expires_at`). Past this instant
58    /// the Client App stops surfacing the notification (it drops out
59    /// of toasts / the modal / the unread badge) even if never acked.
60    /// `None` ⇒ the notification never auto-expires. Additive +
61    /// optional so pre-Phase-E bodies on the wire still decode.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
64    /// `acked_at` from this user's perspective. Populated by
65    /// `notifications.list` for already-acked entries; never set on
66    /// `notifications.new` pushes (a fresh push by definition
67    /// hasn't been acked yet).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub acked_at: Option<chrono::DateTime<chrono::Utc>>,
70}
71
72/// Severity ladder. Drives the SPA color, toast/dialog choice, and
73/// whether the Client App grabs window focus on push arrival.
74/// `#[non_exhaustive]` so a future SPEC can add severities (e.g.
75/// `Critical` above Emergency) without a wire bump.
76#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
77#[serde(rename_all = "snake_case")]
78#[non_exhaustive]
79pub enum NotificationPriority {
80    /// Background-style toast. Routine maintenance reminders.
81    Info,
82    /// Yellow toast. Heads-up about upcoming changes.
83    Warn,
84    /// Red modal — grabs window focus, blocks until ack
85    /// (SPEC §2.12.8: "緊急: ネットワーク機器メンテ").
86    Emergency,
87    /// #492: serde-level forward-compat catch-all. `#[non_exhaustive]`
88    /// only affects Rust match exhaustiveness — serde still hard-fails
89    /// on an unknown variant STRING, so a newer peer's new variant
90    /// used to make older readers reject the whole containing message.
91    /// Unknown decodes any unrecognised value; UIs render it neutrally.
92    #[serde(other)]
93    Unknown,
94}
95
96// ---------- notifications.list ----------
97
98/// `notifications.list` params — paginated history of notifications
99/// this user has received (per-user, scoped via OS SID).
100#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
101pub struct NotificationsListParams {
102    /// Filter: which subset of the user's notifications to return.
103    /// Defaults to [`NotificationsFilter::Unread`] — the Client App
104    /// loads the unread bucket on first paint.
105    #[serde(default)]
106    pub filter: NotificationsFilter,
107    /// Max number of entries to return. Clamped agent-side to a
108    /// safe upper bound (currently 200) so a misbehaving client
109    /// can't ask for unbounded history. Defaults to 50.
110    #[serde(default = "default_limit")]
111    pub limit: u32,
112    /// Continuation token from a prior response's
113    /// [`NotificationsListResult::next_cursor`]. `None` on first
114    /// page.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub cursor: Option<String>,
117}
118
119impl Default for NotificationsListParams {
120    fn default() -> Self {
121        Self {
122            filter: NotificationsFilter::default(),
123            limit: default_limit(),
124            cursor: None,
125        }
126    }
127}
128
129fn default_limit() -> u32 {
130    50
131}
132
133/// History-list filter selector.
134#[derive(
135    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
136)]
137#[serde(rename_all = "snake_case")]
138pub enum NotificationsFilter {
139    /// Only entries this user has NOT acked. Default — the Client
140    /// App's notification panel opens to this view.
141    #[default]
142    Unread,
143    /// Everything in the user's history window, acked or not.
144    All,
145}
146
147/// `notifications.list` response.
148#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
149pub struct NotificationsListResult {
150    pub items: Vec<Notification>,
151    /// Opaque continuation token. `Some(cursor)` ⇒ caller should
152    /// re-request with `params.cursor = Some(cursor)` to fetch the
153    /// next page; `None` ⇒ caller has the tail.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub next_cursor: Option<String>,
156}
157
158// ---------- notifications.subscribe ----------
159
160#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
161pub struct NotificationsSubscribeParams {}
162
163#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
164pub struct NotificationsSubscribeResult {
165    pub subscription: String,
166}
167
168#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
169pub struct NotificationsUnsubscribeParams {
170    pub subscription: String,
171}
172
173// ---------- notifications.new (push) ----------
174
175/// Push payload for `notifications.new`. The full notification body
176/// inline — no second round-trip needed.
177#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
178pub struct NotificationNewParams {
179    #[serde(flatten)]
180    pub notification: Notification,
181}
182
183// ---------- notifications.ack ----------
184
185/// `notifications.ack` params — mark this notification read for the
186/// caller's user (SID derived from the OS at connect time, NOT
187/// from the payload). SPEC §2.12.4 forbids ack-ing other users'
188/// notifications even on a shared PC — the agent rejects with
189/// `Unauthorized` if the notification's audience doesn't include
190/// the caller.
191#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
192pub struct NotificationsAckParams {
193    pub id: String,
194}
195
196/// `notifications.ack` response — confirms the agent persisted the
197/// ack and published the `events.notifications.acked.>` event.
198#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
199pub struct NotificationsAckResult {
200    /// Wall-clock the agent wrote into `notifications_read` KV.
201    pub acked_at: chrono::DateTime<chrono::Utc>,
202}
203
204// ---------- backend HTTP compose (POST /api/notifications) ----------
205
206/// Operator-facing request body for `POST /api/notifications` (and the
207/// equivalent `notifications/*.yaml` manifest, SPEC §2.4.1). The
208/// backend mints the [`Notification::id`] (when `id` is omitted) and
209/// [`Notification::issued_at`], resolves [`target`](Self::target) into
210/// the `notifications.{all|group.X|pc.Y}` fan-out subjects, and
211/// publishes one [`Notification`] per resolved subject into the
212/// `NOTIFICATIONS` stream.
213#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
214pub struct PublishNotificationRequest {
215    /// Operator-supplied id — the manifest's `id:` doubles as the
216    /// notification id (SPEC §2.4.1). Omit it for ad-hoc SPA composer
217    /// sends and the backend mints a UUID instead.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub id: Option<String>,
220    pub priority: NotificationPriority,
221    #[serde(default)]
222    pub require_ack: bool,
223    pub title: String,
224    pub body: String,
225    /// Surface an OS toast (see [`Notification::toast`]). Decoupled from
226    /// `priority`; defaults to `false` (in-app only).
227    #[serde(default)]
228    pub toast: bool,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub issued_by: Option<String>,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
233    /// Fan-out audience — same shape as a job manifest's `target:`
234    /// (SPEC §2.4.1). At least one of `all` / `groups` / `pcs` must be
235    /// set or the backend rejects the request.
236    pub target: crate::manifest::Target,
237}
238
239/// Response of `POST /api/notifications` — the minted/echoed id plus
240/// the subjects the notification fanned out to, so the operator UI can
241/// confirm the resolved audience.
242#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
243pub struct PublishNotificationResponse {
244    pub id: String,
245    pub subjects: Vec<String>,
246}
247
248// ---------- ack event (Agent → NATS → backend projector) ----------
249
250/// Body of the
251/// `events.notifications.acked.{pc_id}.{user_sid}.{notif_id}` event the
252/// agent publishes when a user acks a notification. The backend's
253/// notification-acks projector reads these fields from the JSON body
254/// (not by parsing the subject) so an id / SID containing a `.` can't
255/// desync the projected row from its subject tokens.
256#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
257pub struct NotificationAcked {
258    pub notification_id: String,
259    pub pc_id: String,
260    pub user_sid: String,
261    pub acked_at: chrono::DateTime<chrono::Utc>,
262    /// The acking user's login name (`DOMAIN\sam` or `.\user`), from the
263    /// agent connection's resolved peer identity — far more legible than
264    /// the raw SID in the operator's confirmation view. Additive +
265    /// optional so a pre-this-version agent's ack (SID only) still
266    /// decodes; the backend falls back to the PC's last-logon identity
267    /// when it's absent.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub account: Option<String>,
270}
271
272// ---------- ack status (GET /api/notifications/{id}/ack_status) ----
273
274/// One recipient's confirmation record for a notification.
275#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
276pub struct NotificationAckEntry {
277    pub pc_id: String,
278    pub user_sid: String,
279    pub acked_at: chrono::DateTime<chrono::Utc>,
280    /// Human-readable label for who confirmed — the acking user's login
281    /// name from the ack event, or (for pre-account acks) the PC's
282    /// last-logon display name / login as a fallback. `None` only when
283    /// neither is available, in which case the SPA shows the `user_sid`.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub account: Option<String>,
286}
287
288/// Response of `GET /api/notifications/{id}/ack_status` — every
289/// `(pc_id, user_sid, acked_at)` tuple recorded for the notification,
290/// powering the SPA's "who confirmed when" view.
291#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
292pub struct NotificationAckStatus {
293    pub id: String,
294    pub acks: Vec<NotificationAckEntry>,
295}
296
297// ---------- detail (GET /api/notifications/{id}) ------------------
298
299/// Response of `GET /api/notifications/{id}` — one sent notification's
300/// full content (so the SPA can show "what was sent", including the
301/// `body` the history table truncates away) paired with its
302/// per-recipient confirmation list. Powers the deep-linkable
303/// `/notifications/{id}` detail page, which an operator opens in a new
304/// tab from the history list (Ctrl/⌘ click), mirroring the Activity →
305/// result-detail deep link.
306///
307/// `acks` is the same set `ack_status` returns; bundling it here saves
308/// the detail page a second round-trip.
309///
310/// `audience` is the per-PC confirmation roster (④): the set of PCs the
311/// notification was addressed to, each flagged confirmed/pending, so an
312/// operator can see *who hasn't* acknowledged — not just who has. Empty
313/// when the audience couldn't be reconstructed (e.g. the fan-out subjects
314/// aged out of the stream).
315#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
316pub struct NotificationDetail {
317    pub notification: Notification,
318    pub acks: Vec<NotificationAckEntry>,
319    #[serde(default)]
320    pub audience: Vec<AudiencePc>,
321}
322
323/// One targeted PC's confirmation state, for the detail page's "who
324/// hasn't confirmed" roster (④). Resolved by expanding the notification's
325/// fan-out subjects (`all` / `group.X` / `pc.Y`) to the fleet's PCs and
326/// joining against the recorded acks.
327///
328/// Granularity is the PC, not the individual user: the backend has no
329/// full per-PC user roster, only each host's last-logon identity, so
330/// `last_logon_*` stands in as "the PC's representative user". `confirmed`
331/// is true when *any* user on that PC acked (the detailed who-and-when is
332/// in `acks`).
333#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
334pub struct AudiencePc {
335    pub pc_id: String,
336    /// The host's last sign-in account (`DOMAIN\sam`) / display name from
337    /// the `agents` row — `None` for a targeted PC with no agent record
338    /// (e.g. an explicit `pc.Y` target that never registered).
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub last_logon_user: Option<String>,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub last_logon_display_name: Option<String>,
343    pub confirmed: bool,
344    /// Earliest ack instant recorded for this PC; `None` while pending.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub acked_at: Option<chrono::DateTime<chrono::Utc>>,
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use chrono::TimeZone;
353
354    #[test]
355    fn priority_serialises_snake_case() {
356        for (variant, expected) in [
357            (NotificationPriority::Info, "\"info\""),
358            (NotificationPriority::Warn, "\"warn\""),
359            (NotificationPriority::Emergency, "\"emergency\""),
360        ] {
361            let s = serde_json::to_string(&variant).unwrap();
362            assert_eq!(s, expected, "encode {variant:?}");
363            let back: NotificationPriority = serde_json::from_str(expected).unwrap();
364            assert_eq!(back, variant, "round-trip {expected}");
365        }
366    }
367
368    #[test]
369    fn filter_defaults_to_unread() {
370        // The Client App's notification panel opens to "unread" so
371        // the default selector must match.
372        let p = NotificationsListParams::default();
373        assert_eq!(p.filter, NotificationsFilter::Unread);
374        // Default decode of an empty object.
375        let p: NotificationsListParams = serde_json::from_str("{}").unwrap();
376        assert_eq!(p.filter, NotificationsFilter::Unread);
377        assert_eq!(p.limit, 50);
378    }
379
380    #[test]
381    fn notification_new_spec_example_decodes() {
382        // SPEC §2.12.8's emergency push payload, verbatim. The
383        // flatten attribute means the wire is the Notification's
384        // own keys at the top level — no `notification: {…}` nest.
385        let wire = r#"{
386            "id":"notif-9f3a","priority":"emergency","require_ack":true,
387            "title":"緊急: ネットワーク機器メンテ","body":"22時から30分停止します",
388            "issued_at":"2026-05-20T12:00:00Z","issued_by":"infra-team"
389        }"#;
390        let p: NotificationNewParams = serde_json::from_str(wire).expect("decode");
391        assert_eq!(p.notification.id, "notif-9f3a");
392        assert_eq!(p.notification.priority, NotificationPriority::Emergency);
393        assert!(p.notification.require_ack);
394        assert_eq!(p.notification.title, "緊急: ネットワーク機器メンテ");
395        assert_eq!(p.notification.issued_by.as_deref(), Some("infra-team"));
396    }
397
398    #[test]
399    fn notification_expires_at_is_optional_and_skipped_when_none() {
400        // Additive field: a body without expires_at decodes (None) and
401        // a None value is omitted from the wire so pre-Phase-E
402        // consumers don't see a null key.
403        let wire = r#"{
404            "id":"n1","priority":"info","title":"t","body":"b",
405            "issued_at":"2026-05-20T12:00:00Z"
406        }"#;
407        let n: Notification = serde_json::from_str(wire).expect("decode without expires_at");
408        assert!(n.expires_at.is_none());
409        let v = serde_json::to_value(&n).unwrap();
410        assert!(
411            v.get("expires_at").is_none(),
412            "None expires_at omitted: {v:?}"
413        );
414    }
415
416    #[test]
417    fn notification_toast_defaults_false_and_round_trips() {
418        // A body on the retained stream from before the `toast` field
419        // decodes with toast = false (so old messages just don't toast).
420        let wire = r#"{
421            "id":"n1","priority":"info","title":"t","body":"b",
422            "issued_at":"2026-05-20T12:00:00Z"
423        }"#;
424        let n: Notification = serde_json::from_str(wire).expect("decode without toast");
425        assert!(!n.toast, "absent toast ⇒ false (in-app only, not a toast)");
426
427        // And an explicit toast:true round-trips.
428        let wire_true = r#"{
429            "id":"n2","priority":"warn","title":"t","body":"b","toast":true,
430            "issued_at":"2026-05-20T12:00:00Z"
431        }"#;
432        let n: Notification = serde_json::from_str(wire_true).expect("decode toast:true");
433        assert!(n.toast);
434        // Decoupled from priority: a warn can carry toast:true.
435        assert_eq!(n.priority, NotificationPriority::Warn);
436    }
437
438    #[test]
439    fn publish_request_toast_defaults_false_and_decodes() {
440        // Toast is driven ONLY by this flag (decoupled from priority by
441        // design): an omitted `toast` decodes to false even for an
442        // emergency — the caller must opt in with `toast: true`. There is
443        // deliberately no priority fallback.
444        let req: PublishNotificationRequest =
445            serde_json::from_str(r#"{"priority":"emergency","title":"t","body":"b","target":{}}"#)
446                .expect("decode without toast");
447        assert!(!req.toast, "omitted toast ⇒ false, even for emergency");
448
449        let req: PublishNotificationRequest = serde_json::from_str(
450            r#"{"priority":"warn","title":"t","body":"b","toast":true,"target":{}}"#,
451        )
452        .expect("decode with toast:true");
453        assert!(req.toast, "explicit toast:true on a non-emergency priority");
454    }
455
456    #[test]
457    fn publish_request_requires_target_audience() {
458        // The wire decodes a target with no audience set; the handler
459        // is what rejects it. Here we just pin Target::is_specified so
460        // the handler's guard has a stable contract to lean on.
461        let req: PublishNotificationRequest =
462            serde_json::from_str(r#"{"priority":"warn","title":"t","body":"b","target":{}}"#)
463                .expect("decode");
464        assert!(!req.target.is_specified(), "empty target is unspecified");
465        assert_eq!(req.id, None, "id omitted ⇒ backend mints one");
466        assert!(!req.require_ack, "require_ack defaults false");
467        assert!(!req.toast, "toast defaults false");
468    }
469
470    #[test]
471    fn notification_acked_round_trips() {
472        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
473        let a = NotificationAcked {
474            notification_id: "notif-9f3a".into(),
475            pc_id: "PC1234".into(),
476            // SIDs use hyphens, never dots — safe alongside the dotted
477            // subject, but the projector reads this body field anyway.
478            user_sid: "S-1-5-21-1001".into(),
479            acked_at: t,
480            account: Some("EXAMPLE\\taro".into()),
481        };
482        let json = serde_json::to_string(&a).unwrap();
483        let back: NotificationAcked = serde_json::from_str(&json).unwrap();
484        assert_eq!(back.notification_id, a.notification_id);
485        assert_eq!(back.pc_id, a.pc_id);
486        assert_eq!(back.user_sid, a.user_sid);
487        assert_eq!(back.acked_at, t);
488        assert_eq!(back.account.as_deref(), Some("EXAMPLE\\taro"));
489    }
490
491    #[test]
492    fn notification_acked_without_account_decodes() {
493        // A pre-account agent emits the ack body without `account`; it must
494        // still decode (None), and a None account is omitted on the wire so
495        // older readers never see a null key.
496        let wire = r#"{
497            "notification_id":"n1","pc_id":"PC1","user_sid":"S-1-5-21-1",
498            "acked_at":"2026-05-20T12:00:05Z"
499        }"#;
500        let a: NotificationAcked = serde_json::from_str(wire).expect("decode without account");
501        assert_eq!(a.account, None);
502        let v = serde_json::to_value(&a).unwrap();
503        assert!(v.get("account").is_none(), "None account omitted: {v:?}");
504    }
505
506    #[test]
507    fn ack_result_round_trips() {
508        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
509        let r = NotificationsAckResult { acked_at: t };
510        let json = serde_json::to_string(&r).unwrap();
511        let back: NotificationsAckResult = serde_json::from_str(&json).unwrap();
512        assert_eq!(back.acked_at, t);
513    }
514
515    #[test]
516    fn notifications_list_paginates_via_cursor() {
517        // First page: no cursor.
518        let p = NotificationsListParams {
519            filter: NotificationsFilter::All,
520            limit: 25,
521            cursor: None,
522        };
523        let v = serde_json::to_value(&p).unwrap();
524        assert!(v.get("cursor").is_none(), "wire: {v:?}");
525
526        // Continuation: cursor present.
527        let p = NotificationsListParams {
528            cursor: Some("opaque-token".into()),
529            ..NotificationsListParams::default()
530        };
531        let v = serde_json::to_value(&p).unwrap();
532        assert_eq!(v["cursor"], "opaque-token");
533    }
534}