Skip to main content

headless_lms_server/controllers/main_frontend/
user_details.rs

1use models::{pages::SearchRequest, user_details::UserDetail};
2use utoipa::{OpenApi, ToSchema};
3
4use crate::{controllers, prelude::*};
5use headless_lms_utils::{ip_to_country::IpToCountryMapper, tmc::TmcClient};
6use std::net::IpAddr;
7
8#[derive(OpenApi)]
9#[openapi(paths(
10    get_user_details,
11    get_user_details_by_courses,
12    search_users_by_email,
13    search_users_by_other_details,
14    search_users_fuzzy_match,
15    get_users_by_course_id,
16    get_bulk_user_details,
17    get_user_details_for_user,
18    get_user_country_by_ip,
19    update_user_info
20))]
21pub(crate) struct MainFrontendUserDetailsApiDoc;
22
23#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
24
25pub struct BulkUserDetailsRequest {
26    pub user_ids: Vec<Uuid>,
27    pub course_id: Uuid,
28}
29
30#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
31
32pub struct UserDetailsRequest {
33    pub user_id: Uuid,
34    pub course_ids: Vec<Uuid>,
35}
36
37/**
38GET `/api/v0/main-frontend/user-details/{course_id}/user/{user_id}` - Find user details by user id with course permission check
39Only returns user details if the user is enrolled in the specified course
40*/
41#[instrument(skip(pool))]
42#[utoipa::path(
43    get,
44    path = "/{course_id}/user/{user_id}",
45    operation_id = "getUserDetailsByCourseAndUserId",
46    tag = "user-details",
47    params(
48        ("course_id" = Uuid, Path, description = "Course id"),
49        ("user_id" = Uuid, Path, description = "User id")
50    ),
51    responses(
52        (status = 200, description = "User details", body = UserDetail)
53    )
54)]
55pub async fn get_user_details(
56    user: AuthUser,
57    pool: web::Data<PgPool>,
58    path: web::Path<(Uuid, Uuid)>,
59) -> ControllerResult<web::Json<UserDetail>> {
60    let (course_id, user_id) = path.into_inner();
61    let mut conn = pool.acquire().await?;
62
63    let token = authorize(
64        &mut conn,
65        Act::ViewUserProgressOrDetails,
66        Some(user.id),
67        Res::Course(course_id),
68    )
69    .await?;
70    let res =
71        models::user_details::get_user_details_by_user_id_for_course(&mut conn, user_id, course_id)
72            .await?;
73    token.authorized_ok(web::Json(res))
74}
75
76/**
77POST `/api/v0/main-frontend/user-details/user-by-courses` - Find user details by user id with multi-course permission check
78Returns user details if the user has permission to view user details through any of the specified courses
79*/
80#[instrument(skip(pool))]
81#[utoipa::path(
82    post,
83    path = "/user-by-courses",
84    operation_id = "getUserDetailsByCourses",
85    tag = "user-details",
86    request_body = UserDetailsRequest,
87    responses(
88        (status = 200, description = "User details", body = UserDetail),
89        (status = 403, description = "Forbidden", body = serde_json::Value),
90        (status = 404, description = "User details not found", body = serde_json::Value),
91        (status = 500, description = "Internal server error", body = serde_json::Value)
92    )
93)]
94pub async fn get_user_details_by_courses(
95    user: AuthUser,
96    pool: web::Data<PgPool>,
97    payload: web::Json<UserDetailsRequest>,
98) -> ControllerResult<web::Json<UserDetail>> {
99    let mut conn = pool.acquire().await?;
100
101    // Check if the user has permission to view user details through any of the provided courses
102    let mut token = None;
103    let mut authorized_course_ids = Vec::new();
104
105    if payload.course_ids.is_empty() {
106        // One can view the details though global permissions even though they have not started any course yet
107        token = Some(
108            authorize(
109                &mut conn,
110                Act::ViewUserProgressOrDetails,
111                Some(user.id),
112                Res::GlobalPermissions,
113            )
114            .await?,
115        );
116    } else {
117        for course_id in &payload.course_ids {
118            if let Ok(auth_token) = authorize(
119                &mut conn,
120                Act::ViewUserProgressOrDetails,
121                Some(user.id),
122                Res::Course(*course_id),
123            )
124            .await
125            {
126                if token.is_none() {
127                    token = Some(auth_token);
128                }
129                authorized_course_ids.push(*course_id);
130            }
131        }
132    }
133
134    let token = token.ok_or_else(|| {
135        ControllerError::new(
136            ControllerErrorType::Forbidden,
137            "No permission to view user details through any of the provided courses".to_string(),
138            None,
139        )
140    })?;
141
142    if !payload.course_ids.is_empty() {
143        let enrollments = models::course_instance_enrollments::get_by_user_id_and_course_ids(
144            &mut conn,
145            payload.user_id,
146            &authorized_course_ids,
147        )
148        .await?;
149        let roles = models::roles::get_by_user_id_and_course_ids(
150            &mut conn,
151            payload.user_id,
152            &authorized_course_ids,
153        )
154        .await?;
155        if enrollments.is_empty() && roles.is_empty() {
156            return Err(controller_err!(
157                Forbidden,
158                "Target user is not linked to an authorized course".to_string()
159            ));
160        }
161    }
162
163    // If we have permission, get the user details without course restriction
164    let res = models::user_details::get_user_details_by_user_id(&mut conn, payload.user_id).await?;
165    token.authorized_ok(web::Json(res))
166}
167
168/**
169GET `/api/v0/main-frontend/user-details/search-by-email` - Allows to search user by their email
170*/
171#[instrument(skip(pool))]
172#[utoipa::path(
173    post,
174    path = "/search-by-email",
175    operation_id = "searchUserDetailsByEmail",
176    tag = "user-details",
177    request_body = SearchRequest,
178    responses(
179        (status = 200, description = "User details search results", body = [UserDetail])
180    )
181)]
182pub async fn search_users_by_email(
183    user: AuthUser,
184    pool: web::Data<PgPool>,
185    payload: web::Json<SearchRequest>,
186) -> ControllerResult<web::Json<Vec<UserDetail>>> {
187    let mut conn = pool.acquire().await?;
188
189    let token = authorize(
190        &mut conn,
191        Act::ViewUserProgressOrDetails,
192        Some(user.id),
193        Res::GlobalPermissions,
194    )
195    .await?;
196    let res =
197        models::user_details::search_for_user_details_by_email(&mut conn, &payload.query).await?;
198    token.authorized_ok(web::Json(res))
199}
200
201/**
202GET `/api/v0/main-frontend/user-details/search-by-other-details` - Allows to search user by their names etc.
203*/
204#[instrument(skip(pool))]
205#[utoipa::path(
206    post,
207    path = "/search-by-other-details",
208    operation_id = "searchUserDetailsByOtherDetails",
209    tag = "user-details",
210    request_body = SearchRequest,
211    responses(
212        (status = 200, description = "User details search results", body = [UserDetail])
213    )
214)]
215pub async fn search_users_by_other_details(
216    user: AuthUser,
217    pool: web::Data<PgPool>,
218    payload: web::Json<SearchRequest>,
219) -> ControllerResult<web::Json<Vec<UserDetail>>> {
220    let mut conn = pool.acquire().await?;
221
222    let token = authorize(
223        &mut conn,
224        Act::ViewUserProgressOrDetails,
225        Some(user.id),
226        Res::GlobalPermissions,
227    )
228    .await?;
229    let res =
230        models::user_details::search_for_user_details_by_other_details(&mut conn, &payload.query)
231            .await?;
232    token.authorized_ok(web::Json(res))
233}
234
235/**
236GET `/api/v0/main-frontend/user-details/search-fuzzy-match` - Allows to find the right user details in cases where there is a small typing error in the search query
237*/
238#[instrument(skip(pool))]
239#[utoipa::path(
240    post,
241    path = "/search-fuzzy-match",
242    operation_id = "searchUserDetailsFuzzyMatch",
243    tag = "user-details",
244    request_body = SearchRequest,
245    responses(
246        (status = 200, description = "User details fuzzy search results", body = [UserDetail])
247    )
248)]
249pub async fn search_users_fuzzy_match(
250    user: AuthUser,
251    pool: web::Data<PgPool>,
252    payload: web::Json<SearchRequest>,
253) -> ControllerResult<web::Json<Vec<UserDetail>>> {
254    let mut conn = pool.acquire().await?;
255
256    let token = authorize(
257        &mut conn,
258        Act::ViewUserProgressOrDetails,
259        Some(user.id),
260        Res::GlobalPermissions,
261    )
262    .await?;
263    let res = models::user_details::search_for_user_details_fuzzy_match(&mut conn, &payload.query)
264        .await?;
265    token.authorized_ok(web::Json(res))
266}
267
268/**
269GET `/api/v0/main-frontend/user-details/get-users-by-course-id` - Get user details of users that are in the course
270*/
271#[utoipa::path(
272    get,
273    path = "/{course_id}/get-users-by-course-id",
274    operation_id = "getUsersByCourseIdForUserDetails",
275    tag = "user-details",
276    params(
277        ("course_id" = Uuid, Path, description = "Course id")
278    ),
279    responses(
280        (status = 200, description = "Users by course id", body = [UserDetail])
281    )
282)]
283pub async fn get_users_by_course_id(
284    course_id: web::Path<Uuid>,
285    user: AuthUser,
286    pool: web::Data<PgPool>,
287) -> ControllerResult<web::Json<Vec<UserDetail>>> {
288    let mut conn = pool.acquire().await?;
289
290    let token = authorize(
291        &mut conn,
292        Act::ViewUserProgressOrDetails,
293        Some(user.id),
294        Res::Course(*course_id),
295    )
296    .await?;
297    let res = models::user_details::get_users_by_course_id(&mut conn, *course_id).await?;
298    token.authorized_ok(web::Json(res))
299}
300
301/**
302POST `/api/v0/main-frontend/user-details/bulk-user-details` - Get user details for a list of user IDs with course permission check
303Only returns user details for users who are actually enrolled in the specified course
304*/
305#[instrument(skip(pool))]
306#[utoipa::path(
307    post,
308    path = "/bulk-user-details",
309    operation_id = "getBulkUserDetails",
310    tag = "user-details",
311    request_body = BulkUserDetailsRequest,
312    responses(
313        (status = 200, description = "Bulk user details", body = [UserDetail])
314    )
315)]
316pub async fn get_bulk_user_details(
317    user: AuthUser,
318    pool: web::Data<PgPool>,
319    payload: web::Json<BulkUserDetailsRequest>,
320) -> ControllerResult<web::Json<Vec<UserDetail>>> {
321    let mut conn = pool.acquire().await?;
322
323    let token = authorize(
324        &mut conn,
325        Act::ViewUserProgressOrDetails,
326        Some(user.id),
327        Res::Course(payload.course_id),
328    )
329    .await?;
330    let res = models::user_details::get_user_details_by_user_ids_for_course(
331        &mut conn,
332        &payload.user_ids,
333        payload.course_id,
334    )
335    .await?;
336    token.authorized_ok(web::Json(res))
337}
338
339/**
340GET `/api/v0/main-frontend/user-details/user-details-for-user` - Get authenticated user's own details
341*/
342#[instrument(skip(pool))]
343#[utoipa::path(
344    get,
345    path = "/user-details-for-user",
346    operation_id = "getUserDetailsForAuthenticatedUser",
347    tag = "user-details",
348    responses(
349        (status = 200, description = "Authenticated user details", body = UserDetail)
350    )
351)]
352pub async fn get_user_details_for_user(
353    user: AuthUser,
354    pool: web::Data<PgPool>,
355) -> ControllerResult<web::Json<UserDetail>> {
356    let mut conn = pool.acquire().await?;
357
358    let token = skip_authorize();
359    let user_id = user.id;
360    let res = models::user_details::get_user_details_by_user_id(&mut conn, user_id).await?;
361    token.authorized_ok(web::Json(res))
362}
363
364#[utoipa::path(
365    get,
366    path = "/users-ip-country",
367    operation_id = "getUsersIpCountry",
368    tag = "user-details",
369    responses(
370        (status = 200, description = "Country inferred from request IP", body = String)
371    )
372)]
373pub async fn get_user_country_by_ip(
374    req: HttpRequest,
375    ip_to_country_mapper: web::Data<IpToCountryMapper>,
376) -> ControllerResult<String> {
377    let connection_info = req.connection_info();
378
379    let ip: Option<IpAddr> = connection_info
380        .realip_remote_addr()
381        .and_then(|ip| ip.parse::<IpAddr>().ok());
382
383    let country = ip
384        .and_then(|ip| ip_to_country_mapper.map_ip_to_country(&ip))
385        .map(|c| c.to_string())
386        .unwrap_or_default();
387
388    let token = skip_authorize();
389    token.authorized_ok(country)
390}
391
392#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
393
394pub struct UserInfoPayload {
395    pub email: String,
396    pub first_name: String,
397    pub last_name: String,
398    pub country: String,
399    pub email_communication_consent: bool,
400}
401
402/**
403POST `/api/v0/main-frontend/user-details/update-user-info` - Updates the users information such as email, name, country and email communication consent
404*/
405#[instrument(skip(pool, app_conf, tmc_client))]
406#[utoipa::path(
407    post,
408    path = "/update-user-info",
409    operation_id = "updateUserInfo",
410    tag = "user-details",
411    request_body = UserInfoPayload,
412    responses(
413        (status = 200, description = "Updated user details", body = UserDetail)
414    )
415)]
416pub async fn update_user_info(
417    user: AuthUser,
418    pool: web::Data<PgPool>,
419    payload: web::Json<UserInfoPayload>,
420    tmc_client: web::Data<TmcClient>,
421    app_conf: web::Data<ApplicationConfiguration>,
422) -> ControllerResult<web::Json<UserDetail>> {
423    let mut tx = pool.begin().await?;
424
425    let existing_user = models::user_details::get_user_details_by_user_id(&mut tx, user.id)
426        .await
427        .context("Failed to fetch existing user data")?;
428
429    let user = models::users::get_by_id(&mut tx, user.id)
430        .await
431        .context("Failed to fetch user")?;
432
433    let updated_user = models::user_details::update_user_info(
434        &mut tx,
435        user.id,
436        &payload.email,
437        &payload.first_name,
438        &payload.last_name,
439        &payload.country,
440        payload.email_communication_consent,
441    )
442    .await
443    .context("Failed to update database")?;
444
445    let email_changed = existing_user.email != payload.email;
446    let first_name_changed = existing_user.first_name != Some(payload.first_name.clone());
447    let last_name_changed = existing_user.last_name != Some(payload.last_name.clone());
448
449    if !app_conf.test_mode && (email_changed || first_name_changed || last_name_changed) {
450        let email_opt = if email_changed {
451            Some(payload.email.clone())
452        } else {
453            None
454        };
455
456        let upstream_id = user
457            .upstream_id
458            .ok_or_else(|| {
459                ControllerError::new(
460                    ControllerErrorType::InternalServerError,
461                    "Missing upstream_id".to_string(),
462                    None,
463                )
464            })?
465            .to_string();
466
467        controllers::auth::update_user_information_to_tmc(
468            payload.first_name.clone(),
469            payload.last_name.clone(),
470            email_opt,
471            upstream_id,
472            tmc_client.clone(),
473            app_conf,
474        )
475        .await
476        .map_err(|e| {
477            ControllerError::new(
478                ControllerErrorType::InternalServerError,
479                "Failed to update user info to tmc",
480                e,
481            )
482        })?;
483    } else {
484        info!("User info unchanged, skipping update to TMC.");
485    }
486
487    tx.commit().await?;
488    let token = skip_authorize();
489    token.authorized_ok(web::Json(updated_user))
490}
491
492pub fn _add_routes(cfg: &mut ServiceConfig) {
493    cfg.route("/search-by-email", web::post().to(search_users_by_email))
494        .route(
495            "/search-by-other-details",
496            web::post().to(search_users_by_other_details),
497        )
498        .route(
499            "/search-fuzzy-match",
500            web::post().to(search_users_fuzzy_match),
501        )
502        .route(
503            "/{course_id}/user/{user_id}",
504            web::get().to(get_user_details),
505        )
506        .route(
507            "/user-by-courses",
508            web::post().to(get_user_details_by_courses),
509        )
510        .route("/users-ip-country", web::get().to(get_user_country_by_ip))
511        .route(
512            "/user-details-for-user",
513            web::get().to(get_user_details_for_user),
514        )
515        .route("/update-user-info", web::post().to(update_user_info))
516        .route(
517            "/{course_id}/get-users-by-course-id",
518            web::get().to(get_users_by_course_id),
519        )
520        .route("/bulk-user-details", web::post().to(get_bulk_user_details));
521}