Skip to main content

headless_lms_models/
user_details.rs

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/// Trigram distance floor for [`search_for_user_details_by_email`] and
11/// [`search_for_user_details_fuzzy_match`] (`pg_trgm`'s `<<->` returns 0 for an identical string,
12/// larger for a less similar one). Loose enough that a real typo still matches.
13pub const FUZZY_MATCH_SIMILARITY_THRESHOLD: f32 = 0.7;
14
15/// How proof of control over [`UserDetail::email`] was obtained. `AdminAsserted` is the weakest.
16#[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    /// When the user last proved control of the address in `email`. `None` means unproven. Cleared
39    /// by a database trigger on every address change, so a value here always refers to `email`.
40    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
106/// Includes all users who have returned an exercise on a course
107pub 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
139/// None when no active user has this email. Case-insensitive, matching the users_email unique index.
140pub 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    // ORDER BY dist only so the GiST trigram index can serve KNN distance ordering.
167    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
208/// Searches user_details by exact user id.
209pub 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    // If a full email address reaches name search, compare only the local part against names.
246    let search = normalize_name_search_term(search);
247    if !is_fuzzy_search_term_long_enough(search) {
248        return Ok(Vec::new());
249    }
250
251    // ORDER BY dist only — no secondary tiebreaker. Adding one (e.g. user_id)
252    // would prevent the GiST trigram index from serving the distance ordering,
253    // forcing a full table scan+sort. Ties at exactly equal float distances are
254    // rare enough in practice that non-determinism in the LIMIT 100 is acceptable.
255    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
312/// Retrieves all users enrolled in a specific course
313pub 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
344/// Retrieves user details for a list of user IDs
345pub 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
374/// Retrieves user details for a list of user IDs, but only for users who are enrolled in the specified course
375pub 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
409/// Retrieves user details for a single user ID, but only if the user is enrolled in the specified course
410pub 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
482/// Writes the whole profile form, including the derived `users.email_domain`.
483///
484/// On an address change the `clear_email_verification` trigger nulls `email_verified_at` and
485/// `email_verified_method`, so a caller wanting fresh proof must mail a new verification link.
486pub 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
544/// Records a proof of control over the address currently in `email`.
545///
546/// Must not also write `email`: the `clear_email_verification` trigger would null the flag in the
547/// same statement.
548pub 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
570/// Drops a proof of control, for an admin revoking a verification. Address changes do not need it;
571/// the `clear_email_verification` trigger handles those.
572pub 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
587/// Whether the address currently in `email` has a proof of control, and how it was obtained.
588pub 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    // One test per writer of user_details.email. No call site clears the verification flag itself,
642    // so these are what notices if the clear_email_verification trigger is ever dropped. Writers A
643    // and B both go through update_user_info, so they differ only in the payload sent.
644    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            // The course-material form resubmits the whole profile, so only the address differs.
698            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}