Skip to main content

kanade_shared/wire/
group_contacts.rs

1use serde::{Deserialize, Serialize};
2
3/// Value stored in the `group_contacts` KV bucket, keyed by group name.
4/// Holds the email addresses that should receive notifications targeted
5/// at the group — e.g. a compliance alert's `notify_groups`. Operator-
6/// managed via the SPA Groups page, parallel to (but separate from)
7/// `agent_groups` membership: membership is per-PC, contact is per-group.
8///
9/// A wrapper struct (rather than a bare `Vec<String>`) leaves room for
10/// future per-group contact metadata (a display name, a phone/IM hook)
11/// without breaking the wire format.
12#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
13pub struct GroupContacts {
14    /// Normalised email addresses: trimmed, lower-cased, de-duplicated,
15    /// sorted, with blanks dropped. Use [`GroupContacts::new`] so two
16    /// callers that enter the same set (any case/order) store identical
17    /// JSON.
18    pub emails: Vec<String>,
19}
20
21impl GroupContacts {
22    /// Construct from any iterator, normalising the addresses: trim,
23    /// lower-case, drop empties, sort, dedup.
24    pub fn new<I, S>(emails: I) -> Self
25    where
26        I: IntoIterator<Item = S>,
27        S: Into<String>,
28    {
29        let mut v: Vec<String> = emails
30            .into_iter()
31            .map(|e| e.into().trim().to_lowercase())
32            .filter(|e| !e.is_empty())
33            .collect();
34        v.sort();
35        v.dedup();
36        Self { emails: v }
37    }
38
39    pub fn is_empty(&self) -> bool {
40        self.emails.is_empty()
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn new_trims_lowercases_sorts_dedups_and_drops_blanks() {
50        let c = GroupContacts::new([
51            "  Ops@Example.com ",
52            "it@example.com",
53            "OPS@example.com",
54            "   ",
55            "",
56        ]);
57        assert_eq!(c.emails, vec!["it@example.com", "ops@example.com"]);
58    }
59
60    #[test]
61    fn round_trips_through_json() {
62        let c = GroupContacts::new(["sec@example.com"]);
63        let json = serde_json::to_string(&c).unwrap();
64        assert_eq!(json, r#"{"emails":["sec@example.com"]}"#);
65        let back: GroupContacts = serde_json::from_str(&json).unwrap();
66        assert_eq!(back, c);
67    }
68
69    #[test]
70    fn empty_round_trips() {
71        let c = GroupContacts::default();
72        assert_eq!(serde_json::to_string(&c).unwrap(), r#"{"emails":[]}"#);
73        assert!(c.is_empty());
74    }
75
76    #[test]
77    fn accepts_unknown_fields_for_forward_compat() {
78        let json = r#"{"emails":["a@b.com"],"display_name":"IT","set_by":"alice"}"#;
79        let c: GroupContacts = serde_json::from_str(json).unwrap();
80        assert_eq!(c.emails, vec!["a@b.com"]);
81    }
82}