diff --git a/src-tauri/data.sql b/src-tauri/data.sql index 81f6249..0612fe9 100644 Binary files a/src-tauri/data.sql and b/src-tauri/data.sql differ diff --git a/src-tauri/src/commands/auto_score.rs b/src-tauri/src/commands/auto_score.rs index 8ac3e8b..2d6ad22 100644 --- a/src-tauri/src/commands/auto_score.rs +++ b/src-tauri/src/commands/auto_score.rs @@ -28,6 +28,8 @@ pub struct CreateAutoScoreRule { #[serde(rename = "studentNames")] pub student_names: Vec, pub triggers: Vec, + #[serde(rename = "triggerTree", default)] + pub trigger_tree: Option, pub actions: Vec, #[serde(default)] pub execution: AutoScoreExecutionConfig, @@ -43,6 +45,8 @@ pub struct UpdateAutoScoreRule { #[serde(rename = "studentNames")] pub student_names: Vec, pub triggers: Vec, + #[serde(rename = "triggerTree", default)] + pub trigger_tree: Option, pub actions: Vec, #[serde(default)] pub execution: AutoScoreExecutionConfig, @@ -75,6 +79,7 @@ fn build_rule_from_create(rule: CreateAutoScoreRule) -> AutoScoreRule { enabled: rule.enabled, student_names: rule.student_names, triggers: rule.triggers, + trigger_tree: rule.trigger_tree, actions: rule.actions, execution: rule.execution, filters: rule.filters, @@ -89,6 +94,7 @@ fn build_rule_from_update(rule: UpdateAutoScoreRule) -> AutoScoreRule { enabled: rule.enabled, student_names: rule.student_names, triggers: rule.triggers, + trigger_tree: rule.trigger_tree, actions: rule.actions, execution: rule.execution, filters: rule.filters, diff --git a/src-tauri/src/commands/event.rs b/src-tauri/src/commands/event.rs index ff2a8d6..368aa32 100644 --- a/src-tauri/src/commands/event.rs +++ b/src-tauri/src/commands/event.rs @@ -32,7 +32,9 @@ pub struct ScoreEvent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateScoreEvent { + #[serde(alias = "studentName")] pub student_name: String, + #[serde(alias = "reasonContent")] pub reason_content: String, pub delta: i32, } @@ -44,8 +46,10 @@ pub struct QueryEventParams { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryByStudentParams { + #[serde(alias = "studentName")] pub student_name: String, pub limit: Option, + #[serde(alias = "startTime")] pub start_time: Option, } diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index 8b7ff20..ad4bc4a 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -1,11 +1,11 @@ use chrono::{DateTime, Months, Utc}; use sea_orm::{ - ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, EntityTrait, + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, Statement, TransactionTrait, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as JsonValue}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use tauri::{AppHandle, Emitter, Manager}; use tokio::time::{interval, Duration}; use uuid::Uuid; @@ -75,10 +75,10 @@ enum IntervalUnit { Month, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -struct IntervalTriggerValue { - amount: i64, - unit: IntervalUnit, +#[derive(Debug, Clone, PartialEq, Eq)] +enum IntervalTriggerValue { + Legacy { amount: i64, unit: IntervalUnit }, + Composite { days: i64, hours: i64, minutes: i64 }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -89,6 +89,8 @@ pub struct AutoScoreRule { #[serde(rename = "studentNames")] pub student_names: Vec, pub triggers: Vec, + #[serde(rename = "triggerTree", default)] + pub trigger_tree: Option, pub actions: Vec, #[serde(default)] pub execution: AutoScoreExecutionConfig, @@ -123,18 +125,6 @@ struct RuleExecutionStats { settled: bool, } -impl StudentRefs { - fn is_empty(&self) -> bool { - self.ids.is_empty() && self.names.is_empty() - } - - fn intersect(mut self, other: StudentRefs) -> StudentRefs { - self.ids.retain(|id| other.ids.contains(id)); - self.names.retain(|name| other.names.contains(name)); - self - } -} - impl Default for AutoScoreRule { fn default() -> Self { Self { @@ -143,6 +133,7 @@ impl Default for AutoScoreRule { enabled: true, student_names: Vec::new(), triggers: Vec::new(), + trigger_tree: None, actions: Vec::new(), execution: AutoScoreExecutionConfig::default(), filters: AutoScoreFilterConfig::default(), @@ -461,18 +452,30 @@ fn normalize_rule(mut rule: AutoScoreRule) -> Result { rule.name = normalize_required_string(rule.name, "automation name")?; rule.student_names = dedupe_trimmed_strings(rule.student_names); - if rule.triggers.is_empty() { + if rule.triggers.is_empty() && rule.trigger_tree.is_none() { return Err("At least one trigger is required".to_string()); } if rule.actions.is_empty() { return Err("At least one action is required".to_string()); } - rule.triggers = rule + let normalized_triggers = rule .triggers .into_iter() .map(normalize_trigger) .collect::, _>>()?; + let normalized_tree = if let Some(tree) = rule.trigger_tree.take() { + normalize_trigger_tree(tree)? + } else { + build_trigger_tree_from_triggers(&normalized_triggers) + }; + let tree_triggers = collect_triggers_from_tree(&normalized_tree)?; + if tree_triggers.is_empty() { + return Err("At least one trigger is required".to_string()); + } + + rule.triggers = tree_triggers; + rule.trigger_tree = Some(normalized_tree); rule.actions = rule .actions .into_iter() @@ -507,6 +510,285 @@ fn normalize_filter_config(mut config: AutoScoreFilterConfig) -> Result JsonValue { + JsonValue::Object( + [ + ("id".to_string(), JsonValue::String(Uuid::new_v4().to_string())), + ("type".to_string(), JsonValue::String("group".to_string())), + ( + "properties".to_string(), + json!({ + "conjunction": "AND" + }), + ), + ( + "children1".to_string(), + JsonValue::Array( + triggers + .iter() + .map(build_trigger_rule_node) + .collect::>(), + ), + ), + ] + .into_iter() + .collect(), + ) +} + +fn build_trigger_rule_node(trigger: &AutoScoreTrigger) -> JsonValue { + let (field, operator, value) = match trigger.event.as_str() { + "interval_time_passed" => ( + "interval_minutes", + "equal", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + "student_has_tag" => { + let tag_values = parse_tag_values(trigger.value.as_deref()) + .into_iter() + .map(JsonValue::String) + .collect::>(); + ( + "student_tag", + "multiselect_contains", + JsonValue::Array(vec![JsonValue::Array(tag_values)]), + ) + } + "query_sql" | "student_query_sql" | "student_sql" => ( + "student_sql", + "equal", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + _ => ( + "student_sql", + "equal", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + }; + + JsonValue::Object( + [ + ("id".to_string(), JsonValue::String(Uuid::new_v4().to_string())), + ("type".to_string(), JsonValue::String("rule".to_string())), + ( + "properties".to_string(), + json!({ + "field": field, + "operator": operator, + "value": value, + }), + ), + ] + .into_iter() + .collect(), + ) +} + +fn normalize_trigger_tree(tree: JsonValue) -> Result { + let normalized = normalize_trigger_tree_node(tree)?; + if !matches!( + normalized + .get("type") + .and_then(JsonValue::as_str) + .unwrap_or_default(), + "group" + ) { + return Err("Trigger tree root must be a group".to_string()); + } + Ok(normalized) +} + +fn normalize_trigger_tree_node(node: JsonValue) -> Result { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => normalize_trigger_tree_group(node), + "rule" => normalize_trigger_tree_rule(node), + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), + } +} + +fn normalize_trigger_tree_group(node: JsonValue) -> Result { + let conjunction = node + .get("properties") + .and_then(|value| value.get("conjunction")) + .and_then(JsonValue::as_str) + .unwrap_or("AND") + .to_uppercase(); + if conjunction != "AND" && conjunction != "OR" { + return Err("Trigger tree group conjunction must be AND or OR".to_string()); + } + + let not = node + .get("properties") + .and_then(|value| value.get("not")) + .and_then(JsonValue::as_bool) + .unwrap_or(false); + + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + if children.is_empty() { + return Err("Trigger tree group must contain at least one child".to_string()); + } + + let normalized_children = children + .into_iter() + .map(normalize_trigger_tree_node) + .collect::, _>>()?; + + Ok(JsonValue::Object( + [ + ( + "id".to_string(), + JsonValue::String( + node.get("id") + .and_then(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + ), + ), + ("type".to_string(), JsonValue::String("group".to_string())), + ( + "properties".to_string(), + json!({ + "conjunction": conjunction, + "not": not, + }), + ), + ("children1".to_string(), JsonValue::Array(normalized_children)), + ] + .into_iter() + .collect(), + )) +} + +fn normalize_trigger_tree_rule(node: JsonValue) -> Result { + let trigger = trigger_from_rule_node(&node)?; + + Ok(JsonValue::Object( + [ + ( + "id".to_string(), + JsonValue::String( + node.get("id") + .and_then(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + ), + ), + ("type".to_string(), JsonValue::String("rule".to_string())), + ( + "properties".to_string(), + build_trigger_rule_node(&trigger) + .get("properties") + .cloned() + .unwrap_or_else(|| json!({})), + ), + ] + .into_iter() + .collect(), + )) +} + +fn collect_triggers_from_tree(tree: &JsonValue) -> Result, String> { + let mut collected = Vec::new(); + collect_triggers_from_tree_node(tree, &mut collected)?; + Ok(collected) +} + +fn collect_triggers_from_tree_node( + node: &JsonValue, + collected: &mut Vec, +) -> Result<(), String> { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => { + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Trigger tree group children1 must be an array".to_string())?; + for child in children { + collect_triggers_from_tree_node(child, collected)?; + } + Ok(()) + } + "rule" => { + collected.push(trigger_from_rule_node(node)?); + Ok(()) + } + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), + } +} + +fn trigger_from_rule_node(node: &JsonValue) -> Result { + let field = node + .get("properties") + .and_then(|value| value.get("field")) + .and_then(JsonValue::as_str) + .unwrap_or_default(); + let first_value = node + .get("properties") + .and_then(|value| value.get("value")) + .and_then(JsonValue::as_array) + .and_then(|values| values.first()) + .cloned(); + + match field { + "interval_minutes" => { + let trigger = AutoScoreTrigger { + event: "interval_time_passed".to_string(), + value: first_value + .as_ref() + .and_then(JsonValue::as_str) + .map(str::to_string), + }; + normalize_trigger(trigger) + } + "student_tag" => { + let tags = match first_value { + Some(JsonValue::Array(items)) => items + .into_iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect::>(), + Some(JsonValue::String(value)) => vec![value], + _ => Vec::new(), + }; + let trigger = AutoScoreTrigger { + event: "student_has_tag".to_string(), + value: stringify_tag_values(&tags), + }; + normalize_trigger(trigger) + } + "student_sql" => { + let trigger = AutoScoreTrigger { + event: "query_sql".to_string(), + value: first_value + .as_ref() + .and_then(JsonValue::as_str) + .map(str::to_string), + }; + normalize_trigger(trigger) + } + _ => Err(format!("Unsupported trigger tree field: {}", field)), + } +} + fn normalize_trigger(trigger: AutoScoreTrigger) -> Result { let event = normalize_required_string(trigger.event, "trigger event")?; @@ -657,6 +939,22 @@ fn normalize_last_executed(value: Option) -> Option { .map(|parsed| parsed.with_timezone(&Utc).to_rfc3339()) } +fn parse_positive_i64(value: &JsonValue) -> Option { + match value { + JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed > 0), + JsonValue::String(text) => text.trim().parse::().ok().filter(|parsed| *parsed > 0), + _ => None, + } +} + +fn parse_non_negative_i64(value: &JsonValue) -> Option { + match value { + JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed >= 0), + JsonValue::String(text) => text.trim().parse::().ok().filter(|parsed| *parsed >= 0), + _ => None, + } +} + fn parse_interval_trigger_value(value: Option<&str>) -> Option { let raw_value = value.map(str::trim)?; if raw_value.is_empty() { @@ -665,29 +963,75 @@ fn parse_interval_trigger_value(value: Option<&str>) -> Option() { if minutes > 0 { - return Some(IntervalTriggerValue { + return Some(IntervalTriggerValue::Legacy { amount: minutes, unit: IntervalUnit::Minute, }); } } - let parsed_value = serde_json::from_str::(raw_value).ok()?; - if parsed_value.amount <= 0 { - return None; + let parsed_value = serde_json::from_str::(raw_value).ok()?; + let object = parsed_value.as_object()?; + + if let (Some(days), Some(hours), Some(minutes)) = ( + object.get("days").and_then(parse_non_negative_i64), + object.get("hours").and_then(parse_non_negative_i64), + object.get("minutes").and_then(parse_non_negative_i64), + ) { + if days > 0 || hours > 0 || minutes > 0 { + return Some(IntervalTriggerValue::Composite { + days, + hours, + minutes, + }); + } } - Some(parsed_value) + let amount = object.get("amount").and_then(parse_positive_i64)?; + let unit = match object.get("unit").and_then(JsonValue::as_str) { + Some("minute") => IntervalUnit::Minute, + Some("day") => IntervalUnit::Day, + Some("month") => IntervalUnit::Month, + _ => return None, + }; + + Some(IntervalTriggerValue::Legacy { amount, unit }) } fn stringify_interval_trigger_value(value: &IntervalTriggerValue) -> Option { - if value.amount <= 0 { - return None; - } + match value { + IntervalTriggerValue::Legacy { amount, unit } => { + if *amount <= 0 { + return None; + } - match value.unit { - IntervalUnit::Minute => Some(value.amount.to_string()), - IntervalUnit::Day | IntervalUnit::Month => serde_json::to_string(value).ok(), + match unit { + IntervalUnit::Minute => Some(amount.to_string()), + IntervalUnit::Day | IntervalUnit::Month => serde_json::to_string( + &json!({ + "amount": amount, + "unit": unit, + }), + ) + .ok(), + } + } + IntervalTriggerValue::Composite { + days, + hours, + minutes, + } => { + if *days <= 0 && *hours <= 0 && *minutes <= 0 { + return None; + } + + serde_json::to_string(&json!({ + "days": days, + "hours": hours, + "minutes": minutes, + })) + .ok() + } } } @@ -695,48 +1039,73 @@ fn add_interval_to_time( base_time: DateTime, interval: &IntervalTriggerValue, ) -> Option> { - match interval.unit { - IntervalUnit::Minute => { - base_time.checked_add_signed(chrono::Duration::minutes(interval.amount)) - } - IntervalUnit::Day => base_time.checked_add_signed(chrono::Duration::days(interval.amount)), - IntervalUnit::Month => { - let months = u32::try_from(interval.amount).ok()?; - base_time.checked_add_months(Months::new(months)) + match interval { + IntervalTriggerValue::Legacy { amount, unit } => match unit { + IntervalUnit::Minute => { + base_time.checked_add_signed(chrono::Duration::minutes(*amount)) + } + IntervalUnit::Day => { + base_time.checked_add_signed(chrono::Duration::days(*amount)) + } + IntervalUnit::Month => { + let months = u32::try_from(*amount).ok()?; + base_time.checked_add_months(Months::new(months)) + } + }, + IntervalTriggerValue::Composite { + days, + hours, + minutes, + } => { + let total_minutes = days + .saturating_mul(24 * 60) + .saturating_add(hours.saturating_mul(60)) + .saturating_add(*minutes); + if total_minutes <= 0 { + return None; + } + base_time.checked_add_signed(chrono::Duration::minutes(total_minutes)) } } } fn check_interval_trigger(rule: &AutoScoreRule) -> Option { - let now = Utc::now(); + let delays = rule + .triggers + .iter() + .filter(|trigger| trigger.event == "interval_time_passed") + .map(|trigger| get_interval_delay_ms(rule, trigger.value.as_deref())) + .collect::>>(); - for trigger in &rule.triggers { - if trigger.event != "interval_time_passed" { - continue; - } - - let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or( - IntervalTriggerValue { - amount: DEFAULT_INTERVAL_MINUTES, - unit: IntervalUnit::Minute, - }, - ); - - let Some(base_time) = rule - .last_executed - .as_ref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - else { - return Some(0); - }; - - let next_execute_time = add_interval_to_time(base_time, &interval)?; - let delay_ms = (next_execute_time - now).num_milliseconds(); - return Some(delay_ms.max(0)); + if delays.is_empty() { + return None; } - None + delays.into_iter().flatten().min() +} + +fn get_interval_delay_ms(rule: &AutoScoreRule, value: Option<&str>) -> Option { + let now = Utc::now(); + let interval = parse_interval_trigger_value(value).unwrap_or(IntervalTriggerValue::Legacy { + amount: DEFAULT_INTERVAL_MINUTES, + unit: IntervalUnit::Minute, + }); + + let Some(base_time) = rule + .last_executed + .as_ref() + .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok()) + .map(|parsed| parsed.with_timezone(&Utc)) + else { + return Some(0); + }; + + let next_execute_time = add_interval_to_time(base_time, &interval)?; + Some((next_execute_time - now).num_milliseconds().max(0)) +} + +fn is_interval_due(rule: &AutoScoreRule, value: Option<&str>) -> bool { + get_interval_delay_ms(rule, value).unwrap_or(0) <= 0 } fn contains_forbidden_keyword(sql: &str) -> bool { @@ -1301,82 +1670,198 @@ async fn execute_settlement(conn: &DatabaseConnection) -> Result<(), String> { Ok(()) } -async fn resolve_target_students( - conn: &DatabaseConnection, - rule: &AutoScoreRule, -) -> Result, String> { - let explicit_sql_values: Vec = rule - .triggers - .iter() - .filter(|trigger| { - matches!( +#[derive(Debug, Default)] +struct TriggerEvalContext { + student_tags_by_id: HashMap>, + sql_refs_by_query: HashMap, +} + +fn collect_sql_queries_from_tree(node: &JsonValue, queries: &mut Vec) -> Result<(), String> { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => { + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Trigger tree group children1 must be an array".to_string())?; + for child in children { + collect_sql_queries_from_tree(child, queries)?; + } + Ok(()) + } + "rule" => { + let trigger = trigger_from_rule_node(node)?; + if matches!( trigger.event.as_str(), "query_sql" | "student_query_sql" | "student_sql" - ) - }) - .filter_map(|trigger| trigger.value.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .collect(); - - if !explicit_sql_values.is_empty() { - let mut refs: Option = None; - for sql_value in explicit_sql_values { - let next_refs = query_student_refs_by_sql(conn, &sql_value).await?; - refs = Some(match refs { - None => next_refs, - Some(existing) => existing.intersect(next_refs), - }); + ) { + if let Some(sql) = trigger.value.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + queries.push(sql.to_string()); + } + } + Ok(()) } - - let refs = refs.unwrap_or_default(); - if refs.is_empty() { - return Ok(vec![]); - } - return load_students_from_refs(conn, refs).await; - } - - let tag_filters: Vec = rule - .triggers - .iter() - .filter(|trigger| trigger.event == "student_has_tag") - .flat_map(|trigger| parse_tag_values(trigger.value.as_deref())) - .collect(); - - let tag_filters = dedupe_trimmed_strings(tag_filters); - if tag_filters.is_empty() { - return students::Entity::find() - .all(conn) - .await - .map_err(|e| e.to_string()); + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), } +} +async fn load_student_tags_by_student_id( + conn: &DatabaseConnection, +) -> Result>, String> { let tag_models = tags::Entity::find() - .filter(tags::Column::Name.is_in(tag_filters)) .all(conn) .await .map_err(|e| e.to_string())?; if tag_models.is_empty() { - return Ok(vec![]); + return Ok(HashMap::new()); } - let tag_ids: Vec = tag_models.iter().map(|tag| tag.id).collect(); + let tag_name_by_id: HashMap = tag_models + .into_iter() + .map(|tag| (tag.id, tag.name)) + .collect(); let links = student_tags::Entity::find() - .filter(student_tags::Column::TagId.is_in(tag_ids)) .all(conn) .await .map_err(|e| e.to_string())?; - if links.is_empty() { + + let mut student_tags_by_id: HashMap> = HashMap::new(); + for link in links { + let Some(tag_name) = tag_name_by_id.get(&link.tag_id) else { + continue; + }; + student_tags_by_id + .entry(link.student_id) + .or_default() + .insert(tag_name.clone()); + } + + Ok(student_tags_by_id) +} + +fn evaluate_trigger_tree_for_student( + node: &JsonValue, + student: &students::Model, + rule: &AutoScoreRule, + ctx: &TriggerEvalContext, +) -> Result { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => { + let conjunction = node + .get("properties") + .and_then(|value| value.get("conjunction")) + .and_then(JsonValue::as_str) + .unwrap_or("AND") + .to_uppercase(); + let not = node + .get("properties") + .and_then(|value| value.get("not")) + .and_then(JsonValue::as_bool) + .unwrap_or(false); + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Trigger tree group children1 must be an array".to_string())?; + if children.is_empty() { + return Ok(false); + } + + let base = if conjunction == "OR" { + children.iter().try_fold(false, |matched, child| { + if matched { + return Ok(true); + } + evaluate_trigger_tree_for_student(child, student, rule, ctx) + })? + } else { + children.iter().try_fold(true, |matched, child| { + if !matched { + return Ok(false); + } + evaluate_trigger_tree_for_student(child, student, rule, ctx) + })? + }; + + Ok(if not { !base } else { base }) + } + "rule" => { + let trigger = trigger_from_rule_node(node)?; + let matched = match trigger.event.as_str() { + "interval_time_passed" => is_interval_due(rule, trigger.value.as_deref()), + "student_has_tag" => { + let required_tags = parse_tag_values(trigger.value.as_deref()); + let student_tags = ctx.student_tags_by_id.get(&student.id); + required_tags.iter().any(|tag| { + student_tags + .map(|values| values.contains(tag)) + .unwrap_or(false) + }) + } + "query_sql" | "student_query_sql" | "student_sql" => { + let sql = trigger + .value + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); + ctx.sql_refs_by_query + .get(&sql) + .map(|refs| refs.ids.contains(&student.id) || refs.names.contains(&student.name)) + .unwrap_or(false) + } + _ => false, + }; + Ok(matched) + } + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), + } +} + +async fn resolve_target_students( + conn: &DatabaseConnection, + rule: &AutoScoreRule, +) -> Result, String> { + let all_students = students::Entity::find() + .all(conn) + .await + .map_err(|e| e.to_string())?; + if all_students.is_empty() { return Ok(vec![]); } - let student_ids: HashSet = links.iter().map(|link| link.student_id).collect(); - students::Entity::find() - .filter(students::Column::Id.is_in(student_ids)) - .all(conn) - .await - .map_err(|e| e.to_string()) + let fallback_tree = build_trigger_tree_from_triggers(&rule.triggers); + let trigger_tree = rule.trigger_tree.as_ref().unwrap_or(&fallback_tree); + + let mut sql_queries = Vec::new(); + collect_sql_queries_from_tree(trigger_tree, &mut sql_queries)?; + let sql_queries = dedupe_trimmed_strings(sql_queries); + + let mut sql_refs_by_query = HashMap::new(); + for sql in sql_queries { + let refs = query_student_refs_by_sql(conn, &sql).await?; + sql_refs_by_query.insert(sql, refs); + } + + let ctx = TriggerEvalContext { + student_tags_by_id: load_student_tags_by_student_id(conn).await?, + sql_refs_by_query, + }; + + let mut matched = Vec::new(); + for student in all_students { + if evaluate_trigger_tree_for_student(trigger_tree, &student, rule, &ctx)? { + matched.push(student); + } + } + + Ok(matched) } @@ -1458,29 +1943,6 @@ async fn query_student_refs_by_sql( Ok(refs) } -async fn load_students_from_refs( - conn: &DatabaseConnection, - refs: StudentRefs, -) -> Result, String> { - if refs.is_empty() { - return Ok(vec![]); - } - - let mut condition = Condition::any(); - if !refs.ids.is_empty() { - condition = condition.add(students::Column::Id.is_in(refs.ids)); - } - if !refs.names.is_empty() { - condition = condition.add(students::Column::Name.is_in(refs.names)); - } - - students::Entity::find() - .filter(condition) - .all(conn) - .await - .map_err(|e| e.to_string()) -} - fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option { row.try_get::("", column) .ok() diff --git a/src/components/AutoScore/AutoScoreUtils.ts b/src/components/AutoScore/AutoScoreUtils.ts index 458a851..d1188f1 100644 --- a/src/components/AutoScore/AutoScoreUtils.ts +++ b/src/components/AutoScore/AutoScoreUtils.ts @@ -51,6 +51,7 @@ export interface AutoScoreRule { enabled: boolean studentNames: string[] triggers: AutoScoreTrigger[] + triggerTree?: JsonGroup | null actions: AutoScoreAction[] execution?: AutoScoreExecutionConfig lastExecuted?: string | null @@ -368,6 +369,70 @@ export const queryTreeToTriggers = (tree: ImmutableTree, config: Config): AutoSc return collectTriggersFromItems(jsonTree.children1) } +export const queryTreeToJson = (tree: ImmutableTree, config: Config): JsonGroup | null => { + const checkedTree = QbUtils.checkTree(tree, config) + const jsonTree = QbUtils.getTree(checkedTree, false, true) + if (!jsonTree || jsonTree.type !== "group") return null + return jsonTree as JsonGroup +} + +const hydrateTriggerTreeNode = ( + node: JsonItem, + fallbackTriggers: AutoScoreTrigger[], + fallbackIndex: { current: number } +): JsonItem | null => { + if (node.type === "group") { + const children = Array.isArray(node.children1) + ? node.children1 + .map((child) => hydrateTriggerTreeNode(child, fallbackTriggers, fallbackIndex)) + .filter((child): child is JsonItem => Boolean(child)) + : [] + return { + ...node, + children1: children, + } + } + + if (node.type !== "rule") { + return node + } + + const fallbackTrigger = fallbackTriggers[fallbackIndex.current] + fallbackIndex.current += 1 + const parsed = triggerFromRule(node) + if (parsed) { + return node + } + + if (!fallbackTrigger) { + return node + } + + const fallbackRule = ruleFromTrigger(fallbackTrigger) + if (!fallbackRule) { + return node + } + + return { + ...node, + properties: fallbackRule.properties, + } +} + +export const triggerTreeJsonToQueryTree = ( + config: Config, + triggerTree?: JsonGroup | null, + fallbackTriggers: AutoScoreTrigger[] = [] +): ImmutableTree => { + if (triggerTree && triggerTree.type === "group") { + const hydratedTree = hydrateTriggerTreeNode(triggerTree, fallbackTriggers, { current: 0 }) + if (hydratedTree && hydratedTree.type === "group") { + return QbUtils.checkTree(QbUtils.loadTree(hydratedTree), config) + } + } + return triggersToQueryTree(config, fallbackTriggers) +} + const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => { const conjunction = typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND" diff --git a/src/components/AutoScore/IntervalValueCodec.ts b/src/components/AutoScore/IntervalValueCodec.ts index 66b0a18..66ed942 100644 --- a/src/components/AutoScore/IntervalValueCodec.ts +++ b/src/components/AutoScore/IntervalValueCodec.ts @@ -1,44 +1,87 @@ export type IntervalUnit = "minute" | "day" | "month" export interface IntervalValue { + days: number + hours: number + minutes: number +} + +interface LegacyIntervalValue { amount: number unit: IntervalUnit } -export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day" - -const normalizePositiveInteger = (value: unknown): number | null => { +const normalizeNonNegativeInteger = (value: unknown): number | null => { if (typeof value === "number" && Number.isFinite(value)) { const normalized = Math.floor(value) - return normalized > 0 ? normalized : null + return normalized >= 0 ? normalized : null } if (typeof value === "string" && value.trim()) { const parsed = Number(value) if (Number.isFinite(parsed)) { const normalized = Math.floor(parsed) - return normalized > 0 ? normalized : null + return normalized >= 0 ? normalized : null } } return null } +const normalizePositiveInteger = (value: unknown): number | null => { + const normalized = normalizeNonNegativeInteger(value) + if (normalized === null || normalized <= 0) return null + return normalized +} + const isIntervalUnit = (value: unknown): value is IntervalUnit => value === "minute" || value === "day" || value === "month" export const getDefaultIntervalValue = (): IntervalValue => ({ - amount: 1, - unit: DEFAULT_INTERVAL_UNIT, + days: 1, + hours: 0, + minutes: 0, }) +const fromTotalMinutes = (totalMinutes: number): IntervalValue => { + const normalized = Math.max(0, Math.floor(totalMinutes)) + const days = Math.floor(normalized / (24 * 60)) + const hours = Math.floor((normalized % (24 * 60)) / 60) + const minutes = normalized % 60 + return { days, hours, minutes } +} + +const toTotalMinutes = (value: IntervalValue): number => + value.days * 24 * 60 + value.hours * 60 + value.minutes + +const normalizeCompositeValue = (value: Partial): IntervalValue | null => { + const days = normalizeNonNegativeInteger(value.days) ?? 0 + const hours = normalizeNonNegativeInteger(value.hours) ?? 0 + const minutes = normalizeNonNegativeInteger(value.minutes) ?? 0 + + const totalMinutes = days * 24 * 60 + hours * 60 + minutes + if (totalMinutes <= 0) return null + return fromTotalMinutes(totalMinutes) +} + +const legacyToComposite = (value: Partial): IntervalValue | null => { + const amount = normalizePositiveInteger(value.amount) + if (!amount || !isIntervalUnit(value.unit)) return null + + if (value.unit === "minute") return fromTotalMinutes(amount) + if (value.unit === "day") return fromTotalMinutes(amount * 24 * 60) + + // Legacy month values are approximated as 30 days in the editor. + return fromTotalMinutes(amount * 30 * 24 * 60) +} + export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => { if (typeof value === "object" && value !== null) { - const amount = normalizePositiveInteger((value as IntervalValue).amount) - const unit = (value as IntervalValue).unit - if (amount && isIntervalUnit(unit)) { - return { amount, unit } - } + const composite = normalizeCompositeValue(value as Partial) + if (composite) return composite + + const legacy = legacyToComposite(value as Partial) + if (legacy) return legacy } const text = value === null || value === undefined ? "" : String(value).trim() @@ -46,15 +89,16 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null const legacyMinutes = normalizePositiveInteger(text) if (legacyMinutes !== null && !text.startsWith("{")) { - return { amount: legacyMinutes, unit: "minute" } + return fromTotalMinutes(legacyMinutes) } try { - const parsed = JSON.parse(text) as Partial - const amount = normalizePositiveInteger(parsed.amount) - if (amount && isIntervalUnit(parsed.unit)) { - return { amount, unit: parsed.unit } - } + const parsed = JSON.parse(text) as Partial + const composite = normalizeCompositeValue(parsed) + if (composite) return composite + + const legacy = legacyToComposite(parsed) + if (legacy) return legacy } catch { void 0 } @@ -63,17 +107,15 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null } export const stringifyIntervalTriggerValue = (value: IntervalValue): string | null => { - const amount = normalizePositiveInteger(value.amount) - if (!amount || !isIntervalUnit(value.unit)) { - return null - } + const normalized = normalizeCompositeValue(value) + if (!normalized) return null - if (value.unit === "minute") { - return String(amount) - } + const totalMinutes = toTotalMinutes(normalized) + if (totalMinutes <= 0) return null return JSON.stringify({ - amount, - unit: value.unit, + days: normalized.days, + hours: normalized.hours, + minutes: normalized.minutes, }) } diff --git a/src/components/AutoScore/IntervalValueWidget.tsx b/src/components/AutoScore/IntervalValueWidget.tsx index bdc1562..76d82f2 100644 --- a/src/components/AutoScore/IntervalValueWidget.tsx +++ b/src/components/AutoScore/IntervalValueWidget.tsx @@ -1,116 +1,87 @@ -import React, { useMemo } from "react" -import { InputNumber, Select } from "antd" +import React from "react" +import { InputNumber } from "antd" import type { WidgetProps } from "@react-awesome-query-builder/antd" import { useTranslation } from "react-i18next" +import { + getDefaultIntervalValue, + parseIntervalTriggerValue, + stringifyIntervalTriggerValue, + type IntervalValue, +} from "./IntervalValueCodec" -export type IntervalUnit = "month" | "day" | "minute" +export { parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueCodec" +export type { IntervalValue, IntervalUnit } from "./IntervalValueCodec" -export interface IntervalValue { - amount: number - unit: IntervalUnit -} - -export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day" - -export const getDefaultIntervalValue = (): IntervalValue => ({ - amount: 1, - unit: DEFAULT_INTERVAL_UNIT, -}) - -export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => { - if (value === null || value === undefined) return null - - const str = String(value).trim() - if (!str) return null - - try { - const parsed = JSON.parse(str) - if ( - typeof parsed === "object" && - parsed !== null && - typeof parsed.amount === "number" && - typeof parsed.unit === "string" - ) { - const validUnits: IntervalUnit[] = ["month", "day", "minute"] - if (validUnits.includes(parsed.unit as IntervalUnit)) { - return { - amount: Math.max(1, Math.floor(parsed.amount)), - unit: parsed.unit as IntervalUnit, - } - } - } - } catch { - void 0 +const buildNextIntervalValue = ( + currentValue: IntervalValue | null, + patch: Partial +): IntervalValue => { + const base = currentValue ?? getDefaultIntervalValue() + return { + days: patch.days ?? base.days, + hours: patch.hours ?? base.hours, + minutes: patch.minutes ?? base.minutes, } - - return null } -export const stringifyIntervalTriggerValue = (value: IntervalValue): string => { - return JSON.stringify({ - amount: value.amount, - unit: value.unit, - }) -} +const getDisplayValue = (value: number | undefined, fallback = 0) => + typeof value === "number" && Number.isFinite(value) ? value : fallback export const IntervalValueWidget: React.FC = ({ value, setValue, readonly, - placeholder, }) => { const { t } = useTranslation() const parsedValue = parseIntervalTriggerValue(value) - const unitOptions = useMemo( - () => [ - { label: t("autoScore.intervalUnitMonth"), value: "month" }, - { label: t("autoScore.intervalUnitDay"), value: "day" }, - { label: t("autoScore.intervalUnitMinute"), value: "minute" }, - ], - [t] - ) - - const handleAmountChange = (nextAmount: number | null) => { - if (nextAmount === null) { - setValue(null) - return - } - - const serializedValue = stringifyIntervalTriggerValue({ - amount: nextAmount, - unit: parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT, - }) - setValue(serializedValue) - } - - const handleUnitChange = (nextUnit: IntervalUnit) => { - const baseValue = parsedValue ?? getDefaultIntervalValue() - const serializedValue = stringifyIntervalTriggerValue({ - amount: baseValue.amount, - unit: nextUnit, - }) - setValue(serializedValue) + const updateValue = (patch: Partial) => { + const nextValue = buildNextIntervalValue(parsedValue, patch) + const serialized = stringifyIntervalTriggerValue(nextValue) + setValue(serialized) } return ( -
- - matchStudentName(getOptionLabel(option), input)} - options={students.map((s) => ({ label: s.name, value: s.name }))} - /> + + + +