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    /// When this notification was last edited (`PATCH /api/notifications/{id}`),
71    /// re-published with the same `id` + `issued_at` but new content. `None`
72    /// ⇒ never edited. Lets the SPA show an "edited" badge and lets a client
73    /// recognise a re-published copy as a content update of one it already
74    /// holds (vs a fresh arrival). Additive + optional so pre-edit bodies on
75    /// the retained stream still decode.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
78    /// When an edit reset confirmations: any ack (read mark) recorded *before*
79    /// this instant is stale and the user must re-confirm the new content.
80    /// The agent's `notifications.list` treats a read mark older than this as
81    /// unread; a connected client clears a locally-held ack older than this on
82    /// the live update. `None` ⇒ acks were never reset (the common case).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub acks_reset_at: Option<chrono::DateTime<chrono::Utc>>,
85}
86
87/// Severity ladder. Drives the SPA color, toast/dialog choice, and
88/// whether the Client App grabs window focus on push arrival.
89/// `#[non_exhaustive]` so a future SPEC can add severities (e.g.
90/// `Critical` above Emergency) without a wire bump.
91#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
92#[serde(rename_all = "snake_case")]
93#[non_exhaustive]
94pub enum NotificationPriority {
95    /// Background-style toast. Routine maintenance reminders.
96    Info,
97    /// Yellow toast. Heads-up about upcoming changes.
98    Warn,
99    /// Red modal — grabs window focus, blocks until ack
100    /// (SPEC §2.12.8: "緊急: ネットワーク機器メンテ").
101    Emergency,
102    /// #492: serde-level forward-compat catch-all. `#[non_exhaustive]`
103    /// only affects Rust match exhaustiveness — serde still hard-fails
104    /// on an unknown variant STRING, so a newer peer's new variant
105    /// used to make older readers reject the whole containing message.
106    /// Unknown decodes any unrecognised value; UIs render it neutrally.
107    #[serde(other)]
108    Unknown,
109}
110
111// ---------- notifications.list ----------
112
113/// `notifications.list` params — paginated history of notifications
114/// this user has received (per-user, scoped via OS SID).
115#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
116pub struct NotificationsListParams {
117    /// Filter: which subset of the user's notifications to return.
118    /// Defaults to [`NotificationsFilter::Unread`] — the Client App
119    /// loads the unread bucket on first paint.
120    #[serde(default)]
121    pub filter: NotificationsFilter,
122    /// Max number of entries to return. Clamped agent-side to a
123    /// safe upper bound (currently 200) so a misbehaving client
124    /// can't ask for unbounded history. Defaults to 50.
125    #[serde(default = "default_limit")]
126    pub limit: u32,
127    /// Continuation token from a prior response's
128    /// [`NotificationsListResult::next_cursor`]. `None` on first
129    /// page.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub cursor: Option<String>,
132}
133
134impl Default for NotificationsListParams {
135    fn default() -> Self {
136        Self {
137            filter: NotificationsFilter::default(),
138            limit: default_limit(),
139            cursor: None,
140        }
141    }
142}
143
144fn default_limit() -> u32 {
145    50
146}
147
148/// History-list filter selector.
149#[derive(
150    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
151)]
152#[serde(rename_all = "snake_case")]
153pub enum NotificationsFilter {
154    /// Only entries this user has NOT acked. Default — the Client
155    /// App's notification panel opens to this view.
156    #[default]
157    Unread,
158    /// Everything in the user's history window, acked or not.
159    All,
160}
161
162/// `notifications.list` response.
163#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
164pub struct NotificationsListResult {
165    pub items: Vec<Notification>,
166    /// Opaque continuation token. `Some(cursor)` ⇒ caller should
167    /// re-request with `params.cursor = Some(cursor)` to fetch the
168    /// next page; `None` ⇒ caller has the tail.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub next_cursor: Option<String>,
171}
172
173// ---------- notifications.subscribe ----------
174
175#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
176pub struct NotificationsSubscribeParams {}
177
178#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
179pub struct NotificationsSubscribeResult {
180    pub subscription: String,
181}
182
183#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
184pub struct NotificationsUnsubscribeParams {
185    pub subscription: String,
186}
187
188// ---------- notifications.new (push) ----------
189
190/// Push payload for `notifications.new`. The full notification body
191/// inline — no second round-trip needed.
192#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
193pub struct NotificationNewParams {
194    #[serde(flatten)]
195    pub notification: Notification,
196}
197
198// ---------- notifications.ack ----------
199
200/// `notifications.ack` params — mark this notification read for the
201/// caller's user (SID derived from the OS at connect time, NOT
202/// from the payload). SPEC §2.12.4 forbids ack-ing other users'
203/// notifications even on a shared PC — the agent rejects with
204/// `Unauthorized` if the notification's audience doesn't include
205/// the caller.
206#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
207pub struct NotificationsAckParams {
208    pub id: String,
209}
210
211/// `notifications.ack` response — confirms the agent persisted the
212/// ack and published the `events.notifications.acked.>` event.
213#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
214pub struct NotificationsAckResult {
215    /// Wall-clock the agent wrote into `notifications_read` KV.
216    pub acked_at: chrono::DateTime<chrono::Utc>,
217}
218
219// ---------- backend HTTP compose (POST /api/notifications) ----------
220
221/// Operator-facing request body for `POST /api/notifications` (and the
222/// equivalent `notifications/*.yaml` manifest, SPEC §2.4.1). The
223/// backend mints the [`Notification::id`] (when `id` is omitted) and
224/// [`Notification::issued_at`], resolves [`target`](Self::target) into
225/// the `notifications.{all|group.X|pc.Y}` fan-out subjects, and
226/// publishes one [`Notification`] per resolved subject into the
227/// `NOTIFICATIONS` stream.
228#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
229pub struct PublishNotificationRequest {
230    /// Operator-supplied id — the manifest's `id:` doubles as the
231    /// notification id (SPEC §2.4.1). Omit it for ad-hoc SPA composer
232    /// sends and the backend mints a UUID instead.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub id: Option<String>,
235    pub priority: NotificationPriority,
236    #[serde(default)]
237    pub require_ack: bool,
238    pub title: String,
239    pub body: String,
240    /// Surface an OS toast (see [`Notification::toast`]). Decoupled from
241    /// `priority`; defaults to `false` (in-app only).
242    #[serde(default)]
243    pub toast: bool,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub issued_by: Option<String>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
248    /// Fan-out audience — same shape as a job manifest's `target:`
249    /// (SPEC §2.4.1). At least one of `all` / `groups` / `pcs` must be
250    /// set or the backend rejects the request.
251    pub target: crate::manifest::Target,
252}
253
254/// Response of `POST /api/notifications` — the minted/echoed id plus
255/// the subjects the notification fanned out to, so the operator UI can
256/// confirm the resolved audience.
257#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
258pub struct PublishNotificationResponse {
259    pub id: String,
260    pub subjects: Vec<String>,
261}
262
263// ---------- backend HTTP edit (PATCH /api/notifications/{id}) --------
264
265/// Operator-facing request body for `PATCH /api/notifications/{id}` — edit
266/// an already-sent notification's content (fix a typo, shorten/extend the
267/// expiry, change priority / require_ack / toast) without re-sending it.
268///
269/// The **audience is immutable** here — there is no `target` field. Changing
270/// who it goes to is "recall → re-send" (the backend keeps the original
271/// fan-out subjects). `id`, `issued_at`, and `issued_by` are preserved; only
272/// the fields below change. The backend deletes the old stream copies and
273/// re-publishes the merged notification under the same id + `issued_at` (so
274/// "sent at" is unchanged), stamping [`Notification::edited_at`].
275///
276/// Unlike [`PublishNotificationRequest`] this is a *full* edit set (the SPA
277/// pre-fills every field from the current notification and submits them all),
278/// so there is no per-field optionality to disambiguate; `expires_at: None`
279/// means "never expires", a past instant expires it immediately.
280#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
281pub struct EditNotificationRequest {
282    pub priority: NotificationPriority,
283    #[serde(default)]
284    pub require_ack: bool,
285    pub title: String,
286    pub body: String,
287    #[serde(default)]
288    pub toast: bool,
289    /// `None` ⇒ never expires; a past instant expires it immediately (unlike
290    /// `publish`, which rejects a past expiry as a likely typo — here it is a
291    /// deliberate "retire it but keep history" choice, distinct from recall).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
294    /// Reset confirmations: when `true` the backend clears every recorded ack
295    /// for this notification and stamps [`Notification::acks_reset_at`], so a
296    /// materially-changed body forces everyone to re-confirm. `false` (the
297    /// default, e.g. a typo fix) leaves existing confirmations intact.
298    #[serde(default)]
299    pub reset_acks: bool,
300}
301
302// ---------- ack event (Agent → NATS → backend projector) ----------
303
304/// Body of the
305/// `events.notifications.acked.{pc_id}.{user_sid}.{notif_id}` event the
306/// agent publishes when a user acks a notification. The backend's
307/// notification-acks projector reads these fields from the JSON body
308/// (not by parsing the subject) so an id / SID containing a `.` can't
309/// desync the projected row from its subject tokens.
310#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
311pub struct NotificationAcked {
312    pub notification_id: String,
313    pub pc_id: String,
314    pub user_sid: String,
315    pub acked_at: chrono::DateTime<chrono::Utc>,
316    /// The acking user's login name (`DOMAIN\sam` or `.\user`), from the
317    /// agent connection's resolved peer identity — far more legible than
318    /// the raw SID in the operator's confirmation view. Additive +
319    /// optional so a pre-this-version agent's ack (SID only) still
320    /// decodes; the backend falls back to the PC's last-logon identity
321    /// when it's absent.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub account: Option<String>,
324}
325
326// ---------- ack status (GET /api/notifications/{id}/ack_status) ----
327
328/// One recipient's confirmation record for a notification.
329#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
330pub struct NotificationAckEntry {
331    pub pc_id: String,
332    pub user_sid: String,
333    pub acked_at: chrono::DateTime<chrono::Utc>,
334    /// Human-readable label for who confirmed — the acking user's login
335    /// name from the ack event, or (for pre-account acks) the PC's
336    /// last-logon display name / login as a fallback. `None` only when
337    /// neither is available, in which case the SPA shows the `user_sid`.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub account: Option<String>,
340}
341
342/// Response of `GET /api/notifications/{id}/ack_status` — every
343/// `(pc_id, user_sid, acked_at)` tuple recorded for the notification,
344/// powering the SPA's "who confirmed when" view.
345#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
346pub struct NotificationAckStatus {
347    pub id: String,
348    pub acks: Vec<NotificationAckEntry>,
349}
350
351// ---------- detail (GET /api/notifications/{id}) ------------------
352
353/// Response of `GET /api/notifications/{id}` — one sent notification's
354/// full content (so the SPA can show "what was sent", including the
355/// `body` the history table truncates away) paired with its
356/// per-recipient confirmation list. Powers the deep-linkable
357/// `/notifications/{id}` detail page, which an operator opens in a new
358/// tab from the history list (Ctrl/⌘ click), mirroring the Activity →
359/// result-detail deep link.
360///
361/// `acks` is the same set `ack_status` returns; bundling it here saves
362/// the detail page a second round-trip.
363///
364/// `audience` is the per-PC confirmation roster (④): the set of PCs the
365/// notification was addressed to, each flagged confirmed/pending, so an
366/// operator can see *who hasn't* acknowledged — not just who has. Empty
367/// when the audience couldn't be reconstructed (e.g. the fan-out subjects
368/// aged out of the stream).
369#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
370pub struct NotificationDetail {
371    pub notification: Notification,
372    pub acks: Vec<NotificationAckEntry>,
373    #[serde(default)]
374    pub audience: Vec<AudiencePc>,
375    /// The original send target (where it was addressed: all / groups /
376    /// pcs), reconstructed from the fan-out subjects — so the SPA can show
377    /// "送信先" (vs `audience`, which is the *resolved* per-PC roster).
378    /// `None` when the subjects couldn't be reconstructed.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub target: Option<NotificationTarget>,
381}
382
383/// The audience a notification was *addressed* to (the `target:` of the
384/// publish), reconstructed from its fan-out subjects
385/// (`notifications.{all|group.X|pc.Y}`). Distinct from the resolved
386/// per-PC [`AudiencePc`] roster: this is the operator's intent ("sent to
387/// the it-admins group + PC minipc"), not the expanded PC list.
388#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
389pub struct NotificationTarget {
390    #[serde(default)]
391    pub all: bool,
392    #[serde(default)]
393    pub groups: Vec<String>,
394    #[serde(default)]
395    pub pcs: Vec<String>,
396}
397
398/// One targeted PC's confirmation state, for the detail page's "who
399/// hasn't confirmed" roster (④). Resolved by expanding the notification's
400/// fan-out subjects (`all` / `group.X` / `pc.Y`) to the fleet's PCs and
401/// joining against the recorded acks.
402///
403/// Granularity is the PC, not the individual user: the backend has no
404/// full per-PC user roster, only each host's last-logon identity, so
405/// `last_logon_*` stands in as "the PC's representative user". `confirmed`
406/// is true when *any* user on that PC acked (the detailed who-and-when is
407/// in `acks`).
408#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
409pub struct AudiencePc {
410    pub pc_id: String,
411    /// The host's last sign-in account (`DOMAIN\sam`) / display name from
412    /// the `agents` row — `None` for a targeted PC with no agent record
413    /// (e.g. an explicit `pc.Y` target that never registered).
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub last_logon_user: Option<String>,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub last_logon_display_name: Option<String>,
418    pub confirmed: bool,
419    /// Earliest ack instant recorded for this PC; `None` while pending.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub acked_at: Option<chrono::DateTime<chrono::Utc>>,
422}
423
424// ---------- amend (post-send operations) -------------------------
425
426/// A post-send amendment to an already-fanned-out notification, broadcast
427/// fleet-wide on the ephemeral [`crate::subject::NOTIFICATIONS_AMEND_SUBJECT`]
428/// channel so every connected client showing the notification can react in
429/// real time. Carries only the notification `id` plus the operation — a
430/// client applies it only if it currently holds that id (an id it never
431/// received is a no-op), so the single broadcast needs no audience routing.
432///
433/// The durable half of an operation lives in the backend (recall deletes the
434/// stream copies; a future edit re-publishes them); this is the "update the
435/// screens that are showing it right now" half. Built to grow: today only
436/// `Recall`, but `op` is a tagged enum so an `Update`/`SetExpiry` variant can
437/// be added without breaking the wire format.
438#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
439pub struct NotificationAmend {
440    pub id: String,
441    pub op: NotificationAmendOp,
442}
443
444/// The operation an [`NotificationAmend`] applies. Tagged on `kind` so future
445/// data-carrying variants (e.g. `Update { notification }`) stay wire-compatible.
446#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
447#[serde(tag = "kind", rename_all = "snake_case")]
448pub enum NotificationAmendOp {
449    /// The notification was recalled (deleted): remove it from the panel,
450    /// unread badge, and any open require-ack modal.
451    Recall,
452}
453
454/// Params of the `notifications.amended` push (Agent → Client) — the
455/// flattened [`NotificationAmend`] (`{ "id", "kind": "recall" }`).
456#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
457pub struct NotificationAmendedParams {
458    #[serde(flatten)]
459    pub amend: NotificationAmend,
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use chrono::TimeZone;
466
467    #[test]
468    fn priority_serialises_snake_case() {
469        for (variant, expected) in [
470            (NotificationPriority::Info, "\"info\""),
471            (NotificationPriority::Warn, "\"warn\""),
472            (NotificationPriority::Emergency, "\"emergency\""),
473        ] {
474            let s = serde_json::to_string(&variant).unwrap();
475            assert_eq!(s, expected, "encode {variant:?}");
476            let back: NotificationPriority = serde_json::from_str(expected).unwrap();
477            assert_eq!(back, variant, "round-trip {expected}");
478        }
479    }
480
481    #[test]
482    fn filter_defaults_to_unread() {
483        // The Client App's notification panel opens to "unread" so
484        // the default selector must match.
485        let p = NotificationsListParams::default();
486        assert_eq!(p.filter, NotificationsFilter::Unread);
487        // Default decode of an empty object.
488        let p: NotificationsListParams = serde_json::from_str("{}").unwrap();
489        assert_eq!(p.filter, NotificationsFilter::Unread);
490        assert_eq!(p.limit, 50);
491    }
492
493    #[test]
494    fn notification_new_spec_example_decodes() {
495        // SPEC §2.12.8's emergency push payload, verbatim. The
496        // flatten attribute means the wire is the Notification's
497        // own keys at the top level — no `notification: {…}` nest.
498        let wire = r#"{
499            "id":"notif-9f3a","priority":"emergency","require_ack":true,
500            "title":"緊急: ネットワーク機器メンテ","body":"22時から30分停止します",
501            "issued_at":"2026-05-20T12:00:00Z","issued_by":"infra-team"
502        }"#;
503        let p: NotificationNewParams = serde_json::from_str(wire).expect("decode");
504        assert_eq!(p.notification.id, "notif-9f3a");
505        assert_eq!(p.notification.priority, NotificationPriority::Emergency);
506        assert!(p.notification.require_ack);
507        assert_eq!(p.notification.title, "緊急: ネットワーク機器メンテ");
508        assert_eq!(p.notification.issued_by.as_deref(), Some("infra-team"));
509    }
510
511    #[test]
512    fn notification_expires_at_is_optional_and_skipped_when_none() {
513        // Additive field: a body without expires_at decodes (None) and
514        // a None value is omitted from the wire so pre-Phase-E
515        // consumers don't see a null key.
516        let wire = r#"{
517            "id":"n1","priority":"info","title":"t","body":"b",
518            "issued_at":"2026-05-20T12:00:00Z"
519        }"#;
520        let n: Notification = serde_json::from_str(wire).expect("decode without expires_at");
521        assert!(n.expires_at.is_none());
522        let v = serde_json::to_value(&n).unwrap();
523        assert!(
524            v.get("expires_at").is_none(),
525            "None expires_at omitted: {v:?}"
526        );
527    }
528
529    #[test]
530    fn notification_toast_defaults_false_and_round_trips() {
531        // A body on the retained stream from before the `toast` field
532        // decodes with toast = false (so old messages just don't toast).
533        let wire = r#"{
534            "id":"n1","priority":"info","title":"t","body":"b",
535            "issued_at":"2026-05-20T12:00:00Z"
536        }"#;
537        let n: Notification = serde_json::from_str(wire).expect("decode without toast");
538        assert!(!n.toast, "absent toast ⇒ false (in-app only, not a toast)");
539
540        // And an explicit toast:true round-trips.
541        let wire_true = r#"{
542            "id":"n2","priority":"warn","title":"t","body":"b","toast":true,
543            "issued_at":"2026-05-20T12:00:00Z"
544        }"#;
545        let n: Notification = serde_json::from_str(wire_true).expect("decode toast:true");
546        assert!(n.toast);
547        // Decoupled from priority: a warn can carry toast:true.
548        assert_eq!(n.priority, NotificationPriority::Warn);
549    }
550
551    #[test]
552    fn publish_request_toast_defaults_false_and_decodes() {
553        // Toast is driven ONLY by this flag (decoupled from priority by
554        // design): an omitted `toast` decodes to false even for an
555        // emergency — the caller must opt in with `toast: true`. There is
556        // deliberately no priority fallback.
557        let req: PublishNotificationRequest =
558            serde_json::from_str(r#"{"priority":"emergency","title":"t","body":"b","target":{}}"#)
559                .expect("decode without toast");
560        assert!(!req.toast, "omitted toast ⇒ false, even for emergency");
561
562        let req: PublishNotificationRequest = serde_json::from_str(
563            r#"{"priority":"warn","title":"t","body":"b","toast":true,"target":{}}"#,
564        )
565        .expect("decode with toast:true");
566        assert!(req.toast, "explicit toast:true on a non-emergency priority");
567    }
568
569    #[test]
570    fn publish_request_requires_target_audience() {
571        // The wire decodes a target with no audience set; the handler
572        // is what rejects it. Here we just pin Target::is_specified so
573        // the handler's guard has a stable contract to lean on.
574        let req: PublishNotificationRequest =
575            serde_json::from_str(r#"{"priority":"warn","title":"t","body":"b","target":{}}"#)
576                .expect("decode");
577        assert!(!req.target.is_specified(), "empty target is unspecified");
578        assert_eq!(req.id, None, "id omitted ⇒ backend mints one");
579        assert!(!req.require_ack, "require_ack defaults false");
580        assert!(!req.toast, "toast defaults false");
581    }
582
583    #[test]
584    fn edit_request_decodes_with_defaults() {
585        // Minimal body: the SPA always submits all editable fields, but
586        // require_ack / toast / reset_acks default false and expires_at omitted
587        // ⇒ never expires.
588        let req: EditNotificationRequest =
589            serde_json::from_str(r#"{"priority":"warn","title":"t","body":"b"}"#).expect("decode");
590        assert!(!req.require_ack);
591        assert!(!req.toast);
592        assert!(
593            !req.reset_acks,
594            "reset_acks defaults false (keep confirmations)"
595        );
596        assert_eq!(req.expires_at, None, "omitted expiry ⇒ never expires");
597
598        // reset_acks + an explicit expiry decode as set.
599        let req: EditNotificationRequest = serde_json::from_str(
600            r#"{"priority":"info","title":"t","body":"b","reset_acks":true,"expires_at":"2099-01-01T00:00:00Z"}"#,
601        )
602        .expect("decode");
603        assert!(req.reset_acks);
604        assert!(req.expires_at.is_some());
605    }
606
607    #[test]
608    fn notification_edit_fields_default_none_and_round_trip() {
609        // A pre-edit body (no edited_at / acks_reset_at) still decodes, and
610        // both fields are omitted on the wire when None.
611        let n: Notification = serde_json::from_str(
612            r#"{"id":"n1","priority":"info","title":"t","body":"b","issued_at":"2026-06-01T00:00:00Z"}"#,
613        )
614        .expect("decode pre-edit body");
615        assert_eq!(n.edited_at, None);
616        assert_eq!(n.acks_reset_at, None);
617        let v = serde_json::to_value(&n).unwrap();
618        assert!(
619            v.get("edited_at").is_none(),
620            "None edited_at omitted: {v:?}"
621        );
622        assert!(
623            v.get("acks_reset_at").is_none(),
624            "None acks_reset_at omitted: {v:?}"
625        );
626    }
627
628    #[test]
629    fn notification_acked_round_trips() {
630        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
631        let a = NotificationAcked {
632            notification_id: "notif-9f3a".into(),
633            pc_id: "PC1234".into(),
634            // SIDs use hyphens, never dots — safe alongside the dotted
635            // subject, but the projector reads this body field anyway.
636            user_sid: "S-1-5-21-1001".into(),
637            acked_at: t,
638            account: Some("EXAMPLE\\taro".into()),
639        };
640        let json = serde_json::to_string(&a).unwrap();
641        let back: NotificationAcked = serde_json::from_str(&json).unwrap();
642        assert_eq!(back.notification_id, a.notification_id);
643        assert_eq!(back.pc_id, a.pc_id);
644        assert_eq!(back.user_sid, a.user_sid);
645        assert_eq!(back.acked_at, t);
646        assert_eq!(back.account.as_deref(), Some("EXAMPLE\\taro"));
647    }
648
649    #[test]
650    fn notification_amend_recall_round_trips() {
651        // Wire shape the backend broadcasts and the client decodes:
652        // the op is tagged on `kind` so adding a data-carrying variant
653        // later (Update { .. }) stays compatible.
654        let a = NotificationAmend {
655            id: "notif-9f3a".into(),
656            op: NotificationAmendOp::Recall,
657        };
658        let v = serde_json::to_value(&a).unwrap();
659        assert_eq!(v["id"], "notif-9f3a");
660        assert_eq!(v["op"]["kind"], "recall");
661        let back: NotificationAmend = serde_json::from_value(v).unwrap();
662        assert_eq!(back, a);
663
664        // The push params flatten the amend (no nested "amend" key).
665        let p = NotificationAmendedParams { amend: a.clone() };
666        let pv = serde_json::to_value(&p).unwrap();
667        assert_eq!(pv["id"], "notif-9f3a");
668        assert_eq!(pv["op"]["kind"], "recall");
669        assert!(pv.get("amend").is_none(), "amend is flattened: {pv:?}");
670    }
671
672    #[test]
673    fn notification_acked_without_account_decodes() {
674        // A pre-account agent emits the ack body without `account`; it must
675        // still decode (None), and a None account is omitted on the wire so
676        // older readers never see a null key.
677        let wire = r#"{
678            "notification_id":"n1","pc_id":"PC1","user_sid":"S-1-5-21-1",
679            "acked_at":"2026-05-20T12:00:05Z"
680        }"#;
681        let a: NotificationAcked = serde_json::from_str(wire).expect("decode without account");
682        assert_eq!(a.account, None);
683        let v = serde_json::to_value(&a).unwrap();
684        assert!(v.get("account").is_none(), "None account omitted: {v:?}");
685    }
686
687    #[test]
688    fn ack_result_round_trips() {
689        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
690        let r = NotificationsAckResult { acked_at: t };
691        let json = serde_json::to_string(&r).unwrap();
692        let back: NotificationsAckResult = serde_json::from_str(&json).unwrap();
693        assert_eq!(back.acked_at, t);
694    }
695
696    #[test]
697    fn notifications_list_paginates_via_cursor() {
698        // First page: no cursor.
699        let p = NotificationsListParams {
700            filter: NotificationsFilter::All,
701            limit: 25,
702            cursor: None,
703        };
704        let v = serde_json::to_value(&p).unwrap();
705        assert!(v.get("cursor").is_none(), "wire: {v:?}");
706
707        // Continuation: cursor present.
708        let p = NotificationsListParams {
709            cursor: Some("opaque-token".into()),
710            ..NotificationsListParams::default()
711        };
712        let v = serde_json::to_value(&p).unwrap();
713        assert_eq!(v["cursor"], "opaque-token");
714    }
715}