this repo has no description
at content-sections 153 lines 4.7 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 \ 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 }, 55 })) 56} 57 58// --------------------------------------------------------------------------- 59// PUT /api/progress 60// --------------------------------------------------------------------------- 61 62pub async fn update_progress( 63 State(state): State<AppState>, 64 AuthUser(user_id): AuthUser, 65 Json(body): Json<ProgressUpdateRequest>, 66) -> Result<Json<StatsResponse>, AppError> { 67 let today = Utc::now().format("%Y-%m-%d").to_string(); 68 let now_iso = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); 69 70 // Upsert progress row. 71 // If the row already exists, only update best_score when the new score is higher. 72 let existing = sqlx::query_as::<_, Progress>( 73 "SELECT user_id, topic_id, lesson_id, completed, best_score, completed_at \ 74 FROM progress WHERE user_id = ? AND topic_id = ? AND lesson_id = ?", 75 ) 76 .bind(&user_id) 77 .bind(&body.topic_id) 78 .bind(&body.lesson_id) 79 .fetch_optional(&state.db) 80 .await?; 81 82 match existing { 83 Some(prev) => { 84 let new_best = if body.score > prev.best_score { 85 body.score 86 } else { 87 prev.best_score 88 }; 89 sqlx::query( 90 "UPDATE progress SET best_score = ?, completed = 1, completed_at = ? \ 91 WHERE user_id = ? AND topic_id = ? AND lesson_id = ?", 92 ) 93 .bind(new_best) 94 .bind(&now_iso) 95 .bind(&user_id) 96 .bind(&body.topic_id) 97 .bind(&body.lesson_id) 98 .execute(&state.db) 99 .await?; 100 } 101 None => { 102 sqlx::query( 103 "INSERT INTO progress (user_id, topic_id, lesson_id, completed, best_score, completed_at) \ 104 VALUES (?, ?, ?, 1, ?, ?)", 105 ) 106 .bind(&user_id) 107 .bind(&body.topic_id) 108 .bind(&body.lesson_id) 109 .bind(body.score) 110 .bind(&now_iso) 111 .execute(&state.db) 112 .await?; 113 } 114 } 115 116 // Fetch current stats for streak calculation. 117 let stats = sqlx::query_as::<_, UserStats>( 118 "SELECT user_id, xp, streak_days, last_active_date, hearts \ 119 FROM user_stats WHERE user_id = ?", 120 ) 121 .bind(&user_id) 122 .fetch_one(&state.db) 123 .await?; 124 125 // Streak logic. 126 let yesterday = (Utc::now() - chrono::Duration::days(1)) 127 .format("%Y-%m-%d") 128 .to_string(); 129 130 let new_streak = match stats.last_active_date.as_deref() { 131 Some(d) if d == today => stats.streak_days, // already active today, no-op 132 Some(d) if d == yesterday => stats.streak_days + 1, 133 _ => 1, // first time or gap in activity 134 }; 135 136 let new_xp = stats.xp + body.xp_earned; 137 138 sqlx::query( 139 "UPDATE user_stats SET xp = ?, streak_days = ?, last_active_date = ? WHERE user_id = ?", 140 ) 141 .bind(new_xp) 142 .bind(new_streak) 143 .bind(&today) 144 .bind(&user_id) 145 .execute(&state.db) 146 .await?; 147 148 Ok(Json(StatsResponse { 149 xp: new_xp, 150 streak_days: new_streak, 151 hearts: stats.hearts, 152 })) 153}