Skip to main content

headless_lms_models/
user_details.rs

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
10/// Cap on [`FuzzyUserSearchResults::users`].
11pub const FUZZY_SEARCH_RESULT_LIMIT: i64 = 50;
12
13/// Minimum `pg_trgm` word similarity (0..1) for a fuzzy match. Real typos score as low as ~0.35.
14const FUZZY_SEARCH_WORD_SIMILARITY_THRESHOLD: f32 = 0.3;
15
16/// Search box filter for user lists: every word of the search appears in the name or email, in any
17/// order, or the whole search is a near match for a misspelled name or email. Queries apply it as
18/// `(ud.name_search_helper || ' ' || ud.email_search_helper) LIKE ALL ($word_patterns)` OR
19/// `word_similarity($misspelling_term, ud.name_search_helper || ' ' || ud.email_search_helper) >= 0.5`.
20/// [`search_for_user_details_fuzzy`] is the ranked search over all users.
21#[derive(Debug, Clone, Default)]
22pub struct UserListSearch {
23    /// One `%word%` pattern per word, lowercased and LIKE-escaped.
24    pub word_patterns: Vec<String>,
25    /// `None` when the search contains a digit: a near match for a student number or for
26    /// `user_3@example.com` is a different person.
27    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/// How proof of control over [`UserDetail::email`] was obtained. `AdminAsserted` is the weakest.
46#[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    /// When the user last proved control of the address in `email`. `None` means unproven. Cleared
68    /// by a database trigger on every address change, so a value here always refers to `email`.
69    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
133/// Includes all users who have returned an exercise on a course
134pub 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
165/// None when no active user has this email. Case-insensitive, matching the users_email unique index.
166pub 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
183/// Users whose id or email equals the search term, ignoring case and surrounding whitespace.
184///
185/// At most one row per kind, and cheap enough to answer before [`search_for_user_details_fuzzy`].
186pub 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/// Typo-tolerant matches from [`search_for_user_details_fuzzy`], best first.
222#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ToSchema)]
223pub struct FuzzyUserSearchResults {
224    pub users: Vec<UserDetail>,
225    /// More users matched than `users` holds.
226    pub has_more: bool,
227}
228
229/// Users whose name or email local part resembles the search term, best match first, ties by id.
230///
231/// A term containing `@` is matched by its local part. Terms shorter than three characters and
232/// user ids match nothing; [`search_for_user_details_exact`] covers ids and whole emails. A common
233/// term takes seconds on production, so callers should show the exact matches first.
234pub 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    // `<%` uses the threshold set above. The expression must stay identical to the
249    // user_details_search_text_gin index. Not ordered by GiST trigram distance: a term close to no
250    // name walks most of such an index, 14s on production.
251    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    // Rolled back rather than committed: it only read, and a commit inside a caller's transaction
276    // would keep the threshold for the rest of that transaction.
277    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
284/// The lowercased local part of the search term, or `None` when it should match nothing.
285fn 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
299/// Retrieves all users enrolled in a specific course
300pub 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
330/// Retrieves user details for a list of user IDs
331pub 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
359/// Retrieves user details for a list of user IDs, but only for users who are enrolled in the specified course
360pub 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
393/// Retrieves user details for a single user ID, but only if the user is enrolled in the specified course
394pub 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
446/// Sets the name alone. The caller also pushes it to TMC, which otherwise keeps the old one.
447pub 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
488/// Writes the whole profile form, including the derived `users.email_domain`.
489///
490/// On an address change the `clear_email_verification` trigger nulls `email_verified_at` and
491/// `email_verified_method`, so a caller wanting fresh proof must mail a new verification link.
492pub 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
549/// Records a proof of control over the address currently in `email`.
550///
551/// Must not also write `email`: the `clear_email_verification` trigger would null the flag in the
552/// same statement.
553pub 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
575/// Drops a proof of control, for an admin revoking a verification. Address changes do not need it;
576/// the `clear_email_verification` trigger handles those.
577pub 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
592/// Whether the address currently in `email` has a proof of control, and how it was obtained.
593pub 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    // One test per writer of user_details.email. No call site clears the verification flag itself,
635    // so these are what notices if the clear_email_verification trigger is ever dropped. Writers A
636    // and B both go through update_user_info, so they differ only in the payload sent.
637    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            // The course-material form resubmits the whole profile, so only the address differs.
691            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}