From 49dff8ec79da198312b64ebe166b272b0d6217bb Mon Sep 17 00:00:00 2001 From: paulobressan Date: Mon, 2 Sep 2024 16:17:18 -0300 Subject: [PATCH] feat: implemented accept project invite domain --- src/domain/event/mod.rs | 17 +++- src/domain/project/cache.rs | 4 +- src/domain/project/command.rs | 164 +++++++++++++++++++++++++++++++-- src/domain/project/mod.rs | 41 ++++++++- src/domain/resource/command.rs | 5 +- src/domain/usage/command.rs | 3 +- src/driven/cache/project.rs | 6 +- src/drivers/cache/mod.rs | 3 + 8 files changed, 228 insertions(+), 15 deletions(-) diff --git a/src/domain/event/mod.rs b/src/domain/event/mod.rs index 3771e75..246b608 100644 --- a/src/domain/event/mod.rs +++ b/src/domain/event/mod.rs @@ -66,12 +66,23 @@ pub struct ProjectUserInviteCreated { pub id: String, pub project_id: String, pub email: String, + pub role: String, pub code: String, pub expire_in: DateTime, pub created_at: DateTime, } into_event!(ProjectUserInviteCreated); +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectUserInviteAccepted { + pub id: String, + pub project_id: String, + pub user_id: String, + pub role: String, + pub created_at: DateTime, +} +into_event!(ProjectUserInviteAccepted); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceCreated { pub id: String, @@ -124,13 +135,13 @@ into_event!(UsageCreated); #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] -#[allow(clippy::enum_variant_names)] pub enum Event { ProjectCreated(ProjectCreated), ProjectUpdated(ProjectUpdated), ProjectDeleted(ProjectDeleted), ProjectSecretCreated(ProjectSecretCreated), ProjectUserInviteCreated(ProjectUserInviteCreated), + ProjectUserInviteAccepted(ProjectUserInviteAccepted), ResourceCreated(ResourceCreated), ResourceUpdated(ResourceUpdated), ResourceDeleted(ResourceDeleted), @@ -144,6 +155,7 @@ impl Event { Event::ProjectDeleted(_) => "ProjectDeleted".into(), Event::ProjectSecretCreated(_) => "ProjectSecretCreated".into(), Event::ProjectUserInviteCreated(_) => "ProjectUserInviteCreated".into(), + Event::ProjectUserInviteAccepted(_) => "ProjectUserInviteAccepted".into(), Event::ResourceCreated(_) => "ResourceCreated".into(), Event::ResourceUpdated(_) => "ResourceUpdated".into(), Event::ResourceDeleted(_) => "ResourceDeleted".into(), @@ -161,6 +173,9 @@ impl Event { "ProjectUserInviteCreated" => Ok(Self::ProjectUserInviteCreated( serde_json::from_slice(payload)?, )), + "ProjectUserInviteAccepted" => Ok(Self::ProjectUserInviteAccepted( + serde_json::from_slice(payload)?, + )), "ResourceCreated" => Ok(Self::ResourceCreated(serde_json::from_slice(payload)?)), "ResourceUpdated" => Ok(Self::ResourceUpdated(serde_json::from_slice(payload)?)), "ResourceDeleted" => Ok(Self::ResourceDeleted(serde_json::from_slice(payload)?)), diff --git a/src/domain/project/cache.rs b/src/domain/project/cache.rs index 3c904cc..864241e 100644 --- a/src/domain/project/cache.rs +++ b/src/domain/project/cache.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use crate::domain::event::{ProjectCreated, ProjectDeleted, ProjectSecretCreated, ProjectUpdated}; use crate::domain::Result; -use super::{Project, ProjectSecret, ProjectUpdate, ProjectUser}; +use super::{Project, ProjectSecret, ProjectUpdate, ProjectUser, ProjectUserInvite}; #[async_trait::async_trait] pub trait ProjectDrivenCache: Send + Sync { @@ -21,6 +21,7 @@ pub trait ProjectDrivenCache: Send + Sync { user_id: &str, project_id: &str, ) -> Result>; + async fn find_user_invite_by_code(&self, code: &str) -> Result>; } pub async fn create(cache: Arc, evt: ProjectCreated) -> Result<()> { @@ -62,6 +63,7 @@ mod tests { async fn create_secret(&self, secret: &ProjectSecret) -> Result<()>; async fn find_secret_by_project_id(&self, project_id: &str) -> Result>; async fn find_user_permission(&self,user_id: &str, project_id: &str) -> Result>; + async fn find_user_invite_by_code(&self, code: &str) -> Result>; } } diff --git a/src/domain/project/command.rs b/src/domain/project/command.rs index 7d9fcce..7a7866a 100644 --- a/src/domain/project/command.rs +++ b/src/domain/project/command.rs @@ -16,13 +16,16 @@ use crate::domain::{ error::Error, event::{ EventDrivenBridge, ProjectCreated, ProjectDeleted, ProjectSecretCreated, ProjectUpdated, - ProjectUserInviteCreated, + ProjectUserInviteAccepted, ProjectUserInviteCreated, }, project::ProjectStatus, utils, Result, MAX_SECRET, PAGE_SIZE_DEFAULT, PAGE_SIZE_MAX, }; -use super::{cache::ProjectDrivenCache, Project, ProjectEmailDriven, ProjectSecret, StripeDriven}; +use super::{ + cache::ProjectDrivenCache, Project, ProjectEmailDriven, ProjectSecret, ProjectUserRole, + StripeDriven, +}; pub async fn fetch(cache: Arc, cmd: FetchCmd) -> Result> { let user_id = assert_credential(&cmd.credential)?; @@ -244,8 +247,9 @@ pub async fn create_user_invite( id: cmd.id, project_id: project.id, email: cmd.email, + role: cmd.role.to_string(), code, - expire_in: expire_in.clone(), + expire_in, created_at: Utc::now(), }; @@ -255,6 +259,39 @@ pub async fn create_user_invite( Ok(()) } +pub async fn accept_user_invite( + cache: Arc, + auth0: Arc, + event: Arc, + cmd: AcceptUserInviteCmd, +) -> Result<()> { + let user_id = assert_credential(&cmd.credential)?; + + let Some(user_invite) = cache.find_user_invite_by_code(&cmd.code).await? else { + return Err(Error::CommandMalformed("invalid invite code".into())); + }; + + let (_, email) = auth0.find_info(&user_id).await?; + if user_invite.email != email { + return Err(Error::CommandMalformed( + "user email doesnt match with invite".into(), + )); + } + + let evt = ProjectUserInviteAccepted { + id: cmd.id, + project_id: user_invite.project_id, + user_id, + role: user_invite.role.to_string(), + created_at: Utc::now(), + }; + + event.dispatch(evt.into()).await?; + info!("new project invite accepted"); + + Ok(()) +} + fn assert_credential(credential: &Credential) -> Result { match credential { Credential::Auth0(user_id) => Ok(user_id.into()), @@ -387,17 +424,43 @@ pub struct CreateUserInviteCmd { pub id: String, pub project_id: String, pub email: String, + pub role: ProjectUserRole, } impl CreateUserInviteCmd { - pub fn new(credential: Credential, ttl: Duration, project_id: String, email: String) -> Self { + pub fn try_new( + credential: Credential, + ttl: Duration, + project_id: String, + email: String, + role: ProjectUserRole, + ) -> Result { let id = Uuid::new_v4().to_string(); - Self { + Ok(Self { credential, ttl, id, project_id, email, + role, + }) + } +} + +#[derive(Debug, Clone)] +pub struct AcceptUserInviteCmd { + pub credential: Credential, + pub id: String, + pub code: String, +} +impl AcceptUserInviteCmd { + pub fn new(credential: Credential, code: String) -> Self { + let id = Uuid::new_v4().to_string(); + + Self { + credential, + id, + code, } } } @@ -411,7 +474,7 @@ mod tests { use super::*; use crate::domain::{ event::Event, - project::{ProjectUpdate, ProjectUser}, + project::{ProjectUpdate, ProjectUser, ProjectUserInvite}, tests::{INVALID_HRP_KEY, INVALID_KEY, KEY, SECRET}, }; @@ -429,6 +492,7 @@ mod tests { async fn create_secret(&self, secret: &ProjectSecret) -> Result<()>; async fn find_secret_by_project_id(&self, project_id: &str) -> Result>; async fn find_user_permission(&self,user_id: &str, project_id: &str) -> Result>; + async fn find_user_invite_by_code(&self, code: &str) -> Result>; } } @@ -524,6 +588,16 @@ mod tests { id: Uuid::new_v4().to_string(), project_id: Uuid::new_v4().to_string(), email: "p@txpipe.io".into(), + role: ProjectUserRole::Owner, + } + } + } + impl Default for AcceptUserInviteCmd { + fn default() -> Self { + Self { + credential: Credential::Auth0("user id".into()), + id: Uuid::new_v4().to_string(), + code: "123".into(), } } } @@ -862,4 +936,82 @@ mod tests { create_user_invite(Arc::new(cache), Arc::new(email), Arc::new(event), cmd).await; assert!(result.is_err()); } + + #[tokio::test] + async fn it_should_accept_project_user_invite() { + let invite = ProjectUserInvite::default(); + let invite_email = invite.email.clone(); + + let mut cache = MockFakeProjectDrivenCache::new(); + cache + .expect_find_user_invite_by_code() + .return_once(|_| Ok(Some(invite))); + + let mut auth0 = MockFakeAuth0Driven::new(); + auth0 + .expect_find_info() + .return_once(|_| Ok(("user name".into(), invite_email))); + + let mut event = MockFakeEventDrivenBridge::new(); + event.expect_dispatch().return_once(|_| Ok(())); + + let cmd = AcceptUserInviteCmd::default(); + + let result = + accept_user_invite(Arc::new(cache), Arc::new(auth0), Arc::new(event), cmd).await; + + assert!(result.is_ok()); + } + #[tokio::test] + async fn it_should_fail_accept_project_user_invite_when_invalid_code() { + let mut cache = MockFakeProjectDrivenCache::new(); + cache + .expect_find_user_invite_by_code() + .return_once(|_| Ok(None)); + + let auth0 = MockFakeAuth0Driven::new(); + let event = MockFakeEventDrivenBridge::new(); + + let cmd = AcceptUserInviteCmd::default(); + + let result = + accept_user_invite(Arc::new(cache), Arc::new(auth0), Arc::new(event), cmd).await; + assert!(result.is_err()); + } + #[tokio::test] + async fn it_should_fail_accept_project_user_invite_when_invalid_credential() { + let cache = MockFakeProjectDrivenCache::new(); + let auth0 = MockFakeAuth0Driven::new(); + let event = MockFakeEventDrivenBridge::new(); + + let cmd = AcceptUserInviteCmd { + credential: Credential::ApiKey("xxxx".into()), + ..Default::default() + }; + + let result = + accept_user_invite(Arc::new(cache), Arc::new(auth0), Arc::new(event), cmd).await; + assert!(result.is_err()); + } + #[tokio::test] + async fn it_should_fail_accept_project_user_invite_when_email_doesnt_match() { + let mut cache = MockFakeProjectDrivenCache::new(); + cache + .expect_find_user_invite_by_code() + .return_once(|_| Ok(Some(ProjectUserInvite::default()))); + + let mut auth0 = MockFakeAuth0Driven::new(); + auth0 + .expect_find_info() + .return_once(|_| Ok(("user name".into(), "user email".into()))); + + let event = MockFakeEventDrivenBridge::new(); + + let cmd = AcceptUserInviteCmd::default(); + + let result = + accept_user_invite(Arc::new(cache), Arc::new(auth0), Arc::new(event), cmd).await; + + assert!(result.is_err()); + } } diff --git a/src/domain/project/mod.rs b/src/domain/project/mod.rs index ce3daae..ef78f47 100644 --- a/src/domain/project/mod.rs +++ b/src/domain/project/mod.rs @@ -136,20 +136,24 @@ pub struct ProjectUserInvite { pub id: String, pub project_id: String, pub email: String, + pub role: ProjectUserRole, pub code: String, pub expire_in: DateTime, pub created_at: DateTime, } -impl From for ProjectUserInvite { - fn from(value: ProjectUserInviteCreated) -> Self { - Self { +impl TryFrom for ProjectUserInvite { + type Error = Error; + + fn try_from(value: ProjectUserInviteCreated) -> std::result::Result { + Ok(Self { id: value.id, project_id: value.project_id, email: value.email, + role: value.role.parse()?, code: value.code, expire_in: value.expire_in, created_at: value.created_at, - } + }) } } @@ -160,6 +164,34 @@ pub struct ProjectUser { pub created_at: DateTime, } +#[derive(Debug, Clone)] +pub enum ProjectUserRole { + Owner, + Member, +} +impl FromStr for ProjectUserRole { + type Err = Error; + + fn from_str(s: &str) -> std::result::Result { + match s { + "owner" => Ok(ProjectUserRole::Owner), + "member" => Ok(ProjectUserRole::Member), + _ => Err(Error::Unexpected(format!( + "project user role not supported: {}", + s + ))), + } + } +} +impl Display for ProjectUserRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProjectUserRole::Owner => write!(f, "owner"), + ProjectUserRole::Member => write!(f, "member"), + } + } +} + #[cfg(test)] mod tests { use std::time::Duration; @@ -213,6 +245,7 @@ mod tests { id: Uuid::new_v4().to_string(), project_id: Uuid::new_v4().to_string(), email: "p@txpipe.io".into(), + role: ProjectUserRole::Owner, code: "123".into(), expire_in: Utc::now() + Duration::from_secs(15 * 60), created_at: Utc::now(), diff --git a/src/domain/resource/command.rs b/src/domain/resource/command.rs index e58cb50..6c2a386 100644 --- a/src/domain/resource/command.rs +++ b/src/domain/resource/command.rs @@ -298,7 +298,9 @@ mod tests { use crate::domain::event::Event; use crate::domain::metadata::tests::mock_crd; - use crate::domain::project::{Project, ProjectSecret, ProjectUpdate, ProjectUser}; + use crate::domain::project::{ + Project, ProjectSecret, ProjectUpdate, ProjectUser, ProjectUserInvite, + }; use crate::domain::resource::ResourceUpdate; use super::*; @@ -317,6 +319,7 @@ mod tests { async fn create_secret(&self, secret: &ProjectSecret) -> Result<()>; async fn find_secret_by_project_id(&self, project_id: &str) -> Result>; async fn find_user_permission(&self,user_id: &str, project_id: &str) -> Result>; + async fn find_user_invite_by_code(&self, code: &str) -> Result>; } } diff --git a/src/domain/usage/command.rs b/src/domain/usage/command.rs index f24b524..ae05613 100644 --- a/src/domain/usage/command.rs +++ b/src/domain/usage/command.rs @@ -61,7 +61,7 @@ mod tests { use super::*; use crate::domain::{ - project::{Project, ProjectSecret, ProjectUpdate, ProjectUser}, + project::{Project, ProjectSecret, ProjectUpdate, ProjectUser, ProjectUserInvite}, usage::Usage, }; @@ -79,6 +79,7 @@ mod tests { async fn create_secret(&self, secret: &ProjectSecret) -> Result<()>; async fn find_secret_by_project_id(&self, project_id: &str) -> Result>; async fn find_user_permission(&self,user_id: &str, project_id: &str) -> Result>; + async fn find_user_invite_by_code(&self, code: &str) -> Result>; } } diff --git a/src/driven/cache/project.rs b/src/driven/cache/project.rs index ff4d48a..cbf8249 100644 --- a/src/driven/cache/project.rs +++ b/src/driven/cache/project.rs @@ -6,7 +6,7 @@ use crate::domain::{ error::Error, project::{ cache::ProjectDrivenCache, Project, ProjectSecret, ProjectStatus, ProjectUpdate, - ProjectUser, + ProjectUser, ProjectUserInvite, }, resource::ResourceStatus, Result, @@ -323,6 +323,10 @@ impl ProjectDrivenCache for SqliteProjectDrivenCache { Ok(project_user) } + + async fn find_user_invite_by_code(&self, code: &str) -> Result> { + todo!() + } } impl FromRow<'_, SqliteRow> for Project { diff --git a/src/drivers/cache/mod.rs b/src/drivers/cache/mod.rs index eb99808..6333973 100644 --- a/src/drivers/cache/mod.rs +++ b/src/drivers/cache/mod.rs @@ -56,6 +56,9 @@ pub async fn subscribe(config: CacheConfig) -> Result<()> { Event::ProjectUserInviteCreated(_evt) => { todo!() } + Event::ProjectUserInviteAccepted(_evt) => { + todo!() + } Event::ResourceCreated(evt) => { resource::cache::create(resource_cache.clone(), evt.clone()).await }