Skip to main content

teamcraft/domain/
notifications.rs

1//! Notification kinds and webhook events.
2
3use serde::{Deserialize, Serialize};
4
5/// What a notification is about. Users can mute every kind except `Test`.
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum NotificationKind {
9    /// You were asked to review a change.
10    ReviewRequested,
11    /// A change you authored, review or watch was decided, released,
12    /// rejected, withdrawn or cancelled.
13    ChangeUpdate,
14    /// An item you created or watch has to be checked (change propagation).
15    Impact,
16    /// An item you watch got a new revision, was released, made obsolete or
17    /// checked in.
18    ItemUpdate,
19    /// Somebody else released your check-out.
20    Unlock,
21    /// A change request you raised, are assigned to, watch or have to triage
22    /// was raised, commented, assigned, decided or implemented.
23    ChangeRequest,
24    /// The periodic e-mail of the change requests that wait for triage in a
25    /// project group you edit (`services::request_digests`).
26    RequestDigest,
27    /// Security notice for the managers of a need-to-know group: an
28    /// administrator opened an elevation or added themselves. Cannot be muted.
29    AccessAlert,
30    /// Test message from the account page.
31    Test,
32}
33
34impl NotificationKind {
35    /// The kinds a user can switch off.
36    pub const MUTABLE: [NotificationKind; 7] = [
37        NotificationKind::ReviewRequested,
38        NotificationKind::ChangeUpdate,
39        NotificationKind::Impact,
40        NotificationKind::ItemUpdate,
41        NotificationKind::Unlock,
42        NotificationKind::ChangeRequest,
43        NotificationKind::RequestDigest,
44    ];
45
46    pub fn as_str(self) -> &'static str {
47        match self {
48            NotificationKind::ReviewRequested => "review_requested",
49            NotificationKind::ChangeUpdate => "change_update",
50            NotificationKind::Impact => "impact",
51            NotificationKind::ItemUpdate => "item_update",
52            NotificationKind::Unlock => "unlock",
53            NotificationKind::ChangeRequest => "change_request",
54            NotificationKind::RequestDigest => "request_digest",
55            NotificationKind::AccessAlert => "access_alert",
56            NotificationKind::Test => "test",
57        }
58    }
59
60    pub fn label(self) -> &'static str {
61        match self {
62            NotificationKind::ReviewRequested => "Review requests",
63            NotificationKind::ChangeUpdate => "Changes I authored, review or watch",
64            NotificationKind::Impact => "Checks needed on my items (change propagation)",
65            NotificationKind::ItemUpdate => "Items I watch: new revisions, releases, check-ins",
66            NotificationKind::Unlock => "Somebody released my check-out",
67            NotificationKind::ChangeRequest => {
68                "Change requests I raised, triage, am assigned to or watch"
69            }
70            NotificationKind::RequestDigest => {
71                "E-mail digest of new change requests waiting for triage in my project groups"
72            }
73            NotificationKind::AccessAlert => "Access to a need-to-know group",
74            NotificationKind::Test => "Test messages",
75        }
76    }
77
78    pub fn parse(s: &str) -> Option<NotificationKind> {
79        [
80            NotificationKind::ReviewRequested,
81            NotificationKind::ChangeUpdate,
82            NotificationKind::Impact,
83            NotificationKind::ItemUpdate,
84            NotificationKind::Unlock,
85            NotificationKind::ChangeRequest,
86            NotificationKind::RequestDigest,
87            NotificationKind::AccessAlert,
88            NotificationKind::Test,
89        ]
90        .into_iter()
91        .find(|k| k.as_str() == s.trim())
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Webhooks
97// ---------------------------------------------------------------------------
98
99/// An event an outgoing webhook can subscribe to (`webhooks.events`; the
100/// CHECK constraint in migration 0022 lists the same names).
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
102pub enum WebhookEvent {
103    #[serde(rename = "revision.released")]
104    RevisionReleased,
105    #[serde(rename = "revision.obsoleted")]
106    RevisionObsoleted,
107    #[serde(rename = "change.submitted")]
108    ChangeSubmitted,
109    #[serde(rename = "change.approved")]
110    ChangeApproved,
111    #[serde(rename = "change.rejected")]
112    ChangeRejected,
113    #[serde(rename = "change.released")]
114    ChangeReleased,
115    #[serde(rename = "change_request.created")]
116    ChangeRequestCreated,
117    #[serde(rename = "change_request.decided")]
118    ChangeRequestDecided,
119    #[serde(rename = "simulation.result_added")]
120    SimulationResultAdded,
121}
122
123impl WebhookEvent {
124    pub const ALL: [WebhookEvent; 9] = [
125        WebhookEvent::RevisionReleased,
126        WebhookEvent::RevisionObsoleted,
127        WebhookEvent::ChangeSubmitted,
128        WebhookEvent::ChangeApproved,
129        WebhookEvent::ChangeRejected,
130        WebhookEvent::ChangeReleased,
131        WebhookEvent::ChangeRequestCreated,
132        WebhookEvent::ChangeRequestDecided,
133        WebhookEvent::SimulationResultAdded,
134    ];
135
136    pub fn as_str(self) -> &'static str {
137        match self {
138            WebhookEvent::RevisionReleased => "revision.released",
139            WebhookEvent::RevisionObsoleted => "revision.obsoleted",
140            WebhookEvent::ChangeSubmitted => "change.submitted",
141            WebhookEvent::ChangeApproved => "change.approved",
142            WebhookEvent::ChangeRejected => "change.rejected",
143            WebhookEvent::ChangeReleased => "change.released",
144            WebhookEvent::ChangeRequestCreated => "change_request.created",
145            WebhookEvent::ChangeRequestDecided => "change_request.decided",
146            WebhookEvent::SimulationResultAdded => "simulation.result_added",
147        }
148    }
149
150    pub fn label(self) -> &'static str {
151        match self {
152            WebhookEvent::RevisionReleased => "A revision was released",
153            WebhookEvent::RevisionObsoleted => "A revision was made obsolete",
154            WebhookEvent::ChangeSubmitted => "A change was submitted for review",
155            WebhookEvent::ChangeApproved => "A reviewer approved a change",
156            WebhookEvent::ChangeRejected => "A change was rejected",
157            WebhookEvent::ChangeReleased => "A change was released",
158            WebhookEvent::ChangeRequestCreated => "A change request was raised",
159            WebhookEvent::ChangeRequestDecided => {
160                "A change request was accepted, rejected or deferred"
161            }
162            WebhookEvent::SimulationResultAdded => "Simulation result values were added",
163        }
164    }
165
166    pub fn parse(s: &str) -> Option<WebhookEvent> {
167        WebhookEvent::ALL
168            .into_iter()
169            .find(|e| e.as_str() == s.trim())
170    }
171}
172
173/// Found by cargo mutants: the stored names (database CHECK constraints, the
174/// webhook payload) and the labels were not tested.
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    const KINDS: [NotificationKind; 9] = [
180        NotificationKind::ReviewRequested,
181        NotificationKind::ChangeUpdate,
182        NotificationKind::Impact,
183        NotificationKind::ItemUpdate,
184        NotificationKind::Unlock,
185        NotificationKind::ChangeRequest,
186        NotificationKind::RequestDigest,
187        NotificationKind::AccessAlert,
188        NotificationKind::Test,
189    ];
190
191    #[test]
192    fn notification_kinds() {
193        let stored: Vec<&str> = KINDS.iter().map(|k| k.as_str()).collect();
194        assert_eq!(
195            stored,
196            [
197                "review_requested",
198                "change_update",
199                "impact",
200                "item_update",
201                "unlock",
202                "change_request",
203                "request_digest",
204                "access_alert",
205                "test"
206            ]
207        );
208        for k in KINDS {
209            assert_eq!(NotificationKind::parse(k.as_str()), Some(k));
210            assert_eq!(
211                NotificationKind::parse(&format!(" {} ", k.as_str())),
212                Some(k)
213            );
214        }
215        assert_eq!(NotificationKind::parse("spam"), None);
216        // every kind has its own, non-empty label (settings page)
217        let mut labels: Vec<&str> = KINDS.iter().map(|k| k.label()).collect();
218        assert!(labels.iter().all(|l| l.len() > 3), "{labels:?}");
219        labels.sort_unstable();
220        labels.dedup();
221        assert_eq!(labels.len(), KINDS.len());
222        assert_eq!(NotificationKind::ReviewRequested.label(), "Review requests");
223        // the security alert cannot be muted, the test message neither
224        assert!(!NotificationKind::MUTABLE.contains(&NotificationKind::AccessAlert));
225        assert!(!NotificationKind::MUTABLE.contains(&NotificationKind::Test));
226    }
227
228    #[test]
229    fn webhook_events() {
230        let stored: Vec<&str> = WebhookEvent::ALL.iter().map(|e| e.as_str()).collect();
231        assert_eq!(
232            stored,
233            [
234                "revision.released",
235                "revision.obsoleted",
236                "change.submitted",
237                "change.approved",
238                "change.rejected",
239                "change.released",
240                "change_request.created",
241                "change_request.decided",
242                "simulation.result_added"
243            ]
244        );
245        for e in WebhookEvent::ALL {
246            assert_eq!(WebhookEvent::parse(e.as_str()), Some(e));
247        }
248        assert_eq!(WebhookEvent::parse("item.deleted"), None);
249        let mut labels: Vec<&str> = WebhookEvent::ALL.iter().map(|e| e.label()).collect();
250        assert!(labels.iter().all(|l| l.len() > 3), "{labels:?}");
251        labels.sort_unstable();
252        labels.dedup();
253        assert_eq!(labels.len(), WebhookEvent::ALL.len());
254        assert_eq!(
255            WebhookEvent::RevisionReleased.label(),
256            "A revision was released"
257        );
258    }
259}