1use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use mas_data_model::{
11 BrowserSession, Clock, UlidExt as _, UpstreamOAuthAuthorizationSession, User, UserEmail,
12 UserEmailAuthentication, UserEmailAuthenticationCode, UserRegistration,
13};
14use mas_storage::{
15 Page, Pagination,
16 pagination::Node,
17 user::{UserEmailFilter, UserEmailRepository},
18};
19use rand::Rng;
20use sea_query::{Expr, ExprTrait, Func, PostgresQueryBuilder, Query, SimpleExpr, enum_def};
21use sea_query_sqlx::SqlxBinder;
22use sqlx::PgConnection;
23use ulid::Ulid;
24use uuid::Uuid;
25
26use crate::{
27 DatabaseError,
28 filter::{Filter, StatementExt},
29 iden::UserEmails,
30 pagination::QueryBuilderExt,
31 tracing::ExecuteExt,
32};
33
34pub struct PgUserEmailRepository<'c> {
36 conn: &'c mut PgConnection,
37}
38
39impl<'c> PgUserEmailRepository<'c> {
40 pub fn new(conn: &'c mut PgConnection) -> Self {
43 Self { conn }
44 }
45}
46
47#[derive(Debug, Clone, sqlx::FromRow)]
48#[enum_def]
49struct UserEmailLookup {
50 user_email_id: Uuid,
51 user_id: Uuid,
52 email: String,
53 created_at: DateTime<Utc>,
54}
55
56impl Node<Ulid> for UserEmailLookup {
57 fn cursor(&self) -> Ulid {
58 self.user_email_id.into()
59 }
60}
61
62impl From<UserEmailLookup> for UserEmail {
63 fn from(e: UserEmailLookup) -> UserEmail {
64 UserEmail {
65 id: e.user_email_id.into(),
66 user_id: e.user_id.into(),
67 email: e.email,
68 created_at: e.created_at,
69 }
70 }
71}
72
73struct UserEmailAuthenticationLookup {
74 user_email_authentication_id: Uuid,
75 user_session_id: Option<Uuid>,
76 user_registration_id: Option<Uuid>,
77 email: String,
78 created_at: DateTime<Utc>,
79 completed_at: Option<DateTime<Utc>>,
80}
81
82impl From<UserEmailAuthenticationLookup> for UserEmailAuthentication {
83 fn from(value: UserEmailAuthenticationLookup) -> Self {
84 UserEmailAuthentication {
85 id: value.user_email_authentication_id.into(),
86 user_session_id: value.user_session_id.map(Ulid::from),
87 user_registration_id: value.user_registration_id.map(Ulid::from),
88 email: value.email,
89 created_at: value.created_at,
90 completed_at: value.completed_at,
91 }
92 }
93}
94
95struct UserEmailAuthenticationCodeLookup {
96 user_email_authentication_code_id: Uuid,
97 user_email_authentication_id: Uuid,
98 code: String,
99 created_at: DateTime<Utc>,
100 expires_at: DateTime<Utc>,
101}
102
103impl From<UserEmailAuthenticationCodeLookup> for UserEmailAuthenticationCode {
104 fn from(value: UserEmailAuthenticationCodeLookup) -> Self {
105 UserEmailAuthenticationCode {
106 id: value.user_email_authentication_code_id.into(),
107 user_email_authentication_id: value.user_email_authentication_id.into(),
108 code: value.code,
109 created_at: value.created_at,
110 expires_at: value.expires_at,
111 }
112 }
113}
114
115impl Filter for UserEmailFilter<'_> {
116 fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition {
117 sea_query::Condition::all()
118 .add_option(self.user().map(|user| {
119 Expr::col((UserEmails::Table, UserEmails::UserId)).eq(Uuid::from(user.id))
120 }))
121 .add_option(self.email().map(|email| {
122 SimpleExpr::from(Func::lower(Expr::col((
123 UserEmails::Table,
124 UserEmails::Email,
125 ))))
126 .eq(Func::lower(email))
127 }))
128 }
129}
130
131#[async_trait]
132impl UserEmailRepository for PgUserEmailRepository<'_> {
133 type Error = DatabaseError;
134
135 #[tracing::instrument(
136 name = "db.user_email.lookup",
137 skip_all,
138 fields(
139 db.query.text,
140 user_email.id = %id,
141 ),
142 )]
143 async fn lookup(&mut self, id: Ulid) -> Result<Option<UserEmail>, Self::Error> {
144 let res = sqlx::query_as!(
145 UserEmailLookup,
146 r#"
147 SELECT user_email_id
148 , user_id
149 , email
150 , created_at
151 FROM user_emails
152
153 WHERE user_email_id = $1
154 "#,
155 Uuid::from(id),
156 )
157 .traced()
158 .fetch_optional(&mut *self.conn)
159 .await?;
160
161 let Some(user_email) = res else {
162 return Ok(None);
163 };
164
165 Ok(Some(user_email.into()))
166 }
167
168 #[tracing::instrument(
169 name = "db.user_email.find",
170 skip_all,
171 fields(
172 db.query.text,
173 %user.id,
174 user_email.email = email,
175 ),
176 )]
177 async fn find(&mut self, user: &User, email: &str) -> Result<Option<UserEmail>, Self::Error> {
178 let res = sqlx::query_as!(
179 UserEmailLookup,
180 r#"
181 SELECT user_email_id
182 , user_id
183 , email
184 , created_at
185 FROM user_emails
186
187 WHERE user_id = $1 AND LOWER(email) = LOWER($2)
188 "#,
189 Uuid::from(user.id),
190 email,
191 )
192 .traced()
193 .fetch_optional(&mut *self.conn)
194 .await?;
195
196 let Some(user_email) = res else {
197 return Ok(None);
198 };
199
200 Ok(Some(user_email.into()))
201 }
202
203 #[tracing::instrument(
204 name = "db.user_email.find_by_email",
205 skip_all,
206 fields(
207 db.query.text,
208 user_email.email = email,
209 ),
210 )]
211 async fn find_by_email(&mut self, email: &str) -> Result<Option<UserEmail>, Self::Error> {
212 let res = sqlx::query_as!(
213 UserEmailLookup,
214 r#"
215 SELECT user_email_id
216 , user_id
217 , email
218 , created_at
219 FROM user_emails
220 WHERE LOWER(email) = LOWER($1)
221 "#,
222 email,
223 )
224 .traced()
225 .fetch_all(&mut *self.conn)
226 .await?;
227
228 if res.len() != 1 {
229 return Ok(None);
230 }
231
232 let Some(user_email) = res.into_iter().next() else {
233 return Ok(None);
234 };
235
236 Ok(Some(user_email.into()))
237 }
238
239 #[tracing::instrument(
240 name = "db.user_email.all",
241 skip_all,
242 fields(
243 db.query.text,
244 %user.id,
245 ),
246 )]
247 async fn all(&mut self, user: &User) -> Result<Vec<UserEmail>, Self::Error> {
248 let res = sqlx::query_as!(
249 UserEmailLookup,
250 r#"
251 SELECT user_email_id
252 , user_id
253 , email
254 , created_at
255 FROM user_emails
256
257 WHERE user_id = $1
258
259 ORDER BY email ASC
260 "#,
261 Uuid::from(user.id),
262 )
263 .traced()
264 .fetch_all(&mut *self.conn)
265 .await?;
266
267 Ok(res.into_iter().map(Into::into).collect())
268 }
269
270 #[tracing::instrument(
271 name = "db.user_email.list",
272 skip_all,
273 fields(
274 db.query.text,
275 ),
276 )]
277 async fn list(
278 &mut self,
279 filter: UserEmailFilter<'_>,
280 pagination: Pagination,
281 ) -> Result<Page<UserEmail>, DatabaseError> {
282 let (sql, arguments) = Query::select()
283 .expr_as(
284 Expr::col((UserEmails::Table, UserEmails::UserEmailId)),
285 UserEmailLookupIden::UserEmailId,
286 )
287 .expr_as(
288 Expr::col((UserEmails::Table, UserEmails::UserId)),
289 UserEmailLookupIden::UserId,
290 )
291 .expr_as(
292 Expr::col((UserEmails::Table, UserEmails::Email)),
293 UserEmailLookupIden::Email,
294 )
295 .expr_as(
296 Expr::col((UserEmails::Table, UserEmails::CreatedAt)),
297 UserEmailLookupIden::CreatedAt,
298 )
299 .from(UserEmails::Table)
300 .apply_filter(filter)
301 .generate_pagination((UserEmails::Table, UserEmails::UserEmailId), pagination)
302 .build_sqlx(PostgresQueryBuilder);
303
304 let edges: Vec<UserEmailLookup> = sqlx::query_as_with(&sql, arguments)
305 .traced()
306 .fetch_all(&mut *self.conn)
307 .await?;
308
309 let page = pagination.process(edges).map(UserEmail::from);
310
311 Ok(page)
312 }
313
314 #[tracing::instrument(
315 name = "db.user_email.count",
316 skip_all,
317 fields(
318 db.query.text,
319 ),
320 )]
321 async fn count(&mut self, filter: UserEmailFilter<'_>) -> Result<usize, Self::Error> {
322 let (sql, arguments) = Query::select()
323 .expr(Expr::col((UserEmails::Table, UserEmails::UserEmailId)).count())
324 .from(UserEmails::Table)
325 .apply_filter(filter)
326 .build_sqlx(PostgresQueryBuilder);
327
328 let count: i64 = sqlx::query_scalar_with(&sql, arguments)
329 .traced()
330 .fetch_one(&mut *self.conn)
331 .await?;
332
333 count
334 .try_into()
335 .map_err(DatabaseError::to_invalid_operation)
336 }
337
338 #[tracing::instrument(
339 name = "db.user_email.add",
340 skip_all,
341 fields(
342 db.query.text,
343 %user.id,
344 user_email.id,
345 user_email.email = email,
346 ),
347 )]
348 async fn add(
349 &mut self,
350 rng: &mut (dyn Rng + Send),
351 clock: &dyn Clock,
352 user: &User,
353 email: String,
354 ) -> Result<UserEmail, Self::Error> {
355 let created_at = clock.now();
356 let id = Ulid::from_datetime_with_rng(created_at, rng);
357 tracing::Span::current().record("user_email.id", tracing::field::display(id));
358
359 sqlx::query!(
360 r#"
361 INSERT INTO user_emails (user_email_id, user_id, email, created_at)
362 VALUES ($1, $2, $3, $4)
363 "#,
364 Uuid::from(id),
365 Uuid::from(user.id),
366 &email,
367 created_at,
368 )
369 .traced()
370 .execute(&mut *self.conn)
371 .await?;
372
373 Ok(UserEmail {
374 id,
375 user_id: user.id,
376 email,
377 created_at,
378 })
379 }
380
381 #[tracing::instrument(
382 name = "db.user_email.remove",
383 skip_all,
384 fields(
385 db.query.text,
386 user.id = %user_email.user_id,
387 %user_email.id,
388 %user_email.email,
389 ),
390 )]
391 async fn remove(&mut self, user_email: UserEmail) -> Result<(), Self::Error> {
392 let res = sqlx::query!(
393 r#"
394 DELETE FROM user_emails
395 WHERE user_email_id = $1
396 "#,
397 Uuid::from(user_email.id),
398 )
399 .traced()
400 .execute(&mut *self.conn)
401 .await?;
402
403 DatabaseError::ensure_affected_rows(&res, 1)?;
404
405 Ok(())
406 }
407
408 #[tracing::instrument(
409 name = "db.user_email.remove_bulk",
410 skip_all,
411 fields(
412 db.query.text,
413 ),
414 )]
415 async fn remove_bulk(&mut self, filter: UserEmailFilter<'_>) -> Result<usize, Self::Error> {
416 let (sql, arguments) = Query::delete()
417 .from_table(UserEmails::Table)
418 .apply_filter(filter)
419 .build_sqlx(PostgresQueryBuilder);
420
421 let res = sqlx::query_with(&sql, arguments)
422 .traced()
423 .execute(&mut *self.conn)
424 .await?;
425
426 Ok(res.rows_affected().try_into().unwrap_or(usize::MAX))
427 }
428
429 #[tracing::instrument(
430 name = "db.user_email.add_authentication_for_session",
431 skip_all,
432 fields(
433 db.query.text,
434 %session.id,
435 user_email_authentication.id,
436 user_email_authentication.email = email,
437 ),
438 )]
439 async fn add_authentication_for_session(
440 &mut self,
441 rng: &mut (dyn Rng + Send),
442 clock: &dyn Clock,
443 email: String,
444 session: &BrowserSession,
445 ) -> Result<UserEmailAuthentication, Self::Error> {
446 let created_at = clock.now();
447 let id = Ulid::from_datetime_with_rng(created_at, rng);
448 tracing::Span::current()
449 .record("user_email_authentication.id", tracing::field::display(id));
450
451 sqlx::query!(
452 r#"
453 INSERT INTO user_email_authentications
454 ( user_email_authentication_id
455 , user_session_id
456 , email
457 , created_at
458 )
459 VALUES ($1, $2, $3, $4)
460 "#,
461 Uuid::from(id),
462 Uuid::from(session.id),
463 &email,
464 created_at,
465 )
466 .traced()
467 .execute(&mut *self.conn)
468 .await?;
469
470 Ok(UserEmailAuthentication {
471 id,
472 user_session_id: Some(session.id),
473 user_registration_id: None,
474 email,
475 created_at,
476 completed_at: None,
477 })
478 }
479
480 #[tracing::instrument(
481 name = "db.user_email.add_authentication_for_registration",
482 skip_all,
483 fields(
484 db.query.text,
485 %user_registration.id,
486 user_email_authentication.id,
487 user_email_authentication.email = email,
488 ),
489 )]
490 async fn add_authentication_for_registration(
491 &mut self,
492 rng: &mut (dyn Rng + Send),
493 clock: &dyn Clock,
494 email: String,
495 user_registration: &UserRegistration,
496 ) -> Result<UserEmailAuthentication, Self::Error> {
497 let created_at = clock.now();
498 let id = Ulid::from_datetime_with_rng(created_at, rng);
499 tracing::Span::current()
500 .record("user_email_authentication.id", tracing::field::display(id));
501
502 sqlx::query!(
503 r#"
504 INSERT INTO user_email_authentications
505 ( user_email_authentication_id
506 , user_registration_id
507 , email
508 , created_at
509 )
510 VALUES ($1, $2, $3, $4)
511 "#,
512 Uuid::from(id),
513 Uuid::from(user_registration.id),
514 &email,
515 created_at,
516 )
517 .traced()
518 .execute(&mut *self.conn)
519 .await?;
520
521 Ok(UserEmailAuthentication {
522 id,
523 user_session_id: None,
524 user_registration_id: Some(user_registration.id),
525 email,
526 created_at,
527 completed_at: None,
528 })
529 }
530
531 #[tracing::instrument(
532 name = "db.user_email.add_authentication_code",
533 skip_all,
534 fields(
535 db.query.text,
536 %user_email_authentication.id,
537 %user_email_authentication.email,
538 user_email_authentication_code.id,
539 user_email_authentication_code.code = code,
540 ),
541 )]
542 async fn add_authentication_code(
543 &mut self,
544 rng: &mut (dyn Rng + Send),
545 clock: &dyn Clock,
546 duration: chrono::Duration,
547 user_email_authentication: &UserEmailAuthentication,
548 code: String,
549 ) -> Result<UserEmailAuthenticationCode, Self::Error> {
550 let created_at = clock.now();
551 let expires_at = created_at + duration;
552 let id = Ulid::from_datetime_with_rng(created_at, rng);
553 tracing::Span::current().record(
554 "user_email_authentication_code.id",
555 tracing::field::display(id),
556 );
557
558 sqlx::query!(
559 r#"
560 INSERT INTO user_email_authentication_codes
561 ( user_email_authentication_code_id
562 , user_email_authentication_id
563 , code
564 , created_at
565 , expires_at
566 )
567 VALUES ($1, $2, $3, $4, $5)
568 "#,
569 Uuid::from(id),
570 Uuid::from(user_email_authentication.id),
571 &code,
572 created_at,
573 expires_at,
574 )
575 .traced()
576 .execute(&mut *self.conn)
577 .await?;
578
579 Ok(UserEmailAuthenticationCode {
580 id,
581 user_email_authentication_id: user_email_authentication.id,
582 code,
583 created_at,
584 expires_at,
585 })
586 }
587
588 #[tracing::instrument(
589 name = "db.user_email.lookup_authentication",
590 skip_all,
591 fields(
592 db.query.text,
593 user_email_authentication.id = %id,
594 ),
595 )]
596 async fn lookup_authentication(
597 &mut self,
598 id: Ulid,
599 ) -> Result<Option<UserEmailAuthentication>, Self::Error> {
600 let res = sqlx::query_as!(
601 UserEmailAuthenticationLookup,
602 r#"
603 SELECT user_email_authentication_id
604 , user_session_id
605 , user_registration_id
606 , email
607 , created_at
608 , completed_at
609 FROM user_email_authentications
610 WHERE user_email_authentication_id = $1
611 "#,
612 Uuid::from(id),
613 )
614 .traced()
615 .fetch_optional(&mut *self.conn)
616 .await?;
617
618 Ok(res.map(UserEmailAuthentication::from))
619 }
620
621 #[tracing::instrument(
622 name = "db.user_email.find_authentication_by_code",
623 skip_all,
624 fields(
625 db.query.text,
626 %authentication.id,
627 user_email_authentication_code.code = code,
628 ),
629 )]
630 async fn find_authentication_code(
631 &mut self,
632 authentication: &UserEmailAuthentication,
633 code: &str,
634 ) -> Result<Option<UserEmailAuthenticationCode>, Self::Error> {
635 let res = sqlx::query_as!(
636 UserEmailAuthenticationCodeLookup,
637 r#"
638 SELECT user_email_authentication_code_id
639 , user_email_authentication_id
640 , code
641 , created_at
642 , expires_at
643 FROM user_email_authentication_codes
644 WHERE user_email_authentication_id = $1
645 AND code = $2
646 "#,
647 Uuid::from(authentication.id),
648 code,
649 )
650 .traced()
651 .fetch_optional(&mut *self.conn)
652 .await?;
653
654 Ok(res.map(UserEmailAuthenticationCode::from))
655 }
656
657 #[tracing::instrument(
658 name = "db.user_email.complete_email_authentication_with_code",
659 skip_all,
660 fields(
661 db.query.text,
662 %user_email_authentication.id,
663 %user_email_authentication.email,
664 %user_email_authentication_code.id,
665 %user_email_authentication_code.code,
666 ),
667 )]
668 async fn complete_authentication_with_code(
669 &mut self,
670 clock: &dyn Clock,
671 mut user_email_authentication: UserEmailAuthentication,
672 user_email_authentication_code: &UserEmailAuthenticationCode,
673 ) -> Result<UserEmailAuthentication, Self::Error> {
674 let completed_at = clock.now();
678
679 let res = sqlx::query!(
683 r#"
684 UPDATE user_email_authentications
685 SET completed_at = $2
686 WHERE user_email_authentication_id = $1
687 AND completed_at IS NULL
688 "#,
689 Uuid::from(user_email_authentication.id),
690 completed_at,
691 )
692 .traced()
693 .execute(&mut *self.conn)
694 .await?;
695
696 DatabaseError::ensure_affected_rows(&res, 1)?;
697
698 user_email_authentication.completed_at = Some(completed_at);
699 Ok(user_email_authentication)
700 }
701
702 #[tracing::instrument(
703 name = "db.user_email.complete_email_authentication_with_upstream",
704 skip_all,
705 fields(
706 db.query.text,
707 %user_email_authentication.id,
708 %user_email_authentication.email,
709 %upstream_oauth_authorization_session.id,
710 ),
711 )]
712 async fn complete_authentication_with_upstream(
713 &mut self,
714 clock: &dyn Clock,
715 mut user_email_authentication: UserEmailAuthentication,
716 upstream_oauth_authorization_session: &UpstreamOAuthAuthorizationSession,
717 ) -> Result<UserEmailAuthentication, Self::Error> {
718 let completed_at = clock.now();
722
723 let res = sqlx::query!(
727 r#"
728 UPDATE user_email_authentications
729 SET completed_at = $2
730 WHERE user_email_authentication_id = $1
731 AND completed_at IS NULL
732 "#,
733 Uuid::from(user_email_authentication.id),
734 completed_at,
735 )
736 .traced()
737 .execute(&mut *self.conn)
738 .await?;
739
740 DatabaseError::ensure_affected_rows(&res, 1)?;
741
742 user_email_authentication.completed_at = Some(completed_at);
743 Ok(user_email_authentication)
744 }
745
746 #[tracing::instrument(
747 name = "db.user_email.cleanup_authentications",
748 skip_all,
749 fields(
750 db.query.text,
751 since = since.map(tracing::field::display),
752 until = %until,
753 limit = limit,
754 ),
755 )]
756 async fn cleanup_authentications(
757 &mut self,
758 since: Option<Ulid>,
759 until: Ulid,
760 limit: usize,
761 ) -> Result<(usize, Option<Ulid>), Self::Error> {
762 let res = sqlx::query_scalar!(
766 r#"
767 WITH
768 to_delete AS (
769 SELECT user_email_authentication_id
770 FROM user_email_authentications
771 WHERE ($1::uuid IS NULL OR user_email_authentication_id > $1)
772 AND user_email_authentication_id <= $2
773 ORDER BY user_email_authentication_id
774 LIMIT $3
775 ),
776 deleted_codes AS (
777 DELETE FROM user_email_authentication_codes
778 USING to_delete
779 WHERE user_email_authentication_codes.user_email_authentication_id = to_delete.user_email_authentication_id
780 RETURNING user_email_authentication_codes.user_email_authentication_code_id
781 )
782 DELETE FROM user_email_authentications
783 USING to_delete
784 WHERE user_email_authentications.user_email_authentication_id = to_delete.user_email_authentication_id
785 RETURNING user_email_authentications.user_email_authentication_id
786 "#,
787 since.map(Uuid::from),
788 Uuid::from(until),
789 i64::try_from(limit).unwrap_or(i64::MAX)
790 )
791 .traced()
792 .fetch_all(&mut *self.conn)
793 .await?;
794
795 let count = res.len();
796 let max_id = res.into_iter().max();
797
798 Ok((count, max_id.map(Ulid::from)))
799 }
800}