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