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,
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/**
40GET `/api/v0/main-frontend/users/:id`
41*/
42#[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    // Same scope as the sibling user-details endpoints.
64    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/**
75GET `/api/v0/main-frontend/users/:id/course-enrollments`
76*/
77#[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/**
117POST `/api/v0/main-frontend/users/user-research-consents` - Adds a research consent for a student.
118*/
119#[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/**
149GET `/api/v0/main-frontend/users/get-user-research-consent` - Gets users research consent.
150*/
151#[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/**
175GET `/api/v0/main-frontend/users/get-user-research-consents` - Gets all users research consents for a course specific research form.
176*/
177#[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    /// Whether the course can be hidden from the "My courses" list. False for courses the user has
206    /// not enrolled in or has a role in.
207    pub can_hide: bool,
208}
209
210/**
211GET `/api/v0/main-frontend/users/my-courses` - Gets all the courses the user has either started or gotten a permission to.
212*/
213#[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        // A course the user has a role in always stays visible and can't be hidden.
254        .filter(|c| !hidden_course_ids.contains(&c.id) || role_course_ids.contains(&c.id))
255        .collect();
256
257    // Stable ordering so the "My courses" grid does not reshuffle between requests.
258    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/**
273POST `/api/v0/main-frontend/users/my-courses/:course_id/hide` - Hides a course from the
274authenticated user's "My courses" list.
275*/
276#[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    // A course the user has a role in can't be hidden.
298    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/**
310POST `/api/v0/main-frontend/users/my-courses/:course_id/unhide` - Puts a previously hidden course
311back into the authenticated user's "My courses" list.
312*/
313#[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/// A course module as the student's own profile shows it, with their best visible completion.
340#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
341pub struct MyStudiesCourseModule {
342    pub course_module_id: Uuid,
343    /// `None` for the course's default module; the frontend labels those with the course name.
344    pub name: Option<String>,
345    pub order_number: i32,
346    pub ects_credits: Option<f32>,
347    pub uh_course_code: Option<String>,
348    /// Whether this student's credits for the module go through credit registration via Suotar: the
349    /// flag of the completion [`models::course_module_completions::select_registration_completion`]
350    /// picks, or, before a completion is shown, whether a new one would get it.
351    pub supports_credit_registration: bool,
352    /// Whether a credit registration is about to be created for the shown completion, which the
353    /// student is told is `sending` until it exists.
354    pub is_credit_registration_starting: bool,
355    /// Exercise points the student has in the module, rounded to two decimals. Not ECTS credits.
356    pub score_given: f32,
357    /// Exercise points the module offers. `None` when it has no exercises.
358    pub score_maximum: Option<u32>,
359    /// Exercise points an automatic completion requires. `None` when the module is completed
360    /// manually or sets no point threshold.
361    pub score_required: Option<i32>,
362    /// Exercises the module offers. `None` when it has none.
363    pub total_exercises: Option<u32>,
364    /// Exercises the student has answered.
365    pub attempted_exercises: i32,
366    /// Attempted exercises an automatic completion requires. `None` when the module is completed
367    /// manually or sets no attempt threshold.
368    pub attempted_exercises_required: Option<i32>,
369    /// False when a teacher grades the module, in which case neither threshold says anything about
370    /// completing it.
371    pub automatic_completion: bool,
372    /// When true, the thresholds qualify the student to sit an exam rather than complete the module.
373    pub requires_exam: bool,
374    /// `None` when no completion may be shown to the student. May be a failed one, so check `passed`.
375    pub completion: Option<MyStudiesCompletion>,
376}
377
378/// A completion as the student may see it. `needs_to_be_reviewed` ones are excluded so a student
379/// cannot infer that they are under suspicion.
380#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
381pub struct MyStudiesCompletion {
382    pub course_module_completion_id: Uuid,
383    pub completion_date: DateTime<Utc>,
384    /// `None` on pass/fail modules; the frontend falls back to `passed`.
385    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    /// False when the student's active version of this course is a different language version.
399    pub is_current: bool,
400    /// Hidden courses are included here, unlike in `getMyCourses`, so the profile can offer unhiding.
401    pub hidden: bool,
402    /// The instance the per-module progress is fetched for. `None` if the enrolment has no instance.
403    pub current_course_instance_id: Option<Uuid>,
404    pub current_course_instance_name: Option<String>,
405    pub supports_credit_registration: bool,
406    /// Whether the student has passed an exam of this course, on the terms the completion check
407    /// uses. `None` when no module requires one, so it was never checked.
408    pub exam_passed: Option<bool>,
409    pub modules: Vec<MyStudiesCourseModule>,
410}
411
412/// Summarises the courses the profile lists, i.e. the non-hidden ones.
413#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
414pub struct MyStudiesTotals {
415    pub courses: i32,
416    /// Counts passed completions only.
417    pub completions: i32,
418    /// Summed over passed completions only.
419    pub ects: f32,
420}
421
422#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
423pub struct MyStudies {
424    /// Drives whether the profile's credit-registration tab renders. Covers hidden courses too:
425    /// hiding a course must not take away access to registering its credits.
426    pub any_module_supports_credit_registration: bool,
427    pub courses: Vec<MyStudiesCourse>,
428    pub totals: MyStudiesTotals,
429}
430
431/**
432GET `/api/v0/main-frontend/users/my-studies` - The authenticated user's own study record: every
433course they are enrolled in, its modules, and their completions.
434
435No user id parameter, so it cannot be pointed at another account. The teacher/admin equivalent is
436`getUserCourseEnrollments`.
437*/
438#[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        // Best visible completion per module, matching the course material's
493        // `get_user_module_completion_statuses_for_course`.
494        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                // Failed completions are kept for the course's own table; only the totals omit them.
518                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                    // Defaults to automatic: a missing row must not make the profile claim a
561                    // teacher grades the module.
562                    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        // Only worth the queries when a module's requirements hinge on an exam.
572        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        // Prefer the settings' instance: it is the one the course material shows progress for.
586        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        // Without an organization slug there is no url to the course, so skip it rather than fail the
597        // whole study record.
598        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    // So the top of the page is what the student is working on now.
632    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/**
667GET `/api/v0/main-frontend/users/my-certificates` - Every certificate the authenticated user holds.
668
669No user id parameter, so it cannot be pointed at another account. Anyone holding a certificate's
670verification id can already fetch its image; this only lists which ones are the caller's.
671*/
672#[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/**
695GET `/api/v0/main-frontend/users/:id/user-reset-exercise-logs` - Get all logs of reset exercises for a user
696*/
697#[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/**
730GET `/api/v0/main-frontend/users/:id/courses/:course_id/submission-times` - A user's exercise
731submission times in a course, each tagged with its exercise and module. Teacher/admin (global) view.
732*/
733#[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/**
770GET `/api/v0/main-frontend/users/:id/suspected-cheaters` - Cross-course suspected-cheater records for
771a user, each paired with the course's applicable duration threshold. Teacher/admin (global) view;
772read-only (confirm/dismiss happen on the per-course cheaters page).
773*/
774#[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/**
807GET `/api/v0/main-frontend/users/:id/roles` - All roles held by a user, across scopes. Teacher/admin
808(global) view; used to label the account (e.g. staff/teacher) on the user-details page.
809*/
810#[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 the user does not exist in the courses.mooc.fi database,
887            // check TMC for the user and create a new user in courses.mooc.fi if found.
888            if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
889                // The account may already exist under a different email but the same upstream_id
890                // (e.g. the user changed their email in TMC). Reuse that row instead of inserting,
891                // which would violate the users_upstream_id_active_uniq_idx unique index.
892                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}