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