1use serde::{Deserialize, Serialize};
10
11use crate::entities::{ExternalContextBundle, TaskConstraints};
12use crate::error::DomainError;
13use crate::events::envelope::EventEnvelope;
14use crate::value_objects::{Attributes, Specialty, TaskDescription};
15
16const MAX_KIND_LEN: usize = 128;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TriggerEvent {
21 #[serde(flatten)]
22 envelope: EventEnvelope,
23 kind: String,
24 requested_specialties: Vec<Specialty>,
25 task_description_template: Option<TaskDescription>,
26 #[serde(default)]
27 constraints: TaskConstraints,
28 #[serde(default)]
29 payload: Attributes,
30 external_context: Option<ExternalContextBundle>,
31}
32
33impl TriggerEvent {
34 pub fn new(
42 envelope: EventEnvelope,
43 kind: impl Into<String>,
44 requested_specialties: impl IntoIterator<Item = Specialty>,
45 task_description_template: Option<TaskDescription>,
46 constraints: TaskConstraints,
47 payload: Attributes,
48 ) -> Result<Self, DomainError> {
49 Self::new_with_context(
50 envelope,
51 kind,
52 requested_specialties,
53 task_description_template,
54 constraints,
55 payload,
56 None,
57 )
58 }
59
60 pub fn new_with_context(
61 envelope: EventEnvelope,
62 kind: impl Into<String>,
63 requested_specialties: impl IntoIterator<Item = Specialty>,
64 task_description_template: Option<TaskDescription>,
65 constraints: TaskConstraints,
66 payload: Attributes,
67 external_context: Option<ExternalContextBundle>,
68 ) -> Result<Self, DomainError> {
69 let kind = kind.into();
70 let trimmed = kind.trim();
71 if trimmed.is_empty() {
72 return Err(DomainError::EmptyField {
73 field: "trigger.kind",
74 });
75 }
76 if trimmed.len() > MAX_KIND_LEN {
77 return Err(DomainError::FieldTooLong {
78 field: "trigger.kind",
79 actual: trimmed.len(),
80 max: MAX_KIND_LEN,
81 });
82 }
83
84 let mut seen = std::collections::BTreeSet::new();
85 let mut unique = Vec::new();
86 for sp in requested_specialties {
87 if seen.insert(sp.clone()) {
88 unique.push(sp);
89 }
90 }
91 if unique.is_empty() {
92 return Err(DomainError::EmptyCollection {
93 field: "trigger.requested_specialties",
94 });
95 }
96
97 Ok(Self {
98 envelope,
99 kind: trimmed.to_owned(),
100 requested_specialties: unique,
101 task_description_template,
102 constraints,
103 payload,
104 external_context,
105 })
106 }
107
108 #[must_use]
109 pub fn envelope(&self) -> &EventEnvelope {
110 &self.envelope
111 }
112 #[must_use]
113 pub fn kind(&self) -> &str {
114 &self.kind
115 }
116 #[must_use]
117 pub fn requested_specialties(&self) -> &[Specialty] {
118 &self.requested_specialties
119 }
120 #[must_use]
121 pub fn task_description_template(&self) -> Option<&TaskDescription> {
122 self.task_description_template.as_ref()
123 }
124 #[must_use]
125 pub fn constraints(&self) -> &TaskConstraints {
126 &self.constraints
127 }
128 #[must_use]
129 pub fn payload(&self) -> &Attributes {
130 &self.payload
131 }
132
133 #[must_use]
134 pub fn external_context(&self) -> Option<&ExternalContextBundle> {
135 self.external_context.as_ref()
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::value_objects::EventId;
143 use time::macros::datetime;
144
145 fn env() -> EventEnvelope {
146 EventEnvelope::new(
147 EventId::new("e1").unwrap(),
148 datetime!(2026-04-15 12:00:00 UTC),
149 "grafana",
150 None,
151 )
152 .unwrap()
153 }
154
155 fn sp(s: &str) -> Specialty {
156 Specialty::new(s).unwrap()
157 }
158
159 #[test]
160 fn empty_kind_is_rejected() {
161 let err = TriggerEvent::new(
162 env(),
163 " ",
164 vec![sp("triage")],
165 None,
166 TaskConstraints::default(),
167 Attributes::empty(),
168 )
169 .unwrap_err();
170 assert!(matches!(
171 err,
172 DomainError::EmptyField {
173 field: "trigger.kind"
174 }
175 ));
176 }
177
178 #[test]
179 fn overlong_kind_is_rejected() {
180 let err = TriggerEvent::new(
181 env(),
182 "k".repeat(MAX_KIND_LEN + 1),
183 vec![sp("triage")],
184 None,
185 TaskConstraints::default(),
186 Attributes::empty(),
187 )
188 .unwrap_err();
189 assert!(matches!(err, DomainError::FieldTooLong { .. }));
190 }
191
192 #[test]
193 fn empty_specialty_list_is_rejected() {
194 let err = TriggerEvent::new(
195 env(),
196 "alert.fired",
197 Vec::<Specialty>::new(),
198 None,
199 TaskConstraints::default(),
200 Attributes::empty(),
201 )
202 .unwrap_err();
203 assert!(matches!(
204 err,
205 DomainError::EmptyCollection {
206 field: "trigger.requested_specialties"
207 }
208 ));
209 }
210
211 #[test]
212 fn duplicate_specialties_are_deduplicated() {
213 let ev = TriggerEvent::new(
214 env(),
215 "alert.fired",
216 vec![sp("triage"), sp("triage"), sp("reviewer")],
217 None,
218 TaskConstraints::default(),
219 Attributes::empty(),
220 )
221 .unwrap();
222 assert_eq!(ev.requested_specialties().len(), 2);
223 }
224
225 #[test]
226 fn json_shape_is_flat_per_asyncapi() {
227 let ev = TriggerEvent::new(
233 env(),
234 "alert.fired",
235 vec![sp("triage")],
236 None,
237 TaskConstraints::default(),
238 Attributes::empty(),
239 )
240 .unwrap();
241 let json = serde_json::to_value(&ev).unwrap();
242 let obj = json.as_object().unwrap();
243 assert!(obj.contains_key("event_id"));
244 assert!(obj.contains_key("source"));
245 assert!(obj.contains_key("emitted_at"));
246 assert!(obj.contains_key("kind"));
247 assert!(obj.contains_key("requested_specialties"));
248 assert!(
249 !obj.contains_key("envelope"),
250 "envelope must flatten into the root"
251 );
252 }
253
254 #[test]
255 fn kind_is_free_form_across_domains() {
256 for kind in [
257 "alert.fired",
258 "case.opened",
259 "shipment.delayed",
260 "protocol.deviation.detected",
261 "claim.submitted",
262 ] {
263 TriggerEvent::new(
264 env(),
265 kind,
266 vec![sp("x")],
267 None,
268 TaskConstraints::default(),
269 Attributes::empty(),
270 )
271 .unwrap();
272 }
273 }
274
275 #[test]
276 fn json_defaults_optional_constraints_and_payload() {
277 let json = serde_json::json!({
278 "event_id": "e1",
279 "kind": "alert.fired",
280 "source": "grafana",
281 "emitted_at": "2026-04-15T12:00:00Z",
282 "requested_specialties": ["triage"]
283 });
284
285 let ev: TriggerEvent = serde_json::from_value(json).unwrap();
286 assert_eq!(ev.constraints(), &TaskConstraints::default());
287 assert_eq!(ev.payload(), &Attributes::empty());
288 }
289
290 #[test]
291 fn json_accepts_empty_constraints_object() {
292 let json = serde_json::json!({
293 "event_id": "e1",
294 "kind": "alert.fired",
295 "source": "grafana",
296 "emitted_at": "2026-04-15T12:00:00Z",
297 "requested_specialties": ["triage"],
298 "constraints": {},
299 "payload": {}
300 });
301
302 let ev: TriggerEvent = serde_json::from_value(json).unwrap();
303 assert_eq!(ev.constraints(), &TaskConstraints::default());
304 assert_eq!(ev.payload(), &Attributes::empty());
305 }
306}