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    /// `acked_at` from this user's perspective. Populated by
47    /// `notifications.list` for already-acked entries; never set on
48    /// `notifications.new` pushes (a fresh push by definition
49    /// hasn't been acked yet).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub acked_at: Option<chrono::DateTime<chrono::Utc>>,
52}
53
54/// Severity ladder. Drives the SPA color, toast/dialog choice, and
55/// whether the Client App grabs window focus on push arrival.
56/// `#[non_exhaustive]` so a future SPEC can add severities (e.g.
57/// `Critical` above Emergency) without a wire bump.
58#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
59#[serde(rename_all = "snake_case")]
60#[non_exhaustive]
61pub enum NotificationPriority {
62    /// Background-style toast. Routine maintenance reminders.
63    Info,
64    /// Yellow toast. Heads-up about upcoming changes.
65    Warn,
66    /// Red modal — grabs window focus, blocks until ack
67    /// (SPEC §2.12.8: "緊急: ネットワーク機器メンテ").
68    Emergency,
69    /// #492: serde-level forward-compat catch-all. `#[non_exhaustive]`
70    /// only affects Rust match exhaustiveness — serde still hard-fails
71    /// on an unknown variant STRING, so a newer peer's new variant
72    /// used to make older readers reject the whole containing message.
73    /// Unknown decodes any unrecognised value; UIs render it neutrally.
74    #[serde(other)]
75    Unknown,
76}
77
78// ---------- notifications.list ----------
79
80/// `notifications.list` params — paginated history of notifications
81/// this user has received (per-user, scoped via OS SID).
82#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
83pub struct NotificationsListParams {
84    /// Filter: which subset of the user's notifications to return.
85    /// Defaults to [`NotificationsFilter::Unread`] — the Client App
86    /// loads the unread bucket on first paint.
87    #[serde(default)]
88    pub filter: NotificationsFilter,
89    /// Max number of entries to return. Clamped agent-side to a
90    /// safe upper bound (currently 200) so a misbehaving client
91    /// can't ask for unbounded history. Defaults to 50.
92    #[serde(default = "default_limit")]
93    pub limit: u32,
94    /// Continuation token from a prior response's
95    /// [`NotificationsListResult::next_cursor`]. `None` on first
96    /// page.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub cursor: Option<String>,
99}
100
101impl Default for NotificationsListParams {
102    fn default() -> Self {
103        Self {
104            filter: NotificationsFilter::default(),
105            limit: default_limit(),
106            cursor: None,
107        }
108    }
109}
110
111fn default_limit() -> u32 {
112    50
113}
114
115/// History-list filter selector.
116#[derive(
117    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
118)]
119#[serde(rename_all = "snake_case")]
120pub enum NotificationsFilter {
121    /// Only entries this user has NOT acked. Default — the Client
122    /// App's notification panel opens to this view.
123    #[default]
124    Unread,
125    /// Everything in the user's history window, acked or not.
126    All,
127}
128
129/// `notifications.list` response.
130#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
131pub struct NotificationsListResult {
132    pub items: Vec<Notification>,
133    /// Opaque continuation token. `Some(cursor)` ⇒ caller should
134    /// re-request with `params.cursor = Some(cursor)` to fetch the
135    /// next page; `None` ⇒ caller has the tail.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub next_cursor: Option<String>,
138}
139
140// ---------- notifications.subscribe ----------
141
142#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
143pub struct NotificationsSubscribeParams {}
144
145#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
146pub struct NotificationsSubscribeResult {
147    pub subscription: String,
148}
149
150#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
151pub struct NotificationsUnsubscribeParams {
152    pub subscription: String,
153}
154
155// ---------- notifications.new (push) ----------
156
157/// Push payload for `notifications.new`. The full notification body
158/// inline — no second round-trip needed.
159#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
160pub struct NotificationNewParams {
161    #[serde(flatten)]
162    pub notification: Notification,
163}
164
165// ---------- notifications.ack ----------
166
167/// `notifications.ack` params — mark this notification read for the
168/// caller's user (SID derived from the OS at connect time, NOT
169/// from the payload). SPEC §2.12.4 forbids ack-ing other users'
170/// notifications even on a shared PC — the agent rejects with
171/// `Unauthorized` if the notification's audience doesn't include
172/// the caller.
173#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
174pub struct NotificationsAckParams {
175    pub id: String,
176}
177
178/// `notifications.ack` response — confirms the agent persisted the
179/// ack and published the `events.notifications.acked.>` event.
180#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
181pub struct NotificationsAckResult {
182    /// Wall-clock the agent wrote into `notifications_read` KV.
183    pub acked_at: chrono::DateTime<chrono::Utc>,
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use chrono::TimeZone;
190
191    #[test]
192    fn priority_serialises_snake_case() {
193        for (variant, expected) in [
194            (NotificationPriority::Info, "\"info\""),
195            (NotificationPriority::Warn, "\"warn\""),
196            (NotificationPriority::Emergency, "\"emergency\""),
197        ] {
198            let s = serde_json::to_string(&variant).unwrap();
199            assert_eq!(s, expected, "encode {variant:?}");
200            let back: NotificationPriority = serde_json::from_str(expected).unwrap();
201            assert_eq!(back, variant, "round-trip {expected}");
202        }
203    }
204
205    #[test]
206    fn filter_defaults_to_unread() {
207        // The Client App's notification panel opens to "unread" so
208        // the default selector must match.
209        let p = NotificationsListParams::default();
210        assert_eq!(p.filter, NotificationsFilter::Unread);
211        // Default decode of an empty object.
212        let p: NotificationsListParams = serde_json::from_str("{}").unwrap();
213        assert_eq!(p.filter, NotificationsFilter::Unread);
214        assert_eq!(p.limit, 50);
215    }
216
217    #[test]
218    fn notification_new_spec_example_decodes() {
219        // SPEC §2.12.8's emergency push payload, verbatim. The
220        // flatten attribute means the wire is the Notification's
221        // own keys at the top level — no `notification: {…}` nest.
222        let wire = r#"{
223            "id":"notif-9f3a","priority":"emergency","require_ack":true,
224            "title":"緊急: ネットワーク機器メンテ","body":"22時から30分停止します",
225            "issued_at":"2026-05-20T12:00:00Z","issued_by":"infra-team"
226        }"#;
227        let p: NotificationNewParams = serde_json::from_str(wire).expect("decode");
228        assert_eq!(p.notification.id, "notif-9f3a");
229        assert_eq!(p.notification.priority, NotificationPriority::Emergency);
230        assert!(p.notification.require_ack);
231        assert_eq!(p.notification.title, "緊急: ネットワーク機器メンテ");
232        assert_eq!(p.notification.issued_by.as_deref(), Some("infra-team"));
233    }
234
235    #[test]
236    fn ack_result_round_trips() {
237        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
238        let r = NotificationsAckResult { acked_at: t };
239        let json = serde_json::to_string(&r).unwrap();
240        let back: NotificationsAckResult = serde_json::from_str(&json).unwrap();
241        assert_eq!(back.acked_at, t);
242    }
243
244    #[test]
245    fn notifications_list_paginates_via_cursor() {
246        // First page: no cursor.
247        let p = NotificationsListParams {
248            filter: NotificationsFilter::All,
249            limit: 25,
250            cursor: None,
251        };
252        let v = serde_json::to_value(&p).unwrap();
253        assert!(v.get("cursor").is_none(), "wire: {v:?}");
254
255        // Continuation: cursor present.
256        let p = NotificationsListParams {
257            cursor: Some("opaque-token".into()),
258            ..NotificationsListParams::default()
259        };
260        let v = serde_json::to_value(&p).unwrap();
261        assert_eq!(v["cursor"], "opaque-token");
262    }
263}