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