1use crate::prelude::*;
2use anyhow::anyhow;
3use headless_lms_utils::services::tmc::TmcClient;
4use models::{
5 course_instance_enrollments::CourseEnrollmentsInfo, courses::Course,
6 exercise_reset_logs::ExerciseResetLog, exercise_slide_submissions::UserCourseSubmissionTime,
7 research_forms::ResearchFormQuestionAnswer, roles::Role,
8 suspected_cheaters::UserSuspectedCheaterInfo, user_research_consents::UserResearchConsent,
9 users::User,
10};
11use secrecy::{ExposeSecret, SecretString};
12use std::collections::{HashMap, HashSet};
13use utoipa::{OpenApi, ToSchema};
14
15#[derive(OpenApi)]
16#[openapi(paths(
17 get_user,
18 get_course_enrollments_for_user,
19 get_user_suspected_cheaters,
20 get_user_roles,
21 post_user_consents,
22 get_research_consent_by_user_id,
23 get_all_research_form_answers_with_user_id,
24 get_my_courses,
25 hide_course_from_my_courses,
26 unhide_course_from_my_courses,
27 get_my_studies,
28 get_user_reset_exercise_logs,
29 get_user_course_submission_times,
30 send_reset_password_email,
31 reset_password_token_status,
32 reset_user_password,
33 change_user_password
34))]
35pub(crate) struct MainFrontendUsersApiDoc;
36
37#[instrument(skip(pool))]
41#[utoipa::path(
42 get,
43 path = "/{user_id}",
44 operation_id = "getUser",
45 tag = "users",
46 params(
47 ("user_id" = Uuid, Path, description = "User id")
48 ),
49 responses(
50 (status = 200, description = "User", body = User)
51 )
52)]
53pub async fn get_user(
54 user_id: web::Path<Uuid>,
55 pool: web::Data<PgPool>,
56 auth_user: AuthUser,
57) -> ControllerResult<web::Json<User>> {
58 let mut conn = pool.acquire().await?;
59 let user = models::users::get_by_id(&mut conn, *user_id).await?;
60
61 let token = authorize(
63 &mut conn,
64 Act::ViewUserProgressOrDetails,
65 Some(auth_user.id),
66 Res::GlobalPermissions,
67 )
68 .await?;
69 token.authorized_ok(web::Json(user))
70}
71
72#[instrument(skip(pool))]
76#[utoipa::path(
77 get,
78 path = "/{user_id}/course-enrollments",
79 operation_id = "getUserCourseEnrollments",
80 tag = "users",
81 params(
82 ("user_id" = Uuid, Path, description = "User id")
83 ),
84 responses(
85 (status = 200, description = "User course enrollments", body = CourseEnrollmentsInfo)
86 )
87)]
88pub async fn get_course_enrollments_for_user(
89 user_id: web::Path<Uuid>,
90 pool: web::Data<PgPool>,
91 auth_user: AuthUser,
92) -> ControllerResult<web::Json<CourseEnrollmentsInfo>> {
93 let mut conn = pool.acquire().await?;
94 let token = authorize(
95 &mut conn,
96 Act::ViewUserProgressOrDetails,
97 Some(auth_user.id),
98 Res::GlobalPermissions,
99 )
100 .await?;
101 let res = models::course_instance_enrollments::get_course_enrollments_info_for_user(
102 &mut conn, *user_id,
103 )
104 .await?;
105 token.authorized_ok(web::Json(res))
106}
107
108#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
109
110pub struct ConsentData {
111 pub consent: bool,
112}
113
114#[instrument(skip(pool))]
118#[utoipa::path(
119 post,
120 path = "/user-research-consents",
121 operation_id = "createUserResearchConsent",
122 tag = "users",
123 request_body = ConsentData,
124 responses(
125 (status = 200, description = "User research consent", body = UserResearchConsent)
126 )
127)]
128pub async fn post_user_consents(
129 payload: web::Json<ConsentData>,
130 user: AuthUser,
131 pool: web::Data<PgPool>,
132) -> ControllerResult<web::Json<UserResearchConsent>> {
133 let mut conn = pool.acquire().await?;
134 let token = skip_authorize();
135
136 let res = models::user_research_consents::upsert(
137 &mut conn,
138 PKeyPolicy::Generate,
139 user.id,
140 payload.consent,
141 )
142 .await?;
143 token.authorized_ok(web::Json(res))
144}
145
146#[instrument(skip(pool))]
150#[utoipa::path(
151 get,
152 path = "/get-user-research-consent",
153 operation_id = "getUserResearchConsent",
154 tag = "users",
155 responses(
156 (status = 200, description = "User research consent", body = UserResearchConsent)
157 )
158)]
159pub async fn get_research_consent_by_user_id(
160 user: AuthUser,
161 pool: web::Data<PgPool>,
162) -> ControllerResult<web::Json<UserResearchConsent>> {
163 let mut conn = pool.acquire().await?;
164 let token = skip_authorize();
165
166 let res =
167 models::user_research_consents::get_research_consent_by_user_id(&mut conn, user.id).await?;
168
169 token.authorized_ok(web::Json(res))
170}
171
172#[instrument(skip(pool))]
176#[utoipa::path(
177 get,
178 path = "/user-research-form-question-answers",
179 operation_id = "getUserResearchFormQuestionAnswers",
180 tag = "users",
181 responses(
182 (status = 200, description = "Research form answers for user", body = [ResearchFormQuestionAnswer])
183 )
184)]
185async fn get_all_research_form_answers_with_user_id(
186 user: AuthUser,
187 pool: web::Data<PgPool>,
188) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
189 let mut conn = pool.acquire().await?;
190 let token = skip_authorize();
191
192 let res =
193 models::research_forms::get_all_research_form_answers_with_user_id(&mut conn, user.id)
194 .await?;
195
196 token.authorized_ok(web::Json(res))
197}
198
199#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
200pub struct MyCourse {
201 #[serde(flatten)]
202 pub course: Course,
203 pub can_hide: bool,
206}
207
208#[instrument(skip(pool))]
212#[utoipa::path(
213 get,
214 path = "/my-courses",
215 operation_id = "getMyCourses",
216 tag = "users",
217 responses(
218 (status = 200, description = "Courses for authenticated user", body = [MyCourse])
219 )
220)]
221async fn get_my_courses(
222 user: AuthUser,
223 pool: web::Data<PgPool>,
224) -> ControllerResult<web::Json<Vec<MyCourse>>> {
225 let mut conn = pool.acquire().await?;
226 let token = skip_authorize();
227
228 let courses_enrolled_to =
229 models::courses::all_courses_user_enrolled_to(&mut conn, user.id).await?;
230
231 let courses_with_roles =
232 models::courses::all_courses_with_roles_for_user(&mut conn, user.id).await?;
233
234 let settings = models::user_course_settings::get_all_by_user_id(&mut conn, user.id).await?;
235 let hidden_course_ids: HashSet<Uuid> = settings
236 .iter()
237 .filter(|s| s.hidden)
238 .map(|s| s.current_course_id)
239 .collect();
240 let enrolled_course_ids: HashSet<Uuid> = settings.iter().map(|s| s.current_course_id).collect();
241 let role_course_ids: HashSet<Uuid> = courses_with_roles.iter().map(|c| c.id).collect();
242
243 let mut combined: Vec<Course> = courses_enrolled_to
244 .clone()
245 .into_iter()
246 .chain(
247 courses_with_roles
248 .into_iter()
249 .filter(|c| !courses_enrolled_to.iter().any(|c2| c.id == c2.id)),
250 )
251 .filter(|c| !hidden_course_ids.contains(&c.id) || role_course_ids.contains(&c.id))
253 .collect();
254
255 combined.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id)));
257
258 let my_courses = combined
259 .into_iter()
260 .map(|course| {
261 let can_hide =
262 enrolled_course_ids.contains(&course.id) && !role_course_ids.contains(&course.id);
263 MyCourse { course, can_hide }
264 })
265 .collect();
266
267 token.authorized_ok(web::Json(my_courses))
268}
269
270#[instrument(skip(pool))]
275#[utoipa::path(
276 post,
277 path = "/my-courses/{course_id}/hide",
278 operation_id = "hideCourseFromMyCourses",
279 tag = "users",
280 params(
281 ("course_id" = Uuid, Path, description = "Course id")
282 ),
283 responses(
284 (status = 200, description = "Course hidden from the user's my-courses list")
285 )
286)]
287async fn hide_course_from_my_courses(
288 course_id: web::Path<Uuid>,
289 user: AuthUser,
290 pool: web::Data<PgPool>,
291) -> ControllerResult<web::Json<()>> {
292 let mut conn = pool.acquire().await?;
293 let token = skip_authorize();
294
295 let has_role = models::courses::all_courses_with_roles_for_user(&mut conn, user.id)
297 .await?
298 .iter()
299 .any(|c| c.id == *course_id);
300 if !has_role {
301 models::user_course_settings::set_hidden(&mut conn, user.id, *course_id, true).await?;
302 }
303
304 token.authorized_ok(web::Json(()))
305}
306
307#[instrument(skip(pool))]
312#[utoipa::path(
313 post,
314 path = "/my-courses/{course_id}/unhide",
315 operation_id = "unhideCourseFromMyCourses",
316 tag = "users",
317 params(
318 ("course_id" = Uuid, Path, description = "Course id")
319 ),
320 responses(
321 (status = 200, description = "Course restored to the user's my-courses list")
322 )
323)]
324async fn unhide_course_from_my_courses(
325 course_id: web::Path<Uuid>,
326 user: AuthUser,
327 pool: web::Data<PgPool>,
328) -> ControllerResult<web::Json<()>> {
329 let mut conn = pool.acquire().await?;
330 let token = skip_authorize();
331
332 models::user_course_settings::set_hidden(&mut conn, user.id, *course_id, false).await?;
333
334 token.authorized_ok(web::Json(()))
335}
336
337#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
339pub struct MyStudiesCourseModule {
340 pub course_module_id: Uuid,
341 pub name: Option<String>,
343 pub order_number: i32,
344 pub ects_credits: Option<f32>,
345 pub uh_course_code: Option<String>,
346 pub supports_credit_registration: bool,
347 pub completion: Option<MyStudiesCompletion>,
349}
350
351#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
354pub struct MyStudiesCompletion {
355 pub course_module_completion_id: Uuid,
356 pub completion_date: DateTime<Utc>,
357 pub grade: Option<i32>,
359 pub passed: bool,
360 pub prerequisite_modules_completed: bool,
361}
362
363#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
364pub struct MyStudiesCourse {
365 pub course_id: Uuid,
366 pub course_name: String,
367 pub course_slug: String,
368 pub organization_slug: String,
369 pub language_code: String,
370 pub first_enrolled_at: DateTime<Utc>,
371 pub is_current: bool,
373 pub hidden: bool,
375 pub current_course_instance_id: Option<Uuid>,
377 pub current_course_instance_name: Option<String>,
378 pub supports_credit_registration: bool,
379 pub modules: Vec<MyStudiesCourseModule>,
380}
381
382#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
384pub struct MyStudiesTotals {
385 pub courses: i32,
386 pub completions: i32,
388 pub ects: f32,
390}
391
392#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
393pub struct MyStudies {
394 pub any_module_supports_credit_registration: bool,
397 pub courses: Vec<MyStudiesCourse>,
398 pub totals: MyStudiesTotals,
399}
400
401#[instrument(skip(pool))]
409#[utoipa::path(
410 get,
411 path = "/my-studies",
412 operation_id = "getMyStudies",
413 tag = "users",
414 responses(
415 (status = 200, description = "The authenticated user's study record", body = MyStudies)
416 )
417)]
418async fn get_my_studies(
419 user: AuthUser,
420 pool: web::Data<PgPool>,
421) -> ControllerResult<web::Json<MyStudies>> {
422 let mut conn = pool.acquire().await?;
423 let token = skip_authorize();
424
425 let enrollments_info =
426 models::course_instance_enrollments::get_course_enrollments_info_for_user(
427 &mut conn, user.id,
428 )
429 .await?;
430 let organizations = models::organizations::all_organizations_include_hidden(&mut conn).await?;
431 let organization_slugs: HashMap<Uuid, String> =
432 organizations.into_iter().map(|o| (o.id, o.slug)).collect();
433
434 let mut courses = Vec::with_capacity(enrollments_info.course_enrollments.len());
435
436 for enrollment in enrollments_info.course_enrollments {
437 let mut best_completion_by_module: HashMap<Uuid, MyStudiesCompletion> = HashMap::new();
440 for course_module in &enrollment.course_modules {
441 let visible_completions: Vec<_> = enrollment
442 .course_module_completions
443 .iter()
444 .filter(|c| c.course_module_id == course_module.id && !c.needs_to_be_reviewed)
445 .cloned()
446 .collect();
447 if let Some(best) =
448 models::course_module_completions::select_best_completion(visible_completions)
449 {
450 best_completion_by_module.insert(
452 course_module.id,
453 MyStudiesCompletion {
454 course_module_completion_id: best.id,
455 completion_date: best.completion_date,
456 grade: best.grade,
457 passed: best.passed,
458 prerequisite_modules_completed: best.prerequisite_modules_completed,
459 },
460 );
461 }
462 }
463
464 let mut modules: Vec<MyStudiesCourseModule> = enrollment
465 .course_modules
466 .iter()
467 .map(|course_module| MyStudiesCourseModule {
468 course_module_id: course_module.id,
469 name: course_module.name.clone(),
470 order_number: course_module.order_number,
471 ects_credits: course_module.ects_credits,
472 uh_course_code: course_module.uh_course_code.clone(),
473 supports_credit_registration: course_module.enable_credit_registration_via_suotar,
474 completion: best_completion_by_module.remove(&course_module.id),
475 })
476 .collect();
477 modules.sort_by_key(|m| m.order_number);
478
479 let settings_instance_id = enrollment
481 .user_course_settings
482 .as_ref()
483 .map(|s| s.current_course_instance_id);
484 let current_instance = enrollment
485 .course_instances
486 .iter()
487 .find(|ci| Some(ci.id) == settings_instance_id)
488 .or_else(|| enrollment.course_instances.first());
489
490 let Some(organization_slug) = organization_slugs
493 .get(&enrollment.course.organization_id)
494 .cloned()
495 else {
496 warn!(
497 user_id = %user.id,
498 course_id = %enrollment.course_id,
499 organization_id = %enrollment.course.organization_id,
500 "Skipping course from the user's studies because its organization is deleted"
501 );
502 continue;
503 };
504
505 courses.push(MyStudiesCourse {
506 course_id: enrollment.course_id,
507 course_name: enrollment.course.name.clone(),
508 course_slug: enrollment.course.slug.clone(),
509 organization_slug,
510 language_code: enrollment.course.language_code.clone(),
511 first_enrolled_at: enrollment.first_enrolled_at,
512 is_current: enrollment.is_current,
513 hidden: enrollment
514 .user_course_settings
515 .as_ref()
516 .is_some_and(|s| s.hidden),
517 current_course_instance_id: current_instance.map(|ci| ci.id),
518 current_course_instance_name: current_instance.and_then(|ci| ci.name.clone()),
519 supports_credit_registration: modules.iter().any(|m| m.supports_credit_registration),
520 modules,
521 });
522 }
523
524 courses.sort_by(|a, b| {
526 b.is_current
527 .cmp(&a.is_current)
528 .then(b.first_enrolled_at.cmp(&a.first_enrolled_at))
529 });
530
531 let mut total_courses = 0;
532 let mut total_completions = 0;
533 let mut total_ects = 0.0;
534 for course in courses.iter().filter(|c| !c.hidden) {
535 total_courses += 1;
536 for module in &course.modules {
537 if module.completion.as_ref().is_some_and(|c| c.passed) {
538 total_completions += 1;
539 total_ects += module.ects_credits.unwrap_or(0.0);
540 }
541 }
542 }
543
544 let res = MyStudies {
545 any_module_supports_credit_registration: courses
546 .iter()
547 .any(|c| c.supports_credit_registration),
548 totals: MyStudiesTotals {
549 courses: total_courses,
550 completions: total_completions,
551 ects: total_ects,
552 },
553 courses,
554 };
555
556 token.authorized_ok(web::Json(res))
557}
558
559#[instrument(skip(pool))]
563#[utoipa::path(
564 get,
565 path = "/{user_id}/user-reset-exercise-logs",
566 operation_id = "getUserResetExerciseLogs",
567 tag = "users",
568 params(
569 ("user_id" = Uuid, Path, description = "User id")
570 ),
571 responses(
572 (status = 200, description = "User reset exercise logs", body = [ExerciseResetLog])
573 )
574)]
575pub async fn get_user_reset_exercise_logs(
576 user_id: web::Path<Uuid>,
577 pool: web::Data<PgPool>,
578 auth_user: AuthUser,
579) -> ControllerResult<web::Json<Vec<ExerciseResetLog>>> {
580 let mut conn = pool.acquire().await?;
581 let token = authorize(
582 &mut conn,
583 Act::ViewUserProgressOrDetails,
584 Some(auth_user.id),
585 Res::GlobalPermissions,
586 )
587 .await?;
588 let res =
589 models::exercise_reset_logs::get_exercise_reset_logs_for_user(&mut conn, *user_id).await?;
590
591 token.authorized_ok(web::Json(res))
592}
593
594#[instrument(skip(pool))]
599#[utoipa::path(
600 get,
601 path = "/{user_id}/courses/{course_id}/submission-times",
602 operation_id = "getUserCourseSubmissionTimes",
603 tag = "users",
604 params(
605 ("user_id" = Uuid, Path, description = "User id"),
606 ("course_id" = Uuid, Path, description = "Course id")
607 ),
608 responses(
609 (status = 200, description = "User course submission times", body = [UserCourseSubmissionTime])
610 )
611)]
612pub async fn get_user_course_submission_times(
613 path: web::Path<(Uuid, Uuid)>,
614 pool: web::Data<PgPool>,
615 auth_user: AuthUser,
616) -> ControllerResult<web::Json<Vec<UserCourseSubmissionTime>>> {
617 let (user_id, course_id) = path.into_inner();
618 let mut conn = pool.acquire().await?;
619 let token = authorize(
620 &mut conn,
621 Act::ViewUserProgressOrDetails,
622 Some(auth_user.id),
623 Res::GlobalPermissions,
624 )
625 .await?;
626 let res = models::exercise_slide_submissions::get_user_course_submission_times(
627 &mut conn, user_id, course_id,
628 )
629 .await?;
630
631 token.authorized_ok(web::Json(res))
632}
633
634#[instrument(skip(pool))]
640#[utoipa::path(
641 get,
642 path = "/{user_id}/suspected-cheaters",
643 operation_id = "getUserSuspectedCheaters",
644 tag = "users",
645 params(
646 ("user_id" = Uuid, Path, description = "User id")
647 ),
648 responses(
649 (status = 200, description = "User suspected-cheater records across courses", body = [UserSuspectedCheaterInfo])
650 )
651)]
652pub async fn get_user_suspected_cheaters(
653 user_id: web::Path<Uuid>,
654 pool: web::Data<PgPool>,
655 auth_user: AuthUser,
656) -> ControllerResult<web::Json<Vec<UserSuspectedCheaterInfo>>> {
657 let mut conn = pool.acquire().await?;
658 let token = authorize(
659 &mut conn,
660 Act::ViewUserProgressOrDetails,
661 Some(auth_user.id),
662 Res::GlobalPermissions,
663 )
664 .await?;
665 let res = models::suspected_cheaters::get_suspected_cheater_info_for_user(&mut conn, *user_id)
666 .await?;
667
668 token.authorized_ok(web::Json(res))
669}
670
671#[instrument(skip(pool))]
676#[utoipa::path(
677 get,
678 path = "/{user_id}/roles",
679 operation_id = "getUserRoles",
680 tag = "users",
681 params(
682 ("user_id" = Uuid, Path, description = "User id")
683 ),
684 responses(
685 (status = 200, description = "User roles across scopes", body = [Role])
686 )
687)]
688pub async fn get_user_roles(
689 user_id: web::Path<Uuid>,
690 pool: web::Data<PgPool>,
691 auth_user: AuthUser,
692) -> ControllerResult<web::Json<Vec<Role>>> {
693 let mut conn = pool.acquire().await?;
694 let token = authorize(
695 &mut conn,
696 Act::ViewUserProgressOrDetails,
697 Some(auth_user.id),
698 Res::GlobalPermissions,
699 )
700 .await?;
701 let res = models::roles::get_roles(&mut conn, *user_id).await?;
702
703 token.authorized_ok(web::Json(res))
704}
705
706#[derive(Debug, Serialize, Deserialize, ToSchema)]
707
708pub struct EmailData {
709 pub email: String,
710 pub language: String,
711}
712
713#[instrument(skip(pool))]
714#[utoipa::path(
715 post,
716 path = "/send-reset-password-email",
717 operation_id = "sendResetPasswordEmail",
718 tag = "users",
719 request_body = EmailData,
720 responses(
721 (status = 200, description = "Reset password email accepted", body = bool)
722 )
723)]
724pub async fn send_reset_password_email(
725 pool: web::Data<PgPool>,
726 payload: web::Json<EmailData>,
727 tmc_client: web::Data<TmcClient>,
728) -> ControllerResult<web::Json<bool>> {
729 let mut conn = pool.acquire().await?;
730 let token = skip_authorize();
731
732 let email = &payload.email.trim().to_lowercase();
733 let language = &payload.language;
734
735 let reset_template = models::email_templates::get_generic_email_template_by_type_and_language(
736 &mut conn,
737 models::email_templates::EmailTemplateType::ResetPasswordEmail,
738 language,
739 )
740 .await
741 .map_err(|_e| {
742 anyhow::anyhow!(
743 "Password reset email template not configured. Missing template 'reset-password-email' for language '{}'",
744 language
745 )
746 })?;
747
748 let user = match models::users::get_by_email(&mut conn, email).await {
749 Ok(user) => Some(user),
750 Err(_) => {
751 if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
754 match models::users::find_by_upstream_id(&mut conn, tmc_user.upstream_id).await? {
758 Some(existing_user) => Some(existing_user),
759 None => Some(
760 models::users::insert_with_upstream_id_and_moocfi_id(
761 &mut conn,
762 &tmc_user.email,
763 tmc_user.first_name.as_deref(),
764 tmc_user.last_name.as_deref(),
765 tmc_user.upstream_id,
766 tmc_user.id,
767 )
768 .await?,
769 ),
770 }
771 } else {
772 None
773 }
774 }
775 };
776
777 if let Some(user) = user {
778 let token = Uuid::new_v4();
779
780 let _password_token =
781 models::user_passwords::insert_password_reset_token(&mut conn, user.id, token).await?;
782
783 let _ =
784 models::email_deliveries::insert_email_delivery(&mut conn, user.id, reset_template.id)
785 .await?;
786 }
787
788 token.authorized_ok(web::Json(true))
789}
790
791#[derive(Debug, Deserialize, ToSchema)]
792pub struct ResetPasswordTokenPayload {
793 #[schema(value_type = String)]
794 pub token: SecretString,
795}
796
797#[instrument(skip(pool))]
798#[utoipa::path(
799 post,
800 path = "/reset-password-token-status",
801 operation_id = "getResetPasswordTokenStatus",
802 tag = "users",
803 request_body = ResetPasswordTokenPayload,
804 responses(
805 (status = 200, description = "Reset password token validity", body = bool)
806 )
807)]
808pub async fn reset_password_token_status(
809 pool: web::Data<PgPool>,
810 payload: web::Json<ResetPasswordTokenPayload>,
811) -> ControllerResult<web::Json<bool>> {
812 let mut conn = pool.acquire().await?;
813 let token = skip_authorize();
814
815 let password_token = match Uuid::parse_str(payload.token.expose_secret()) {
816 Ok(u) => u,
817 Err(_) => return token.authorized_ok(web::Json(false)),
818 };
819
820 let res =
821 models::user_passwords::is_reset_password_token_valid(&mut conn, &password_token).await?;
822
823 token.authorized_ok(web::Json(res))
824}
825
826#[derive(Debug, Deserialize, ToSchema)]
827pub struct ResetPasswordData {
828 #[schema(value_type = String)]
829 pub token: SecretString,
830 #[schema(value_type = String)]
831 pub new_password: SecretString,
832}
833
834#[instrument(skip(pool))]
835#[utoipa::path(
836 post,
837 path = "/reset-password",
838 operation_id = "resetUserPassword",
839 tag = "users",
840 request_body = ResetPasswordData,
841 responses(
842 (status = 200, description = "Password reset status", body = bool)
843 )
844)]
845pub async fn reset_user_password(
846 pool: web::Data<PgPool>,
847 payload: web::Json<ResetPasswordData>,
848 tmc_client: web::Data<TmcClient>,
849) -> ControllerResult<web::Json<bool>> {
850 let mut conn = pool.acquire().await?;
851 let token = skip_authorize();
852
853 let token_uuid = Uuid::parse_str(payload.token.expose_secret())?;
854 let password_hash = models::user_passwords::hash_password(&payload.new_password)
855 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
856
857 let res = models::user_passwords::change_user_password_with_password_reset_token(
858 &mut conn,
859 token_uuid,
860 &password_hash,
861 &tmc_client,
862 )
863 .await?;
864
865 token.authorized_ok(web::Json(res))
866}
867
868#[derive(Debug, Deserialize, ToSchema)]
869pub struct ChangePasswordData {
870 #[schema(value_type = String)]
871 pub old_password: SecretString,
872 #[schema(value_type = String)]
873 pub new_password: SecretString,
874}
875
876#[instrument(skip(pool))]
877#[utoipa::path(
878 post,
879 path = "/change-password",
880 operation_id = "changeUserPassword",
881 tag = "users",
882 request_body = ChangePasswordData,
883 responses(
884 (status = 200, description = "Password change status", body = bool)
885 )
886)]
887pub async fn change_user_password(
888 pool: web::Data<PgPool>,
889 payload: web::Json<ChangePasswordData>,
890 user: AuthUser,
891) -> ControllerResult<web::Json<bool>> {
892 let mut conn = pool.acquire().await?;
893 let token = skip_authorize();
894 let password_hash = models::user_passwords::hash_password(&payload.new_password)
895 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
896
897 let res = models::user_passwords::change_user_password_with_old_password(
898 &mut conn,
899 user.id,
900 &payload.old_password,
901 &password_hash,
902 )
903 .await?;
904
905 token.authorized_ok(web::Json(res))
906}
907
908pub fn _add_routes(cfg: &mut ServiceConfig) {
909 cfg.route(
910 "/user-research-form-question-answers",
911 web::get().to(get_all_research_form_answers_with_user_id),
912 )
913 .route("/my-courses", web::get().to(get_my_courses))
914 .route("/my-studies", web::get().to(get_my_studies))
915 .route(
916 "/my-courses/{course_id}/hide",
917 web::post().to(hide_course_from_my_courses),
918 )
919 .route(
920 "/my-courses/{course_id}/unhide",
921 web::post().to(unhide_course_from_my_courses),
922 )
923 .route(
924 "/get-user-research-consent",
925 web::get().to(get_research_consent_by_user_id),
926 )
927 .route(
928 "/user-research-consents",
929 web::post().to(post_user_consents),
930 )
931 .route(
932 "/send-reset-password-email",
933 web::post().to(send_reset_password_email),
934 )
935 .route("/{user_id}", web::get().to(get_user))
936 .route(
937 "/{user_id}/course-enrollments",
938 web::get().to(get_course_enrollments_for_user),
939 )
940 .route(
941 "/{user_id}/user-reset-exercise-logs",
942 web::get().to(get_user_reset_exercise_logs),
943 )
944 .route(
945 "/{user_id}/courses/{course_id}/submission-times",
946 web::get().to(get_user_course_submission_times),
947 )
948 .route(
949 "/{user_id}/suspected-cheaters",
950 web::get().to(get_user_suspected_cheaters),
951 )
952 .route("/{user_id}/roles", web::get().to(get_user_roles))
953 .route(
954 "/reset-password-token-status",
955 web::post().to(reset_password_token_status),
956 )
957 .route("/reset-password", web::post().to(reset_user_password))
958 .route("/change-password", web::post().to(change_user_password));
959}