Skip to main content

headless_lms_server/controllers/main_frontend/
users.rs

1use crate::prelude::*;
2use anyhow::anyhow;
3use headless_lms_utils::tmc::TmcClient;
4use models::{
5    course_instance_enrollments::CourseEnrollmentsInfo, courses::Course,
6    exercise_reset_logs::ExerciseResetLog, research_forms::ResearchFormQuestionAnswer,
7    user_research_consents::UserResearchConsent, users::User,
8};
9use secrecy::SecretString;
10use utoipa::{OpenApi, ToSchema};
11
12#[derive(OpenApi)]
13#[openapi(paths(
14    get_user,
15    get_course_enrollments_for_user,
16    post_user_consents,
17    get_research_consent_by_user_id,
18    get_all_research_form_answers_with_user_id,
19    get_my_courses,
20    get_user_reset_exercise_logs,
21    send_reset_password_email,
22    reset_password_token_status,
23    reset_user_password,
24    change_user_password
25))]
26pub(crate) struct MainFrontendUsersApiDoc;
27
28/**
29GET `/api/v0/main-frontend/users/:id`
30*/
31#[instrument(skip(pool))]
32#[utoipa::path(
33    get,
34    path = "/{user_id}",
35    operation_id = "getUser",
36    tag = "users",
37    params(
38        ("user_id" = Uuid, Path, description = "User id")
39    ),
40    responses(
41        (status = 200, description = "User", body = serde_json::Value)
42    )
43)]
44pub async fn get_user(
45    user_id: web::Path<Uuid>,
46    pool: web::Data<PgPool>,
47) -> ControllerResult<web::Json<User>> {
48    let mut conn = pool.acquire().await?;
49    let user = models::users::get_by_id(&mut conn, *user_id).await?;
50
51    let token = authorize(&mut conn, Act::Teach, Some(*user_id), Res::AnyCourse).await?;
52    token.authorized_ok(web::Json(user))
53}
54
55/**
56GET `/api/v0/main-frontend/users/:id/course-enrollments`
57*/
58#[instrument(skip(pool))]
59#[utoipa::path(
60    get,
61    path = "/{user_id}/course-enrollments",
62    operation_id = "getUserCourseEnrollments",
63    tag = "users",
64    params(
65        ("user_id" = Uuid, Path, description = "User id")
66    ),
67    responses(
68        (status = 200, description = "User course enrollments", body = CourseEnrollmentsInfo)
69    )
70)]
71pub async fn get_course_enrollments_for_user(
72    user_id: web::Path<Uuid>,
73    pool: web::Data<PgPool>,
74    auth_user: AuthUser,
75) -> ControllerResult<web::Json<CourseEnrollmentsInfo>> {
76    let mut conn = pool.acquire().await?;
77    let token = authorize(
78        &mut conn,
79        Act::ViewUserProgressOrDetails,
80        Some(auth_user.id),
81        Res::GlobalPermissions,
82    )
83    .await?;
84    let res = models::course_instance_enrollments::get_course_enrollments_info_for_user(
85        &mut conn, *user_id,
86    )
87    .await?;
88    token.authorized_ok(web::Json(res))
89}
90
91#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
92
93pub struct ConsentData {
94    pub consent: bool,
95}
96
97/**
98POST `/api/v0/main-frontend/users/user-research-consents` - Adds a research consent for a student.
99*/
100#[instrument(skip(pool))]
101#[utoipa::path(
102    post,
103    path = "/user-research-consents",
104    operation_id = "createUserResearchConsent",
105    tag = "users",
106    request_body = ConsentData,
107    responses(
108        (status = 200, description = "User research consent", body = UserResearchConsent)
109    )
110)]
111pub async fn post_user_consents(
112    payload: web::Json<ConsentData>,
113    user: AuthUser,
114    pool: web::Data<PgPool>,
115) -> ControllerResult<web::Json<UserResearchConsent>> {
116    let mut conn = pool.acquire().await?;
117    let token = skip_authorize();
118
119    let res = models::user_research_consents::upsert(
120        &mut conn,
121        PKeyPolicy::Generate,
122        user.id,
123        payload.consent,
124    )
125    .await?;
126    token.authorized_ok(web::Json(res))
127}
128
129/**
130GET `/api/v0/main-frontend/users/get-user-research-consent` - Gets users research consent.
131*/
132#[instrument(skip(pool))]
133#[utoipa::path(
134    get,
135    path = "/get-user-research-consent",
136    operation_id = "getUserResearchConsent",
137    tag = "users",
138    responses(
139        (status = 200, description = "User research consent", body = UserResearchConsent)
140    )
141)]
142pub async fn get_research_consent_by_user_id(
143    user: AuthUser,
144    pool: web::Data<PgPool>,
145) -> ControllerResult<web::Json<UserResearchConsent>> {
146    let mut conn = pool.acquire().await?;
147    let token = skip_authorize();
148
149    let res =
150        models::user_research_consents::get_research_consent_by_user_id(&mut conn, user.id).await?;
151
152    token.authorized_ok(web::Json(res))
153}
154
155/**
156GET `/api/v0/main-frontend/users/get-user-research-consents` - Gets all users research consents for a course specific research form.
157*/
158#[instrument(skip(pool))]
159#[utoipa::path(
160    get,
161    path = "/user-research-form-question-answers",
162    operation_id = "getUserResearchFormQuestionAnswers",
163    tag = "users",
164    responses(
165        (status = 200, description = "Research form answers for user", body = [ResearchFormQuestionAnswer])
166    )
167)]
168async fn get_all_research_form_answers_with_user_id(
169    user: AuthUser,
170    pool: web::Data<PgPool>,
171) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
172    let mut conn = pool.acquire().await?;
173    let token = skip_authorize();
174
175    let res =
176        models::research_forms::get_all_research_form_answers_with_user_id(&mut conn, user.id)
177            .await?;
178
179    token.authorized_ok(web::Json(res))
180}
181
182/**
183GET `/api/v0/main-frontend/users/my-courses` - Gets all the courses the user has either started or gotten a permission to.
184*/
185#[instrument(skip(pool))]
186#[utoipa::path(
187    get,
188    path = "/my-courses",
189    operation_id = "getMyCourses",
190    tag = "users",
191    responses(
192        (status = 200, description = "Courses for authenticated user", body = [Course])
193    )
194)]
195async fn get_my_courses(
196    user: AuthUser,
197    pool: web::Data<PgPool>,
198) -> ControllerResult<web::Json<Vec<Course>>> {
199    let mut conn = pool.acquire().await?;
200    let token = skip_authorize();
201
202    let courses_enrolled_to =
203        models::courses::all_courses_user_enrolled_to(&mut conn, user.id).await?;
204
205    let courses_with_roles =
206        models::courses::all_courses_with_roles_for_user(&mut conn, user.id).await?;
207
208    let combined = courses_enrolled_to
209        .clone()
210        .into_iter()
211        .chain(
212            courses_with_roles
213                .into_iter()
214                .filter(|c| !courses_enrolled_to.iter().any(|c2| c.id == c2.id)),
215        )
216        .collect();
217
218    token.authorized_ok(web::Json(combined))
219}
220
221/**
222GET `/api/v0/main-frontend/users/:id/user-reset-exercise-logs` - Get all logs of reset exercises for a user
223*/
224#[instrument(skip(pool))]
225#[utoipa::path(
226    get,
227    path = "/{user_id}/user-reset-exercise-logs",
228    operation_id = "getUserResetExerciseLogs",
229    tag = "users",
230    params(
231        ("user_id" = Uuid, Path, description = "User id")
232    ),
233    responses(
234        (status = 200, description = "User reset exercise logs", body = [ExerciseResetLog])
235    )
236)]
237pub async fn get_user_reset_exercise_logs(
238    user_id: web::Path<Uuid>,
239    pool: web::Data<PgPool>,
240    auth_user: AuthUser,
241) -> ControllerResult<web::Json<Vec<ExerciseResetLog>>> {
242    let mut conn = pool.acquire().await?;
243    let token = authorize(
244        &mut conn,
245        Act::ViewUserProgressOrDetails,
246        Some(auth_user.id),
247        Res::GlobalPermissions,
248    )
249    .await?;
250    let res =
251        models::exercise_reset_logs::get_exercise_reset_logs_for_user(&mut conn, *user_id).await?;
252
253    token.authorized_ok(web::Json(res))
254}
255
256#[derive(Debug, Serialize, Deserialize, ToSchema)]
257
258pub struct EmailData {
259    pub email: String,
260    pub language: String,
261}
262
263#[instrument(skip(pool))]
264#[utoipa::path(
265    post,
266    path = "/send-reset-password-email",
267    operation_id = "sendResetPasswordEmail",
268    tag = "users",
269    request_body = EmailData,
270    responses(
271        (status = 200, description = "Reset password email accepted", body = bool)
272    )
273)]
274pub async fn send_reset_password_email(
275    pool: web::Data<PgPool>,
276    payload: web::Json<EmailData>,
277    tmc_client: web::Data<TmcClient>,
278) -> ControllerResult<web::Json<bool>> {
279    let mut conn = pool.acquire().await?;
280    let token = skip_authorize();
281
282    let email = &payload.email.trim().to_lowercase();
283    let language = &payload.language;
284
285    let reset_template = models::email_templates::get_generic_email_template_by_type_and_language(
286        &mut conn,
287        models::email_templates::EmailTemplateType::ResetPasswordEmail,
288        language,
289    )
290    .await
291    .map_err(|_e| {
292        anyhow::anyhow!(
293            "Password reset email template not configured. Missing template 'reset-password-email' for language '{}'",
294            language
295        )
296    })?;
297
298    let user = match models::users::get_by_email(&mut conn, email).await {
299        Ok(user) => Some(user),
300        Err(_) => {
301            // If the user does not exist in the courses.mooc.fi database,
302            // check TMC for the user and create a new user in courses.mooc.fi if found.
303            if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
304                Some(
305                    models::users::insert_with_upstream_id_and_moocfi_id(
306                        &mut conn,
307                        &tmc_user.email,
308                        tmc_user.first_name.as_deref(),
309                        tmc_user.last_name.as_deref(),
310                        tmc_user.upstream_id,
311                        tmc_user.id,
312                    )
313                    .await?,
314                )
315            } else {
316                None
317            }
318        }
319    };
320
321    if let Some(user) = user {
322        let token = Uuid::new_v4();
323
324        let _password_token =
325            models::user_passwords::insert_password_reset_token(&mut conn, user.id, token).await?;
326
327        let _ =
328            models::email_deliveries::insert_email_delivery(&mut conn, user.id, reset_template.id)
329                .await?;
330    }
331
332    token.authorized_ok(web::Json(true))
333}
334
335#[derive(Debug, Serialize, Deserialize, ToSchema)]
336pub struct ResetPasswordTokenPayload {
337    pub token: String,
338}
339
340#[instrument(skip(pool))]
341#[utoipa::path(
342    post,
343    path = "/reset-password-token-status",
344    operation_id = "getResetPasswordTokenStatus",
345    tag = "users",
346    request_body = ResetPasswordTokenPayload,
347    responses(
348        (status = 200, description = "Reset password token validity", body = bool)
349    )
350)]
351pub async fn reset_password_token_status(
352    pool: web::Data<PgPool>,
353    payload: web::Json<ResetPasswordTokenPayload>,
354) -> ControllerResult<web::Json<bool>> {
355    let mut conn = pool.acquire().await?;
356    let token = skip_authorize();
357
358    let password_token = match Uuid::parse_str(&payload.token) {
359        Ok(u) => u,
360        Err(_) => return token.authorized_ok(web::Json(false)),
361    };
362
363    let res =
364        models::user_passwords::is_reset_password_token_valid(&mut conn, &password_token).await?;
365
366    token.authorized_ok(web::Json(res))
367}
368
369#[derive(Debug, Serialize, Deserialize, ToSchema)]
370
371pub struct ResetPasswordData {
372    pub token: String,
373    pub new_password: String,
374}
375
376#[instrument(skip(pool))]
377#[utoipa::path(
378    post,
379    path = "/reset-password",
380    operation_id = "resetUserPassword",
381    tag = "users",
382    request_body = ResetPasswordData,
383    responses(
384        (status = 200, description = "Password reset status", body = bool)
385    )
386)]
387pub async fn reset_user_password(
388    pool: web::Data<PgPool>,
389    payload: web::Json<ResetPasswordData>,
390    tmc_client: web::Data<TmcClient>,
391) -> ControllerResult<web::Json<bool>> {
392    let mut conn = pool.acquire().await?;
393    let token = skip_authorize();
394
395    let token_uuid = Uuid::parse_str(&payload.token)?;
396    let password_hash = models::user_passwords::hash_password(&SecretString::new(
397        payload.new_password.clone().into(),
398    ))
399    .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
400
401    let res = models::user_passwords::change_user_password_with_password_reset_token(
402        &mut conn,
403        token_uuid,
404        &password_hash,
405        &tmc_client,
406    )
407    .await?;
408
409    token.authorized_ok(web::Json(res))
410}
411
412#[derive(Debug, Serialize, Deserialize, ToSchema)]
413
414pub struct ChangePasswordData {
415    pub old_password: String,
416    pub new_password: String,
417}
418
419#[instrument(skip(pool))]
420#[utoipa::path(
421    post,
422    path = "/change-password",
423    operation_id = "changeUserPassword",
424    tag = "users",
425    request_body = ChangePasswordData,
426    responses(
427        (status = 200, description = "Password change status", body = bool)
428    )
429)]
430pub async fn change_user_password(
431    pool: web::Data<PgPool>,
432    payload: web::Json<ChangePasswordData>,
433    user: AuthUser,
434) -> ControllerResult<web::Json<bool>> {
435    let mut conn = pool.acquire().await?;
436    let token = skip_authorize();
437    let password_hash = models::user_passwords::hash_password(&SecretString::new(
438        payload.new_password.clone().into(),
439    ))
440    .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
441    let old_password = SecretString::new(payload.old_password.clone().into());
442
443    let res = models::user_passwords::change_user_password_with_old_password(
444        &mut conn,
445        user.id,
446        &old_password,
447        &password_hash,
448    )
449    .await?;
450
451    token.authorized_ok(web::Json(res))
452}
453
454pub fn _add_routes(cfg: &mut ServiceConfig) {
455    cfg.route(
456        "/user-research-form-question-answers",
457        web::get().to(get_all_research_form_answers_with_user_id),
458    )
459    .route("/my-courses", web::get().to(get_my_courses))
460    .route(
461        "/get-user-research-consent",
462        web::get().to(get_research_consent_by_user_id),
463    )
464    .route(
465        "/user-research-consents",
466        web::post().to(post_user_consents),
467    )
468    .route(
469        "/send-reset-password-email",
470        web::post().to(send_reset_password_email),
471    )
472    .route("/{user_id}", web::get().to(get_user))
473    .route(
474        "/{user_id}/course-enrollments",
475        web::get().to(get_course_enrollments_for_user),
476    )
477    .route(
478        "/{user_id}/user-reset-exercise-logs",
479        web::get().to(get_user_reset_exercise_logs),
480    )
481    .route(
482        "/reset-password-token-status",
483        web::post().to(reset_password_token_status),
484    )
485    .route("/reset-password", web::post().to(reset_user_password))
486    .route("/change-password", web::post().to(change_user_password));
487}