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
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
9
10pub struct UserDetail {
11    pub user_id: Uuid,
12    pub created_at: DateTime<Utc>,
13    pub updated_at: DateTime<Utc>,
14    pub email: String,
15    pub first_name: Option<String>,
16    pub last_name: Option<String>,
17    pub search_helper: Option<String>,
18    pub country: Option<String>,
19    pub email_communication_consent: Option<bool>,
20}
21
22pub async fn get_user_details_by_user_id(
23    conn: &mut PgConnection,
24    user_id: Uuid,
25) -> ModelResult<UserDetail> {
26    let res = sqlx::query_as!(
27        UserDetail,
28        "
29SELECT *
30FROM user_details
31WHERE user_id = $1 ",
32        user_id
33    )
34    .fetch_one(conn)
35    .await?;
36    Ok(res)
37}
38
39pub async fn get_users_details_by_user_id_map(
40    conn: &mut PgConnection,
41    users: &[User],
42) -> ModelResult<HashMap<Uuid, UserDetail>> {
43    let ids = users.iter().map(|u| u.id).collect::<Vec<_>>();
44    let details = sqlx::query_as!(
45        UserDetail,
46        "
47SELECT *
48FROM user_details
49WHERE user_id IN (
50    SELECT UNNEST($1::uuid [])
51  )
52",
53        &ids
54    )
55    .fetch_all(conn)
56    .await?;
57    let mut res = HashMap::new();
58    details.into_iter().for_each(|d| {
59        res.insert(d.user_id, d);
60    });
61    Ok(res)
62}
63
64/// Includes all users who have returned an exercise on a course
65pub fn stream_users_details_having_user_exercise_states_on_course(
66    conn: &mut PgConnection,
67    course_id: Uuid,
68) -> impl Stream<Item = sqlx::Result<UserDetail>> + '_ {
69    sqlx::query_as!(
70        UserDetail,
71        "
72SELECT distinct (ud.user_id),
73 ud.created_at,
74 ud.updated_at,
75 ud.first_name,
76 ud.last_name,
77 ud.email,
78 ud.search_helper,
79 ud.country,
80 ud.email_communication_consent
81FROM user_details ud
82JOIN users u
83  ON u.id = ud.user_id
84JOIN user_exercise_states ues
85  ON ud.user_id = ues.user_id
86WHERE ues.course_id = $1
87  AND u.deleted_at IS NULL
88  AND ues.deleted_at IS NULL
89        ",
90        course_id
91    )
92    .fetch(conn)
93}
94
95pub async fn search_for_user_details_by_email(
96    conn: &mut PgConnection,
97    email: &str,
98) -> ModelResult<Vec<UserDetail>> {
99    let res = sqlx::query_as!(
100        UserDetail,
101        "
102SELECT *
103FROM user_details
104WHERE lower(email::text) LIKE '%' || lower($1) || '%'
105ORDER BY similarity(lower(email::text), lower($1)) DESC
106LIMIT 100;
107",
108        email.trim(),
109    )
110    .fetch_all(conn)
111    .await?;
112    Ok(res)
113}
114
115/// Searches user_details by partial match on the generated `search_helper` column so the
116/// `user_details_search_helper_gist` trigram index can serve the `LIKE '%x%'` predicate.
117pub async fn search_for_user_details_by_other_details(
118    conn: &mut PgConnection,
119    search: &str,
120) -> ModelResult<Vec<UserDetail>> {
121    let res = sqlx::query_as!(
122        UserDetail,
123        "
124SELECT *
125FROM user_details
126WHERE search_helper LIKE '%' || lower($1) || '%'
127ORDER BY similarity(search_helper, lower($1)) DESC
128LIMIT 100;
129",
130        search.trim(),
131    )
132    .fetch_all(conn)
133    .await?;
134    Ok(res)
135}
136
137pub async fn search_for_user_details_fuzzy_match(
138    conn: &mut PgConnection,
139    search: &str,
140) -> ModelResult<Vec<UserDetail>> {
141    // For email domains, the fuzzy match returns too much results that have the same domain
142    // To combat this, we omit the email domain from the fuzzy match
143    let search = search.split('@').next().unwrap_or(search);
144
145    // ORDER BY dist only — no secondary tiebreaker. Adding one (e.g. user_id)
146    // would prevent the GiST trigram index from serving the distance ordering,
147    // forcing a full table scan+sort. Ties at exactly equal float distances are
148    // rare enough in practice that non-determinism in the LIMIT 100 is acceptable.
149    let res = sqlx::query_as!(
150        UserDetail,
151        "
152SELECT user_id,
153  created_at,
154  updated_at,
155  email,
156  first_name,
157  last_name,
158  search_helper,
159  country,
160  email_communication_consent
161FROM (
162    SELECT *,
163      LOWER($1) <<-> search_helper AS dist
164    FROM user_details
165    ORDER BY dist
166    LIMIT 100
167  ) search
168WHERE dist < 0.7;
169",
170        search.trim(),
171    )
172    .fetch_all(conn)
173    .await?;
174    Ok(res)
175}
176
177/// Retrieves all users enrolled in a specific course
178pub async fn get_users_by_course_id(
179    conn: &mut PgConnection,
180    course_id: Uuid,
181) -> ModelResult<Vec<UserDetail>> {
182    let res = sqlx::query_as!(
183        UserDetail,
184        r#"
185SELECT d.user_id,
186  d.created_at,
187  d.updated_at,
188  d.email,
189  d.first_name,
190  d.last_name,
191  d.search_helper,
192  d.country,
193  d.email_communication_consent
194FROM course_instance_enrollments e
195  JOIN user_details d ON e.user_id = d.user_id
196WHERE e.course_id = $1
197  AND e.deleted_at IS NULL
198        "#,
199        course_id
200    )
201    .fetch_all(conn)
202    .await?;
203
204    Ok(res)
205}
206
207/// Retrieves user details for a list of user IDs
208pub async fn get_user_details_by_user_ids(
209    conn: &mut PgConnection,
210    user_ids: &[Uuid],
211) -> ModelResult<Vec<UserDetail>> {
212    let res = sqlx::query_as!(
213        UserDetail,
214        r#"
215SELECT *
216FROM user_details
217WHERE user_id = ANY($1::uuid[])
218        "#,
219        user_ids
220    )
221    .fetch_all(conn)
222    .await?;
223
224    Ok(res)
225}
226
227/// Retrieves user details for a list of user IDs, but only for users who are enrolled in the specified course
228pub async fn get_user_details_by_user_ids_for_course(
229    conn: &mut PgConnection,
230    user_ids: &[Uuid],
231    course_id: Uuid,
232) -> ModelResult<Vec<UserDetail>> {
233    let res = sqlx::query_as!(
234        UserDetail,
235        r#"
236SELECT ud.*
237FROM user_details ud
238JOIN user_course_settings ucs ON ud.user_id = ucs.user_id
239WHERE ud.user_id = ANY($1::uuid[])
240  AND ucs.current_course_id = $2
241  AND ucs.deleted_at IS NULL
242        "#,
243        user_ids,
244        course_id
245    )
246    .fetch_all(conn)
247    .await?;
248
249    Ok(res)
250}
251
252/// Retrieves user details for a single user ID, but only if the user is enrolled in the specified course
253pub async fn get_user_details_by_user_id_for_course(
254    conn: &mut PgConnection,
255    user_id: Uuid,
256    course_id: Uuid,
257) -> ModelResult<UserDetail> {
258    let res = sqlx::query_as!(
259        UserDetail,
260        r#"
261SELECT ud.*
262FROM user_details ud
263JOIN user_course_settings ucs ON ud.user_id = ucs.user_id
264WHERE ud.user_id = $1
265  AND ucs.current_course_id = $2
266  AND ucs.deleted_at IS NULL
267        "#,
268        user_id,
269        course_id
270    )
271    .fetch_one(conn)
272    .await?;
273
274    Ok(res)
275}
276
277pub async fn update_user_country(
278    conn: &mut PgConnection,
279    user_id: Uuid,
280    country: &str,
281) -> Result<(), sqlx::Error> {
282    sqlx::query!(
283        r#"
284UPDATE user_details
285SET country = $1
286WHERE user_id = $2
287"#,
288        country,
289        user_id,
290    )
291    .execute(conn)
292    .await?;
293    Ok(())
294}
295
296pub async fn update_user_email_communication_consent(
297    conn: &mut PgConnection,
298    user_id: Uuid,
299    email_communication_consent: bool,
300) -> Result<(), sqlx::Error> {
301    sqlx::query!(
302        r#"
303UPDATE user_details
304SET email_communication_consent = $1
305WHERE user_id = $2
306"#,
307        email_communication_consent,
308        user_id,
309    )
310    .execute(conn)
311    .await?;
312    Ok(())
313}
314
315pub async fn update_user_info(
316    conn: &mut PgConnection,
317    user_id: Uuid,
318    email: &str,
319    first_name: &str,
320    last_name: &str,
321    country: &str,
322    email_communication_consent: bool,
323) -> Result<UserDetail, sqlx::Error> {
324    let updated_user = sqlx::query_as!(
325        UserDetail,
326        r#"
327UPDATE user_details
328SET email = $1,
329  first_name = $2,
330  last_name = $3,
331  country = $4,
332  email_communication_consent = $5
333WHERE user_id = $6
334RETURNING *
335"#,
336        email,
337        first_name,
338        last_name,
339        country,
340        email_communication_consent,
341        user_id,
342    )
343    .fetch_one(conn)
344    .await?;
345
346    Ok(updated_user)
347}