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