use serde::{Deserialize, Serialize};
use crate::entities::{ExternalContextBundle, TaskConstraints};
use crate::error::DomainError;
use crate::events::envelope::EventEnvelope;
use crate::value_objects::{Attributes, Specialty, TaskDescription};
const MAX_KIND_LEN: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TriggerEvent {
#[serde(flatten)]
envelope: EventEnvelope,
kind: String,
requested_specialties: Vec<Specialty>,
task_description_template: Option<TaskDescription>,
#[serde(default)]
constraints: TaskConstraints,
#[serde(default)]
payload: Attributes,
external_context: Option<ExternalContextBundle>,
}
impl TriggerEvent {
pub fn new(
envelope: EventEnvelope,
kind: impl Into<String>,
requested_specialties: impl IntoIterator<Item = Specialty>,
task_description_template: Option<TaskDescription>,
constraints: TaskConstraints,
payload: Attributes,
) -> Result<Self, DomainError> {
Self::new_with_context(
envelope,
kind,
requested_specialties,
task_description_template,
constraints,
payload,
None,
)
}
pub fn new_with_context(
envelope: EventEnvelope,
kind: impl Into<String>,
requested_specialties: impl IntoIterator<Item = Specialty>,
task_description_template: Option<TaskDescription>,
constraints: TaskConstraints,
payload: Attributes,
external_context: Option<ExternalContextBundle>,
) -> Result<Self, DomainError> {
let kind = kind.into();
let trimmed = kind.trim();
if trimmed.is_empty() {
return Err(DomainError::EmptyField {
field: "trigger.kind",
});
}
if trimmed.len() > MAX_KIND_LEN {
return Err(DomainError::FieldTooLong {
field: "trigger.kind",
actual: trimmed.len(),
max: MAX_KIND_LEN,
});
}
let mut seen = std::collections::BTreeSet::new();
let mut unique = Vec::new();
for sp in requested_specialties {
if seen.insert(sp.clone()) {
unique.push(sp);
}
}
if unique.is_empty() {
return Err(DomainError::EmptyCollection {
field: "trigger.requested_specialties",
});
}
Ok(Self {
envelope,
kind: trimmed.to_owned(),
requested_specialties: unique,
task_description_template,
constraints,
payload,
external_context,
})
}
#[must_use]
pub fn envelope(&self) -> &EventEnvelope {
&self.envelope
}
#[must_use]
pub fn kind(&self) -> &str {
&self.kind
}
#[must_use]
pub fn requested_specialties(&self) -> &[Specialty] {
&self.requested_specialties
}
#[must_use]
pub fn task_description_template(&self) -> Option<&TaskDescription> {
self.task_description_template.as_ref()
}
#[must_use]
pub fn constraints(&self) -> &TaskConstraints {
&self.constraints
}
#[must_use]
pub fn payload(&self) -> &Attributes {
&self.payload
}
#[must_use]
pub fn external_context(&self) -> Option<&ExternalContextBundle> {
self.external_context.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value_objects::EventId;
use time::macros::datetime;
fn env() -> EventEnvelope {
EventEnvelope::new(
EventId::new("e1").unwrap(),
datetime!(2026-04-15 12:00:00 UTC),
"grafana",
None,
)
.unwrap()
}
fn sp(s: &str) -> Specialty {
Specialty::new(s).unwrap()
}
#[test]
fn empty_kind_is_rejected() {
let err = TriggerEvent::new(
env(),
" ",
vec![sp("triage")],
None,
TaskConstraints::default(),
Attributes::empty(),
)
.unwrap_err();
assert!(matches!(
err,
DomainError::EmptyField {
field: "trigger.kind"
}
));
}
#[test]
fn overlong_kind_is_rejected() {
let err = TriggerEvent::new(
env(),
"k".repeat(MAX_KIND_LEN + 1),
vec![sp("triage")],
None,
TaskConstraints::default(),
Attributes::empty(),
)
.unwrap_err();
assert!(matches!(err, DomainError::FieldTooLong { .. }));
}
#[test]
fn empty_specialty_list_is_rejected() {
let err = TriggerEvent::new(
env(),
"alert.fired",
Vec::<Specialty>::new(),
None,
TaskConstraints::default(),
Attributes::empty(),
)
.unwrap_err();
assert!(matches!(
err,
DomainError::EmptyCollection {
field: "trigger.requested_specialties"
}
));
}
#[test]
fn duplicate_specialties_are_deduplicated() {
let ev = TriggerEvent::new(
env(),
"alert.fired",
vec![sp("triage"), sp("triage"), sp("reviewer")],
None,
TaskConstraints::default(),
Attributes::empty(),
)
.unwrap();
assert_eq!(ev.requested_specialties().len(), 2);
}
#[test]
fn json_shape_is_flat_per_asyncapi() {
let ev = TriggerEvent::new(
env(),
"alert.fired",
vec![sp("triage")],
None,
TaskConstraints::default(),
Attributes::empty(),
)
.unwrap();
let json = serde_json::to_value(&ev).unwrap();
let obj = json.as_object().unwrap();
assert!(obj.contains_key("event_id"));
assert!(obj.contains_key("source"));
assert!(obj.contains_key("emitted_at"));
assert!(obj.contains_key("kind"));
assert!(obj.contains_key("requested_specialties"));
assert!(
!obj.contains_key("envelope"),
"envelope must flatten into the root"
);
}
#[test]
fn kind_is_free_form_across_domains() {
for kind in [
"alert.fired",
"case.opened",
"shipment.delayed",
"protocol.deviation.detected",
"claim.submitted",
] {
TriggerEvent::new(
env(),
kind,
vec![sp("x")],
None,
TaskConstraints::default(),
Attributes::empty(),
)
.unwrap();
}
}
#[test]
fn json_defaults_optional_constraints_and_payload() {
let json = serde_json::json!({
"event_id": "e1",
"kind": "alert.fired",
"source": "grafana",
"emitted_at": "2026-04-15T12:00:00Z",
"requested_specialties": ["triage"]
});
let ev: TriggerEvent = serde_json::from_value(json).unwrap();
assert_eq!(ev.constraints(), &TaskConstraints::default());
assert_eq!(ev.payload(), &Attributes::empty());
}
#[test]
fn json_accepts_empty_constraints_object() {
let json = serde_json::json!({
"event_id": "e1",
"kind": "alert.fired",
"source": "grafana",
"emitted_at": "2026-04-15T12:00:00Z",
"requested_specialties": ["triage"],
"constraints": {},
"payload": {}
});
let ev: TriggerEvent = serde_json::from_value(json).unwrap();
assert_eq!(ev.constraints(), &TaskConstraints::default());
assert_eq!(ev.payload(), &Attributes::empty());
}
}