1use crate::prelude::*;
2use anyhow::anyhow;
3use headless_lms_utils::services::tmc::TmcClient;
4use models::{
5 course_instance_enrollments::CourseEnrollmentsInfo,
6 course_module_completions::CourseModuleCompletion, courses::Course,
7 exercise_reset_logs::ExerciseResetLog, exercise_slide_submissions::UserCourseSubmissionTime,
8 generated_certificates::UserCertificate, research_forms::ResearchFormQuestionAnswer,
9 roles::Role, suspected_cheaters::UserSuspectedCheaterInfo,
10 user_research_consents::UserResearchConsent, users::User,
11};
12use secrecy::{ExposeSecret, SecretString};
13use std::collections::{HashMap, HashSet};
14use utoipa::{OpenApi, ToSchema};
15
16#[derive(OpenApi)]
17#[openapi(paths(
18 get_user,
19 get_course_enrollments_for_user,
20 get_user_suspected_cheaters,
21 get_user_roles,
22 post_user_consents,
23 get_research_consent_by_user_id,
24 get_all_research_form_answers_with_user_id,
25 get_my_courses,
26 hide_course_from_my_courses,
27 unhide_course_from_my_courses,
28 get_my_studies,
29 get_my_certificates,
30 get_user_reset_exercise_logs,
31 get_user_course_submission_times,
32 send_reset_password_email,
33 reset_password_token_status,
34 reset_user_password,
35 change_user_password
36))]
37pub(crate) struct MainFrontendUsersApiDoc;
38
39#[instrument(skip(pool))]
43#[utoipa::path(
44 get,
45 path = "/{user_id}",
46 operation_id = "getUser",
47 tag = "users",
48 params(
49 ("user_id" = Uuid, Path, description = "User id")
50 ),
51 responses(
52 (status = 200, description = "User", body = User)
53 )
54)]
55pub async fn get_user(
56 user_id: web::Path<Uuid>,
57 pool: web::Data<PgPool>,
58 auth_user: AuthUser,
59) -> ControllerResult<web::Json<User>> {
60 let mut conn = pool.acquire().await?;
61 let user = models::users::get_by_id(&mut conn, *user_id).await?;
62
63 let token = authorize(
65 &mut conn,
66 Act::ViewUserProgressOrDetails,
67 Some(auth_user.id),
68 Res::GlobalPermissions,
69 )
70 .await?;
71 token.authorized_ok(web::Json(user))
72}
73
74#[instrument(skip(pool))]
78#[utoipa::path(
79 get,
80 path = "/{user_id}/course-enrollments",
81 operation_id = "getUserCourseEnrollments",
82 tag = "users",
83 params(
84 ("user_id" = Uuid, Path, description = "User id")
85 ),
86 responses(
87 (status = 200, description = "User course enrollments", body = CourseEnrollmentsInfo)
88 )
89)]
90pub async fn get_course_enrollments_for_user(
91 user_id: web::Path<Uuid>,
92 pool: web::Data<PgPool>,
93 auth_user: AuthUser,
94) -> ControllerResult<web::Json<CourseEnrollmentsInfo>> {
95 let mut conn = pool.acquire().await?;
96 let token = authorize(
97 &mut conn,
98 Act::ViewUserProgressOrDetails,
99 Some(auth_user.id),
100 Res::GlobalPermissions,
101 )
102 .await?;
103 let res = models::course_instance_enrollments::get_course_enrollments_info_for_user(
104 &mut conn, *user_id,
105 )
106 .await?;
107 token.authorized_ok(web::Json(res))
108}
109
110#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
111
112pub struct ConsentData {
113 pub consent: bool,
114}
115
116#[instrument(skip(pool))]
120#[utoipa::path(
121 post,
122 path = "/user-research-consents",
123 operation_id = "createUserResearchConsent",
124 tag = "users",
125 request_body = ConsentData,
126 responses(
127 (status = 200, description = "User research consent", body = UserResearchConsent)
128 )
129)]
130pub async fn post_user_consents(
131 payload: web::Json<ConsentData>,
132 user: AuthUser,
133 pool: web::Data<PgPool>,
134) -> ControllerResult<web::Json<UserResearchConsent>> {
135 let mut conn = pool.acquire().await?;
136 let token = skip_authorize();
137
138 let res = models::user_research_consents::upsert(
139 &mut conn,
140 PKeyPolicy::Generate,
141 user.id,
142 payload.consent,
143 )
144 .await?;
145 token.authorized_ok(web::Json(res))
146}
147
148#[instrument(skip(pool))]
152#[utoipa::path(
153 get,
154 path = "/get-user-research-consent",
155 operation_id = "getUserResearchConsent",
156 tag = "users",
157 responses(
158 (status = 200, description = "User research consent", body = UserResearchConsent)
159 )
160)]
161pub async fn get_research_consent_by_user_id(
162 user: AuthUser,
163 pool: web::Data<PgPool>,
164) -> ControllerResult<web::Json<UserResearchConsent>> {
165 let mut conn = pool.acquire().await?;
166 let token = skip_authorize();
167
168 let res =
169 models::user_research_consents::get_research_consent_by_user_id(&mut conn, user.id).await?;
170
171 token.authorized_ok(web::Json(res))
172}
173
174#[instrument(skip(pool))]
178#[utoipa::path(
179 get,
180 path = "/user-research-form-question-answers",
181 operation_id = "getUserResearchFormQuestionAnswers",
182 tag = "users",
183 responses(
184 (status = 200, description = "Research form answers for user", body = [ResearchFormQuestionAnswer])
185 )
186)]
187async fn get_all_research_form_answers_with_user_id(
188 user: AuthUser,
189 pool: web::Data<PgPool>,
190) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
191 let mut conn = pool.acquire().await?;
192 let token = skip_authorize();
193
194 let res =
195 models::research_forms::get_all_research_form_answers_with_user_id(&mut conn, user.id)
196 .await?;
197
198 token.authorized_ok(web::Json(res))
199}
200
201#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
202pub struct MyCourse {
203 #[serde(flatten)]
204 pub course: Course,
205 pub can_hide: bool,
208}
209
210#[instrument(skip(pool))]
214#[utoipa::path(
215 get,
216 path = "/my-courses",
217 operation_id = "getMyCourses",
218 tag = "users",
219 responses(
220 (status = 200, description = "Courses for authenticated user", body = [MyCourse])
221 )
222)]
223async fn get_my_courses(
224 user: AuthUser,
225 pool: web::Data<PgPool>,
226) -> ControllerResult<web::Json<Vec<MyCourse>>> {
227 let mut conn = pool.acquire().await?;
228 let token = skip_authorize();
229
230 let courses_enrolled_to =
231 models::courses::all_courses_user_enrolled_to(&mut conn, user.id).await?;
232
233 let courses_with_roles =
234 models::courses::all_courses_with_roles_for_user(&mut conn, user.id).await?;
235
236 let settings = models::user_course_settings::get_all_by_user_id(&mut conn, user.id).await?;
237 let hidden_course_ids: HashSet<Uuid> = settings
238 .iter()
239 .filter(|s| s.hidden)
240 .map(|s| s.current_course_id)
241 .collect();
242 let enrolled_course_ids: HashSet<Uuid> = settings.iter().map(|s| s.current_course_id).collect();
243 let role_course_ids: HashSet<Uuid> = courses_with_roles.iter().map(|c| c.id).collect();
244
245 let mut combined: Vec<Course> = courses_enrolled_to
246 .clone()
247 .into_iter()
248 .chain(
249 courses_with_roles
250 .into_iter()
251 .filter(|c| !courses_enrolled_to.iter().any(|c2| c.id == c2.id)),
252 )
253 .filter(|c| !hidden_course_ids.contains(&c.id) || role_course_ids.contains(&c.id))
255 .collect();
256
257 combined.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id)));
259
260 let my_courses = combined
261 .into_iter()
262 .map(|course| {
263 let can_hide =
264 enrolled_course_ids.contains(&course.id) && !role_course_ids.contains(&course.id);
265 MyCourse { course, can_hide }
266 })
267 .collect();
268
269 token.authorized_ok(web::Json(my_courses))
270}
271
272#[instrument(skip(pool))]
277#[utoipa::path(
278 post,
279 path = "/my-courses/{course_id}/hide",
280 operation_id = "hideCourseFromMyCourses",
281 tag = "users",
282 params(
283 ("course_id" = Uuid, Path, description = "Course id")
284 ),
285 responses(
286 (status = 200, description = "Course hidden from the user's my-courses list")
287 )
288)]
289async fn hide_course_from_my_courses(
290 course_id: web::Path<Uuid>,
291 user: AuthUser,
292 pool: web::Data<PgPool>,
293) -> ControllerResult<web::Json<()>> {
294 let mut conn = pool.acquire().await?;
295 let token = skip_authorize();
296
297 let has_role = models::courses::all_courses_with_roles_for_user(&mut conn, user.id)
299 .await?
300 .iter()
301 .any(|c| c.id == *course_id);
302 if !has_role {
303 models::user_course_settings::set_hidden(&mut conn, user.id, *course_id, true).await?;
304 }
305
306 token.authorized_ok(web::Json(()))
307}
308
309#[instrument(skip(pool))]
314#[utoipa::path(
315 post,
316 path = "/my-courses/{course_id}/unhide",
317 operation_id = "unhideCourseFromMyCourses",
318 tag = "users",
319 params(
320 ("course_id" = Uuid, Path, description = "Course id")
321 ),
322 responses(
323 (status = 200, description = "Course restored to the user's my-courses list")
324 )
325)]
326async fn unhide_course_from_my_courses(
327 course_id: web::Path<Uuid>,
328 user: AuthUser,
329 pool: web::Data<PgPool>,
330) -> ControllerResult<web::Json<()>> {
331 let mut conn = pool.acquire().await?;
332 let token = skip_authorize();
333
334 models::user_course_settings::set_hidden(&mut conn, user.id, *course_id, false).await?;
335
336 token.authorized_ok(web::Json(()))
337}
338
339#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
341pub struct MyStudiesCourseModule {
342 pub course_module_id: Uuid,
343 pub name: Option<String>,
345 pub order_number: i32,
346 pub ects_credits: Option<f32>,
347 pub uh_course_code: Option<String>,
348 pub supports_credit_registration: bool,
352 pub is_credit_registration_starting: bool,
355 pub score_given: f32,
357 pub score_maximum: Option<u32>,
359 pub score_required: Option<i32>,
362 pub total_exercises: Option<u32>,
364 pub attempted_exercises: i32,
366 pub attempted_exercises_required: Option<i32>,
369 pub automatic_completion: bool,
372 pub requires_exam: bool,
374 pub completion: Option<MyStudiesCompletion>,
376}
377
378#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
381pub struct MyStudiesCompletion {
382 pub course_module_completion_id: Uuid,
383 pub completion_date: DateTime<Utc>,
384 pub grade: Option<i32>,
386 pub passed: bool,
387 pub prerequisite_modules_completed: bool,
388}
389
390#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
391pub struct MyStudiesCourse {
392 pub course_id: Uuid,
393 pub course_name: String,
394 pub course_slug: String,
395 pub organization_slug: String,
396 pub language_code: String,
397 pub first_enrolled_at: DateTime<Utc>,
398 pub is_current: bool,
400 pub hidden: bool,
402 pub current_course_instance_id: Option<Uuid>,
404 pub current_course_instance_name: Option<String>,
405 pub supports_credit_registration: bool,
406 pub exam_passed: Option<bool>,
409 pub modules: Vec<MyStudiesCourseModule>,
410}
411
412#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
414pub struct MyStudiesTotals {
415 pub courses: i32,
416 pub completions: i32,
418 pub ects: f32,
420}
421
422#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
423pub struct MyStudies {
424 pub any_module_supports_credit_registration: bool,
427 pub courses: Vec<MyStudiesCourse>,
428 pub totals: MyStudiesTotals,
429}
430
431#[instrument(skip(pool))]
439#[utoipa::path(
440 get,
441 path = "/my-studies",
442 operation_id = "getMyStudies",
443 tag = "users",
444 responses(
445 (status = 200, description = "The authenticated user's study record", body = MyStudies)
446 )
447)]
448async fn get_my_studies(
449 user: AuthUser,
450 pool: web::Data<PgPool>,
451) -> ControllerResult<web::Json<MyStudies>> {
452 let mut conn = pool.acquire().await?;
453 let token = skip_authorize();
454
455 let enrollments_info =
456 models::course_instance_enrollments::get_course_enrollments_info_for_user(
457 &mut conn, user.id,
458 )
459 .await?;
460 let organizations = models::organizations::all_organizations_include_hidden(&mut conn).await?;
461 let organization_slugs: HashMap<Uuid, String> =
462 organizations.into_iter().map(|o| (o.id, o.slug)).collect();
463
464 let course_ids: Vec<Uuid> = enrollments_info
465 .course_enrollments
466 .iter()
467 .map(|enrollment| enrollment.course_id)
468 .collect();
469 let progress_by_module = models::user_exercise_states::get_user_course_module_progress(
470 &mut conn,
471 &course_ids,
472 user.id,
473 )
474 .await?;
475
476 let registering_new_completion_module_ids: HashSet<Uuid> =
477 if models::verified_student_numbers::get_by_user_id(&mut conn, user.id)
478 .await?
479 .is_some()
480 {
481 models::course_modules::get_ids_registering_eligible_new_completions_via_suotar(
482 &mut conn,
483 &course_ids,
484 )
485 .await?
486 .into_iter()
487 .collect()
488 } else {
489 HashSet::new()
490 };
491
492 let completion_ids: Vec<Uuid> = enrollments_info
493 .course_enrollments
494 .iter()
495 .flat_map(|enrollment| &enrollment.course_module_completions)
496 .map(|completion| completion.id)
497 .collect();
498 let credit_registration_expected_ids =
499 models::course_module_completions::get_credit_registration_expected_ids(
500 &mut conn,
501 &completion_ids,
502 )
503 .await?;
504
505 let mut courses = Vec::with_capacity(enrollments_info.course_enrollments.len());
506
507 for enrollment in enrollments_info.course_enrollments {
508 let mut best_completion_by_module: HashMap<Uuid, MyStudiesCompletion> = HashMap::new();
511 let mut registration_completion_by_module: HashMap<Uuid, &CourseModuleCompletion> =
512 HashMap::new();
513 for course_module in &enrollment.course_modules {
514 let module_completions: Vec<&CourseModuleCompletion> = enrollment
515 .course_module_completions
516 .iter()
517 .filter(|c| c.course_module_id == course_module.id)
518 .collect();
519 if let Some(best) = models::course_module_completions::select_best_completion(
520 module_completions
521 .iter()
522 .copied()
523 .filter(|c| !c.needs_to_be_reviewed),
524 ) {
525 if let Some(registration_completion) =
526 models::course_module_completions::select_registration_completion(
527 module_completions.iter().copied(),
528 )
529 {
530 registration_completion_by_module
531 .insert(course_module.id, registration_completion);
532 }
533 best_completion_by_module.insert(
535 course_module.id,
536 MyStudiesCompletion {
537 course_module_completion_id: best.id,
538 completion_date: best.completion_date,
539 grade: best.grade,
540 passed: best.passed,
541 prerequisite_modules_completed: best.prerequisite_modules_completed,
542 },
543 );
544 }
545 }
546
547 let mut modules: Vec<MyStudiesCourseModule> = enrollment
548 .course_modules
549 .iter()
550 .map(|course_module| {
551 let progress = progress_by_module.get(&course_module.id);
552 let registration_completion =
553 registration_completion_by_module.get(&course_module.id);
554 MyStudiesCourseModule {
555 course_module_id: course_module.id,
556 name: course_module.name.clone(),
557 order_number: course_module.order_number,
558 ects_credits: course_module.ects_credits,
559 uh_course_code: course_module.uh_course_code.clone(),
560 supports_credit_registration: registration_completion.map_or_else(
561 || registering_new_completion_module_ids.contains(&course_module.id),
562 |completion| completion.register_credits_via_suotar,
563 ),
564 is_credit_registration_starting: registration_completion.is_some_and(
565 |completion| credit_registration_expected_ids.contains(&completion.id),
566 ),
567 score_given: progress.map_or(0.0, |progress| progress.score_given),
568 score_maximum: progress.and_then(|progress| progress.score_maximum),
569 score_required: progress.and_then(|progress| progress.score_required),
570 total_exercises: progress.and_then(|progress| progress.total_exercises),
571 attempted_exercises: progress
572 .map_or(0, |progress| progress.attempted_exercises),
573 attempted_exercises_required: progress
574 .and_then(|progress| progress.attempted_exercises_required),
575 automatic_completion: progress
578 .is_none_or(|progress| progress.automatic_completion),
579 requires_exam: progress.is_some_and(|progress| progress.requires_exam),
580 completion: best_completion_by_module.remove(&course_module.id),
581 }
582 })
583 .collect();
584 modules.sort_by_key(|m| m.order_number);
585
586 let exam_passed = if modules.iter().any(|module| module.requires_exam) {
588 Some(
589 models::library::progressing::user_has_passed_exam_for_the_course_based_on_points(
590 &mut conn,
591 user.id,
592 enrollment.course_id,
593 )
594 .await?,
595 )
596 } else {
597 None
598 };
599
600 let settings_instance_id = enrollment
602 .user_course_settings
603 .as_ref()
604 .map(|s| s.current_course_instance_id);
605 let current_instance = enrollment
606 .course_instances
607 .iter()
608 .find(|ci| Some(ci.id) == settings_instance_id)
609 .or_else(|| enrollment.course_instances.first());
610
611 let Some(organization_slug) = organization_slugs
614 .get(&enrollment.course.organization_id)
615 .cloned()
616 else {
617 warn!(
618 user_id = %user.id,
619 course_id = %enrollment.course_id,
620 organization_id = %enrollment.course.organization_id,
621 "Skipping course from the user's studies because its organization is deleted"
622 );
623 continue;
624 };
625
626 courses.push(MyStudiesCourse {
627 course_id: enrollment.course_id,
628 course_name: enrollment.course.name.clone(),
629 course_slug: enrollment.course.slug.clone(),
630 organization_slug,
631 language_code: enrollment.course.language_code.clone(),
632 first_enrolled_at: enrollment.first_enrolled_at,
633 is_current: enrollment.is_current,
634 hidden: enrollment
635 .user_course_settings
636 .as_ref()
637 .is_some_and(|s| s.hidden),
638 current_course_instance_id: current_instance.map(|ci| ci.id),
639 current_course_instance_name: current_instance.and_then(|ci| ci.name.clone()),
640 supports_credit_registration: modules.iter().any(|m| m.supports_credit_registration),
641 exam_passed,
642 modules,
643 });
644 }
645
646 courses.sort_by(|a, b| {
648 b.is_current
649 .cmp(&a.is_current)
650 .then(b.first_enrolled_at.cmp(&a.first_enrolled_at))
651 });
652
653 let mut total_courses = 0;
654 let mut total_completions = 0;
655 let mut total_ects = 0.0;
656 for course in courses.iter().filter(|c| !c.hidden) {
657 total_courses += 1;
658 for module in &course.modules {
659 if module.completion.as_ref().is_some_and(|c| c.passed) {
660 total_completions += 1;
661 total_ects += module.ects_credits.unwrap_or(0.0);
662 }
663 }
664 }
665
666 let res = MyStudies {
667 any_module_supports_credit_registration: courses
668 .iter()
669 .any(|c| c.supports_credit_registration),
670 totals: MyStudiesTotals {
671 courses: total_courses,
672 completions: total_completions,
673 ects: total_ects,
674 },
675 courses,
676 };
677
678 token.authorized_ok(web::Json(res))
679}
680
681#[instrument(skip(pool))]
688#[utoipa::path(
689 get,
690 path = "/my-certificates",
691 operation_id = "getMyCertificates",
692 tag = "users",
693 responses(
694 (status = 200, description = "The authenticated user's certificates", body = Vec<UserCertificate>)
695 )
696)]
697async fn get_my_certificates(
698 user: AuthUser,
699 pool: web::Data<PgPool>,
700) -> ControllerResult<web::Json<Vec<UserCertificate>>> {
701 let mut conn = pool.acquire().await?;
702 let token = skip_authorize();
703
704 let res = models::generated_certificates::get_all_by_user_id(&mut conn, user.id).await?;
705
706 token.authorized_ok(web::Json(res))
707}
708
709#[instrument(skip(pool))]
713#[utoipa::path(
714 get,
715 path = "/{user_id}/user-reset-exercise-logs",
716 operation_id = "getUserResetExerciseLogs",
717 tag = "users",
718 params(
719 ("user_id" = Uuid, Path, description = "User id")
720 ),
721 responses(
722 (status = 200, description = "User reset exercise logs", body = [ExerciseResetLog])
723 )
724)]
725pub async fn get_user_reset_exercise_logs(
726 user_id: web::Path<Uuid>,
727 pool: web::Data<PgPool>,
728 auth_user: AuthUser,
729) -> ControllerResult<web::Json<Vec<ExerciseResetLog>>> {
730 let mut conn = pool.acquire().await?;
731 let token = authorize(
732 &mut conn,
733 Act::ViewUserProgressOrDetails,
734 Some(auth_user.id),
735 Res::GlobalPermissions,
736 )
737 .await?;
738 let res =
739 models::exercise_reset_logs::get_exercise_reset_logs_for_user(&mut conn, *user_id).await?;
740
741 token.authorized_ok(web::Json(res))
742}
743
744#[instrument(skip(pool))]
749#[utoipa::path(
750 get,
751 path = "/{user_id}/courses/{course_id}/submission-times",
752 operation_id = "getUserCourseSubmissionTimes",
753 tag = "users",
754 params(
755 ("user_id" = Uuid, Path, description = "User id"),
756 ("course_id" = Uuid, Path, description = "Course id")
757 ),
758 responses(
759 (status = 200, description = "User course submission times", body = [UserCourseSubmissionTime])
760 )
761)]
762pub async fn get_user_course_submission_times(
763 path: web::Path<(Uuid, Uuid)>,
764 pool: web::Data<PgPool>,
765 auth_user: AuthUser,
766) -> ControllerResult<web::Json<Vec<UserCourseSubmissionTime>>> {
767 let (user_id, course_id) = path.into_inner();
768 let mut conn = pool.acquire().await?;
769 let token = authorize(
770 &mut conn,
771 Act::ViewUserProgressOrDetails,
772 Some(auth_user.id),
773 Res::GlobalPermissions,
774 )
775 .await?;
776 let res = models::exercise_slide_submissions::get_user_course_submission_times(
777 &mut conn, user_id, course_id,
778 )
779 .await?;
780
781 token.authorized_ok(web::Json(res))
782}
783
784#[instrument(skip(pool))]
790#[utoipa::path(
791 get,
792 path = "/{user_id}/suspected-cheaters",
793 operation_id = "getUserSuspectedCheaters",
794 tag = "users",
795 params(
796 ("user_id" = Uuid, Path, description = "User id")
797 ),
798 responses(
799 (status = 200, description = "User suspected-cheater records across courses", body = [UserSuspectedCheaterInfo])
800 )
801)]
802pub async fn get_user_suspected_cheaters(
803 user_id: web::Path<Uuid>,
804 pool: web::Data<PgPool>,
805 auth_user: AuthUser,
806) -> ControllerResult<web::Json<Vec<UserSuspectedCheaterInfo>>> {
807 let mut conn = pool.acquire().await?;
808 let token = authorize(
809 &mut conn,
810 Act::ViewUserProgressOrDetails,
811 Some(auth_user.id),
812 Res::GlobalPermissions,
813 )
814 .await?;
815 let res = models::suspected_cheaters::get_suspected_cheater_info_for_user(&mut conn, *user_id)
816 .await?;
817
818 token.authorized_ok(web::Json(res))
819}
820
821#[instrument(skip(pool))]
826#[utoipa::path(
827 get,
828 path = "/{user_id}/roles",
829 operation_id = "getUserRoles",
830 tag = "users",
831 params(
832 ("user_id" = Uuid, Path, description = "User id")
833 ),
834 responses(
835 (status = 200, description = "User roles across scopes", body = [Role])
836 )
837)]
838pub async fn get_user_roles(
839 user_id: web::Path<Uuid>,
840 pool: web::Data<PgPool>,
841 auth_user: AuthUser,
842) -> ControllerResult<web::Json<Vec<Role>>> {
843 let mut conn = pool.acquire().await?;
844 let token = authorize(
845 &mut conn,
846 Act::ViewUserProgressOrDetails,
847 Some(auth_user.id),
848 Res::GlobalPermissions,
849 )
850 .await?;
851 let res = models::roles::get_roles(&mut conn, *user_id).await?;
852
853 token.authorized_ok(web::Json(res))
854}
855
856#[derive(Debug, Serialize, Deserialize, ToSchema)]
857
858pub struct EmailData {
859 pub email: String,
860 pub language: String,
861}
862
863#[instrument(skip(pool))]
864#[utoipa::path(
865 post,
866 path = "/send-reset-password-email",
867 operation_id = "sendResetPasswordEmail",
868 tag = "users",
869 request_body = EmailData,
870 responses(
871 (status = 200, description = "Reset password email accepted", body = bool)
872 )
873)]
874pub async fn send_reset_password_email(
875 pool: web::Data<PgPool>,
876 payload: web::Json<EmailData>,
877 tmc_client: web::Data<TmcClient>,
878) -> ControllerResult<web::Json<bool>> {
879 let mut conn = pool.acquire().await?;
880 let token = skip_authorize();
881
882 let email = &payload.email.trim().to_lowercase();
883 let language = &payload.language;
884
885 let reset_template = models::email_templates::get_generic_email_template_by_type_and_language(
886 &mut conn,
887 models::email_templates::EmailTemplateType::ResetPasswordEmail,
888 language,
889 )
890 .await
891 .map_err(|_e| {
892 anyhow::anyhow!(
893 "Password reset email template not configured. Missing template 'reset-password-email' for language '{}'",
894 language
895 )
896 })?;
897
898 let user = match models::users::get_by_email(&mut conn, email).await {
899 Ok(user) => Some(user),
900 Err(_) => {
901 if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
904 match models::users::find_by_upstream_id(&mut conn, tmc_user.upstream_id).await? {
908 Some(existing_user) => Some(existing_user),
909 None => Some(
910 models::users::insert_with_upstream_id_and_moocfi_id(
911 &mut conn,
912 &tmc_user.email,
913 tmc_user.first_name.as_deref(),
914 tmc_user.last_name.as_deref(),
915 tmc_user.upstream_id,
916 tmc_user.id,
917 )
918 .await?,
919 ),
920 }
921 } else {
922 None
923 }
924 }
925 };
926
927 if let Some(user) = user {
928 let token = Uuid::new_v4();
929
930 let _password_token =
931 models::user_passwords::insert_password_reset_token(&mut conn, user.id, token).await?;
932
933 let _ =
934 models::email_deliveries::insert_email_delivery(&mut conn, user.id, reset_template.id)
935 .await?;
936 }
937
938 token.authorized_ok(web::Json(true))
939}
940
941#[derive(Debug, Deserialize, ToSchema)]
942pub struct ResetPasswordTokenPayload {
943 #[schema(value_type = String)]
944 pub token: SecretString,
945}
946
947#[instrument(skip(pool))]
948#[utoipa::path(
949 post,
950 path = "/reset-password-token-status",
951 operation_id = "getResetPasswordTokenStatus",
952 tag = "users",
953 request_body = ResetPasswordTokenPayload,
954 responses(
955 (status = 200, description = "Reset password token validity", body = bool)
956 )
957)]
958pub async fn reset_password_token_status(
959 pool: web::Data<PgPool>,
960 payload: web::Json<ResetPasswordTokenPayload>,
961) -> ControllerResult<web::Json<bool>> {
962 let mut conn = pool.acquire().await?;
963 let token = skip_authorize();
964
965 let password_token = match Uuid::parse_str(payload.token.expose_secret()) {
966 Ok(u) => u,
967 Err(_) => return token.authorized_ok(web::Json(false)),
968 };
969
970 let res =
971 models::user_passwords::is_reset_password_token_valid(&mut conn, &password_token).await?;
972
973 token.authorized_ok(web::Json(res))
974}
975
976#[derive(Debug, Deserialize, ToSchema)]
977pub struct ResetPasswordData {
978 #[schema(value_type = String)]
979 pub token: SecretString,
980 #[schema(value_type = String)]
981 pub new_password: SecretString,
982}
983
984#[instrument(skip(pool))]
985#[utoipa::path(
986 post,
987 path = "/reset-password",
988 operation_id = "resetUserPassword",
989 tag = "users",
990 request_body = ResetPasswordData,
991 responses(
992 (status = 200, description = "Password reset status", body = bool)
993 )
994)]
995pub async fn reset_user_password(
996 pool: web::Data<PgPool>,
997 payload: web::Json<ResetPasswordData>,
998 tmc_client: web::Data<TmcClient>,
999) -> ControllerResult<web::Json<bool>> {
1000 let mut conn = pool.acquire().await?;
1001 let token = skip_authorize();
1002
1003 let token_uuid = Uuid::parse_str(payload.token.expose_secret())?;
1004 let password_hash = models::user_passwords::hash_password(&payload.new_password)
1005 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
1006
1007 let res = models::user_passwords::change_user_password_with_password_reset_token(
1008 &mut conn,
1009 token_uuid,
1010 &password_hash,
1011 &tmc_client,
1012 )
1013 .await?;
1014
1015 token.authorized_ok(web::Json(res))
1016}
1017
1018#[derive(Debug, Deserialize, ToSchema)]
1019pub struct ChangePasswordData {
1020 #[schema(value_type = String)]
1021 pub old_password: SecretString,
1022 #[schema(value_type = String)]
1023 pub new_password: SecretString,
1024}
1025
1026#[instrument(skip(pool))]
1027#[utoipa::path(
1028 post,
1029 path = "/change-password",
1030 operation_id = "changeUserPassword",
1031 tag = "users",
1032 request_body = ChangePasswordData,
1033 responses(
1034 (status = 200, description = "Password change status", body = bool)
1035 )
1036)]
1037pub async fn change_user_password(
1038 pool: web::Data<PgPool>,
1039 payload: web::Json<ChangePasswordData>,
1040 user: AuthUser,
1041) -> ControllerResult<web::Json<bool>> {
1042 let mut conn = pool.acquire().await?;
1043 let token = skip_authorize();
1044 let password_hash = models::user_passwords::hash_password(&payload.new_password)
1045 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
1046
1047 let res = models::user_passwords::change_user_password_with_old_password(
1048 &mut conn,
1049 user.id,
1050 &payload.old_password,
1051 &password_hash,
1052 )
1053 .await?;
1054
1055 token.authorized_ok(web::Json(res))
1056}
1057
1058pub fn _add_routes(cfg: &mut ServiceConfig) {
1059 cfg.route(
1060 "/user-research-form-question-answers",
1061 web::get().to(get_all_research_form_answers_with_user_id),
1062 )
1063 .route("/my-courses", web::get().to(get_my_courses))
1064 .route("/my-studies", web::get().to(get_my_studies))
1065 .route("/my-certificates", web::get().to(get_my_certificates))
1066 .route(
1067 "/my-courses/{course_id}/hide",
1068 web::post().to(hide_course_from_my_courses),
1069 )
1070 .route(
1071 "/my-courses/{course_id}/unhide",
1072 web::post().to(unhide_course_from_my_courses),
1073 )
1074 .route(
1075 "/get-user-research-consent",
1076 web::get().to(get_research_consent_by_user_id),
1077 )
1078 .route(
1079 "/user-research-consents",
1080 web::post().to(post_user_consents),
1081 )
1082 .route(
1083 "/send-reset-password-email",
1084 web::post().to(send_reset_password_email),
1085 )
1086 .route("/{user_id}", web::get().to(get_user))
1087 .route(
1088 "/{user_id}/course-enrollments",
1089 web::get().to(get_course_enrollments_for_user),
1090 )
1091 .route(
1092 "/{user_id}/user-reset-exercise-logs",
1093 web::get().to(get_user_reset_exercise_logs),
1094 )
1095 .route(
1096 "/{user_id}/courses/{course_id}/submission-times",
1097 web::get().to(get_user_course_submission_times),
1098 )
1099 .route(
1100 "/{user_id}/suspected-cheaters",
1101 web::get().to(get_user_suspected_cheaters),
1102 )
1103 .route("/{user_id}/roles", web::get().to(get_user_roles))
1104 .route(
1105 "/reset-password-token-status",
1106 web::post().to(reset_password_token_status),
1107 )
1108 .route("/reset-password", web::post().to(reset_user_password))
1109 .route("/change-password", web::post().to(change_user_password));
1110}