Skip to content

Commit

Permalink
feat: implemented accept project invite domain
Browse files Browse the repository at this point in the history
  • Loading branch information
paulobressan committed Sep 2, 2024
1 parent 1c6fa2b commit 49dff8e
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 15 deletions.
17 changes: 16 additions & 1 deletion src/domain/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utc>,
pub created_at: DateTime<Utc>,
}
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<Utc>,
}
into_event!(ProjectUserInviteAccepted);

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceCreated {
pub id: String,
Expand Down Expand Up @@ -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),
Expand All @@ -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(),
Expand All @@ -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)?)),
Expand Down
4 changes: 3 additions & 1 deletion src/domain/project/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,6 +21,7 @@ pub trait ProjectDrivenCache: Send + Sync {
user_id: &str,
project_id: &str,
) -> Result<Option<ProjectUser>>;
async fn find_user_invite_by_code(&self, code: &str) -> Result<Option<ProjectUserInvite>>;
}

pub async fn create(cache: Arc<dyn ProjectDrivenCache>, evt: ProjectCreated) -> Result<()> {
Expand Down Expand Up @@ -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<Vec<ProjectSecret>>;
async fn find_user_permission(&self,user_id: &str, project_id: &str) -> Result<Option<ProjectUser>>;
async fn find_user_invite_by_code(&self, code: &str) -> Result<Option<ProjectUserInvite>>;
}
}

Expand Down
164 changes: 158 additions & 6 deletions src/domain/project/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn ProjectDrivenCache>, cmd: FetchCmd) -> Result<Vec<Project>> {
let user_id = assert_credential(&cmd.credential)?;
Expand Down Expand Up @@ -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(),
};

Expand All @@ -255,6 +259,39 @@ pub async fn create_user_invite(
Ok(())
}

pub async fn accept_user_invite(
cache: Arc<dyn ProjectDrivenCache>,
auth0: Arc<dyn Auth0Driven>,
event: Arc<dyn EventDrivenBridge>,
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<UserId> {
match credential {
Credential::Auth0(user_id) => Ok(user_id.into()),
Expand Down Expand Up @@ -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<Self> {
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,
}
}
}
Expand All @@ -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},
};

Expand All @@ -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<Vec<ProjectSecret>>;
async fn find_user_permission(&self,user_id: &str, project_id: &str) -> Result<Option<ProjectUser>>;
async fn find_user_invite_by_code(&self, code: &str) -> Result<Option<ProjectUserInvite>>;
}
}

Expand Down Expand Up @@ -524,6 +588,16 @@ mod tests {
id: Uuid::new_v4().to_string(),
project_id: Uuid::new_v4().to_string(),
email: "[email protected]".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(),
}
}
}
Expand Down Expand Up @@ -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());
}
}
41 changes: 37 additions & 4 deletions src/domain/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utc>,
pub created_at: DateTime<Utc>,
}
impl From<ProjectUserInviteCreated> for ProjectUserInvite {
fn from(value: ProjectUserInviteCreated) -> Self {
Self {
impl TryFrom<ProjectUserInviteCreated> for ProjectUserInvite {
type Error = Error;

fn try_from(value: ProjectUserInviteCreated) -> std::result::Result<Self, Self::Error> {
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,
}
})
}
}

Expand All @@ -160,6 +164,34 @@ pub struct ProjectUser {
pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone)]
pub enum ProjectUserRole {
Owner,
Member,
}
impl FromStr for ProjectUserRole {
type Err = Error;

fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
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;
Expand Down Expand Up @@ -213,6 +245,7 @@ mod tests {
id: Uuid::new_v4().to_string(),
project_id: Uuid::new_v4().to_string(),
email: "[email protected]".into(),
role: ProjectUserRole::Owner,
code: "123".into(),
expire_in: Utc::now() + Duration::from_secs(15 * 60),
created_at: Utc::now(),
Expand Down
Loading

0 comments on commit 49dff8e

Please sign in to comment.