新增奖励兑换模式与奖励设置,并持久化兑换记录

This commit is contained in:
JSR
2026-03-20 17:59:06 +08:00
parent ad6b0808cb
commit 8155dac75e
22 changed files with 1507 additions and 17 deletions
+284 -5
View File
@@ -7,7 +7,9 @@ use tokio::time::{timeout, Duration};
use crate::db::connection::{create_postgres_connection, create_sqlite_connection};
use crate::db::connection::DatabaseType;
use crate::db::entities::{reasons, score_events, student_tags, students, tags};
use crate::db::entities::{
reasons, reward_redemptions, reward_settings, score_events, student_tags, students, tags,
};
use crate::db::migration::run_migration;
use crate::services::permission::PermissionLevel;
use crate::services::settings::{SettingsKey, SettingsValue};
@@ -93,6 +95,7 @@ pub enum ConflictStrategy {
struct StudentNormalized {
name: String,
score: i32,
reward_points: i32,
tags: String,
extra_json: Option<String>,
created_at: String,
@@ -126,6 +129,24 @@ struct EventNormalized {
event_time: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RewardSettingNormalized {
name: String,
cost_points: i32,
created_at: String,
updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RewardRedemptionNormalized {
uuid: String,
student_name: String,
reward_id: i32,
reward_name: String,
cost_points: i32,
redeemed_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct StudentTagPair {
student_name: String,
@@ -177,6 +198,7 @@ async fn load_students(
StudentNormalized {
name: row.name,
score: row.score,
reward_points: row.reward_points,
tags: normalize_tags(&row.tags),
extra_json: row.extra_json,
created_at: row.created_at,
@@ -256,6 +278,52 @@ async fn load_events(
Ok(map)
}
async fn load_reward_settings(
conn: &sea_orm::DatabaseConnection,
) -> Result<std::collections::HashMap<String, RewardSettingNormalized>, String> {
let rows = reward_settings::Entity::find()
.all(conn)
.await
.map_err(|e| e.to_string())?;
let mut map = std::collections::HashMap::new();
for row in rows {
map.insert(
row.name.clone(),
RewardSettingNormalized {
name: row.name,
cost_points: row.cost_points,
created_at: row.created_at,
updated_at: row.updated_at,
},
);
}
Ok(map)
}
async fn load_reward_redemptions(
conn: &sea_orm::DatabaseConnection,
) -> Result<std::collections::HashMap<String, RewardRedemptionNormalized>, String> {
let rows = reward_redemptions::Entity::find()
.all(conn)
.await
.map_err(|e| e.to_string())?;
let mut map = std::collections::HashMap::new();
for row in rows {
map.insert(
row.uuid.clone(),
RewardRedemptionNormalized {
uuid: row.uuid,
student_name: row.student_name,
reward_id: row.reward_id,
reward_name: row.reward_name,
cost_points: row.cost_points,
redeemed_at: row.redeemed_at,
},
);
}
Ok(map)
}
async fn load_student_tag_pairs(
conn: &sea_orm::DatabaseConnection,
) -> Result<std::collections::HashSet<StudentTagPair>, String> {
@@ -334,6 +402,7 @@ async fn upsert_student(
let normalized_current = StudentNormalized {
name: row.name.clone(),
score: row.score,
reward_points: row.reward_points,
tags: normalize_tags(&row.tags),
extra_json: row.extra_json.clone(),
created_at: row.created_at.clone(),
@@ -344,6 +413,7 @@ async fn upsert_student(
}
let mut active: students::ActiveModel = row.into();
active.score = Set(data.score);
active.reward_points = Set(data.reward_points);
active.tags = Set(data.tags.clone());
active.extra_json = Set(data.extra_json.clone());
active.created_at = Set(data.created_at.clone());
@@ -356,6 +426,7 @@ async fn upsert_student(
id: sea_orm::ActiveValue::NotSet,
name: Set(data.name.clone()),
score: Set(data.score),
reward_points: Set(data.reward_points),
tags: Set(data.tags.clone()),
extra_json: Set(data.extra_json.clone()),
created_at: Set(data.created_at.clone()),
@@ -505,6 +576,98 @@ async fn upsert_event(
}
}
async fn upsert_reward_setting(
conn: &sea_orm::DatabaseConnection,
data: &RewardSettingNormalized,
) -> Result<bool, String> {
let existing = reward_settings::Entity::find()
.filter(reward_settings::Column::Name.eq(&data.name))
.one(conn)
.await
.map_err(|e| e.to_string())?;
match existing {
Some(row) => {
let normalized_current = RewardSettingNormalized {
name: row.name.clone(),
cost_points: row.cost_points,
created_at: row.created_at.clone(),
updated_at: row.updated_at.clone(),
};
if normalized_current == *data {
return Ok(false);
}
let mut active: reward_settings::ActiveModel = row.into();
active.cost_points = Set(data.cost_points);
active.created_at = Set(data.created_at.clone());
active.updated_at = Set(data.updated_at.clone());
active.update(conn).await.map_err(|e| e.to_string())?;
Ok(true)
}
None => {
reward_settings::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
name: Set(data.name.clone()),
cost_points: Set(data.cost_points),
created_at: Set(data.created_at.clone()),
updated_at: Set(data.updated_at.clone()),
}
.insert(conn)
.await
.map_err(|e| e.to_string())?;
Ok(true)
}
}
}
async fn upsert_reward_redemption(
conn: &sea_orm::DatabaseConnection,
data: &RewardRedemptionNormalized,
) -> Result<bool, String> {
let existing = reward_redemptions::Entity::find()
.filter(reward_redemptions::Column::Uuid.eq(&data.uuid))
.one(conn)
.await
.map_err(|e| e.to_string())?;
match existing {
Some(row) => {
let normalized_current = RewardRedemptionNormalized {
uuid: row.uuid.clone(),
student_name: row.student_name.clone(),
reward_id: row.reward_id,
reward_name: row.reward_name.clone(),
cost_points: row.cost_points,
redeemed_at: row.redeemed_at.clone(),
};
if normalized_current == *data {
return Ok(false);
}
let mut active: reward_redemptions::ActiveModel = row.into();
active.student_name = Set(data.student_name.clone());
active.reward_id = Set(data.reward_id);
active.reward_name = Set(data.reward_name.clone());
active.cost_points = Set(data.cost_points);
active.redeemed_at = Set(data.redeemed_at.clone());
active.update(conn).await.map_err(|e| e.to_string())?;
Ok(true)
}
None => {
reward_redemptions::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
uuid: Set(data.uuid.clone()),
student_name: Set(data.student_name.clone()),
reward_id: Set(data.reward_id),
reward_name: Set(data.reward_name.clone()),
cost_points: Set(data.cost_points),
redeemed_at: Set(data.redeemed_at.clone()),
}
.insert(conn)
.await
.map_err(|e| e.to_string())?;
Ok(true)
}
}
}
async fn ensure_student_tag_pair(
conn: &sea_orm::DatabaseConnection,
pair: &StudentTagPair,
@@ -625,6 +788,16 @@ pub async fn realtime_dual_write_sync(app_state: &Arc<RwLock<AppState>>) -> Resu
let _ = upsert_event(&local_conn, event).await?;
}
let remote_reward_settings = load_reward_settings(&remote_conn).await?;
for reward in remote_reward_settings.values() {
let _ = upsert_reward_setting(&local_conn, reward).await?;
}
let remote_redemptions = load_reward_redemptions(&remote_conn).await?;
for redemption in remote_redemptions.values() {
let _ = upsert_reward_redemption(&local_conn, redemption).await?;
}
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
for pair in &remote_pairs {
let _ = ensure_student_tag_pair(&local_conn, pair).await?;
@@ -653,6 +826,8 @@ async fn db_sync_apply_internal(
let local_reasons = load_reasons(&local_conn).await?;
let local_tags = load_tags(&local_conn).await?;
let local_events = load_events(&local_conn).await?;
let local_reward_settings = load_reward_settings(&local_conn).await?;
let local_reward_redemptions = load_reward_redemptions(&local_conn).await?;
let local_pairs = load_student_tag_pairs(&local_conn).await?;
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
@@ -709,6 +884,28 @@ async fn db_sync_apply_internal(
synced_records += 1;
}
}
for reward in local_reward_settings.values() {
if upsert_reward_setting(&remote_conn, reward).await? {
synced_records += 1;
}
}
let remote_reward_settings_after = load_reward_settings(&remote_conn).await?;
for reward in remote_reward_settings_after.values() {
if upsert_reward_setting(&local_conn, reward).await? {
synced_records += 1;
}
}
for redemption in local_reward_redemptions.values() {
if upsert_reward_redemption(&remote_conn, redemption).await? {
synced_records += 1;
}
}
let remote_reward_redemptions_after = load_reward_redemptions(&remote_conn).await?;
for redemption in remote_reward_redemptions_after.values() {
if upsert_reward_redemption(&local_conn, redemption).await? {
synced_records += 1;
}
}
for pair in local_pairs.union(&remote_pairs) {
if ensure_student_tag_pair(&local_conn, pair).await? {
@@ -743,6 +940,18 @@ async fn db_sync_apply_internal(
resolved_conflicts += 1;
}
}
let preferred_reward_settings = load_reward_settings(preferred).await?;
for reward in preferred_reward_settings.values() {
if upsert_reward_setting(target, reward).await? {
resolved_conflicts += 1;
}
}
let preferred_reward_redemptions = load_reward_redemptions(preferred).await?;
for redemption in preferred_reward_redemptions.values() {
if upsert_reward_redemption(target, redemption).await? {
resolved_conflicts += 1;
}
}
let preferred_pairs = load_student_tag_pairs(preferred).await?;
for pair in &preferred_pairs {
if ensure_student_tag_pair(target, pair).await? {
@@ -1005,6 +1214,10 @@ pub async fn db_sync_preview(
let remote_tags = load_tags(&remote_conn).await?;
let local_events = load_events(&local_conn).await?;
let remote_events = load_events(&remote_conn).await?;
let local_reward_settings = load_reward_settings(&local_conn).await?;
let remote_reward_settings = load_reward_settings(&remote_conn).await?;
let local_reward_redemptions = load_reward_redemptions(&local_conn).await?;
let remote_reward_redemptions = load_reward_redemptions(&remote_conn).await?;
let local_pairs = load_student_tag_pairs(&local_conn).await?;
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
@@ -1016,9 +1229,29 @@ pub async fn db_sync_preview(
compare_maps("tags", &local_tags, &remote_tags);
let (evt_local_only, evt_remote_only, evt_conflicts) =
compare_maps("score_events", &local_events, &remote_events);
let (reward_local_only, reward_remote_only, reward_conflicts) = compare_maps(
"reward_settings",
&local_reward_settings,
&remote_reward_settings,
);
let (redeem_local_only, redeem_remote_only, redeem_conflicts) = compare_maps(
"reward_redemptions",
&local_reward_redemptions,
&remote_reward_redemptions,
);
let mut local_only = stu_local_only + rea_local_only + tag_local_only + evt_local_only;
let mut remote_only = stu_remote_only + rea_remote_only + tag_remote_only + evt_remote_only;
let mut local_only = stu_local_only
+ rea_local_only
+ tag_local_only
+ evt_local_only
+ reward_local_only
+ redeem_local_only;
let mut remote_only = stu_remote_only
+ rea_remote_only
+ tag_remote_only
+ evt_remote_only
+ reward_remote_only
+ redeem_remote_only;
for pair in &local_pairs {
if !remote_pairs.contains(pair) {
@@ -1038,8 +1271,14 @@ pub async fn db_sync_preview(
conflicts.push(DbSyncConflict {
table,
key,
local_summary: format!("score={}, updated_at={}", local.score, local.updated_at),
remote_summary: format!("score={}, updated_at={}", remote.score, remote.updated_at),
local_summary: format!(
"score={}, reward_points={}, updated_at={}",
local.score, local.reward_points, local.updated_at
),
remote_summary: format!(
"score={}, reward_points={}, updated_at={}",
remote.score, remote.reward_points, remote.updated_at
),
});
}
for (table, key) in rea_conflicts {
@@ -1078,6 +1317,46 @@ pub async fn db_sync_preview(
),
});
}
for (table, key) in reward_conflicts {
let local = local_reward_settings
.get(&key)
.expect("local reward setting exists");
let remote = remote_reward_settings
.get(&key)
.expect("remote reward setting exists");
conflicts.push(DbSyncConflict {
table,
key,
local_summary: format!(
"cost_points={}, updated_at={}",
local.cost_points, local.updated_at
),
remote_summary: format!(
"cost_points={}, updated_at={}",
remote.cost_points, remote.updated_at
),
});
}
for (table, key) in redeem_conflicts {
let local = local_reward_redemptions
.get(&key)
.expect("local redemption exists");
let remote = remote_reward_redemptions
.get(&key)
.expect("remote redemption exists");
conflicts.push(DbSyncConflict {
table,
key,
local_summary: format!(
"student={}, reward={}, cost={}, redeemed_at={}",
local.student_name, local.reward_name, local.cost_points, local.redeemed_at
),
remote_summary: format!(
"student={}, reward={}, cost={}, redeemed_at={}",
remote.student_name, remote.reward_name, remote.cost_points, remote.redeemed_at
),
});
}
let need_sync = local_only > 0 || remote_only > 0 || !conflicts.is_empty();
Ok(IpcResponse::success(DbSyncPreviewResult {
+4
View File
@@ -147,6 +147,7 @@ pub async fn event_create(
Ok(Some(student)) => {
let val_prev = student.score;
let val_curr = val_prev + data.delta;
let reward_points_next = student.reward_points + data.delta;
let now = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
@@ -170,6 +171,7 @@ pub async fn event_create(
let mut active: students::ActiveModel = student.into();
active.score = Set(val_curr);
active.reward_points = Set(reward_points_next);
active.updated_at = Set(now);
active.update(&txn).await.map_err(|e| e.to_string())?;
@@ -244,12 +246,14 @@ pub async fn event_delete(
if let Some(student) = student {
let new_score = student.score - event.delta;
let new_reward_points = student.reward_points - event.delta;
let now = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
let mut active: students::ActiveModel = student.into();
active.score = Set(new_score);
active.reward_points = Set(new_reward_points);
active.updated_at = Set(now);
active.update(&txn).await.map_err(|e| e.to_string())?;
}
+2
View File
@@ -8,6 +8,7 @@ pub mod filesystem;
pub mod http_server;
pub mod log;
pub mod reason;
pub mod reward;
pub mod response;
pub mod settings;
pub mod settlement;
@@ -26,6 +27,7 @@ pub use filesystem::*;
pub use http_server::*;
pub use log::*;
pub use reason::*;
pub use reward::*;
pub use response::*;
pub use settings::*;
pub use settlement::*;
+372
View File
@@ -0,0 +1,372 @@
use parking_lot::RwLock;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
TransactionTrait,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use uuid::Uuid;
use crate::db::entities::{reward_redemptions, reward_settings, students};
use crate::services::PermissionLevel;
use crate::state::AppState;
use super::database::realtime_dual_write_sync;
use super::response::IpcResponse;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RewardSettingDto {
pub id: i32,
pub name: String,
pub cost_points: i32,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RewardRedemptionDto {
pub id: i32,
pub uuid: String,
pub student_name: String,
pub reward_id: i32,
pub reward_name: String,
pub cost_points: i32,
pub redeemed_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateRewardSettingData {
pub name: String,
pub cost_points: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateRewardSettingData {
pub name: Option<String>,
pub cost_points: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedeemRewardData {
pub student_name: String,
pub reward_id: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedeemRewardResult {
pub redemption_id: i32,
pub remaining_reward_points: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryRewardRedemptionsParams {
pub limit: Option<u64>,
}
fn require_permission(
state: &Arc<RwLock<AppState>>,
level: PermissionLevel,
) -> Result<(), IpcResponse<()>> {
let state_guard = state.read();
let mut permissions = state_guard.permissions.write();
if permissions.require_permission(0, level) {
Ok(())
} else {
Err(IpcResponse::error("Permission denied"))
}
}
#[tauri::command]
pub async fn reward_setting_query(
state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<Vec<RewardSettingDto>>, String> {
if require_permission(&state, PermissionLevel::View).is_err() {
return Ok(IpcResponse::error("Permission denied: view required"));
}
let state_guard = state.read();
let db_guard = state_guard.db.read();
let Some(conn) = db_guard.as_ref() else {
return Ok(IpcResponse::error("Database not connected"));
};
let rows = reward_settings::Entity::find()
.order_by_asc(reward_settings::Column::CostPoints)
.order_by_asc(reward_settings::Column::Name)
.all(conn)
.await
.map_err(|e| e.to_string())?;
let data = rows
.into_iter()
.map(|row| RewardSettingDto {
id: row.id,
name: row.name,
cost_points: row.cost_points,
created_at: row.created_at,
updated_at: row.updated_at,
})
.collect();
Ok(IpcResponse::success(data))
}
#[tauri::command]
pub async fn reward_setting_create(
state: State<'_, Arc<RwLock<AppState>>>,
data: CreateRewardSettingData,
) -> Result<IpcResponse<i32>, String> {
if require_permission(&state, PermissionLevel::Admin).is_err() {
return Ok(IpcResponse::error("Permission denied: admin required"));
}
let name = data.name.trim();
if name.is_empty() {
return Ok(IpcResponse::error("Reward name cannot be empty"));
}
if data.cost_points <= 0 {
return Ok(IpcResponse::error("Reward cost points must be positive"));
}
let state_guard = state.read();
let db_guard = state_guard.db.read();
let Some(conn) = db_guard.as_ref() else {
return Ok(IpcResponse::error("Database not connected"));
};
let existing = reward_settings::Entity::find()
.filter(reward_settings::Column::Name.eq(name))
.one(conn)
.await
.map_err(|e| e.to_string())?;
if existing.is_some() {
return Ok(IpcResponse::error("Reward with this name already exists"));
}
let now = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
let inserted = reward_settings::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
name: Set(name.to_string()),
cost_points: Set(data.cost_points),
created_at: Set(now.clone()),
updated_at: Set(now),
}
.insert(conn)
.await
.map_err(|e| e.to_string())?;
realtime_dual_write_sync(state.inner()).await?;
Ok(IpcResponse::success(inserted.id))
}
#[tauri::command]
pub async fn reward_setting_update(
state: State<'_, Arc<RwLock<AppState>>>,
id: i32,
data: UpdateRewardSettingData,
) -> Result<IpcResponse<()>, String> {
if require_permission(&state, PermissionLevel::Admin).is_err() {
return Ok(IpcResponse::error("Permission denied: admin required"));
}
let state_guard = state.read();
let db_guard = state_guard.db.read();
let Some(conn) = db_guard.as_ref() else {
return Ok(IpcResponse::error("Database not connected"));
};
let Some(row) = reward_settings::Entity::find_by_id(id)
.one(conn)
.await
.map_err(|e| e.to_string())?
else {
return Ok(IpcResponse::error("Reward not found"));
};
let mut active: reward_settings::ActiveModel = row.into();
if let Some(name) = data.name {
let name = name.trim().to_string();
if name.is_empty() {
return Ok(IpcResponse::error("Reward name cannot be empty"));
}
let existing = reward_settings::Entity::find()
.filter(reward_settings::Column::Name.eq(&name))
.filter(reward_settings::Column::Id.ne(id))
.one(conn)
.await
.map_err(|e| e.to_string())?;
if existing.is_some() {
return Ok(IpcResponse::error("Reward with this name already exists"));
}
active.name = Set(name);
}
if let Some(cost_points) = data.cost_points {
if cost_points <= 0 {
return Ok(IpcResponse::error("Reward cost points must be positive"));
}
active.cost_points = Set(cost_points);
}
let now = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
active.updated_at = Set(now);
active.update(conn).await.map_err(|e| e.to_string())?;
realtime_dual_write_sync(state.inner()).await?;
Ok(IpcResponse::success_empty())
}
#[tauri::command]
pub async fn reward_setting_delete(
state: State<'_, Arc<RwLock<AppState>>>,
id: i32,
) -> Result<IpcResponse<()>, String> {
if require_permission(&state, PermissionLevel::Admin).is_err() {
return Ok(IpcResponse::error("Permission denied: admin required"));
}
let state_guard = state.read();
let db_guard = state_guard.db.read();
let Some(conn) = db_guard.as_ref() else {
return Ok(IpcResponse::error("Database not connected"));
};
let deleted = reward_settings::Entity::delete_by_id(id)
.exec(conn)
.await
.map_err(|e| e.to_string())?;
if deleted.rows_affected == 0 {
return Ok(IpcResponse::error("Reward not found"));
}
realtime_dual_write_sync(state.inner()).await?;
Ok(IpcResponse::success_empty())
}
#[tauri::command]
pub async fn reward_redeem(
state: State<'_, Arc<RwLock<AppState>>>,
data: RedeemRewardData,
) -> Result<IpcResponse<RedeemRewardResult>, String> {
if require_permission(&state, PermissionLevel::Points).is_err() {
return Ok(IpcResponse::error("Permission denied: points required"));
}
let student_name = data.student_name.trim();
if student_name.is_empty() {
return Ok(IpcResponse::error("Student name cannot be empty"));
}
let state_guard = state.read();
let db_guard = state_guard.db.read();
let Some(conn) = db_guard.as_ref() else {
return Ok(IpcResponse::error("Database not connected"));
};
let txn = conn.begin().await.map_err(|e| e.to_string())?;
let Some(student) = students::Entity::find()
.filter(students::Column::Name.eq(student_name))
.one(&txn)
.await
.map_err(|e| e.to_string())?
else {
return Ok(IpcResponse::error("Student not found"));
};
let Some(reward) = reward_settings::Entity::find_by_id(data.reward_id)
.one(&txn)
.await
.map_err(|e| e.to_string())?
else {
return Ok(IpcResponse::error("Reward not found"));
};
if student.reward_points < reward.cost_points {
return Ok(IpcResponse::error("Insufficient reward points"));
}
let remaining = student.reward_points - reward.cost_points;
let now = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
let uuid = Uuid::new_v4().to_string();
let redemption = reward_redemptions::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
uuid: Set(uuid),
student_name: Set(student_name.to_string()),
reward_id: Set(reward.id),
reward_name: Set(reward.name.clone()),
cost_points: Set(reward.cost_points),
redeemed_at: Set(now.clone()),
}
.insert(&txn)
.await
.map_err(|e| e.to_string())?;
let mut active_student: students::ActiveModel = student.into();
active_student.reward_points = Set(remaining);
active_student.updated_at = Set(now);
active_student
.update(&txn)
.await
.map_err(|e| e.to_string())?;
txn.commit().await.map_err(|e| e.to_string())?;
realtime_dual_write_sync(state.inner()).await?;
Ok(IpcResponse::success(RedeemRewardResult {
redemption_id: redemption.id,
remaining_reward_points: remaining,
}))
}
#[tauri::command]
pub async fn reward_redemption_query(
state: State<'_, Arc<RwLock<AppState>>>,
params: Option<QueryRewardRedemptionsParams>,
) -> Result<IpcResponse<Vec<RewardRedemptionDto>>, String> {
if require_permission(&state, PermissionLevel::View).is_err() {
return Ok(IpcResponse::error("Permission denied: view required"));
}
let state_guard = state.read();
let db_guard = state_guard.db.read();
let Some(conn) = db_guard.as_ref() else {
return Ok(IpcResponse::error("Database not connected"));
};
let limit = params.and_then(|p| p.limit).unwrap_or(100);
let rows = reward_redemptions::Entity::find()
.order_by_desc(reward_redemptions::Column::RedeemedAt)
.limit(limit)
.all(conn)
.await
.map_err(|e| e.to_string())?;
let data = rows
.into_iter()
.map(|row| RewardRedemptionDto {
id: row.id,
uuid: row.uuid,
student_name: row.student_name,
reward_id: row.reward_id,
reward_name: row.reward_name,
cost_points: row.cost_points,
redeemed_at: row.redeemed_at,
})
.collect();
Ok(IpcResponse::success(data))
}
+7
View File
@@ -65,6 +65,7 @@ pub async fn student_query(
id: s.id,
name: s.name,
score: s.score,
reward_points: s.reward_points,
tags: serde_json::from_str(&s.tags).unwrap_or_default(),
extra_json: s.extra_json,
})
@@ -120,6 +121,7 @@ pub async fn student_create(
id: sea_orm::ActiveValue::NotSet,
name: Set(name.to_string()),
score: Set(0),
reward_points: Set(0),
tags: Set("[]".to_string()),
extra_json: Set(None),
created_at: Set(now.clone()),
@@ -176,6 +178,9 @@ pub async fn student_update(
if let Some(score) = data.score {
active.score = Set(score);
}
if let Some(reward_points) = data.reward_points {
active.reward_points = Set(reward_points);
}
if let Some(tags) = data.tags {
active.tags =
Set(serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string()));
@@ -227,6 +232,7 @@ pub async fn student_delete(
id: sea_orm::ActiveValue::Set(student.id),
name: sea_orm::ActiveValue::Unchanged(student.name),
score: sea_orm::ActiveValue::Unchanged(student.score),
reward_points: sea_orm::ActiveValue::Unchanged(student.reward_points),
tags: sea_orm::ActiveValue::Unchanged(student.tags),
extra_json: sea_orm::ActiveValue::Unchanged(student.extra_json),
created_at: sea_orm::ActiveValue::Unchanged(student.created_at),
@@ -312,6 +318,7 @@ pub async fn student_import_from_xlsx(
id: sea_orm::ActiveValue::NotSet,
name: Set(name.to_string()),
score: Set(0),
reward_points: Set(0),
tags: Set("[]".to_string()),
extra_json: Set(None),
created_at: Set(now.clone()),
+4
View File
@@ -1,10 +1,14 @@
pub mod reasons;
pub mod reward_redemptions;
pub mod reward_settings;
pub mod score_events;
pub mod student_tags;
pub mod students;
pub mod tags;
pub use reasons::Entity as Reasons;
pub use reward_redemptions::Entity as RewardRedemptions;
pub use reward_settings::Entity as RewardSettings;
pub use score_events::Entity as ScoreEvents;
pub use student_tags::Entity as StudentTags;
pub use students::Entity as Students;
@@ -0,0 +1,20 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "reward_redemptions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub uuid: String,
pub student_name: String,
pub reward_id: i32,
pub reward_name: String,
pub cost_points: i32,
pub redeemed_at: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,18 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "reward_settings")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub cost_points: i32,
pub created_at: String,
pub updated_at: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+1
View File
@@ -8,6 +8,7 @@ pub struct Model {
pub id: i32,
pub name: String,
pub score: i32,
pub reward_points: i32,
pub tags: String,
pub extra_json: Option<String>,
pub created_at: String,
+58
View File
@@ -19,6 +19,9 @@ impl Migration {
Self::create_settings_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?;
Self::create_reward_redemptions_table(conn, is_sqlite).await?;
Self::ensure_students_reward_points_column(conn, is_sqlite).await?;
Self::create_indexes(conn, is_sqlite).await?;
@@ -93,11 +96,64 @@ impl Migration {
Ok(())
}
async fn create_reward_settings_table(
conn: &impl ConnectionTrait,
sqlite: bool,
) -> Result<(), DbErr> {
let sql = get_create_reward_settings_table_sql(sqlite);
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
.await?;
info!("Created reward_settings table");
Ok(())
}
async fn create_reward_redemptions_table(
conn: &impl ConnectionTrait,
sqlite: bool,
) -> Result<(), DbErr> {
let sql = get_create_reward_redemptions_table_sql(sqlite);
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
.await?;
info!("Created reward_redemptions table");
Ok(())
}
async fn ensure_students_reward_points_column(
conn: &impl ConnectionTrait,
sqlite: bool,
) -> Result<(), DbErr> {
let db_backend = Self::get_db_backend(sqlite);
let exists_sql = if sqlite {
"SELECT 1 AS exists FROM pragma_table_info('students') WHERE name = 'reward_points' LIMIT 1"
.to_string()
} else {
"SELECT 1 AS exists FROM information_schema.columns WHERE table_name = 'students' AND column_name = 'reward_points' LIMIT 1"
.to_string()
};
let exists = conn
.query_one(Statement::from_string(db_backend.clone(), exists_sql))
.await?
.is_some();
if !exists {
let alter_sql = "ALTER TABLE students ADD COLUMN reward_points INTEGER DEFAULT 0";
conn.execute(Statement::from_string(db_backend.clone(), alter_sql.to_string()))
.await?;
info!("Added students.reward_points column");
}
Ok(())
}
async fn create_indexes(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
let indexes = vec![
get_create_index_score_events_settlement_id_sql(sqlite),
get_create_index_score_events_student_name_sql(sqlite),
get_create_index_reasons_content_sql(sqlite),
get_create_index_reward_settings_name_sql(sqlite),
get_create_index_reward_redemptions_student_name_sql(sqlite),
get_create_index_reward_redemptions_reward_id_sql(sqlite),
];
for index_sql in indexes {
@@ -251,6 +307,8 @@ impl Migration {
TABLE_REASONS,
TABLE_TAGS,
TABLE_STUDENTS,
TABLE_REWARD_REDEMPTIONS,
TABLE_REWARD_SETTINGS,
TABLE_SETTINGS,
];
+20 -12
View File
@@ -30,7 +30,7 @@ impl StudentRepository {
pub async fn find_all(&self) -> Result<Vec<StudentWithTags>, sqlx::Error> {
if let Some(pool) = &self.sqlite_pool {
let students = sqlx::query_as::<Sqlite, Student>(
r#"SELECT id, name, score, tags, extra_json, created_at, updated_at
r#"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at
FROM students
ORDER BY score DESC, name ASC"#
)
@@ -40,7 +40,7 @@ impl StudentRepository {
Ok(students.into_iter().map(StudentWithTags::from).collect())
} else if let Some(pool) = &self.postgres_pool {
let students = sqlx::query_as::<Postgres, Student>(
r#"SELECT id, name, score, tags, extra_json, created_at, updated_at
r#"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at
FROM students
ORDER BY score DESC, name ASC"#
)
@@ -59,8 +59,8 @@ impl StudentRepository {
if let Some(pool) = &self.sqlite_pool {
let result = sqlx::query(
r#"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at)
VALUES (?, 0, '[]', NULL, ?, ?)"#
r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at)
VALUES (?, 0, 0, '[]', NULL, ?, ?)"#
)
.bind(name)
.bind(&now)
@@ -71,8 +71,8 @@ impl StudentRepository {
Ok(result.last_insert_rowid() as i32)
} else if let Some(pool) = &self.postgres_pool {
let result = sqlx::query_scalar::<Postgres, i32>(
r#"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at)
VALUES ($1, 0, '[]', NULL, $2, $3)
r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at)
VALUES ($1, 0, 0, '[]', NULL, $2, $3)
RETURNING id"#
)
.bind(name)
@@ -102,6 +102,10 @@ impl StudentRepository {
query.push(", score = ");
query.push_bind(score);
}
if let Some(reward_points) = data.reward_points {
query.push(", reward_points = ");
query.push_bind(reward_points);
}
if let Some(tags) = &data.tags {
query.push(", tags = ");
query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()));
@@ -127,6 +131,10 @@ impl StudentRepository {
query.push(", score = ");
query.push_bind(score);
}
if let Some(reward_points) = data.reward_points {
query.push(", reward_points = ");
query.push_bind(reward_points);
}
if let Some(tags) = &data.tags {
query.push(", tags = ");
query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()));
@@ -148,7 +156,7 @@ impl StudentRepository {
pub async fn delete(&self, id: i32) -> Result<(), sqlx::Error> {
if let Some(pool) = &self.sqlite_pool {
let student = sqlx::query_as::<Sqlite, Student>(
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE id = ?"
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = ?"
)
.bind(id)
.fetch_optional(pool)
@@ -171,7 +179,7 @@ impl StudentRepository {
}
} else if let Some(pool) = &self.postgres_pool {
let student = sqlx::query_as::<Postgres, Student>(
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE id = $1"
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = $1"
)
.bind(id)
.fetch_optional(pool)
@@ -239,7 +247,7 @@ impl StudentRepository {
for name in &to_insert {
sqlx::query(
"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) VALUES (?, 0, '[]', NULL, ?, ?)"
"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES (?, 0, 0, '[]', NULL, ?, ?)"
)
.bind(name)
.bind(&now)
@@ -274,7 +282,7 @@ impl StudentRepository {
for name in &to_insert {
sqlx::query(
"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) VALUES ($1, 0, '[]', NULL, $2, $3)"
"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES ($1, 0, 0, '[]', NULL, $2, $3)"
)
.bind(name)
.bind(&now)
@@ -298,14 +306,14 @@ impl StudentRepository {
pub async fn find_by_name(&self, name: &str) -> Result<Option<Student>, sqlx::Error> {
if let Some(pool) = &self.sqlite_pool {
sqlx::query_as::<Sqlite, Student>(
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE name = ?"
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = ?"
)
.bind(name)
.fetch_optional(pool)
.await
} else if let Some(pool) = &self.postgres_pool {
sqlx::query_as::<Postgres, Student>(
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE name = $1"
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = $1"
)
.bind(name)
.fetch_optional(pool)
+95
View File
@@ -5,6 +5,8 @@ pub const TABLE_SETTLEMENTS: &str = "settlements";
pub const TABLE_SETTINGS: &str = "settings";
pub const TABLE_TAGS: &str = "tags";
pub const TABLE_STUDENT_TAGS: &str = "student_tags";
pub const TABLE_REWARD_SETTINGS: &str = "reward_settings";
pub const TABLE_REWARD_REDEMPTIONS: &str = "reward_redemptions";
pub mod students {
pub const TABLE: &str = "students";
@@ -12,6 +14,7 @@ pub mod students {
pub const NAME: &str = "name";
pub const TAGS: &str = "tags";
pub const SCORE: &str = "score";
pub const REWARD_POINTS: &str = "reward_points";
pub const EXTRA_JSON: &str = "extra_json";
pub const CREATED_AT: &str = "created_at";
pub const UPDATED_AT: &str = "updated_at";
@@ -70,6 +73,26 @@ pub mod student_tags {
pub const CREATED_AT: &str = "created_at";
}
pub mod reward_settings {
pub const TABLE: &str = "reward_settings";
pub const ID: &str = "id";
pub const NAME: &str = "name";
pub const COST_POINTS: &str = "cost_points";
pub const CREATED_AT: &str = "created_at";
pub const UPDATED_AT: &str = "updated_at";
}
pub mod reward_redemptions {
pub const TABLE: &str = "reward_redemptions";
pub const ID: &str = "id";
pub const UUID: &str = "uuid";
pub const STUDENT_NAME: &str = "student_name";
pub const REWARD_ID: &str = "reward_id";
pub const REWARD_NAME: &str = "reward_name";
pub const COST_POINTS: &str = "cost_points";
pub const REDEEMED_AT: &str = "redeemed_at";
}
pub fn get_create_students_table_sql(sqlite: bool) -> String {
if sqlite {
r#"
@@ -78,6 +101,7 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String {
name TEXT NOT NULL,
tags TEXT DEFAULT '[]',
score INTEGER DEFAULT 0,
reward_points INTEGER DEFAULT 0,
extra_json TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
@@ -91,6 +115,7 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String {
name TEXT NOT NULL,
tags TEXT DEFAULT '[]',
score INTEGER DEFAULT 0,
reward_points INTEGER DEFAULT 0,
extra_json TEXT,
created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
@@ -100,6 +125,62 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String {
}
}
pub fn get_create_reward_settings_table_sql(sqlite: bool) -> String {
if sqlite {
r#"
CREATE TABLE IF NOT EXISTS reward_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
cost_points INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
"#
.to_string()
} else {
r#"
CREATE TABLE IF NOT EXISTS reward_settings (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
cost_points INTEGER NOT NULL,
created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
)
"#
.to_string()
}
}
pub fn get_create_reward_redemptions_table_sql(sqlite: bool) -> String {
if sqlite {
r#"
CREATE TABLE IF NOT EXISTS reward_redemptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
student_name TEXT NOT NULL,
reward_id INTEGER NOT NULL,
reward_name TEXT NOT NULL,
cost_points INTEGER NOT NULL,
redeemed_at TEXT DEFAULT (datetime('now', 'localtime'))
)
"#
.to_string()
} else {
r#"
CREATE TABLE IF NOT EXISTS reward_redemptions (
id SERIAL PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
student_name TEXT NOT NULL,
reward_id INTEGER NOT NULL,
reward_name TEXT NOT NULL,
cost_points INTEGER NOT NULL,
redeemed_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
)
"#
.to_string()
}
}
pub fn get_create_reasons_table_sql(sqlite: bool) -> String {
if sqlite {
r#"
@@ -260,6 +341,20 @@ pub fn get_create_student_tags_table_sql(sqlite: bool) -> String {
}
}
pub fn get_create_index_reward_settings_name_sql(_sqlite: bool) -> String {
"CREATE INDEX IF NOT EXISTS idx_reward_settings_name ON reward_settings(name)".to_string()
}
pub fn get_create_index_reward_redemptions_student_name_sql(_sqlite: bool) -> String {
"CREATE INDEX IF NOT EXISTS idx_reward_redemptions_student_name ON reward_redemptions(student_name)"
.to_string()
}
pub fn get_create_index_reward_redemptions_reward_id_sql(_sqlite: bool) -> String {
"CREATE INDEX IF NOT EXISTS idx_reward_redemptions_reward_id ON reward_redemptions(reward_id)"
.to_string()
}
pub fn get_create_index_score_events_settlement_id_sql(sqlite: bool) -> String {
if sqlite {
"CREATE INDEX IF NOT EXISTS idx_score_events_settlement_id ON score_events(settlement_id)"
+6
View File
@@ -47,6 +47,12 @@ pub fn run() {
reason_create,
reason_update,
reason_delete,
reward_setting_query,
reward_setting_create,
reward_setting_update,
reward_setting_delete,
reward_redeem,
reward_redemption_query,
event_query,
event_create,
event_delete,
+4
View File
@@ -5,6 +5,7 @@ pub struct Student {
pub id: i32,
pub name: String,
pub score: i32,
pub reward_points: i32,
pub tags: String,
pub extra_json: Option<String>,
pub created_at: String,
@@ -15,6 +16,7 @@ pub struct Student {
pub struct StudentUpdate {
pub name: Option<String>,
pub score: Option<i32>,
pub reward_points: Option<i32>,
pub tags: Option<Vec<String>>,
pub extra_json: Option<String>,
}
@@ -24,6 +26,7 @@ pub struct StudentWithTags {
pub id: i32,
pub name: String,
pub score: i32,
pub reward_points: i32,
pub tags: Vec<String>,
pub extra_json: Option<String>,
}
@@ -35,6 +38,7 @@ impl From<Student> for StudentWithTags {
id: student.id,
name: student.name,
score: student.score,
reward_points: student.reward_points,
tags,
extra_json: student.extra_json,
}