mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
feat: Enhance AutoScore functionality with interval triggers and JSON tree support
- Added support for trigger trees in AutoScoreRule interface. - Implemented functions to convert query trees to JSON and vice versa. - Updated IntervalValue to use days, hours, and minutes instead of a single amount and unit. - Refactored IntervalValueWidget to allow separate input for days, hours, and minutes. - Added validation to ensure at least one interval trigger is present. - Enhanced user interface with informative alerts and tooltips for better user experience. - Updated localization files to include new strings for interval triggers and UI elements.
This commit is contained in:
Binary file not shown.
@@ -28,6 +28,8 @@ pub struct CreateAutoScoreRule {
|
||||
#[serde(rename = "studentNames")]
|
||||
pub student_names: Vec<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
#[serde(rename = "triggerTree", default)]
|
||||
pub trigger_tree: Option<JsonValue>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
#[serde(default)]
|
||||
pub execution: AutoScoreExecutionConfig,
|
||||
@@ -43,6 +45,8 @@ pub struct UpdateAutoScoreRule {
|
||||
#[serde(rename = "studentNames")]
|
||||
pub student_names: Vec<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
#[serde(rename = "triggerTree", default)]
|
||||
pub trigger_tree: Option<JsonValue>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
#[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,
|
||||
|
||||
@@ -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<i32>,
|
||||
#[serde(alias = "startTime")]
|
||||
pub start_time: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
#[serde(rename = "triggerTree", default)]
|
||||
pub trigger_tree: Option<JsonValue>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
#[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<AutoScoreRule, String> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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<AutoScor
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn build_trigger_tree_from_triggers(triggers: &[AutoScoreTrigger]) -> 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::<Vec<JsonValue>>(),
|
||||
),
|
||||
),
|
||||
]
|
||||
.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::<Vec<JsonValue>>();
|
||||
(
|
||||
"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<JsonValue, String> {
|
||||
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<JsonValue, 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" => 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<JsonValue, String> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
|
||||
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<JsonValue, String> {
|
||||
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<Vec<AutoScoreTrigger>, 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<AutoScoreTrigger>,
|
||||
) -> 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<AutoScoreTrigger, String> {
|
||||
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::<Vec<String>>(),
|
||||
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<AutoScoreTrigger, String> {
|
||||
let event = normalize_required_string(trigger.event, "trigger event")?;
|
||||
|
||||
@@ -657,6 +939,22 @@ fn normalize_last_executed(value: Option<String>) -> Option<String> {
|
||||
.map(|parsed| parsed.with_timezone(&Utc).to_rfc3339())
|
||||
}
|
||||
|
||||
fn parse_positive_i64(value: &JsonValue) -> Option<i64> {
|
||||
match value {
|
||||
JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed > 0),
|
||||
JsonValue::String(text) => text.trim().parse::<i64>().ok().filter(|parsed| *parsed > 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_non_negative_i64(value: &JsonValue) -> Option<i64> {
|
||||
match value {
|
||||
JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed >= 0),
|
||||
JsonValue::String(text) => text.trim().parse::<i64>().ok().filter(|parsed| *parsed >= 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_interval_trigger_value(value: Option<&str>) -> Option<IntervalTriggerValue> {
|
||||
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<IntervalTriggerVa
|
||||
|
||||
if let Ok(minutes) = raw_value.parse::<i64>() {
|
||||
if minutes > 0 {
|
||||
return Some(IntervalTriggerValue {
|
||||
return Some(IntervalTriggerValue::Legacy {
|
||||
amount: minutes,
|
||||
unit: IntervalUnit::Minute,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let parsed_value = serde_json::from_str::<IntervalTriggerValue>(raw_value).ok()?;
|
||||
if parsed_value.amount <= 0 {
|
||||
return None;
|
||||
let parsed_value = serde_json::from_str::<JsonValue>(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<String> {
|
||||
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<Utc>,
|
||||
interval: &IntervalTriggerValue,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
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<i64> {
|
||||
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::<Vec<Option<i64>>>();
|
||||
|
||||
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<i64> {
|
||||
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<Vec<students::Model>, String> {
|
||||
let explicit_sql_values: Vec<String> = rule
|
||||
.triggers
|
||||
.iter()
|
||||
.filter(|trigger| {
|
||||
matches!(
|
||||
#[derive(Debug, Default)]
|
||||
struct TriggerEvalContext {
|
||||
student_tags_by_id: HashMap<i32, HashSet<String>>,
|
||||
sql_refs_by_query: HashMap<String, StudentRefs>,
|
||||
}
|
||||
|
||||
fn collect_sql_queries_from_tree(node: &JsonValue, queries: &mut Vec<String>) -> 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<StudentRefs> = 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<String> = 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<HashMap<i32, HashSet<String>>, 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<i32> = tag_models.iter().map(|tag| tag.id).collect();
|
||||
let tag_name_by_id: HashMap<i32, String> = 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<i32, HashSet<String>> = 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<bool, 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 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<Vec<students::Model>, 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<i32> = 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<Vec<students::Model>, 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<i32> {
|
||||
row.try_get::<i32>("", column)
|
||||
.ok()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>): 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<LegacyIntervalValue>): 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<IntervalValue>)
|
||||
if (composite) return composite
|
||||
|
||||
const legacy = legacyToComposite(value as Partial<LegacyIntervalValue>)
|
||||
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<IntervalValue>
|
||||
const amount = normalizePositiveInteger(parsed.amount)
|
||||
if (amount && isIntervalUnit(parsed.unit)) {
|
||||
return { amount, unit: parsed.unit }
|
||||
}
|
||||
const parsed = JSON.parse(text) as Partial<IntervalValue & LegacyIntervalValue>
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
): 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<WidgetProps> = ({
|
||||
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<IntervalValue>) => {
|
||||
const nextValue = buildNextIntervalValue(parsedValue, patch)
|
||||
const serialized = stringifyIntervalTriggerValue(nextValue)
|
||||
setValue(serialized)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, minWidth: 280 }}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
disabled={readonly}
|
||||
value={parsedValue?.amount}
|
||||
placeholder={placeholder || t("autoScore.intervalAmountPlaceholder")}
|
||||
onChange={handleAmountChange}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Select
|
||||
disabled={readonly}
|
||||
value={parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT}
|
||||
options={unitOptions}
|
||||
onChange={handleUnitChange}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 12, minWidth: 360 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.intervalPartDay")}
|
||||
</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={readonly}
|
||||
value={getDisplayValue(parsedValue?.days)}
|
||||
onChange={(next) => updateValue({ days: Math.max(0, Number(next ?? 0)) })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.intervalPartHour")}
|
||||
</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={readonly}
|
||||
value={getDisplayValue(parsedValue?.hours)}
|
||||
onChange={(next) => updateValue({ hours: Math.max(0, Number(next ?? 0)) })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.intervalPartMinute")}
|
||||
</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={readonly}
|
||||
value={getDisplayValue(parsedValue?.minutes)}
|
||||
onChange={(next) => updateValue({ minutes: Math.max(0, Number(next ?? 0)) })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { Card } from "antd"
|
||||
import { Alert, Card } from "antd"
|
||||
import {
|
||||
Builder,
|
||||
Query,
|
||||
@@ -46,6 +46,12 @@ export const TriggerRuleBuilder: React.FC<TriggerRuleBuilderProps> = ({
|
||||
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
|
||||
title={t("autoScore.whenTriggered")}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: "16px" }}
|
||||
title={t("autoScore.triggerScheduleHint")}
|
||||
/>
|
||||
<div
|
||||
className="query-builder-container"
|
||||
style={{
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { fetchAllTags } from "./TagEditorDialog"
|
||||
import { ActionEditor } from "./AutoScore/ActionEditor"
|
||||
import { parseIntervalTriggerValue } from "./AutoScore/IntervalValueCodec"
|
||||
import { TriggerRuleBuilder } from "./AutoScore/TriggerRuleBuilder"
|
||||
import {
|
||||
actionDraftsToPayload,
|
||||
@@ -29,11 +30,11 @@ import {
|
||||
createDefaultActionDraft,
|
||||
createEmptyTriggerTree,
|
||||
createTriggerQueryConfig,
|
||||
hasUnsupportedTriggerLogic,
|
||||
normalizeTriggerTree,
|
||||
queryTreeToJson,
|
||||
normalizeActionDrafts,
|
||||
queryTreeToTriggers,
|
||||
triggersToQueryTree,
|
||||
triggerTreeJsonToQueryTree,
|
||||
type ActionDraft,
|
||||
type AutoScoreExecutionBatch,
|
||||
type AutoScoreExecutionConfig,
|
||||
@@ -203,16 +204,16 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
return
|
||||
}
|
||||
|
||||
if (hasUnsupportedTriggerLogic(triggerTree, triggerConfig)) {
|
||||
messageApi.warning(t("autoScore.unsupportedLogic"))
|
||||
return
|
||||
}
|
||||
|
||||
const triggers = queryTreeToTriggers(triggerTree, triggerConfig)
|
||||
const triggerTreeJson = queryTreeToJson(triggerTree, triggerConfig)
|
||||
if (triggers.length === 0) {
|
||||
messageApi.warning(t("autoScore.triggerRequired"))
|
||||
return
|
||||
}
|
||||
if (!triggers.some((trigger) => trigger.event === "interval_time_passed")) {
|
||||
messageApi.warning(t("autoScore.intervalTriggerRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedActionDrafts = normalizeActionDrafts(actionDrafts)
|
||||
const actionPayload = actionDraftsToPayload(normalizedActionDrafts)
|
||||
@@ -243,6 +244,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
enabled: true,
|
||||
studentNames,
|
||||
triggers,
|
||||
triggerTree: triggerTreeJson,
|
||||
actions: actionPayload.actions,
|
||||
execution,
|
||||
}
|
||||
@@ -281,7 +283,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
studentNames: rule.studentNames || [],
|
||||
execution: rule.execution || {},
|
||||
})
|
||||
setTriggerTree(triggersToQueryTree(triggerConfig, rule.triggers || []))
|
||||
setTriggerTree(triggerTreeJsonToQueryTree(triggerConfig, rule.triggerTree, rule.triggers || []))
|
||||
setActionDrafts(actionsToDrafts(rule.actions || []))
|
||||
}
|
||||
|
||||
@@ -342,6 +344,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
enabled: rule.enabled,
|
||||
studentNames: rule.studentNames,
|
||||
triggers: rule.triggers,
|
||||
triggerTree: rule.triggerTree ?? null,
|
||||
actions: rule.actions,
|
||||
execution: rule.execution || {},
|
||||
lastExecuted: rule.lastExecuted ?? null,
|
||||
@@ -370,6 +373,44 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatTriggerSummary = (trigger: AutoScoreRule["triggers"][number]) => {
|
||||
if (trigger.event === "interval_time_passed") {
|
||||
const intervalValue = parseIntervalTriggerValue(trigger.value)
|
||||
if (!intervalValue) {
|
||||
return `${t("autoScore.triggerIntervalTime")}: -`
|
||||
}
|
||||
return `${t("autoScore.triggerIntervalTime")}: ${intervalValue.days}${t(
|
||||
"autoScore.intervalPartDay"
|
||||
)} ${intervalValue.hours}${t("autoScore.intervalPartHour")} ${intervalValue.minutes}${t(
|
||||
"autoScore.intervalPartMinute"
|
||||
)}`
|
||||
}
|
||||
if (trigger.event === "student_has_tag") {
|
||||
return `${t("autoScore.triggerStudentTag")}: ${String(trigger.value || "").trim() || "-"}`
|
||||
}
|
||||
if (
|
||||
trigger.event === "query_sql" ||
|
||||
trigger.event === "student_query_sql" ||
|
||||
trigger.event === "student_sql"
|
||||
) {
|
||||
return `${t("autoScore.triggerStudentSql")}: ${String(trigger.value || "").trim() || "-"}`
|
||||
}
|
||||
return `${trigger.event}: ${String(trigger.value || "").trim() || "-"}`
|
||||
}
|
||||
|
||||
const formatActionSummary = (action: AutoScoreRule["actions"][number]) => {
|
||||
if (action.event === "add_score") {
|
||||
return `${t("autoScore.actionAddScore")}: ${String(action.value || "").trim() || "-"}`
|
||||
}
|
||||
if (action.event === "add_tag") {
|
||||
return `${t("autoScore.actionAddTag")}: ${String(action.value || "").trim() || "-"}`
|
||||
}
|
||||
if (action.event === "settle_score") {
|
||||
return `${t("autoScore.actionSettleScore")}: ${t("autoScore.actionSettleScoreHint")}`
|
||||
}
|
||||
return `${action.event}: ${String(action.value || "").trim() || "-"}`
|
||||
}
|
||||
|
||||
const handleRollbackBatch = async (batchId: string) => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
@@ -434,13 +475,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
title: t("autoScore.triggers"),
|
||||
key: "triggers",
|
||||
width: 120,
|
||||
render: (_, row) => <Tag>{t("autoScore.triggerCount", { count: row.triggers.length })}</Tag>,
|
||||
render: (_, row) => (
|
||||
<Tooltip title={row.triggers.map(formatTriggerSummary).join("\n") || "-"}>
|
||||
<Tag>{t("autoScore.triggerCount", { count: row.triggers.length })}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("autoScore.actions"),
|
||||
key: "actions",
|
||||
width: 120,
|
||||
render: (_, row) => <Tag>{t("autoScore.actionCount", { count: row.actions.length })}</Tag>,
|
||||
render: (_, row) => (
|
||||
<Tooltip title={row.actions.map(formatActionSummary).join("\n") || "-"}>
|
||||
<Tag>{t("autoScore.actionCount", { count: row.actions.length })}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("autoScore.lastExecuted"),
|
||||
|
||||
@@ -274,17 +274,19 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<h2 style={{ marginBottom: "24px", color: "var(--ss-text-main)" }}>{t("score.title")}</h2>
|
||||
|
||||
<Card style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ type: "add" }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ type: "add", student_name: [] }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "24px" }}>
|
||||
<Form.Item label={t("score.student")} name="student_name">
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder={t("score.pleaseSelectStudent")}
|
||||
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
||||
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
||||
/>
|
||||
<Form.Item label={t("score.student")}>
|
||||
<Space orientation="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Form.Item name="student_name" noStyle>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder={t("score.pleaseSelectStudent")}
|
||||
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
||||
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -772,6 +772,7 @@
|
||||
"addTrigger": "Add Rule",
|
||||
"addAction": "Add Action",
|
||||
"whenTriggered": "When the following rules trigger",
|
||||
"triggerScheduleHint": "Note: whether a rule runs is still scheduled by the interval trigger. Tags, SQL, and AND / OR / NOT only decide which students are matched for that run.",
|
||||
"triggeredActions": "Actions triggered when rules are met",
|
||||
"addAutomation": "Add Automation",
|
||||
"updateAutomation": "Update Automation",
|
||||
@@ -785,6 +786,7 @@
|
||||
"fetchFailed": "Failed to get automation",
|
||||
"unsupportedLogic": "Saving currently supports AND-only conditions. OR / NOT logic is not wired to backend persistence yet",
|
||||
"triggerRequired": "Please add at least one trigger",
|
||||
"intervalTriggerRequired": "Please add at least one interval trigger, otherwise the rule will never run automatically",
|
||||
"actionRequired": "Please add at least one action",
|
||||
"createSuccess": "Automation created successfully",
|
||||
"updateSuccess": "Automation updated successfully",
|
||||
@@ -841,6 +843,9 @@
|
||||
"intervalUnitMonth": "month(s) later",
|
||||
"intervalUnitDay": "day(s) later",
|
||||
"intervalUnitMinute": "minute(s) later",
|
||||
"intervalPartDay": "Days",
|
||||
"intervalPartHour": "Hours",
|
||||
"intervalPartMinute": "Minutes",
|
||||
"minutesPlaceholder": "Enter minutes",
|
||||
"minutes": "minutes",
|
||||
"tagsPlaceholder": "Enter tag name",
|
||||
|
||||
@@ -772,6 +772,7 @@
|
||||
"addTrigger": "添加规则",
|
||||
"addAction": "添加行动",
|
||||
"whenTriggered": "当以下规则触发时",
|
||||
"triggerScheduleHint": "提示:规则是否执行仍由“间隔时间”触发器决定,标签、SQL、AND / OR / NOT 仅用于筛选本次命中的学生。",
|
||||
"triggeredActions": "满足规则时触发的行动",
|
||||
"addAutomation": "添加自动化",
|
||||
"updateAutomation": "更新自动化",
|
||||
@@ -785,6 +786,7 @@
|
||||
"fetchFailed": "获取自动化失败",
|
||||
"unsupportedLogic": "当前版本保存规则时仅支持 AND 条件,OR / NOT 逻辑暂未接入后端存储",
|
||||
"triggerRequired": "请至少添加一个触发器",
|
||||
"intervalTriggerRequired": "请至少添加一个“间隔时间”触发器,否则规则不会自动执行",
|
||||
"actionRequired": "请至少添加一个行动",
|
||||
"createSuccess": "自动化创建成功",
|
||||
"updateSuccess": "自动化更新成功",
|
||||
@@ -838,7 +840,10 @@
|
||||
"intervalAmountPlaceholder": "请输入间隔时间",
|
||||
"intervalUnitMonth": "个月",
|
||||
"intervalUnitDay": "天",
|
||||
"intervalUnitMinute": "分 钟",
|
||||
"intervalUnitMinute": "分钟",
|
||||
"intervalPartDay": "天",
|
||||
"intervalPartHour": "小时",
|
||||
"intervalPartMinute": "分钟",
|
||||
"minutesPlaceholder": "请输入分钟数",
|
||||
"minutes": "分钟",
|
||||
"tagsPlaceholder": "请输入标签名称",
|
||||
|
||||
+26
-6
@@ -59,6 +59,7 @@ export interface autoScoreRule {
|
||||
enabled: boolean
|
||||
studentNames: string[]
|
||||
triggers: autoScoreTrigger[]
|
||||
triggerTree?: any | null
|
||||
actions: autoScoreAction[]
|
||||
execution?: autoScoreExecutionConfig
|
||||
lastExecuted?: string | null
|
||||
@@ -255,17 +256,34 @@ const api = {
|
||||
queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
|
||||
invoke("event_query", { params }),
|
||||
createEvent: (data: {
|
||||
studentName: string
|
||||
reasonContent: string
|
||||
student_name?: string
|
||||
reason_content?: string
|
||||
delta: number
|
||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||
invoke("event_create", { data }),
|
||||
studentName?: string
|
||||
reasonContent?: string
|
||||
}): Promise<{ success: boolean; data?: number; message?: string }> => {
|
||||
const normalized = {
|
||||
student_name: String(data.student_name ?? data.studentName ?? "").trim(),
|
||||
reason_content: String(data.reason_content ?? data.reasonContent ?? "").trim(),
|
||||
delta: Number(data.delta),
|
||||
}
|
||||
return invoke("event_create", { data: normalized })
|
||||
},
|
||||
deleteEvent: (uuid: string): Promise<{ success: boolean }> => invoke("event_delete", { uuid }),
|
||||
queryEventsByStudent: (params: {
|
||||
studentName: string
|
||||
student_name?: string
|
||||
limit?: number
|
||||
start_time?: string
|
||||
studentName?: string
|
||||
startTime?: string
|
||||
}): Promise<{ success: boolean; data: any[] }> => invoke("event_query_by_student", { params }),
|
||||
}): Promise<{ success: boolean; data: any[] }> =>
|
||||
invoke("event_query_by_student", {
|
||||
params: {
|
||||
student_name: String(params.student_name ?? params.studentName ?? "").trim(),
|
||||
limit: params.limit,
|
||||
start_time: params.start_time ?? params.startTime,
|
||||
},
|
||||
}),
|
||||
queryLeaderboard: (params: {
|
||||
range: "today" | "week" | "month"
|
||||
}): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> =>
|
||||
@@ -298,6 +316,7 @@ const api = {
|
||||
enabled: boolean
|
||||
studentNames: string[]
|
||||
triggers: autoScoreTrigger[]
|
||||
triggerTree?: any | null
|
||||
actions: autoScoreAction[]
|
||||
execution?: autoScoreExecutionConfig
|
||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||
@@ -308,6 +327,7 @@ const api = {
|
||||
enabled: boolean
|
||||
studentNames: string[]
|
||||
triggers: autoScoreTrigger[]
|
||||
triggerTree?: any | null
|
||||
actions: autoScoreAction[]
|
||||
execution?: autoScoreExecutionConfig
|
||||
}): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
|
||||
Reference in New Issue
Block a user