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