1use crate::prelude::*;
2use anyhow::anyhow;
3use headless_lms_utils::tmc::TmcClient;
4use models::{
5 course_instance_enrollments::CourseEnrollmentsInfo, courses::Course,
6 exercise_reset_logs::ExerciseResetLog, research_forms::ResearchFormQuestionAnswer,
7 user_research_consents::UserResearchConsent, users::User,
8};
9use secrecy::SecretString;
10use utoipa::{OpenApi, ToSchema};
11
12#[derive(OpenApi)]
13#[openapi(paths(
14 get_user,
15 get_course_enrollments_for_user,
16 post_user_consents,
17 get_research_consent_by_user_id,
18 get_all_research_form_answers_with_user_id,
19 get_my_courses,
20 get_user_reset_exercise_logs,
21 send_reset_password_email,
22 reset_password_token_status,
23 reset_user_password,
24 change_user_password
25))]
26pub(crate) struct MainFrontendUsersApiDoc;
27
28#[instrument(skip(pool))]
32#[utoipa::path(
33 get,
34 path = "/{user_id}",
35 operation_id = "getUser",
36 tag = "users",
37 params(
38 ("user_id" = Uuid, Path, description = "User id")
39 ),
40 responses(
41 (status = 200, description = "User", body = serde_json::Value)
42 )
43)]
44pub async fn get_user(
45 user_id: web::Path<Uuid>,
46 pool: web::Data<PgPool>,
47) -> ControllerResult<web::Json<User>> {
48 let mut conn = pool.acquire().await?;
49 let user = models::users::get_by_id(&mut conn, *user_id).await?;
50
51 let token = authorize(&mut conn, Act::Teach, Some(*user_id), Res::AnyCourse).await?;
52 token.authorized_ok(web::Json(user))
53}
54
55#[instrument(skip(pool))]
59#[utoipa::path(
60 get,
61 path = "/{user_id}/course-enrollments",
62 operation_id = "getUserCourseEnrollments",
63 tag = "users",
64 params(
65 ("user_id" = Uuid, Path, description = "User id")
66 ),
67 responses(
68 (status = 200, description = "User course enrollments", body = CourseEnrollmentsInfo)
69 )
70)]
71pub async fn get_course_enrollments_for_user(
72 user_id: web::Path<Uuid>,
73 pool: web::Data<PgPool>,
74 auth_user: AuthUser,
75) -> ControllerResult<web::Json<CourseEnrollmentsInfo>> {
76 let mut conn = pool.acquire().await?;
77 let token = authorize(
78 &mut conn,
79 Act::ViewUserProgressOrDetails,
80 Some(auth_user.id),
81 Res::GlobalPermissions,
82 )
83 .await?;
84 let res = models::course_instance_enrollments::get_course_enrollments_info_for_user(
85 &mut conn, *user_id,
86 )
87 .await?;
88 token.authorized_ok(web::Json(res))
89}
90
91#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
92
93pub struct ConsentData {
94 pub consent: bool,
95}
96
97#[instrument(skip(pool))]
101#[utoipa::path(
102 post,
103 path = "/user-research-consents",
104 operation_id = "createUserResearchConsent",
105 tag = "users",
106 request_body = ConsentData,
107 responses(
108 (status = 200, description = "User research consent", body = UserResearchConsent)
109 )
110)]
111pub async fn post_user_consents(
112 payload: web::Json<ConsentData>,
113 user: AuthUser,
114 pool: web::Data<PgPool>,
115) -> ControllerResult<web::Json<UserResearchConsent>> {
116 let mut conn = pool.acquire().await?;
117 let token = skip_authorize();
118
119 let res = models::user_research_consents::upsert(
120 &mut conn,
121 PKeyPolicy::Generate,
122 user.id,
123 payload.consent,
124 )
125 .await?;
126 token.authorized_ok(web::Json(res))
127}
128
129#[instrument(skip(pool))]
133#[utoipa::path(
134 get,
135 path = "/get-user-research-consent",
136 operation_id = "getUserResearchConsent",
137 tag = "users",
138 responses(
139 (status = 200, description = "User research consent", body = UserResearchConsent)
140 )
141)]
142pub async fn get_research_consent_by_user_id(
143 user: AuthUser,
144 pool: web::Data<PgPool>,
145) -> ControllerResult<web::Json<UserResearchConsent>> {
146 let mut conn = pool.acquire().await?;
147 let token = skip_authorize();
148
149 let res =
150 models::user_research_consents::get_research_consent_by_user_id(&mut conn, user.id).await?;
151
152 token.authorized_ok(web::Json(res))
153}
154
155#[instrument(skip(pool))]
159#[utoipa::path(
160 get,
161 path = "/user-research-form-question-answers",
162 operation_id = "getUserResearchFormQuestionAnswers",
163 tag = "users",
164 responses(
165 (status = 200, description = "Research form answers for user", body = [ResearchFormQuestionAnswer])
166 )
167)]
168async fn get_all_research_form_answers_with_user_id(
169 user: AuthUser,
170 pool: web::Data<PgPool>,
171) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
172 let mut conn = pool.acquire().await?;
173 let token = skip_authorize();
174
175 let res =
176 models::research_forms::get_all_research_form_answers_with_user_id(&mut conn, user.id)
177 .await?;
178
179 token.authorized_ok(web::Json(res))
180}
181
182#[instrument(skip(pool))]
186#[utoipa::path(
187 get,
188 path = "/my-courses",
189 operation_id = "getMyCourses",
190 tag = "users",
191 responses(
192 (status = 200, description = "Courses for authenticated user", body = [Course])
193 )
194)]
195async fn get_my_courses(
196 user: AuthUser,
197 pool: web::Data<PgPool>,
198) -> ControllerResult<web::Json<Vec<Course>>> {
199 let mut conn = pool.acquire().await?;
200 let token = skip_authorize();
201
202 let courses_enrolled_to =
203 models::courses::all_courses_user_enrolled_to(&mut conn, user.id).await?;
204
205 let courses_with_roles =
206 models::courses::all_courses_with_roles_for_user(&mut conn, user.id).await?;
207
208 let combined = courses_enrolled_to
209 .clone()
210 .into_iter()
211 .chain(
212 courses_with_roles
213 .into_iter()
214 .filter(|c| !courses_enrolled_to.iter().any(|c2| c.id == c2.id)),
215 )
216 .collect();
217
218 token.authorized_ok(web::Json(combined))
219}
220
221#[instrument(skip(pool))]
225#[utoipa::path(
226 get,
227 path = "/{user_id}/user-reset-exercise-logs",
228 operation_id = "getUserResetExerciseLogs",
229 tag = "users",
230 params(
231 ("user_id" = Uuid, Path, description = "User id")
232 ),
233 responses(
234 (status = 200, description = "User reset exercise logs", body = [ExerciseResetLog])
235 )
236)]
237pub async fn get_user_reset_exercise_logs(
238 user_id: web::Path<Uuid>,
239 pool: web::Data<PgPool>,
240 auth_user: AuthUser,
241) -> ControllerResult<web::Json<Vec<ExerciseResetLog>>> {
242 let mut conn = pool.acquire().await?;
243 let token = authorize(
244 &mut conn,
245 Act::ViewUserProgressOrDetails,
246 Some(auth_user.id),
247 Res::GlobalPermissions,
248 )
249 .await?;
250 let res =
251 models::exercise_reset_logs::get_exercise_reset_logs_for_user(&mut conn, *user_id).await?;
252
253 token.authorized_ok(web::Json(res))
254}
255
256#[derive(Debug, Serialize, Deserialize, ToSchema)]
257
258pub struct EmailData {
259 pub email: String,
260 pub language: String,
261}
262
263#[instrument(skip(pool))]
264#[utoipa::path(
265 post,
266 path = "/send-reset-password-email",
267 operation_id = "sendResetPasswordEmail",
268 tag = "users",
269 request_body = EmailData,
270 responses(
271 (status = 200, description = "Reset password email accepted", body = bool)
272 )
273)]
274pub async fn send_reset_password_email(
275 pool: web::Data<PgPool>,
276 payload: web::Json<EmailData>,
277 tmc_client: web::Data<TmcClient>,
278) -> ControllerResult<web::Json<bool>> {
279 let mut conn = pool.acquire().await?;
280 let token = skip_authorize();
281
282 let email = &payload.email.trim().to_lowercase();
283 let language = &payload.language;
284
285 let reset_template = models::email_templates::get_generic_email_template_by_type_and_language(
286 &mut conn,
287 models::email_templates::EmailTemplateType::ResetPasswordEmail,
288 language,
289 )
290 .await
291 .map_err(|_e| {
292 anyhow::anyhow!(
293 "Password reset email template not configured. Missing template 'reset-password-email' for language '{}'",
294 language
295 )
296 })?;
297
298 let user = match models::users::get_by_email(&mut conn, email).await {
299 Ok(user) => Some(user),
300 Err(_) => {
301 if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
304 Some(
305 models::users::insert_with_upstream_id_and_moocfi_id(
306 &mut conn,
307 &tmc_user.email,
308 tmc_user.first_name.as_deref(),
309 tmc_user.last_name.as_deref(),
310 tmc_user.upstream_id,
311 tmc_user.id,
312 )
313 .await?,
314 )
315 } else {
316 None
317 }
318 }
319 };
320
321 if let Some(user) = user {
322 let token = Uuid::new_v4();
323
324 let _password_token =
325 models::user_passwords::insert_password_reset_token(&mut conn, user.id, token).await?;
326
327 let _ =
328 models::email_deliveries::insert_email_delivery(&mut conn, user.id, reset_template.id)
329 .await?;
330 }
331
332 token.authorized_ok(web::Json(true))
333}
334
335#[derive(Debug, Serialize, Deserialize, ToSchema)]
336pub struct ResetPasswordTokenPayload {
337 pub token: String,
338}
339
340#[instrument(skip(pool))]
341#[utoipa::path(
342 post,
343 path = "/reset-password-token-status",
344 operation_id = "getResetPasswordTokenStatus",
345 tag = "users",
346 request_body = ResetPasswordTokenPayload,
347 responses(
348 (status = 200, description = "Reset password token validity", body = bool)
349 )
350)]
351pub async fn reset_password_token_status(
352 pool: web::Data<PgPool>,
353 payload: web::Json<ResetPasswordTokenPayload>,
354) -> ControllerResult<web::Json<bool>> {
355 let mut conn = pool.acquire().await?;
356 let token = skip_authorize();
357
358 let password_token = match Uuid::parse_str(&payload.token) {
359 Ok(u) => u,
360 Err(_) => return token.authorized_ok(web::Json(false)),
361 };
362
363 let res =
364 models::user_passwords::is_reset_password_token_valid(&mut conn, &password_token).await?;
365
366 token.authorized_ok(web::Json(res))
367}
368
369#[derive(Debug, Serialize, Deserialize, ToSchema)]
370
371pub struct ResetPasswordData {
372 pub token: String,
373 pub new_password: String,
374}
375
376#[instrument(skip(pool))]
377#[utoipa::path(
378 post,
379 path = "/reset-password",
380 operation_id = "resetUserPassword",
381 tag = "users",
382 request_body = ResetPasswordData,
383 responses(
384 (status = 200, description = "Password reset status", body = bool)
385 )
386)]
387pub async fn reset_user_password(
388 pool: web::Data<PgPool>,
389 payload: web::Json<ResetPasswordData>,
390 tmc_client: web::Data<TmcClient>,
391) -> ControllerResult<web::Json<bool>> {
392 let mut conn = pool.acquire().await?;
393 let token = skip_authorize();
394
395 let token_uuid = Uuid::parse_str(&payload.token)?;
396 let password_hash = models::user_passwords::hash_password(&SecretString::new(
397 payload.new_password.clone().into(),
398 ))
399 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
400
401 let res = models::user_passwords::change_user_password_with_password_reset_token(
402 &mut conn,
403 token_uuid,
404 &password_hash,
405 &tmc_client,
406 )
407 .await?;
408
409 token.authorized_ok(web::Json(res))
410}
411
412#[derive(Debug, Serialize, Deserialize, ToSchema)]
413
414pub struct ChangePasswordData {
415 pub old_password: String,
416 pub new_password: String,
417}
418
419#[instrument(skip(pool))]
420#[utoipa::path(
421 post,
422 path = "/change-password",
423 operation_id = "changeUserPassword",
424 tag = "users",
425 request_body = ChangePasswordData,
426 responses(
427 (status = 200, description = "Password change status", body = bool)
428 )
429)]
430pub async fn change_user_password(
431 pool: web::Data<PgPool>,
432 payload: web::Json<ChangePasswordData>,
433 user: AuthUser,
434) -> ControllerResult<web::Json<bool>> {
435 let mut conn = pool.acquire().await?;
436 let token = skip_authorize();
437 let password_hash = models::user_passwords::hash_password(&SecretString::new(
438 payload.new_password.clone().into(),
439 ))
440 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
441 let old_password = SecretString::new(payload.old_password.clone().into());
442
443 let res = models::user_passwords::change_user_password_with_old_password(
444 &mut conn,
445 user.id,
446 &old_password,
447 &password_hash,
448 )
449 .await?;
450
451 token.authorized_ok(web::Json(res))
452}
453
454pub fn _add_routes(cfg: &mut ServiceConfig) {
455 cfg.route(
456 "/user-research-form-question-answers",
457 web::get().to(get_all_research_form_answers_with_user_id),
458 )
459 .route("/my-courses", web::get().to(get_my_courses))
460 .route(
461 "/get-user-research-consent",
462 web::get().to(get_research_consent_by_user_id),
463 )
464 .route(
465 "/user-research-consents",
466 web::post().to(post_user_consents),
467 )
468 .route(
469 "/send-reset-password-email",
470 web::post().to(send_reset_password_email),
471 )
472 .route("/{user_id}", web::get().to(get_user))
473 .route(
474 "/{user_id}/course-enrollments",
475 web::get().to(get_course_enrollments_for_user),
476 )
477 .route(
478 "/{user_id}/user-reset-exercise-logs",
479 web::get().to(get_user_reset_exercise_logs),
480 )
481 .route(
482 "/reset-password-token-status",
483 web::post().to(reset_password_token_status),
484 )
485 .route("/reset-password", web::post().to(reset_user_password))
486 .route("/change-password", web::post().to(change_user_password));
487}