diff --git a/src-tauri/src/commands/board.rs b/src-tauri/src/commands/board.rs index 3646836..6459962 100644 --- a/src-tauri/src/commands/board.rs +++ b/src-tauri/src/commands/board.rs @@ -1,5 +1,6 @@ use parking_lot::RwLock; use serde::Deserialize; +use sea_orm::{ConnectionTrait, DatabaseConnection, Statement}; use serde_json::{Map, Value as JsonValue}; use sqlx::{Column, Row, SqlitePool}; use std::collections::HashSet; @@ -24,11 +25,23 @@ enum QueryBackend { Postgres, } -fn check_view_permission(state: &Arc>, sender_id: Option) -> bool { +fn check_permission( + state: &Arc>, + sender_id: Option, + level: PermissionLevel, +) -> bool { let state_guard = state.read(); let mut permissions = state_guard.permissions.write(); let id = sender_id.unwrap_or(0); - permissions.require_permission(id, PermissionLevel::View) + permissions.require_permission(id, level) +} + +fn check_view_permission(state: &Arc>, sender_id: Option) -> bool { + check_permission(state, sender_id, PermissionLevel::View) +} + +fn check_admin_permission(state: &Arc>, sender_id: Option) -> bool { + check_permission(state, sender_id, PermissionLevel::Admin) } fn contains_forbidden_keyword(sql: &str) -> bool { @@ -92,6 +105,87 @@ fn parse_limit(limit: Option) -> u64 { } } +fn escape_sql(value: &str) -> String { + value.replace('\'', "''") +} + +fn now_iso() -> String { + chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string() +} + +fn normalize_board_configs(value: JsonValue) -> JsonValue { + if value.is_array() { + value + } else { + JsonValue::Array(vec![]) + } +} + +async fn ensure_board_configs_table(conn: &DatabaseConnection) -> Result<(), String> { + let sql = r#" + CREATE TABLE IF NOT EXISTS board_configs ( + id INTEGER PRIMARY KEY, + config_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + "# + .to_string(); + + conn.execute(Statement::from_string(conn.get_database_backend(), sql)) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +async fn get_board_configs_raw(conn: &DatabaseConnection) -> Result, String> { + let sql = "SELECT config_json FROM board_configs WHERE id = 1".to_string(); + let row = conn + .query_one(Statement::from_string(conn.get_database_backend(), sql)) + .await + .map_err(|e| e.to_string())?; + + match row { + Some(r) => r + .try_get("", "config_json") + .map(Some) + .map_err(|e| e.to_string()), + None => Ok(None), + } +} + +async fn upsert_board_configs_raw(conn: &DatabaseConnection, config_json: &str) -> Result<(), String> { + let config_escaped = escape_sql(config_json); + let now_escaped = escape_sql(&now_iso()); + let sql = format!( + "INSERT INTO board_configs (id, config_json, updated_at) VALUES (1, '{}', '{}') ON CONFLICT(id) DO UPDATE SET config_json = EXCLUDED.config_json, updated_at = EXCLUDED.updated_at", + config_escaped, now_escaped + ); + + conn.execute(Statement::from_string(conn.get_database_backend(), sql)) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +async fn get_legacy_board_configs_from_settings( + state: &Arc>, + conn: Option, +) -> Result { + let state_guard = state.read(); + let mut settings = state_guard.settings.write(); + settings.attach_db(conn); + settings.initialize().await.map_err(|e| e.to_string())?; + + let legacy = match settings.get_value(SettingsKey::DashboardsConfig) { + SettingsValue::Json(v) => v, + _ => JsonValue::Array(vec![]), + }; + + Ok(normalize_board_configs(legacy)) +} + fn sqlite_db_path(app_handle: &AppHandle) -> Result { if cfg!(all(debug_assertions, desktop)) { return Ok("data.sql".to_string()); @@ -200,6 +294,68 @@ async fn query_postgres(sql: &str, pg_url: &str) -> Result, Strin Ok(data) } +#[tauri::command] +pub async fn board_get_configs( + sender_id: Option, + state: State<'_, Arc>>, +) -> Result, String> { + if !check_view_permission(&state, sender_id) { + return Ok(IpcResponse::error("Permission denied: view required")); + } + + let db_conn = { + let state_guard = state.read(); + let conn = state_guard.db.read().clone(); + conn + }; + let Some(conn) = db_conn else { + return Ok(IpcResponse::error("Database not connected")); + }; + + ensure_board_configs_table(&conn).await?; + + if let Some(raw) = get_board_configs_raw(&conn).await? { + let parsed: JsonValue = serde_json::from_str(&raw).unwrap_or(JsonValue::Array(vec![])); + return Ok(IpcResponse::success(normalize_board_configs(parsed))); + } + + let legacy = get_legacy_board_configs_from_settings(&state, Some(conn.clone())).await?; + if legacy.as_array().map(|v| !v.is_empty()).unwrap_or(false) { + let raw = serde_json::to_string(&legacy).map_err(|e| e.to_string())?; + upsert_board_configs_raw(&conn, &raw).await?; + } + + Ok(IpcResponse::success(legacy)) +} + +#[tauri::command] +pub async fn board_save_configs( + configs: JsonValue, + sender_id: Option, + state: State<'_, Arc>>, +) -> Result, String> { + if !check_admin_permission(&state, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + + let normalized = normalize_board_configs(configs); + let raw = serde_json::to_string(&normalized).map_err(|e| e.to_string())?; + + let db_conn = { + let state_guard = state.read(); + let conn = state_guard.db.read().clone(); + conn + }; + let Some(conn) = db_conn else { + return Ok(IpcResponse::error("Database not connected")); + }; + + ensure_board_configs_table(&conn).await?; + upsert_board_configs_raw(&conn, &raw).await?; + + Ok(IpcResponse::success_empty()) +} + #[tauri::command] pub async fn board_query_sql( params: BoardSqlQueryParams, diff --git a/src-tauri/src/db/migration.rs b/src-tauri/src/db/migration.rs index 4380077..157e1bb 100644 --- a/src-tauri/src/db/migration.rs +++ b/src-tauri/src/db/migration.rs @@ -17,6 +17,7 @@ impl Migration { Self::create_score_events_table(conn, is_sqlite).await?; Self::create_settlements_table(conn, is_sqlite).await?; Self::create_settings_table(conn, is_sqlite).await?; + Self::create_board_configs_table(conn, is_sqlite).await?; Self::create_tags_table(conn, is_sqlite).await?; Self::create_student_tags_table(conn, is_sqlite).await?; Self::create_reward_settings_table(conn, is_sqlite).await?; @@ -77,6 +78,17 @@ impl Migration { Ok(()) } + async fn create_board_configs_table( + conn: &impl ConnectionTrait, + sqlite: bool, + ) -> Result<(), DbErr> { + let sql = get_create_board_configs_table_sql(sqlite); + conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql)) + .await?; + info!("Created board_configs table"); + Ok(()) + } + async fn create_tags_table(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> { let sql = get_create_tags_table_sql(sqlite); conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql)) @@ -315,6 +327,7 @@ impl Migration { TABLE_REWARD_REDEMPTIONS, TABLE_REWARD_SETTINGS, TABLE_SETTINGS, + TABLE_BOARD_CONFIGS, ]; let db_backend = Self::get_db_backend(sqlite); diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index 28441b4..9368c0b 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -3,6 +3,7 @@ pub const TABLE_REASONS: &str = "reasons"; pub const TABLE_SCORE_EVENTS: &str = "score_events"; pub const TABLE_SETTLEMENTS: &str = "settlements"; pub const TABLE_SETTINGS: &str = "settings"; +pub const TABLE_BOARD_CONFIGS: &str = "board_configs"; pub const TABLE_TAGS: &str = "tags"; pub const TABLE_STUDENT_TAGS: &str = "student_tags"; pub const TABLE_REWARD_SETTINGS: &str = "reward_settings"; @@ -57,6 +58,13 @@ pub mod settings { pub const VALUE: &str = "value"; } +pub mod board_configs { + pub const TABLE: &str = "board_configs"; + pub const ID: &str = "id"; + pub const CONFIG_JSON: &str = "config_json"; + pub const UPDATED_AT: &str = "updated_at"; +} + pub mod tags { pub const TABLE: &str = "tags"; pub const ID: &str = "id"; @@ -287,6 +295,28 @@ pub fn get_create_settings_table_sql(sqlite: bool) -> String { } } +pub fn get_create_board_configs_table_sql(sqlite: bool) -> String { + if sqlite { + r#" + CREATE TABLE IF NOT EXISTS board_configs ( + id INTEGER PRIMARY KEY, + config_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + "# + .to_string() + } else { + r#" + CREATE TABLE IF NOT EXISTS board_configs ( + id INTEGER PRIMARY KEY, + config_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + "# + .to_string() + } +} + pub fn get_create_tags_table_sql(sqlite: bool) -> String { if sqlite { r#" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3603139..192302e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -83,6 +83,8 @@ pub fn run() { auto_score_toggle_rule, auto_score_get_status, auto_score_sort_rules, + board_get_configs, + board_save_configs, board_query_sql, log_query, log_clear, diff --git a/src/components/BoardManager.tsx b/src/components/BoardManager.tsx index 60c0720..8e63926 100644 --- a/src/components/BoardManager.tsx +++ b/src/components/BoardManager.tsx @@ -444,7 +444,7 @@ ORDER BY reward_points DESC, score DESC`, setSaving(true) try { - const res = await (window as any).api.setSetting("dashboards_config", nextBoards) + const res = await (window as any).api.boardSaveConfigs(nextBoards) if (!res?.success) { messageApi.error(res?.message || t("board.saveFailed")) } @@ -475,7 +475,7 @@ ORDER BY reward_points DESC, score DESC`, setLoading(true) try { - const res = await (window as any).api.getSetting("dashboards_config") + const res = await (window as any).api.boardGetConfigs() if (res?.success) { const normalized = normalizeBoards(res?.data) setBoards(normalized) diff --git a/src/preload/types.ts b/src/preload/types.ts index 0e8dde9..fa07e73 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -155,6 +155,10 @@ const api = { limit?: number }): Promise<{ success: boolean; data: any[]; message?: string }> => invoke("board_query_sql", { params }), + boardGetConfigs: (): Promise<{ success: boolean; data: any[]; message?: string }> => + invoke("board_get_configs"), + boardSaveConfigs: (configs: any[]): Promise<{ success: boolean; message?: string }> => + invoke("board_save_configs", { configs }), // Settlement querySettlements: (): Promise<{ success: boolean; data: any[] }> => invoke("db_settlement_query"),