Skip to main content

headless_lms_server/controllers/main_frontend/
user_details.rs

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