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