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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
11
12pub struct UserDetail {
13    pub user_id: Uuid,
14    pub created_at: DateTime<Utc>,
15    pub updated_at: DateTime<Utc>,
16    pub email: String,
17    pub first_name: Option<String>,
18    pub last_name: Option<String>,
19    pub search_helper: Option<String>,
20    pub country: Option<String>,
21    pub email_communication_consent: Option<bool>,
22}
23
24pub async fn get_user_details_by_user_id(
25    conn: &mut PgConnection,
26    user_id: Uuid,
27) -> ModelResult<UserDetail> {
28    let res = sqlx::query_as!(
29        UserDetail,
30        "
31SELECT user_id,
32  created_at,
33  updated_at,
34  email,
35  first_name,
36  last_name,
37  search_helper,
38  country,
39  email_communication_consent
40FROM user_details
41WHERE user_id = $1 ",
42        user_id
43    )
44    .fetch_one(conn)
45    .await?;
46    Ok(res)
47}
48
49pub async fn get_users_details_by_user_id_map(
50    conn: &mut PgConnection,
51    users: &[User],
52) -> ModelResult<HashMap<Uuid, UserDetail>> {
53    let ids = users.iter().map(|u| u.id).collect::<Vec<_>>();
54    let details = sqlx::query_as!(
55        UserDetail,
56        "
57SELECT user_id,
58  created_at,
59  updated_at,
60  email,
61  first_name,
62  last_name,
63  search_helper,
64  country,
65  email_communication_consent
66FROM user_details
67WHERE user_id IN (
68    SELECT UNNEST($1::uuid [])
69  )
70",
71        &ids
72    )
73    .fetch_all(conn)
74    .await?;
75    let mut res = HashMap::new();
76    details.into_iter().for_each(|d| {
77        res.insert(d.user_id, d);
78    });
79    Ok(res)
80}
81
82/// Includes all users who have returned an exercise on a course
83pub fn stream_users_details_having_user_exercise_states_on_course(
84    conn: &mut PgConnection,
85    course_id: Uuid,
86) -> impl Stream<Item = sqlx::Result<UserDetail>> + '_ {
87    sqlx::query_as!(
88        UserDetail,
89        "
90SELECT distinct (ud.user_id),
91 ud.created_at,
92 ud.updated_at,
93 ud.first_name,
94 ud.last_name,
95 ud.email,
96 ud.search_helper,
97 ud.country,
98 ud.email_communication_consent
99FROM user_details ud
100JOIN users u
101  ON u.id = ud.user_id
102JOIN user_exercise_states ues
103  ON ud.user_id = ues.user_id
104WHERE ues.course_id = $1
105  AND u.deleted_at IS NULL
106  AND ues.deleted_at IS NULL
107        ",
108        course_id
109    )
110    .fetch(conn)
111}
112
113pub async fn search_for_user_details_by_email(
114    conn: &mut PgConnection,
115    email: &str,
116) -> ModelResult<Vec<UserDetail>> {
117    let email = normalize_email_search_term(email);
118    if !is_fuzzy_search_term_long_enough(email) {
119        return Ok(Vec::new());
120    }
121
122    // ORDER BY dist only so the GiST trigram index can serve KNN distance ordering.
123    let res = sqlx::query_as!(
124        UserDetail,
125        "
126SELECT user_id,
127  created_at,
128  updated_at,
129  email,
130  first_name,
131  last_name,
132  search_helper,
133  country,
134  email_communication_consent
135FROM (
136    SELECT user_id,
137      created_at,
138      updated_at,
139      email,
140      first_name,
141      last_name,
142      search_helper,
143      country,
144      email_communication_consent,
145      lower($1) <<-> email_search_helper AS dist
146    FROM user_details
147    ORDER BY dist
148    LIMIT 100
149  ) search
150WHERE dist < 0.7;
151",
152        email,
153    )
154    .fetch_all(conn)
155    .await?;
156    Ok(res)
157}
158
159/// Searches user_details by exact user id.
160pub async fn search_for_user_details_by_other_details(
161    conn: &mut PgConnection,
162    search: &str,
163) -> ModelResult<Vec<UserDetail>> {
164    let Some(user_id) = parse_exact_user_id_search_term(search) else {
165        return Ok(Vec::new());
166    };
167
168    let res = sqlx::query_as!(
169        UserDetail,
170        "
171SELECT user_id,
172  created_at,
173  updated_at,
174  email,
175  first_name,
176  last_name,
177  search_helper,
178  country,
179  email_communication_consent
180FROM user_details
181WHERE user_id = $1;
182",
183        user_id,
184    )
185    .fetch_all(conn)
186    .await?;
187    Ok(res)
188}
189
190pub async fn search_for_user_details_fuzzy_match(
191    conn: &mut PgConnection,
192    search: &str,
193) -> ModelResult<Vec<UserDetail>> {
194    // If a full email address reaches name search, compare only the local part against names.
195    let search = normalize_name_search_term(search);
196    if !is_fuzzy_search_term_long_enough(search) {
197        return Ok(Vec::new());
198    }
199
200    // ORDER BY dist only — no secondary tiebreaker. Adding one (e.g. user_id)
201    // would prevent the GiST trigram index from serving the distance ordering,
202    // forcing a full table scan+sort. Ties at exactly equal float distances are
203    // rare enough in practice that non-determinism in the LIMIT 100 is acceptable.
204    let res = sqlx::query_as!(
205        UserDetail,
206        "
207SELECT user_id,
208  created_at,
209  updated_at,
210  email,
211  first_name,
212  last_name,
213  search_helper,
214  country,
215  email_communication_consent
216FROM (
217    SELECT user_id,
218      created_at,
219      updated_at,
220      email,
221      first_name,
222      last_name,
223      search_helper,
224      country,
225      email_communication_consent,
226      lower($1) <<-> name_search_helper AS dist
227    FROM user_details
228    ORDER BY dist
229    LIMIT 100
230  ) search
231WHERE dist < 0.7;
232",
233        search,
234    )
235    .fetch_all(conn)
236    .await?;
237    Ok(res)
238}
239
240fn normalize_name_search_term(search: &str) -> &str {
241    search.split('@').next().unwrap_or(search).trim()
242}
243
244fn normalize_email_search_term(search: &str) -> &str {
245    search.trim()
246}
247
248fn is_fuzzy_search_term_long_enough(search: &str) -> bool {
249    search.chars().count() >= MIN_FUZZY_SEARCH_TERM_LENGTH
250}
251
252fn parse_exact_user_id_search_term(search: &str) -> Option<Uuid> {
253    search.trim().parse().ok()
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn normalizes_name_search_term() {
262        assert_eq!(normalize_name_search_term("  alice@example.com  "), "alice");
263        assert_eq!(normalize_name_search_term("  alice  "), "alice");
264    }
265
266    #[test]
267    fn normalizes_email_search_term_without_removing_domain() {
268        assert_eq!(
269            normalize_email_search_term("  alice@example.com  "),
270            "alice@example.com"
271        );
272    }
273
274    #[test]
275    fn rejects_short_fuzzy_search_terms() {
276        assert!(!is_fuzzy_search_term_long_enough("al"));
277        assert!(is_fuzzy_search_term_long_enough("ali"));
278    }
279
280    #[test]
281    fn parses_exact_user_id_search_term() {
282        let user_id = Uuid::parse_str("5b177cc9-fbc3-43b5-8108-63481ff0b0e4").unwrap();
283
284        assert_eq!(
285            parse_exact_user_id_search_term("  5b177cc9-fbc3-43b5-8108-63481ff0b0e4  "),
286            Some(user_id)
287        );
288        assert_eq!(parse_exact_user_id_search_term("not-a-user-id"), None);
289    }
290}
291
292/// Retrieves all users enrolled in a specific course
293pub async fn get_users_by_course_id(
294    conn: &mut PgConnection,
295    course_id: Uuid,
296) -> ModelResult<Vec<UserDetail>> {
297    let res = sqlx::query_as!(
298        UserDetail,
299        r#"
300SELECT d.user_id,
301  d.created_at,
302  d.updated_at,
303  d.email,
304  d.first_name,
305  d.last_name,
306  d.search_helper,
307  d.country,
308  d.email_communication_consent
309FROM course_instance_enrollments e
310  JOIN user_details d ON e.user_id = d.user_id
311WHERE e.course_id = $1
312  AND e.deleted_at IS NULL
313        "#,
314        course_id
315    )
316    .fetch_all(conn)
317    .await?;
318
319    Ok(res)
320}
321
322/// Retrieves user details for a list of user IDs
323pub async fn get_user_details_by_user_ids(
324    conn: &mut PgConnection,
325    user_ids: &[Uuid],
326) -> ModelResult<Vec<UserDetail>> {
327    let res = sqlx::query_as!(
328        UserDetail,
329        r#"
330SELECT user_id,
331  created_at,
332  updated_at,
333  email,
334  first_name,
335  last_name,
336  search_helper,
337  country,
338  email_communication_consent
339FROM user_details
340WHERE user_id = ANY($1::uuid[])
341        "#,
342        user_ids
343    )
344    .fetch_all(conn)
345    .await?;
346
347    Ok(res)
348}
349
350/// Retrieves user details for a list of user IDs, but only for users who are enrolled in the specified course
351pub async fn get_user_details_by_user_ids_for_course(
352    conn: &mut PgConnection,
353    user_ids: &[Uuid],
354    course_id: Uuid,
355) -> ModelResult<Vec<UserDetail>> {
356    let res = sqlx::query_as!(
357        UserDetail,
358        r#"
359SELECT ud.user_id,
360  ud.created_at,
361  ud.updated_at,
362  ud.email,
363  ud.first_name,
364  ud.last_name,
365  ud.search_helper,
366  ud.country,
367  ud.email_communication_consent
368FROM user_details ud
369JOIN user_course_settings ucs ON ud.user_id = ucs.user_id
370WHERE ud.user_id = ANY($1::uuid[])
371  AND ucs.current_course_id = $2
372  AND ucs.deleted_at IS NULL
373        "#,
374        user_ids,
375        course_id
376    )
377    .fetch_all(conn)
378    .await?;
379
380    Ok(res)
381}
382
383/// Retrieves user details for a single user ID, but only if the user is enrolled in the specified course
384pub async fn get_user_details_by_user_id_for_course(
385    conn: &mut PgConnection,
386    user_id: Uuid,
387    course_id: Uuid,
388) -> ModelResult<UserDetail> {
389    let res = sqlx::query_as!(
390        UserDetail,
391        r#"
392SELECT ud.user_id,
393  ud.created_at,
394  ud.updated_at,
395  ud.email,
396  ud.first_name,
397  ud.last_name,
398  ud.search_helper,
399  ud.country,
400  ud.email_communication_consent
401FROM user_details ud
402JOIN user_course_settings ucs ON ud.user_id = ucs.user_id
403WHERE ud.user_id = $1
404  AND ucs.current_course_id = $2
405  AND ucs.deleted_at IS NULL
406        "#,
407        user_id,
408        course_id
409    )
410    .fetch_one(conn)
411    .await?;
412
413    Ok(res)
414}
415
416pub async fn update_user_country(
417    conn: &mut PgConnection,
418    user_id: Uuid,
419    country: &str,
420) -> Result<(), sqlx::Error> {
421    sqlx::query!(
422        r#"
423UPDATE user_details
424SET country = $1
425WHERE user_id = $2
426"#,
427        country,
428        user_id,
429    )
430    .execute(conn)
431    .await?;
432    Ok(())
433}
434
435pub async fn update_user_email_communication_consent(
436    conn: &mut PgConnection,
437    user_id: Uuid,
438    email_communication_consent: bool,
439) -> Result<(), sqlx::Error> {
440    sqlx::query!(
441        r#"
442UPDATE user_details
443SET email_communication_consent = $1
444WHERE user_id = $2
445"#,
446        email_communication_consent,
447        user_id,
448    )
449    .execute(conn)
450    .await?;
451    Ok(())
452}
453
454pub async fn update_user_info(
455    conn: &mut PgConnection,
456    user_id: Uuid,
457    email: &str,
458    first_name: &str,
459    last_name: &str,
460    country: &str,
461    email_communication_consent: bool,
462) -> Result<UserDetail, sqlx::Error> {
463    let updated_user = sqlx::query_as!(
464        UserDetail,
465        r#"
466UPDATE user_details
467SET email = $1,
468  first_name = $2,
469  last_name = $3,
470  country = $4,
471  email_communication_consent = $5
472WHERE user_id = $6
473RETURNING user_id,
474  created_at,
475  updated_at,
476  email,
477  first_name,
478  last_name,
479  search_helper,
480  country,
481  email_communication_consent
482"#,
483        email,
484        first_name,
485        last_name,
486        country,
487        email_communication_consent,
488        user_id,
489    )
490    .fetch_one(conn)
491    .await?;
492
493    Ok(updated_user)
494}