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#[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#[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 let mut token = None;
103 let mut authorized_course_ids = Vec::new();
104
105 if payload.course_ids.is_empty() {
106 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 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#[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#[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#[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#[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#[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#[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#[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}