diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index e0e1dc23..dde4a799 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -14,14 +14,14 @@ use async_graphql::{ connection::{query, Connection, Edge, OpaqueCursor}, - Context, Description, Object, ID, + Context, Description, Enum, Object, ID, }; use chrono::{DateTime, Utc}; use mas_storage::{ compat::{CompatSessionFilter, CompatSsoLoginFilter, CompatSsoLoginRepository}, oauth2::OAuth2SessionRepository, upstream_oauth2::UpstreamOAuthLinkRepository, - user::{BrowserSessionFilter, BrowserSessionRepository, UserEmailRepository}, + user::{BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository}, Pagination, RepositoryAccess, }; @@ -300,13 +300,16 @@ impl User { &self, ctx: &Context<'_>, + #[graphql(name = "state", desc = "List only emails in the given state.")] + state_param: Option, + #[graphql(desc = "Returns the elements in the list that come after the cursor.")] after: Option, #[graphql(desc = "Returns the elements in the list that come before the cursor.")] before: Option, #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option, #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option, - ) -> Result, async_graphql::Error> { + ) -> Result, async_graphql::Error> { let state = ctx.state(); let mut repo = state.repository().await?; @@ -324,17 +327,29 @@ impl User { .transpose()?; let pagination = Pagination::try_new(before_id, after_id, first, last)?; - let page = repo - .user_email() - .list_paginated(&self.0, pagination) - .await?; + let filter = UserEmailFilter::new().for_user(&self.0); + + let filter = match state_param { + Some(UserEmailState::Pending) => filter.pending_only(), + Some(UserEmailState::Confirmed) => filter.verified_only(), + None => filter, + }; + + let page = repo.user_email().list(filter, pagination).await?; + + // Preload the total count if requested + let count = if ctx.look_ahead().field("totalCount").exists() { + Some(repo.user_email().count(filter).await?) + } else { + None + }; repo.cancel().await?; let mut connection = Connection::with_additional_fields( page.has_previous_page, page.has_next_page, - UserEmailsPagination(self.0.clone()), + PreloadedTotalCount(count), ); connection.edges.extend(page.edges.into_iter().map(|u| { Edge::new( @@ -493,16 +508,12 @@ impl UserEmail { } } -pub struct UserEmailsPagination(mas_data_model::User); +/// The state of a compatibility session. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum UserEmailState { + /// The email address is pending confirmation. + Pending, -#[Object] -impl UserEmailsPagination { - /// Identifies the total count of items in the connection. - async fn total_count(&self, ctx: &Context<'_>) -> Result { - let state = ctx.state(); - let mut repo = state.repository().await?; - let count = repo.user_email().count(&self.0).await?; - repo.cancel().await?; - Ok(count) - } + /// The email address has been confirmed. + Confirmed, } diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index b95c04d4..e3128179 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -31,6 +31,16 @@ pub enum Users { PrimaryUserEmailId, } +#[derive(sea_query::Iden)] +pub enum UserEmails { + Table, + UserEmailId, + UserId, + Email, + CreatedAt, + ConfirmedAt, +} + #[derive(sea_query::Iden)] pub enum CompatSessions { Table, diff --git a/crates/storage-pg/src/user/email.rs b/crates/storage-pg/src/user/email.rs index 2168efa0..a9a36314 100644 --- a/crates/storage-pg/src/user/email.rs +++ b/crates/storage-pg/src/user/email.rs @@ -15,15 +15,20 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{User, UserEmail, UserEmailVerification, UserEmailVerificationState}; -use mas_storage::{user::UserEmailRepository, Clock, Page, Pagination}; +use mas_storage::{ + user::{UserEmailFilter, UserEmailRepository}, + Clock, Page, Pagination, +}; use rand::RngCore; -use sqlx::{PgConnection, QueryBuilder}; +use sea_query::{enum_def, Expr, IntoColumnRef, PostgresQueryBuilder, Query}; +use sqlx::PgConnection; use tracing::{info_span, Instrument}; use ulid::Ulid; use uuid::Uuid; use crate::{ - pagination::QueryBuilderExt, tracing::ExecuteExt, DatabaseError, DatabaseInconsistencyError, + iden::UserEmails, pagination::QueryBuilderExt, sea_query_sqlx::map_values, tracing::ExecuteExt, + DatabaseError, DatabaseInconsistencyError, }; /// An implementation of [`UserEmailRepository`] for a PostgreSQL connection @@ -40,6 +45,7 @@ impl<'c> PgUserEmailRepository<'c> { } #[derive(Debug, Clone, sqlx::FromRow)] +#[enum_def] struct UserEmailLookup { user_email_id: Uuid, user_id: Uuid, @@ -225,42 +231,65 @@ impl<'c> UserEmailRepository for PgUserEmailRepository<'c> { } #[tracing::instrument( - name = "db.user_email.list_paginated", + name = "db.user_email.list", skip_all, fields( db.statement, - %user.id, ), err, )] - async fn list_paginated( + async fn list( &mut self, - user: &User, + filter: UserEmailFilter<'_>, pagination: Pagination, ) -> Result, DatabaseError> { - let mut query = QueryBuilder::new( - r#" - SELECT user_email_id - , user_id - , email - , created_at - , confirmed_at - FROM user_emails - "#, - ); + let (sql, values) = Query::select() + .expr_as( + Expr::col((UserEmails::Table, UserEmails::UserEmailId)), + UserEmailLookupIden::UserEmailId, + ) + .expr_as( + Expr::col((UserEmails::Table, UserEmails::UserId)), + UserEmailLookupIden::UserId, + ) + .expr_as( + Expr::col((UserEmails::Table, UserEmails::Email)), + UserEmailLookupIden::Email, + ) + .expr_as( + Expr::col((UserEmails::Table, UserEmails::CreatedAt)), + UserEmailLookupIden::CreatedAt, + ) + .expr_as( + Expr::col((UserEmails::Table, UserEmails::ConfirmedAt)), + UserEmailLookupIden::ConfirmedAt, + ) + .from(UserEmails::Table) + .and_where_option(filter.user().map(|user| { + Expr::col((UserEmails::Table, UserEmails::UserId)).eq(Uuid::from(user.id)) + })) + .and_where_option(filter.state().map(|state| { + if state.is_verified() { + Expr::col((UserEmails::Table, UserEmails::ConfirmedAt)).is_not_null() + } else { + Expr::col((UserEmails::Table, UserEmails::ConfirmedAt)).is_null() + } + })) + .generate_pagination( + (UserEmails::Table, UserEmails::UserEmailId).into_column_ref(), + pagination, + ) + .build(PostgresQueryBuilder); - query - .push(" WHERE user_id = ") - .push_bind(Uuid::from(user.id)) - .generate_pagination("user_email_id", pagination); + let arguments = map_values(values); - let edges: Vec = query - .build_query_as() + let edges: Vec = sqlx::query_as_with(&sql, arguments) .traced() .fetch_all(&mut *self.conn) .await?; let page = pagination.process(edges).map(UserEmail::from); + Ok(page) } @@ -269,28 +298,35 @@ impl<'c> UserEmailRepository for PgUserEmailRepository<'c> { skip_all, fields( db.statement, - %user.id, ), err, )] - async fn count(&mut self, user: &User) -> Result { - let res = sqlx::query_scalar!( - r#" - SELECT COUNT(*) - FROM user_emails - WHERE user_id = $1 - "#, - Uuid::from(user.id), - ) - .traced() - .fetch_one(&mut *self.conn) - .await?; + async fn count(&mut self, filter: UserEmailFilter<'_>) -> Result { + let (sql, values) = Query::select() + .expr(Expr::col((UserEmails::Table, UserEmails::UserEmailId)).count()) + .from(UserEmails::Table) + .and_where_option(filter.user().map(|user| { + Expr::col((UserEmails::Table, UserEmails::UserId)).eq(Uuid::from(user.id)) + })) + .and_where_option(filter.state().map(|state| { + if state.is_verified() { + Expr::col((UserEmails::Table, UserEmails::ConfirmedAt)).is_not_null() + } else { + Expr::col((UserEmails::Table, UserEmails::ConfirmedAt)).is_null() + } + })) + .build(PostgresQueryBuilder); - let res = res.unwrap_or_default(); + let arguments = map_values(values); - Ok(res + let count: i64 = sqlx::query_scalar_with(&sql, arguments) + .traced() + .fetch_one(&mut *self.conn) + .await?; + + count .try_into() - .map_err(DatabaseError::to_invalid_operation)?) + .map_err(DatabaseError::to_invalid_operation) } #[tracing::instrument( diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index de15280b..7036ecbd 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -16,7 +16,7 @@ use chrono::Duration; use mas_storage::{ clock::MockClock, user::{ - BrowserSessionFilter, BrowserSessionRepository, UserEmailRepository, + BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository, UserPasswordRepository, UserRepository, }, Pagination, Repository, RepositoryAccess, @@ -98,7 +98,14 @@ async fn test_user_email_repo(pool: PgPool) { .unwrap() .is_none()); - assert_eq!(repo.user_email().count(&user).await.unwrap(), 0); + let all = UserEmailFilter::new().for_user(&user); + let pending = all.pending_only(); + let verified = all.verified_only(); + + // Check the counts + assert_eq!(repo.user_email().count(all).await.unwrap(), 0); + assert_eq!(repo.user_email().count(pending).await.unwrap(), 0); + assert_eq!(repo.user_email().count(verified).await.unwrap(), 0); let user_email = repo .user_email() @@ -110,7 +117,10 @@ async fn test_user_email_repo(pool: PgPool) { assert_eq!(user_email.email, EMAIL); assert!(user_email.confirmed_at.is_none()); - assert_eq!(repo.user_email().count(&user).await.unwrap(), 1); + // Check the counts + assert_eq!(repo.user_email().count(all).await.unwrap(), 1); + assert_eq!(repo.user_email().count(pending).await.unwrap(), 1); + assert_eq!(repo.user_email().count(verified).await.unwrap(), 0); assert!(repo .user_email() @@ -181,6 +191,11 @@ async fn test_user_email_repo(pool: PgPool) { .await .unwrap(); + // Check the counts + assert_eq!(repo.user_email().count(all).await.unwrap(), 1); + assert_eq!(repo.user_email().count(pending).await.unwrap(), 0); + assert_eq!(repo.user_email().count(verified).await.unwrap(), 1); + // Reload the user_email let user_email = repo .user_email() @@ -236,16 +251,35 @@ async fn test_user_email_repo(pool: PgPool) { // Listing the user emails should work let emails = repo .user_email() - .list_paginated(&user, Pagination::first(10)) + .list(all, Pagination::first(10)) .await .unwrap(); assert!(!emails.has_next_page); assert_eq!(emails.edges.len(), 1); assert_eq!(emails.edges[0], user_email); + let emails = repo + .user_email() + .list(verified, Pagination::first(10)) + .await + .unwrap(); + assert!(!emails.has_next_page); + assert_eq!(emails.edges.len(), 1); + assert_eq!(emails.edges[0], user_email); + + let emails = repo + .user_email() + .list(pending, Pagination::first(10)) + .await + .unwrap(); + assert!(!emails.has_next_page); + assert!(emails.edges.is_empty()); + // Deleting the user email should work repo.user_email().remove(user_email).await.unwrap(); - assert_eq!(repo.user_email().count(&user).await.unwrap(), 0); + assert_eq!(repo.user_email().count(all).await.unwrap(), 0); + assert_eq!(repo.user_email().count(pending).await.unwrap(), 0); + assert_eq!(repo.user_email().count(verified).await.unwrap(), 0); // Reload the user let user = repo diff --git a/crates/storage/src/user/email.rs b/crates/storage/src/user/email.rs index 9ae81534..12628657 100644 --- a/crates/storage/src/user/email.rs +++ b/crates/storage/src/user/email.rs @@ -19,6 +19,76 @@ use ulid::Ulid; use crate::{pagination::Page, repository_impl, Clock, Pagination}; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UserEmailState { + Pending, + Verified, +} + +impl UserEmailState { + /// Returns true if the filter should only return non-verified emails + pub fn is_pending(self) -> bool { + matches!(self, Self::Pending) + } + + /// Returns true if the filter should only return verified emails + pub fn is_verified(self) -> bool { + matches!(self, Self::Verified) + } +} + +/// Filter parameters for listing user emails +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct UserEmailFilter<'a> { + user: Option<&'a User>, + state: Option, +} + +impl<'a> UserEmailFilter<'a> { + /// Create a new [`UserEmailFilter`] with default values + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Filter for emails of a specific user + #[must_use] + pub fn for_user(mut self, user: &'a User) -> Self { + self.user = Some(user); + self + } + + /// Get the user filter + /// + /// Returns [`None`] if no user filter is set + #[must_use] + pub fn user(&self) -> Option<&User> { + self.user + } + + /// Filter for emails that are verified + #[must_use] + pub fn verified_only(mut self) -> Self { + self.state = Some(UserEmailState::Verified); + self + } + + /// Filter for emails that are not verified + #[must_use] + pub fn pending_only(mut self) -> Self { + self.state = Some(UserEmailState::Pending); + self + } + + /// Get the state filter + /// + /// Returns [`None`] if no state filter is set + #[must_use] + pub fn state(&self) -> Option { + self.state + } +} + /// A [`UserEmailRepository`] helps interacting with [`UserEmail`] saved in the /// storage backend #[async_trait] @@ -77,32 +147,32 @@ pub trait UserEmailRepository: Send + Sync { /// Returns [`Self::Error`] if the underlying repository fails async fn all(&mut self, user: &User) -> Result, Self::Error>; - /// List [`UserEmail`] of a [`User`] with the given pagination + /// List [`UserEmail`] with the given filter and pagination /// /// # Parameters /// - /// * `user`: The [`User`] for whom to lookup the [`UserEmail`] + /// * `filter`: The filter parameters /// * `pagination`: The pagination parameters /// /// # Errors /// /// Returns [`Self::Error`] if the underlying repository fails - async fn list_paginated( + async fn list( &mut self, - user: &User, + filter: UserEmailFilter<'_>, pagination: Pagination, ) -> Result, Self::Error>; - /// Count the [`UserEmail`] of a [`User`] + /// Count the [`UserEmail`] with the given filter /// /// # Parameters /// - /// * `user`: The [`User`] for whom to count the [`UserEmail`] + /// * `filter`: The filter parameters /// /// # Errors /// /// Returns [`Self::Error`] if the underlying repository fails - async fn count(&mut self, user: &User) -> Result; + async fn count(&mut self, filter: UserEmailFilter<'_>) -> Result; /// Create a new [`UserEmail`] for a [`User`] /// @@ -235,12 +305,12 @@ repository_impl!(UserEmailRepository: async fn get_primary(&mut self, user: &User) -> Result, Self::Error>; async fn all(&mut self, user: &User) -> Result, Self::Error>; - async fn list_paginated( + async fn list( &mut self, - user: &User, + filter: UserEmailFilter<'_>, pagination: Pagination, ) -> Result, Self::Error>; - async fn count(&mut self, user: &User) -> Result; + async fn count(&mut self, filter: UserEmailFilter<'_>) -> Result; async fn add( &mut self, diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index 9a3da3d6..65709ecf 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -26,7 +26,7 @@ mod password; mod session; pub use self::{ - email::UserEmailRepository, + email::{UserEmailFilter, UserEmailRepository}, password::UserPasswordRepository, session::{BrowserSessionFilter, BrowserSessionRepository}, }; diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 6a2af24f..4c368dfa 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -937,6 +937,7 @@ type User implements Node { Get the list of emails, chronologically sorted """ emails( + state: UserEmailState after: String before: String first: Int @@ -1018,6 +1019,20 @@ type UserEmailEdge { cursor: String! } +""" +The state of a compatibility session. +""" +enum UserEmailState { + """ + The email address is pending confirmation. + """ + PENDING + """ + The email address has been confirmed. + """ + CONFIRMED +} + """ The input for the `verifyEmail` mutation """ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 27f00a45..2fa201d4 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -727,6 +727,7 @@ export type UserEmailsArgs = { before?: InputMaybe; first?: InputMaybe; last?: InputMaybe; + state?: InputMaybe; }; /** A user is an individual's account. */ @@ -783,6 +784,14 @@ export type UserEmailEdge = { node: UserEmail; }; +/** The state of a compatibility session. */ +export enum UserEmailState { + /** The email address has been confirmed. */ + Confirmed = "CONFIRMED", + /** The email address is pending confirmation. */ + Pending = "PENDING", +} + /** The input for the `verifyEmail` mutation */ export type VerifyEmailInput = { /** The verification code */ diff --git a/frontend/src/gql/schema.ts b/frontend/src/gql/schema.ts index 382741bc..c98fe589 100644 --- a/frontend/src/gql/schema.ts +++ b/frontend/src/gql/schema.ts @@ -2147,6 +2147,13 @@ export default { name: "Any", }, }, + { + name: "state", + type: { + kind: "SCALAR", + name: "Any", + }, + }, ], }, {