1use crate::prelude::*;
2use anyhow::anyhow;
3use headless_lms_utils::tmc::TmcClient;
4use models::{
5 course_instance_enrollments::CourseEnrollmentsInfo, courses::Course,
6 exercise_reset_logs::ExerciseResetLog, research_forms::ResearchFormQuestionAnswer,
7 user_research_consents::UserResearchConsent, users::User,
8};
9use secrecy::{ExposeSecret, SecretString};
10use utoipa::{OpenApi, ToSchema};
11
12#[derive(OpenApi)]
13#[openapi(paths(
14 get_user,
15 get_course_enrollments_for_user,
16 post_user_consents,
17 get_research_consent_by_user_id,
18 get_all_research_form_answers_with_user_id,
19 get_my_courses,
20 get_user_reset_exercise_logs,
21 send_reset_password_email,
22 reset_password_token_status,
23 reset_user_password,
24 change_user_password
25))]
26pub(crate) struct MainFrontendUsersApiDoc;
27
28#[instrument(skip(pool))]
32#[utoipa::path(
33 get,
34 path = "/{user_id}",
35 operation_id = "getUser",
36 tag = "users",
37 params(
38 ("user_id" = Uuid, Path, description = "User id")
39 ),
40 responses(
41 (status = 200, description = "User", body = serde_json::Value)
42 )
43)]
44pub async fn get_user(
45 user_id: web::Path<Uuid>,
46 pool: web::Data<PgPool>,
47 auth_user: AuthUser,
48) -> ControllerResult<web::Json<User>> {
49 let mut conn = pool.acquire().await?;
50 let user = models::users::get_by_id(&mut conn, *user_id).await?;
51
52 let token = authorize(&mut conn, Act::Teach, Some(auth_user.id), Res::AnyCourse).await?;
53 token.authorized_ok(web::Json(user))
54}
55
56#[instrument(skip(pool))]
60#[utoipa::path(
61 get,
62 path = "/{user_id}/course-enrollments",
63 operation_id = "getUserCourseEnrollments",
64 tag = "users",
65 params(
66 ("user_id" = Uuid, Path, description = "User id")
67 ),
68 responses(
69 (status = 200, description = "User course enrollments", body = CourseEnrollmentsInfo)
70 )
71)]
72pub async fn get_course_enrollments_for_user(
73 user_id: web::Path<Uuid>,
74 pool: web::Data<PgPool>,
75 auth_user: AuthUser,
76) -> ControllerResult<web::Json<CourseEnrollmentsInfo>> {
77 let mut conn = pool.acquire().await?;
78 let token = authorize(
79 &mut conn,
80 Act::ViewUserProgressOrDetails,
81 Some(auth_user.id),
82 Res::GlobalPermissions,
83 )
84 .await?;
85 let res = models::course_instance_enrollments::get_course_enrollments_info_for_user(
86 &mut conn, *user_id,
87 )
88 .await?;
89 token.authorized_ok(web::Json(res))
90}
91
92#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
93
94pub struct ConsentData {
95 pub consent: bool,
96}
97
98#[instrument(skip(pool))]
102#[utoipa::path(
103 post,
104 path = "/user-research-consents",
105 operation_id = "createUserResearchConsent",
106 tag = "users",
107 request_body = ConsentData,
108 responses(
109 (status = 200, description = "User research consent", body = UserResearchConsent)
110 )
111)]
112pub async fn post_user_consents(
113 payload: web::Json<ConsentData>,
114 user: AuthUser,
115 pool: web::Data<PgPool>,
116) -> ControllerResult<web::Json<UserResearchConsent>> {
117 let mut conn = pool.acquire().await?;
118 let token = skip_authorize();
119
120 let res = models::user_research_consents::upsert(
121 &mut conn,
122 PKeyPolicy::Generate,
123 user.id,
124 payload.consent,
125 )
126 .await?;
127 token.authorized_ok(web::Json(res))
128}
129
130#[instrument(skip(pool))]
134#[utoipa::path(
135 get,
136 path = "/get-user-research-consent",
137 operation_id = "getUserResearchConsent",
138 tag = "users",
139 responses(
140 (status = 200, description = "User research consent", body = UserResearchConsent)
141 )
142)]
143pub async fn get_research_consent_by_user_id(
144 user: AuthUser,
145 pool: web::Data<PgPool>,
146) -> ControllerResult<web::Json<UserResearchConsent>> {
147 let mut conn = pool.acquire().await?;
148 let token = skip_authorize();
149
150 let res =
151 models::user_research_consents::get_research_consent_by_user_id(&mut conn, user.id).await?;
152
153 token.authorized_ok(web::Json(res))
154}
155
156#[instrument(skip(pool))]
160#[utoipa::path(
161 get,
162 path = "/user-research-form-question-answers",
163 operation_id = "getUserResearchFormQuestionAnswers",
164 tag = "users",
165 responses(
166 (status = 200, description = "Research form answers for user", body = [ResearchFormQuestionAnswer])
167 )
168)]
169async fn get_all_research_form_answers_with_user_id(
170 user: AuthUser,
171 pool: web::Data<PgPool>,
172) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
173 let mut conn = pool.acquire().await?;
174 let token = skip_authorize();
175
176 let res =
177 models::research_forms::get_all_research_form_answers_with_user_id(&mut conn, user.id)
178 .await?;
179
180 token.authorized_ok(web::Json(res))
181}
182
183#[instrument(skip(pool))]
187#[utoipa::path(
188 get,
189 path = "/my-courses",
190 operation_id = "getMyCourses",
191 tag = "users",
192 responses(
193 (status = 200, description = "Courses for authenticated user", body = [Course])
194 )
195)]
196async fn get_my_courses(
197 user: AuthUser,
198 pool: web::Data<PgPool>,
199) -> ControllerResult<web::Json<Vec<Course>>> {
200 let mut conn = pool.acquire().await?;
201 let token = skip_authorize();
202
203 let courses_enrolled_to =
204 models::courses::all_courses_user_enrolled_to(&mut conn, user.id).await?;
205
206 let courses_with_roles =
207 models::courses::all_courses_with_roles_for_user(&mut conn, user.id).await?;
208
209 let combined = courses_enrolled_to
210 .clone()
211 .into_iter()
212 .chain(
213 courses_with_roles
214 .into_iter()
215 .filter(|c| !courses_enrolled_to.iter().any(|c2| c.id == c2.id)),
216 )
217 .collect();
218
219 token.authorized_ok(web::Json(combined))
220}
221
222#[instrument(skip(pool))]
226#[utoipa::path(
227 get,
228 path = "/{user_id}/user-reset-exercise-logs",
229 operation_id = "getUserResetExerciseLogs",
230 tag = "users",
231 params(
232 ("user_id" = Uuid, Path, description = "User id")
233 ),
234 responses(
235 (status = 200, description = "User reset exercise logs", body = [ExerciseResetLog])
236 )
237)]
238pub async fn get_user_reset_exercise_logs(
239 user_id: web::Path<Uuid>,
240 pool: web::Data<PgPool>,
241 auth_user: AuthUser,
242) -> ControllerResult<web::Json<Vec<ExerciseResetLog>>> {
243 let mut conn = pool.acquire().await?;
244 let token = authorize(
245 &mut conn,
246 Act::ViewUserProgressOrDetails,
247 Some(auth_user.id),
248 Res::GlobalPermissions,
249 )
250 .await?;
251 let res =
252 models::exercise_reset_logs::get_exercise_reset_logs_for_user(&mut conn, *user_id).await?;
253
254 token.authorized_ok(web::Json(res))
255}
256
257#[derive(Debug, Serialize, Deserialize, ToSchema)]
258
259pub struct EmailData {
260 pub email: String,
261 pub language: String,
262}
263
264#[instrument(skip(pool))]
265#[utoipa::path(
266 post,
267 path = "/send-reset-password-email",
268 operation_id = "sendResetPasswordEmail",
269 tag = "users",
270 request_body = EmailData,
271 responses(
272 (status = 200, description = "Reset password email accepted", body = bool)
273 )
274)]
275pub async fn send_reset_password_email(
276 pool: web::Data<PgPool>,
277 payload: web::Json<EmailData>,
278 tmc_client: web::Data<TmcClient>,
279) -> ControllerResult<web::Json<bool>> {
280 let mut conn = pool.acquire().await?;
281 let token = skip_authorize();
282
283 let email = &payload.email.trim().to_lowercase();
284 let language = &payload.language;
285
286 let reset_template = models::email_templates::get_generic_email_template_by_type_and_language(
287 &mut conn,
288 models::email_templates::EmailTemplateType::ResetPasswordEmail,
289 language,
290 )
291 .await
292 .map_err(|_e| {
293 anyhow::anyhow!(
294 "Password reset email template not configured. Missing template 'reset-password-email' for language '{}'",
295 language
296 )
297 })?;
298
299 let user = match models::users::get_by_email(&mut conn, email).await {
300 Ok(user) => Some(user),
301 Err(_) => {
302 if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
305 Some(
306 models::users::insert_with_upstream_id_and_moocfi_id(
307 &mut conn,
308 &tmc_user.email,
309 tmc_user.first_name.as_deref(),
310 tmc_user.last_name.as_deref(),
311 tmc_user.upstream_id,
312 tmc_user.id,
313 )
314 .await?,
315 )
316 } else {
317 None
318 }
319 }
320 };
321
322 if let Some(user) = user {
323 let token = Uuid::new_v4();
324
325 let _password_token =
326 models::user_passwords::insert_password_reset_token(&mut conn, user.id, token).await?;
327
328 let _ =
329 models::email_deliveries::insert_email_delivery(&mut conn, user.id, reset_template.id)
330 .await?;
331 }
332
333 token.authorized_ok(web::Json(true))
334}
335
336#[derive(Debug, Deserialize, ToSchema)]
337pub struct ResetPasswordTokenPayload {
338 #[schema(value_type = String)]
339 pub token: SecretString,
340}
341
342#[instrument(skip(pool))]
343#[utoipa::path(
344 post,
345 path = "/reset-password-token-status",
346 operation_id = "getResetPasswordTokenStatus",
347 tag = "users",
348 request_body = ResetPasswordTokenPayload,
349 responses(
350 (status = 200, description = "Reset password token validity", body = bool)
351 )
352)]
353pub async fn reset_password_token_status(
354 pool: web::Data<PgPool>,
355 payload: web::Json<ResetPasswordTokenPayload>,
356) -> ControllerResult<web::Json<bool>> {
357 let mut conn = pool.acquire().await?;
358 let token = skip_authorize();
359
360 let password_token = match Uuid::parse_str(payload.token.expose_secret()) {
361 Ok(u) => u,
362 Err(_) => return token.authorized_ok(web::Json(false)),
363 };
364
365 let res =
366 models::user_passwords::is_reset_password_token_valid(&mut conn, &password_token).await?;
367
368 token.authorized_ok(web::Json(res))
369}
370
371#[derive(Debug, Deserialize, ToSchema)]
372pub struct ResetPasswordData {
373 #[schema(value_type = String)]
374 pub token: SecretString,
375 #[schema(value_type = String)]
376 pub new_password: SecretString,
377}
378
379#[instrument(skip(pool))]
380#[utoipa::path(
381 post,
382 path = "/reset-password",
383 operation_id = "resetUserPassword",
384 tag = "users",
385 request_body = ResetPasswordData,
386 responses(
387 (status = 200, description = "Password reset status", body = bool)
388 )
389)]
390pub async fn reset_user_password(
391 pool: web::Data<PgPool>,
392 payload: web::Json<ResetPasswordData>,
393 tmc_client: web::Data<TmcClient>,
394) -> ControllerResult<web::Json<bool>> {
395 let mut conn = pool.acquire().await?;
396 let token = skip_authorize();
397
398 let token_uuid = Uuid::parse_str(payload.token.expose_secret())?;
399 let password_hash = models::user_passwords::hash_password(&payload.new_password)
400 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
401
402 let res = models::user_passwords::change_user_password_with_password_reset_token(
403 &mut conn,
404 token_uuid,
405 &password_hash,
406 &tmc_client,
407 )
408 .await?;
409
410 token.authorized_ok(web::Json(res))
411}
412
413#[derive(Debug, Deserialize, ToSchema)]
414pub struct ChangePasswordData {
415 #[schema(value_type = String)]
416 pub old_password: SecretString,
417 #[schema(value_type = String)]
418 pub new_password: SecretString,
419}
420
421#[instrument(skip(pool))]
422#[utoipa::path(
423 post,
424 path = "/change-password",
425 operation_id = "changeUserPassword",
426 tag = "users",
427 request_body = ChangePasswordData,
428 responses(
429 (status = 200, description = "Password change status", body = bool)
430 )
431)]
432pub async fn change_user_password(
433 pool: web::Data<PgPool>,
434 payload: web::Json<ChangePasswordData>,
435 user: AuthUser,
436) -> ControllerResult<web::Json<bool>> {
437 let mut conn = pool.acquire().await?;
438 let token = skip_authorize();
439 let password_hash = models::user_passwords::hash_password(&payload.new_password)
440 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
441
442 let res = models::user_passwords::change_user_password_with_old_password(
443 &mut conn,
444 user.id,
445 &payload.old_password,
446 &password_hash,
447 )
448 .await?;
449
450 token.authorized_ok(web::Json(res))
451}
452
453pub fn _add_routes(cfg: &mut ServiceConfig) {
454 cfg.route(
455 "/user-research-form-question-answers",
456 web::get().to(get_all_research_form_answers_with_user_id),
457 )
458 .route("/my-courses", web::get().to(get_my_courses))
459 .route(
460 "/get-user-research-consent",
461 web::get().to(get_research_consent_by_user_id),
462 )
463 .route(
464 "/user-research-consents",
465 web::post().to(post_user_consents),
466 )
467 .route(
468 "/send-reset-password-email",
469 web::post().to(send_reset_password_email),
470 )
471 .route("/{user_id}", web::get().to(get_user))
472 .route(
473 "/{user_id}/course-enrollments",
474 web::get().to(get_course_enrollments_for_user),
475 )
476 .route(
477 "/{user_id}/user-reset-exercise-logs",
478 web::get().to(get_user_reset_exercise_logs),
479 )
480 .route(
481 "/reset-password-token-status",
482 web::post().to(reset_password_token_status),
483 )
484 .route("/reset-password", web::post().to(reset_user_password))
485 .route("/change-password", web::post().to(change_user_password));
486}