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 )
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 let mut token = None;
100
101 if payload.course_ids.is_empty() {
102 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 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#[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#[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#[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#[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#[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#[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#[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}