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    generated_certificates::UserCertificate, research_forms::ResearchFormQuestionAnswer,
8    roles::Role, suspected_cheaters::UserSuspectedCheaterInfo,
9    user_research_consents::UserResearchConsent, 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_my_certificates,
29    get_user_reset_exercise_logs,
30    get_user_course_submission_times,
31    send_reset_password_email,
32    reset_password_token_status,
33    reset_user_password,
34    change_user_password
35))]
36pub(crate) struct MainFrontendUsersApiDoc;
37
38/**
39GET `/api/v0/main-frontend/users/:id`
40*/
41#[instrument(skip(pool))]
42#[utoipa::path(
43    get,
44    path = "/{user_id}",
45    operation_id = "getUser",
46    tag = "users",
47    params(
48        ("user_id" = Uuid, Path, description = "User id")
49    ),
50    responses(
51        (status = 200, description = "User", body = User)
52    )
53)]
54pub async fn get_user(
55    user_id: web::Path<Uuid>,
56    pool: web::Data<PgPool>,
57    auth_user: AuthUser,
58) -> ControllerResult<web::Json<User>> {
59    let mut conn = pool.acquire().await?;
60    let user = models::users::get_by_id(&mut conn, *user_id).await?;
61
62    // Same scope as the sibling user-details endpoints.
63    let token = authorize(
64        &mut conn,
65        Act::ViewUserProgressOrDetails,
66        Some(auth_user.id),
67        Res::GlobalPermissions,
68    )
69    .await?;
70    token.authorized_ok(web::Json(user))
71}
72
73/**
74GET `/api/v0/main-frontend/users/:id/course-enrollments`
75*/
76#[instrument(skip(pool))]
77#[utoipa::path(
78    get,
79    path = "/{user_id}/course-enrollments",
80    operation_id = "getUserCourseEnrollments",
81    tag = "users",
82    params(
83        ("user_id" = Uuid, Path, description = "User id")
84    ),
85    responses(
86        (status = 200, description = "User course enrollments", body = CourseEnrollmentsInfo)
87    )
88)]
89pub async fn get_course_enrollments_for_user(
90    user_id: web::Path<Uuid>,
91    pool: web::Data<PgPool>,
92    auth_user: AuthUser,
93) -> ControllerResult<web::Json<CourseEnrollmentsInfo>> {
94    let mut conn = pool.acquire().await?;
95    let token = authorize(
96        &mut conn,
97        Act::ViewUserProgressOrDetails,
98        Some(auth_user.id),
99        Res::GlobalPermissions,
100    )
101    .await?;
102    let res = models::course_instance_enrollments::get_course_enrollments_info_for_user(
103        &mut conn, *user_id,
104    )
105    .await?;
106    token.authorized_ok(web::Json(res))
107}
108
109#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
110
111pub struct ConsentData {
112    pub consent: bool,
113}
114
115/**
116POST `/api/v0/main-frontend/users/user-research-consents` - Adds a research consent for a student.
117*/
118#[instrument(skip(pool))]
119#[utoipa::path(
120    post,
121    path = "/user-research-consents",
122    operation_id = "createUserResearchConsent",
123    tag = "users",
124    request_body = ConsentData,
125    responses(
126        (status = 200, description = "User research consent", body = UserResearchConsent)
127    )
128)]
129pub async fn post_user_consents(
130    payload: web::Json<ConsentData>,
131    user: AuthUser,
132    pool: web::Data<PgPool>,
133) -> ControllerResult<web::Json<UserResearchConsent>> {
134    let mut conn = pool.acquire().await?;
135    let token = skip_authorize();
136
137    let res = models::user_research_consents::upsert(
138        &mut conn,
139        PKeyPolicy::Generate,
140        user.id,
141        payload.consent,
142    )
143    .await?;
144    token.authorized_ok(web::Json(res))
145}
146
147/**
148GET `/api/v0/main-frontend/users/get-user-research-consent` - Gets users research consent.
149*/
150#[instrument(skip(pool))]
151#[utoipa::path(
152    get,
153    path = "/get-user-research-consent",
154    operation_id = "getUserResearchConsent",
155    tag = "users",
156    responses(
157        (status = 200, description = "User research consent", body = UserResearchConsent)
158    )
159)]
160pub async fn get_research_consent_by_user_id(
161    user: AuthUser,
162    pool: web::Data<PgPool>,
163) -> ControllerResult<web::Json<UserResearchConsent>> {
164    let mut conn = pool.acquire().await?;
165    let token = skip_authorize();
166
167    let res =
168        models::user_research_consents::get_research_consent_by_user_id(&mut conn, user.id).await?;
169
170    token.authorized_ok(web::Json(res))
171}
172
173/**
174GET `/api/v0/main-frontend/users/get-user-research-consents` - Gets all users research consents for a course specific research form.
175*/
176#[instrument(skip(pool))]
177#[utoipa::path(
178    get,
179    path = "/user-research-form-question-answers",
180    operation_id = "getUserResearchFormQuestionAnswers",
181    tag = "users",
182    responses(
183        (status = 200, description = "Research form answers for user", body = [ResearchFormQuestionAnswer])
184    )
185)]
186async fn get_all_research_form_answers_with_user_id(
187    user: AuthUser,
188    pool: web::Data<PgPool>,
189) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
190    let mut conn = pool.acquire().await?;
191    let token = skip_authorize();
192
193    let res =
194        models::research_forms::get_all_research_form_answers_with_user_id(&mut conn, user.id)
195            .await?;
196
197    token.authorized_ok(web::Json(res))
198}
199
200#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
201pub struct MyCourse {
202    #[serde(flatten)]
203    pub course: Course,
204    /// Whether the course can be hidden from the "My courses" list. False for courses the user has
205    /// not enrolled in or has a role in.
206    pub can_hide: bool,
207}
208
209/**
210GET `/api/v0/main-frontend/users/my-courses` - Gets all the courses the user has either started or gotten a permission to.
211*/
212#[instrument(skip(pool))]
213#[utoipa::path(
214    get,
215    path = "/my-courses",
216    operation_id = "getMyCourses",
217    tag = "users",
218    responses(
219        (status = 200, description = "Courses for authenticated user", body = [MyCourse])
220    )
221)]
222async fn get_my_courses(
223    user: AuthUser,
224    pool: web::Data<PgPool>,
225) -> ControllerResult<web::Json<Vec<MyCourse>>> {
226    let mut conn = pool.acquire().await?;
227    let token = skip_authorize();
228
229    let courses_enrolled_to =
230        models::courses::all_courses_user_enrolled_to(&mut conn, user.id).await?;
231
232    let courses_with_roles =
233        models::courses::all_courses_with_roles_for_user(&mut conn, user.id).await?;
234
235    let settings = models::user_course_settings::get_all_by_user_id(&mut conn, user.id).await?;
236    let hidden_course_ids: HashSet<Uuid> = settings
237        .iter()
238        .filter(|s| s.hidden)
239        .map(|s| s.current_course_id)
240        .collect();
241    let enrolled_course_ids: HashSet<Uuid> = settings.iter().map(|s| s.current_course_id).collect();
242    let role_course_ids: HashSet<Uuid> = courses_with_roles.iter().map(|c| c.id).collect();
243
244    let mut combined: Vec<Course> = courses_enrolled_to
245        .clone()
246        .into_iter()
247        .chain(
248            courses_with_roles
249                .into_iter()
250                .filter(|c| !courses_enrolled_to.iter().any(|c2| c.id == c2.id)),
251        )
252        // A course the user has a role in always stays visible and can't be hidden.
253        .filter(|c| !hidden_course_ids.contains(&c.id) || role_course_ids.contains(&c.id))
254        .collect();
255
256    // Stable ordering so the "My courses" grid does not reshuffle between requests.
257    combined.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id)));
258
259    let my_courses = combined
260        .into_iter()
261        .map(|course| {
262            let can_hide =
263                enrolled_course_ids.contains(&course.id) && !role_course_ids.contains(&course.id);
264            MyCourse { course, can_hide }
265        })
266        .collect();
267
268    token.authorized_ok(web::Json(my_courses))
269}
270
271/**
272POST `/api/v0/main-frontend/users/my-courses/:course_id/hide` - Hides a course from the
273authenticated user's "My courses" list.
274*/
275#[instrument(skip(pool))]
276#[utoipa::path(
277    post,
278    path = "/my-courses/{course_id}/hide",
279    operation_id = "hideCourseFromMyCourses",
280    tag = "users",
281    params(
282        ("course_id" = Uuid, Path, description = "Course id")
283    ),
284    responses(
285        (status = 200, description = "Course hidden from the user's my-courses list")
286    )
287)]
288async fn hide_course_from_my_courses(
289    course_id: web::Path<Uuid>,
290    user: AuthUser,
291    pool: web::Data<PgPool>,
292) -> ControllerResult<web::Json<()>> {
293    let mut conn = pool.acquire().await?;
294    let token = skip_authorize();
295
296    // A course the user has a role in can't be hidden.
297    let has_role = models::courses::all_courses_with_roles_for_user(&mut conn, user.id)
298        .await?
299        .iter()
300        .any(|c| c.id == *course_id);
301    if !has_role {
302        models::user_course_settings::set_hidden(&mut conn, user.id, *course_id, true).await?;
303    }
304
305    token.authorized_ok(web::Json(()))
306}
307
308/**
309POST `/api/v0/main-frontend/users/my-courses/:course_id/unhide` - Puts a previously hidden course
310back into the authenticated user's "My courses" list.
311*/
312#[instrument(skip(pool))]
313#[utoipa::path(
314    post,
315    path = "/my-courses/{course_id}/unhide",
316    operation_id = "unhideCourseFromMyCourses",
317    tag = "users",
318    params(
319        ("course_id" = Uuid, Path, description = "Course id")
320    ),
321    responses(
322        (status = 200, description = "Course restored to the user's my-courses list")
323    )
324)]
325async fn unhide_course_from_my_courses(
326    course_id: web::Path<Uuid>,
327    user: AuthUser,
328    pool: web::Data<PgPool>,
329) -> ControllerResult<web::Json<()>> {
330    let mut conn = pool.acquire().await?;
331    let token = skip_authorize();
332
333    models::user_course_settings::set_hidden(&mut conn, user.id, *course_id, false).await?;
334
335    token.authorized_ok(web::Json(()))
336}
337
338/// A course module as the student's own profile shows it, with their best visible completion.
339#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
340pub struct MyStudiesCourseModule {
341    pub course_module_id: Uuid,
342    /// `None` for the course's default module; the frontend labels those with the course name.
343    pub name: Option<String>,
344    pub order_number: i32,
345    pub ects_credits: Option<f32>,
346    pub uh_course_code: Option<String>,
347    pub supports_credit_registration: bool,
348    /// `None` when no completion may be shown to the student. May be a failed one, so check `passed`.
349    pub completion: Option<MyStudiesCompletion>,
350}
351
352/// A completion as the student may see it. `needs_to_be_reviewed` ones are excluded so a student
353/// cannot infer that they are under suspicion.
354#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
355pub struct MyStudiesCompletion {
356    pub course_module_completion_id: Uuid,
357    pub completion_date: DateTime<Utc>,
358    /// `None` on pass/fail modules; the frontend falls back to `passed`.
359    pub grade: Option<i32>,
360    pub passed: bool,
361    pub prerequisite_modules_completed: bool,
362}
363
364#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
365pub struct MyStudiesCourse {
366    pub course_id: Uuid,
367    pub course_name: String,
368    pub course_slug: String,
369    pub organization_slug: String,
370    pub language_code: String,
371    pub first_enrolled_at: DateTime<Utc>,
372    /// False when the student's active version of this course is a different language version.
373    pub is_current: bool,
374    /// Hidden courses are included here, unlike in `getMyCourses`, so the profile can offer unhiding.
375    pub hidden: bool,
376    /// The instance the per-module progress is fetched for. `None` if the enrolment has no instance.
377    pub current_course_instance_id: Option<Uuid>,
378    pub current_course_instance_name: Option<String>,
379    pub supports_credit_registration: bool,
380    pub modules: Vec<MyStudiesCourseModule>,
381}
382
383/// Summarises the courses the profile lists, i.e. the non-hidden ones.
384#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
385pub struct MyStudiesTotals {
386    pub courses: i32,
387    /// Counts passed completions only.
388    pub completions: i32,
389    /// Summed over passed completions only.
390    pub ects: f32,
391}
392
393#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
394pub struct MyStudies {
395    /// Drives whether the profile's credit-registration tab renders. Covers hidden courses too:
396    /// hiding a course must not take away access to registering its credits.
397    pub any_module_supports_credit_registration: bool,
398    pub courses: Vec<MyStudiesCourse>,
399    pub totals: MyStudiesTotals,
400}
401
402/**
403GET `/api/v0/main-frontend/users/my-studies` - The authenticated user's own study record: every
404course they are enrolled in, its modules, and their completions.
405
406No user id parameter, so it cannot be pointed at another account. The teacher/admin equivalent is
407`getUserCourseEnrollments`.
408*/
409#[instrument(skip(pool))]
410#[utoipa::path(
411    get,
412    path = "/my-studies",
413    operation_id = "getMyStudies",
414    tag = "users",
415    responses(
416        (status = 200, description = "The authenticated user's study record", body = MyStudies)
417    )
418)]
419async fn get_my_studies(
420    user: AuthUser,
421    pool: web::Data<PgPool>,
422) -> ControllerResult<web::Json<MyStudies>> {
423    let mut conn = pool.acquire().await?;
424    let token = skip_authorize();
425
426    let enrollments_info =
427        models::course_instance_enrollments::get_course_enrollments_info_for_user(
428            &mut conn, user.id,
429        )
430        .await?;
431    let organizations = models::organizations::all_organizations_include_hidden(&mut conn).await?;
432    let organization_slugs: HashMap<Uuid, String> =
433        organizations.into_iter().map(|o| (o.id, o.slug)).collect();
434
435    let mut courses = Vec::with_capacity(enrollments_info.course_enrollments.len());
436
437    for enrollment in enrollments_info.course_enrollments {
438        // Best visible completion per module, matching the course material's
439        // `get_user_module_completion_statuses_for_course`.
440        let mut best_completion_by_module: HashMap<Uuid, MyStudiesCompletion> = HashMap::new();
441        for course_module in &enrollment.course_modules {
442            let visible_completions: Vec<_> = enrollment
443                .course_module_completions
444                .iter()
445                .filter(|c| c.course_module_id == course_module.id && !c.needs_to_be_reviewed)
446                .cloned()
447                .collect();
448            if let Some(best) =
449                models::course_module_completions::select_best_completion(visible_completions)
450            {
451                // Failed completions are kept for the course's own table; only the totals omit them.
452                best_completion_by_module.insert(
453                    course_module.id,
454                    MyStudiesCompletion {
455                        course_module_completion_id: best.id,
456                        completion_date: best.completion_date,
457                        grade: best.grade,
458                        passed: best.passed,
459                        prerequisite_modules_completed: best.prerequisite_modules_completed,
460                    },
461                );
462            }
463        }
464
465        let mut modules: Vec<MyStudiesCourseModule> = enrollment
466            .course_modules
467            .iter()
468            .map(|course_module| MyStudiesCourseModule {
469                course_module_id: course_module.id,
470                name: course_module.name.clone(),
471                order_number: course_module.order_number,
472                ects_credits: course_module.ects_credits,
473                uh_course_code: course_module.uh_course_code.clone(),
474                supports_credit_registration: course_module.enable_credit_registration_via_suotar,
475                completion: best_completion_by_module.remove(&course_module.id),
476            })
477            .collect();
478        modules.sort_by_key(|m| m.order_number);
479
480        // Prefer the settings' instance: it is the one the course material shows progress for.
481        let settings_instance_id = enrollment
482            .user_course_settings
483            .as_ref()
484            .map(|s| s.current_course_instance_id);
485        let current_instance = enrollment
486            .course_instances
487            .iter()
488            .find(|ci| Some(ci.id) == settings_instance_id)
489            .or_else(|| enrollment.course_instances.first());
490
491        // Without an organization slug there is no url to the course, so skip it rather than fail the
492        // whole study record.
493        let Some(organization_slug) = organization_slugs
494            .get(&enrollment.course.organization_id)
495            .cloned()
496        else {
497            warn!(
498                user_id = %user.id,
499                course_id = %enrollment.course_id,
500                organization_id = %enrollment.course.organization_id,
501                "Skipping course from the user's studies because its organization is deleted"
502            );
503            continue;
504        };
505
506        courses.push(MyStudiesCourse {
507            course_id: enrollment.course_id,
508            course_name: enrollment.course.name.clone(),
509            course_slug: enrollment.course.slug.clone(),
510            organization_slug,
511            language_code: enrollment.course.language_code.clone(),
512            first_enrolled_at: enrollment.first_enrolled_at,
513            is_current: enrollment.is_current,
514            hidden: enrollment
515                .user_course_settings
516                .as_ref()
517                .is_some_and(|s| s.hidden),
518            current_course_instance_id: current_instance.map(|ci| ci.id),
519            current_course_instance_name: current_instance.and_then(|ci| ci.name.clone()),
520            supports_credit_registration: modules.iter().any(|m| m.supports_credit_registration),
521            modules,
522        });
523    }
524
525    // So the top of the page is what the student is working on now.
526    courses.sort_by(|a, b| {
527        b.is_current
528            .cmp(&a.is_current)
529            .then(b.first_enrolled_at.cmp(&a.first_enrolled_at))
530    });
531
532    let mut total_courses = 0;
533    let mut total_completions = 0;
534    let mut total_ects = 0.0;
535    for course in courses.iter().filter(|c| !c.hidden) {
536        total_courses += 1;
537        for module in &course.modules {
538            if module.completion.as_ref().is_some_and(|c| c.passed) {
539                total_completions += 1;
540                total_ects += module.ects_credits.unwrap_or(0.0);
541            }
542        }
543    }
544
545    let res = MyStudies {
546        any_module_supports_credit_registration: courses
547            .iter()
548            .any(|c| c.supports_credit_registration),
549        totals: MyStudiesTotals {
550            courses: total_courses,
551            completions: total_completions,
552            ects: total_ects,
553        },
554        courses,
555    };
556
557    token.authorized_ok(web::Json(res))
558}
559
560/**
561GET `/api/v0/main-frontend/users/my-certificates` - Every certificate the authenticated user holds.
562
563No user id parameter, so it cannot be pointed at another account. Anyone holding a certificate's
564verification id can already fetch its image; this only lists which ones are the caller's.
565*/
566#[instrument(skip(pool))]
567#[utoipa::path(
568    get,
569    path = "/my-certificates",
570    operation_id = "getMyCertificates",
571    tag = "users",
572    responses(
573        (status = 200, description = "The authenticated user's certificates", body = Vec<UserCertificate>)
574    )
575)]
576async fn get_my_certificates(
577    user: AuthUser,
578    pool: web::Data<PgPool>,
579) -> ControllerResult<web::Json<Vec<UserCertificate>>> {
580    let mut conn = pool.acquire().await?;
581    let token = skip_authorize();
582
583    let res = models::generated_certificates::get_all_by_user_id(&mut conn, user.id).await?;
584
585    token.authorized_ok(web::Json(res))
586}
587
588/**
589GET `/api/v0/main-frontend/users/:id/user-reset-exercise-logs` - Get all logs of reset exercises for a user
590*/
591#[instrument(skip(pool))]
592#[utoipa::path(
593    get,
594    path = "/{user_id}/user-reset-exercise-logs",
595    operation_id = "getUserResetExerciseLogs",
596    tag = "users",
597    params(
598        ("user_id" = Uuid, Path, description = "User id")
599    ),
600    responses(
601        (status = 200, description = "User reset exercise logs", body = [ExerciseResetLog])
602    )
603)]
604pub async fn get_user_reset_exercise_logs(
605    user_id: web::Path<Uuid>,
606    pool: web::Data<PgPool>,
607    auth_user: AuthUser,
608) -> ControllerResult<web::Json<Vec<ExerciseResetLog>>> {
609    let mut conn = pool.acquire().await?;
610    let token = authorize(
611        &mut conn,
612        Act::ViewUserProgressOrDetails,
613        Some(auth_user.id),
614        Res::GlobalPermissions,
615    )
616    .await?;
617    let res =
618        models::exercise_reset_logs::get_exercise_reset_logs_for_user(&mut conn, *user_id).await?;
619
620    token.authorized_ok(web::Json(res))
621}
622
623/**
624GET `/api/v0/main-frontend/users/:id/courses/:course_id/submission-times` - A user's exercise
625submission times in a course, each tagged with its exercise and module. Teacher/admin (global) view.
626*/
627#[instrument(skip(pool))]
628#[utoipa::path(
629    get,
630    path = "/{user_id}/courses/{course_id}/submission-times",
631    operation_id = "getUserCourseSubmissionTimes",
632    tag = "users",
633    params(
634        ("user_id" = Uuid, Path, description = "User id"),
635        ("course_id" = Uuid, Path, description = "Course id")
636    ),
637    responses(
638        (status = 200, description = "User course submission times", body = [UserCourseSubmissionTime])
639    )
640)]
641pub async fn get_user_course_submission_times(
642    path: web::Path<(Uuid, Uuid)>,
643    pool: web::Data<PgPool>,
644    auth_user: AuthUser,
645) -> ControllerResult<web::Json<Vec<UserCourseSubmissionTime>>> {
646    let (user_id, course_id) = path.into_inner();
647    let mut conn = pool.acquire().await?;
648    let token = authorize(
649        &mut conn,
650        Act::ViewUserProgressOrDetails,
651        Some(auth_user.id),
652        Res::GlobalPermissions,
653    )
654    .await?;
655    let res = models::exercise_slide_submissions::get_user_course_submission_times(
656        &mut conn, user_id, course_id,
657    )
658    .await?;
659
660    token.authorized_ok(web::Json(res))
661}
662
663/**
664GET `/api/v0/main-frontend/users/:id/suspected-cheaters` - Cross-course suspected-cheater records for
665a user, each paired with the course's applicable duration threshold. Teacher/admin (global) view;
666read-only (confirm/dismiss happen on the per-course cheaters page).
667*/
668#[instrument(skip(pool))]
669#[utoipa::path(
670    get,
671    path = "/{user_id}/suspected-cheaters",
672    operation_id = "getUserSuspectedCheaters",
673    tag = "users",
674    params(
675        ("user_id" = Uuid, Path, description = "User id")
676    ),
677    responses(
678        (status = 200, description = "User suspected-cheater records across courses", body = [UserSuspectedCheaterInfo])
679    )
680)]
681pub async fn get_user_suspected_cheaters(
682    user_id: web::Path<Uuid>,
683    pool: web::Data<PgPool>,
684    auth_user: AuthUser,
685) -> ControllerResult<web::Json<Vec<UserSuspectedCheaterInfo>>> {
686    let mut conn = pool.acquire().await?;
687    let token = authorize(
688        &mut conn,
689        Act::ViewUserProgressOrDetails,
690        Some(auth_user.id),
691        Res::GlobalPermissions,
692    )
693    .await?;
694    let res = models::suspected_cheaters::get_suspected_cheater_info_for_user(&mut conn, *user_id)
695        .await?;
696
697    token.authorized_ok(web::Json(res))
698}
699
700/**
701GET `/api/v0/main-frontend/users/:id/roles` - All roles held by a user, across scopes. Teacher/admin
702(global) view; used to label the account (e.g. staff/teacher) on the user-details page.
703*/
704#[instrument(skip(pool))]
705#[utoipa::path(
706    get,
707    path = "/{user_id}/roles",
708    operation_id = "getUserRoles",
709    tag = "users",
710    params(
711        ("user_id" = Uuid, Path, description = "User id")
712    ),
713    responses(
714        (status = 200, description = "User roles across scopes", body = [Role])
715    )
716)]
717pub async fn get_user_roles(
718    user_id: web::Path<Uuid>,
719    pool: web::Data<PgPool>,
720    auth_user: AuthUser,
721) -> ControllerResult<web::Json<Vec<Role>>> {
722    let mut conn = pool.acquire().await?;
723    let token = authorize(
724        &mut conn,
725        Act::ViewUserProgressOrDetails,
726        Some(auth_user.id),
727        Res::GlobalPermissions,
728    )
729    .await?;
730    let res = models::roles::get_roles(&mut conn, *user_id).await?;
731
732    token.authorized_ok(web::Json(res))
733}
734
735#[derive(Debug, Serialize, Deserialize, ToSchema)]
736
737pub struct EmailData {
738    pub email: String,
739    pub language: String,
740}
741
742#[instrument(skip(pool))]
743#[utoipa::path(
744    post,
745    path = "/send-reset-password-email",
746    operation_id = "sendResetPasswordEmail",
747    tag = "users",
748    request_body = EmailData,
749    responses(
750        (status = 200, description = "Reset password email accepted", body = bool)
751    )
752)]
753pub async fn send_reset_password_email(
754    pool: web::Data<PgPool>,
755    payload: web::Json<EmailData>,
756    tmc_client: web::Data<TmcClient>,
757) -> ControllerResult<web::Json<bool>> {
758    let mut conn = pool.acquire().await?;
759    let token = skip_authorize();
760
761    let email = &payload.email.trim().to_lowercase();
762    let language = &payload.language;
763
764    let reset_template = models::email_templates::get_generic_email_template_by_type_and_language(
765        &mut conn,
766        models::email_templates::EmailTemplateType::ResetPasswordEmail,
767        language,
768    )
769    .await
770    .map_err(|_e| {
771        anyhow::anyhow!(
772            "Password reset email template not configured. Missing template 'reset-password-email' for language '{}'",
773            language
774        )
775    })?;
776
777    let user = match models::users::get_by_email(&mut conn, email).await {
778        Ok(user) => Some(user),
779        Err(_) => {
780            // If the user does not exist in the courses.mooc.fi database,
781            // check TMC for the user and create a new user in courses.mooc.fi if found.
782            if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
783                // The account may already exist under a different email but the same upstream_id
784                // (e.g. the user changed their email in TMC). Reuse that row instead of inserting,
785                // which would violate the users_upstream_id_active_uniq_idx unique index.
786                match models::users::find_by_upstream_id(&mut conn, tmc_user.upstream_id).await? {
787                    Some(existing_user) => Some(existing_user),
788                    None => Some(
789                        models::users::insert_with_upstream_id_and_moocfi_id(
790                            &mut conn,
791                            &tmc_user.email,
792                            tmc_user.first_name.as_deref(),
793                            tmc_user.last_name.as_deref(),
794                            tmc_user.upstream_id,
795                            tmc_user.id,
796                        )
797                        .await?,
798                    ),
799                }
800            } else {
801                None
802            }
803        }
804    };
805
806    if let Some(user) = user {
807        let token = Uuid::new_v4();
808
809        let _password_token =
810            models::user_passwords::insert_password_reset_token(&mut conn, user.id, token).await?;
811
812        let _ =
813            models::email_deliveries::insert_email_delivery(&mut conn, user.id, reset_template.id)
814                .await?;
815    }
816
817    token.authorized_ok(web::Json(true))
818}
819
820#[derive(Debug, Deserialize, ToSchema)]
821pub struct ResetPasswordTokenPayload {
822    #[schema(value_type = String)]
823    pub token: SecretString,
824}
825
826#[instrument(skip(pool))]
827#[utoipa::path(
828    post,
829    path = "/reset-password-token-status",
830    operation_id = "getResetPasswordTokenStatus",
831    tag = "users",
832    request_body = ResetPasswordTokenPayload,
833    responses(
834        (status = 200, description = "Reset password token validity", body = bool)
835    )
836)]
837pub async fn reset_password_token_status(
838    pool: web::Data<PgPool>,
839    payload: web::Json<ResetPasswordTokenPayload>,
840) -> ControllerResult<web::Json<bool>> {
841    let mut conn = pool.acquire().await?;
842    let token = skip_authorize();
843
844    let password_token = match Uuid::parse_str(payload.token.expose_secret()) {
845        Ok(u) => u,
846        Err(_) => return token.authorized_ok(web::Json(false)),
847    };
848
849    let res =
850        models::user_passwords::is_reset_password_token_valid(&mut conn, &password_token).await?;
851
852    token.authorized_ok(web::Json(res))
853}
854
855#[derive(Debug, Deserialize, ToSchema)]
856pub struct ResetPasswordData {
857    #[schema(value_type = String)]
858    pub token: SecretString,
859    #[schema(value_type = String)]
860    pub new_password: SecretString,
861}
862
863#[instrument(skip(pool))]
864#[utoipa::path(
865    post,
866    path = "/reset-password",
867    operation_id = "resetUserPassword",
868    tag = "users",
869    request_body = ResetPasswordData,
870    responses(
871        (status = 200, description = "Password reset status", body = bool)
872    )
873)]
874pub async fn reset_user_password(
875    pool: web::Data<PgPool>,
876    payload: web::Json<ResetPasswordData>,
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 token_uuid = Uuid::parse_str(payload.token.expose_secret())?;
883    let password_hash = models::user_passwords::hash_password(&payload.new_password)
884        .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
885
886    let res = models::user_passwords::change_user_password_with_password_reset_token(
887        &mut conn,
888        token_uuid,
889        &password_hash,
890        &tmc_client,
891    )
892    .await?;
893
894    token.authorized_ok(web::Json(res))
895}
896
897#[derive(Debug, Deserialize, ToSchema)]
898pub struct ChangePasswordData {
899    #[schema(value_type = String)]
900    pub old_password: SecretString,
901    #[schema(value_type = String)]
902    pub new_password: SecretString,
903}
904
905#[instrument(skip(pool))]
906#[utoipa::path(
907    post,
908    path = "/change-password",
909    operation_id = "changeUserPassword",
910    tag = "users",
911    request_body = ChangePasswordData,
912    responses(
913        (status = 200, description = "Password change status", body = bool)
914    )
915)]
916pub async fn change_user_password(
917    pool: web::Data<PgPool>,
918    payload: web::Json<ChangePasswordData>,
919    user: AuthUser,
920) -> ControllerResult<web::Json<bool>> {
921    let mut conn = pool.acquire().await?;
922    let token = skip_authorize();
923    let password_hash = models::user_passwords::hash_password(&payload.new_password)
924        .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
925
926    let res = models::user_passwords::change_user_password_with_old_password(
927        &mut conn,
928        user.id,
929        &payload.old_password,
930        &password_hash,
931    )
932    .await?;
933
934    token.authorized_ok(web::Json(res))
935}
936
937pub fn _add_routes(cfg: &mut ServiceConfig) {
938    cfg.route(
939        "/user-research-form-question-answers",
940        web::get().to(get_all_research_form_answers_with_user_id),
941    )
942    .route("/my-courses", web::get().to(get_my_courses))
943    .route("/my-studies", web::get().to(get_my_studies))
944    .route("/my-certificates", web::get().to(get_my_certificates))
945    .route(
946        "/my-courses/{course_id}/hide",
947        web::post().to(hide_course_from_my_courses),
948    )
949    .route(
950        "/my-courses/{course_id}/unhide",
951        web::post().to(unhide_course_from_my_courses),
952    )
953    .route(
954        "/get-user-research-consent",
955        web::get().to(get_research_consent_by_user_id),
956    )
957    .route(
958        "/user-research-consents",
959        web::post().to(post_user_consents),
960    )
961    .route(
962        "/send-reset-password-email",
963        web::post().to(send_reset_password_email),
964    )
965    .route("/{user_id}", web::get().to(get_user))
966    .route(
967        "/{user_id}/course-enrollments",
968        web::get().to(get_course_enrollments_for_user),
969    )
970    .route(
971        "/{user_id}/user-reset-exercise-logs",
972        web::get().to(get_user_reset_exercise_logs),
973    )
974    .route(
975        "/{user_id}/courses/{course_id}/submission-times",
976        web::get().to(get_user_course_submission_times),
977    )
978    .route(
979        "/{user_id}/suspected-cheaters",
980        web::get().to(get_user_suspected_cheaters),
981    )
982    .route("/{user_id}/roles", web::get().to(get_user_roles))
983    .route(
984        "/reset-password-token-status",
985        web::post().to(reset_password_token_status),
986    )
987    .route("/reset-password", web::post().to(reset_user_password))
988    .route("/change-password", web::post().to(change_user_password));
989}