use serde::Deserialize;
use uuid::Uuid;
use super::event_error::{EventError, EventResult};
use crate::infrastructure::persistence::lead_command_repository::{
LeadCommandRepository, LeadPredicateRow, LeadRuleRow, PredicateInput,
};
pub const PREDICATE_AXES: [&str; 4] = ["event", "event_type", "company", "question_answer"];
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PredicateSpec {
pub axis: String,
pub question_id: Option<Uuid>,
pub value_ids: Vec<Uuid>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateRuleInput {
pub name: String,
pub event_id: Option<Uuid>,
pub basis: Option<String>,
pub on_create: Option<bool>,
pub on_confirm: Option<bool>,
pub on_done: Option<bool>,
pub predicates: Vec<PredicateSpec>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PatchRuleInput {
pub name: Option<String>,
pub event_id: Option<Uuid>,
pub active: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FromAnswerInput {
pub name: String,
pub event_id: Option<Uuid>,
pub on_create: Option<bool>,
pub on_confirm: Option<bool>,
pub on_done: Option<bool>,
pub question_id: Uuid,
pub answer_id: Uuid,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RelinkInput {
pub old_lead_id: Uuid,
pub new_lead_id: Uuid,
}
pub struct LeadRuleCommandService {
repo: LeadCommandRepository,
}
impl LeadRuleCommandService {
pub fn new(repo: LeadCommandRepository) -> Self {
Self { repo }
}
pub async fn create_rule(&self, input: CreateRuleInput, actor: Option<Uuid>) -> EventResult<LeadRuleRow> {
let name = input.name.trim();
if name.is_empty() {
return Err(EventError::Validation("rule name must not be empty".into()));
}
if input.predicates.is_empty() {
return Err(EventError::Validation(
"a rule carries at least one predicate — an unconstrained rule is refused".into(),
));
}
let on_create = input.on_create.unwrap_or(false);
let on_confirm = input.on_confirm.unwrap_or(false);
let on_done = input.on_done.unwrap_or(false);
if !(on_create || on_confirm || on_done) {
return Err(EventError::Validation(
"at least one trigger (on_create / on_confirm / on_done) must be armed".into(),
));
}
let basis = input.basis.unwrap_or_else(|| "registration".to_string());
if basis != "registration" && basis != "attendee" {
return Err(EventError::Validation(format!(
"basis must be registration or attendee (got {basis})"
)));
}
let predicates = validate_predicates(&input.predicates)?;
self.repo
.create_rule(name, input.event_id, &basis, on_create, on_confirm, on_done, &predicates, actor)
.await
}
pub async fn patch_rule(&self, rule_id: Uuid, input: PatchRuleInput, actor: Option<Uuid>) -> EventResult<LeadRuleRow> {
if let Some(name) = input.name.as_deref() {
if name.trim().is_empty() {
return Err(EventError::Validation("rule name must not be empty".into()));
}
}
self.repo
.patch_rule(rule_id, input.name.as_deref().map(str::trim), input.event_id, input.active, actor)
.await
}
pub async fn from_answer(&self, input: FromAnswerInput, actor: Option<Uuid>) -> EventResult<LeadRuleRow> {
let name = input.name.trim();
if name.is_empty() {
return Err(EventError::Validation("rule name must not be empty".into()));
}
let on_create = input.on_create.unwrap_or(false);
let on_confirm = input.on_confirm.unwrap_or(false);
let on_done = input.on_done.unwrap_or(false);
if !(on_create || on_confirm || on_done) {
return Err(EventError::Validation(
"at least one trigger (on_create / on_confirm / on_done) must be armed".into(),
));
}
self.repo
.bridge_answer_to_rule(name, input.event_id, on_create, on_confirm, on_done, input.question_id, input.answer_id, actor)
.await
}
pub async fn rule_read_model(&self, rule_id: Uuid) -> EventResult<serde_json::Value> {
self.repo.rule_read_model(rule_id).await
}
pub async fn find_rule(&self, rule_id: Uuid) -> EventResult<LeadRuleRow> {
self.repo.find_rule(rule_id).await
}
pub async fn list_rules(&self, limit: i64) -> EventResult<Vec<LeadRuleRow>> {
self.repo.list_rules(limit).await
}
pub async fn predicates_of_rule(&self, rule_id: Uuid) -> EventResult<Vec<LeadPredicateRow>> {
self.repo.predicates_of_rule(rule_id).await
}
pub async fn relink_lead(&self, input: RelinkInput, actor: Option<Uuid>) -> EventResult<usize> {
if input.old_lead_id == input.new_lead_id {
return Ok(0);
}
self.repo.relink_lead(input.old_lead_id, input.new_lead_id, actor).await
}
}
fn validate_predicates(specs: &[PredicateSpec]) -> Result<Vec<PredicateInput>, EventError> {
let mut out = Vec::with_capacity(specs.len());
for spec in specs {
if !PREDICATE_AXES.contains(&spec.axis.as_str()) {
return Err(EventError::Validation(format!(
"predicate axis '{}' is outside the closed vocabulary {PREDICATE_AXES:?}",
spec.axis
)));
}
if spec.value_ids.is_empty() {
return Err(EventError::Validation(format!(
"predicate axis '{}' carries no values",
spec.axis
)));
}
match spec.axis.as_str() {
"question_answer" => {
let question_id = spec.question_id.ok_or_else(|| {
EventError::Validation(
"the question_answer axis requires question_id (refused)".into(),
)
})?;
out.push(PredicateInput {
axis: spec.axis.clone(),
question_id: Some(question_id),
value_ids: spec.value_ids.clone(),
});
}
axis => {
if spec.question_id.is_some() {
return Err(EventError::Validation(format!(
"the {axis} axis carries no question_id (refused)"
)));
}
out.push(PredicateInput {
axis: spec.axis.clone(),
question_id: None,
value_ids: spec.value_ids.clone(),
});
}
}
}
Ok(out)
}