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
82pub 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 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
159pub 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 let search = normalize_name_search_term(search);
196 if !is_fuzzy_search_term_long_enough(search) {
197 return Ok(Vec::new());
198 }
199
200 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
292pub 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
322pub 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
350pub 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
383pub 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}