this repo has no description
at main 199 lines 6.6 kB view raw
1use axum::extract::State; 2use axum::Json; 3use chrono::Utc; 4 5use crate::auth::AuthUser; 6use crate::config::AppState; 7use crate::errors::AppError; 8use crate::models::{ 9 LessonProgress, Progress, ProgressResponse, ProgressUpdateRequest, StatsResponse, UserStats, 10}; 11 12// --------------------------------------------------------------------------- 13// GET /api/progress 14// --------------------------------------------------------------------------- 15 16pub async fn get_progress( 17 State(state): State<AppState>, 18 AuthUser(user_id): AuthUser, 19) -> Result<Json<ProgressResponse>, AppError> { 20 let rows = sqlx::query_as::<_, Progress>( 21 "SELECT user_id, topic_id, lesson_id, completed, best_score, completed_at \ 22 FROM progress WHERE user_id = ?", 23 ) 24 .bind(&user_id) 25 .fetch_all(&state.db) 26 .await?; 27 28 let stats = sqlx::query_as::<_, UserStats>( 29 "SELECT user_id, xp, streak_days, last_active_date, hearts, streak_freezes \ 30 FROM user_stats WHERE user_id = ?", 31 ) 32 .bind(&user_id) 33 .fetch_optional(&state.db) 34 .await? 35 .ok_or_else(|| AppError::NotFound("User stats not found".to_string()))?; 36 37 let lessons: Vec<LessonProgress> = rows 38 .into_iter() 39 .map(|p| LessonProgress { 40 topic_id: p.topic_id, 41 lesson_id: p.lesson_id, 42 completed: p.completed != 0, 43 best_score: p.best_score, 44 completed_at: p.completed_at, 45 }) 46 .collect(); 47 48 Ok(Json(ProgressResponse { 49 lessons, 50 stats: StatsResponse { 51 xp: stats.xp, 52 streak_days: stats.streak_days, 53 hearts: stats.hearts, 54 streak_freezes: stats.streak_freezes, 55 }, 56 })) 57} 58 59// --------------------------------------------------------------------------- 60// PUT /api/progress 61// --------------------------------------------------------------------------- 62 63pub async fn update_progress( 64 State(state): State<AppState>, 65 AuthUser(user_id): AuthUser, 66 Json(body): Json<ProgressUpdateRequest>, 67) -> Result<Json<StatsResponse>, AppError> { 68 let today = Utc::now().format("%Y-%m-%d").to_string(); 69 let now_iso = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); 70 71 // Upsert progress row. 72 // If the row already exists, only update best_score when the new score is higher. 73 let existing = sqlx::query_as::<_, Progress>( 74 "SELECT user_id, topic_id, lesson_id, completed, best_score, completed_at \ 75 FROM progress WHERE user_id = ? AND topic_id = ? AND lesson_id = ?", 76 ) 77 .bind(&user_id) 78 .bind(&body.topic_id) 79 .bind(&body.lesson_id) 80 .fetch_optional(&state.db) 81 .await?; 82 83 match existing { 84 Some(prev) => { 85 let new_best = if body.score > prev.best_score { 86 body.score 87 } else { 88 prev.best_score 89 }; 90 sqlx::query( 91 "UPDATE progress SET best_score = ?, completed = 1, completed_at = ? \ 92 WHERE user_id = ? AND topic_id = ? AND lesson_id = ?", 93 ) 94 .bind(new_best) 95 .bind(&now_iso) 96 .bind(&user_id) 97 .bind(&body.topic_id) 98 .bind(&body.lesson_id) 99 .execute(&state.db) 100 .await?; 101 } 102 None => { 103 sqlx::query( 104 "INSERT INTO progress (user_id, topic_id, lesson_id, completed, best_score, completed_at) \ 105 VALUES (?, ?, ?, 1, ?, ?)", 106 ) 107 .bind(&user_id) 108 .bind(&body.topic_id) 109 .bind(&body.lesson_id) 110 .bind(body.score) 111 .bind(&now_iso) 112 .execute(&state.db) 113 .await?; 114 } 115 } 116 117 // Fetch current stats for streak calculation. 118 let stats = sqlx::query_as::<_, UserStats>( 119 "SELECT user_id, xp, streak_days, last_active_date, hearts, streak_freezes \ 120 FROM user_stats WHERE user_id = ?", 121 ) 122 .bind(&user_id) 123 .fetch_one(&state.db) 124 .await?; 125 126 // Streak logic with streak freeze support. 127 let yesterday = (Utc::now() - chrono::Duration::days(1)) 128 .format("%Y-%m-%d") 129 .to_string(); 130 131 let (new_streak, new_freezes) = match stats.last_active_date.as_deref() { 132 Some(d) if d == today => { 133 // Already active today — no streak change 134 (stats.streak_days, stats.streak_freezes) 135 } 136 Some(d) if d == yesterday => { 137 // Extend streak by 1 138 let extended = stats.streak_days + 1; 139 // Award a streak freeze every 7 days 140 let freezes = if extended > 0 && extended % 7 == 0 { 141 stats.streak_freezes + 1 142 } else { 143 stats.streak_freezes 144 }; 145 (extended, freezes) 146 } 147 Some(last_date) => { 148 // Gap in activity — check if streak freezes can cover the missed days 149 let missed_days = chrono::NaiveDate::parse_from_str(&today, "%Y-%m-%d") 150 .ok() 151 .and_then(|t| { 152 chrono::NaiveDate::parse_from_str(last_date, "%Y-%m-%d") 153 .ok() 154 .map(|l| (t - l).num_days() - 1) // days between last active and today, exclusive 155 }) 156 .unwrap_or(i64::MAX); 157 158 if missed_days > 0 && missed_days <= stats.streak_freezes as i64 { 159 // Consume freezes for missed days, extend streak for today's activity 160 let extended = stats.streak_days + 1; 161 let freezes = stats.streak_freezes - missed_days as i32; 162 // Award a streak freeze every 7 days 163 let freezes = if extended > 0 && extended % 7 == 0 { 164 freezes + 1 165 } else { 166 freezes 167 }; 168 (extended, freezes) 169 } else { 170 // Not enough freezes — reset streak 171 (1, 0) 172 } 173 } 174 None => { 175 // First time ever 176 (1, 0) 177 } 178 }; 179 180 let new_xp = stats.xp + body.xp_earned; 181 182 sqlx::query( 183 "UPDATE user_stats SET xp = ?, streak_days = ?, streak_freezes = ?, last_active_date = ? WHERE user_id = ?", 184 ) 185 .bind(new_xp) 186 .bind(new_streak) 187 .bind(new_freezes) 188 .bind(&today) 189 .bind(&user_id) 190 .execute(&state.db) 191 .await?; 192 193 Ok(Json(StatsResponse { 194 xp: new_xp, 195 streak_days: new_streak, 196 hearts: stats.hearts, 197 streak_freezes: new_freezes, 198 })) 199}