diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6c69b4a..7b73cdf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1079,7 +1079,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -4242,6 +4242,7 @@ dependencies = [ "tracing-subscriber", "urlencoding", "uuid", + "winreg 0.52.0", ] [[package]] @@ -6913,6 +6914,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2abbe0c..4e6f8a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,6 +42,9 @@ dirs = "6.0.0" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } urlencoding = "2.1" +[target.'cfg(windows)'.dependencies] +winreg = "0.52" + [profile.release] panic = "abort" codegen-units = 1 diff --git a/src-tauri/data.sql b/src-tauri/data.sql index e03ea45..5fb6752 100644 Binary files a/src-tauri/data.sql and b/src-tauri/data.sql differ diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 3542307..36fbbed 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -335,7 +335,10 @@ pub async fn oauth_get_authorization_url( use rand::Rng; let mut rng = rand::thread_rng(); let random_bytes: Vec = (0..32).map(|_| rng.gen()).collect(); - base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &random_bytes) + base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + &random_bytes, + ) }); let url = format!( @@ -345,7 +348,10 @@ pub async fn oauth_get_authorization_url( urlencoding::encode(&state) ); - Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { url, state })) + Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { + url, + state, + })) } #[tauri::command] @@ -367,9 +373,9 @@ pub async fn oauth_exchange_code( ("client_secret", &platform_secret), ("redirect_uri", &callback_url), ]; - + println!("[OAuth] 请求参数:{:?}", params); - + let response = client .post("https://sectl.top/api/oauth/token") .form(¶ms) @@ -378,8 +384,11 @@ pub async fn oauth_exchange_code( .map_err(|e| format!("Request failed: {}", e))?; println!("[OAuth] 响应状态:{}", response.status()); - - let response_text = response.text().await.map_err(|e| format!("Failed to read response: {}", e))?; + + let response_text = response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e))?; println!("[OAuth] 响应内容:{}", response_text); if !response_text.is_empty() && response_text.contains("error") { diff --git a/src-tauri/src/commands/auto_score.rs b/src-tauri/src/commands/auto_score.rs index 8c2e639..2d6ad22 100644 --- a/src-tauri/src/commands/auto_score.rs +++ b/src-tauri/src/commands/auto_score.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; use crate::services::{ - AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger, PermissionLevel, - SettingsKey, SettingsValue, + query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch, + AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService, + AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue, }; use crate::state::AppState; @@ -27,7 +28,13 @@ 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, + #[serde(default)] + pub filters: AutoScoreFilterConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -38,7 +45,19 @@ 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, + #[serde(default)] + pub filters: AutoScoreFilterConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RollbackBatchParams { + #[serde(rename = "batchId")] + pub batch_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -60,7 +79,10 @@ 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, last_executed: None, } } @@ -72,7 +94,10 @@ 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, last_executed: None, } } @@ -336,3 +361,38 @@ pub async fn auto_score_sort_rules( Ok(IpcResponse::success(true)) } + +#[tauri::command] +pub async fn auto_score_query_batches( + sender_id: Option, + state: State<'_, Arc>>, +) -> Result>, String> { + { + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + if !check_admin_permission(&mut permissions, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + } + + let batches = query_execution_batches(state.inner()).await?; + Ok(IpcResponse::success(batches)) +} + +#[tauri::command] +pub async fn auto_score_rollback_batch( + params: RollbackBatchParams, + sender_id: Option, + state: State<'_, Arc>>, +) -> Result, String> { + { + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + if !check_admin_permission(&mut permissions, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + } + + let batch = rollback_execution_batch(state.inner(), ¶ms.batch_id).await?; + Ok(IpcResponse::success(batch)) +} 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/commands/filesystem.rs b/src-tauri/src/commands/filesystem.rs index 537c0fb..7a50197 100644 --- a/src-tauri/src/commands/filesystem.rs +++ b/src-tauri/src/commands/filesystem.rs @@ -3,6 +3,7 @@ use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use std::process::Command; use std::sync::Arc; use tauri::State; @@ -284,3 +285,55 @@ pub async fn fs_file_exists( Ok(IpcResponse::success(file_path.exists())) } + +#[tauri::command] +pub async fn fs_open_path( + relative_path: String, + folder: String, + _state: State<'_, Arc>>, +) -> Result, String> { + let folder_type = ConfigFolder::from_str(&folder) + .ok_or_else(|| format!("Invalid folder type: {}", folder))?; + + let folder_path = ensure_folder_exists(&folder_type)?; + let file_path = folder_path.join(&relative_path); + + if !file_path.exists() { + return Ok(IpcResponse::error("File not found")); + } + + open_path_with_system(&file_path)?; + Ok(IpcResponse::success_empty()) +} + +fn open_path_with_system(path: &PathBuf) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", "start", "", path.to_string_lossy().as_ref()]) + .spawn() + .map_err(|e| e.to_string())?; + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(path) + .spawn() + .map_err(|e| e.to_string())?; + return Ok(()); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + Command::new("xdg-open") + .arg(path) + .spawn() + .map_err(|e| e.to_string())?; + return Ok(()); + } + + #[allow(unreachable_code)] + Err("Unsupported platform".to_string()) +} diff --git a/src-tauri/src/commands/oauth_server.rs b/src-tauri/src/commands/oauth_server.rs index a1e2a41..0731bb4 100644 --- a/src-tauri/src/commands/oauth_server.rs +++ b/src-tauri/src/commands/oauth_server.rs @@ -1,342 +1,345 @@ -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use std::net::SocketAddr; -use std::sync::Arc; -use tauri::Emitter; -use tauri::State; -use tokio::sync::Mutex; -use tokio::sync::oneshot; - -use crate::state::AppState; - -use super::response::IpcResponse; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthServerStartResult { - pub url: String, - pub port: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthCallbackResult { - pub code: Option, - pub state: Option, - pub error: Option, - pub error_description: Option, -} - -static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy>>>> = - once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None))); - -#[tauri::command] -pub async fn oauth_start_callback_server( - app_handle: tauri::AppHandle, - _state: State<'_, Arc>>, -) -> Result, String> { - let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; - - // 如果服务器已经在运行,直接返回 URL - if shutdown_tx.is_some() { - let port = 16888u16; - let url = format!("http://127.0.0.1:{}/oauth/callback", port); - return Ok(IpcResponse::success(OAuthServerStartResult { url, port })); - } - - let port = 16888u16; - let addr: SocketAddr = ([127, 0, 0, 1], port).into(); - let url = format!("http://127.0.0.1:{}/oauth/callback", port); - let url_for_spawn = url.clone(); - - let (tx, rx) = oneshot::channel::<()>(); - *shutdown_tx = Some(tx); - drop(shutdown_tx); - - let app_handle_clone = app_handle.clone(); - - tokio::spawn(async move { - let app = axum::Router::new() - .route("/oauth/callback", axum::routing::get(handle_oauth_callback)) - .layer(axum::extract::Extension(app_handle_clone)); - - let listener = match tokio::net::TcpListener::bind(addr).await { - Ok(l) => l, - Err(e) => { - eprintln!("Failed to bind OAuth callback server: {}", e); - return; - } - }; - - println!("OAuth callback server started at {}", url_for_spawn); - - let server = axum::serve(listener, app); - - tokio::select! { - _ = server => {}, - _ = rx => { - println!("OAuth callback server shutting down"); - } - } - }); - - Ok(IpcResponse::success(OAuthServerStartResult { url, port })) -} - -#[tauri::command] -pub async fn oauth_stop_callback_server( - _state: State<'_, Arc>>, -) -> Result, String> { - let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; - - if let Some(tx) = shutdown_tx.take() { - let _ = tx.send(()); - } - - Ok(IpcResponse::success(())) -} - -async fn handle_oauth_callback( - axum::extract::Query(params): axum::extract::Query>, - axum::extract::Extension(app_handle): axum::extract::Extension, -) -> impl axum::response::IntoResponse { - let code = params.get("code").cloned(); - let state = params.get("state").cloned(); - let error = params.get("error").cloned(); - let error_description = params.get("error_description").cloned(); - - println!("[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", code, state, error); - - let result = OAuthCallbackResult { - code: code.clone(), - state: state.clone(), - error: error.clone(), - error_description: error_description.clone(), - }; - - match app_handle.emit("oauth-callback", result) { - Ok(_) => println!("[OAuth Callback] Event 发送成功"), - Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e), - } - - let html = r#" - - - - - OAuth 授权完成 - - - -
-
- - - -
-

授权成功

-

请返回 SecScore 应用查看登录结果

-

此窗口可以关闭

-
- - - - "#; - - let error_html = r#" - - - - - OAuth 授权失败 - - - -
-
- - - -
-

授权失败

-

请返回 SecScore 应用查看错误信息

-

此窗口可以关闭

-
- - - "#; - - let response_html = if error.is_some() { error_html } else { html }; - - ( - axum::http::StatusCode::OK, - [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], - response_html, - ) -} +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::sync::Arc; +use tauri::Emitter; +use tauri::State; +use tokio::sync::oneshot; +use tokio::sync::Mutex; + +use crate::state::AppState; + +use super::response::IpcResponse; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthServerStartResult { + pub url: String, + pub port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthCallbackResult { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy>>>> = + once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None))); + +#[tauri::command] +pub async fn oauth_start_callback_server( + app_handle: tauri::AppHandle, + _state: State<'_, Arc>>, +) -> Result, String> { + let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; + + // 如果服务器已经在运行,直接返回 URL + if shutdown_tx.is_some() { + let port = 16888u16; + let url = format!("http://127.0.0.1:{}/oauth/callback", port); + return Ok(IpcResponse::success(OAuthServerStartResult { url, port })); + } + + let port = 16888u16; + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let url = format!("http://127.0.0.1:{}/oauth/callback", port); + let url_for_spawn = url.clone(); + + let (tx, rx) = oneshot::channel::<()>(); + *shutdown_tx = Some(tx); + drop(shutdown_tx); + + let app_handle_clone = app_handle.clone(); + + tokio::spawn(async move { + let app = axum::Router::new() + .route("/oauth/callback", axum::routing::get(handle_oauth_callback)) + .layer(axum::extract::Extension(app_handle_clone)); + + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("Failed to bind OAuth callback server: {}", e); + return; + } + }; + + println!("OAuth callback server started at {}", url_for_spawn); + + let server = axum::serve(listener, app); + + tokio::select! { + _ = server => {}, + _ = rx => { + println!("OAuth callback server shutting down"); + } + } + }); + + Ok(IpcResponse::success(OAuthServerStartResult { url, port })) +} + +#[tauri::command] +pub async fn oauth_stop_callback_server( + _state: State<'_, Arc>>, +) -> Result, String> { + let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; + + if let Some(tx) = shutdown_tx.take() { + let _ = tx.send(()); + } + + Ok(IpcResponse::success(())) +} + +async fn handle_oauth_callback( + axum::extract::Query(params): axum::extract::Query>, + axum::extract::Extension(app_handle): axum::extract::Extension, +) -> impl axum::response::IntoResponse { + let code = params.get("code").cloned(); + let state = params.get("state").cloned(); + let error = params.get("error").cloned(); + let error_description = params.get("error_description").cloned(); + + println!( + "[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", + code, state, error + ); + + let result = OAuthCallbackResult { + code: code.clone(), + state: state.clone(), + error: error.clone(), + error_description: error_description.clone(), + }; + + match app_handle.emit("oauth-callback", result) { + Ok(_) => println!("[OAuth Callback] Event 发送成功"), + Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e), + } + + let html = r#" + + + + + OAuth 授权完成 + + + +
+
+ + + +
+

授权成功

+

请返回 SecScore 应用查看登录结果

+

此窗口可以关闭

+
+ + + + "#; + + let error_html = r#" + + + + + OAuth 授权失败 + + + +
+
+ + + +
+

授权失败

+

请返回 SecScore 应用查看错误信息

+

此窗口可以关闭

+
+ + + "#; + + let response_html = if error.is_some() { error_html } else { html }; + + ( + axum::http::StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + response_html, + ) +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index d788fc9..9e20276 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -2,9 +2,14 @@ use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::BTreeSet; -use std::process::Command; use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::process::Command; +#[cfg(target_os = "windows")] +use winreg::enums::HKEY_LOCAL_MACHINE; +#[cfg(target_os = "windows")] +use winreg::RegKey; use crate::services::{ settings::{ @@ -161,20 +166,6 @@ pub async fn settings_set( Ok(IpcResponse::success(())) } -fn fallback_font_families() -> Vec { - vec![ - "系统默认".to_string(), - "Microsoft YaHei UI".to_string(), - "Microsoft YaHei".to_string(), - "PingFang SC".to_string(), - "Noto Sans CJK SC".to_string(), - "Source Han Sans SC".to_string(), - "Segoe UI".to_string(), - "Arial".to_string(), - "Helvetica Neue".to_string(), - ] -} - fn normalize_font_name(value: &str) -> String { value .trim() @@ -188,33 +179,20 @@ fn normalize_font_name(value: &str) -> String { #[cfg(target_os = "windows")] fn query_system_fonts() -> Vec { - let output = Command::new("reg") - .args([ - "query", - r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", - ]) - .output(); + let mut families = BTreeSet::new(); - let Ok(output) = output else { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"); + let Ok(key) = key else { return Vec::new(); }; - if !output.status.success() { - return Vec::new(); - } - let mut families = BTreeSet::new(); - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if !line.contains("REG_") { + for (value_name, _) in key.enum_values().flatten() { + let name = normalize_font_name(&value_name); + if name.is_empty() { continue; } - let Some((left, _)) = line.split_once("REG_") else { - continue; - }; - let name = normalize_font_name(left); - if !name.is_empty() { - families.insert(name); - } + families.insert(name); } families.into_iter().collect() @@ -276,19 +254,5 @@ fn query_system_fonts() -> Vec { #[tauri::command] pub async fn settings_get_system_fonts() -> Result>, String> { - let mut fonts = query_system_fonts(); - if fonts.is_empty() { - fonts = fallback_font_families(); - } else { - let mut merged = BTreeSet::new(); - for item in fallback_font_families() { - merged.insert(item); - } - for item in fonts { - merged.insert(item); - } - fonts = merged.into_iter().collect(); - } - - Ok(IpcResponse::success(fonts)) + Ok(IpcResponse::success(query_system_fonts())) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72273fd..4094bab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -96,6 +96,8 @@ pub fn run() { auto_score_toggle_rule, auto_score_get_status, auto_score_sort_rules, + auto_score_query_batches, + auto_score_rollback_batch, board_get_configs, board_save_configs, board_query_sql, @@ -126,6 +128,7 @@ pub fn run() { fs_delete_file, fs_list_files, fs_file_exists, + fs_open_path, http_server_start, http_server_stop, http_server_status, diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index b3dcdf2..d44326d 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -1,10 +1,23 @@ use chrono::{DateTime, Months, Utc}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, + QueryFilter, Set, Statement, TransactionTrait, +}; use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; -use std::collections::HashSet; -use tauri::{AppHandle, Emitter}; +use serde_json::{json, Value as JsonValue}; +use std::collections::{HashMap, HashSet}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::time::{interval, Duration}; +use uuid::Uuid; + +use crate::db::entities::{score_events, student_tags, students, tags}; +use crate::services::settings::{SettingsKey, SettingsValue}; +use crate::state::SafeAppState; const DEFAULT_INTERVAL_MINUTES: i64 = 30; +const AUTO_SCORE_TICK_SECONDS: u64 = 15; +const AUTO_SCORE_SQL_LIMIT: u64 = 5000; +const AUTO_SCORE_REASON_PREFIX: &str = "自动化"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AutoScoreTrigger { @@ -18,6 +31,42 @@ pub struct AutoScoreAction { pub value: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct AutoScoreExecutionConfig { + pub cooldown_minutes: Option, + pub max_runs_per_day: Option, + pub max_score_delta_per_day: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct AutoScoreFilterConfig { + pub groups: Vec, + pub grades: Vec, + pub min_score: Option, + pub max_score: Option, + pub recent_event_days: Option, + pub min_recent_event_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AutoScoreExecutionBatch { + pub id: String, + pub rule_id: i32, + pub rule_name: String, + pub run_at: String, + pub affected_students: usize, + pub affected_student_names: Vec, + pub created_event_ids: Vec, + pub added_student_tag_ids: Vec, + pub score_delta_total: i64, + pub settled: bool, + pub rolled_back: bool, + pub rollback_at: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] enum IntervalUnit { @@ -26,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)] @@ -40,11 +89,42 @@ 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, + #[serde(default)] + pub filters: AutoScoreFilterConfig, #[serde(rename = "lastExecuted")] pub last_executed: Option, } +#[derive(Debug, Clone)] +enum PlannedAction { + AddScore(i32), + AddTags(Vec), + SettleScore, +} + +#[derive(Debug, Default, Clone)] +struct StudentRefs { + ids: HashSet, + names: HashSet, +} + +#[derive(Debug, Default, Clone)] +struct RuleExecutionStats { + affected_students: usize, + affected_student_names: Vec, + created_events: usize, + added_tags: usize, + score_delta_total: i64, + created_event_ids: Vec, + added_student_tag_ids: Vec, + settled: bool, +} + impl Default for AutoScoreRule { fn default() -> Self { Self { @@ -53,7 +133,10 @@ 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(), last_executed: None, } } @@ -87,12 +170,14 @@ impl AutoScoreService { pub async fn initialize( &mut self, - _app_handle: &AppHandle, + app_handle: &AppHandle, ) -> Result<(), Box> { if self.initialized { return Ok(()); } + self.initialized = true; + Self::spawn_scheduler(app_handle.clone()); Ok(()) } @@ -211,66 +296,535 @@ impl AutoScoreService { } pub fn check_interval_trigger(&self, rule: &AutoScoreRule) -> Option { - let now = Utc::now(); - - 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 base_time = rule - .last_executed - .as_ref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - .unwrap_or(now); - - 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)); - } - - None + check_interval_trigger(rule) } pub async fn notify_rules_changed(&self, app_handle: &AppHandle) { let _ = app_handle.emit("auto-score:rulesChanged", &self.rules); } + + fn spawn_scheduler(app_handle: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut ticker = interval(Duration::from_secs(AUTO_SCORE_TICK_SECONDS)); + loop { + ticker.tick().await; + if let Err(error) = Self::run_scheduler_tick(&app_handle).await { + eprintln!("auto score scheduler tick failed: {}", error); + } + } + }); + } + + async fn run_scheduler_tick(app_handle: &AppHandle) -> Result<(), String> { + let state = app_handle.state::().inner().clone(); + let mut execution_batches = load_batches_from_settings(&state).await?; + + let rules_snapshot = { + let state_guard = state.read(); + let auto_score = state_guard.auto_score.read(); + auto_score.get_rules().to_vec() + }; + + if !rules_snapshot.iter().any(|rule| rule.enabled) { + return Ok(()); + } + + let conn = { + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + db_conn + } + .ok_or_else(|| "Database not connected".to_string())?; + + let mut next_rules = rules_snapshot.clone(); + let mut changed = false; + + for rule in next_rules.iter_mut().filter(|rule| rule.enabled) { + let Some(delay_ms) = check_interval_trigger(rule) else { + continue; + }; + if delay_ms > 0 { + continue; + } + + match execute_rule(&conn, rule, &execution_batches).await { + Ok(stats) => { + if stats.affected_students == 0 { + Self::log_rule_skipped(&state, rule, "no matched students"); + continue; + } + rule.last_executed = Some(Utc::now().to_rfc3339()); + changed = true; + Self::log_rule_executed(&state, rule, &stats); + let batch = AutoScoreExecutionBatch { + id: Uuid::new_v4().to_string(), + rule_id: rule.id, + rule_name: rule.name.clone(), + run_at: now_iso(), + affected_students: stats.affected_students, + affected_student_names: stats.affected_student_names.clone(), + created_event_ids: stats.created_event_ids.clone(), + added_student_tag_ids: stats.added_student_tag_ids.clone(), + score_delta_total: stats.score_delta_total, + settled: stats.settled, + rolled_back: false, + rollback_at: None, + }; + execution_batches.push(batch); + } + Err(error) => { + Self::log_rule_failed(&state, rule, &error); + } + } + } + + if !changed { + return Ok(()); + } + + persist_rules_to_settings(&state, &next_rules).await?; + save_batches_to_settings(&state, &execution_batches).await?; + + { + let state_guard = state.read(); + let mut auto_score = state_guard.auto_score.write(); + auto_score.replace_rules(next_rules.clone()); + } + + let enabled = next_rules.iter().any(|rule| rule.enabled); + let _ = app_handle.emit("auto-score:rulesChanged", &next_rules); + let _ = app_handle.emit( + "settings:changed", + json!({ + "key": "auto_score_enabled", + "value": enabled, + }), + ); + + Ok(()) + } + + fn log_rule_executed(state: &SafeAppState, rule: &AutoScoreRule, stats: &RuleExecutionStats) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.info_with_meta( + "auto_score:rule_executed", + json!({ + "rule_id": rule.id, + "rule_name": rule.name, + "affected_students": stats.affected_students, + "created_events": stats.created_events, + "added_tags": stats.added_tags, + "score_delta_total": stats.score_delta_total, + "settled": stats.settled, + }), + ); + } + + fn log_rule_failed(state: &SafeAppState, rule: &AutoScoreRule, error: &str) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.warn_with_meta( + "auto_score:rule_failed", + json!({ + "rule_id": rule.id, + "rule_name": rule.name, + "error": error, + }), + ); + } + + fn log_rule_skipped(state: &SafeAppState, rule: &AutoScoreRule, reason: &str) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.info_with_meta( + "auto_score:rule_skipped", + json!({ + "rule_id": rule.id, + "rule_name": rule.name, + "reason": reason, + }), + ); + } } 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() .map(normalize_action) .collect::, _>>()?; + rule.execution = normalize_execution_config(rule.execution)?; + rule.filters = normalize_filter_config(rule.filters)?; rule.last_executed = normalize_last_executed(rule.last_executed); Ok(rule) } +fn normalize_execution_config( + mut config: AutoScoreExecutionConfig, +) -> Result { + config.cooldown_minutes = config.cooldown_minutes.filter(|value| *value > 0); + config.max_runs_per_day = config.max_runs_per_day.filter(|value| *value > 0); + config.max_score_delta_per_day = config.max_score_delta_per_day.filter(|value| *value > 0); + Ok(config) +} + +fn normalize_filter_config(mut config: AutoScoreFilterConfig) -> Result { + config.groups = dedupe_trimmed_strings(config.groups); + config.grades = dedupe_trimmed_strings(config.grades); + if let (Some(min_score), Some(max_score)) = (config.min_score, config.max_score) { + if min_score > max_score { + return Err("minScore cannot be greater than maxScore".to_string()); + } + } + config.recent_event_days = config.recent_event_days.filter(|value| *value > 0); + config.min_recent_event_count = config.min_recent_event_count.filter(|value| *value >= 0); + 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::>(), + ), + ), + ] + .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_score_gt" => ( + "student_score", + "greater", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + "student_score_lt" => ( + "student_score", + "less", + 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 operator = node + .get("properties") + .and_then(|value| value.get("operator")) + .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) + } + "student_score" | "student_score_gt" | "student_score_lt" => { + let score = match first_value { + Some(JsonValue::Number(value)) => value.to_string(), + Some(JsonValue::String(value)) => value, + _ => String::new(), + }; + let event = if operator.eq_ignore_ascii_case("less") || field == "student_score_lt" { + "student_score_lt" + } else { + "student_score_gt" + }; + let trigger = AutoScoreTrigger { + event: event.to_string(), + value: if score.trim().is_empty() { None } else { Some(score) }, + }; + 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")?; @@ -296,6 +850,29 @@ fn normalize_trigger(trigger: AutoScoreTrigger) -> Result { + let raw_sql = trigger + .value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "SQL trigger requires non-empty SQL".to_string())?; + if !is_valid_auto_score_sql(&raw_sql) { + return Err("Only read-only SQL expression/query is allowed".to_string()); + } + + Ok(AutoScoreTrigger { + event, + value: Some(raw_sql), + }) + } + "student_score_gt" | "student_score_lt" => { + let threshold = normalize_integer_string(trigger.value.as_deref()) + .ok_or_else(|| "Student score trigger requires an integer value".to_string())?; + Ok(AutoScoreTrigger { + event, + value: Some(threshold), + }) + } _ => Err(format!("Unsupported trigger event: {}", event)), } } @@ -305,8 +882,8 @@ fn normalize_action(action: AutoScoreAction) -> Result match event.as_str() { "add_score" => { - let value = normalize_non_zero_numeric_string(action.value.as_deref()) - .ok_or_else(|| "Add score action requires a non-zero numeric value".to_string())?; + let value = normalize_non_zero_integer_string(action.value.as_deref()) + .ok_or_else(|| "Add score action requires a non-zero integer value".to_string())?; Ok(AutoScoreAction { event, @@ -323,6 +900,7 @@ fn normalize_action(action: AutoScoreAction) -> Result value: Some(value), }) } + "settle_score" => Ok(AutoScoreAction { event, value: None }), _ => Err(format!("Unsupported action event: {}", event)), } } @@ -384,18 +962,27 @@ fn stringify_tag_values(values: &[String]) -> Option { serde_json::to_string(values).ok() } -fn normalize_non_zero_numeric_string(value: Option<&str>) -> Option { +fn normalize_non_zero_integer_string(value: Option<&str>) -> Option { let normalized = value?.trim(); if normalized.is_empty() { return None; } - let parsed = normalized.parse::().ok()?; - if !parsed.is_finite() || parsed == 0.0 { + let parsed = normalized.parse::().ok()?; + if parsed == 0 { return None; } - Some(normalized.to_string()) + Some(parsed.to_string()) +} + +fn normalize_integer_string(value: Option<&str>) -> Option { + let normalized = value?.trim(); + if normalized.is_empty() { + return None; + } + let parsed = normalized.parse::().ok()?; + Some(parsed.to_string()) } fn normalize_last_executed(value: Option) -> Option { @@ -405,6 +992,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() { @@ -413,29 +1016,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() + } } } @@ -443,14 +1092,1034 @@ 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 delays = rule + .triggers + .iter() + .filter(|trigger| trigger.event == "interval_time_passed") + .map(|trigger| get_interval_delay_ms(rule, trigger.value.as_deref())) + .collect::>>(); + + if delays.is_empty() { + return 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 { + let forbidden: HashSet<&str> = [ + "insert", "update", "delete", "drop", "alter", "create", "truncate", "reindex", "vacuum", + "grant", "revoke", "commit", "rollback", "begin", "attach", "detach", "pragma", "analyze", + "merge", "call", "execute", + ] + .into_iter() + .collect(); + + let mut token = String::new(); + for ch in sql.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + token.push(ch); + continue; + } + + if !token.is_empty() { + if forbidden.contains(token.as_str()) { + return true; + } + token.clear(); + } + } + + !token.is_empty() && forbidden.contains(token.as_str()) +} + +fn starts_with_select_or_with(sql: &str) -> bool { + sql.split_whitespace() + .next() + .map(|first| { + let first_lower = first.to_ascii_lowercase(); + first_lower == "select" || first_lower == "with" + }) + .unwrap_or(false) +} + +fn has_sql_comment_or_multi_statement(sql: &str) -> bool { + sql.contains(';') || sql.contains("--") || sql.contains("/*") || sql.contains("*/") +} + +fn is_readonly_query(sql: &str) -> bool { + let trimmed = sql.trim(); + if trimmed.is_empty() { + return false; + } + + if has_sql_comment_or_multi_statement(trimmed) { + return false; + } + + if !starts_with_select_or_with(trimmed) { + return false; + } + + !contains_forbidden_keyword(&trimmed.to_ascii_lowercase()) +} + +fn is_safe_sql_expression(sql: &str) -> bool { + let trimmed = sql.trim(); + if trimmed.is_empty() { + return false; + } + + if has_sql_comment_or_multi_statement(trimmed) { + return false; + } + + !contains_forbidden_keyword(&trimmed.to_ascii_lowercase()) +} + +fn is_valid_auto_score_sql(sql: &str) -> bool { + let trimmed = sql.trim(); + if trimmed.is_empty() { + return false; + } + + if starts_with_select_or_with(trimmed) { + is_readonly_query(trimmed) + } else { + is_safe_sql_expression(trimmed) + } +} + +fn build_readonly_student_query(sql_or_expression: &str) -> Result { + let trimmed = sql_or_expression.trim(); + if trimmed.is_empty() { + return Err("Empty SQL".to_string()); + } + + let normalized = if starts_with_select_or_with(trimmed) { + if !is_readonly_query(trimmed) { + return Err("Only read-only SELECT/CTE query is allowed".to_string()); + } + trimmed.to_string() + } else { + if !is_safe_sql_expression(trimmed) { + return Err("Only safe SQL expression is allowed".to_string()); + } + format!("SELECT id, name FROM students WHERE ({})", trimmed) + }; + + Ok(format!( + "SELECT * FROM ({}) AS ss_auto_score_query LIMIT {}", + normalized, AUTO_SCORE_SQL_LIMIT + )) +} + +async fn persist_rules_to_settings( + state: &SafeAppState, + rules: &[AutoScoreRule], +) -> Result<(), String> { + let rules_json = AutoScoreService::serialize_rules(rules)?; + let enabled = rules.iter().any(|rule| rule.enabled); + + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + let mut settings = state_guard.settings.write(); + settings.attach_db(db_conn); + settings.initialize().await?; + settings + .set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json)) + .await?; + settings + .set_value( + SettingsKey::AutoScoreEnabled, + SettingsValue::Boolean(enabled), + ) + .await?; + + Ok(()) +} + +fn deserialize_batches(value: &JsonValue) -> Vec { + let JsonValue::Array(items) = value else { + return Vec::new(); + }; + + items + .iter() + .filter_map(|item| serde_json::from_value::(item.clone()).ok()) + .collect() +} + +async fn load_batches_from_settings(state: &SafeAppState) -> Result, String> { + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + let mut settings = state_guard.settings.write(); + settings.attach_db(db_conn); + settings.initialize().await?; + let raw = match settings.get_value(SettingsKey::AutoScoreBatches) { + SettingsValue::Json(value) => value, + _ => JsonValue::Array(vec![]), + }; + Ok(deserialize_batches(&raw)) +} + +async fn save_batches_to_settings( + state: &SafeAppState, + batches: &[AutoScoreExecutionBatch], +) -> Result<(), String> { + let encoded = serde_json::to_value(batches).map_err(|e| e.to_string())?; + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + let mut settings = state_guard.settings.write(); + settings.attach_db(db_conn); + settings.initialize().await?; + settings + .set_value(SettingsKey::AutoScoreBatches, SettingsValue::Json(encoded)) + .await?; + Ok(()) +} + +pub async fn query_execution_batches(state: &SafeAppState) -> Result, String> { + let mut batches = load_batches_from_settings(state).await?; + batches.sort_by(|a, b| b.run_at.cmp(&a.run_at)); + Ok(batches) +} + +pub async fn rollback_execution_batch( + state: &SafeAppState, + batch_id: &str, +) -> Result { + let conn = { + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + db_conn + } + .ok_or_else(|| "Database not connected".to_string())?; + + let mut batches = load_batches_from_settings(state).await?; + let Some(index) = batches.iter().position(|batch| batch.id == batch_id) else { + return Err("Execution batch not found".to_string()); + }; + + if batches[index].rolled_back { + return Err("Execution batch already rolled back".to_string()); + } + if batches[index].settled { + return Err("Cannot rollback settled execution batch".to_string()); + } + + let mut batch = batches[index].clone(); + let txn = conn.begin().await.map_err(|e| e.to_string())?; + + for event_id in &batch.created_event_ids { + let event = score_events::Entity::find_by_id(*event_id) + .one(&txn) + .await + .map_err(|e| e.to_string())?; + let Some(event) = event else { + continue; + }; + if event.settlement_id.is_some() { + return Err("Cannot rollback batch containing settled events".to_string()); + } + score_events::Entity::delete_by_id(*event_id) + .exec(&txn) + .await + .map_err(|e| e.to_string())?; + + if let Some(student) = students::Entity::find() + .filter(students::Column::Name.eq(event.student_name.clone())) + .one(&txn) + .await + .map_err(|e| e.to_string())? + { + let current_score = student.score; + let current_reward_points = student.reward_points; + let mut student_active: students::ActiveModel = student.into(); + let next_score = current_score - event.delta; + let next_reward = current_reward_points - event.delta; + student_active.score = Set(next_score); + student_active.reward_points = Set(next_reward); + student_active.updated_at = Set(now_iso()); + student_active + .update(&txn) + .await + .map_err(|e| e.to_string())?; + } + } + + for link_id in &batch.added_student_tag_ids { + student_tags::Entity::delete_by_id(*link_id) + .exec(&txn) + .await + .map_err(|e| e.to_string())?; + } + + txn.commit().await.map_err(|e| e.to_string())?; + + batch.rolled_back = true; + batch.rollback_at = Some(now_iso()); + batches[index] = batch.clone(); + save_batches_to_settings(state, &batches).await?; + Ok(batch) +} + +async fn execute_rule( + conn: &DatabaseConnection, + rule: &AutoScoreRule, + execution_batches: &[AutoScoreExecutionBatch], +) -> Result { + let mut target_students = resolve_target_students(conn, rule).await?; + + if !rule.student_names.is_empty() { + let whitelist: HashSet = rule + .student_names + .iter() + .map(|name| name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(); + target_students.retain(|student| whitelist.contains(&student.name)); + } + + if target_students.is_empty() { + return Ok(RuleExecutionStats::default()); + } + + if let Some(max_runs) = rule.execution.max_runs_per_day { + let today_runs = execution_batches + .iter() + .filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at)) + .count() as i64; + if today_runs >= max_runs { + return Ok(RuleExecutionStats::default()); + } + } + + let planned_actions = plan_actions(&rule.actions)?; + if planned_actions.is_empty() { + return Err("No executable action".to_string()); + } + let should_settle = planned_actions + .iter() + .any(|action| matches!(action, PlannedAction::SettleScore)); + + let mut daily_score_delta_used: i64 = execution_batches + .iter() + .filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at)) + .map(|batch| batch.score_delta_total.abs()) + .sum(); + + let txn = conn.begin().await.map_err(|e| e.to_string())?; + let mut stats = RuleExecutionStats::default(); + + let mut all_action_tags = Vec::new(); + for action in &planned_actions { + if let PlannedAction::AddTags(tags) = action { + all_action_tags.extend(tags.iter().cloned()); + } + } + let all_action_tags = dedupe_trimmed_strings(all_action_tags); + + let mut tag_name_to_id = std::collections::HashMap::new(); + for tag_name in all_action_tags { + let tag_id = ensure_tag_exists_in_txn(&txn, &tag_name).await?; + tag_name_to_id.insert(tag_name, tag_id); + } + + for mut student in target_students { + if !is_student_pass_cooldown( + conn, + execution_batches, + rule, + student.id, + student.name.as_str(), + ) + .await? + { + continue; + } + let mut touched = false; + for action in &planned_actions { + match action { + PlannedAction::AddScore(delta) => { + if let Some(max_delta) = rule.execution.max_score_delta_per_day { + let next = daily_score_delta_used + (*delta as i64).abs(); + if next > max_delta { + continue; + } + daily_score_delta_used = next; + } + let now = now_iso(); + let val_prev = student.score; + let val_curr = val_prev + delta; + let reward_points = student.reward_points + delta; + + let event = score_events::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + uuid: Set(Uuid::new_v4().to_string()), + student_name: Set(student.name.clone()), + reason_content: Set(build_auto_score_reason(rule.id, &rule.name, *delta)), + delta: Set(*delta), + val_prev: Set(val_prev), + val_curr: Set(val_curr), + event_time: Set(now.clone()), + settlement_id: Set(None), + }; + let inserted_event = event.insert(&txn).await.map_err(|e| e.to_string())?; + stats.created_events += 1; + stats.created_event_ids.push(inserted_event.id); + stats.score_delta_total += *delta as i64; + + let mut student_active: students::ActiveModel = student.clone().into(); + student_active.score = Set(val_curr); + student_active.reward_points = Set(reward_points); + student_active.updated_at = Set(now); + student_active + .update(&txn) + .await + .map_err(|e| e.to_string())?; + + student.score = val_curr; + student.reward_points = reward_points; + touched = true; + } + PlannedAction::AddTags(tag_names) => { + for tag_name in tag_names { + let Some(tag_id) = tag_name_to_id.get(tag_name).copied() else { + continue; + }; + let inserted = + attach_tag_to_student_if_missing(&txn, student.id, tag_id).await?; + if let Some(link_id) = inserted { + stats.added_tags += 1; + stats.added_student_tag_ids.push(link_id); + touched = true; + } + } + } + PlannedAction::SettleScore => {} + } + } + if touched { + stats.affected_students += 1; + stats.affected_student_names.push(student.name.clone()); + } + } + + txn.commit().await.map_err(|e| e.to_string())?; + if should_settle { + execute_settlement(conn).await?; + stats.settled = true; + } + Ok(stats) +} + +fn plan_actions(actions: &[AutoScoreAction]) -> Result, String> { + let mut planned = Vec::new(); + for action in actions { + match action.event.as_str() { + "add_score" => { + let delta = action + .value + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value != 0) + .ok_or_else(|| "Invalid add_score value".to_string())?; + planned.push(PlannedAction::AddScore(delta)); + } + "add_tag" => { + let tags = parse_tag_values(action.value.as_deref()); + if tags.is_empty() { + return Err("Invalid add_tag value".to_string()); + } + planned.push(PlannedAction::AddTags(tags)); + } + "settle_score" => { + planned.push(PlannedAction::SettleScore); + } + _ => {} + } + } + Ok(planned) +} + +async fn execute_settlement(conn: &DatabaseConnection) -> Result<(), String> { + let backend = conn.get_database_backend(); + let count_stmt = Statement::from_string( + backend, + "SELECT COUNT(*) AS cnt FROM score_events WHERE settlement_id IS NULL", + ); + let count_row = conn.query_one(count_stmt).await.map_err(|e| e.to_string())?; + let Some(count_row) = count_row else { + return Ok(()); + }; + let unsettled_count = try_get_i64(&count_row, "cnt").unwrap_or(0); + if unsettled_count <= 0 { + return Ok(()); + } + + let last_end_sql = match backend { + sea_orm::DatabaseBackend::Sqlite => { + "SELECT end_time AS end_time FROM settlements ORDER BY julianday(end_time) DESC LIMIT 1" + } + sea_orm::DatabaseBackend::Postgres => { + "SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1" + } + _ => "SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1", + }; + let min_event_sql = + "SELECT MIN(event_time) AS min_event_time FROM score_events WHERE settlement_id IS NULL"; + + let last_end = conn + .query_one(Statement::from_string(backend, last_end_sql)) + .await + .map_err(|e| e.to_string())? + .and_then(|row| try_get_string(&row, "end_time")); + + let min_event_time = conn + .query_one(Statement::from_string(backend, min_event_sql)) + .await + .map_err(|e| e.to_string())? + .and_then(|row| try_get_string(&row, "min_event_time")); + + let end_time = now_iso(); + let start_time = last_end.or(min_event_time).unwrap_or_else(|| end_time.clone()); + let created_at = end_time.clone(); + + match backend { + sea_orm::DatabaseBackend::Postgres => { + let insert_stmt = Statement::from_string( + backend, + format!( + "INSERT INTO settlements (start_time, end_time, created_at) VALUES ('{}', '{}', '{}') RETURNING id", + start_time.replace('\'', "''"), + end_time.replace('\'', "''"), + created_at.replace('\'', "''") + ), + ); + let row = conn + .query_one(insert_stmt) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Failed to create settlement".to_string())?; + let settlement_id = try_get_i32(&row, "id") + .ok_or_else(|| "Failed to read settlement id".to_string())?; + + let update_events_stmt = Statement::from_string( + backend, + format!( + "UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL", + settlement_id + ), + ); + conn.execute(update_events_stmt).await.map_err(|e| e.to_string())?; + + let update_students_stmt = Statement::from_string( + backend, + format!( + "UPDATE students SET score = 0, updated_at = '{}' ", + end_time.replace('\'', "''") + ), + ); + conn.execute(update_students_stmt).await.map_err(|e| e.to_string())?; + } + _ => { + let insert_stmt = Statement::from_string( + backend, + format!( + "INSERT INTO settlements (start_time, end_time, created_at) VALUES ('{}', '{}', '{}')", + start_time.replace('\'', "''"), + end_time.replace('\'', "''"), + created_at.replace('\'', "''") + ), + ); + conn.execute(insert_stmt).await.map_err(|e| e.to_string())?; + + let id_stmt = Statement::from_string(backend, "SELECT last_insert_rowid() AS id"); + let row = conn + .query_one(id_stmt) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Failed to read settlement id".to_string())?; + let settlement_id = try_get_i32(&row, "id") + .ok_or_else(|| "Failed to read settlement id".to_string())?; + + let update_events_stmt = Statement::from_string( + backend, + format!( + "UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL", + settlement_id + ), + ); + conn.execute(update_events_stmt).await.map_err(|e| e.to_string())?; + + let update_students_stmt = Statement::from_string( + backend, + format!( + "UPDATE students SET score = 0, updated_at = '{}'", + end_time.replace('\'', "''") + ), + ); + conn.execute(update_students_stmt).await.map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +#[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" + ) { + if let Some(sql) = trigger.value.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + queries.push(sql.to_string()); + } + } + Ok(()) + } + _ => 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() + .all(conn) + .await + .map_err(|e| e.to_string())?; + if tag_models.is_empty() { + return Ok(HashMap::new()); + } + + let tag_name_by_id: HashMap = tag_models + .into_iter() + .map(|tag| (tag.id, tag.name)) + .collect(); + let links = student_tags::Entity::find() + .all(conn) + .await + .map_err(|e| e.to_string())?; + + 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) + } + "student_score_gt" => trigger + .value + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .map(|threshold| student.score > threshold) + .unwrap_or(false), + "student_score_lt" => trigger + .value + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .map(|threshold| student.score < threshold) + .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 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) +} + + +async fn is_student_pass_cooldown( + conn: &DatabaseConnection, + execution_batches: &[AutoScoreExecutionBatch], + rule: &AutoScoreRule, + student_id: i32, + student_name: &str, +) -> Result { + let Some(cooldown_minutes) = rule.execution.cooldown_minutes else { + return Ok(true); + }; + if cooldown_minutes <= 0 { + return Ok(true); + } + + let _ = student_id; + let cutoff = Utc::now() - chrono::Duration::minutes(cooldown_minutes); + for batch in execution_batches + .iter() + .filter(|batch| batch.rule_id == rule.id && !batch.rolled_back) + { + let run_at = DateTime::parse_from_rfc3339(batch.run_at.as_str()) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now() - chrono::Duration::days(3650)); + if run_at >= cutoff && batch.affected_student_names.iter().any(|name| name == student_name) + { + return Ok(false); + } + } + + let prefix = format!("{}#{}:", AUTO_SCORE_REASON_PREFIX, rule.id); + let since = cutoff + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + + let exists = score_events::Entity::find() + .filter(score_events::Column::StudentName.eq(student_name.to_string())) + .filter(score_events::Column::ReasonContent.like(format!("{}%", prefix))) + .filter(score_events::Column::EventTime.gte(since)) + .one(conn) + .await + .map_err(|e| e.to_string())?; + Ok(exists.is_none()) +} + +fn is_same_utc_day(timestamp: &str) -> bool { + DateTime::parse_from_rfc3339(timestamp) + .map(|value| value.with_timezone(&Utc).date_naive() == Utc::now().date_naive()) + .unwrap_or(false) +} + +async fn query_student_refs_by_sql( + conn: &DatabaseConnection, + sql_or_expression: &str, +) -> Result { + let wrapped_sql = build_readonly_student_query(sql_or_expression)?; + let rows = conn + .query_all(Statement::from_string( + conn.get_database_backend(), + wrapped_sql, + )) + .await + .map_err(|e| e.to_string())?; + + let mut refs = StudentRefs::default(); + for row in &rows { + if let Some(id) = try_get_i32(row, "id").or_else(|| try_get_i32(row, "student_id")) { + refs.ids.insert(id); + } + if let Some(name) = + try_get_string(row, "name").or_else(|| try_get_string(row, "student_name")) + { + refs.names.insert(name); + } + } + + Ok(refs) +} + +fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option { + row.try_get::("", column) + .ok() + .or_else(|| { + row.try_get::("", column) + .ok() + .and_then(|value| i32::try_from(value).ok()) + }) + .or_else(|| { + row.try_get::("", column) + .ok() + .and_then(|value| value.trim().parse::().ok()) + }) +} + +fn try_get_i64(row: &sea_orm::QueryResult, column: &str) -> Option { + row.try_get::("", column) + .ok() + .or_else(|| { + row.try_get::("", column) + .ok() + .map(|value| value as i64) + }) + .or_else(|| { + row.try_get::("", column) + .ok() + .and_then(|value| value.trim().parse::().ok()) + }) +} + +fn try_get_string(row: &sea_orm::QueryResult, column: &str) -> Option { + row.try_get::("", column).ok().and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +async fn ensure_tag_exists_in_txn( + txn: &sea_orm::DatabaseTransaction, + tag_name: &str, +) -> Result { + if let Some(existing) = tags::Entity::find() + .filter(tags::Column::Name.eq(tag_name)) + .one(txn) + .await + .map_err(|e| e.to_string())? + { + return Ok(existing.id); + } + + let now = now_iso(); + let inserted = tags::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + name: Set(tag_name.to_string()), + created_at: Set(now.clone()), + updated_at: Set(now), + } + .insert(txn) + .await + .map_err(|e| e.to_string())?; + + Ok(inserted.id) +} + +async fn attach_tag_to_student_if_missing( + txn: &sea_orm::DatabaseTransaction, + student_id: i32, + tag_id: i32, +) -> Result, String> { + let existing = student_tags::Entity::find() + .filter(student_tags::Column::StudentId.eq(student_id)) + .filter(student_tags::Column::TagId.eq(tag_id)) + .one(txn) + .await + .map_err(|e| e.to_string())?; + + if existing.is_some() { + return Ok(None); + } + + let now = now_iso(); + let inserted = student_tags::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + student_id: Set(student_id), + tag_id: Set(tag_id), + created_at: Set(now), + } + .insert(txn) + .await + .map_err(|e| e.to_string())?; + + Ok(Some(inserted.id)) +} + +fn now_iso() -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +fn build_auto_score_reason(rule_id: i32, rule_name: &str, delta: i32) -> String { + if delta > 0 { + format!( + "{}#{}: {} (+{})", + AUTO_SCORE_REASON_PREFIX, rule_id, rule_name, delta + ) + } else { + format!( + "{}#{}: {} ({})", + AUTO_SCORE_REASON_PREFIX, rule_id, rule_name, delta + ) + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 65c89ef..7a7ea77 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -8,7 +8,11 @@ pub mod settings; pub mod theme; pub use auth::AuthService; -pub use auto_score::{AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger}; +pub use auto_score::{ + query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch, + AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService, + AutoScoreTrigger, +}; pub use data::DataService; pub use logger::LoggerService; pub use permission::{PermissionLevel, PermissionService}; diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index 775d749..8a4d40e 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -14,6 +14,7 @@ pub struct SettingsSpec { pub themes_custom: JsonValue, pub auto_score_enabled: bool, pub auto_score_rules: JsonValue, + pub auto_score_batches: JsonValue, pub current_theme_id: String, pub dashboards_config: JsonValue, pub pg_connection_string: String, @@ -33,6 +34,7 @@ impl Default for SettingsSpec { themes_custom: JsonValue::Array(vec![]), auto_score_enabled: false, auto_score_rules: JsonValue::Array(vec![]), + auto_score_batches: JsonValue::Array(vec![]), current_theme_id: "light-default".to_string(), dashboards_config: JsonValue::Array(vec![]), pg_connection_string: String::new(), @@ -64,6 +66,7 @@ pub enum SettingsKey { ThemesCustom, AutoScoreEnabled, AutoScoreRules, + AutoScoreBatches, CurrentThemeId, DashboardsConfig, PgConnectionString, @@ -83,6 +86,7 @@ impl SettingsKey { SettingsKey::ThemesCustom => "themes_custom", SettingsKey::AutoScoreEnabled => "auto_score_enabled", SettingsKey::AutoScoreRules => "auto_score_rules", + SettingsKey::AutoScoreBatches => "auto_score_batches", SettingsKey::CurrentThemeId => "current_theme_id", SettingsKey::DashboardsConfig => "dashboards_config", SettingsKey::PgConnectionString => "pg_connection_string", @@ -102,6 +106,7 @@ impl SettingsKey { "themes_custom" => Some(SettingsKey::ThemesCustom), "auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled), "auto_score_rules" => Some(SettingsKey::AutoScoreRules), + "auto_score_batches" => Some(SettingsKey::AutoScoreBatches), "current_theme_id" => Some(SettingsKey::CurrentThemeId), "dashboards_config" => Some(SettingsKey::DashboardsConfig), "pg_connection_string" => Some(SettingsKey::PgConnectionString), @@ -378,6 +383,16 @@ impl SettingsService { }, ); + defs.insert( + SettingsKey::AutoScoreBatches, + SettingDefinition { + kind: SettingValueKind::Json, + default_value: SettingsValue::Json(JsonValue::Array(vec![])), + write_permission: PermissionRequirement::Admin, + validate: None, + }, + ); + defs.insert( SettingsKey::CurrentThemeId, SettingDefinition { @@ -586,6 +601,10 @@ impl SettingsService { SettingsValue::Json(j) => j, _ => JsonValue::Array(vec![]), }, + auto_score_batches: match self.get_value(SettingsKey::AutoScoreBatches) { + SettingsValue::Json(j) => j, + _ => JsonValue::Array(vec![]), + }, current_theme_id: match self.get_value(SettingsKey::CurrentThemeId) { SettingsValue::String(s) => s, _ => "light-default".to_string(), diff --git a/src/App.tsx b/src/App.tsx index 6f6d5b5..3708538 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,26 +33,15 @@ import { OAuthLogin } from "./components/OAuth/OAuthLogin" import { OAuthCallback } from "./components/OAuth/OAuthCallback" import { ThemeProvider, useTheme } from "./contexts/ThemeContext" import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation" +import { resolveStoredFontFamily } from "./shared/fontFamily" const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key) const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice( 0, 4 ) -const SYSTEM_FONT_STACK = - '"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif' - -const resolveFontFamily = (fontValue?: string): string => { - if (!fontValue || fontValue === "system") return SYSTEM_FONT_STACK - if (fontValue.startsWith("system-")) { - const family = fontValue.slice("system-".length).trim() - if (family) return `"${family}", ${SYSTEM_FONT_STACK}` - } - return SYSTEM_FONT_STACK -} - const applyGlobalFontFamily = (fontValue?: string) => { - const fontFamily = resolveFontFamily(fontValue) + const fontFamily = resolveStoredFontFamily(fontValue) document.documentElement.style.setProperty("--ss-font-family", fontFamily) document.body.style.fontFamily = fontFamily } diff --git a/src/components/AutoScore/ActionEditor.tsx b/src/components/AutoScore/ActionEditor.tsx index b17b44c..c1dd700 100644 --- a/src/components/AutoScore/ActionEditor.tsx +++ b/src/components/AutoScore/ActionEditor.tsx @@ -32,6 +32,7 @@ export const ActionEditor: React.FC = ({ () => [ { value: "add_score", label: t("autoScore.actionAddScore") }, { value: "add_tag", label: t("autoScore.actionAddTag") }, + { value: "settle_score", label: t("autoScore.actionSettleScore") }, ], [t] ) @@ -86,7 +87,7 @@ export const ActionEditor: React.FC = ({ onChange={(event: ActionEvent) => updateAction(action.id, { event, - value: event === "add_score" ? "1" : [], + value: event === "add_score" ? "1" : event === "add_tag" ? [] : "", }) } /> @@ -100,7 +101,7 @@ export const ActionEditor: React.FC = ({ updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) }) } /> - ) : ( + ) : action.event === "add_tag" ? ( +
+
+ + {t("autoScore.intervalPartDay")} + + updateValue({ days: Math.max(0, Number(next ?? 0)) })} + style={{ width: "100%" }} + /> +
+
+ + {t("autoScore.intervalPartHour")} + + updateValue({ hours: Math.max(0, Number(next ?? 0)) })} + style={{ width: "100%" }} + /> +
+
+ + {t("autoScore.intervalPartMinute")} + + updateValue({ minutes: Math.max(0, Number(next ?? 0)) })} + style={{ width: "100%" }} + /> +
) } diff --git a/src/components/AutoScore/TriggerRuleBuilder.tsx b/src/components/AutoScore/TriggerRuleBuilder.tsx index 1396f6a..40ce77d 100644 --- a/src/components/AutoScore/TriggerRuleBuilder.tsx +++ b/src/components/AutoScore/TriggerRuleBuilder.tsx @@ -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 = ({ style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }} title={t("autoScore.whenTriggered")} > +
`auto-score/rule-${ruleId}.json` + function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { const { t, i18n } = useTranslation() const [form] = Form.useForm() @@ -79,11 +89,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element const [actionDrafts, setActionDrafts] = useState([createDefaultActionDraft()]) const [students, setStudents] = useState([]) const [rules, setRules] = useState([]) + const [batches, setBatches] = useState([]) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) + const [rollingBackBatchId, setRollingBackBatchId] = useState(null) const [editingRuleId, setEditingRuleId] = useState(null) const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(10) + const [batchCurrentPage, setBatchCurrentPage] = useState(1) + const [batchPageSize, setBatchPageSize] = useState(10) useEffect(() => { const maxPage = Math.max(1, Math.ceil(rules.length / pageSize)) @@ -92,9 +106,20 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } }, [currentPage, pageSize, rules.length]) + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(batches.length / batchPageSize)) + if (batchCurrentPage > maxPage) { + setBatchCurrentPage(maxPage) + } + }, [batchCurrentPage, batchPageSize, batches.length]) + + useEffect(() => { + setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig)) + }, [triggerConfig]) + const resetEditor = () => { setEditingRuleId(null) - form.setFieldsValue({ name: "", studentNames: [] }) + form.setFieldsValue({ name: "", studentNames: [], execution: {} }) setTriggerTree(createEmptyTriggerTree(triggerConfig)) setActionDrafts([createDefaultActionDraft()]) } @@ -149,11 +174,25 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } } + const fetchBatches = async () => { + const api = (window as any).api + if (!api || !canEdit) return + try { + const res = await api.autoScoreQueryBatches() + if (res.success && Array.isArray(res.data)) { + setBatches(res.data) + } + } catch { + void 0 + } + } + useEffect(() => { if (!canEdit) return fetchTags().catch(() => void 0) fetchStudents().catch(() => void 0) fetchRules().catch(() => void 0) + fetchBatches().catch(() => void 0) }, [canEdit]) const handleSubmit = async () => { @@ -167,22 +206,23 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element const values = await form.validateFields() const name = String(values.name || "").trim() const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [] + const execution = values.execution || {} if (!name) { messageApi.warning(t("autoScore.nameRequired")) 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) @@ -213,7 +253,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element enabled: true, studentNames, triggers, + triggerTree: triggerTreeJson, actions: actionPayload.actions, + execution, } const res = editingRuleId === null @@ -226,6 +268,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element ) resetEditor() await fetchRules() + await fetchBatches() emitDataUpdated() } else { messageApi.error( @@ -247,8 +290,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element form.setFieldsValue({ name: rule.name, studentNames: rule.studentNames || [], + execution: rule.execution || {}, }) - setTriggerTree(triggersToQueryTree(triggerConfig, rule.triggers || [])) + setTriggerTree(triggerTreeJsonToQueryTree(triggerConfig, rule.triggerTree, rule.triggers || [])) setActionDrafts(actionsToDrafts(rule.actions || [])) } @@ -262,11 +306,13 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element const res = await api.autoScoreDeleteRule(ruleId) if (res.success) { + await api.fsDeleteFile(getRuleFileRelativePath(ruleId), "automatic").catch(() => void 0) messageApi.success(t("autoScore.deleteSuccess")) if (editingRuleId === ruleId) { resetEditor() } await fetchRules() + await fetchBatches() emitDataUpdated() } else { messageApi.error(res.message || t("autoScore.deleteFailed")) @@ -295,6 +341,40 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } } + const handleOpenRuleFile = async (rule: AutoScoreRule) => { + const api = (window as any).api + if (!api) return + + try { + const relativePath = getRuleFileRelativePath(rule.id) + const fileContent = { + id: rule.id, + name: rule.name, + enabled: rule.enabled, + studentNames: rule.studentNames, + triggers: rule.triggers, + triggerTree: rule.triggerTree ?? null, + actions: rule.actions, + execution: rule.execution || {}, + lastExecuted: rule.lastExecuted ?? null, + exportedAt: new Date().toISOString(), + } + + const writeRes = await api.fsWriteJson(relativePath, fileContent, "automatic") + + if (!writeRes?.success) { + throw new Error("prepare rule file failed") + } + + const openRes = await api.fsOpenPath(relativePath, "automatic") + if (!openRes?.success) { + throw new Error(openRes?.message || "open rule file failed") + } + } catch { + messageApi.error(t("autoScore.openFileFailed")) + } + } + const formatLastExecuted = (value?: string | null) => { if (!value) return t("autoScore.notExecuted") const date = new Date(value) @@ -302,6 +382,69 @@ 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 + Modal.confirm({ + title: t("autoScore.batchRollbackConfirm"), + onOk: async () => { + setRollingBackBatchId(batchId) + try { + const res = await api.autoScoreRollbackBatch({ batchId }) + if (res.success) { + messageApi.success(t("autoScore.batchRollbackSuccess")) + await fetchBatches() + emitDataUpdated() + } else { + messageApi.error(res.message || t("autoScore.batchRollbackFailed")) + } + } catch { + messageApi.error(t("autoScore.batchRollbackFailed")) + } finally { + setRollingBackBatchId(null) + } + } + }) + } + const columns: ColumnsType = [ { title: t("autoScore.name"), @@ -330,7 +473,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element ellipsis: true, render: (_, row) => row.studentNames.length > 0 ? ( - {t("autoScore.studentCount", { count: row.studentNames.length })} + + {t("autoScore.studentCount", { count: row.studentNames.length })} + ) : ( {t("autoScore.allStudents")} ), @@ -339,13 +484,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element title: t("autoScore.triggers"), key: "triggers", width: 120, - render: (_, row) => {t("autoScore.triggerCount", { count: row.triggers.length })}, + render: (_, row) => ( + + {t("autoScore.triggerCount", { count: row.triggers.length })} + + ), }, { title: t("autoScore.actions"), key: "actions", width: 120, - render: (_, row) => {t("autoScore.actionCount", { count: row.actions.length })}, + render: (_, row) => ( + + {t("autoScore.actionCount", { count: row.actions.length })} + + ), }, { title: t("autoScore.lastExecuted"), @@ -358,12 +511,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { title: t("common.operation"), key: "operation", - width: 140, + width: 220, render: (_, row) => ( + { @@ -379,7 +535,68 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element }, ] + const batchColumns: ColumnsType = [ + { + title: t("autoScore.batchId"), + dataIndex: "id", + key: "id", + ellipsis: true, + width: 220, + render: (value: string) => value.slice(0, 8), + }, + { + title: t("autoScore.name"), + dataIndex: "ruleName", + key: "ruleName", + ellipsis: true, + }, + { + title: t("autoScore.lastExecuted"), + dataIndex: "runAt", + key: "runAt", + width: 180, + render: (value: string) => formatLastExecuted(value), + }, + { + title: t("autoScore.applicableStudents"), + dataIndex: "affectedStudents", + key: "affectedStudents", + width: 100, + render: (value: number) => value, + }, + { + title: t("autoScore.actionAddScore"), + dataIndex: "scoreDeltaTotal", + key: "scoreDeltaTotal", + width: 120, + render: (value: number) => value, + }, + { + title: t("common.operation"), + key: "operation", + width: 180, + render: (_, row) => + row.rolledBack ? ( + {t("autoScore.batchRolledBack")} + ) : ( + + ), + }, + ] + const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize) + const pagedBatches = batches.slice( + (batchCurrentPage - 1) * batchPageSize, + batchCurrentPage * batchPageSize + ) return (
@@ -391,7 +608,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element type="warning" showIcon style={{ marginBottom: "16px" }} - message={t("autoScore.adminRequired")} + title={t("autoScore.adminRequired")} /> )} @@ -403,7 +620,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element name="name" rules={[{ required: true, message: t("autoScore.nameRequired") }]} > - + matchStudentName(getOptionLabel(option), input)} - options={students.map((s) => ({ label: s.name, value: s.name }))} - /> + + + +