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,
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>>,
}
#[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, 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 NotificationAcked {
pub notification_id: String,
pub pc_id: String,
pub user_sid: String,
pub acked_at: chrono::DateTime<chrono::Utc>,
}
#[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>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct NotificationAckStatus {
pub id: String,
pub acks: Vec<NotificationAckEntry>,
}
#[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 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");
}
#[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,
};
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);
}
#[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");
}
}