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