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, 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}