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 completion_ids: Vec<Uuid> = enrollments_info
477 .course_enrollments
478 .iter()
479 .flat_map(|enrollment| &enrollment.course_module_completions)
480 .map(|completion| completion.id)
481 .collect();
482 let credit_registration_expected_ids =
483 models::course_module_completions::get_credit_registration_expected_ids(
484 &mut conn,
485 &completion_ids,
486 )
487 .await?;
488
489 let mut courses = Vec::with_capacity(enrollments_info.course_enrollments.len());
490
491 for enrollment in enrollments_info.course_enrollments {
492 let mut best_completion_by_module: HashMap<Uuid, MyStudiesCompletion> = HashMap::new();
495 let mut registration_completion_by_module: HashMap<Uuid, &CourseModuleCompletion> =
496 HashMap::new();
497 for course_module in &enrollment.course_modules {
498 let module_completions: Vec<&CourseModuleCompletion> = enrollment
499 .course_module_completions
500 .iter()
501 .filter(|c| c.course_module_id == course_module.id)
502 .collect();
503 if let Some(best) = models::course_module_completions::select_best_completion(
504 module_completions
505 .iter()
506 .copied()
507 .filter(|c| !c.needs_to_be_reviewed),
508 ) {
509 if let Some(registration_completion) =
510 models::course_module_completions::select_registration_completion(
511 module_completions.iter().copied(),
512 )
513 {
514 registration_completion_by_module
515 .insert(course_module.id, registration_completion);
516 }
517 best_completion_by_module.insert(
519 course_module.id,
520 MyStudiesCompletion {
521 course_module_completion_id: best.id,
522 completion_date: best.completion_date,
523 grade: best.grade,
524 passed: best.passed,
525 prerequisite_modules_completed: best.prerequisite_modules_completed,
526 },
527 );
528 }
529 }
530
531 let mut modules: Vec<MyStudiesCourseModule> = enrollment
532 .course_modules
533 .iter()
534 .map(|course_module| {
535 let progress = progress_by_module.get(&course_module.id);
536 let registration_completion =
537 registration_completion_by_module.get(&course_module.id);
538 MyStudiesCourseModule {
539 course_module_id: course_module.id,
540 name: course_module.name.clone(),
541 order_number: course_module.order_number,
542 ects_credits: course_module.ects_credits,
543 uh_course_code: course_module.uh_course_code.clone(),
544 supports_credit_registration: models::course_modules::registers_credits(
545 course_module.uh_course_code.as_deref(),
546 course_module.completions_can_be_registered_to_sisu,
547 ) && registration_completion
548 .is_none_or(|completion| completion.register_credits_via_suotar),
549 is_credit_registration_starting: registration_completion.is_some_and(
550 |completion| credit_registration_expected_ids.contains(&completion.id),
551 ),
552 score_given: progress.map_or(0.0, |progress| progress.score_given),
553 score_maximum: progress.and_then(|progress| progress.score_maximum),
554 score_required: progress.and_then(|progress| progress.score_required),
555 total_exercises: progress.and_then(|progress| progress.total_exercises),
556 attempted_exercises: progress
557 .map_or(0, |progress| progress.attempted_exercises),
558 attempted_exercises_required: progress
559 .and_then(|progress| progress.attempted_exercises_required),
560 automatic_completion: progress
563 .is_none_or(|progress| progress.automatic_completion),
564 requires_exam: progress.is_some_and(|progress| progress.requires_exam),
565 completion: best_completion_by_module.remove(&course_module.id),
566 }
567 })
568 .collect();
569 modules.sort_by_key(|m| m.order_number);
570
571 let exam_passed = if modules.iter().any(|module| module.requires_exam) {
573 Some(
574 models::library::progressing::user_has_passed_exam_for_the_course_based_on_points(
575 &mut conn,
576 user.id,
577 enrollment.course_id,
578 )
579 .await?,
580 )
581 } else {
582 None
583 };
584
585 let settings_instance_id = enrollment
587 .user_course_settings
588 .as_ref()
589 .map(|s| s.current_course_instance_id);
590 let current_instance = enrollment
591 .course_instances
592 .iter()
593 .find(|ci| Some(ci.id) == settings_instance_id)
594 .or_else(|| enrollment.course_instances.first());
595
596 let Some(organization_slug) = organization_slugs
599 .get(&enrollment.course.organization_id)
600 .cloned()
601 else {
602 warn!(
603 user_id = %user.id,
604 course_id = %enrollment.course_id,
605 organization_id = %enrollment.course.organization_id,
606 "Skipping course from the user's studies because its organization is deleted"
607 );
608 continue;
609 };
610
611 courses.push(MyStudiesCourse {
612 course_id: enrollment.course_id,
613 course_name: enrollment.course.name.clone(),
614 course_slug: enrollment.course.slug.clone(),
615 organization_slug,
616 language_code: enrollment.course.language_code.clone(),
617 first_enrolled_at: enrollment.first_enrolled_at,
618 is_current: enrollment.is_current,
619 hidden: enrollment
620 .user_course_settings
621 .as_ref()
622 .is_some_and(|s| s.hidden),
623 current_course_instance_id: current_instance.map(|ci| ci.id),
624 current_course_instance_name: current_instance.and_then(|ci| ci.name.clone()),
625 supports_credit_registration: modules.iter().any(|m| m.supports_credit_registration),
626 exam_passed,
627 modules,
628 });
629 }
630
631 courses.sort_by(|a, b| {
633 b.is_current
634 .cmp(&a.is_current)
635 .then(b.first_enrolled_at.cmp(&a.first_enrolled_at))
636 });
637
638 let mut total_courses = 0;
639 let mut total_completions = 0;
640 let mut total_ects = 0.0;
641 for course in courses.iter().filter(|c| !c.hidden) {
642 total_courses += 1;
643 for module in &course.modules {
644 if module.completion.as_ref().is_some_and(|c| c.passed) {
645 total_completions += 1;
646 total_ects += module.ects_credits.unwrap_or(0.0);
647 }
648 }
649 }
650
651 let res = MyStudies {
652 any_module_supports_credit_registration: courses
653 .iter()
654 .any(|c| c.supports_credit_registration),
655 totals: MyStudiesTotals {
656 courses: total_courses,
657 completions: total_completions,
658 ects: total_ects,
659 },
660 courses,
661 };
662
663 token.authorized_ok(web::Json(res))
664}
665
666#[instrument(skip(pool))]
673#[utoipa::path(
674 get,
675 path = "/my-certificates",
676 operation_id = "getMyCertificates",
677 tag = "users",
678 responses(
679 (status = 200, description = "The authenticated user's certificates", body = Vec<UserCertificate>)
680 )
681)]
682async fn get_my_certificates(
683 user: AuthUser,
684 pool: web::Data<PgPool>,
685) -> ControllerResult<web::Json<Vec<UserCertificate>>> {
686 let mut conn = pool.acquire().await?;
687 let token = skip_authorize();
688
689 let res = models::generated_certificates::get_all_by_user_id(&mut conn, user.id).await?;
690
691 token.authorized_ok(web::Json(res))
692}
693
694#[instrument(skip(pool))]
698#[utoipa::path(
699 get,
700 path = "/{user_id}/user-reset-exercise-logs",
701 operation_id = "getUserResetExerciseLogs",
702 tag = "users",
703 params(
704 ("user_id" = Uuid, Path, description = "User id")
705 ),
706 responses(
707 (status = 200, description = "User reset exercise logs", body = [ExerciseResetLog])
708 )
709)]
710pub async fn get_user_reset_exercise_logs(
711 user_id: web::Path<Uuid>,
712 pool: web::Data<PgPool>,
713 auth_user: AuthUser,
714) -> ControllerResult<web::Json<Vec<ExerciseResetLog>>> {
715 let mut conn = pool.acquire().await?;
716 let token = authorize(
717 &mut conn,
718 Act::ViewUserProgressOrDetails,
719 Some(auth_user.id),
720 Res::GlobalPermissions,
721 )
722 .await?;
723 let res =
724 models::exercise_reset_logs::get_exercise_reset_logs_for_user(&mut conn, *user_id).await?;
725
726 token.authorized_ok(web::Json(res))
727}
728
729#[instrument(skip(pool))]
734#[utoipa::path(
735 get,
736 path = "/{user_id}/courses/{course_id}/submission-times",
737 operation_id = "getUserCourseSubmissionTimes",
738 tag = "users",
739 params(
740 ("user_id" = Uuid, Path, description = "User id"),
741 ("course_id" = Uuid, Path, description = "Course id")
742 ),
743 responses(
744 (status = 200, description = "User course submission times", body = [UserCourseSubmissionTime])
745 )
746)]
747pub async fn get_user_course_submission_times(
748 path: web::Path<(Uuid, Uuid)>,
749 pool: web::Data<PgPool>,
750 auth_user: AuthUser,
751) -> ControllerResult<web::Json<Vec<UserCourseSubmissionTime>>> {
752 let (user_id, course_id) = path.into_inner();
753 let mut conn = pool.acquire().await?;
754 let token = authorize(
755 &mut conn,
756 Act::ViewUserProgressOrDetails,
757 Some(auth_user.id),
758 Res::GlobalPermissions,
759 )
760 .await?;
761 let res = models::exercise_slide_submissions::get_user_course_submission_times(
762 &mut conn, user_id, course_id,
763 )
764 .await?;
765
766 token.authorized_ok(web::Json(res))
767}
768
769#[instrument(skip(pool))]
775#[utoipa::path(
776 get,
777 path = "/{user_id}/suspected-cheaters",
778 operation_id = "getUserSuspectedCheaters",
779 tag = "users",
780 params(
781 ("user_id" = Uuid, Path, description = "User id")
782 ),
783 responses(
784 (status = 200, description = "User suspected-cheater records across courses", body = [UserSuspectedCheaterInfo])
785 )
786)]
787pub async fn get_user_suspected_cheaters(
788 user_id: web::Path<Uuid>,
789 pool: web::Data<PgPool>,
790 auth_user: AuthUser,
791) -> ControllerResult<web::Json<Vec<UserSuspectedCheaterInfo>>> {
792 let mut conn = pool.acquire().await?;
793 let token = authorize(
794 &mut conn,
795 Act::ViewUserProgressOrDetails,
796 Some(auth_user.id),
797 Res::GlobalPermissions,
798 )
799 .await?;
800 let res = models::suspected_cheaters::get_suspected_cheater_info_for_user(&mut conn, *user_id)
801 .await?;
802
803 token.authorized_ok(web::Json(res))
804}
805
806#[instrument(skip(pool))]
811#[utoipa::path(
812 get,
813 path = "/{user_id}/roles",
814 operation_id = "getUserRoles",
815 tag = "users",
816 params(
817 ("user_id" = Uuid, Path, description = "User id")
818 ),
819 responses(
820 (status = 200, description = "User roles across scopes", body = [Role])
821 )
822)]
823pub async fn get_user_roles(
824 user_id: web::Path<Uuid>,
825 pool: web::Data<PgPool>,
826 auth_user: AuthUser,
827) -> ControllerResult<web::Json<Vec<Role>>> {
828 let mut conn = pool.acquire().await?;
829 let token = authorize(
830 &mut conn,
831 Act::ViewUserProgressOrDetails,
832 Some(auth_user.id),
833 Res::GlobalPermissions,
834 )
835 .await?;
836 let res = models::roles::get_roles(&mut conn, *user_id).await?;
837
838 token.authorized_ok(web::Json(res))
839}
840
841#[derive(Debug, Serialize, Deserialize, ToSchema)]
842
843pub struct EmailData {
844 pub email: String,
845 pub language: String,
846}
847
848#[instrument(skip(pool))]
849#[utoipa::path(
850 post,
851 path = "/send-reset-password-email",
852 operation_id = "sendResetPasswordEmail",
853 tag = "users",
854 request_body = EmailData,
855 responses(
856 (status = 200, description = "Reset password email accepted", body = bool)
857 )
858)]
859pub async fn send_reset_password_email(
860 pool: web::Data<PgPool>,
861 payload: web::Json<EmailData>,
862 tmc_client: web::Data<TmcClient>,
863) -> ControllerResult<web::Json<bool>> {
864 let mut conn = pool.acquire().await?;
865 let token = skip_authorize();
866
867 let email = &payload.email.trim().to_lowercase();
868 let language = &payload.language;
869
870 let reset_template = models::email_templates::get_generic_email_template_by_type_and_language(
871 &mut conn,
872 models::email_templates::EmailTemplateType::ResetPasswordEmail,
873 language,
874 )
875 .await
876 .map_err(|_e| {
877 anyhow::anyhow!(
878 "Password reset email template not configured. Missing template 'reset-password-email' for language '{}'",
879 language
880 )
881 })?;
882
883 let user = match models::users::get_by_email(&mut conn, email).await {
884 Ok(user) => Some(user),
885 Err(_) => {
886 if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
889 match models::users::find_by_upstream_id(&mut conn, tmc_user.upstream_id).await? {
893 Some(existing_user) => Some(existing_user),
894 None => Some(
895 models::users::insert_with_upstream_id_and_moocfi_id(
896 &mut conn,
897 &tmc_user.email,
898 tmc_user.first_name.as_deref(),
899 tmc_user.last_name.as_deref(),
900 tmc_user.upstream_id,
901 tmc_user.id,
902 )
903 .await?,
904 ),
905 }
906 } else {
907 None
908 }
909 }
910 };
911
912 if let Some(user) = user {
913 let token = Uuid::new_v4();
914
915 let _password_token =
916 models::user_passwords::insert_password_reset_token(&mut conn, user.id, token).await?;
917
918 let _ =
919 models::email_deliveries::insert_email_delivery(&mut conn, user.id, reset_template.id)
920 .await?;
921 }
922
923 token.authorized_ok(web::Json(true))
924}
925
926#[derive(Debug, Deserialize, ToSchema)]
927pub struct ResetPasswordTokenPayload {
928 #[schema(value_type = String)]
929 pub token: SecretString,
930}
931
932#[instrument(skip(pool))]
933#[utoipa::path(
934 post,
935 path = "/reset-password-token-status",
936 operation_id = "getResetPasswordTokenStatus",
937 tag = "users",
938 request_body = ResetPasswordTokenPayload,
939 responses(
940 (status = 200, description = "Reset password token validity", body = bool)
941 )
942)]
943pub async fn reset_password_token_status(
944 pool: web::Data<PgPool>,
945 payload: web::Json<ResetPasswordTokenPayload>,
946) -> ControllerResult<web::Json<bool>> {
947 let mut conn = pool.acquire().await?;
948 let token = skip_authorize();
949
950 let password_token = match Uuid::parse_str(payload.token.expose_secret()) {
951 Ok(u) => u,
952 Err(_) => return token.authorized_ok(web::Json(false)),
953 };
954
955 let res =
956 models::user_passwords::is_reset_password_token_valid(&mut conn, &password_token).await?;
957
958 token.authorized_ok(web::Json(res))
959}
960
961#[derive(Debug, Deserialize, ToSchema)]
962pub struct ResetPasswordData {
963 #[schema(value_type = String)]
964 pub token: SecretString,
965 #[schema(value_type = String)]
966 pub new_password: SecretString,
967}
968
969#[instrument(skip(pool))]
970#[utoipa::path(
971 post,
972 path = "/reset-password",
973 operation_id = "resetUserPassword",
974 tag = "users",
975 request_body = ResetPasswordData,
976 responses(
977 (status = 200, description = "Password reset status", body = bool)
978 )
979)]
980pub async fn reset_user_password(
981 pool: web::Data<PgPool>,
982 payload: web::Json<ResetPasswordData>,
983 tmc_client: web::Data<TmcClient>,
984) -> ControllerResult<web::Json<bool>> {
985 let mut conn = pool.acquire().await?;
986 let token = skip_authorize();
987
988 let token_uuid = Uuid::parse_str(payload.token.expose_secret())?;
989 let password_hash = models::user_passwords::hash_password(&payload.new_password)
990 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
991
992 let res = models::user_passwords::change_user_password_with_password_reset_token(
993 &mut conn,
994 token_uuid,
995 &password_hash,
996 &tmc_client,
997 )
998 .await?;
999
1000 token.authorized_ok(web::Json(res))
1001}
1002
1003#[derive(Debug, Deserialize, ToSchema)]
1004pub struct ChangePasswordData {
1005 #[schema(value_type = String)]
1006 pub old_password: SecretString,
1007 #[schema(value_type = String)]
1008 pub new_password: SecretString,
1009}
1010
1011#[instrument(skip(pool))]
1012#[utoipa::path(
1013 post,
1014 path = "/change-password",
1015 operation_id = "changeUserPassword",
1016 tag = "users",
1017 request_body = ChangePasswordData,
1018 responses(
1019 (status = 200, description = "Password change status", body = bool)
1020 )
1021)]
1022pub async fn change_user_password(
1023 pool: web::Data<PgPool>,
1024 payload: web::Json<ChangePasswordData>,
1025 user: AuthUser,
1026) -> ControllerResult<web::Json<bool>> {
1027 let mut conn = pool.acquire().await?;
1028 let token = skip_authorize();
1029 let password_hash = models::user_passwords::hash_password(&payload.new_password)
1030 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
1031
1032 let res = models::user_passwords::change_user_password_with_old_password(
1033 &mut conn,
1034 user.id,
1035 &payload.old_password,
1036 &password_hash,
1037 )
1038 .await?;
1039
1040 token.authorized_ok(web::Json(res))
1041}
1042
1043pub fn _add_routes(cfg: &mut ServiceConfig) {
1044 cfg.route(
1045 "/user-research-form-question-answers",
1046 web::get().to(get_all_research_form_answers_with_user_id),
1047 )
1048 .route("/my-courses", web::get().to(get_my_courses))
1049 .route("/my-studies", web::get().to(get_my_studies))
1050 .route("/my-certificates", web::get().to(get_my_certificates))
1051 .route(
1052 "/my-courses/{course_id}/hide",
1053 web::post().to(hide_course_from_my_courses),
1054 )
1055 .route(
1056 "/my-courses/{course_id}/unhide",
1057 web::post().to(unhide_course_from_my_courses),
1058 )
1059 .route(
1060 "/get-user-research-consent",
1061 web::get().to(get_research_consent_by_user_id),
1062 )
1063 .route(
1064 "/user-research-consents",
1065 web::post().to(post_user_consents),
1066 )
1067 .route(
1068 "/send-reset-password-email",
1069 web::post().to(send_reset_password_email),
1070 )
1071 .route("/{user_id}", web::get().to(get_user))
1072 .route(
1073 "/{user_id}/course-enrollments",
1074 web::get().to(get_course_enrollments_for_user),
1075 )
1076 .route(
1077 "/{user_id}/user-reset-exercise-logs",
1078 web::get().to(get_user_reset_exercise_logs),
1079 )
1080 .route(
1081 "/{user_id}/courses/{course_id}/submission-times",
1082 web::get().to(get_user_course_submission_times),
1083 )
1084 .route(
1085 "/{user_id}/suspected-cheaters",
1086 web::get().to(get_user_suspected_cheaters),
1087 )
1088 .route("/{user_id}/roles", web::get().to(get_user_roles))
1089 .route(
1090 "/reset-password-token-status",
1091 web::post().to(reset_password_token_status),
1092 )
1093 .route("/reset-password", web::post().to(reset_user_password))
1094 .route("/change-password", web::post().to(change_user_password));
1095}