this repo has no description
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}