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/// How proof of control over [`UserDetail::email`] was obtained. `AdminAsserted` is the weakest.
11#[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    /// When the user last proved control of the address in `email`. `None` means unproven. Cleared
34    /// by a database trigger on every address change, so a value here always refers to `email`.
35    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
101/// Includes all users who have returned an exercise on a course
102pub 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    // ORDER BY dist only so the GiST trigram index can serve KNN distance ordering.
144    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
184/// Searches user_details by exact user id.
185pub 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    // If a full email address reaches name search, compare only the local part against names.
222    let search = normalize_name_search_term(search);
223    if !is_fuzzy_search_term_long_enough(search) {
224        return Ok(Vec::new());
225    }
226
227    // ORDER BY dist only — no secondary tiebreaker. Adding one (e.g. user_id)
228    // would prevent the GiST trigram index from serving the distance ordering,
229    // forcing a full table scan+sort. Ties at exactly equal float distances are
230    // rare enough in practice that non-determinism in the LIMIT 100 is acceptable.
231    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    // One test per writer of user_details.email. No call site clears the verification flag itself,
323    // so these are what notices if the clear_email_verification trigger is ever dropped. Writers A
324    // and B both go through update_user_info, so they differ only in the payload sent.
325    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            // The course-material form resubmits the whole profile, so only the address differs.
379            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
456/// Retrieves all users enrolled in a specific course
457pub 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
488/// Retrieves user details for a list of user IDs
489pub 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
518/// Retrieves user details for a list of user IDs, but only for users who are enrolled in the specified course
519pub 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
553/// Retrieves user details for a single user ID, but only if the user is enrolled in the specified course
554pub 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
626/// Writes the whole profile form, including the derived `users.email_domain`.
627///
628/// On an address change the `clear_email_verification` trigger nulls `email_verified_at` and
629/// `email_verified_method`, so a caller wanting fresh proof must mail a new verification link.
630pub 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
688/// Records a proof of control over the address currently in `email`.
689///
690/// Must not also write `email`: the `clear_email_verification` trigger would null the flag in the
691/// same statement.
692pub 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
714/// Drops a proof of control, for an admin revoking a verification. Address changes do not need it;
715/// the `clear_email_verification` trigger handles those.
716pub 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
731/// Whether the address currently in `email` has a proof of control, and how it was obtained.
732pub 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}