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// ---------- notifications.unack ----------
220
221/// `notifications.unack` params — retract this user's prior ack (the
222/// read↔unread toggle): the user clicked "確認" by mistake and wants the
223/// notification back as unread. Same SID-from-the-OS / audience guard as
224/// [`NotificationsAckParams`]; a user may only unack their own
225/// confirmation.
226#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
227pub struct NotificationsUnackParams {
228    pub id: String,
229}
230
231/// `notifications.unack` response — confirms the agent deleted the
232/// `notifications_read` KV entry and published
233/// `events.notifications.unacked.>`. Carries the instant the revoke was
234/// recorded (the agent's wall clock), so the operator's audit view can
235/// show "confirmed at X, retracted at Y".
236#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
237pub struct NotificationsUnackResult {
238    pub unacked_at: chrono::DateTime<chrono::Utc>,
239}
240
241// ---------- backend HTTP compose (POST /api/notifications) ----------
242
243/// Operator-facing request body for `POST /api/notifications` (and the
244/// equivalent `notifications/*.yaml` manifest, SPEC §2.4.1). The
245/// backend mints the [`Notification::id`] (when `id` is omitted) and
246/// [`Notification::issued_at`], resolves [`target`](Self::target) into
247/// the `notifications.{all|group.X|pc.Y}` fan-out subjects, and
248/// publishes one [`Notification`] per resolved subject into the
249/// `NOTIFICATIONS` stream.
250#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
251pub struct PublishNotificationRequest {
252    /// Operator-supplied id — the manifest's `id:` doubles as the
253    /// notification id (SPEC §2.4.1). Omit it for ad-hoc SPA composer
254    /// sends and the backend mints a UUID instead.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub id: Option<String>,
257    pub priority: NotificationPriority,
258    #[serde(default)]
259    pub require_ack: bool,
260    pub title: String,
261    pub body: String,
262    /// Surface an OS toast (see [`Notification::toast`]). Decoupled from
263    /// `priority`; defaults to `false` (in-app only).
264    #[serde(default)]
265    pub toast: bool,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub issued_by: Option<String>,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
270    /// Fan-out audience — same shape as a job manifest's `target:`
271    /// (SPEC §2.4.1). At least one of `all` / `groups` / `pcs` must be
272    /// set or the backend rejects the request.
273    pub target: crate::manifest::Target,
274}
275
276/// Response of `POST /api/notifications` — the minted/echoed id plus
277/// the subjects the notification fanned out to, so the operator UI can
278/// confirm the resolved audience.
279#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
280pub struct PublishNotificationResponse {
281    pub id: String,
282    pub subjects: Vec<String>,
283}
284
285// ---------- backend HTTP edit (PATCH /api/notifications/{id}) --------
286
287/// Operator-facing request body for `PATCH /api/notifications/{id}` — edit
288/// an already-sent notification's content (fix a typo, shorten/extend the
289/// expiry, change priority / require_ack / toast) without re-sending it.
290///
291/// The **audience is immutable** here — there is no `target` field. Changing
292/// who it goes to is "recall → re-send" (the backend keeps the original
293/// fan-out subjects). `id`, `issued_at`, and `issued_by` are preserved; only
294/// the fields below change. The backend deletes the old stream copies and
295/// re-publishes the merged notification under the same id + `issued_at` (so
296/// "sent at" is unchanged), stamping [`Notification::edited_at`].
297///
298/// Unlike [`PublishNotificationRequest`] this is a *full* edit set (the SPA
299/// pre-fills every field from the current notification and submits them all),
300/// so there is no per-field optionality to disambiguate; `expires_at: None`
301/// means "never expires", a past instant expires it immediately.
302#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
303pub struct EditNotificationRequest {
304    pub priority: NotificationPriority,
305    #[serde(default)]
306    pub require_ack: bool,
307    pub title: String,
308    pub body: String,
309    #[serde(default)]
310    pub toast: bool,
311    /// `None` ⇒ never expires; a past instant expires it immediately (unlike
312    /// `publish`, which rejects a past expiry as a likely typo — here it is a
313    /// deliberate "retire it but keep history" choice, distinct from recall).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
316    /// Reset confirmations: when `true` the backend clears every recorded ack
317    /// for this notification and stamps [`Notification::acks_reset_at`], so a
318    /// materially-changed body forces everyone to re-confirm. `false` (the
319    /// default, e.g. a typo fix) leaves existing confirmations intact.
320    #[serde(default)]
321    pub reset_acks: bool,
322}
323
324// ---------- ack event (Agent → NATS → backend projector) ----------
325
326/// Body of the
327/// `events.notifications.acked.{pc_id}.{user_sid}.{notif_id}` event the
328/// agent publishes when a user acks a notification. The backend's
329/// notification-acks projector reads these fields from the JSON body
330/// (not by parsing the subject) so an id / SID containing a `.` can't
331/// desync the projected row from its subject tokens.
332#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
333pub struct NotificationAcked {
334    pub notification_id: String,
335    pub pc_id: String,
336    pub user_sid: String,
337    pub acked_at: chrono::DateTime<chrono::Utc>,
338    /// The acking user's login name (`DOMAIN\sam` or `.\user`), from the
339    /// agent connection's resolved peer identity — far more legible than
340    /// the raw SID in the operator's confirmation view. Additive +
341    /// optional so a pre-this-version agent's ack (SID only) still
342    /// decodes; the backend falls back to the PC's last-logon identity
343    /// when it's absent.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub account: Option<String>,
346}
347
348// ---------- unack event (Agent → NATS → backend projector) --------
349
350/// Body of the
351/// `events.notifications.unacked.{pc_id}.{user_sid}.{notif_id}` event the
352/// agent publishes when a user *retracts* a confirmation. Mirror of
353/// [`NotificationAcked`]; the projector reads these body fields (not the
354/// subject) and, in the same stream-ordered consumer, appends a
355/// `kind = 'unacked'` row to `notification_ack_events` and stamps
356/// `notification_acks.unacked_at` so the SPA roster flips the recipient
357/// from confirmed back to "未確認" while the audit log keeps the original
358/// ack.
359#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
360pub struct NotificationUnacked {
361    pub notification_id: String,
362    pub pc_id: String,
363    pub user_sid: String,
364    pub unacked_at: chrono::DateTime<chrono::Utc>,
365    /// The retracting user's login name — same provenance and fallback
366    /// semantics as [`NotificationAcked::account`]. Carried for audit
367    /// symmetry (the projector's DELETE/UPDATE keys on the SID, not the
368    /// account).
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub account: Option<String>,
371}
372
373// ---------- ack status (GET /api/notifications/{id}/ack_status) ----
374
375/// One recipient's confirmation record for a notification.
376#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
377pub struct NotificationAckEntry {
378    pub pc_id: String,
379    pub user_sid: String,
380    pub acked_at: chrono::DateTime<chrono::Utc>,
381    /// Human-readable label for who confirmed — the acking user's login
382    /// name from the ack event, or (for pre-account acks) the PC's
383    /// last-logon display name / login as a fallback. `None` only when
384    /// neither is available, in which case the SPA shows the `user_sid`.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub account: Option<String>,
387    /// When this user *retracted* their confirmation (the read↔unread
388    /// toggle). `Some` ⇒ they confirmed at `acked_at` then later took it
389    /// back at this instant — the SPA renders this recipient as "取消済み"
390    /// (confirmed→revoked), distinct from both "確認済み" and a
391    /// never-confirmed "未確認". `None` ⇒ the confirmation still stands.
392    /// Additive + optional so a pre-unack backend's `ack_status` still
393    /// decodes.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub unacked_at: Option<chrono::DateTime<chrono::Utc>>,
396}
397
398/// Response of `GET /api/notifications/{id}/ack_status` — every
399/// `(pc_id, user_sid, acked_at)` tuple recorded for the notification,
400/// powering the SPA's "who confirmed when" view.
401#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
402pub struct NotificationAckStatus {
403    pub id: String,
404    pub acks: Vec<NotificationAckEntry>,
405}
406
407// ---------- detail (GET /api/notifications/{id}) ------------------
408
409/// Response of `GET /api/notifications/{id}` — one sent notification's
410/// full content (so the SPA can show "what was sent", including the
411/// `body` the history table truncates away) paired with its
412/// per-recipient confirmation list. Powers the deep-linkable
413/// `/notifications/{id}` detail page, which an operator opens in a new
414/// tab from the history list (Ctrl/⌘ click), mirroring the Activity →
415/// result-detail deep link.
416///
417/// `acks` is the same set `ack_status` returns; bundling it here saves
418/// the detail page a second round-trip.
419///
420/// `audience` is the per-PC confirmation roster (④): the set of PCs the
421/// notification was addressed to, each flagged confirmed/pending, so an
422/// operator can see *who hasn't* acknowledged — not just who has. Empty
423/// when the audience couldn't be reconstructed (e.g. the fan-out subjects
424/// aged out of the stream).
425#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
426pub struct NotificationDetail {
427    pub notification: Notification,
428    pub acks: Vec<NotificationAckEntry>,
429    #[serde(default)]
430    pub audience: Vec<AudiencePc>,
431    /// The original send target (where it was addressed: all / groups /
432    /// pcs), reconstructed from the fan-out subjects — so the SPA can show
433    /// "送信先" (vs `audience`, which is the *resolved* per-PC roster).
434    /// `None` when the subjects couldn't be reconstructed.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub target: Option<NotificationTarget>,
437}
438
439/// The audience a notification was *addressed* to (the `target:` of the
440/// publish), reconstructed from its fan-out subjects
441/// (`notifications.{all|group.X|pc.Y}`). Distinct from the resolved
442/// per-PC [`AudiencePc`] roster: this is the operator's intent ("sent to
443/// the it-admins group + PC minipc"), not the expanded PC list.
444#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
445pub struct NotificationTarget {
446    #[serde(default)]
447    pub all: bool,
448    #[serde(default)]
449    pub groups: Vec<String>,
450    #[serde(default)]
451    pub pcs: Vec<String>,
452}
453
454/// One targeted PC's confirmation state, for the detail page's "who
455/// hasn't confirmed" roster (④). Resolved by expanding the notification's
456/// fan-out subjects (`all` / `group.X` / `pc.Y`) to the fleet's PCs and
457/// joining against the recorded acks.
458///
459/// Granularity is the PC, not the individual user: the backend has no
460/// full per-PC user roster, only each host's last-logon identity, so
461/// `last_logon_*` stands in as "the PC's representative user". `confirmed`
462/// is true when *any* user on that PC acked (the detailed who-and-when is
463/// in `acks`).
464#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
465pub struct AudiencePc {
466    pub pc_id: String,
467    /// The host's last sign-in account (`DOMAIN\sam`) / display name from
468    /// the `agents` row — `None` for a targeted PC with no agent record
469    /// (e.g. an explicit `pc.Y` target that never registered).
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub last_logon_user: Option<String>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub last_logon_display_name: Option<String>,
474    /// `true` when this PC currently has a *standing* confirmation — at
475    /// least one user acked and has not since retracted it. A PC whose
476    /// only ack was later revoked is `confirmed = false` with
477    /// `unacked_at = Some` (the "取消済み" state), so the operator's
478    /// "who hasn't confirmed" roster counts it as not-confirmed while
479    /// still surfacing that it once was.
480    pub confirmed: bool,
481    /// Earliest ack instant recorded for this PC; `None` while pending.
482    /// Retained even after a revoke so the audit view can show
483    /// "confirmed at X → retracted at Y".
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub acked_at: Option<chrono::DateTime<chrono::Utc>>,
486    /// When this PC's confirmation was retracted (the latest revoke
487    /// across its users). `Some` with `confirmed = false` ⇒ "取消済み"
488    /// (was confirmed, then taken back); `None` ⇒ never retracted (either
489    /// still confirmed or never confirmed — disambiguated by `confirmed`
490    /// / `acked_at`). Additive + optional for pre-unack decode.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub unacked_at: Option<chrono::DateTime<chrono::Utc>>,
493}
494
495// ---------- amend (post-send operations) -------------------------
496
497/// A post-send amendment to an already-fanned-out notification, broadcast
498/// fleet-wide on the ephemeral [`crate::subject::NOTIFICATIONS_AMEND_SUBJECT`]
499/// channel so every connected client showing the notification can react in
500/// real time. Carries only the notification `id` plus the operation — a
501/// client applies it only if it currently holds that id (an id it never
502/// received is a no-op), so the single broadcast needs no audience routing.
503///
504/// The durable half of an operation lives in the backend (recall deletes the
505/// stream copies; a future edit re-publishes them); this is the "update the
506/// screens that are showing it right now" half. Built to grow: today only
507/// `Recall`, but `op` is a tagged enum so an `Update`/`SetExpiry` variant can
508/// be added without breaking the wire format.
509#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
510pub struct NotificationAmend {
511    pub id: String,
512    pub op: NotificationAmendOp,
513}
514
515/// The operation an [`NotificationAmend`] applies. Tagged on `kind` so future
516/// data-carrying variants (e.g. `Update { notification }`) stay wire-compatible.
517#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
518#[serde(tag = "kind", rename_all = "snake_case")]
519pub enum NotificationAmendOp {
520    /// The notification was recalled (deleted): remove it from the panel,
521    /// unread badge, and any open require-ack modal.
522    Recall,
523}
524
525/// Params of the `notifications.amended` push (Agent → Client) — the
526/// flattened [`NotificationAmend`] (`{ "id", "kind": "recall" }`).
527#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
528pub struct NotificationAmendedParams {
529    #[serde(flatten)]
530    pub amend: NotificationAmend,
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use chrono::TimeZone;
537
538    #[test]
539    fn priority_serialises_snake_case() {
540        for (variant, expected) in [
541            (NotificationPriority::Info, "\"info\""),
542            (NotificationPriority::Warn, "\"warn\""),
543            (NotificationPriority::Emergency, "\"emergency\""),
544        ] {
545            let s = serde_json::to_string(&variant).unwrap();
546            assert_eq!(s, expected, "encode {variant:?}");
547            let back: NotificationPriority = serde_json::from_str(expected).unwrap();
548            assert_eq!(back, variant, "round-trip {expected}");
549        }
550    }
551
552    #[test]
553    fn filter_defaults_to_unread() {
554        // The Client App's notification panel opens to "unread" so
555        // the default selector must match.
556        let p = NotificationsListParams::default();
557        assert_eq!(p.filter, NotificationsFilter::Unread);
558        // Default decode of an empty object.
559        let p: NotificationsListParams = serde_json::from_str("{}").unwrap();
560        assert_eq!(p.filter, NotificationsFilter::Unread);
561        assert_eq!(p.limit, 50);
562    }
563
564    #[test]
565    fn notification_new_spec_example_decodes() {
566        // SPEC §2.12.8's emergency push payload, verbatim. The
567        // flatten attribute means the wire is the Notification's
568        // own keys at the top level — no `notification: {…}` nest.
569        let wire = r#"{
570            "id":"notif-9f3a","priority":"emergency","require_ack":true,
571            "title":"緊急: ネットワーク機器メンテ","body":"22時から30分停止します",
572            "issued_at":"2026-05-20T12:00:00Z","issued_by":"infra-team"
573        }"#;
574        let p: NotificationNewParams = serde_json::from_str(wire).expect("decode");
575        assert_eq!(p.notification.id, "notif-9f3a");
576        assert_eq!(p.notification.priority, NotificationPriority::Emergency);
577        assert!(p.notification.require_ack);
578        assert_eq!(p.notification.title, "緊急: ネットワーク機器メンテ");
579        assert_eq!(p.notification.issued_by.as_deref(), Some("infra-team"));
580    }
581
582    #[test]
583    fn notification_expires_at_is_optional_and_skipped_when_none() {
584        // Additive field: a body without expires_at decodes (None) and
585        // a None value is omitted from the wire so pre-Phase-E
586        // consumers don't see a null key.
587        let wire = r#"{
588            "id":"n1","priority":"info","title":"t","body":"b",
589            "issued_at":"2026-05-20T12:00:00Z"
590        }"#;
591        let n: Notification = serde_json::from_str(wire).expect("decode without expires_at");
592        assert!(n.expires_at.is_none());
593        let v = serde_json::to_value(&n).unwrap();
594        assert!(
595            v.get("expires_at").is_none(),
596            "None expires_at omitted: {v:?}"
597        );
598    }
599
600    #[test]
601    fn notification_toast_defaults_false_and_round_trips() {
602        // A body on the retained stream from before the `toast` field
603        // decodes with toast = false (so old messages just don't toast).
604        let wire = r#"{
605            "id":"n1","priority":"info","title":"t","body":"b",
606            "issued_at":"2026-05-20T12:00:00Z"
607        }"#;
608        let n: Notification = serde_json::from_str(wire).expect("decode without toast");
609        assert!(!n.toast, "absent toast ⇒ false (in-app only, not a toast)");
610
611        // And an explicit toast:true round-trips.
612        let wire_true = r#"{
613            "id":"n2","priority":"warn","title":"t","body":"b","toast":true,
614            "issued_at":"2026-05-20T12:00:00Z"
615        }"#;
616        let n: Notification = serde_json::from_str(wire_true).expect("decode toast:true");
617        assert!(n.toast);
618        // Decoupled from priority: a warn can carry toast:true.
619        assert_eq!(n.priority, NotificationPriority::Warn);
620    }
621
622    #[test]
623    fn publish_request_toast_defaults_false_and_decodes() {
624        // Toast is driven ONLY by this flag (decoupled from priority by
625        // design): an omitted `toast` decodes to false even for an
626        // emergency — the caller must opt in with `toast: true`. There is
627        // deliberately no priority fallback.
628        let req: PublishNotificationRequest =
629            serde_json::from_str(r#"{"priority":"emergency","title":"t","body":"b","target":{}}"#)
630                .expect("decode without toast");
631        assert!(!req.toast, "omitted toast ⇒ false, even for emergency");
632
633        let req: PublishNotificationRequest = serde_json::from_str(
634            r#"{"priority":"warn","title":"t","body":"b","toast":true,"target":{}}"#,
635        )
636        .expect("decode with toast:true");
637        assert!(req.toast, "explicit toast:true on a non-emergency priority");
638    }
639
640    #[test]
641    fn publish_request_requires_target_audience() {
642        // The wire decodes a target with no audience set; the handler
643        // is what rejects it. Here we just pin Target::is_specified so
644        // the handler's guard has a stable contract to lean on.
645        let req: PublishNotificationRequest =
646            serde_json::from_str(r#"{"priority":"warn","title":"t","body":"b","target":{}}"#)
647                .expect("decode");
648        assert!(!req.target.is_specified(), "empty target is unspecified");
649        assert_eq!(req.id, None, "id omitted ⇒ backend mints one");
650        assert!(!req.require_ack, "require_ack defaults false");
651        assert!(!req.toast, "toast defaults false");
652    }
653
654    #[test]
655    fn edit_request_decodes_with_defaults() {
656        // Minimal body: the SPA always submits all editable fields, but
657        // require_ack / toast / reset_acks default false and expires_at omitted
658        // ⇒ never expires.
659        let req: EditNotificationRequest =
660            serde_json::from_str(r#"{"priority":"warn","title":"t","body":"b"}"#).expect("decode");
661        assert!(!req.require_ack);
662        assert!(!req.toast);
663        assert!(
664            !req.reset_acks,
665            "reset_acks defaults false (keep confirmations)"
666        );
667        assert_eq!(req.expires_at, None, "omitted expiry ⇒ never expires");
668
669        // reset_acks + an explicit expiry decode as set.
670        let req: EditNotificationRequest = serde_json::from_str(
671            r#"{"priority":"info","title":"t","body":"b","reset_acks":true,"expires_at":"2099-01-01T00:00:00Z"}"#,
672        )
673        .expect("decode");
674        assert!(req.reset_acks);
675        assert!(req.expires_at.is_some());
676    }
677
678    #[test]
679    fn notification_edit_fields_default_none_and_round_trip() {
680        // A pre-edit body (no edited_at / acks_reset_at) still decodes, and
681        // both fields are omitted on the wire when None.
682        let n: Notification = serde_json::from_str(
683            r#"{"id":"n1","priority":"info","title":"t","body":"b","issued_at":"2026-06-01T00:00:00Z"}"#,
684        )
685        .expect("decode pre-edit body");
686        assert_eq!(n.edited_at, None);
687        assert_eq!(n.acks_reset_at, None);
688        let v = serde_json::to_value(&n).unwrap();
689        assert!(
690            v.get("edited_at").is_none(),
691            "None edited_at omitted: {v:?}"
692        );
693        assert!(
694            v.get("acks_reset_at").is_none(),
695            "None acks_reset_at omitted: {v:?}"
696        );
697    }
698
699    #[test]
700    fn notification_acked_round_trips() {
701        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
702        let a = NotificationAcked {
703            notification_id: "notif-9f3a".into(),
704            pc_id: "PC1234".into(),
705            // SIDs use hyphens, never dots — safe alongside the dotted
706            // subject, but the projector reads this body field anyway.
707            user_sid: "S-1-5-21-1001".into(),
708            acked_at: t,
709            account: Some("EXAMPLE\\taro".into()),
710        };
711        let json = serde_json::to_string(&a).unwrap();
712        let back: NotificationAcked = serde_json::from_str(&json).unwrap();
713        assert_eq!(back.notification_id, a.notification_id);
714        assert_eq!(back.pc_id, a.pc_id);
715        assert_eq!(back.user_sid, a.user_sid);
716        assert_eq!(back.acked_at, t);
717        assert_eq!(back.account.as_deref(), Some("EXAMPLE\\taro"));
718    }
719
720    #[test]
721    fn notification_amend_recall_round_trips() {
722        // Wire shape the backend broadcasts and the client decodes:
723        // the op is tagged on `kind` so adding a data-carrying variant
724        // later (Update { .. }) stays compatible.
725        let a = NotificationAmend {
726            id: "notif-9f3a".into(),
727            op: NotificationAmendOp::Recall,
728        };
729        let v = serde_json::to_value(&a).unwrap();
730        assert_eq!(v["id"], "notif-9f3a");
731        assert_eq!(v["op"]["kind"], "recall");
732        let back: NotificationAmend = serde_json::from_value(v).unwrap();
733        assert_eq!(back, a);
734
735        // The push params flatten the amend (no nested "amend" key).
736        let p = NotificationAmendedParams { amend: a.clone() };
737        let pv = serde_json::to_value(&p).unwrap();
738        assert_eq!(pv["id"], "notif-9f3a");
739        assert_eq!(pv["op"]["kind"], "recall");
740        assert!(pv.get("amend").is_none(), "amend is flattened: {pv:?}");
741    }
742
743    #[test]
744    fn notification_acked_without_account_decodes() {
745        // A pre-account agent emits the ack body without `account`; it must
746        // still decode (None), and a None account is omitted on the wire so
747        // older readers never see a null key.
748        let wire = r#"{
749            "notification_id":"n1","pc_id":"PC1","user_sid":"S-1-5-21-1",
750            "acked_at":"2026-05-20T12:00:05Z"
751        }"#;
752        let a: NotificationAcked = serde_json::from_str(wire).expect("decode without account");
753        assert_eq!(a.account, None);
754        let v = serde_json::to_value(&a).unwrap();
755        assert!(v.get("account").is_none(), "None account omitted: {v:?}");
756    }
757
758    #[test]
759    fn ack_result_round_trips() {
760        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
761        let r = NotificationsAckResult { acked_at: t };
762        let json = serde_json::to_string(&r).unwrap();
763        let back: NotificationsAckResult = serde_json::from_str(&json).unwrap();
764        assert_eq!(back.acked_at, t);
765    }
766
767    #[test]
768    fn unack_result_round_trips() {
769        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 5, 0).unwrap();
770        let r = NotificationsUnackResult { unacked_at: t };
771        let json = serde_json::to_string(&r).unwrap();
772        let back: NotificationsUnackResult = serde_json::from_str(&json).unwrap();
773        assert_eq!(back.unacked_at, t);
774    }
775
776    #[test]
777    fn notification_unacked_round_trips_and_account_optional() {
778        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 5, 0).unwrap();
779        let u = NotificationUnacked {
780            notification_id: "notif-9f3a".into(),
781            pc_id: "PC1234".into(),
782            user_sid: "S-1-5-21-1001".into(),
783            unacked_at: t,
784            account: Some("EXAMPLE\\taro".into()),
785        };
786        let json = serde_json::to_string(&u).unwrap();
787        let back: NotificationUnacked = serde_json::from_str(&json).unwrap();
788        assert_eq!(back.notification_id, u.notification_id);
789        assert_eq!(back.pc_id, u.pc_id);
790        assert_eq!(back.user_sid, u.user_sid);
791        assert_eq!(back.unacked_at, t);
792        assert_eq!(back.account.as_deref(), Some("EXAMPLE\\taro"));
793
794        // account omitted ⇒ decodes None and is left off the wire.
795        let wire = r#"{
796            "notification_id":"n1","pc_id":"PC1","user_sid":"S-1-5-21-1",
797            "unacked_at":"2026-05-20T12:05:00Z"
798        }"#;
799        let u: NotificationUnacked = serde_json::from_str(wire).expect("decode without account");
800        assert_eq!(u.account, None);
801        let v = serde_json::to_value(&u).unwrap();
802        assert!(v.get("account").is_none(), "None account omitted: {v:?}");
803    }
804
805    #[test]
806    fn ack_entry_unacked_at_optional_and_skipped_when_none() {
807        // A pre-unack backend emits an ack entry with no unacked_at; it
808        // must decode (None) and a None value is omitted on the wire.
809        let wire = r#"{
810            "pc_id":"PC1","user_sid":"S-1-5-21-1","acked_at":"2026-05-20T12:00:05Z"
811        }"#;
812        let e: NotificationAckEntry =
813            serde_json::from_str(wire).expect("decode without unacked_at");
814        assert_eq!(e.unacked_at, None);
815        let v = serde_json::to_value(&e).unwrap();
816        assert!(
817            v.get("unacked_at").is_none(),
818            "None unacked_at omitted: {v:?}"
819        );
820    }
821
822    #[test]
823    fn notifications_list_paginates_via_cursor() {
824        // First page: no cursor.
825        let p = NotificationsListParams {
826            filter: NotificationsFilter::All,
827            limit: 25,
828            cursor: None,
829        };
830        let v = serde_json::to_value(&p).unwrap();
831        assert!(v.get("cursor").is_none(), "wire: {v:?}");
832
833        // Continuation: cursor present.
834        let p = NotificationsListParams {
835            cursor: Some("opaque-token".into()),
836            ..NotificationsListParams::default()
837        };
838        let v = serde_json::to_value(&p).unwrap();
839        assert_eq!(v["cursor"], "opaque-token");
840    }
841}