diff --git a/application/src/dto.rs b/application/src/dto.rs index ac36b45c..54ba7cd5 100644 --- a/application/src/dto.rs +++ b/application/src/dto.rs @@ -3,5 +3,6 @@ pub mod activitypub; pub mod block_mute; pub mod media; pub mod metadata; +pub mod organization; pub mod pagination; pub mod profile; diff --git a/application/src/dto/organization.rs b/application/src/dto/organization.rs new file mode 100644 index 00000000..81cb0e2a --- /dev/null +++ b/application/src/dto/organization.rs @@ -0,0 +1,29 @@ +use kernel::prelude::entity::{OrgRole, OrganizationMembershipStatus}; +use time::OffsetDateTime; + +#[derive(Debug)] +pub struct CreateOrganizationDto { + pub name: String, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct OrganizationSummaryDto { + pub account_id: String, + pub name: String, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct MyOrganizationDto { + pub organization: OrganizationSummaryDto, + pub role: OrgRole, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct OrganizationMemberDto { + pub account_id: String, + pub name: String, + pub role: OrgRole, + pub status: OrganizationMembershipStatus, + pub invited_by: String, + pub created_at: OffsetDateTime, +} diff --git a/application/src/projection/account_projector.rs b/application/src/projection/account_projector.rs index ff4efb8d..df438fbf 100644 --- a/application/src/projection/account_projector.rs +++ b/application/src/projection/account_projector.rs @@ -14,7 +14,9 @@ use kernel::interfaces::read_model::{ DependOnProfileReadModel, MetadataReadModel, ProfileReadModel, }; use kernel::interfaces::repository::{DependOnFollowRepository, FollowRepository}; -use kernel::prelude::entity::{Account, AccountEvent, AccountId, EventEnvelope, FollowTargetId}; +use kernel::prelude::entity::{ + Account, AccountEvent, AccountId, AccountKind, EventEnvelope, FollowTargetId, +}; use kernel::KernelError; use std::collections::HashMap; use std::future::Future; @@ -192,22 +194,24 @@ pub trait ProjectAccountBatch: self.account_read_model() .link_auth_account(executor, account_id, &auth_id) .await?; - if let Err(e) = self - .permission_writer() - .create_relation( - &RelationTarget::Account { - account_id: account_id.clone(), - relation: AccountRelation::Owner, - }, - &auth_id, - ) - .await - { - tracing::warn!( - "Account projector: failed to create Owner relation for account {:?}: {:?}", - account_id, - e - ); + if account.kind() == &AccountKind::Personal { + if let Err(e) = self + .permission_writer() + .create_relation( + &RelationTarget::Account { + account_id: account_id.clone(), + relation: AccountRelation::Owner, + }, + &auth_id, + ) + .await + { + tracing::warn!( + "Account projector: failed to create Owner relation for account {:?}: {:?}", + account_id, + e + ); + } } } } diff --git a/application/src/projection/tests.rs b/application/src/projection/tests.rs index b8120a0d..90f8534b 100644 --- a/application/src/projection/tests.rs +++ b/application/src/projection/tests.rs @@ -279,6 +279,7 @@ async fn older_version_upsert_is_a_noop() { AccountEvent::Created { name: AccountName::new(kernel::test_utils::unique_account_name()), is_bot: AccountIsBot::new(false), + kind: kernel::prelude::entity::AccountKind::Personal, nanoid: Nanoid::default(), auth_account_id: AuthAccountId::default(), }, @@ -297,6 +298,7 @@ async fn older_version_upsert_is_a_noop() { AccountEvent::Created { name: AccountName::new(kernel::test_utils::unique_account_name()), is_bot: AccountIsBot::new(false), + kind: kernel::prelude::entity::AccountKind::Personal, nanoid: Nanoid::default(), auth_account_id: AuthAccountId::default(), }, diff --git a/application/src/service.rs b/application/src/service.rs index 3ebe8af9..5c5e386f 100644 --- a/application/src/service.rs +++ b/application/src/service.rs @@ -5,6 +5,7 @@ pub mod auth_account; pub mod block; pub mod media; pub mod mute; +pub mod organization; pub mod report; pub mod session_context; diff --git a/application/src/service/account/read.rs b/application/src/service/account/read.rs index 80d5ba9a..a0c36e84 100644 --- a/application/src/service/account/read.rs +++ b/application/src/service/account/read.rs @@ -4,7 +4,7 @@ use crate::permission::{account_view, check_permission}; use kernel::interfaces::database::DatabaseConnection; use kernel::interfaces::permission::DependOnPermissionChecker; use kernel::interfaces::read_model::{AccountQuery, DependOnAccountQuery}; -use kernel::prelude::entity::{Account, AuthAccountId, Nanoid}; +use kernel::prelude::entity::{Account, AccountKind, AuthAccountId, Nanoid}; use kernel::KernelError; use std::future::Future; @@ -28,7 +28,10 @@ pub trait GetAccountUseCase: let accounts = self .account_query() .find_by_auth_id(&mut conn, auth_account_id) - .await?; + .await? + .into_iter() + .filter(|account| account.kind() == &AccountKind::Personal) + .collect(); let cursor = if let Some(cursor) = cursor { let id: Nanoid = Nanoid::new(cursor); self.account_query().find_by_nanoid(&mut conn, &id).await? @@ -71,3 +74,189 @@ pub trait GetAccountUseCase: } impl GetAccountUseCase for T where T: 'static + DependOnAccountQuery + DependOnPermissionChecker {} + +#[cfg(test)] +mod tests { + use super::*; + use kernel::interfaces::database::{Connection, DependOnDatabaseConnection}; + use kernel::interfaces::permission::{InstanceRole, PermissionChecker, PermissionReq}; + use kernel::prelude::entity::{AccountId, AccountName}; + use kernel::test_utils::AccountBuilder; + + struct MockConnection; + impl Connection for MockConnection {} + + struct MockDatabase; + impl kernel::interfaces::database::DatabaseConnection for MockDatabase { + type Connection = MockConnection; + async fn connection(&self) -> error_stack::Result { + Ok(MockConnection) + } + } + + struct MockAccounts(Vec); + impl AccountQuery for MockAccounts { + type Connection = MockConnection; + async fn find_by_id( + &self, + _: &mut MockConnection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self.0.iter().find(|account| account.id() == id).cloned()) + } + async fn find_by_auth_id( + &self, + _: &mut MockConnection, + _: &AuthAccountId, + ) -> error_stack::Result, KernelError> { + Ok(self.0.clone()) + } + async fn find_auth_account_id_by_account_id( + &self, + _: &mut MockConnection, + _: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(None) + } + async fn find_by_name( + &self, + _: &mut MockConnection, + name: &AccountName, + ) -> error_stack::Result, KernelError> { + Ok(self + .0 + .iter() + .find(|account| account.name() == name) + .cloned()) + } + async fn find_by_nanoid( + &self, + _: &mut MockConnection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + Ok(self + .0 + .iter() + .find(|account| account.nanoid() == nanoid) + .cloned()) + } + async fn find_by_nanoids( + &self, + _: &mut MockConnection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + Ok(self + .0 + .iter() + .filter(|account| nanoids.contains(account.nanoid())) + .cloned() + .collect()) + } + async fn find_by_id_unfiltered( + &self, + executor: &mut MockConnection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + self.find_by_id(executor, id).await + } + async fn find_by_nanoid_unfiltered( + &self, + executor: &mut MockConnection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + self.find_by_nanoid(executor, nanoid).await + } + async fn find_by_nanoids_unfiltered( + &self, + executor: &mut MockConnection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + self.find_by_nanoids(executor, nanoids).await + } + async fn find_by_nanoid_including_deleted( + &self, + executor: &mut MockConnection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + self.find_by_nanoid(executor, nanoid).await + } + async fn is_linked_including_deleted( + &self, + _: &mut MockConnection, + _: &AuthAccountId, + _: &AccountId, + ) -> error_stack::Result { + Ok(false) + } + } + + struct AllowPermissions; + impl PermissionChecker for AllowPermissions { + async fn check( + &self, + _: &AuthAccountId, + _: &PermissionReq, + ) -> error_stack::Result { + Ok(true) + } + async fn list_instance_roles( + &self, + _: &AuthAccountId, + ) -> error_stack::Result, KernelError> { + Ok(Vec::new()) + } + } + + struct Module { + database: MockDatabase, + accounts: MockAccounts, + permissions: AllowPermissions, + } + impl DependOnDatabaseConnection for Module { + type DatabaseConnection = MockDatabase; + fn database_connection(&self) -> &MockDatabase { + &self.database + } + } + impl DependOnAccountQuery for Module { + type AccountQuery = MockAccounts; + fn account_query(&self) -> &MockAccounts { + &self.accounts + } + } + impl DependOnPermissionChecker for Module { + type PermissionChecker = AllowPermissions; + fn permission_checker(&self) -> &AllowPermissions { + &self.permissions + } + } + + #[tokio::test] + async fn get_all_accounts_excludes_organization_accounts() { + kernel::ensure_generator_initialized(); + let personal = AccountBuilder::new() + .nanoid(Nanoid::new("personal")) + .build(); + let organization = AccountBuilder::new() + .kind(AccountKind::Organization) + .nanoid(Nanoid::new("organization")) + .build(); + let module = Module { + database: MockDatabase, + accounts: MockAccounts(vec![personal, organization]), + permissions: AllowPermissions, + }; + + let result = module + .get_all_accounts( + &AuthAccountId::default(), + Pagination::new(None, None, Default::default()), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].nanoid, "personal"); + } +} diff --git a/application/src/service/characterization_tests.rs b/application/src/service/characterization_tests.rs index ad2376c3..4519bf51 100644 --- a/application/src/service/characterization_tests.rs +++ b/application/src/service/characterization_tests.rs @@ -758,6 +758,7 @@ delegate_database_dependence! { kernel::interfaces::repository::DependOnImageRepository { ImageRepository, image_repository }, kernel::interfaces::repository::DependOnMetadataRepository { MetadataRepository, metadata_repository }, kernel::interfaces::repository::DependOnMuteRepository { MuteRepository, mute_repository }, + kernel::interfaces::repository::DependOnOrganizationMembershipRepository { OrganizationMembershipRepository, organization_membership_repository }, kernel::interfaces::repository::DependOnOutboxActivityRepository { OutboxActivityRepository, outbox_activity_repository }, kernel::interfaces::repository::DependOnProfileRepository { ProfileRepository, profile_repository }, kernel::interfaces::repository::DependOnRemoteAccountRepository { RemoteAccountRepository, remote_account_repository }, diff --git a/application/src/service/organization.rs b/application/src/service/organization.rs new file mode 100644 index 00000000..509f83c2 --- /dev/null +++ b/application/src/service/organization.rs @@ -0,0 +1,10 @@ +mod create; +mod list; +mod membership; + +pub use create::*; +pub use list::*; +pub use membership::*; + +#[cfg(test)] +mod tests; diff --git a/application/src/service/organization/create.rs b/application/src/service/organization/create.rs new file mode 100644 index 00000000..d1bac7b4 --- /dev/null +++ b/application/src/service/organization/create.rs @@ -0,0 +1,117 @@ +use crate::dto::organization::{CreateOrganizationDto, OrganizationSummaryDto}; +use error_stack::Report; +use kernel::interfaces::database::{ + DatabaseConnection, DependOnTransactionManager, TransactionManager, +}; +use kernel::interfaces::event::EventApplier; +use kernel::interfaces::read_model::{ + AccountQuery, AccountReadModel, DependOnAccountQuery, DependOnAccountReadModel, +}; +use kernel::interfaces::repository::{ + AggregateRepository, DependOnAccountRepository, DependOnOrganizationMembershipRepository, + OrganizationMembershipRepository, +}; +use kernel::prelude::entity::{ + Account, AccountId, AccountIsBot, AccountKind, AccountName, AuthAccountId, CreatedAt, Nanoid, + OrgRole, OrganizationMembership, OrganizationMembershipStatus, +}; +use kernel::KernelError; +use std::future::Future; + +pub trait CreateOrganizationUseCase: + 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnAccountReadModel + + DependOnAccountRepository + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ + fn create_organization( + &self, + auth_account_id: AuthAccountId, + dto: CreateOrganizationDto, + ) -> impl Future> + Send + '_ + { + async move { + let mut connection = self.database_connection().connection().await?; + let creator = self + .account_query() + .find_by_auth_id(&mut connection, &auth_account_id) + .await? + .into_iter() + .find(|account| { + account.kind() == &AccountKind::Personal && account.deleted_at().is_none() + }) + .ok_or_else(|| { + Report::new(KernelError::NotFound) + .attach_printable("Personal account not found for authenticated account") + })?; + + let account_name = AccountName::new(dto.name); + let transaction_auth_id = auth_account_id.clone(); + let creator_id = creator.id().clone(); + let deps = self.clone(); + let organization = self + .transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + let organization_id = AccountId::default(); + let command = Account::create_organization( + organization_id.clone(), + account_name, + AccountIsBot::new(false), + Nanoid::::default(), + transaction_auth_id.clone(), + ); + let event = deps.account_repository().save(executor, command).await?; + let mut organization = None; + Account::apply(&mut organization, event)?; + let organization = organization.ok_or_else(|| { + Report::new(KernelError::Internal) + .attach_printable("Failed to construct organization account") + })?; + deps.account_read_model() + .create(executor, &organization) + .await?; + deps.account_read_model() + .link_auth_account(executor, &organization_id, &transaction_auth_id) + .await?; + deps.organization_membership_repository() + .create( + executor, + &OrganizationMembership::new( + organization_id, + creator_id.clone(), + OrgRole::Owner, + OrganizationMembershipStatus::Active, + creator_id, + CreatedAt::now(), + ), + ) + .await?; + Ok(organization) + }) + }) + .await?; + + Ok(OrganizationSummaryDto { + account_id: organization.nanoid().as_ref().to_string(), + name: organization.name().as_ref().to_string(), + }) + } + } +} + +impl CreateOrganizationUseCase for T where + T: 'static + + Clone + + DependOnAccountQuery + + DependOnAccountReadModel + + DependOnAccountRepository + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ +} diff --git a/application/src/service/organization/list.rs b/application/src/service/organization/list.rs new file mode 100644 index 00000000..598d46e1 --- /dev/null +++ b/application/src/service/organization/list.rs @@ -0,0 +1,139 @@ +use crate::dto::organization::{MyOrganizationDto, OrganizationMemberDto, OrganizationSummaryDto}; +use error_stack::Report; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::read_model::{ + AccountQuery, DependOnAccountQuery, DependOnOrganizationMembershipQuery, + OrganizationMembershipQuery, +}; +use kernel::prelude::entity::{ + Account, AccountKind, AuthAccountId, Nanoid, OrganizationMembershipStatus, +}; +use kernel::KernelError; +use std::future::Future; + +pub trait ListMyOrganizationsUseCase: + Sync + Send + DependOnAccountQuery + DependOnOrganizationMembershipQuery +{ + fn list_my_organizations<'a>( + &'a self, + auth_id: &'a AuthAccountId, + ) -> impl Future, KernelError>> + Send + 'a + { + async move { + let mut conn = self.database_connection().connection().await?; + let accounts = self + .account_query() + .find_by_auth_id(&mut conn, auth_id) + .await?; + let mut result = Vec::new(); + for account in accounts + .into_iter() + .filter(|a| a.kind() == &AccountKind::Personal && a.deleted_at().is_none()) + { + let memberships = self + .organization_membership_query() + .find_by_member(&mut conn, account.id()) + .await?; + for membership in memberships + .into_iter() + .filter(|m| m.status() == &OrganizationMembershipStatus::Active) + { + if let Some(org) = self + .account_query() + .find_by_id(&mut conn, membership.org_account_id()) + .await? + .filter(|a| a.kind() == &AccountKind::Organization) + { + result.push(MyOrganizationDto { + organization: OrganizationSummaryDto { + account_id: org.nanoid().as_ref().to_string(), + name: org.name().as_ref().to_string(), + }, + role: *membership.role(), + }); + } + } + } + Ok(result) + } + } +} + +impl ListMyOrganizationsUseCase for T where + T: Sync + Send + DependOnAccountQuery + DependOnOrganizationMembershipQuery +{ +} + +pub trait ListOrganizationMembersUseCase: + Sync + Send + DependOnAccountQuery + DependOnOrganizationMembershipQuery +{ + fn list_organization_members<'a>( + &'a self, + auth_id: &'a AuthAccountId, + org_nanoid: String, + ) -> impl Future, KernelError>> + Send + 'a + { + async move { + let mut conn = self.database_connection().connection().await?; + let actor_accounts = self + .account_query() + .find_by_auth_id(&mut conn, auth_id) + .await?; + let org = self + .account_query() + .find_by_nanoid(&mut conn, &Nanoid::::new(org_nanoid)) + .await? + .filter(|a| a.kind() == &AccountKind::Organization) + .ok_or_else(|| Report::new(KernelError::NotFound))?; + let mut visible = false; + for account in actor_accounts + .into_iter() + .filter(|a| a.kind() == &AccountKind::Personal && a.deleted_at().is_none()) + { + if self + .organization_membership_query() + .find(&mut conn, org.id(), account.id()) + .await? + .is_some_and(|m| m.status() == &OrganizationMembershipStatus::Active) + { + visible = true; + break; + } + } + if !visible { + return Err(Report::new(KernelError::PermissionDenied)); + } + let memberships = self + .organization_membership_query() + .find_by_org(&mut conn, org.id()) + .await?; + let mut result = Vec::new(); + for membership in memberships { + let member = self + .account_query() + .find_by_id_unfiltered(&mut conn, membership.member_account_id()) + .await? + .ok_or_else(|| Report::new(KernelError::NotFound))?; + let inviter = self + .account_query() + .find_by_id_unfiltered(&mut conn, membership.invited_by()) + .await? + .ok_or_else(|| Report::new(KernelError::NotFound))?; + result.push(OrganizationMemberDto { + account_id: member.nanoid().as_ref().to_string(), + name: member.name().as_ref().to_string(), + role: *membership.role(), + status: *membership.status(), + invited_by: inviter.nanoid().as_ref().to_string(), + created_at: *membership.created_at().as_ref(), + }); + } + Ok(result) + } + } +} + +impl ListOrganizationMembersUseCase for T where + T: Sync + Send + DependOnAccountQuery + DependOnOrganizationMembershipQuery +{ +} diff --git a/application/src/service/organization/membership.rs b/application/src/service/organization/membership.rs new file mode 100644 index 00000000..b4a4457c --- /dev/null +++ b/application/src/service/organization/membership.rs @@ -0,0 +1,476 @@ +use error_stack::Report; +use kernel::interfaces::database::{ + DatabaseConnection, DependOnTransactionManager, TransactionManager, +}; +use kernel::interfaces::read_model::{ + AccountQuery, DependOnAccountQuery, DependOnOrganizationMembershipQuery, + OrganizationMembershipQuery, +}; +use kernel::interfaces::repository::{ + DependOnOrganizationMembershipRepository, OrganizationMembershipRepository, +}; +use kernel::prelude::entity::{ + Account, AccountKind, AuthAccountId, CreatedAt, Nanoid, OrgRole, OrganizationMembership, + OrganizationMembershipStatus, +}; +use kernel::KernelError; +use std::future::Future; + +fn permission_denied() -> Report { + Report::new(KernelError::PermissionDenied) +} + +fn not_found() -> Report { + Report::new(KernelError::NotFound) +} + +fn rejected(message: &'static str) -> Report { + Report::new(KernelError::Rejected).attach_printable(message) +} + +pub trait InviteMemberUseCase: + 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ + fn invite_member( + &self, + auth_id: AuthAccountId, + org_nanoid: String, + target_nanoid: String, + role: OrgRole, + ) -> impl Future> + Send + '_ { + async move { + if role == OrgRole::Owner { + return Err(rejected("Owner invitations are not allowed")); + } + let mut conn = self.database_connection().connection().await?; + let actor_accounts = self + .account_query() + .find_by_auth_id(&mut conn, &auth_id) + .await?; + let org = self + .account_query() + .find_by_nanoid(&mut conn, &Nanoid::::new(org_nanoid)) + .await? + .filter(|a| a.kind() == &AccountKind::Organization) + .ok_or_else(not_found)?; + let target = self + .account_query() + .find_by_nanoid(&mut conn, &Nanoid::::new(target_nanoid)) + .await? + .filter(|a| a.kind() == &AccountKind::Personal && a.deleted_at().is_none()) + .ok_or_else(not_found)?; + let mut actor_membership = None; + let mut actor = None; + for account in actor_accounts + .into_iter() + .filter(|a| a.kind() == &AccountKind::Personal && a.deleted_at().is_none()) + { + if let Some(membership) = self + .organization_membership_query() + .find(&mut conn, org.id(), account.id()) + .await? + .filter(|m| m.status() == &OrganizationMembershipStatus::Active) + { + actor = Some(account); + actor_membership = Some(membership); + break; + } + } + let actor = actor.ok_or_else(permission_denied)?; + let actor_membership = actor_membership.ok_or_else(permission_denied)?; + if !matches!(actor_membership.role(), OrgRole::Owner | OrgRole::Admin) { + return Err(permission_denied()); + } + if self + .organization_membership_query() + .find(&mut conn, org.id(), target.id()) + .await? + .is_some() + { + return Err(rejected("Account is already an organization member")); + } + let membership = OrganizationMembership::new( + org.id().clone(), + target.id().clone(), + role, + OrganizationMembershipStatus::Pending, + actor.id().clone(), + CreatedAt::now(), + ); + let deps = self.clone(); + self.transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + deps.organization_membership_repository() + .create(executor, &membership) + .await + }) + }) + .await + } + } +} + +impl InviteMemberUseCase for T where + T: 'static + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ +} + +pub trait AcceptInviteUseCase: + 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ + fn accept_invite( + &self, + auth_id: AuthAccountId, + org_nanoid: String, + actor_nanoid: String, + ) -> impl Future> + Send + '_ { + async move { + let mut conn = self.database_connection().connection().await?; + let actor = self + .account_query() + .find_by_auth_id(&mut conn, &auth_id) + .await? + .into_iter() + .find(|a| { + a.kind() == &AccountKind::Personal + && a.deleted_at().is_none() + && a.nanoid().as_ref() == &actor_nanoid + }) + .ok_or_else(permission_denied)?; + let org = self + .account_query() + .find_by_nanoid(&mut conn, &Nanoid::::new(org_nanoid)) + .await? + .filter(|a| a.kind() == &AccountKind::Organization) + .ok_or_else(not_found)?; + let membership = self + .organization_membership_query() + .find(&mut conn, org.id(), actor.id()) + .await? + .ok_or_else(not_found)?; + if membership.status() == &OrganizationMembershipStatus::Active { + return Err(rejected("Invitation is already active")); + } + let org_id = org.id().clone(); + let actor_id = actor.id().clone(); + let deps = self.clone(); + self.transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + deps.organization_membership_repository() + .update_status( + executor, + &org_id, + &actor_id, + OrganizationMembershipStatus::Active, + ) + .await + }) + }) + .await + } + } +} + +impl AcceptInviteUseCase for T where + T: 'static + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ +} + +pub trait ChangeRoleUseCase: + 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ + fn change_role( + &self, + auth_id: AuthAccountId, + org_nanoid: String, + member_nanoid: String, + new_role: OrgRole, + ) -> impl Future> + Send + '_ { + async move { + let mut conn = self.database_connection().connection().await?; + let actor_accounts = self + .account_query() + .find_by_auth_id(&mut conn, &auth_id) + .await?; + let org = self + .account_query() + .find_by_nanoid(&mut conn, &Nanoid::::new(org_nanoid)) + .await? + .filter(|a| a.kind() == &AccountKind::Organization) + .ok_or_else(not_found)?; + let member = self + .account_query() + .find_by_nanoid(&mut conn, &Nanoid::::new(member_nanoid)) + .await? + .filter(|a| a.kind() == &AccountKind::Personal && a.deleted_at().is_none()) + .ok_or_else(not_found)?; + let mut actor_is_owner = false; + for account in actor_accounts + .into_iter() + .filter(|a| a.kind() == &AccountKind::Personal && a.deleted_at().is_none()) + { + if self + .organization_membership_query() + .find(&mut conn, org.id(), account.id()) + .await? + .is_some_and(|m| { + m.status() == &OrganizationMembershipStatus::Active + && m.role() == &OrgRole::Owner + }) + { + actor_is_owner = true; + break; + } + } + if !actor_is_owner { + return Err(permission_denied()); + } + let target = self + .organization_membership_query() + .find(&mut conn, org.id(), member.id()) + .await? + .filter(|m| m.status() == &OrganizationMembershipStatus::Active) + .ok_or_else(not_found)?; + let demotes_owner = target.role() == &OrgRole::Owner && new_role != OrgRole::Owner; + let org_id = org.id().clone(); + let member_id = member.id().clone(); + let deps = self.clone(); + self.transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + if demotes_owner { + deps.organization_membership_repository() + .lock_active_owner_rows(executor, &org_id) + .await?; + if deps + .organization_membership_repository() + .count_active_owners(executor, &org_id) + .await? + == 1 + { + return Err(rejected("The last active owner cannot be demoted")); + } + } + deps.organization_membership_repository() + .update_role(executor, &org_id, &member_id, new_role) + .await + }) + }) + .await + } + } +} + +impl ChangeRoleUseCase for T where + T: 'static + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ +} + +pub trait RemoveMemberUseCase: + 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ + fn remove_member( + &self, + auth_id: AuthAccountId, + org_nanoid: String, + member_nanoid: String, + ) -> impl Future> + Send + '_ { + async move { + let mut conn = self.database_connection().connection().await?; + let actor_accounts = self + .account_query() + .find_by_auth_id(&mut conn, &auth_id) + .await?; + let org = self + .account_query() + .find_by_nanoid(&mut conn, &Nanoid::::new(org_nanoid)) + .await? + .filter(|a| a.kind() == &AccountKind::Organization) + .ok_or_else(not_found)?; + let member = self + .account_query() + .find_by_nanoid(&mut conn, &Nanoid::::new(member_nanoid)) + .await? + .filter(|a| a.kind() == &AccountKind::Personal) + .ok_or_else(not_found)?; + let mut actor_can_manage = false; + for account in actor_accounts + .into_iter() + .filter(|a| a.kind() == &AccountKind::Personal && a.deleted_at().is_none()) + { + if self + .organization_membership_query() + .find(&mut conn, org.id(), account.id()) + .await? + .is_some_and(|m| { + m.status() == &OrganizationMembershipStatus::Active + && matches!(m.role(), OrgRole::Owner | OrgRole::Admin) + }) + { + actor_can_manage = true; + break; + } + } + if !actor_can_manage { + return Err(permission_denied()); + } + let target = self + .organization_membership_query() + .find(&mut conn, org.id(), member.id()) + .await? + .filter(|m| m.status() == &OrganizationMembershipStatus::Active) + .ok_or_else(not_found)?; + if target.role() == &OrgRole::Owner { + return Err(rejected("Owners cannot be removed")); + } + let org_id = org.id().clone(); + let member_id = member.id().clone(); + let deps = self.clone(); + self.transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + deps.organization_membership_repository() + .delete(executor, &org_id, &member_id) + .await + }) + }) + .await + } + } +} + +impl RemoveMemberUseCase for T where + T: 'static + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ +} + +pub trait LeaveOrganizationUseCase: + 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ + fn leave_organization( + &self, + auth_id: AuthAccountId, + org_nanoid: String, + actor_nanoid: String, + ) -> impl Future> + Send + '_ { + async move { + let mut conn = self.database_connection().connection().await?; + let actor = self + .account_query() + .find_by_auth_id(&mut conn, &auth_id) + .await? + .into_iter() + .find(|a| { + a.kind() == &AccountKind::Personal + && a.deleted_at().is_none() + && a.nanoid().as_ref() == &actor_nanoid + }) + .ok_or_else(permission_denied)?; + let org = self + .account_query() + .find_by_nanoid(&mut conn, &Nanoid::::new(org_nanoid)) + .await? + .filter(|a| a.kind() == &AccountKind::Organization) + .ok_or_else(not_found)?; + let membership = self + .organization_membership_query() + .find(&mut conn, org.id(), actor.id()) + .await? + .filter(|m| m.status() == &OrganizationMembershipStatus::Active) + .ok_or_else(not_found)?; + let leaves_as_owner = membership.role() == &OrgRole::Owner; + let org_id = org.id().clone(); + let actor_id = actor.id().clone(); + let deps = self.clone(); + self.transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + if leaves_as_owner { + deps.organization_membership_repository() + .lock_active_owner_rows(executor, &org_id) + .await?; + if deps + .organization_membership_repository() + .count_active_owners(executor, &org_id) + .await? + == 1 + { + return Err(rejected("The last active owner cannot leave")); + } + } + deps.organization_membership_repository() + .delete(executor, &org_id, &actor_id) + .await + }) + }) + .await + } + } +} + +impl LeaveOrganizationUseCase for T where + T: 'static + + Clone + + DependOnAccountQuery + + DependOnOrganizationMembershipQuery + + DependOnOrganizationMembershipRepository + + DependOnTransactionManager +{ +} diff --git a/application/src/service/organization/tests.rs b/application/src/service/organization/tests.rs new file mode 100644 index 00000000..26361e34 --- /dev/null +++ b/application/src/service/organization/tests.rs @@ -0,0 +1,880 @@ +use super::*; +use error_stack::Report; +use kernel::interfaces::database::{ + Connection, DatabaseConnection, DependOnDatabaseConnection, DependOnTransactionManager, + TransactionManager, +}; +use kernel::interfaces::read_model::{AccountQuery, AccountReadModel, DependOnAccountReadModel}; +use kernel::interfaces::repository::{ + AggregateRepository, DependOnAccountRepository, DependOnOrganizationMembershipRepository, + OrganizationMembershipRepository, Rehydrated, +}; +use kernel::prelude::entity::{ + Account, AccountEvent, AccountId, AccountKind, AccountName, AuthAccountId, CommandEnvelope, + CreatedAt, EventEnvelope, EventVersion, Nanoid, OrgRole, OrganizationMembership, + OrganizationMembershipStatus, +}; +use kernel::test_utils::AccountBuilder; +use kernel::KernelError; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +struct MockConnection; +impl Connection for MockConnection {} + +#[derive(Clone)] +struct MockDatabase; +impl DatabaseConnection for MockDatabase { + type Connection = MockConnection; + async fn connection(&self) -> error_stack::Result { + Ok(MockConnection) + } +} +impl TransactionManager for MockDatabase { + fn transaction<'a, F, T>( + &'a self, + operation: F, + ) -> Pin> + Send + 'a>> + where + F: for<'connection> FnOnce( + &'connection mut Self::Connection, + ) -> Pin< + Box> + Send + 'connection>, + > + Send + + 'a, + T: Send + 'a, + { + Box::pin(async move { operation(&mut MockConnection).await }) + } +} + +#[derive(Clone)] +struct MockAccounts { + values: Arc>>, + links: Arc>>, +} +impl AccountQuery for MockAccounts { + type Connection = MockConnection; + async fn find_by_id( + &self, + _: &mut MockConnection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .values + .lock() + .unwrap() + .iter() + .find(|a| a.id() == id && a.deleted_at().is_none()) + .cloned()) + } + async fn find_by_auth_id( + &self, + _: &mut MockConnection, + _: &AuthAccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .values + .lock() + .unwrap() + .iter() + .filter(|a| a.kind() == &AccountKind::Personal) + .cloned() + .collect()) + } + async fn find_auth_account_id_by_account_id( + &self, + _: &mut MockConnection, + _: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(None) + } + async fn find_by_name( + &self, + _: &mut MockConnection, + name: &AccountName, + ) -> error_stack::Result, KernelError> { + Ok(self + .values + .lock() + .unwrap() + .iter() + .find(|a| a.name() == name) + .cloned()) + } + async fn find_by_nanoid( + &self, + _: &mut MockConnection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + Ok(self + .values + .lock() + .unwrap() + .iter() + .find(|a| a.nanoid() == nanoid && a.deleted_at().is_none()) + .cloned()) + } + async fn find_by_nanoids( + &self, + _: &mut MockConnection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + Ok(self + .values + .lock() + .unwrap() + .iter() + .filter(|a| nanoids.contains(a.nanoid())) + .cloned() + .collect()) + } + async fn find_by_id_unfiltered( + &self, + _: &mut MockConnection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .values + .lock() + .unwrap() + .iter() + .find(|a| a.id() == id) + .cloned()) + } + async fn find_by_nanoid_unfiltered( + &self, + executor: &mut MockConnection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoid(self, executor, nanoid).await + } + async fn find_by_nanoids_unfiltered( + &self, + executor: &mut MockConnection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoids(self, executor, nanoids).await + } + async fn find_by_nanoid_including_deleted( + &self, + _: &mut MockConnection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + Ok(self + .values + .lock() + .unwrap() + .iter() + .find(|a| a.nanoid() == nanoid) + .cloned()) + } + async fn is_linked_including_deleted( + &self, + _: &mut MockConnection, + _: &AuthAccountId, + _: &AccountId, + ) -> error_stack::Result { + Ok(false) + } +} + +impl AccountReadModel for MockAccounts { + type Connection = MockConnection; + + async fn find_by_id( + &self, + executor: &mut MockConnection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_id(self, executor, id).await + } + async fn find_by_auth_id( + &self, + executor: &mut MockConnection, + auth_id: &AuthAccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_auth_id(self, executor, auth_id).await + } + async fn find_auth_account_id_by_account_id( + &self, + executor: &mut MockConnection, + account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_auth_account_id_by_account_id(self, executor, account_id).await + } + async fn find_by_name( + &self, + executor: &mut MockConnection, + name: &AccountName, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_name(self, executor, name).await + } + async fn find_by_nanoid( + &self, + executor: &mut MockConnection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoid(self, executor, nanoid).await + } + async fn find_by_nanoids( + &self, + executor: &mut MockConnection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoids(self, executor, nanoids).await + } + async fn create( + &self, + _: &mut MockConnection, + account: &Account, + ) -> error_stack::Result<(), KernelError> { + self.values.lock().unwrap().push(account.clone()); + Ok(()) + } + async fn update( + &self, + _: &mut MockConnection, + account: &Account, + ) -> error_stack::Result<(), KernelError> { + let mut values = self.values.lock().unwrap(); + let value = values + .iter_mut() + .find(|value| value.id() == account.id()) + .ok_or_else(|| Report::new(KernelError::NotFound))?; + *value = account.clone(); + Ok(()) + } + async fn deactivate( + &self, + _: &mut MockConnection, + _: &AccountId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + async fn unlink_all_auth_accounts( + &self, + _: &mut MockConnection, + account_id: &AccountId, + ) -> error_stack::Result<(), KernelError> { + self.links + .lock() + .unwrap() + .retain(|(linked, _)| linked != account_id); + Ok(()) + } + async fn link_auth_account( + &self, + _: &mut MockConnection, + account_id: &AccountId, + auth_id: &AuthAccountId, + ) -> error_stack::Result<(), KernelError> { + self.links + .lock() + .unwrap() + .push((account_id.clone(), auth_id.clone())); + Ok(()) + } + async fn find_by_id_unfiltered( + &self, + executor: &mut MockConnection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_id_unfiltered(self, executor, id).await + } + async fn find_by_nanoid_unfiltered( + &self, + executor: &mut MockConnection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoid_unfiltered(self, executor, nanoid).await + } + async fn find_by_nanoids_unfiltered( + &self, + executor: &mut MockConnection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoids_unfiltered(self, executor, nanoids).await + } + async fn find_by_id_including_deleted( + &self, + executor: &mut MockConnection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_id_unfiltered(self, executor, id).await + } + async fn find_by_nanoid_including_deleted( + &self, + executor: &mut MockConnection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoid_including_deleted(self, executor, nanoid).await + } + async fn is_linked_including_deleted( + &self, + _: &mut MockConnection, + auth_id: &AuthAccountId, + account_id: &AccountId, + ) -> error_stack::Result { + Ok(self + .links + .lock() + .unwrap() + .contains(&(account_id.clone(), auth_id.clone()))) + } + async fn suspend( + &self, + _: &mut MockConnection, + _: &AccountId, + _: &str, + _: Option, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + async fn unsuspend( + &self, + _: &mut MockConnection, + _: &AccountId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + async fn ban( + &self, + _: &mut MockConnection, + _: &AccountId, + _: &str, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } +} + +#[derive(Clone, Default)] +struct MockAccountRepository { + saved_events: Arc>>, +} + +impl AggregateRepository for MockAccountRepository { + type Connection = MockConnection; + type Id = AccountId; + + async fn load( + &self, + _: &mut MockConnection, + _: &AccountId, + ) -> error_stack::Result, KernelError> { + Err(Report::new(KernelError::NotFound)) + } + async fn save( + &self, + _: &mut MockConnection, + command: CommandEnvelope, + ) -> error_stack::Result, KernelError> { + self.saved_events + .lock() + .unwrap() + .push(command.event().clone()); + Ok(EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::default(), + )) + } +} + +#[derive(Clone)] +struct MockMemberships(Arc>>); +impl OrganizationMembershipRepository for MockMemberships { + type Connection = MockConnection; + async fn create( + &self, + _: &mut MockConnection, + value: &OrganizationMembership, + ) -> error_stack::Result<(), KernelError> { + self.0.lock().unwrap().push(value.clone()); + Ok(()) + } + async fn find( + &self, + _: &mut MockConnection, + org: &AccountId, + member: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .find(|m| m.org_account_id() == org && m.member_account_id() == member) + .cloned()) + } + async fn find_by_org( + &self, + _: &mut MockConnection, + org: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .filter(|m| m.org_account_id() == org) + .cloned() + .collect()) + } + async fn find_by_member( + &self, + _: &mut MockConnection, + member: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .filter(|m| m.member_account_id() == member) + .cloned() + .collect()) + } + async fn update_role( + &self, + _: &mut MockConnection, + org: &AccountId, + member: &AccountId, + role: OrgRole, + ) -> error_stack::Result<(), KernelError> { + let mut values = self.0.lock().unwrap(); + let value = values + .iter_mut() + .find(|m| m.org_account_id() == org && m.member_account_id() == member) + .ok_or_else(|| Report::new(KernelError::NotFound))?; + *value = OrganizationMembership::new( + org.clone(), + member.clone(), + role, + *value.status(), + value.invited_by().clone(), + value.created_at().clone(), + ); + Ok(()) + } + async fn update_status( + &self, + _: &mut MockConnection, + org: &AccountId, + member: &AccountId, + status: OrganizationMembershipStatus, + ) -> error_stack::Result<(), KernelError> { + let mut values = self.0.lock().unwrap(); + let value = values + .iter_mut() + .find(|m| m.org_account_id() == org && m.member_account_id() == member) + .ok_or_else(|| Report::new(KernelError::NotFound))?; + *value = OrganizationMembership::new( + org.clone(), + member.clone(), + *value.role(), + status, + value.invited_by().clone(), + value.created_at().clone(), + ); + Ok(()) + } + async fn delete( + &self, + _: &mut MockConnection, + org: &AccountId, + member: &AccountId, + ) -> error_stack::Result<(), KernelError> { + let mut values = self.0.lock().unwrap(); + let len = values.len(); + values.retain(|m| m.org_account_id() != org || m.member_account_id() != member); + if values.len() == len { + Err(Report::new(KernelError::NotFound)) + } else { + Ok(()) + } + } + async fn count_active_owners( + &self, + _: &mut MockConnection, + org: &AccountId, + ) -> error_stack::Result { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .filter(|m| { + m.org_account_id() == org + && m.role() == &OrgRole::Owner + && m.status() == &OrganizationMembershipStatus::Active + }) + .count() as i64) + } + async fn lock_active_owner_rows( + &self, + _: &mut MockConnection, + _: &AccountId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } +} + +#[derive(Clone)] +struct Module { + database: MockDatabase, + accounts: MockAccounts, + account_repository: MockAccountRepository, + memberships: MockMemberships, +} +impl DependOnDatabaseConnection for Module { + type DatabaseConnection = MockDatabase; + fn database_connection(&self) -> &MockDatabase { + &self.database + } +} +impl DependOnTransactionManager for Module { + type TransactionManager = MockDatabase; + fn transaction_manager(&self) -> &MockDatabase { + &self.database + } +} +impl DependOnAccountReadModel for Module { + type AccountReadModel = MockAccounts; + fn account_read_model(&self) -> &MockAccounts { + &self.accounts + } +} +impl DependOnAccountRepository for Module { + type AccountRepository = MockAccountRepository; + fn account_repository(&self) -> &MockAccountRepository { + &self.account_repository + } +} +impl DependOnOrganizationMembershipRepository for Module { + type OrganizationMembershipRepository = MockMemberships; + fn organization_membership_repository(&self) -> &MockMemberships { + &self.memberships + } +} + +struct Fixture { + module: Module, + auth: AuthAccountId, + member: Account, +} +fn fixture(owner_count: usize) -> Fixture { + kernel::ensure_generator_initialized(); + let org = AccountBuilder::new() + .kind(AccountKind::Organization) + .nanoid(Nanoid::new("org")) + .build(); + let owner = AccountBuilder::new().nanoid(Nanoid::new("owner")).build(); + let member = AccountBuilder::new().nanoid(Nanoid::new("member")).build(); + let mut memberships = vec![OrganizationMembership::new( + org.id().clone(), + owner.id().clone(), + OrgRole::Owner, + OrganizationMembershipStatus::Active, + owner.id().clone(), + CreatedAt::now(), + )]; + if owner_count > 1 { + memberships.push(OrganizationMembership::new( + org.id().clone(), + member.id().clone(), + OrgRole::Owner, + OrganizationMembershipStatus::Active, + owner.id().clone(), + CreatedAt::now(), + )); + } + Fixture { + module: Module { + database: MockDatabase, + accounts: MockAccounts { + values: Arc::new(Mutex::new(vec![org.clone(), owner.clone(), member.clone()])), + links: Arc::new(Mutex::new(Vec::new())), + }, + account_repository: MockAccountRepository::default(), + memberships: MockMemberships(Arc::new(Mutex::new(memberships))), + }, + auth: AuthAccountId::default(), + member, + } +} + +#[tokio::test] +async fn create_organization_persists_organization_owner_and_auth_link() { + let f = fixture(1); + let creator = f + .module + .accounts + .values + .lock() + .unwrap() + .iter() + .find(|account| account.nanoid().as_ref() == "owner") + .unwrap() + .clone(); + + let result = f + .module + .create_organization( + f.auth.clone(), + crate::dto::organization::CreateOrganizationDto { + name: "created-org".to_string(), + }, + ) + .await + .unwrap(); + + let accounts = f.module.accounts.values.lock().unwrap(); + let organization = accounts + .iter() + .find(|account| account.nanoid().as_ref() == &result.account_id) + .unwrap(); + assert_eq!(organization.kind(), &AccountKind::Organization); + let memberships = f.module.memberships.0.lock().unwrap(); + assert!(memberships.iter().any(|membership| { + membership.org_account_id() == organization.id() + && membership.member_account_id() == creator.id() + && membership.role() == &OrgRole::Owner + && membership.status() == &OrganizationMembershipStatus::Active + })); + assert!(f + .module + .accounts + .links + .lock() + .unwrap() + .contains(&(organization.id().clone(), f.auth.clone()))); + assert!(f + .module + .account_repository + .saved_events + .lock() + .unwrap() + .iter() + .any(|event| matches!(event, AccountEvent::Created { kind: AccountKind::Organization, auth_account_id, .. } if auth_account_id == &f.auth))); +} + +#[tokio::test] +async fn invite_accept_flow_creates_pending_then_active_membership() { + let f = fixture(1); + f.module + .invite_member( + f.auth.clone(), + "org".into(), + "member".into(), + OrgRole::Member, + ) + .await + .unwrap(); + f.module + .accept_invite(f.auth, "org".into(), "member".into()) + .await + .unwrap(); + let values = f.module.memberships.0.lock().unwrap(); + assert!(values.iter().any(|m| m.member_account_id() == f.member.id() + && m.status() == &OrganizationMembershipStatus::Active)); +} + +#[tokio::test] +async fn duplicate_invite_is_rejected() { + let f = fixture(1); + f.module + .invite_member( + f.auth.clone(), + "org".into(), + "member".into(), + OrgRole::Member, + ) + .await + .unwrap(); + let error = f + .module + .invite_member(f.auth, "org".into(), "member".into(), OrgRole::Member) + .await + .unwrap_err(); + assert_eq!(error.current_context(), &KernelError::Rejected); +} + +#[tokio::test] +async fn owner_invite_is_rejected() { + let f = fixture(1); + + let error = f + .module + .invite_member(f.auth, "org".into(), "member".into(), OrgRole::Owner) + .await + .unwrap_err(); + + assert_eq!(error.current_context(), &KernelError::Rejected); +} + +#[tokio::test] +async fn non_member_management_is_permission_denied() { + let f = fixture(1); + f.module.memberships.0.lock().unwrap().clear(); + + let error = f + .module + .invite_member(f.auth, "org".into(), "member".into(), OrgRole::Member) + .await + .unwrap_err(); + + assert_eq!(error.current_context(), &KernelError::PermissionDenied); +} + +#[tokio::test] +async fn nonexistent_organization_is_not_found() { + let f = fixture(1); + + let error = f + .module + .invite_member(f.auth, "missing".into(), "member".into(), OrgRole::Member) + .await + .unwrap_err(); + + assert_eq!(error.current_context(), &KernelError::NotFound); +} + +#[tokio::test] +async fn last_owner_cannot_be_demoted_or_leave() { + let f = fixture(1); + let demote = f + .module + .change_role( + f.auth.clone(), + "org".into(), + "owner".into(), + OrgRole::Member, + ) + .await + .unwrap_err(); + let leave = f + .module + .leave_organization(f.auth, "org".into(), "owner".into()) + .await + .unwrap_err(); + assert_eq!(demote.current_context(), &KernelError::Rejected); + assert_eq!(leave.current_context(), &KernelError::Rejected); +} + +#[tokio::test] +async fn owner_changes_active_member_role_to_admin() { + let f = fixture(1); + let owner = f + .module + .accounts + .values + .lock() + .unwrap() + .iter() + .find(|account| account.nanoid().as_ref() == "owner") + .unwrap() + .clone(); + f.module + .memberships + .0 + .lock() + .unwrap() + .push(OrganizationMembership::new( + f.module + .accounts + .values + .lock() + .unwrap() + .iter() + .find(|account| account.nanoid().as_ref() == "org") + .unwrap() + .id() + .clone(), + f.member.id().clone(), + OrgRole::Member, + OrganizationMembershipStatus::Active, + owner.id().clone(), + CreatedAt::now(), + )); + + f.module + .change_role(f.auth, "org".into(), "member".into(), OrgRole::Admin) + .await + .unwrap(); + + assert!(f + .module + .memberships + .0 + .lock() + .unwrap() + .iter() + .any(|membership| { + membership.member_account_id() == f.member.id() && membership.role() == &OrgRole::Admin + })); +} + +#[tokio::test] +async fn owner_removes_active_member() { + let f = fixture(1); + let accounts = f.module.accounts.values.lock().unwrap(); + let org = accounts + .iter() + .find(|account| account.nanoid().as_ref() == "org") + .unwrap() + .clone(); + let owner = accounts + .iter() + .find(|account| account.nanoid().as_ref() == "owner") + .unwrap() + .clone(); + drop(accounts); + f.module + .memberships + .0 + .lock() + .unwrap() + .push(OrganizationMembership::new( + org.id().clone(), + f.member.id().clone(), + OrgRole::Member, + OrganizationMembershipStatus::Active, + owner.id().clone(), + CreatedAt::now(), + )); + + f.module + .remove_member(f.auth, "org".into(), "member".into()) + .await + .unwrap(); + + assert!(!f + .module + .memberships + .0 + .lock() + .unwrap() + .iter() + .any(|membership| { + membership.org_account_id() == org.id() + && membership.member_account_id() == f.member.id() + })); +} + +#[tokio::test] +async fn owner_cannot_be_removed_but_member_can_leave() { + let f = fixture(2); + let remove = f + .module + .remove_member(f.auth.clone(), "org".into(), "owner".into()) + .await + .unwrap_err(); + assert_eq!(remove.current_context(), &KernelError::Rejected); + f.module + .leave_organization(f.auth, "org".into(), "member".into()) + .await + .unwrap(); +} diff --git a/driver/src/database/postgres.rs b/driver/src/database/postgres.rs index 3aa63380..2e665679 100644 --- a/driver/src/database/postgres.rs +++ b/driver/src/database/postgres.rs @@ -13,6 +13,7 @@ mod metadata; mod metadata_event_store; mod metadata_repository; mod mute; +mod organization_membership; mod outbox_activity; mod profile; mod profile_event_store; diff --git a/driver/src/database/postgres/account/mod.rs b/driver/src/database/postgres/account/mod.rs index c1c96b87..7ecd9988 100644 --- a/driver/src/database/postgres/account/mod.rs +++ b/driver/src/database/postgres/account/mod.rs @@ -26,7 +26,7 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query_as::<_, AccountRow>( //language=postgresql r#" - SELECT id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts WHERE id = $1 AND deleted_at IS NULL @@ -37,8 +37,9 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(id.as_ref()) .fetch_optional(con) .await - .convert_error() - .map(|option| option.map(Account::from)) + .convert_error()? + .map(TryFrom::try_from) + .transpose() } async fn find_by_auth_id( @@ -52,7 +53,7 @@ impl AccountReadModel for PostgresAccountReadModel { r#" -- Intentionally does NOT filter suspended/banned: allows account owners -- to see their own accounts' moderation status via the listing endpoint. - SELECT accounts.id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT accounts.id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts INNER JOIN auth_emumet_accounts ON auth_emumet_accounts.emumet_id = accounts.id @@ -62,12 +63,10 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(auth_id.as_ref()) .fetch_all(con) .await - .convert_error() - .map(|rows| { - rows.into_iter() - .map(|row| account_from_row(row, true)) - .collect() - }) + .convert_error()? + .into_iter() + .map(|row| account_from_row(row, true)) + .collect() } async fn find_auth_account_id_by_account_id( @@ -98,7 +97,7 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query_as::<_, AccountRow>( //language=postgresql r#" - SELECT id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts WHERE name = $1 AND deleted_at IS NULL @@ -109,8 +108,9 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(name.as_ref()) .fetch_optional(con) .await - .convert_error() - .map(|option| option.map(Account::from)) + .convert_error()? + .map(TryFrom::try_from) + .transpose() } async fn find_by_nanoid( @@ -122,7 +122,7 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query_as::<_, AccountRow>( //language=postgresql r#" - SELECT id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts WHERE nanoid = $1 AND deleted_at IS NULL @@ -133,8 +133,9 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(nanoid.as_ref()) .fetch_optional(con) .await - .convert_error() - .map(|option| option.map(Account::from)) + .convert_error()? + .map(TryFrom::try_from) + .transpose() } async fn find_by_nanoids( @@ -147,7 +148,7 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query_as::<_, AccountRow>( //language=postgresql r#" - SELECT id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts WHERE nanoid = ANY($1) AND deleted_at IS NULL @@ -158,8 +159,10 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(&nanoid_strs) .fetch_all(con) .await - .convert_error() - .map(|rows| rows.into_iter().map(Account::from).collect()) + .convert_error()? + .into_iter() + .map(TryFrom::try_from) + .collect() } async fn create( @@ -171,13 +174,17 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query( //language=postgresql r#" - INSERT INTO accounts (id, name, is_bot, version, nanoid, created_at) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO accounts (id, name, is_bot, kind, version, nanoid, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) "#, ) .bind(account.id().as_ref()) .bind(account.name().as_ref()) .bind(account.is_bot().as_ref()) + .bind(match account.kind() { + kernel::prelude::entity::AccountKind::Personal => "personal", + kernel::prelude::entity::AccountKind::Organization => "organization", + }) .bind(account.version().as_ref()) .bind(account.nanoid().as_ref()) .bind(account.created_at().as_ref()) @@ -316,7 +323,7 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query_as::<_, AccountRow>( //language=postgresql r#" - SELECT id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts WHERE id = $1 AND deleted_at IS NULL @@ -325,8 +332,9 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(id.as_ref()) .fetch_optional(con) .await - .convert_error() - .map(|option| option.map(|row| account_from_row(row, true))) + .convert_error()? + .map(|row| account_from_row(row, true)) + .transpose() } async fn find_by_nanoid_unfiltered( @@ -338,7 +346,7 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query_as::<_, AccountRow>( //language=postgresql r#" - SELECT id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts WHERE nanoid = $1 AND deleted_at IS NULL @@ -347,8 +355,9 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(nanoid.as_ref()) .fetch_optional(con) .await - .convert_error() - .map(|option| option.map(|row| account_from_row(row, true))) + .convert_error()? + .map(|row| account_from_row(row, true)) + .transpose() } async fn find_by_nanoids_unfiltered( @@ -361,7 +370,7 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query_as::<_, AccountRow>( //language=postgresql r#" - SELECT id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts WHERE nanoid = ANY($1) AND deleted_at IS NULL @@ -370,12 +379,10 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(&nanoid_strs) .fetch_all(con) .await - .convert_error() - .map(|rows| { - rows.into_iter() - .map(|row| account_from_row(row, true)) - .collect() - }) + .convert_error()? + .into_iter() + .map(|row| account_from_row(row, true)) + .collect() } async fn find_by_id_including_deleted( @@ -387,7 +394,7 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query_as::<_, AccountRow>( //language=postgresql r#" - SELECT id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts WHERE id = $1 @@ -396,8 +403,9 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(id.as_ref()) .fetch_optional(con) .await - .convert_error() - .map(|option| option.map(|row| account_from_row(row, true))) + .convert_error()? + .map(|row| account_from_row(row, true)) + .transpose() } async fn find_by_nanoid_including_deleted( @@ -409,7 +417,7 @@ impl AccountReadModel for PostgresAccountReadModel { sqlx::query_as::<_, AccountRow>( //language=postgresql r#" - SELECT id, name, is_bot, deleted_at, version, nanoid, created_at, + SELECT id, name, is_bot, kind, deleted_at, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason FROM accounts WHERE nanoid = $1 @@ -418,8 +426,9 @@ impl AccountReadModel for PostgresAccountReadModel { .bind(nanoid.as_ref()) .fetch_optional(con) .await - .convert_error() - .map(|option| option.map(|row| account_from_row(row, true))) + .convert_error()? + .map(|row| account_from_row(row, true)) + .transpose() } async fn is_linked_including_deleted( diff --git a/driver/src/database/postgres/account/row.rs b/driver/src/database/postgres/account/row.rs index afc63dcc..33ce89c5 100644 --- a/driver/src/database/postgres/account/row.rs +++ b/driver/src/database/postgres/account/row.rs @@ -1,7 +1,9 @@ +use error_stack::Report; use kernel::prelude::entity::{ - Account, AccountId, AccountIsBot, AccountName, AccountStatus, CreatedAt, DeletedAt, - EventVersion, Nanoid, + Account, AccountId, AccountIsBot, AccountKind, AccountName, AccountStatus, CreatedAt, + DeletedAt, EventVersion, Nanoid, }; +use kernel::KernelError; use sqlx::types::time::OffsetDateTime; #[derive(sqlx::FromRow)] @@ -9,6 +11,7 @@ pub(super) struct AccountRow { id: i64, name: String, is_bot: bool, + kind: String, deleted_at: Option, version: i64, nanoid: String, @@ -25,7 +28,18 @@ pub(super) struct AccountRow { /// When `check_suspend_expiry` is `false` (used for filtered queries where SQL already /// excludes expired suspensions), suspended_at is trusted as-is. /// When `true` (used for unfiltered queries), Rust-side expiry check is performed. -pub(super) fn account_from_row(value: AccountRow, check_suspend_expiry: bool) -> Account { +pub(super) fn account_from_row( + value: AccountRow, + check_suspend_expiry: bool, +) -> error_stack::Result { + let kind = match value.kind.as_str() { + "personal" => AccountKind::Personal, + "organization" => AccountKind::Organization, + unknown => { + return Err(Report::new(KernelError::Internal) + .attach_printable(format!("Unknown account kind: {unknown}"))) + } + }; let status = if let (Some(banned_at), Some(reason)) = (value.banned_at, value.ban_reason) { AccountStatus::Banned { reason, banned_at } } else if let (Some(suspended_at), Some(reason)) = @@ -60,20 +74,23 @@ pub(super) fn account_from_row(value: AccountRow, check_suspend_expiry: bool) -> AccountStatus::Active }; - Account::new( + Ok(Account::new( AccountId::new(value.id), AccountName::new(value.name), AccountIsBot::new(value.is_bot), + kind, status, value.deleted_at.map(DeletedAt::new), EventVersion::new(value.version), Nanoid::new(value.nanoid), CreatedAt::new(value.created_at), - ) + )) } -impl From for Account { - fn from(value: AccountRow) -> Self { +impl TryFrom for Account { + type Error = Report; + + fn try_from(value: AccountRow) -> Result { account_from_row(value, false) } } diff --git a/driver/src/database/postgres/account_repository.rs b/driver/src/database/postgres/account_repository.rs index 0981e888..d2205d16 100644 --- a/driver/src/database/postgres/account_repository.rs +++ b/driver/src/database/postgres/account_repository.rs @@ -55,7 +55,7 @@ mod test { use kernel::interfaces::event_store::{AccountEventStore, DependOnAccountEventStore}; use kernel::interfaces::repository::{AggregateRepository, DependOnAccountRepository}; use kernel::prelude::entity::{ - Account, AccountEvent, AccountId, AccountIsBot, AccountName, AuthAccountId, + Account, AccountEvent, AccountId, AccountIsBot, AccountKind, AccountName, AuthAccountId, CommandEnvelope, EventEnvelope, EventId, EventVersion, ExpectedVersion, Nanoid, }; use kernel::KernelError; @@ -67,6 +67,7 @@ mod test { AccountEvent::Created { name: AccountName::new("equivalence"), is_bot: AccountIsBot::new(false), + kind: AccountKind::Personal, nanoid: nanoid.clone(), auth_account_id: AuthAccountId::default(), }, diff --git a/driver/src/database/postgres/organization_membership.rs b/driver/src/database/postgres/organization_membership.rs new file mode 100644 index 00000000..e40bd8be --- /dev/null +++ b/driver/src/database/postgres/organization_membership.rs @@ -0,0 +1,381 @@ +use crate::database::{PostgresConnection, PostgresDatabase}; +use crate::ConvertError; +use error_stack::Report; +use kernel::interfaces::repository::{ + DependOnOrganizationMembershipRepository, OrganizationMembershipRepository, +}; +use kernel::prelude::entity::{ + AccountId, CreatedAt, OrgRole, OrganizationMembership, OrganizationMembershipStatus, +}; +use kernel::KernelError; +use sqlx::types::time::OffsetDateTime; +use sqlx::PgConnection; + +#[derive(sqlx::FromRow)] +struct OrganizationMembershipRow { + org_account_id: i64, + member_account_id: i64, + role: String, + status: String, + invited_by: i64, + created_at: OffsetDateTime, +} + +impl TryFrom for OrganizationMembership { + type Error = Report; + + fn try_from(value: OrganizationMembershipRow) -> Result { + let role = match value.role.as_str() { + "owner" => OrgRole::Owner, + "admin" => OrgRole::Admin, + "member" => OrgRole::Member, + unknown => { + return Err(Report::new(KernelError::Internal) + .attach_printable(format!("Unknown organization role: {unknown}"))) + } + }; + let status = match value.status.as_str() { + "pending" => OrganizationMembershipStatus::Pending, + "active" => OrganizationMembershipStatus::Active, + unknown => { + return Err(Report::new(KernelError::Internal).attach_printable(format!( + "Unknown organization membership status: {unknown}" + ))) + } + }; + Ok(OrganizationMembership::new( + AccountId::new(value.org_account_id), + AccountId::new(value.member_account_id), + role, + status, + AccountId::new(value.invited_by), + CreatedAt::new(value.created_at), + )) + } +} + +fn role_value(role: OrgRole) -> &'static str { + match role { + OrgRole::Owner => "owner", + OrgRole::Admin => "admin", + OrgRole::Member => "member", + } +} + +fn status_value(status: OrganizationMembershipStatus) -> &'static str { + match status { + OrganizationMembershipStatus::Pending => "pending", + OrganizationMembershipStatus::Active => "active", + } +} + +pub struct PostgresOrganizationMembershipRepository; + +impl OrganizationMembershipRepository for PostgresOrganizationMembershipRepository { + type Connection = PostgresConnection; + + async fn create( + &self, + executor: &mut Self::Connection, + membership: &OrganizationMembership, + ) -> error_stack::Result<(), KernelError> { + let con: &mut PgConnection = executor; + sqlx::query( + "INSERT INTO organization_members + (org_account_id, member_account_id, role, status, invited_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(membership.org_account_id().as_ref()) + .bind(membership.member_account_id().as_ref()) + .bind(role_value(*membership.role())) + .bind(status_value(*membership.status())) + .bind(membership.invited_by().as_ref()) + .bind(membership.created_at().as_ref()) + .execute(con) + .await + .convert_error()?; + Ok(()) + } + + async fn find( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + let con: &mut PgConnection = executor; + sqlx::query_as::<_, OrganizationMembershipRow>( + "SELECT org_account_id, member_account_id, role, status, invited_by, created_at + FROM organization_members WHERE org_account_id = $1 AND member_account_id = $2", + ) + .bind(org_account_id.as_ref()) + .bind(member_account_id.as_ref()) + .fetch_optional(con) + .await + .convert_error()? + .map(TryFrom::try_from) + .transpose() + } + + async fn find_by_org( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + let con: &mut PgConnection = executor; + sqlx::query_as::<_, OrganizationMembershipRow>( + "SELECT org_account_id, member_account_id, role, status, invited_by, created_at + FROM organization_members WHERE org_account_id = $1 ORDER BY created_at", + ) + .bind(org_account_id.as_ref()) + .fetch_all(con) + .await + .convert_error()? + .into_iter() + .map(TryFrom::try_from) + .collect() + } + + async fn find_by_member( + &self, + executor: &mut Self::Connection, + member_account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + let con: &mut PgConnection = executor; + sqlx::query_as::<_, OrganizationMembershipRow>( + "SELECT org_account_id, member_account_id, role, status, invited_by, created_at + FROM organization_members WHERE member_account_id = $1 ORDER BY created_at", + ) + .bind(member_account_id.as_ref()) + .fetch_all(con) + .await + .convert_error()? + .into_iter() + .map(TryFrom::try_from) + .collect() + } + + async fn update_role( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + role: OrgRole, + ) -> error_stack::Result<(), KernelError> { + let con: &mut PgConnection = executor; + let result = sqlx::query( + "UPDATE organization_members SET role = $3 + WHERE org_account_id = $1 AND member_account_id = $2", + ) + .bind(org_account_id.as_ref()) + .bind(member_account_id.as_ref()) + .bind(role_value(role)) + .execute(con) + .await + .convert_error()?; + if result.rows_affected() == 0 { + return Err(Report::new(KernelError::NotFound)); + } + Ok(()) + } + + async fn update_status( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + status: OrganizationMembershipStatus, + ) -> error_stack::Result<(), KernelError> { + let con: &mut PgConnection = executor; + let result = sqlx::query( + "UPDATE organization_members SET status = $3 + WHERE org_account_id = $1 AND member_account_id = $2", + ) + .bind(org_account_id.as_ref()) + .bind(member_account_id.as_ref()) + .bind(status_value(status)) + .execute(con) + .await + .convert_error()?; + if result.rows_affected() == 0 { + return Err(Report::new(KernelError::NotFound)); + } + Ok(()) + } + + async fn delete( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + ) -> error_stack::Result<(), KernelError> { + let con: &mut PgConnection = executor; + let result = sqlx::query( + "DELETE FROM organization_members WHERE org_account_id = $1 AND member_account_id = $2", + ) + .bind(org_account_id.as_ref()) + .bind(member_account_id.as_ref()) + .execute(con) + .await + .convert_error()?; + if result.rows_affected() == 0 { + return Err(Report::new(KernelError::NotFound)); + } + Ok(()) + } + + async fn count_active_owners( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> error_stack::Result { + let con: &mut PgConnection = executor; + sqlx::query_scalar( + "SELECT COUNT(*) FROM organization_members + WHERE org_account_id = $1 AND role = 'owner' AND status = 'active'", + ) + .bind(org_account_id.as_ref()) + .fetch_one(con) + .await + .convert_error() + } + + async fn lock_active_owner_rows( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> error_stack::Result<(), KernelError> { + let con: &mut PgConnection = executor; + sqlx::query_scalar::<_, i32>( + "SELECT 1 FROM organization_members + WHERE org_account_id = $1 AND role = 'owner' AND status = 'active' + FOR UPDATE", + ) + .bind(org_account_id.as_ref()) + .fetch_all(con) + .await + .convert_error()?; + Ok(()) + } +} + +impl DependOnOrganizationMembershipRepository for PostgresDatabase { + type OrganizationMembershipRepository = PostgresOrganizationMembershipRepository; + + fn organization_membership_repository(&self) -> &Self::OrganizationMembershipRepository { + &PostgresOrganizationMembershipRepository + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kernel::interfaces::database::DatabaseConnection; + use kernel::interfaces::read_model::{AccountReadModel, DependOnAccountReadModel}; + use kernel::prelude::entity::{Account, AccountKind, EventVersion, Nanoid}; + use kernel::test_utils::AccountBuilder; + + async fn create_account( + db: &PostgresDatabase, + conn: &mut PostgresConnection, + kind: AccountKind, + ) -> Account { + let account = AccountBuilder::new() + .kind(kind) + .name(kernel::test_utils::unique_account_name()) + .nanoid(Nanoid::default()) + .version(EventVersion::new(1)) + .build(); + db.account_read_model() + .create(conn, &account) + .await + .unwrap(); + account + } + + #[test_with::env(DATABASE_URL)] + #[tokio::test] + async fn crud_roundtrip() { + kernel::ensure_generator_initialized(); + let db = PostgresDatabase::new().await.unwrap(); + let mut conn = db.connection().await.unwrap(); + let org = create_account(&db, &mut conn, AccountKind::Organization).await; + let member = create_account(&db, &mut conn, AccountKind::Personal).await; + let inviter = create_account(&db, &mut conn, AccountKind::Personal).await; + // Postgres timestamptz stores microsecond precision; truncate to + // microseconds so the DB round-trip compares equal (CI coverage run + // 33505931615 failed on a sub-microsecond mismatch). + let now = OffsetDateTime::now_utc(); + let created_at = + CreatedAt::new(now - time::Duration::nanoseconds(i64::from(now.nanosecond() % 1_000))); + let membership = OrganizationMembership::new( + org.id().clone(), + member.id().clone(), + OrgRole::Member, + OrganizationMembershipStatus::Pending, + inviter.id().clone(), + created_at, + ); + + db.organization_membership_repository() + .create(&mut conn, &membership) + .await + .unwrap(); + assert_eq!( + db.organization_membership_repository() + .find(&mut conn, org.id(), member.id()) + .await + .unwrap(), + Some(membership) + ); + + db.organization_membership_repository() + .update_role(&mut conn, org.id(), member.id(), OrgRole::Admin) + .await + .unwrap(); + db.organization_membership_repository() + .update_status( + &mut conn, + org.id(), + member.id(), + OrganizationMembershipStatus::Active, + ) + .await + .unwrap(); + let updated = db + .organization_membership_repository() + .find(&mut conn, org.id(), member.id()) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.role(), &OrgRole::Admin); + assert_eq!(updated.status(), &OrganizationMembershipStatus::Active); + assert_eq!( + db.organization_membership_repository() + .find_by_org(&mut conn, org.id()) + .await + .unwrap() + .len(), + 1 + ); + assert_eq!( + db.organization_membership_repository() + .find_by_member(&mut conn, member.id()) + .await + .unwrap() + .len(), + 1 + ); + + db.organization_membership_repository() + .delete(&mut conn, org.id(), member.id()) + .await + .unwrap(); + assert!(db + .organization_membership_repository() + .find(&mut conn, org.id(), member.id()) + .await + .unwrap() + .is_none()); + } +} diff --git a/driver/src/database/postgres/projection.rs b/driver/src/database/postgres/projection.rs index 2f2efc9a..83af1c3e 100644 --- a/driver/src/database/postgres/projection.rs +++ b/driver/src/database/postgres/projection.rs @@ -8,9 +8,9 @@ use kernel::interfaces::projection::{ ProfileProjectionWriter, ProjectionCheckpointStore, SeqEvent, }; use kernel::prelude::entity::{ - Account, AccountEvent, AccountStatus, EventEnvelope, EventId, EventVersion, ImageId, Metadata, - MetadataEvent, MetadataId, Profile, ProfileDisplayName, ProfileEvent, ProfileId, - ProfileSummary, + Account, AccountEvent, AccountKind, AccountStatus, EventEnvelope, EventId, EventVersion, + ImageId, Metadata, MetadataEvent, MetadataId, Profile, ProfileDisplayName, ProfileEvent, + ProfileId, ProfileSummary, }; use kernel::KernelError; use serde_json; @@ -158,13 +158,14 @@ impl AccountProjectionWriter for PostgresAccountProjectionWriter { sqlx::query( //language=postgresql r#" - INSERT INTO accounts (id, name, is_bot, version, nanoid, created_at, + INSERT INTO accounts (id, name, is_bot, kind, version, nanoid, created_at, suspended_at, suspend_expires_at, suspend_reason, banned_at, ban_reason, deleted_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, is_bot = EXCLUDED.is_bot, + kind = EXCLUDED.kind, version = EXCLUDED.version, nanoid = EXCLUDED.nanoid, created_at = EXCLUDED.created_at, @@ -180,6 +181,10 @@ impl AccountProjectionWriter for PostgresAccountProjectionWriter { .bind(account.id().as_ref()) .bind(account.name().as_ref()) .bind(account.is_bot().as_ref()) + .bind(match account.kind() { + AccountKind::Personal => "personal", + AccountKind::Organization => "organization", + }) .bind(account.version().as_ref()) .bind(account.nanoid().as_ref()) .bind(account.created_at().as_ref()) diff --git a/kernel/src/entity.rs b/kernel/src/entity.rs index 98b501e0..5dec5693 100644 --- a/kernel/src/entity.rs +++ b/kernel/src/entity.rs @@ -10,6 +10,7 @@ mod follow; mod image; mod metadata; mod mute; +mod organization_membership; mod profile; mod remote_account; mod signing_key; @@ -26,6 +27,7 @@ pub use self::follow::*; pub use self::image::*; pub use self::metadata::*; pub use self::mute::*; +pub use self::organization_membership::*; pub use self::profile::*; pub use self::remote_account::*; pub use self::signing_key::*; diff --git a/kernel/src/entity/account.rs b/kernel/src/entity/account.rs index a9c75262..1417d7a7 100644 --- a/kernel/src/entity/account.rs +++ b/kernel/src/entity/account.rs @@ -15,12 +15,14 @@ use crate::KernelError; pub use self::id::*; pub use self::is_bot::*; +pub use self::kind::*; pub use self::moderation_reason::*; pub use self::name::*; pub use self::status::*; mod id; mod is_bot; +mod kind; mod moderation_reason; mod name; mod status; @@ -32,6 +34,7 @@ pub struct Account { id: AccountId, name: AccountName, is_bot: AccountIsBot, + kind: AccountKind, status: AccountStatus, deleted_at: Option>, version: EventVersion, @@ -46,6 +49,8 @@ pub enum AccountEvent { Created { name: AccountName, is_bot: AccountIsBot, + #[serde(default)] + kind: AccountKind, nanoid: Nanoid, auth_account_id: AuthAccountId, }, @@ -86,6 +91,29 @@ impl Account { let event = AccountEvent::Created { name, is_bot, + kind: AccountKind::Personal, + nanoid, + auth_account_id, + }; + CommandEnvelope::new( + EventId::from(id), + event.name(), + event, + Some(ExpectedVersion::Nothing), + ) + } + + pub fn create_organization( + id: AccountId, + name: AccountName, + is_bot: AccountIsBot, + nanoid: Nanoid, + auth_account_id: AuthAccountId, + ) -> CommandEnvelope { + let event = AccountEvent::Created { + name, + is_bot, + kind: AccountKind::Organization, nanoid, auth_account_id, }; @@ -224,6 +252,7 @@ impl EventApplier for Account { AccountEvent::Created { name, is_bot, + kind, nanoid: nano_id, auth_account_id: _, } => { @@ -237,6 +266,7 @@ impl EventApplier for Account { id: AccountId::new(*event.id.as_ref()), name, is_bot, + kind, status: AccountStatus::Active, deleted_at: None, version: event.version, @@ -355,8 +385,8 @@ impl EventApplier for Account { #[cfg(test)] mod test { use crate::entity::{ - Account, AccountEvent, AccountId, AccountIsBot, AccountName, AuthAccountId, EventEnvelope, - EventId, EventVersion, Nanoid, + Account, AccountEvent, AccountId, AccountIsBot, AccountKind, AccountName, AuthAccountId, + EventEnvelope, EventId, EventVersion, Nanoid, }; use crate::event::EventApplier; use crate::test_utils::AccountBuilder; @@ -372,6 +402,7 @@ mod test { let event = AccountEvent::Created { name: name.clone(), is_bot: is_bot.clone(), + kind: AccountKind::Personal, nanoid: nano_id.clone(), auth_account_id: AuthAccountId::default(), }; @@ -384,9 +415,33 @@ mod test { assert_eq!(account.id(), &id); assert_eq!(account.name(), &name); assert_eq!(account.is_bot(), &is_bot); + assert_eq!(account.kind(), &AccountKind::Personal); assert_eq!(account.nanoid(), &nano_id); } + #[test] + fn create_organization_sets_organization_kind() { + crate::ensure_generator_initialized(); + let id = AccountId::default(); + let command = Account::create_organization( + id.clone(), + AccountName::new("organization"), + AccountIsBot::new(false), + Nanoid::default(), + AuthAccountId::default(), + ); + let envelope = EventEnvelope::new( + EventId::from(id), + command.event().clone(), + EventVersion::default(), + ); + let mut account = None; + + Account::apply(&mut account, envelope).unwrap(); + + assert_eq!(account.unwrap().kind(), &AccountKind::Organization); + } + // Regression guard: pre-migration Created payloads carry key fields; replay must tolerate them. #[test] fn legacy_created_event_with_keys_deserializes() { @@ -426,6 +481,7 @@ mod test { let event = AccountEvent::Created { name: AccountName::new("test"), is_bot: AccountIsBot::new(false), + kind: AccountKind::Personal, nanoid: nano_id, auth_account_id: AuthAccountId::default(), }; diff --git a/kernel/src/entity/account/kind.rs b/kernel/src/entity/account/kind.rs new file mode 100644 index 00000000..73570a4c --- /dev/null +++ b/kernel/src/entity/account/kind.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountKind { + #[default] + Personal, + Organization, +} diff --git a/kernel/src/entity/organization_membership.rs b/kernel/src/entity/organization_membership.rs new file mode 100644 index 00000000..dfe06188 --- /dev/null +++ b/kernel/src/entity/organization_membership.rs @@ -0,0 +1,31 @@ +use crate::entity::{AccountId, CreatedAt}; +use destructure::Destructure; +use serde::{Deserialize, Serialize}; +use vodca::{Newln, References}; + +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrgRole { + Owner, + Admin, + Member, +} + +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrganizationMembershipStatus { + Pending, + Active, +} + +#[derive( + Debug, Clone, Hash, Eq, PartialEq, References, Newln, Serialize, Deserialize, Destructure, +)] +pub struct OrganizationMembership { + org_account_id: AccountId, + member_account_id: AccountId, + role: OrgRole, + status: OrganizationMembershipStatus, + invited_by: AccountId, + created_at: CreatedAt, +} diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index 1931fc1a..be4ad21c 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -127,6 +127,13 @@ macro_rules! impl_database_delegation { } } + impl $crate::interfaces::repository::DependOnOrganizationMembershipRepository for $impl_type { + type OrganizationMembershipRepository = <$db_type as $crate::interfaces::repository::DependOnOrganizationMembershipRepository>::OrganizationMembershipRepository; + fn organization_membership_repository(&self) -> &Self::OrganizationMembershipRepository { + $crate::interfaces::repository::DependOnOrganizationMembershipRepository::organization_membership_repository(&self.$field) + } + } + impl $crate::interfaces::read_model::DependOnProfileReadModel for $impl_type { type ProfileReadModel = <$db_type as $crate::interfaces::read_model::DependOnProfileReadModel>::ProfileReadModel; fn profile_read_model(&self) -> &Self::ProfileReadModel { diff --git a/kernel/src/read_model.rs b/kernel/src/read_model.rs index a38e4780..37168c8d 100644 --- a/kernel/src/read_model.rs +++ b/kernel/src/read_model.rs @@ -1,9 +1,11 @@ mod account; mod account_report; mod metadata; +mod organization_membership; mod profile; pub use self::account::*; pub use self::account_report::*; pub use self::metadata::*; +pub use self::organization_membership::*; pub use self::profile::*; diff --git a/kernel/src/read_model/organization_membership.rs b/kernel/src/read_model/organization_membership.rs new file mode 100644 index 00000000..43b10419 --- /dev/null +++ b/kernel/src/read_model/organization_membership.rs @@ -0,0 +1,91 @@ +use crate::database::{Connection, DatabaseConnection, DependOnDatabaseConnection}; +use crate::entity::{AccountId, OrganizationMembership}; +use crate::repository::{ + DependOnOrganizationMembershipRepository, OrganizationMembershipRepository, +}; +use crate::KernelError; +use std::future::Future; + +pub trait OrganizationMembershipQuery: Send + Sync + 'static { + type Connection: Connection; + + fn find( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + ) -> impl Future, KernelError>> + Send; + + fn find_by_org( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> impl Future, KernelError>> + Send; + + fn find_by_member( + &self, + executor: &mut Self::Connection, + member_account_id: &AccountId, + ) -> impl Future, KernelError>> + Send; +} + +impl OrganizationMembershipQuery for T +where + T: DependOnOrganizationMembershipRepository + Send + Sync + 'static, +{ + type Connection = <::OrganizationMembershipRepository as OrganizationMembershipRepository>::Connection; + + async fn find( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + self.organization_membership_repository() + .find(executor, org_account_id, member_account_id) + .await + } + + async fn find_by_org( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + self.organization_membership_repository() + .find_by_org(executor, org_account_id) + .await + } + + async fn find_by_member( + &self, + executor: &mut Self::Connection, + member_account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + self.organization_membership_repository() + .find_by_member(executor, member_account_id) + .await + } +} + +pub trait DependOnOrganizationMembershipQuery: DependOnDatabaseConnection + Send + Sync { + type OrganizationMembershipQuery: OrganizationMembershipQuery< + Connection = <::DatabaseConnection as DatabaseConnection>::Connection, + >; + + fn organization_membership_query(&self) -> &Self::OrganizationMembershipQuery; +} + +impl DependOnOrganizationMembershipQuery for T +where + T: DependOnOrganizationMembershipRepository + + DependOnDatabaseConnection + + Send + + Sync + + 'static, +{ + type OrganizationMembershipQuery = Self; + + fn organization_membership_query(&self) -> &Self::OrganizationMembershipQuery { + self + } +} diff --git a/kernel/src/repository.rs b/kernel/src/repository.rs index 76339fef..af93ef65 100644 --- a/kernel/src/repository.rs +++ b/kernel/src/repository.rs @@ -5,6 +5,7 @@ mod block; mod follow; mod image; mod mute; +mod organization_membership; mod outbox_activity; mod remote_account; @@ -15,5 +16,6 @@ pub use self::block::*; pub use self::follow::*; pub use self::image::*; pub use self::mute::*; +pub use self::organization_membership::*; pub use self::outbox_activity::*; pub use self::remote_account::*; diff --git a/kernel/src/repository/aggregate.rs b/kernel/src/repository/aggregate.rs index 9f725243..5f6afbd7 100644 --- a/kernel/src/repository/aggregate.rs +++ b/kernel/src/repository/aggregate.rs @@ -150,8 +150,8 @@ pub trait DependOnAccountReportRepository: Sync + Send + DependOnDatabaseConnect mod test { use super::Rehydrated; use crate::entity::{ - Account, AccountEvent, AccountId, AccountIsBot, AccountName, AuthAccountId, EventEnvelope, - EventId, EventVersion, Nanoid, + Account, AccountEvent, AccountId, AccountIsBot, AccountKind, AccountName, AuthAccountId, + EventEnvelope, EventId, EventVersion, Nanoid, }; use crate::KernelError; @@ -167,6 +167,7 @@ mod test { AccountEvent::Created { name: AccountName::new("test"), is_bot: AccountIsBot::new(false), + kind: AccountKind::Personal, nanoid: Nanoid::default(), auth_account_id: AuthAccountId::default(), } diff --git a/kernel/src/repository/organization_membership.rs b/kernel/src/repository/organization_membership.rs new file mode 100644 index 00000000..d7891eb0 --- /dev/null +++ b/kernel/src/repository/organization_membership.rs @@ -0,0 +1,78 @@ +use crate::database::{Connection, DatabaseConnection, DependOnDatabaseConnection}; +use crate::entity::{AccountId, OrgRole, OrganizationMembership, OrganizationMembershipStatus}; +use crate::KernelError; +use std::future::Future; + +pub trait OrganizationMembershipRepository: Sync + Send + 'static { + type Connection: Connection; + + fn create( + &self, + executor: &mut Self::Connection, + membership: &OrganizationMembership, + ) -> impl Future> + Send; + + fn find( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + ) -> impl Future, KernelError>> + Send; + + fn find_by_org( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> impl Future, KernelError>> + Send; + + fn find_by_member( + &self, + executor: &mut Self::Connection, + member_account_id: &AccountId, + ) -> impl Future, KernelError>> + Send; + + fn update_role( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + role: OrgRole, + ) -> impl Future> + Send; + + fn update_status( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + status: OrganizationMembershipStatus, + ) -> impl Future> + Send; + + fn delete( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + ) -> impl Future> + Send; + + fn count_active_owners( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> impl Future> + Send; + + fn lock_active_owner_rows( + &self, + executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> impl Future> + Send; +} + +pub trait DependOnOrganizationMembershipRepository: + Sync + Send + DependOnDatabaseConnection +{ + type OrganizationMembershipRepository: OrganizationMembershipRepository< + Connection = ::Connection, + >; + + fn organization_membership_repository(&self) -> &Self::OrganizationMembershipRepository; +} diff --git a/kernel/src/test_utils/account.rs b/kernel/src/test_utils/account.rs index cdfe5c6f..fe47c457 100644 --- a/kernel/src/test_utils/account.rs +++ b/kernel/src/test_utils/account.rs @@ -1,6 +1,6 @@ use crate::entity::{ - Account, AccountId, AccountIsBot, AccountName, AccountStatus, CreatedAt, DeletedAt, - EventVersion, Nanoid, + Account, AccountId, AccountIsBot, AccountKind, AccountName, AccountStatus, CreatedAt, + DeletedAt, EventVersion, Nanoid, }; use super::unique_account_name; @@ -9,6 +9,7 @@ pub struct AccountBuilder { id: Option, name: Option, is_bot: Option, + kind: Option, status: Option, deleted_at: Option>>, version: Option>, @@ -28,6 +29,7 @@ impl AccountBuilder { id: None, name: None, is_bot: None, + kind: None, status: None, deleted_at: None, version: None, @@ -51,6 +53,11 @@ impl AccountBuilder { self } + pub fn kind(mut self, kind: AccountKind) -> Self { + self.kind = Some(kind); + self + } + pub fn status(mut self, status: AccountStatus) -> Self { self.status = Some(status); self @@ -83,6 +90,7 @@ impl AccountBuilder { self.name .unwrap_or_else(|| AccountName::new(unique_account_name())), self.is_bot.unwrap_or_else(|| AccountIsBot::new(false)), + self.kind.unwrap_or_default(), self.status.unwrap_or_default(), self.deleted_at.unwrap_or(None), self.version.unwrap_or_default(), diff --git a/migrations/20260901000001_add_account_kind.sql b/migrations/20260901000001_add_account_kind.sql new file mode 100644 index 00000000..f9df17f0 --- /dev/null +++ b/migrations/20260901000001_add_account_kind.sql @@ -0,0 +1,6 @@ +ALTER TABLE "accounts" + ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'personal'; + +ALTER TABLE "accounts" + ADD CONSTRAINT chk_account_kind + CHECK (kind IN ('personal', 'organization')); diff --git a/migrations/20260901000002_add_organization_members.sql b/migrations/20260901000002_add_organization_members.sql new file mode 100644 index 00000000..57cc5fab --- /dev/null +++ b/migrations/20260901000002_add_organization_members.sql @@ -0,0 +1,25 @@ +CREATE TABLE "organization_members" ( + "org_account_id" BIGINT NOT NULL, + "member_account_id" BIGINT NOT NULL, + "role" TEXT NOT NULL, + "status" TEXT NOT NULL, + "invited_by" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY ("org_account_id", "member_account_id"), + CONSTRAINT chk_organization_member_role + CHECK (role IN ('owner', 'admin', 'member')), + CONSTRAINT chk_organization_member_status + CHECK (status IN ('pending', 'active')) +); + +CREATE INDEX idx_organization_members_member_account_id + ON organization_members (member_account_id); + +ALTER TABLE "organization_members" + ADD FOREIGN KEY ("org_account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + +ALTER TABLE "organization_members" + ADD FOREIGN KEY ("member_account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + +ALTER TABLE "organization_members" + ADD FOREIGN KEY ("invited_by") REFERENCES "accounts" ("id") ON DELETE CASCADE; diff --git a/openapi.json b/openapi.json index 32c079e7..1434cb3b 100644 --- a/openapi.json +++ b/openapi.json @@ -1526,6 +1526,302 @@ ] } }, + "/api/v1/me/organizations": { + "get": { + "tags": [ + "Organization" + ], + "operationId": "list_my_organizations", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyOrganizationsResponse" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/organizations": { + "post": { + "tags": [ + "Organization" + ], + "operationId": "create_organization", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationResponse" + } + } + } + }, + "400": { + "description": "" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/organizations/{org}/invites": { + "post": { + "tags": [ + "Organization" + ], + "operationId": "invite_member", + "parameters": [ + { + "name": "org", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InviteMemberRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + }, + "403": { + "description": "" + }, + "404": { + "description": "" + }, + "422": { + "description": "" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/organizations/{org}/invites/{account_id}/accept": { + "post": { + "tags": [ + "Organization" + ], + "operationId": "accept_invite", + "parameters": [ + { + "name": "org", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "account_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "403": { + "description": "" + }, + "404": { + "description": "" + }, + "422": { + "description": "" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/organizations/{org}/members": { + "get": { + "tags": [ + "Organization" + ], + "operationId": "list_organization_members", + "parameters": [ + { + "name": "org", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMembersResponse" + } + } + } + }, + "403": { + "description": "" + }, + "404": { + "description": "" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/organizations/{org}/members/{account_id}": { + "delete": { + "tags": [ + "Organization" + ], + "operationId": "remove_member", + "parameters": [ + { + "name": "org", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "account_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "403": { + "description": "" + }, + "404": { + "description": "" + }, + "422": { + "description": "" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/organizations/{org}/members/{account_id}/role": { + "put": { + "tags": [ + "Organization" + ], + "operationId": "change_role", + "parameters": [ + { + "name": "org", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "account_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeRoleRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + }, + "403": { + "description": "" + }, + "404": { + "description": "" + }, + "422": { + "description": "" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/api/v1/reports": { "post": { "tags": [ @@ -2048,6 +2344,17 @@ } } }, + "ChangeRoleRequest": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "$ref": "#/components/schemas/OrganizationRole" + } + } + }, "CloseReportRequest": { "type": "object", "required": [ @@ -2098,6 +2405,17 @@ } } }, + "CreateOrganizationRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "CreateReportRequest": { "type": "object", "required": [ @@ -2178,6 +2496,21 @@ } } }, + "InviteMemberRequest": { + "type": "object", + "required": [ + "account_id", + "role" + ], + "properties": { + "account_id": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/OrganizationRole" + } + } + }, "MeResponse": { "type": "object", "required": [ @@ -2196,6 +2529,13 @@ } } }, + "MembershipStatus": { + "type": "string", + "enum": [ + "pending", + "active" + ] + }, "ModerationResponse": { "oneOf": [ { @@ -2265,6 +2605,35 @@ } } }, + "MyOrganizationResponse": { + "type": "object", + "required": [ + "organization", + "role" + ], + "properties": { + "organization": { + "$ref": "#/components/schemas/OrganizationResponse" + }, + "role": { + "$ref": "#/components/schemas/OrganizationRole" + } + } + }, + "MyOrganizationsResponse": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MyOrganizationResponse" + } + } + } + }, "OAuth2Response": { "oneOf": [ { @@ -2368,6 +2737,77 @@ } } }, + "OrganizationMemberResponse": { + "type": "object", + "required": [ + "account_id", + "name", + "role", + "status", + "invited_by", + "created_at" + ], + "properties": { + "account_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "invited_by": { + "type": "string", + "description": "Nanoid of the account that issued the invitation.", + "example": "V1StGXR8_Z5jdHi6B-myT" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/OrganizationRole" + }, + "status": { + "$ref": "#/components/schemas/MembershipStatus" + } + } + }, + "OrganizationMembersResponse": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationMemberResponse" + } + } + } + }, + "OrganizationResponse": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "OrganizationRole": { + "type": "string", + "enum": [ + "owner", + "admin", + "member" + ] + }, "PublicKey": { "type": "object", "description": "An ActivityPub public key object attached to an Actor.", @@ -2671,6 +3111,10 @@ { "name": "Report", "description": "Account moderation reports" + }, + { + "name": "Organization", + "description": "Organization account and membership management" } ] } \ No newline at end of file diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index a10a6428..6e23b081 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod admin_account; pub(crate) mod me; pub(crate) mod media; pub(crate) mod oauth2; +pub(crate) mod organization; pub(crate) mod report; pub(crate) mod signing; @@ -30,6 +31,7 @@ pub(crate) use admin_account::AdminAccountApi; pub(crate) use me::MeApi; pub(crate) use media::MediaApi; pub(crate) use oauth2::OAuth2Api; +pub(crate) use organization::OrgAccountApi; pub(crate) use report::{AdminReportApi, ReportApi}; pub(crate) use signing::SigningApi; diff --git a/server/src/api/organization.rs b/server/src/api/organization.rs new file mode 100644 index 00000000..49d309af --- /dev/null +++ b/server/src/api/organization.rs @@ -0,0 +1,100 @@ +use super::resolve_auth_account_id; +use crate::auth::OidcAuthInfo; +use crate::handler::AppModule; +use application::dto::organization::{ + CreateOrganizationDto, MyOrganizationDto, OrganizationMemberDto, OrganizationSummaryDto, +}; +use application::service::organization::{ + AcceptInviteUseCase, ChangeRoleUseCase, CreateOrganizationUseCase, InviteMemberUseCase, + LeaveOrganizationUseCase, ListMyOrganizationsUseCase, ListOrganizationMembersUseCase, + RemoveMemberUseCase, +}; +use axum::extract::FromRef; +use kernel::prelude::entity::{AuthAccountId, OrgRole}; +use kernel::KernelError; +use std::sync::Arc; + +#[derive(Clone)] +pub struct OrgAccountApi { + module: Arc, +} + +impl OrgAccountApi { + pub fn new(module: Arc) -> Self { + Self { module } + } + pub async fn resolve_auth_account_id( + &self, + info: OidcAuthInfo, + ) -> error_stack::Result { + resolve_auth_account_id(&self.module, info).await + } + pub async fn create( + &self, + auth: AuthAccountId, + dto: CreateOrganizationDto, + ) -> error_stack::Result { + self.module.create_organization(auth, dto).await + } + pub async fn list_mine( + &self, + auth: &AuthAccountId, + ) -> error_stack::Result, KernelError> { + self.module.list_my_organizations(auth).await + } + pub async fn list_members( + &self, + auth: &AuthAccountId, + org: String, + ) -> error_stack::Result, KernelError> { + self.module.list_organization_members(auth, org).await + } + pub async fn invite( + &self, + auth: AuthAccountId, + org: String, + target: String, + role: OrgRole, + ) -> error_stack::Result<(), KernelError> { + self.module.invite_member(auth, org, target, role).await + } + pub async fn accept( + &self, + auth: AuthAccountId, + org: String, + actor: String, + ) -> error_stack::Result<(), KernelError> { + self.module.accept_invite(auth, org, actor).await + } + pub async fn change_role( + &self, + auth: AuthAccountId, + org: String, + member: String, + role: OrgRole, + ) -> error_stack::Result<(), KernelError> { + self.module.change_role(auth, org, member, role).await + } + pub async fn remove( + &self, + auth: AuthAccountId, + org: String, + member: String, + ) -> error_stack::Result<(), KernelError> { + self.module.remove_member(auth, org, member).await + } + pub async fn leave( + &self, + auth: AuthAccountId, + org: String, + actor: String, + ) -> error_stack::Result<(), KernelError> { + self.module.leave_organization(auth, org, actor).await + } +} + +impl FromRef for OrgAccountApi { + fn from_ref(module: &AppModule) -> Self { + Self::new(Arc::new(module.clone())) + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 7f052003..c27fc4b2 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,7 +13,7 @@ use crate::auth::{JwksCache, OidcConfig}; use crate::error::StackTrace; use crate::handler::AppModule; use crate::projection_worker::{projection_poll_interval_from_env, ProjectionWorker}; -use crate::route::account::{AccountRouter, AdminAccountRouter}; +use crate::route::account::{AccountRouter, AdminAccountRouter, OrgAccountRouter}; use crate::route::activitypub::{ActivityPubRouter, FederationRouter}; use crate::route::me::MeRouter; use crate::route::media::MediaRouter; @@ -95,6 +95,7 @@ async fn main() -> Result<(), StackTrace> { // Admin authorization (Keto instance_moderate) lives inside the use cases. let api_v1 = axum::Router::new() .route_account() + .route_org_account() .route_reports() .route_me() .route_media() diff --git a/server/src/openapi.rs b/server/src/openapi.rs index 49ae4556..aa2cdcce 100644 --- a/server/src/openapi.rs +++ b/server/src/openapi.rs @@ -51,6 +51,13 @@ impl Modify for SecurityAddon { crate::route::account::mute_account, crate::route::account::unmute_account, crate::route::account::get_mutes, + crate::route::account::create_organization, + crate::route::account::list_my_organizations, + crate::route::account::list_organization_members, + crate::route::account::invite_member, + crate::route::account::accept_invite, + crate::route::account::change_role, + crate::route::account::remove_member, crate::route::me::get_me, crate::route::media::upload_image, crate::route::oauth2::login, @@ -101,6 +108,16 @@ impl Modify for SecurityAddon { crate::schema::report::CloseReportRequest, crate::schema::report::AccountReportResponse, crate::schema::report::AccountReportListResponse, + crate::schema::organization::CreateOrganizationRequest, + crate::schema::organization::InviteMemberRequest, + crate::schema::organization::ChangeRoleRequest, + crate::schema::organization::OrganizationRole, + crate::schema::organization::MembershipStatus, + crate::schema::organization::OrganizationResponse, + crate::schema::organization::MyOrganizationResponse, + crate::schema::organization::MyOrganizationsResponse, + crate::schema::organization::OrganizationMemberResponse, + crate::schema::organization::OrganizationMembersResponse, )), modifiers(&SecurityAddon), tags( @@ -111,6 +128,7 @@ impl Modify for SecurityAddon { (name = "Signing", description = "HTTP Signature signing"), (name = "ActivityPub", description = "ActivityPub discovery and actor endpoints"), (name = "Report", description = "Account moderation reports"), + (name = "Organization", description = "Organization account and membership management"), ) )] #[allow(dead_code)] // utoipa OpenApiマクロ内部で使用される diff --git a/server/src/route.rs b/server/src/route.rs index 6728dcae..c08b0ab1 100644 --- a/server/src/route.rs +++ b/server/src/route.rs @@ -74,7 +74,7 @@ pub(crate) fn build_test_router_with_auth( oidc_config: std::sync::Arc, jwks_cache: std::sync::Arc, ) -> axum::Router { - use crate::route::account::{AccountRouter, AdminAccountRouter}; + use crate::route::account::{AccountRouter, AdminAccountRouter, OrgAccountRouter}; use crate::route::activitypub::{ActivityPubRouter, FederationRouter}; use crate::route::me::MeRouter; use crate::route::media::MediaRouter; @@ -84,6 +84,7 @@ pub(crate) fn build_test_router_with_auth( let api_v1 = axum::Router::new() .route_account() + .route_org_account() .route_reports() .route_me() .route_media() diff --git a/server/src/route/account/mod.rs b/server/src/route/account/mod.rs index 8208d77a..b2726696 100644 --- a/server/src/route/account/mod.rs +++ b/server/src/route/account/mod.rs @@ -3,6 +3,7 @@ mod block_mute; mod client; mod follow; mod follow_relations; +mod organization; mod unfollow; pub(crate) use admin::{ __path_assign_instance_role, __path_ban_account_by_id, __path_revoke_instance_role, @@ -25,6 +26,12 @@ pub(crate) use follow::{__path_follow_account, follow_account}; pub(crate) use follow_relations::{ __path_get_followers, __path_get_following, get_followers, get_following, }; +pub(crate) use organization::{ + __path_accept_invite, __path_change_role, __path_create_organization, __path_invite_member, + __path_list_my_organizations, __path_list_organization_members, __path_remove_member, + accept_invite, change_role, create_organization, invite_member, list_my_organizations, + list_organization_members, remove_member, +}; pub(crate) use unfollow::{__path_unfollow_account, unfollow_account}; use crate::handler::AppModule; @@ -39,6 +46,10 @@ pub trait AdminAccountRouter { fn route_admin_account(self) -> Self; } +pub trait OrgAccountRouter { + fn route_org_account(self) -> Self; +} + impl AccountRouter for Router { fn route_account(self) -> Self { self.route("/accounts", get(get_accounts)) @@ -63,6 +74,30 @@ impl AccountRouter for Router { } } +impl OrgAccountRouter for Router { + fn route_org_account(self) -> Self { + self.route("/organizations", post(create_organization)) + .route("/me/organizations", get(list_my_organizations)) + .route( + "/organizations/{org}/members", + get(list_organization_members), + ) + .route("/organizations/{org}/invites", post(invite_member)) + .route( + "/organizations/{org}/invites/{account_id}/accept", + post(accept_invite), + ) + .route( + "/organizations/{org}/members/{account_id}/role", + put(change_role), + ) + .route( + "/organizations/{org}/members/{account_id}", + delete(remove_member), + ) + } +} + impl AdminAccountRouter for Router { fn route_admin_account(self) -> Self { self.route( diff --git a/server/src/route/account/organization.rs b/server/src/route/account/organization.rs new file mode 100644 index 00000000..c4a42743 --- /dev/null +++ b/server/src/route/account/organization.rs @@ -0,0 +1,138 @@ +use crate::api::OrgAccountApi; +use crate::auth::{AuthClaims, OidcAuthInfo}; +use crate::error::ErrorStatus; +use crate::schema::organization::{ + ChangeRoleRequest, CreateOrganizationRequest, InviteMemberRequest, MyOrganizationResponse, + MyOrganizationsResponse, OrganizationMemberResponse, OrganizationMembersResponse, + OrganizationResponse, +}; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::{Extension, Json}; + +async fn auth( + api: &OrgAccountApi, + claims: AuthClaims, +) -> Result { + api.resolve_auth_account_id(OidcAuthInfo::from(claims)) + .await + .map_err(ErrorStatus::from) +} + +#[utoipa::path(post, path = "/api/v1/organizations", request_body = CreateOrganizationRequest, responses((status = 201, body = OrganizationResponse), (status = 400)), security(("bearer_auth" = [])), tag = "Organization")] +pub(crate) async fn create_organization( + Extension(claims): Extension, + State(api): State, + Json(request): Json, +) -> Result<(StatusCode, Json), ErrorStatus> { + if request.name.trim().is_empty() || request.name.len() > 100 { + return Err(( + StatusCode::BAD_REQUEST, + "Organization name must be between 1 and 100 characters".to_string(), + ) + .into()); + } + let auth_id = auth(&api, claims).await?; + let result = api + .create(auth_id, request.into_dto()) + .await + .map_err(ErrorStatus::from)?; + Ok((StatusCode::CREATED, Json(result.into()))) +} + +#[utoipa::path(get, path = "/api/v1/me/organizations", responses((status = 200, body = MyOrganizationsResponse)), security(("bearer_auth" = [])), tag = "Organization")] +pub(crate) async fn list_my_organizations( + Extension(claims): Extension, + State(api): State, +) -> Result, ErrorStatus> { + let auth_id = auth(&api, claims).await?; + let items = api.list_mine(&auth_id).await.map_err(ErrorStatus::from)?; + Ok(Json(MyOrganizationsResponse { + items: items + .into_iter() + .map(MyOrganizationResponse::from) + .collect(), + })) +} + +#[utoipa::path(get, path = "/api/v1/organizations/{org}/members", params(("org" = String, Path)), responses((status = 200, body = OrganizationMembersResponse), (status = 403), (status = 404)), security(("bearer_auth" = [])), tag = "Organization")] +pub(crate) async fn list_organization_members( + Extension(claims): Extension, + State(api): State, + Path(org): Path, +) -> Result, ErrorStatus> { + let auth_id = auth(&api, claims).await?; + let items = api + .list_members(&auth_id, org) + .await + .map_err(ErrorStatus::from)?; + Ok(Json(OrganizationMembersResponse { + items: items + .into_iter() + .map(OrganizationMemberResponse::from) + .collect(), + })) +} + +#[utoipa::path(post, path = "/api/v1/organizations/{org}/invites", params(("org" = String, Path)), request_body = InviteMemberRequest, responses((status = 204), (status = 403), (status = 404), (status = 422)), security(("bearer_auth" = [])), tag = "Organization")] +pub(crate) async fn invite_member( + Extension(claims): Extension, + State(api): State, + Path(org): Path, + Json(request): Json, +) -> Result { + let auth_id = auth(&api, claims).await?; + api.invite(auth_id, org, request.account_id, request.role.into()) + .await + .map_err(ErrorStatus::from)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(post, path = "/api/v1/organizations/{org}/invites/{account_id}/accept", params(("org" = String, Path), ("account_id" = String, Path)), responses((status = 204), (status = 403), (status = 404), (status = 422)), security(("bearer_auth" = [])), tag = "Organization")] +pub(crate) async fn accept_invite( + Extension(claims): Extension, + State(api): State, + Path((org, account_id)): Path<(String, String)>, +) -> Result { + let auth_id = auth(&api, claims).await?; + api.accept(auth_id, org, account_id) + .await + .map_err(ErrorStatus::from)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(put, path = "/api/v1/organizations/{org}/members/{account_id}/role", params(("org" = String, Path), ("account_id" = String, Path)), request_body = ChangeRoleRequest, responses((status = 204), (status = 403), (status = 404), (status = 422)), security(("bearer_auth" = [])), tag = "Organization")] +pub(crate) async fn change_role( + Extension(claims): Extension, + State(api): State, + Path((org, account_id)): Path<(String, String)>, + Json(request): Json, +) -> Result { + let auth_id = auth(&api, claims).await?; + api.change_role(auth_id, org, account_id, request.role.into()) + .await + .map_err(ErrorStatus::from)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(delete, path = "/api/v1/organizations/{org}/members/{account_id}", params(("org" = String, Path), ("account_id" = String, Path)), responses((status = 204), (status = 403), (status = 404), (status = 422)), security(("bearer_auth" = [])), tag = "Organization")] +pub(crate) async fn remove_member( + Extension(claims): Extension, + State(api): State, + Path((org, account_id)): Path<(String, String)>, +) -> Result { + let auth_id = auth(&api, claims).await?; + match api + .leave(auth_id.clone(), org.clone(), account_id.clone()) + .await + { + Ok(()) => Ok(StatusCode::NO_CONTENT), + Err(error) if error.current_context() == &kernel::KernelError::PermissionDenied => { + api.remove(auth_id, org, account_id) + .await + .map_err(ErrorStatus::from)?; + Ok(StatusCode::NO_CONTENT) + } + Err(error) => Err(ErrorStatus::from(error)), + } +} diff --git a/server/src/schema.rs b/server/src/schema.rs index 6df01bbe..bff8da8d 100644 --- a/server/src/schema.rs +++ b/server/src/schema.rs @@ -2,4 +2,5 @@ pub mod account; pub mod me; pub mod media; pub mod oauth2; +pub mod organization; pub mod report; diff --git a/server/src/schema/organization.rs b/server/src/schema/organization.rs new file mode 100644 index 00000000..4d6d474c --- /dev/null +++ b/server/src/schema/organization.rs @@ -0,0 +1,139 @@ +use application::dto::organization::{ + CreateOrganizationDto, MyOrganizationDto, OrganizationMemberDto, OrganizationSummaryDto, +}; +use kernel::prelude::entity::{OrgRole, OrganizationMembershipStatus}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateOrganizationRequest { + pub name: String, +} + +impl CreateOrganizationRequest { + pub fn into_dto(self) -> CreateOrganizationDto { + CreateOrganizationDto { name: self.name } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum OrganizationRole { + Owner, + Admin, + Member, +} + +impl From for OrgRole { + fn from(value: OrganizationRole) -> Self { + match value { + OrganizationRole::Owner => OrgRole::Owner, + OrganizationRole::Admin => OrgRole::Admin, + OrganizationRole::Member => OrgRole::Member, + } + } +} + +impl From for OrganizationRole { + fn from(value: OrgRole) -> Self { + match value { + OrgRole::Owner => OrganizationRole::Owner, + OrgRole::Admin => OrganizationRole::Admin, + OrgRole::Member => OrganizationRole::Member, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MembershipStatus { + Pending, + Active, +} + +impl From for MembershipStatus { + fn from(value: OrganizationMembershipStatus) -> Self { + match value { + OrganizationMembershipStatus::Pending => MembershipStatus::Pending, + OrganizationMembershipStatus::Active => MembershipStatus::Active, + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct InviteMemberRequest { + pub account_id: String, + pub role: OrganizationRole, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ChangeRoleRequest { + pub role: OrganizationRole, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct OrganizationResponse { + pub id: String, + pub name: String, +} + +impl From for OrganizationResponse { + fn from(value: OrganizationSummaryDto) -> Self { + Self { + id: value.account_id, + name: value.name, + } + } +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct MyOrganizationResponse { + pub organization: OrganizationResponse, + pub role: OrganizationRole, +} + +impl From for MyOrganizationResponse { + fn from(value: MyOrganizationDto) -> Self { + Self { + organization: value.organization.into(), + role: value.role.into(), + } + } +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct MyOrganizationsResponse { + pub items: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct OrganizationMemberResponse { + pub account_id: String, + pub name: String, + pub role: OrganizationRole, + pub status: MembershipStatus, + /// Nanoid of the account that issued the invitation. + #[schema(example = "V1StGXR8_Z5jdHi6B-myT")] + pub invited_by: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, +} + +impl From for OrganizationMemberResponse { + fn from(value: OrganizationMemberDto) -> Self { + Self { + account_id: value.account_id, + name: value.name, + role: value.role.into(), + status: value.status.into(), + invited_by: value.invited_by, + created_at: value.created_at, + } + } +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct OrganizationMembersResponse { + pub items: Vec, +}