Skip to main content

headless_lms_server/controllers/main_frontend/
users.rs

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/**
38GET `/api/v0/main-frontend/users/:id`
39*/
40#[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    // Same scope as the sibling user-details endpoints.
62    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/**
73GET `/api/v0/main-frontend/users/:id/course-enrollments`
74*/
75#[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/**
115POST `/api/v0/main-frontend/users/user-research-consents` - Adds a research consent for a student.
116*/
117#[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/**
147GET `/api/v0/main-frontend/users/get-user-research-consent` - Gets users research consent.
148*/
149#[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/**
173GET `/api/v0/main-frontend/users/get-user-research-consents` - Gets all users research consents for a course specific research form.
174*/
175#[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    /// Whether the course can be hidden from the "My courses" list. False for courses the user has
204    /// not enrolled in or has a role in.
205    pub can_hide: bool,
206}
207
208/**
209GET `/api/v0/main-frontend/users/my-courses` - Gets all the courses the user has either started or gotten a permission to.
210*/
211#[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        // A course the user has a role in always stays visible and can't be hidden.
252        .filter(|c| !hidden_course_ids.contains(&c.id) || role_course_ids.contains(&c.id))
253        .collect();
254
255    // Stable ordering so the "My courses" grid does not reshuffle between requests.
256    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/**
271POST `/api/v0/main-frontend/users/my-courses/:course_id/hide` - Hides a course from the
272authenticated user's "My courses" list.
273*/
274#[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    // A course the user has a role in can't be hidden.
296    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/**
308POST `/api/v0/main-frontend/users/my-courses/:course_id/unhide` - Puts a previously hidden course
309back into the authenticated user's "My courses" list.
310*/
311#[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/// A course module as the student's own profile shows it, with their best visible completion.
338#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
339pub struct MyStudiesCourseModule {
340    pub course_module_id: Uuid,
341    /// `None` for the course's default module; the frontend labels those with the course name.
342    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    /// `None` when no completion may be shown to the student. May be a failed one, so check `passed`.
348    pub completion: Option<MyStudiesCompletion>,
349}
350
351/// A completion as the student may see it. `needs_to_be_reviewed` ones are excluded so a student
352/// cannot infer that they are under suspicion.
353#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
354pub struct MyStudiesCompletion {
355    pub course_module_completion_id: Uuid,
356    pub completion_date: DateTime<Utc>,
357    /// `None` on pass/fail modules; the frontend falls back to `passed`.
358    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    /// False when the student's active version of this course is a different language version.
372    pub is_current: bool,
373    /// Hidden courses are included here, unlike in `getMyCourses`, so the profile can offer unhiding.
374    pub hidden: bool,
375    /// The instance the per-module progress is fetched for. `None` if the enrolment has no instance.
376    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/// Summarises the courses the profile lists, i.e. the non-hidden ones.
383#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
384pub struct MyStudiesTotals {
385    pub courses: i32,
386    /// Counts passed completions only.
387    pub completions: i32,
388    /// Summed over passed completions only.
389    pub ects: f32,
390}
391
392#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
393pub struct MyStudies {
394    /// Drives whether the profile's credit-registration tab renders. Covers hidden courses too:
395    /// hiding a course must not take away access to registering its credits.
396    pub any_module_supports_credit_registration: bool,
397    pub courses: Vec<MyStudiesCourse>,
398    pub totals: MyStudiesTotals,
399}
400
401/**
402GET `/api/v0/main-frontend/users/my-studies` - The authenticated user's own study record: every
403course they are enrolled in, its modules, and their completions.
404
405No user id parameter, so it cannot be pointed at another account. The teacher/admin equivalent is
406`getUserCourseEnrollments`.
407*/
408#[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        // Best visible completion per module, matching the course material's
438        // `get_user_module_completion_statuses_for_course`.
439        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                // Failed completions are kept for the course's own table; only the totals omit them.
451                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        // Prefer the settings' instance: it is the one the course material shows progress for.
480        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        // Without an organization slug there is no url to the course, so skip it rather than fail the
491        // whole study record.
492        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    // So the top of the page is what the student is working on now.
525    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/**
560GET `/api/v0/main-frontend/users/:id/user-reset-exercise-logs` - Get all logs of reset exercises for a user
561*/
562#[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/**
595GET `/api/v0/main-frontend/users/:id/courses/:course_id/submission-times` - A user's exercise
596submission times in a course, each tagged with its exercise and module. Teacher/admin (global) view.
597*/
598#[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/**
635GET `/api/v0/main-frontend/users/:id/suspected-cheaters` - Cross-course suspected-cheater records for
636a user, each paired with the course's applicable duration threshold. Teacher/admin (global) view;
637read-only (confirm/dismiss happen on the per-course cheaters page).
638*/
639#[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/**
672GET `/api/v0/main-frontend/users/:id/roles` - All roles held by a user, across scopes. Teacher/admin
673(global) view; used to label the account (e.g. staff/teacher) on the user-details page.
674*/
675#[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 the user does not exist in the courses.mooc.fi database,
752            // check TMC for the user and create a new user in courses.mooc.fi if found.
753            if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
754                // The account may already exist under a different email but the same upstream_id
755                // (e.g. the user changed their email in TMC). Reuse that row instead of inserting,
756                // which would violate the users_upstream_id_active_uniq_idx unique index.
757                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}