1use std::collections::HashMap;
2
3use futures::Stream;
4use utoipa::ToSchema;
5
6use crate::{prelude::*, users::User};
7
8const MIN_FUZZY_SEARCH_TERM_LENGTH: usize = 3;
9
10#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
12#[sqlx(type_name = "email_verification_method", rename_all = "snake_case")]
13#[serde(rename_all = "snake_case")]
14pub enum EmailVerificationMethod {
15 EmailedCode,
16 PasswordResetBackfill,
17 TmcConfirmed,
18 AdminAsserted,
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
22
23pub struct UserDetail {
24 pub user_id: Uuid,
25 pub created_at: DateTime<Utc>,
26 pub updated_at: DateTime<Utc>,
27 pub email: String,
28 pub first_name: Option<String>,
29 pub last_name: Option<String>,
30 pub search_helper: Option<String>,
31 pub country: Option<String>,
32 pub email_communication_consent: Option<bool>,
33 pub email_verified_at: Option<DateTime<Utc>>,
36 pub email_verified_method: Option<EmailVerificationMethod>,
37}
38
39pub async fn get_user_details_by_user_id(
40 conn: &mut PgConnection,
41 user_id: Uuid,
42) -> ModelResult<UserDetail> {
43 let res = sqlx::query_as!(
44 UserDetail,
45 "
46SELECT user_id,
47 created_at,
48 updated_at,
49 email,
50 first_name,
51 last_name,
52 search_helper,
53 country,
54 email_communication_consent,
55 email_verified_at,
56 email_verified_method
57FROM user_details
58WHERE user_id = $1 ",
59 user_id
60 )
61 .fetch_one(conn)
62 .await?;
63 Ok(res)
64}
65
66pub async fn get_users_details_by_user_id_map(
67 conn: &mut PgConnection,
68 users: &[User],
69) -> ModelResult<HashMap<Uuid, UserDetail>> {
70 let ids = users.iter().map(|u| u.id).collect::<Vec<_>>();
71 let details = sqlx::query_as!(
72 UserDetail,
73 "
74SELECT user_id,
75 created_at,
76 updated_at,
77 email,
78 first_name,
79 last_name,
80 search_helper,
81 country,
82 email_communication_consent,
83 email_verified_at,
84 email_verified_method
85FROM user_details
86WHERE user_id IN (
87 SELECT UNNEST($1::uuid [])
88 )
89",
90 &ids
91 )
92 .fetch_all(conn)
93 .await?;
94 let mut res = HashMap::new();
95 details.into_iter().for_each(|d| {
96 res.insert(d.user_id, d);
97 });
98 Ok(res)
99}
100
101pub fn stream_users_details_having_user_exercise_states_on_course(
103 conn: &mut PgConnection,
104 course_id: Uuid,
105) -> impl Stream<Item = sqlx::Result<UserDetail>> + '_ {
106 sqlx::query_as!(
107 UserDetail,
108 "
109SELECT distinct (ud.user_id),
110 ud.created_at,
111 ud.updated_at,
112 ud.first_name,
113 ud.last_name,
114 ud.email,
115 ud.search_helper,
116 ud.country,
117 ud.email_communication_consent,
118 ud.email_verified_at,
119 ud.email_verified_method
120FROM user_details ud
121JOIN users u
122 ON u.id = ud.user_id
123JOIN user_exercise_states ues
124 ON ud.user_id = ues.user_id
125WHERE ues.course_id = $1
126 AND u.deleted_at IS NULL
127 AND ues.deleted_at IS NULL
128 ",
129 course_id
130 )
131 .fetch(conn)
132}
133
134pub async fn search_for_user_details_by_email(
135 conn: &mut PgConnection,
136 email: &str,
137) -> ModelResult<Vec<UserDetail>> {
138 let email = normalize_email_search_term(email);
139 if !is_fuzzy_search_term_long_enough(email) {
140 return Ok(Vec::new());
141 }
142
143 let res = sqlx::query_as!(
145 UserDetail,
146 "
147SELECT user_id,
148 created_at,
149 updated_at,
150 email,
151 first_name,
152 last_name,
153 search_helper,
154 country,
155 email_communication_consent,
156 email_verified_at,
157 email_verified_method
158FROM (
159 SELECT user_id,
160 created_at,
161 updated_at,
162 email,
163 first_name,
164 last_name,
165 search_helper,
166 country,
167 email_communication_consent,
168 email_verified_at,
169 email_verified_method,
170 lower($1) <<-> email_search_helper AS dist
171 FROM user_details
172 ORDER BY dist
173 LIMIT 100
174 ) search
175WHERE dist < 0.7;
176",
177 email,
178 )
179 .fetch_all(conn)
180 .await?;
181 Ok(res)
182}
183
184pub async fn search_for_user_details_by_other_details(
186 conn: &mut PgConnection,
187 search: &str,
188) -> ModelResult<Vec<UserDetail>> {
189 let Some(user_id) = parse_exact_user_id_search_term(search) else {
190 return Ok(Vec::new());
191 };
192
193 let res = sqlx::query_as!(
194 UserDetail,
195 "
196SELECT user_id,
197 created_at,
198 updated_at,
199 email,
200 first_name,
201 last_name,
202 search_helper,
203 country,
204 email_communication_consent,
205 email_verified_at,
206 email_verified_method
207FROM user_details
208WHERE user_id = $1;
209",
210 user_id,
211 )
212 .fetch_all(conn)
213 .await?;
214 Ok(res)
215}
216
217pub async fn search_for_user_details_fuzzy_match(
218 conn: &mut PgConnection,
219 search: &str,
220) -> ModelResult<Vec<UserDetail>> {
221 let search = normalize_name_search_term(search);
223 if !is_fuzzy_search_term_long_enough(search) {
224 return Ok(Vec::new());
225 }
226
227 let res = sqlx::query_as!(
232 UserDetail,
233 "
234SELECT user_id,
235 created_at,
236 updated_at,
237 email,
238 first_name,
239 last_name,
240 search_helper,
241 country,
242 email_communication_consent,
243 email_verified_at,
244 email_verified_method
245FROM (
246 SELECT user_id,
247 created_at,
248 updated_at,
249 email,
250 first_name,
251 last_name,
252 search_helper,
253 country,
254 email_communication_consent,
255 email_verified_at,
256 email_verified_method,
257 lower($1) <<-> name_search_helper AS dist
258 FROM user_details
259 ORDER BY dist
260 LIMIT 100
261 ) search
262WHERE dist < 0.7;
263",
264 search,
265 )
266 .fetch_all(conn)
267 .await?;
268 Ok(res)
269}
270
271fn normalize_name_search_term(search: &str) -> &str {
272 search.split('@').next().unwrap_or(search).trim()
273}
274
275fn normalize_email_search_term(search: &str) -> &str {
276 search.trim()
277}
278
279fn is_fuzzy_search_term_long_enough(search: &str) -> bool {
280 search.chars().count() >= MIN_FUZZY_SEARCH_TERM_LENGTH
281}
282
283fn parse_exact_user_id_search_term(search: &str) -> Option<Uuid> {
284 search.trim().parse().ok()
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn normalizes_name_search_term() {
293 assert_eq!(normalize_name_search_term(" alice@example.com "), "alice");
294 assert_eq!(normalize_name_search_term(" alice "), "alice");
295 }
296
297 #[test]
298 fn normalizes_email_search_term_without_removing_domain() {
299 assert_eq!(
300 normalize_email_search_term(" alice@example.com "),
301 "alice@example.com"
302 );
303 }
304
305 #[test]
306 fn rejects_short_fuzzy_search_terms() {
307 assert!(!is_fuzzy_search_term_long_enough("al"));
308 assert!(is_fuzzy_search_term_long_enough("ali"));
309 }
310
311 #[test]
312 fn parses_exact_user_id_search_term() {
313 let user_id = Uuid::parse_str("5b177cc9-fbc3-43b5-8108-63481ff0b0e4").unwrap();
314
315 assert_eq!(
316 parse_exact_user_id_search_term(" 5b177cc9-fbc3-43b5-8108-63481ff0b0e4 "),
317 Some(user_id)
318 );
319 assert_eq!(parse_exact_user_id_search_term("not-a-user-id"), None);
320 }
321
322 mod email_verification_trigger {
326 use super::*;
327 use crate::test_helper::*;
328
329 async fn verify_now(tx: &mut PgConnection, user_id: Uuid) {
330 set_email_verified(
331 tx,
332 user_id,
333 EmailVerificationMethod::EmailedCode,
334 Utc::now(),
335 )
336 .await
337 .unwrap();
338 }
339
340 #[tokio::test]
341 async fn writer_a_user_settings_edit_clears_the_flag() {
342 insert_data!(:tx, :user);
343 verify_now(tx.as_mut(), user).await;
344
345 let updated = update_user_info(
346 tx.as_mut(),
347 user,
348 "writer-a-changed@example.com",
349 "Changed",
350 "Name",
351 "FI",
352 true,
353 )
354 .await
355 .unwrap();
356
357 assert_eq!(updated.email, "writer-a-changed@example.com");
358 assert_eq!(updated.email_verified_at, None);
359 assert_eq!(updated.email_verified_method, None);
360 }
361
362 #[tokio::test]
363 async fn writer_b_course_material_edit_clears_the_flag() {
364 insert_data!(:tx, :user);
365 let before = update_user_info(
366 tx.as_mut(),
367 user,
368 "writer-b@example.com",
369 "Course",
370 "Material",
371 "FI",
372 true,
373 )
374 .await
375 .unwrap();
376 verify_now(tx.as_mut(), user).await;
377
378 let updated = update_user_info(
380 tx.as_mut(),
381 user,
382 "writer-b-changed@example.com",
383 before.first_name.as_deref().unwrap(),
384 before.last_name.as_deref().unwrap(),
385 before.country.as_deref().unwrap(),
386 before.email_communication_consent.unwrap(),
387 )
388 .await
389 .unwrap();
390
391 assert_eq!(updated.email_verified_at, None);
392 assert_eq!(updated.email_verified_method, None);
393 }
394
395 #[tokio::test]
396 async fn writer_c_tmc_sync_clears_the_flag() {
397 insert_data!(:tx);
398 let upstream_id = 90_112_233;
399 let user = crate::users::insert_with_upstream_id_and_moocfi_id(
400 tx.as_mut(),
401 "writer-c@example.com",
402 None,
403 None,
404 upstream_id,
405 Uuid::new_v4(),
406 )
407 .await
408 .unwrap();
409 verify_now(tx.as_mut(), user.id).await;
410
411 crate::users::update_email_for_user(
412 tx.as_mut(),
413 &upstream_id,
414 "writer-c-changed@example.com".to_string(),
415 )
416 .await
417 .unwrap();
418
419 assert!(
420 get_email_verification(tx.as_mut(), user.id)
421 .await
422 .unwrap()
423 .is_none()
424 );
425 }
426
427 #[tokio::test]
428 async fn an_edit_that_leaves_the_address_alone_keeps_the_flag() {
429 insert_data!(:tx, :user);
430 let before = get_user_details_by_user_id(tx.as_mut(), user)
431 .await
432 .unwrap();
433 verify_now(tx.as_mut(), user).await;
434
435 let updated = update_user_info(
436 tx.as_mut(),
437 user,
438 &before.email,
439 "Renamed",
440 "Person",
441 "SE",
442 false,
443 )
444 .await
445 .unwrap();
446
447 assert!(updated.email_verified_at.is_some());
448 assert_eq!(
449 updated.email_verified_method,
450 Some(EmailVerificationMethod::EmailedCode)
451 );
452 }
453 }
454}
455
456pub async fn get_users_by_course_id(
458 conn: &mut PgConnection,
459 course_id: Uuid,
460) -> ModelResult<Vec<UserDetail>> {
461 let res = sqlx::query_as!(
462 UserDetail,
463 r#"
464SELECT d.user_id,
465 d.created_at,
466 d.updated_at,
467 d.email,
468 d.first_name,
469 d.last_name,
470 d.search_helper,
471 d.country,
472 d.email_communication_consent,
473 d.email_verified_at,
474 d.email_verified_method
475FROM course_instance_enrollments e
476 JOIN user_details d ON e.user_id = d.user_id
477WHERE e.course_id = $1
478 AND e.deleted_at IS NULL
479 "#,
480 course_id
481 )
482 .fetch_all(conn)
483 .await?;
484
485 Ok(res)
486}
487
488pub async fn get_user_details_by_user_ids(
490 conn: &mut PgConnection,
491 user_ids: &[Uuid],
492) -> ModelResult<Vec<UserDetail>> {
493 let res = sqlx::query_as!(
494 UserDetail,
495 r#"
496SELECT user_id,
497 created_at,
498 updated_at,
499 email,
500 first_name,
501 last_name,
502 search_helper,
503 country,
504 email_communication_consent,
505 email_verified_at,
506 email_verified_method
507FROM user_details
508WHERE user_id = ANY($1::uuid[])
509 "#,
510 user_ids
511 )
512 .fetch_all(conn)
513 .await?;
514
515 Ok(res)
516}
517
518pub async fn get_user_details_by_user_ids_for_course(
520 conn: &mut PgConnection,
521 user_ids: &[Uuid],
522 course_id: Uuid,
523) -> ModelResult<Vec<UserDetail>> {
524 let res = sqlx::query_as!(
525 UserDetail,
526 r#"
527SELECT ud.user_id,
528 ud.created_at,
529 ud.updated_at,
530 ud.email,
531 ud.first_name,
532 ud.last_name,
533 ud.search_helper,
534 ud.country,
535 ud.email_communication_consent,
536 ud.email_verified_at,
537 ud.email_verified_method
538FROM user_details ud
539JOIN user_course_settings ucs ON ud.user_id = ucs.user_id
540WHERE ud.user_id = ANY($1::uuid[])
541 AND ucs.current_course_id = $2
542 AND ucs.deleted_at IS NULL
543 "#,
544 user_ids,
545 course_id
546 )
547 .fetch_all(conn)
548 .await?;
549
550 Ok(res)
551}
552
553pub async fn get_user_details_by_user_id_for_course(
555 conn: &mut PgConnection,
556 user_id: Uuid,
557 course_id: Uuid,
558) -> ModelResult<UserDetail> {
559 let res = sqlx::query_as!(
560 UserDetail,
561 r#"
562SELECT ud.user_id,
563 ud.created_at,
564 ud.updated_at,
565 ud.email,
566 ud.first_name,
567 ud.last_name,
568 ud.search_helper,
569 ud.country,
570 ud.email_communication_consent,
571 ud.email_verified_at,
572 ud.email_verified_method
573FROM user_details ud
574JOIN user_course_settings ucs ON ud.user_id = ucs.user_id
575WHERE ud.user_id = $1
576 AND ucs.current_course_id = $2
577 AND ucs.deleted_at IS NULL
578 "#,
579 user_id,
580 course_id
581 )
582 .fetch_one(conn)
583 .await?;
584
585 Ok(res)
586}
587
588pub async fn update_user_country(
589 conn: &mut PgConnection,
590 user_id: Uuid,
591 country: &str,
592) -> Result<(), sqlx::Error> {
593 sqlx::query!(
594 r#"
595UPDATE user_details
596SET country = $1
597WHERE user_id = $2
598"#,
599 country,
600 user_id,
601 )
602 .execute(conn)
603 .await?;
604 Ok(())
605}
606
607pub async fn update_user_email_communication_consent(
608 conn: &mut PgConnection,
609 user_id: Uuid,
610 email_communication_consent: bool,
611) -> Result<(), sqlx::Error> {
612 sqlx::query!(
613 r#"
614UPDATE user_details
615SET email_communication_consent = $1
616WHERE user_id = $2
617"#,
618 email_communication_consent,
619 user_id,
620 )
621 .execute(conn)
622 .await?;
623 Ok(())
624}
625
626pub async fn update_user_info(
631 conn: &mut PgConnection,
632 user_id: Uuid,
633 email: &str,
634 first_name: &str,
635 last_name: &str,
636 country: &str,
637 email_communication_consent: bool,
638) -> Result<UserDetail, sqlx::Error> {
639 let mut tx = conn.begin().await?;
640 let updated_user = sqlx::query_as!(
641 UserDetail,
642 r#"
643UPDATE user_details
644SET email = $1,
645 first_name = $2,
646 last_name = $3,
647 country = $4,
648 email_communication_consent = $5
649WHERE user_id = $6
650RETURNING user_id,
651 created_at,
652 updated_at,
653 email,
654 first_name,
655 last_name,
656 search_helper,
657 country,
658 email_communication_consent,
659 email_verified_at,
660 email_verified_method
661"#,
662 email,
663 first_name,
664 last_name,
665 country,
666 email_communication_consent,
667 user_id,
668 )
669 .fetch_one(&mut *tx)
670 .await?;
671
672 sqlx::query!(
673 r#"
674UPDATE users
675SET email_domain = $1
676WHERE id = $2
677"#,
678 crate::users::email_domain_from_email(email),
679 user_id,
680 )
681 .execute(&mut *tx)
682 .await?;
683 tx.commit().await?;
684
685 Ok(updated_user)
686}
687
688pub async fn set_email_verified(
693 conn: &mut PgConnection,
694 user_id: Uuid,
695 method: EmailVerificationMethod,
696 verified_at: DateTime<Utc>,
697) -> ModelResult<()> {
698 sqlx::query!(
699 r#"
700UPDATE user_details
701SET email_verified_at = $2,
702 email_verified_method = $3
703WHERE user_id = $1
704"#,
705 user_id,
706 verified_at,
707 method as EmailVerificationMethod,
708 )
709 .execute(conn)
710 .await?;
711 Ok(())
712}
713
714pub async fn clear_email_verified(conn: &mut PgConnection, user_id: Uuid) -> ModelResult<()> {
717 sqlx::query!(
718 r#"
719UPDATE user_details
720SET email_verified_at = NULL,
721 email_verified_method = NULL
722WHERE user_id = $1
723"#,
724 user_id,
725 )
726 .execute(conn)
727 .await?;
728 Ok(())
729}
730
731pub async fn get_email_verification(
733 conn: &mut PgConnection,
734 user_id: Uuid,
735) -> ModelResult<Option<(DateTime<Utc>, EmailVerificationMethod)>> {
736 let row = sqlx::query!(
737 r#"
738SELECT email_verified_at,
739 email_verified_method AS "email_verified_method: EmailVerificationMethod"
740FROM user_details
741WHERE user_id = $1
742"#,
743 user_id,
744 )
745 .fetch_one(conn)
746 .await?;
747 Ok(row.email_verified_at.zip(row.email_verified_method))
748}