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