use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct Notification {
pub id: String,
pub priority: NotificationPriority,
#[serde(default)]
pub require_ack: bool,
pub title: String,
pub body: String,
#[serde(default)]
pub toast: bool,
pub issued_at: chrono::DateTime<chrono::Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acked_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acks_reset_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum NotificationPriority {
Info,
Warn,
Emergency,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationsListParams {
#[serde(default)]
pub filter: NotificationsFilter,
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
impl Default for NotificationsListParams {
fn default() -> Self {
Self {
filter: NotificationsFilter::default(),
limit: default_limit(),
cursor: None,
}
}
}
fn default_limit() -> u32 {
50
}
#[derive(
Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
)]
#[serde(rename_all = "snake_case")]
pub enum NotificationsFilter {
#[default]
Unread,
All,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationsListResult {
pub items: Vec<Notification>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
pub struct NotificationsSubscribeParams {}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationsSubscribeResult {
pub subscription: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationsUnsubscribeParams {
pub subscription: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationNewParams {
#[serde(flatten)]
pub notification: Notification,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationsAckParams {
pub id: String,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationsAckResult {
pub acked_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct PublishNotificationRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub priority: NotificationPriority,
#[serde(default)]
pub require_ack: bool,
pub title: String,
pub body: String,
#[serde(default)]
pub toast: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub target: crate::manifest::Target,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct PublishNotificationResponse {
pub id: String,
pub subjects: Vec<String>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct EditNotificationRequest {
pub priority: NotificationPriority,
#[serde(default)]
pub require_ack: bool,
pub title: String,
pub body: String,
#[serde(default)]
pub toast: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub reset_acks: bool,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationAcked {
pub notification_id: String,
pub pc_id: String,
pub user_sid: String,
pub acked_at: chrono::DateTime<chrono::Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationAckEntry {
pub pc_id: String,
pub user_sid: String,
pub acked_at: chrono::DateTime<chrono::Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationAckStatus {
pub id: String,
pub acks: Vec<NotificationAckEntry>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationDetail {
pub notification: Notification,
pub acks: Vec<NotificationAckEntry>,
#[serde(default)]
pub audience: Vec<AudiencePc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<NotificationTarget>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
pub struct NotificationTarget {
#[serde(default)]
pub all: bool,
#[serde(default)]
pub groups: Vec<String>,
#[serde(default)]
pub pcs: Vec<String>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct AudiencePc {
pub pc_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_logon_user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_logon_display_name: Option<String>,
pub confirmed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acked_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
pub struct NotificationAmend {
pub id: String,
pub op: NotificationAmendOp,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NotificationAmendOp {
Recall,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationAmendedParams {
#[serde(flatten)]
pub amend: NotificationAmend,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn priority_serialises_snake_case() {
for (variant, expected) in [
(NotificationPriority::Info, "\"info\""),
(NotificationPriority::Warn, "\"warn\""),
(NotificationPriority::Emergency, "\"emergency\""),
] {
let s = serde_json::to_string(&variant).unwrap();
assert_eq!(s, expected, "encode {variant:?}");
let back: NotificationPriority = serde_json::from_str(expected).unwrap();
assert_eq!(back, variant, "round-trip {expected}");
}
}
#[test]
fn filter_defaults_to_unread() {
let p = NotificationsListParams::default();
assert_eq!(p.filter, NotificationsFilter::Unread);
let p: NotificationsListParams = serde_json::from_str("{}").unwrap();
assert_eq!(p.filter, NotificationsFilter::Unread);
assert_eq!(p.limit, 50);
}
#[test]
fn notification_new_spec_example_decodes() {
let wire = r#"{
"id":"notif-9f3a","priority":"emergency","require_ack":true,
"title":"緊急: ネットワーク機器メンテ","body":"22時から30分停止します",
"issued_at":"2026-05-20T12:00:00Z","issued_by":"infra-team"
}"#;
let p: NotificationNewParams = serde_json::from_str(wire).expect("decode");
assert_eq!(p.notification.id, "notif-9f3a");
assert_eq!(p.notification.priority, NotificationPriority::Emergency);
assert!(p.notification.require_ack);
assert_eq!(p.notification.title, "緊急: ネットワーク機器メンテ");
assert_eq!(p.notification.issued_by.as_deref(), Some("infra-team"));
}
#[test]
fn notification_expires_at_is_optional_and_skipped_when_none() {
let wire = r#"{
"id":"n1","priority":"info","title":"t","body":"b",
"issued_at":"2026-05-20T12:00:00Z"
}"#;
let n: Notification = serde_json::from_str(wire).expect("decode without expires_at");
assert!(n.expires_at.is_none());
let v = serde_json::to_value(&n).unwrap();
assert!(
v.get("expires_at").is_none(),
"None expires_at omitted: {v:?}"
);
}
#[test]
fn notification_toast_defaults_false_and_round_trips() {
let wire = r#"{
"id":"n1","priority":"info","title":"t","body":"b",
"issued_at":"2026-05-20T12:00:00Z"
}"#;
let n: Notification = serde_json::from_str(wire).expect("decode without toast");
assert!(!n.toast, "absent toast ⇒ false (in-app only, not a toast)");
let wire_true = r#"{
"id":"n2","priority":"warn","title":"t","body":"b","toast":true,
"issued_at":"2026-05-20T12:00:00Z"
}"#;
let n: Notification = serde_json::from_str(wire_true).expect("decode toast:true");
assert!(n.toast);
assert_eq!(n.priority, NotificationPriority::Warn);
}
#[test]
fn publish_request_toast_defaults_false_and_decodes() {
let req: PublishNotificationRequest =
serde_json::from_str(r#"{"priority":"emergency","title":"t","body":"b","target":{}}"#)
.expect("decode without toast");
assert!(!req.toast, "omitted toast ⇒ false, even for emergency");
let req: PublishNotificationRequest = serde_json::from_str(
r#"{"priority":"warn","title":"t","body":"b","toast":true,"target":{}}"#,
)
.expect("decode with toast:true");
assert!(req.toast, "explicit toast:true on a non-emergency priority");
}
#[test]
fn publish_request_requires_target_audience() {
let req: PublishNotificationRequest =
serde_json::from_str(r#"{"priority":"warn","title":"t","body":"b","target":{}}"#)
.expect("decode");
assert!(!req.target.is_specified(), "empty target is unspecified");
assert_eq!(req.id, None, "id omitted ⇒ backend mints one");
assert!(!req.require_ack, "require_ack defaults false");
assert!(!req.toast, "toast defaults false");
}
#[test]
fn edit_request_decodes_with_defaults() {
let req: EditNotificationRequest =
serde_json::from_str(r#"{"priority":"warn","title":"t","body":"b"}"#).expect("decode");
assert!(!req.require_ack);
assert!(!req.toast);
assert!(
!req.reset_acks,
"reset_acks defaults false (keep confirmations)"
);
assert_eq!(req.expires_at, None, "omitted expiry ⇒ never expires");
let req: EditNotificationRequest = serde_json::from_str(
r#"{"priority":"info","title":"t","body":"b","reset_acks":true,"expires_at":"2099-01-01T00:00:00Z"}"#,
)
.expect("decode");
assert!(req.reset_acks);
assert!(req.expires_at.is_some());
}
#[test]
fn notification_edit_fields_default_none_and_round_trip() {
let n: Notification = serde_json::from_str(
r#"{"id":"n1","priority":"info","title":"t","body":"b","issued_at":"2026-06-01T00:00:00Z"}"#,
)
.expect("decode pre-edit body");
assert_eq!(n.edited_at, None);
assert_eq!(n.acks_reset_at, None);
let v = serde_json::to_value(&n).unwrap();
assert!(
v.get("edited_at").is_none(),
"None edited_at omitted: {v:?}"
);
assert!(
v.get("acks_reset_at").is_none(),
"None acks_reset_at omitted: {v:?}"
);
}
#[test]
fn notification_acked_round_trips() {
let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
let a = NotificationAcked {
notification_id: "notif-9f3a".into(),
pc_id: "PC1234".into(),
user_sid: "S-1-5-21-1001".into(),
acked_at: t,
account: Some("EXAMPLE\\taro".into()),
};
let json = serde_json::to_string(&a).unwrap();
let back: NotificationAcked = serde_json::from_str(&json).unwrap();
assert_eq!(back.notification_id, a.notification_id);
assert_eq!(back.pc_id, a.pc_id);
assert_eq!(back.user_sid, a.user_sid);
assert_eq!(back.acked_at, t);
assert_eq!(back.account.as_deref(), Some("EXAMPLE\\taro"));
}
#[test]
fn notification_amend_recall_round_trips() {
let a = NotificationAmend {
id: "notif-9f3a".into(),
op: NotificationAmendOp::Recall,
};
let v = serde_json::to_value(&a).unwrap();
assert_eq!(v["id"], "notif-9f3a");
assert_eq!(v["op"]["kind"], "recall");
let back: NotificationAmend = serde_json::from_value(v).unwrap();
assert_eq!(back, a);
let p = NotificationAmendedParams { amend: a.clone() };
let pv = serde_json::to_value(&p).unwrap();
assert_eq!(pv["id"], "notif-9f3a");
assert_eq!(pv["op"]["kind"], "recall");
assert!(pv.get("amend").is_none(), "amend is flattened: {pv:?}");
}
#[test]
fn notification_acked_without_account_decodes() {
let wire = r#"{
"notification_id":"n1","pc_id":"PC1","user_sid":"S-1-5-21-1",
"acked_at":"2026-05-20T12:00:05Z"
}"#;
let a: NotificationAcked = serde_json::from_str(wire).expect("decode without account");
assert_eq!(a.account, None);
let v = serde_json::to_value(&a).unwrap();
assert!(v.get("account").is_none(), "None account omitted: {v:?}");
}
#[test]
fn ack_result_round_trips() {
let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
let r = NotificationsAckResult { acked_at: t };
let json = serde_json::to_string(&r).unwrap();
let back: NotificationsAckResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.acked_at, t);
}
#[test]
fn notifications_list_paginates_via_cursor() {
let p = NotificationsListParams {
filter: NotificationsFilter::All,
limit: 25,
cursor: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("cursor").is_none(), "wire: {v:?}");
let p = NotificationsListParams {
cursor: Some("opaque-token".into()),
..NotificationsListParams::default()
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["cursor"], "opaque-token");
}
}