1299 lines
44 KiB
Rust
1299 lines
44 KiB
Rust
//! Bounded, deterministic expansion of typed workspace data and visual rule blocks.
|
|||
|
|
use crate::{
|
||
|
|
domain::*,
|
||
|
|
engine,
|
||
|
|
error::{Error, Result, ensure},
|
||
|
|
};
|
||
|
|
use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use serde_json::{Value, json};
|
||
|
|
use sha2::{Digest, Sha256};
|
||
|
|
use std::collections::{BTreeMap, BTreeSet};
|
||
|
|
use uuid::Uuid;
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Node {
|
||
|
|
pub id: Uuid,
|
||
|
|
#[serde(deserialize_with = "deserialize_node_kind")]
|
||
|
|
pub kind: String,
|
||
|
|
#[serde(default)]
|
||
|
|
pub key: String,
|
||
|
|
#[serde(default)]
|
||
|
|
pub value: Value,
|
||
|
|
#[serde(default)]
|
||
|
|
pub entries: Vec<Node>,
|
||
|
|
}
|
||
|
|
// Normalize legacy stored variables and history recursively without changing node IDs.
|
||
|
|
fn deserialize_node_kind<'de, D: serde::Deserializer<'de>>(
|
||
|
|
deserializer: D,
|
||
|
|
) -> std::result::Result<String, D::Error> {
|
||
|
|
let kind = String::deserialize(deserializer)?;
|
||
|
|
Ok(if kind == "record" {
|
||
|
|
"dictionary".into()
|
||
|
|
} else {
|
||
|
|
kind
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Variable {
|
||
|
|
pub id: Uuid,
|
||
|
|
pub name: String,
|
||
|
|
pub node: Node,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
|
|
pub struct Binding {
|
||
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
|
|
pub items: Vec<Binding>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub kind: String,
|
||
|
|
#[serde(default)]
|
||
|
|
pub value: Value,
|
||
|
|
#[serde(default)]
|
||
|
|
pub variable: Option<Uuid>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub iteration: Option<Uuid>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub key: Option<Box<Binding>>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub output: Option<Uuid>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub path: Vec<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub prefix: String,
|
||
|
|
#[serde(default)]
|
||
|
|
pub suffix: String,
|
||
|
|
}
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Iteration {
|
||
|
|
pub id: Uuid,
|
||
|
|
pub name: String,
|
||
|
|
pub source: Binding,
|
||
|
|
}
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Predicate {
|
||
|
|
pub left: Binding,
|
||
|
|
pub operation: String,
|
||
|
|
pub right: Binding,
|
||
|
|
}
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Step {
|
||
|
|
pub delay: Binding,
|
||
|
|
pub recipient: Binding,
|
||
|
|
#[serde(default)]
|
||
|
|
pub user: bool,
|
||
|
|
#[serde(default)]
|
||
|
|
pub condition: String,
|
||
|
|
}
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Condition {
|
||
|
|
pub field: String,
|
||
|
|
pub operation: String,
|
||
|
|
pub value: Binding,
|
||
|
|
#[serde(default)]
|
||
|
|
pub negate: bool,
|
||
|
|
}
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
#[serde(from = "BlockWire")]
|
||
|
|
pub struct Block {
|
||
|
|
pub id: Uuid,
|
||
|
|
pub kind: String,
|
||
|
|
pub fields: BTreeMap<String, Binding>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub steps: Vec<Step>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub conditions: Vec<Condition>,
|
||
|
|
}
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct BlockWire {
|
||
|
|
pub id: Uuid,
|
||
|
|
pub kind: String,
|
||
|
|
pub fields: BTreeMap<String, Binding>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub steps: Vec<Step>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub conditions: Vec<Condition>,
|
||
|
|
}
|
||
|
|
impl From<BlockWire> for Block {
|
||
|
|
fn from(mut value: BlockWire) -> Self {
|
||
|
|
if value.kind == "coverage" {
|
||
|
|
value.kind = "priority".into();
|
||
|
|
let items = ["primary", "secondary"]
|
||
|
|
.into_iter()
|
||
|
|
.map(|key| value.fields.remove(key).unwrap_or_default())
|
||
|
|
.collect();
|
||
|
|
value.fields.insert(
|
||
|
|
"participants".into(),
|
||
|
|
Binding {
|
||
|
|
kind: "list".into(),
|
||
|
|
items,
|
||
|
|
..Default::default()
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
Self {
|
||
|
|
id: value.id,
|
||
|
|
kind: value.kind,
|
||
|
|
fields: value.fields,
|
||
|
|
steps: value.steps,
|
||
|
|
conditions: value.conditions,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Rule {
|
||
|
|
pub id: Uuid,
|
||
|
|
pub name: String,
|
||
|
|
pub enabled: bool,
|
||
|
|
#[serde(default)]
|
||
|
|
pub iterations: Vec<Iteration>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub when: Option<Predicate>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub blocks: Vec<Block>,
|
||
|
|
}
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Managed {
|
||
|
|
pub id: Uuid,
|
||
|
|
pub kind: String,
|
||
|
|
pub rule: Uuid,
|
||
|
|
pub block: Uuid,
|
||
|
|
pub item: String,
|
||
|
|
}
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
|
|
pub struct Generation {
|
||
|
|
#[serde(default)]
|
||
|
|
pub rules: Vec<Rule>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub variables: Vec<Variable>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub route_position: usize,
|
||
|
|
#[serde(default)]
|
||
|
|
pub managed: Vec<Managed>,
|
||
|
|
}
|
||
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
|
|
pub struct Issue {
|
||
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
|
|
pub rule_id: Option<Uuid>,
|
||
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
|
|
pub block_id: Option<Uuid>,
|
||
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
|
|
pub variable_id: Option<Uuid>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub secondary: bool,
|
||
|
|
pub path: String,
|
||
|
|
pub message: String,
|
||
|
|
}
|
||
|
|
#[derive(Debug, Serialize)]
|
||
|
|
pub struct Change {
|
||
|
|
pub kind: String,
|
||
|
|
pub id: Uuid,
|
||
|
|
pub name: String,
|
||
|
|
pub action: String,
|
||
|
|
pub source: String,
|
||
|
|
}
|
||
|
|
pub struct Expansion {
|
||
|
|
pub trace: Vec<Value>,
|
||
|
|
pub document: WorkspaceDocument,
|
||
|
|
pub changes: Vec<Change>,
|
||
|
|
pub issues: Vec<Issue>,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn stable_id(parts: &Value) -> Uuid {
|
||
|
|
let digest = Sha256::digest(parts.to_string().as_bytes());
|
||
|
|
let mut bytes = [0; 16];
|
||
|
|
bytes.copy_from_slice(&digest[..16]);
|
||
|
|
bytes[6] = (bytes[6] & 15) | 128;
|
||
|
|
bytes[8] = (bytes[8] & 63) | 128;
|
||
|
|
Uuid::from_bytes(bytes)
|
||
|
|
}
|
||
|
|
fn problem(path: impl Into<String>, message: impl Into<String>) -> Issue {
|
||
|
|
Issue {
|
||
|
|
path: path.into(),
|
||
|
|
message: message.into(),
|
||
|
|
..Issue::default()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
fn validate_node(
|
||
|
|
node: &Node,
|
||
|
|
path: &str,
|
||
|
|
depth: usize,
|
||
|
|
seen: &mut BTreeSet<Uuid>,
|
||
|
|
issues: &mut Vec<Issue>,
|
||
|
|
) {
|
||
|
|
if depth > 8 || seen.len() >= 10000 {
|
||
|
|
issues.push(problem(
|
||
|
|
path,
|
||
|
|
crate::i18n::text("err_data_is_limited_to_8_nested_levels_and_10_000_va_fa5d71"),
|
||
|
|
));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if !seen.insert(node.id) {
|
||
|
|
issues.push(problem(
|
||
|
|
path,
|
||
|
|
crate::i18n::text("err_data_entry_ids_must_be_unique_4725be"),
|
||
|
|
));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if matches!(node.kind.as_str(), "list" | "dictionary") {
|
||
|
|
if node.entries.len() > 1000 {
|
||
|
|
issues.push(problem(
|
||
|
|
path,
|
||
|
|
crate::i18n::text("err_a_collection_supports_at_most_1_000_entries"),
|
||
|
|
));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let mut keys = BTreeSet::new();
|
||
|
|
for (index, entry) in node.entries.iter().enumerate() {
|
||
|
|
let key = if node.kind == "list" {
|
||
|
|
(index + 1).to_string()
|
||
|
|
} else {
|
||
|
|
entry.key.clone()
|
||
|
|
};
|
||
|
|
if node.kind != "list"
|
||
|
|
&& (key.trim().is_empty() || key.len() > 120 || !keys.insert(key.clone()))
|
||
|
|
{
|
||
|
|
issues.push(problem(
|
||
|
|
format!("{path} / {key}"),
|
||
|
|
crate::i18n::text("err_dictionary_keys_must_be_nonempty_and_unique_4e8ce4"),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
validate_node(entry, &format!("{path} / {key}"), depth + 1, seen, issues);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let valid = match node.kind.as_str() {
|
||
|
|
"text" => node.value.as_str().is_some_and(|s| s.len() <= 2000),
|
||
|
|
"person" => node.value.as_str().is_some_and(|s| {
|
||
|
|
Uuid::parse_str(s).is_ok() || email_address::EmailAddress::is_valid(s)
|
||
|
|
}),
|
||
|
|
"number" | "duration" => node
|
||
|
|
.value
|
||
|
|
.as_f64()
|
||
|
|
.is_some_and(|n| n.is_finite() && (node.kind != "duration" || n >= 0.0)),
|
||
|
|
"boolean" => node.value.is_boolean(),
|
||
|
|
"date" => node
|
||
|
|
.value
|
||
|
|
.as_str()
|
||
|
|
.is_some_and(|s| s.parse::<NaiveDate>().is_ok()),
|
||
|
|
"time" => node.value.as_str().is_some_and(|s| {
|
||
|
|
NaiveTime::parse_from_str(s, "%H:%M")
|
||
|
|
.or_else(|_| s.parse())
|
||
|
|
.is_ok()
|
||
|
|
}),
|
||
|
|
_ => false,
|
||
|
|
};
|
||
|
|
if !valid || !node.entries.is_empty() {
|
||
|
|
issues.push(problem(
|
||
|
|
path,
|
||
|
|
crate::i18n::message(
|
||
|
|
"err_enter_a_valid_p0_value_3b830b",
|
||
|
|
&[("p0", (node.kind).to_string())],
|
||
|
|
),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
pub fn validate_variables(variables: &[Variable]) -> Vec<Issue> {
|
||
|
|
let mut issues = vec![];
|
||
|
|
let mut names = BTreeSet::new();
|
||
|
|
let mut ids = BTreeSet::new();
|
||
|
|
let mut nodes = BTreeSet::new();
|
||
|
|
if variables.len() > 100 {
|
||
|
|
return vec![problem(
|
||
|
|
"Variables",
|
||
|
|
crate::i18n::text("err_use_at_most_100_workspace_variables_c7d391"),
|
||
|
|
)];
|
||
|
|
}
|
||
|
|
for v in variables {
|
||
|
|
let issue_start = issues.len();
|
||
|
|
let path = crate::i18n::message("err_variable_p0_973058", &[("p0", (v.name).to_string())]);
|
||
|
|
if v.name.trim().is_empty()
|
||
|
|
|| v.name.len() > 80
|
||
|
|
|| !names.insert(v.name.trim().to_lowercase())
|
||
|
|
|| !ids.insert(v.id)
|
||
|
|
{
|
||
|
|
issues.push(problem(
|
||
|
|
&path,
|
||
|
|
crate::i18n::text("err_choose_a_unique_variable_name_and_id_ee4fc3"),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
validate_node(&v.node, &path, 0, &mut nodes, &mut issues);
|
||
|
|
for issue in &mut issues[issue_start..] {
|
||
|
|
issue.variable_id = Some(v.id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
issues
|
||
|
|
}
|
||
|
|
impl Node {
|
||
|
|
fn json(&self) -> Value {
|
||
|
|
match self.kind.as_str() {
|
||
|
|
"list" => Value::Array(self.entries.iter().map(Node::json).collect()),
|
||
|
|
"dictionary" => Value::Object(
|
||
|
|
self.entries
|
||
|
|
.iter()
|
||
|
|
.map(|e| (e.key.clone(), e.json()))
|
||
|
|
.collect(),
|
||
|
|
),
|
||
|
|
_ => self.value.clone(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
fn literal(value: Value) -> Self {
|
||
|
|
let id = stable_id(&json!(["literal", value]));
|
||
|
|
let (kind, entries) = match &value {
|
||
|
|
Value::Array(values) => (
|
||
|
|
"list",
|
||
|
|
values.iter().map(|v| Self::literal(v.clone())).collect(),
|
||
|
|
),
|
||
|
|
Value::Object(values) => (
|
||
|
|
"dictionary",
|
||
|
|
values
|
||
|
|
.iter()
|
||
|
|
.map(|(key, v)| {
|
||
|
|
let mut n = Self::literal(v.clone());
|
||
|
|
n.key = key.clone();
|
||
|
|
n
|
||
|
|
})
|
||
|
|
.collect(),
|
||
|
|
),
|
||
|
|
_ => ("literal", vec![]),
|
||
|
|
};
|
||
|
|
Self {
|
||
|
|
id,
|
||
|
|
kind: kind.into(),
|
||
|
|
key: String::new(),
|
||
|
|
value,
|
||
|
|
entries,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
#[derive(Clone, Default)]
|
||
|
|
struct Context {
|
||
|
|
items: BTreeMap<Uuid, Node>,
|
||
|
|
keys: Vec<Uuid>,
|
||
|
|
labels: Vec<String>,
|
||
|
|
}
|
||
|
|
struct Resolver<'a> {
|
||
|
|
variables: &'a [Variable],
|
||
|
|
context: &'a Context,
|
||
|
|
outputs: &'a BTreeMap<Uuid, Uuid>,
|
||
|
|
}
|
||
|
|
impl Resolver<'_> {
|
||
|
|
fn eval(&self, b: &Binding, depth: usize) -> Result<Node> {
|
||
|
|
ensure(
|
||
|
|
depth <= 16,
|
||
|
|
crate::i18n::text("err_bindings_can_be_nested_at_most_16_levels_b78618"),
|
||
|
|
)?;
|
||
|
|
let var = || {
|
||
|
|
self.variables
|
||
|
|
.iter()
|
||
|
|
.find(|v| Some(v.id) == b.variable)
|
||
|
|
.map(|v| v.node.clone())
|
||
|
|
.ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_choose_an_existing_workspace_variable_db6738")
|
||
|
|
.into(),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
};
|
||
|
|
let mut node = match b.kind.as_str() {
|
||
|
|
"literal" => Node::literal(b.value.clone()),
|
||
|
|
"list" => Node::literal(Value::Array(
|
||
|
|
b.items
|
||
|
|
.iter()
|
||
|
|
.map(|item| self.value(item))
|
||
|
|
.collect::<Result<Vec<_>>>()?,
|
||
|
|
)),
|
||
|
|
"variable" => var()?,
|
||
|
|
"item" => self
|
||
|
|
.context
|
||
|
|
.items
|
||
|
|
.get(&b.iteration.unwrap_or_default())
|
||
|
|
.cloned()
|
||
|
|
.ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text(
|
||
|
|
"err_choose_an_earlier_iteration_an_item_is_available_4aef3d",
|
||
|
|
)
|
||
|
|
.into(),
|
||
|
|
)
|
||
|
|
})?,
|
||
|
|
"key" => Node::literal(json!(
|
||
|
|
self.context
|
||
|
|
.items
|
||
|
|
.get(&b.iteration.unwrap_or_default())
|
||
|
|
.ok_or_else(|| Error::Validation(
|
||
|
|
crate::i18n::text("err_choose_an_earlier_iteration_3562e3").into()
|
||
|
|
))?
|
||
|
|
.key
|
||
|
|
)),
|
||
|
|
"lookup" => {
|
||
|
|
let dictionary = var()?;
|
||
|
|
ensure(
|
||
|
|
matches!(dictionary.kind.as_str(), "dictionary"),
|
||
|
|
crate::i18n::text("err_dictionary_lookup_needs_a_dictionary_variable_867cbc"),
|
||
|
|
)?;
|
||
|
|
let key = self
|
||
|
|
.eval(
|
||
|
|
b.key.as_ref().ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_choose_the_dictionary_lookup_key_66d49d")
|
||
|
|
.into(),
|
||
|
|
)
|
||
|
|
})?,
|
||
|
|
depth + 1,
|
||
|
|
)?
|
||
|
|
.json();
|
||
|
|
let key = key.as_str().ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_dictionary_keys_must_be_text_8bcea8").into(),
|
||
|
|
)
|
||
|
|
})?;
|
||
|
|
dictionary
|
||
|
|
.entries
|
||
|
|
.iter()
|
||
|
|
.find(|n| n.key == key)
|
||
|
|
.cloned()
|
||
|
|
.ok_or_else(|| {
|
||
|
|
Error::Validation(crate::i18n::message(
|
||
|
|
"err_no_responsible_value_entry_for_key_key_06146d",
|
||
|
|
&[("key", (key).to_string())],
|
||
|
|
))
|
||
|
|
})?
|
||
|
|
}
|
||
|
|
"output" => Node::literal(json!(
|
||
|
|
self.outputs.get(&b.output.unwrap_or_default()).ok_or_else(
|
||
|
|
|| Error::Validation(
|
||
|
|
crate::i18n::text(
|
||
|
|
"err_choose_an_output_block_in_this_rule_or_a_shared_0758e5"
|
||
|
|
)
|
||
|
|
.into()
|
||
|
|
)
|
||
|
|
)?
|
||
|
|
)),
|
||
|
|
_ => {
|
||
|
|
return Err(Error::Validation(
|
||
|
|
crate::i18n::text("err_choose_a_value_source_00b9ad").into(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
for key in &b.path {
|
||
|
|
node = if node.kind == "list" {
|
||
|
|
key.parse::<usize>()
|
||
|
|
.ok()
|
||
|
|
.and_then(|i| i.checked_sub(1))
|
||
|
|
.and_then(|i| node.entries.get(i))
|
||
|
|
.cloned()
|
||
|
|
} else {
|
||
|
|
node.entries.iter().find(|e| e.key == *key).cloned()
|
||
|
|
}
|
||
|
|
.ok_or_else(|| {
|
||
|
|
Error::Validation(crate::i18n::message(
|
||
|
|
"err_field_or_list_position_key_does_not_exist_4a5e94",
|
||
|
|
&[("key", (key).to_string())],
|
||
|
|
))
|
||
|
|
})?;
|
||
|
|
}
|
||
|
|
if !b.prefix.is_empty() || !b.suffix.is_empty() {
|
||
|
|
let value = node.json();
|
||
|
|
let text = value.as_str().ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_name_prefixes_suffixes_need_a_text_value_96730e").into(),
|
||
|
|
)
|
||
|
|
})?;
|
||
|
|
node = Node::literal(json!(format!("{}{}{}", b.prefix, text, b.suffix)));
|
||
|
|
}
|
||
|
|
Ok(node)
|
||
|
|
}
|
||
|
|
fn value(&self, b: &Binding) -> Result<Value> {
|
||
|
|
Ok(self.eval(b, 0)?.json())
|
||
|
|
}
|
||
|
|
fn matches(&self, p: &Predicate) -> Result<bool> {
|
||
|
|
let left = self.value(&p.left)?;
|
||
|
|
if p.operation == "not_empty" {
|
||
|
|
return Ok(!left.is_null()
|
||
|
|
&& left.as_str().is_none_or(|s| !s.is_empty())
|
||
|
|
&& left.as_array().is_none_or(|a| !a.is_empty())
|
||
|
|
&& left.as_object().is_none_or(|o| !o.is_empty()));
|
||
|
|
}
|
||
|
|
let right = self.value(&p.right)?;
|
||
|
|
match p.operation.as_str() {
|
||
|
|
"equals" | "not_equals" => {
|
||
|
|
ensure(
|
||
|
|
std::mem::discriminant(&left) == std::mem::discriminant(&right),
|
||
|
|
crate::i18n::text("err_condition_values_must_have_the_same_type_e949f9"),
|
||
|
|
)?;
|
||
|
|
Ok((left == right) == (p.operation == "equals"))
|
||
|
|
}
|
||
|
|
"contains" => {
|
||
|
|
if let Some(items) = left.as_array() {
|
||
|
|
Ok(items.contains(&right))
|
||
|
|
} else if let Some((a, b)) = left.as_str().zip(right.as_str()) {
|
||
|
|
Ok(a.contains(b))
|
||
|
|
} else {
|
||
|
|
Err(Error::Validation(
|
||
|
|
crate::i18n::text(
|
||
|
|
"err_contains_needs_a_list_and_a_value_or_two_text_va_b878ec",
|
||
|
|
)
|
||
|
|
.into(),
|
||
|
|
))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_ => Err(Error::Validation(
|
||
|
|
crate::i18n::text("err_choose_a_supported_condition_f7daba").into(),
|
||
|
|
)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn value(fields: &BTreeMap<String, Binding>, key: &str, resolver: &Resolver<'_>) -> Result<Value> {
|
||
|
|
resolver
|
||
|
|
.value(fields.get(key).ok_or_else(|| {
|
||
|
|
Error::Validation(crate::i18n::message(
|
||
|
|
"err_set_key_a7389f",
|
||
|
|
&[("key", (key).to_string())],
|
||
|
|
))
|
||
|
|
})?)
|
||
|
|
.map_err(|e| Error::Validation(format!("{key}: {e}")))
|
||
|
|
}
|
||
|
|
fn text(fields: &BTreeMap<String, Binding>, key: &str, resolver: &Resolver<'_>) -> Result<String> {
|
||
|
|
value(fields, key, resolver)?
|
||
|
|
.as_str()
|
||
|
|
.map(str::to_owned)
|
||
|
|
.ok_or_else(|| {
|
||
|
|
Error::Validation(crate::i18n::message(
|
||
|
|
"err__key_must_be_text_4fd030",
|
||
|
|
&[("key", (key).to_string())],
|
||
|
|
))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
fn number(
|
||
|
|
fields: &BTreeMap<String, Binding>,
|
||
|
|
key: &str,
|
||
|
|
resolver: &Resolver<'_>,
|
||
|
|
default: u64,
|
||
|
|
) -> Result<u64> {
|
||
|
|
if !fields.contains_key(key) {
|
||
|
|
return Ok(default);
|
||
|
|
}
|
||
|
|
value(fields, key, resolver)?.as_u64().ok_or_else(|| {
|
||
|
|
Error::Validation(crate::i18n::message(
|
||
|
|
"err__key_must_be_a_nonnegative_whole_number_2fb848",
|
||
|
|
&[("key", (key).to_string())],
|
||
|
|
))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
fn recipient(
|
||
|
|
value: &Value,
|
||
|
|
workspace: Uuid,
|
||
|
|
doc: &mut WorkspaceDocument,
|
||
|
|
members: &BTreeSet<Uuid>,
|
||
|
|
) -> Result<Uuid> {
|
||
|
|
let value = value.as_str().ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_choose_a_person_or_enter_an_email_address_a24e0a").into(),
|
||
|
|
)
|
||
|
|
})?;
|
||
|
|
if let Ok(id) = Uuid::parse_str(value) {
|
||
|
|
ensure(
|
||
|
|
members.contains(&id) || doc.person(id).is_some(),
|
||
|
|
crate::i18n::text("err_person_is_not_in_this_workspace_or_team_director_c6d509"),
|
||
|
|
)?;
|
||
|
|
return Ok(id);
|
||
|
|
}
|
||
|
|
let email = value.trim().to_lowercase();
|
||
|
|
ensure(
|
||
|
|
email_address::EmailAddress::is_valid(&email),
|
||
|
|
crate::i18n::message(
|
||
|
|
"err__value_is_not_an_email_address_a7e20a",
|
||
|
|
&[("value", (value).to_string())],
|
||
|
|
),
|
||
|
|
)?;
|
||
|
|
if let Some(person) = doc
|
||
|
|
.people
|
||
|
|
.iter()
|
||
|
|
.find(|p| p.email.as_deref() == Some(email.as_str()))
|
||
|
|
{
|
||
|
|
return Ok(person.id);
|
||
|
|
}
|
||
|
|
let id = stable_id(&json!(["generation-contact", workspace, email]));
|
||
|
|
doc.people.push(Person {
|
||
|
|
id,
|
||
|
|
user_id: None,
|
||
|
|
display_name: email.clone(),
|
||
|
|
email: Some(email),
|
||
|
|
country: Some("CY".into()),
|
||
|
|
});
|
||
|
|
Ok(id)
|
||
|
|
}
|
||
|
|
fn id_value(v: Value) -> Result<Uuid> {
|
||
|
|
v.as_str().and_then(|s| s.parse().ok()).ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_choose_a_schedule_or_policy_reference_f55953").into(),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn render(
|
||
|
|
block: &Block,
|
||
|
|
id: Uuid,
|
||
|
|
workspace: Uuid,
|
||
|
|
resolver: &Resolver<'_>,
|
||
|
|
doc: &mut WorkspaceDocument,
|
||
|
|
members: &BTreeSet<Uuid>,
|
||
|
|
) -> Result<()> {
|
||
|
|
let f = &block.fields;
|
||
|
|
let name = text(f, "name", resolver)?;
|
||
|
|
ensure(
|
||
|
|
!name.trim().is_empty() && name.len() <= 120,
|
||
|
|
crate::i18n::text("err_name_needs_1_120_characters_408c00"),
|
||
|
|
)?;
|
||
|
|
if ["rotation", "priority"].contains(&block.kind.as_str()) {
|
||
|
|
let handover = if f.contains_key("handover") {
|
||
|
|
let time = text(f, "handover", resolver)?;
|
||
|
|
NaiveTime::parse_from_str(&time, "%H:%M")
|
||
|
|
.or_else(|_| time.parse())
|
||
|
|
.map_err(|_| {
|
||
|
|
Error::Validation(crate::i18n::text("err_handover_use_hh_mm_25d33e").into())
|
||
|
|
})?
|
||
|
|
} else {
|
||
|
|
default_handover()
|
||
|
|
};
|
||
|
|
let kind = match block.kind.as_str() {
|
||
|
|
"rotation" | "priority" => {
|
||
|
|
let list = value(f, "participants", resolver)?;
|
||
|
|
let list = list.as_array().ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_participants_choose_a_list_of_people_emails_d13655")
|
||
|
|
.into(),
|
||
|
|
)
|
||
|
|
})?;
|
||
|
|
ensure(
|
||
|
|
!list.is_empty() && list.len() <= 100,
|
||
|
|
crate::i18n::text("err_participants_choose_1_100_people_45308a"),
|
||
|
|
)?;
|
||
|
|
let participants = list
|
||
|
|
.iter()
|
||
|
|
.map(|v| recipient(v, workspace, doc, members))
|
||
|
|
.collect::<Result<Vec<_>>>()?;
|
||
|
|
ensure(
|
||
|
|
participants.iter().collect::<BTreeSet<_>>().len() == participants.len(),
|
||
|
|
"participants: a person occurs more than once",
|
||
|
|
)?;
|
||
|
|
if block.kind == "priority" {
|
||
|
|
ScheduleKind::Priority {
|
||
|
|
participants,
|
||
|
|
fallback: id_value(value(f, "fallback", resolver)?)?,
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
let anchor = text(f, "anchor", resolver)?.parse().map_err(|_| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_anchor_choose_a_valid_date_497062").into(),
|
||
|
|
)
|
||
|
|
})?;
|
||
|
|
let days = number(f, "shift_days", resolver, 7)?;
|
||
|
|
ensure(
|
||
|
|
(1..=366).contains(&days),
|
||
|
|
crate::i18n::text("err_shift_days_use_1_366_days_801e00"),
|
||
|
|
)?;
|
||
|
|
ScheduleKind::Rotation {
|
||
|
|
participants,
|
||
|
|
anchor,
|
||
|
|
shift_days: days as u16,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_ => unreachable!(),
|
||
|
|
};
|
||
|
|
doc.schedules.push(Schedule {
|
||
|
|
id,
|
||
|
|
name,
|
||
|
|
handover,
|
||
|
|
kind,
|
||
|
|
});
|
||
|
|
} else if block.kind == "escalation" {
|
||
|
|
ensure(
|
||
|
|
!block.steps.is_empty() && block.steps.len() <= 20,
|
||
|
|
crate::i18n::text("err_an_escalation_needs_1_20_steps_644fbf"),
|
||
|
|
)?;
|
||
|
|
let mut steps = vec![];
|
||
|
|
for (i, step) in block.steps.iter().enumerate() {
|
||
|
|
let delay = resolver
|
||
|
|
.value(&step.delay)?
|
||
|
|
.as_u64()
|
||
|
|
.filter(|n| *n <= 10080)
|
||
|
|
.ok_or_else(|| {
|
||
|
|
Error::Validation(crate::i18n::message(
|
||
|
|
"err_step_p0_delay_must_be_0_10080_minutes_b384d0",
|
||
|
|
&[("p0", (i + 1).to_string())],
|
||
|
|
))
|
||
|
|
})?;
|
||
|
|
let target = resolver.value(&step.recipient)?;
|
||
|
|
steps.push(EscalationStep {
|
||
|
|
delay_minutes: delay as u16,
|
||
|
|
condition: if step.condition == "if-not-closed" {
|
||
|
|
EscalationCondition::IfNotClosed
|
||
|
|
} else {
|
||
|
|
EscalationCondition::IfNotAcked
|
||
|
|
},
|
||
|
|
recipient: if step.user {
|
||
|
|
Recipient::User {
|
||
|
|
id: recipient(&target, workspace, doc, members)?,
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Recipient::Schedule {
|
||
|
|
id: id_value(target)?,
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
let count = number(f, "repeat_count", resolver, 0)?;
|
||
|
|
let wait = number(f, "repeat_wait", resolver, 0)?;
|
||
|
|
ensure(
|
||
|
|
count <= 20 && wait <= 10080,
|
||
|
|
crate::i18n::text("err_escalation_repeat_is_limited_to_20_repeats_10080_9b06f3"),
|
||
|
|
)?;
|
||
|
|
doc.escalations.push(Escalation {
|
||
|
|
id,
|
||
|
|
name,
|
||
|
|
steps,
|
||
|
|
repeat: EscalationRepeat {
|
||
|
|
count: count as u8,
|
||
|
|
wait_minutes: wait as u16,
|
||
|
|
..Default::default()
|
||
|
|
},
|
||
|
|
});
|
||
|
|
} else if block.kind == "route" {
|
||
|
|
let mut conditions = vec![];
|
||
|
|
for c in &block.conditions {
|
||
|
|
let raw = resolver.value(&c.value)?;
|
||
|
|
conditions.push(RouteCondition {
|
||
|
|
field: c.field.clone(),
|
||
|
|
operation: c.operation.clone(),
|
||
|
|
negate: c.negate,
|
||
|
|
value: raw
|
||
|
|
.as_str()
|
||
|
|
.ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_routing_condition_value_must_be_text_233f0e")
|
||
|
|
.into(),
|
||
|
|
)
|
||
|
|
})?
|
||
|
|
.into(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
let policy = if f.contains_key("escalation") {
|
||
|
|
Some(id_value(value(f, "escalation", resolver)?)?)
|
||
|
|
} else {
|
||
|
|
None
|
||
|
|
};
|
||
|
|
let working = if f.contains_key("working") {
|
||
|
|
value(f, "working", resolver)?.as_bool().ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_working_must_be_true_or_false_10625c").into(),
|
||
|
|
)
|
||
|
|
})?
|
||
|
|
} else {
|
||
|
|
false
|
||
|
|
};
|
||
|
|
doc.routes.push(RoutingRule {
|
||
|
|
id,
|
||
|
|
name,
|
||
|
|
order: 0,
|
||
|
|
conditions,
|
||
|
|
escalation_id: policy,
|
||
|
|
window: working.then(WorkingWindow::default),
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
return Err(Error::Validation(
|
||
|
|
crate::i18n::text("err_choose_an_output_type_4ab1b5").into(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[allow(clippy::too_many_arguments)]
|
||
|
|
pub fn expand(
|
||
|
|
doc: &WorkspaceDocument,
|
||
|
|
workspace: Uuid,
|
||
|
|
variables: &[Variable],
|
||
|
|
rules: &[Rule],
|
||
|
|
route_position: usize,
|
||
|
|
interval: &WorkingWindow,
|
||
|
|
members: &BTreeSet<Uuid>,
|
||
|
|
now: DateTime<Utc>,
|
||
|
|
) -> Expansion {
|
||
|
|
let mut trace = vec![];
|
||
|
|
let mut issues = validate_variables(variables);
|
||
|
|
if rules.len() > 50 {
|
||
|
|
issues.push(problem(
|
||
|
|
"Rules",
|
||
|
|
crate::i18n::text("err_use_at_most_50_generation_rules_abf459"),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
let mut output = doc.clone();
|
||
|
|
let old_ids: BTreeSet<_> = doc.generation.managed.iter().map(|m| m.id).collect();
|
||
|
|
output.schedules.retain(|s| !old_ids.contains(&s.id));
|
||
|
|
output.escalations.retain(|s| !old_ids.contains(&s.id));
|
||
|
|
output.routes.retain(|s| !old_ids.contains(&s.id));
|
||
|
|
let manual_routes = output.routes.clone();
|
||
|
|
output.routes.clear();
|
||
|
|
let manual_ids: BTreeSet<_> = output
|
||
|
|
.schedules
|
||
|
|
.iter()
|
||
|
|
.map(|s| s.id)
|
||
|
|
.chain(output.escalations.iter().map(|s| s.id))
|
||
|
|
.chain(manual_routes.iter().map(|s| s.id))
|
||
|
|
.collect();
|
||
|
|
let mut managed = vec![];
|
||
|
|
let mut block_ids = BTreeSet::new();
|
||
|
|
let mut rule_ids = BTreeSet::new();
|
||
|
|
if !issues.is_empty() {
|
||
|
|
return Expansion {
|
||
|
|
trace,
|
||
|
|
document: doc.clone(),
|
||
|
|
changes: vec![],
|
||
|
|
issues,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
// Singleton outputs are shared across rules; iterated outputs stay local to their row.
|
||
|
|
let shared: BTreeMap<_, _> = rules
|
||
|
|
.iter()
|
||
|
|
.filter(|r| r.enabled && r.iterations.is_empty())
|
||
|
|
.flat_map(|r| {
|
||
|
|
r.blocks.iter().map(move |b| {
|
||
|
|
(
|
||
|
|
b.id,
|
||
|
|
stable_id(&json!(["generated", workspace, r.id, [], b.id])),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
for rule in rules {
|
||
|
|
trace.push(json!({"rule":rule.id,"enabled":rule.enabled,"stages":[],"rows":[]}));
|
||
|
|
let issue_start = issues.len();
|
||
|
|
let path = crate::i18n::message("err_rule_p0_eb6431", &[("p0", (rule.name).to_string())]);
|
||
|
|
if !rule_ids.insert(rule.id)
|
||
|
|
|| rule.name.trim().is_empty()
|
||
|
|
|| rule.name.len() > 120
|
||
|
|
|| rule.iterations.len() > 4
|
||
|
|
|| rule.blocks.len() > 20
|
||
|
|
{
|
||
|
|
issues.push(problem(
|
||
|
|
&path,
|
||
|
|
crate::i18n::text("err_use_a_unique_rule_id_name_at_most_4_iterations_a_c413b3"),
|
||
|
|
));
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if !rule.enabled {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let mut contexts = vec![Context::default()];
|
||
|
|
let mut loop_ids = BTreeSet::new();
|
||
|
|
for iteration in &rule.iterations {
|
||
|
|
if !loop_ids.insert(iteration.id) {
|
||
|
|
issues.push(problem(
|
||
|
|
&path,
|
||
|
|
crate::i18n::text("err_iteration_ids_must_be_unique_bfb5cc"),
|
||
|
|
));
|
||
|
|
contexts.clear();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
let mut next = vec![];
|
||
|
|
for context in contexts {
|
||
|
|
let empty = BTreeMap::new();
|
||
|
|
let resolver = Resolver {
|
||
|
|
variables,
|
||
|
|
context: &context,
|
||
|
|
outputs: &empty,
|
||
|
|
};
|
||
|
|
match resolver.eval(&iteration.source, 0) {
|
||
|
|
Ok(node) if matches!(node.kind.as_str(), "list" | "dictionary") => {
|
||
|
|
let mut values = BTreeSet::new();
|
||
|
|
for entry in node.entries {
|
||
|
|
let label = if node.kind == "list" {
|
||
|
|
entry
|
||
|
|
.json()
|
||
|
|
.as_str()
|
||
|
|
.map(str::to_owned)
|
||
|
|
.unwrap_or_else(|| entry.id.to_string())
|
||
|
|
} else {
|
||
|
|
entry.key.clone()
|
||
|
|
};
|
||
|
|
if !values.insert(label.clone()) {
|
||
|
|
issues.push(problem(
|
||
|
|
&path,
|
||
|
|
crate::i18n::message(
|
||
|
|
"err_iteration_p0_contains_duplicate_value_label_1743fe",
|
||
|
|
&[
|
||
|
|
("p0", (iteration.name).to_string()),
|
||
|
|
("label", (label).to_string()),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
));
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let mut child = context.clone();
|
||
|
|
child.keys.push(entry.id);
|
||
|
|
child.labels.push(label.clone());
|
||
|
|
let mut entry = entry;
|
||
|
|
if node.kind == "list" {
|
||
|
|
entry.key = label;
|
||
|
|
}
|
||
|
|
child.items.insert(iteration.id, entry);
|
||
|
|
next.push(child);
|
||
|
|
if next.len() > 500 {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(_) => issues.push(problem(
|
||
|
|
format!("{path} / {}", iteration.name),
|
||
|
|
crate::i18n::text("err_for_each_needs_a_list_or_dictionary_c87f69"),
|
||
|
|
)),
|
||
|
|
Err(e) => issues.push(problem(
|
||
|
|
format!(
|
||
|
|
"{path} / {} / {}",
|
||
|
|
iteration.name,
|
||
|
|
context.labels.join(" / ")
|
||
|
|
),
|
||
|
|
e.to_string(),
|
||
|
|
)),
|
||
|
|
}
|
||
|
|
if next.len() > 500 {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if next.len() > 500 {
|
||
|
|
issues.push(problem(
|
||
|
|
&path,
|
||
|
|
crate::i18n::text(
|
||
|
|
"err_expansion_is_limited_to_500_iteration_combinatio_81a6ef",
|
||
|
|
),
|
||
|
|
));
|
||
|
|
contexts = vec![];
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
trace.last_mut().unwrap()["stages"]
|
||
|
|
.as_array_mut()
|
||
|
|
.unwrap()
|
||
|
|
.push(json!({"iteration":iteration.id,"count":next.len()}));
|
||
|
|
contexts = next;
|
||
|
|
}
|
||
|
|
for block in &rule.blocks {
|
||
|
|
if !block_ids.insert(block.id) {
|
||
|
|
issues.push(problem(
|
||
|
|
&path,
|
||
|
|
crate::i18n::text("err_output_block_ids_must_be_unique_across_rules_1efed6"),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for context in contexts {
|
||
|
|
let mut ids = shared.clone();
|
||
|
|
ids.extend(rule.blocks.iter().map(|b| {
|
||
|
|
(
|
||
|
|
b.id,
|
||
|
|
stable_id(&json!([
|
||
|
|
"generated",
|
||
|
|
workspace,
|
||
|
|
rule.id,
|
||
|
|
context.keys,
|
||
|
|
b.id
|
||
|
|
])),
|
||
|
|
)
|
||
|
|
}));
|
||
|
|
let resolver = Resolver {
|
||
|
|
variables,
|
||
|
|
context: &context,
|
||
|
|
outputs: &ids,
|
||
|
|
};
|
||
|
|
let row_path = if context.labels.is_empty() {
|
||
|
|
path.clone()
|
||
|
|
} else {
|
||
|
|
format!("{path} / {}", context.labels.join(" / "))
|
||
|
|
};
|
||
|
|
let mut row_trace = json!({"labels":context.labels,"inputs":context.items.iter().map(|(id,n)|(id.to_string(),n.json())).collect::<BTreeMap<_,_>>(),"status":"included","resources":[],"blocks":[]});
|
||
|
|
let managed_start = managed.len();
|
||
|
|
let row_issue_start = issues.len();
|
||
|
|
if let Some(predicate) = &rule.when {
|
||
|
|
row_trace["condition"] = json!({"left":resolver.eval(&predicate.left,0).ok().map(|n|n.json()),"operation":predicate.operation,"right":resolver.eval(&predicate.right,0).ok().map(|n|n.json())});
|
||
|
|
match resolver.matches(predicate) {
|
||
|
|
Ok(true) => {}
|
||
|
|
Ok(false) => {
|
||
|
|
row_trace["status"] = json!("filtered");
|
||
|
|
trace.last_mut().unwrap()["rows"]
|
||
|
|
.as_array_mut()
|
||
|
|
.unwrap()
|
||
|
|
.push(row_trace);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
Err(e) => {
|
||
|
|
issues.push(problem(&row_path, e.to_string()));
|
||
|
|
row_trace["status"] = json!("error");
|
||
|
|
trace.last_mut().unwrap()["rows"]
|
||
|
|
.as_array_mut()
|
||
|
|
.unwrap()
|
||
|
|
.push(row_trace);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (index, block) in rule.blocks.iter().enumerate() {
|
||
|
|
let id = ids[&block.id];
|
||
|
|
let resolved = |binding: &Binding| {
|
||
|
|
resolver
|
||
|
|
.eval(binding, 0)
|
||
|
|
.map(|n| n.json())
|
||
|
|
.unwrap_or(Value::Null)
|
||
|
|
};
|
||
|
|
row_trace["blocks"].as_array_mut().unwrap().push(json!({
|
||
|
|
"block":block.id,
|
||
|
|
"fields":block.fields.iter().map(|(key,b)|(key.clone(),resolved(b))).collect::<BTreeMap<_,_>>(),
|
||
|
|
"steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":resolved(&s.recipient),"condition":s.condition})).collect::<Vec<_>>(),
|
||
|
|
"conditions":block.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"value":resolved(&c.value),"negate":c.negate})).collect::<Vec<_>>()
|
||
|
|
}));
|
||
|
|
if manual_ids.contains(&id) {
|
||
|
|
issues.push(problem(
|
||
|
|
&row_path,
|
||
|
|
crate::i18n::text(
|
||
|
|
"err_generated_identity_collides_with_a_manual_resour_4403f1",
|
||
|
|
),
|
||
|
|
));
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if managed.len() >= 900 {
|
||
|
|
issues.push(problem(
|
||
|
|
&path,
|
||
|
|
crate::i18n::text("err_expansion_exceeds_the_team_resource_budget_5669f8"),
|
||
|
|
));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
match render(block, id, workspace, &resolver, &mut output, members) {
|
||
|
|
Ok(()) => managed.push(Managed {
|
||
|
|
id,
|
||
|
|
kind: block.kind.clone(),
|
||
|
|
rule: rule.id,
|
||
|
|
block: block.id,
|
||
|
|
item: context.labels.join(" / "),
|
||
|
|
}),
|
||
|
|
Err(e) => {
|
||
|
|
let mut issue = problem(
|
||
|
|
format!("{row_path} / {} block {}", block.kind, index + 1),
|
||
|
|
e.to_string(),
|
||
|
|
);
|
||
|
|
issue.rule_id = Some(rule.id);
|
||
|
|
issue.block_id = Some(block.id);
|
||
|
|
issues.push(issue);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if issues.len() > row_issue_start {
|
||
|
|
row_trace["status"] = json!("error");
|
||
|
|
}
|
||
|
|
row_trace["resources"] = json!(
|
||
|
|
managed[managed_start..]
|
||
|
|
.iter()
|
||
|
|
.map(|m| m.id)
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
);
|
||
|
|
trace.last_mut().unwrap()["rows"]
|
||
|
|
.as_array_mut()
|
||
|
|
.unwrap()
|
||
|
|
.push(row_trace);
|
||
|
|
}
|
||
|
|
for issue in &mut issues[issue_start..] {
|
||
|
|
issue.rule_id = Some(rule.id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let current_ids: BTreeSet<_> = managed.iter().map(|m| m.id).collect();
|
||
|
|
let removed: BTreeSet<_> = old_ids.difference(¤t_ids).copied().collect();
|
||
|
|
for request in &mut output.requests {
|
||
|
|
if !request
|
||
|
|
.legs
|
||
|
|
.iter()
|
||
|
|
.any(|l| removed.contains(&l.schedule_id))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if request.status == RequestStatus::Applied
|
||
|
|
&& request
|
||
|
|
.effective_legs()
|
||
|
|
.iter()
|
||
|
|
.any(|l| removed.contains(&l.schedule_id) && l.end > now)
|
||
|
|
{
|
||
|
|
issues.push(problem(
|
||
|
|
"Removal",
|
||
|
|
crate::i18n::text("err_a_generated_schedule_has_current_future_approved_0abed7"),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if request.status == RequestStatus::Pending {
|
||
|
|
request.status = RequestStatus::Cancelled;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let mut generated_routes = std::mem::take(&mut output.routes);
|
||
|
|
for route in &mut generated_routes {
|
||
|
|
if route.window.is_some() {
|
||
|
|
route.window = Some(interval.clone());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let mut routes = manual_routes;
|
||
|
|
routes.sort_by_key(|r| r.order);
|
||
|
|
let position = route_position.min(routes.len());
|
||
|
|
routes.splice(position..position, generated_routes);
|
||
|
|
for (i, r) in routes.iter_mut().enumerate() {
|
||
|
|
r.order = i as u16;
|
||
|
|
}
|
||
|
|
output.routes = routes;
|
||
|
|
output.generation = Generation {
|
||
|
|
rules: rules.to_vec(),
|
||
|
|
variables: variables.to_vec(),
|
||
|
|
route_position: position,
|
||
|
|
managed,
|
||
|
|
};
|
||
|
|
if let Err(e) = output.validate(members) {
|
||
|
|
let mut issue = problem(
|
||
|
|
crate::i18n::text("err_generated_configuration_61fd64"),
|
||
|
|
e.to_string(),
|
||
|
|
);
|
||
|
|
issue.secondary = !issues.is_empty();
|
||
|
|
issues.push(issue);
|
||
|
|
}
|
||
|
|
if issues.is_empty()
|
||
|
|
&& let Err(e) = engine::timeline(
|
||
|
|
&output,
|
||
|
|
now,
|
||
|
|
now + chrono::Duration::days(i64::from(output.horizon_days)),
|
||
|
|
)
|
||
|
|
{
|
||
|
|
issues.push(problem(
|
||
|
|
crate::i18n::text("err_generated_calendar_5e4edc"),
|
||
|
|
e.to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
let before = resources(doc);
|
||
|
|
let after = resources(&output);
|
||
|
|
let mut changes = vec![];
|
||
|
|
for m in &output.generation.managed {
|
||
|
|
let Some((name, value)) = after.get(&m.id) else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
let order_changed = m.kind == "route"
|
||
|
|
&& doc.routes.iter().find(|r| r.id == m.id).map(|r| r.order)
|
||
|
|
!= output.routes.iter().find(|r| r.id == m.id).map(|r| r.order);
|
||
|
|
let action = match before.get(&m.id) {
|
||
|
|
None => "create",
|
||
|
|
Some((_, old)) if old == value && !order_changed => "unchanged",
|
||
|
|
_ => "update",
|
||
|
|
};
|
||
|
|
changes.push(Change {
|
||
|
|
kind: m.kind.clone(),
|
||
|
|
id: m.id,
|
||
|
|
name: name.clone(),
|
||
|
|
action: action.into(),
|
||
|
|
source: m.item.clone(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
for m in &doc.generation.managed {
|
||
|
|
if removed.contains(&m.id) {
|
||
|
|
changes.push(Change {
|
||
|
|
kind: m.kind.clone(),
|
||
|
|
id: m.id,
|
||
|
|
name: before
|
||
|
|
.get(&m.id)
|
||
|
|
.map(|(n, _)| n.clone())
|
||
|
|
.unwrap_or_default(),
|
||
|
|
action: "remove".into(),
|
||
|
|
source: m.item.clone(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Expansion {
|
||
|
|
trace,
|
||
|
|
document: output,
|
||
|
|
changes,
|
||
|
|
issues,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn resources(doc: &WorkspaceDocument) -> BTreeMap<Uuid, (String, Value)> {
|
||
|
|
let mut map = BTreeMap::new();
|
||
|
|
for s in &doc.schedules {
|
||
|
|
map.insert(
|
||
|
|
s.id,
|
||
|
|
(
|
||
|
|
s.name.clone(),
|
||
|
|
serde_json::to_value(s).expect("serializable schedule"),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
for s in &doc.escalations {
|
||
|
|
map.insert(
|
||
|
|
s.id,
|
||
|
|
(
|
||
|
|
s.name.clone(),
|
||
|
|
serde_json::to_value(s).expect("serializable policy"),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
for s in &doc.routes {
|
||
|
|
let mut value = serde_json::to_value(s).expect("serializable route");
|
||
|
|
value.as_object_mut().unwrap().remove("order");
|
||
|
|
map.insert(s.id, (s.name.clone(), value));
|
||
|
|
}
|
||
|
|
map
|
||
|
|
}
|
||
|
|
pub fn validate_manual_edit(
|
||
|
|
before: &WorkspaceDocument,
|
||
|
|
after: &mut WorkspaceDocument,
|
||
|
|
) -> Result<()> {
|
||
|
|
ensure(
|
||
|
|
serde_json::to_value(&before.generation)? == serde_json::to_value(&after.generation)?,
|
||
|
|
crate::i18n::text("err_edit_generation_sources_in_variables_rules_8ec0b0"),
|
||
|
|
)?;
|
||
|
|
let old = resources(before);
|
||
|
|
let new = resources(after);
|
||
|
|
ensure(
|
||
|
|
before
|
||
|
|
.generation
|
||
|
|
.managed
|
||
|
|
.iter()
|
||
|
|
.all(|m| old.get(&m.id) == new.get(&m.id)),
|
||
|
|
crate::i18n::text("err_this_resource_is_generated_edit_its_source_in_va_b45498"),
|
||
|
|
)?;
|
||
|
|
validate_route_block(after)
|
||
|
|
}
|
||
|
|
pub fn validate_route_block(doc: &mut WorkspaceDocument) -> Result<()> {
|
||
|
|
let expected: Vec<_> = doc
|
||
|
|
.generation
|
||
|
|
.managed
|
||
|
|
.iter()
|
||
|
|
.filter(|m| m.kind == "route")
|
||
|
|
.map(|m| m.id)
|
||
|
|
.collect();
|
||
|
|
if expected.is_empty() {
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
let mut sorted = doc.routes.clone();
|
||
|
|
sorted.sort_by_key(|r| r.order);
|
||
|
|
let start = sorted
|
||
|
|
.iter()
|
||
|
|
.position(|r| r.id == expected[0])
|
||
|
|
.ok_or_else(|| {
|
||
|
|
Error::Validation(
|
||
|
|
crate::i18n::text("err_a_generated_routing_rule_is_missing_df7dc4").into(),
|
||
|
|
)
|
||
|
|
})?;
|
||
|
|
ensure(
|
||
|
|
sorted
|
||
|
|
.iter()
|
||
|
|
.skip(start)
|
||
|
|
.take(expected.len())
|
||
|
|
.map(|r| r.id)
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
== expected,
|
||
|
|
crate::i18n::text("err_generated_routing_rules_move_as_one_block_set_th_d09425"),
|
||
|
|
)?;
|
||
|
|
doc.generation.route_position = start;
|
||
|
|
Ok(())
|
||
|
|
}
|