diff --git a/docs/design-principles/0150-all-use-cases.md b/docs/design-principles/0150-all-use-cases.md index 77a5f7ec..c1bebf65 100644 --- a/docs/design-principles/0150-all-use-cases.md +++ b/docs/design-principles/0150-all-use-cases.md @@ -80,12 +80,12 @@ These are the main use cases of this product that are exposed via "public" APIs #### Machines -1. Register a new machine +1. Register a new machine (anonymously or by authenticated user) #### Password Credentials 1. Authenticate the current user (with a password) -2. Register a new person (with a password, and optional invitation) +2. Register a new person (with a password and with optional invitation) 3. Confirm registration of a person (from email) #### Single-Sign On @@ -98,9 +98,9 @@ TBD ### Organizations -1. Create a new organization for the current user +1. Create a new (shared) organization for the current user 2. Inspect a specific organization -3. Invite another user to the organization (by email, or an existing user by email or by ID) +3. Invite another guest or person to an organization (guest by email, or an existing person by email or by ID) ### Subscriptions diff --git a/src/Application.Resources.Shared/EndUser.cs b/src/Application.Resources.Shared/EndUser.cs index 82b43fee..bd52f492 100644 --- a/src/Application.Resources.Shared/EndUser.cs +++ b/src/Application.Resources.Shared/EndUser.cs @@ -56,6 +56,15 @@ public class Membership : IIdentifiableResource public List Roles { get; set; } = new(); public required string Id { get; set; } + + public required string UserId { get; set; } +} + +public class MembershipWithUserProfile : Membership +{ + public EndUserStatus Status { get; set; } + + public required UserProfile Profile { get; set; } } public class Invitation diff --git a/src/Application.Resources.Shared/Organization.cs b/src/Application.Resources.Shared/Organization.cs index 63dde585..7a40e8d3 100644 --- a/src/Application.Resources.Shared/Organization.cs +++ b/src/Application.Resources.Shared/Organization.cs @@ -24,4 +24,27 @@ public enum OrganizationOwnership { Shared = 0, Personal = 1 +} + +public class OrganizationMember : IIdentifiableResource +{ + public UserProfileClassification Classification { get; set; } + + public string? EmailAddress { get; set; } + + public List Features { get; set; } = new(); + + public bool IsDefault { get; set; } + + public bool IsOwner { get; set; } + + public bool IsRegistered { get; set; } + + public required PersonName Name { get; set; } + + public List Roles { get; set; } = new(); + + public required string UserId { get; set; } + + public required string Id { get; set; } } \ No newline at end of file diff --git a/src/Application.Resources.Shared/UserProfile.cs b/src/Application.Resources.Shared/UserProfile.cs index 4e80cfaf..016b0dfc 100644 --- a/src/Application.Resources.Shared/UserProfile.cs +++ b/src/Application.Resources.Shared/UserProfile.cs @@ -19,14 +19,14 @@ public class UserProfile : IIdentifiableResource public string? Timezone { get; set; } - public UserProfileType Type { get; set; } + public UserProfileClassification Classification { get; set; } public required string UserId { get; set; } public required string Id { get; set; } } -public enum UserProfileType +public enum UserProfileClassification { Person = 0, Machine = 1 diff --git a/src/Application.Services.Shared/IEndUsersService.cs b/src/Application.Services.Shared/IEndUsersService.cs index 13dd8560..b6a8888b 100644 --- a/src/Application.Services.Shared/IEndUsersService.cs +++ b/src/Application.Services.Shared/IEndUsersService.cs @@ -15,12 +15,17 @@ Task, Error>> FindPersonByEmailPrivateAsync(ICallerCont Task> GetMembershipsPrivateAsync(ICallerContext caller, string id, CancellationToken cancellationToken); + Task> InviteMemberToOrganizationPrivateAsync(ICallerContext caller, string organizationId, + string? userId, string? emailAddress, CancellationToken cancellationToken); + + Task, Error>> ListMembershipsForOrganizationAsync( + ICallerContext caller, + string organizationId, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken); + Task> RegisterMachinePrivateAsync(ICallerContext caller, string name, - string? timezone, - string? countryCode, CancellationToken cancellationToken); + string? timezone, string? countryCode, CancellationToken cancellationToken); Task> RegisterPersonPrivateAsync(ICallerContext caller, string? invitationToken, - string emailAddress, - string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, - CancellationToken cancellationToken); + string emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, + bool termsAndConditionsAccepted, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Services.Shared/IUserProfilesService.cs b/src/Application.Services.Shared/IUserProfilesService.cs index 1c05bd1a..62bc3705 100644 --- a/src/Application.Services.Shared/IUserProfilesService.cs +++ b/src/Application.Services.Shared/IUserProfilesService.cs @@ -17,6 +17,9 @@ Task> CreatePersonProfilePrivateAsync(ICallerContext Task, Error>> FindPersonByEmailAddressPrivateAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken); + Task, Error>> GetAllProfilesPrivateAsync(ICallerContext caller, List ids, + GetOptions options, CancellationToken cancellationToken); + Task> GetProfilePrivateAsync(ICallerContext caller, string userId, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index 0492b61a..4f3abc30 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -124,7 +124,7 @@ public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegis .ReturnsAsync(new UserProfile { Id = "aprofileid", - Type = UserProfileType.Person, + Classification = UserProfileClassification.Person, UserId = "apersonid", DisplayName = "afirstname", Name = new PersonName @@ -206,7 +206,7 @@ await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(), .ReturnsAsync(new UserProfile { Id = "aprofileid", - Type = UserProfileType.Person, + Classification = UserProfileClassification.Person, UserId = "apersonid", DisplayName = "afirstname", Name = new PersonName @@ -276,7 +276,7 @@ public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenReg .ReturnsAsync(new UserProfile { Id = "aprofileid", - Type = UserProfileType.Person, + Classification = UserProfileClassification.Person, UserId = "apersonid", DisplayName = "afirstname", Name = new PersonName @@ -346,7 +346,7 @@ public async Task .ReturnsAsync(new UserProfile { Id = "anotherprofileid", - Type = UserProfileType.Person, + Classification = UserProfileClassification.Person, UserId = "anotherpersonid", DisplayName = "afirstname", Name = new PersonName @@ -394,7 +394,7 @@ public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyE .ReturnsAsync(new UserProfile { Id = "aprofileid", - Type = UserProfileType.Person, + Classification = UserProfileClassification.Person, UserId = "auserid", DisplayName = "afirstname", Name = new PersonName @@ -468,7 +468,7 @@ public async Task WhenRegisterPersonAsyncAndNeverRegisteredNorInvitedAsGuest_The .ReturnsAsync(new UserProfile { Id = "aprofileid", - Type = UserProfileType.Person, + Classification = UserProfileClassification.Person, UserId = "apersonid", DisplayName = "afirstname", Name = new PersonName @@ -523,7 +523,7 @@ public async Task WhenRegisterMachineAsyncByAnonymousUser_ThenRegistersWithNoFea .ReturnsAsync(new UserProfile { Id = "aprofileid", - Type = UserProfileType.Machine, + Classification = UserProfileClassification.Machine, UserId = "amachineid", DisplayName = "amachinename", Name = new PersonName @@ -582,7 +582,7 @@ public async Task WhenRegisterMachineAsyncByAuthenticatedUser_ThenRegistersWithB .ReturnsAsync(new UserProfile { Id = "aprofileid", - Type = UserProfileType.Machine, + Classification = UserProfileClassification.Machine, UserId = "amachineid", DisplayName = "amachinename", Name = new PersonName diff --git a/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs index 9096fef5..86cfcaa3 100644 --- a/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs @@ -23,7 +23,7 @@ public class InvitationsApplicationSpec { private readonly InvitationsApplication _application; private readonly Mock _caller; - private readonly Mock _repository; + private readonly Mock _invitationRepository; private readonly Mock _notificationsService; private readonly Mock _recorder; private readonly Mock _tokensService; @@ -40,11 +40,8 @@ public InvitationsApplicationSpec() settings.Setup( s => s.Platform.GetString(EndUsersApplication.PermittedOperatorsSettingName, It.IsAny())) .Returns(""); - _repository = new Mock(); - _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) - .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root)); - var endUserRepository = new Mock(); - endUserRepository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + _invitationRepository = new Mock(); + _invitationRepository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root)); _userProfilesService = new Mock(); _notificationsService = new Mock(); @@ -54,7 +51,7 @@ public InvitationsApplicationSpec() _application = new InvitationsApplication(_recorder.Object, idFactory.Object, _tokensService.Object, - _notificationsService.Object, _userProfilesService.Object, _repository.Object); + _notificationsService.Object, _userProfilesService.Object, _invitationRepository.Object); } [Fact] @@ -64,15 +61,15 @@ public async Task WhenInviteGuestAsyncAndInviteeAlreadyRegistered_ThenReturnsErr .Returns("aninviterid"); var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); var invitee = EndUserRoot .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; invitee.Register(Roles.Empty, Features.Empty, EmailAddress.Create("aninvitee@company.com").Value); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(invitee.ToOptional()); - _repository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())) .ReturnsAsync(invitee); _userProfilesService.Setup(ups => ups.GetProfilePrivateAsync(It.IsAny(), "aninviterid", It.IsAny())) @@ -94,19 +91,19 @@ public async Task WhenInviteGuestAsyncAndInviteeAlreadyRegistered_ThenReturnsErr _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); + _invitationRepository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); } [Fact] - public async Task WhenInviteGuestAsyncAndEmailOwnerAlreadyRegistered_ThenReturnsError() + public async Task WhenInviteGuestAsyncAndEmailOwnerAlreadyRegistered_ThenDoesNothing() { _caller.Setup(cc => cc.CallerId) .Returns("aninviterid"); var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); _userProfilesService.Setup(ups => @@ -117,23 +114,33 @@ public async Task WhenInviteGuestAsyncAndEmailOwnerAlreadyRegistered_ThenReturns DisplayName = "adisplayname", Name = new PersonName { - FirstName = "afirstname" + FirstName = "afirstname", + LastName = "alastname" }, - UserId = "anotheruserid", + EmailAddress = "aninvitee@company.com", + UserId = "aninviteeid", Id = "aprofileid" }.ToOptional()); + var invitee = EndUserRoot + .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; + _invitationRepository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())) + .ReturnsAsync(invitee); var result = await _application.InviteGuestAsync(_caller.Object, "aninvitee@company.com", CancellationToken.None); - result.Should().BeError(ErrorCode.EntityExists, Resources.EndUsersApplication_GuestAlreadyRegistered); + result.Should().BeSuccess(); + result.Value.EmailAddress.Should().Be("aninvitee@company.com"); + result.Value.FirstName.Should().Be("afirstname"); + result.Value.LastName.Should().Be("alastname"); _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _userProfilesService.Verify(ups => ups.FindPersonByEmailAddressPrivateAsync(_caller.Object, "aninvitee@company.com", It.IsAny())); - _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); + _invitationRepository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); + _invitationRepository.Verify(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())); } [Fact] @@ -143,16 +150,16 @@ public async Task WhenInviteGuestAsyncAndAlreadyInvited_ThenReInvitesGuest() .Returns("aninviterid"); var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); var invitee = EndUserRoot .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; await invitee.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), EmailAddress.Create("aninvitee@company.com").Value, (_, _) => Task.FromResult(Result.Ok)); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(invitee.ToOptional()); - _repository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())) .ReturnsAsync(invitee); _userProfilesService.Setup(ups => ups.GetProfilePrivateAsync(It.IsAny(), "aninviterid", It.IsAny())) @@ -185,9 +192,9 @@ public async Task WhenInviteGuestAsync_ThenInvitesGuest() .Returns("aninviterid"); var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); _userProfilesService.Setup(ups => @@ -219,8 +226,8 @@ public async Task WhenInviteGuestAsync_ThenInvitesGuest() It.IsAny())); _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(_caller.Object, "aninvitationtoken", "aninvitee@company.com", "Aninvitee", "aninviterdisplayname", It.IsAny())); - _repository.Verify(rep => rep.LoadAsync("anid".ToId(), It.IsAny()), Times.Never); - _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); + _invitationRepository.Verify(rep => rep.LoadAsync("anid".ToId(), It.IsAny()), Times.Never); + _invitationRepository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); } [Fact] @@ -230,9 +237,9 @@ public async Task WhenResendGuestInvitationAsyncAndInvitationNotExists_ThenRetur .Returns("aninviterid"); var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); @@ -249,13 +256,13 @@ public async Task WhenResendGuestInvitationAsyncAndInvitationExists_ThenReInvite .Returns("aninviterid"); var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); var invitee = EndUserRoot .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; await invitee.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), EmailAddress.Create("aninvitee@company.com").Value, (_, _) => Task.FromResult(Result.Ok)); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(invitee.ToOptional()); _userProfilesService.Setup(ups => @@ -277,8 +284,8 @@ await invitee.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), result.Should().BeSuccess(); _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(_caller.Object, "aninvitationtoken", "aninvitee@company.com", "Aninvitee", "aninviterdisplayname", It.IsAny())); - _repository.Verify(rep => rep.LoadAsync("anid".ToId(), It.IsAny()), Times.Never); - _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); + _invitationRepository.Verify(rep => rep.LoadAsync("anid".ToId(), It.IsAny()), Times.Never); + _invitationRepository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); } [Fact] @@ -288,9 +295,9 @@ public async Task WhenVerifyGuestInvitationAsyncAndInvitationNotExists_ThenRetur .Returns("aninviterid"); var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); @@ -307,13 +314,13 @@ public async Task WhenVerifyGuestInvitationAsyncAndInvitationExists_ThenVerifies .Returns("aninviterid"); var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; - _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) + _invitationRepository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); var invitee = EndUserRoot .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; await invitee.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), EmailAddress.Create("aninvitee@company.com").Value, (_, _) => Task.FromResult(Result.Ok)); - _repository.Setup(rep => + _invitationRepository.Setup(rep => rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(invitee.ToOptional()); diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index d02ab56b..10d3565f 100644 --- a/src/EndUsersApplication/EndUsersApplication.cs +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -9,7 +9,9 @@ using Domain.Common.ValueObjects; using Domain.Shared; using EndUsersApplication.Persistence; +using EndUsersApplication.Persistence.ReadModels; using EndUsersDomain; +using EndUser = Application.Resources.Shared.EndUser; using Membership = Application.Resources.Shared.Membership; using PersonName = Domain.Shared.PersonName; @@ -59,6 +61,28 @@ public async Task> GetPersonAsync(ICallerContext context, return user.ToUser(); } + public async Task, Error>> ListMembershipsForOrganizationAsync( + ICallerContext caller, string organizationId, SearchOptions searchOptions, + GetOptions getOptions, CancellationToken cancellationToken) + { + var retrieved = + await _endUserRepository.SearchAllMembershipsByOrganizationAsync(organizationId.ToId(), searchOptions, + cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var members = retrieved.Value; + if (!IsMember(caller.ToCallerId(), members)) + { + return Error.ForbiddenAccess(Resources.EndUsersApplication_CallerNotMember); + } + + return searchOptions.ApplyWithMetadata( + await WithGetOptionsAsync(caller, members, getOptions, cancellationToken)); + } + public async Task> GetMembershipsAsync(ICallerContext context, string id, CancellationToken cancellationToken) { @@ -94,8 +118,7 @@ public async Task> RegisterMachineAsync(ICaller var profile = profiled.Value; var (platformRoles, platformFeatures, tenantRoles, tenantFeatures) = - EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Machine, context.IsAuthenticated, - Optional.None, Optional>.None); + EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.CreatingMachine, context.IsAuthenticated); var registered = machine.Register(platformRoles, platformFeatures, Optional.None); if (!registered.IsSuccessful) { @@ -126,9 +149,12 @@ await _organizationsService.CreateOrganizationPrivateAsync(context, machine.Id, return adder.Error; } + var (_, _, tenantRoles2, tenantFeatures2) = + EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.InvitingMachineToCreatorOrg, + context.IsAuthenticated); var adderDefaultOrganizationId = adder.Value.Memberships.DefaultMembership.OrganizationId; - var adderEnrolled = machine.AddMembership(adderDefaultOrganizationId, tenantRoles, - tenantFeatures); + var adderEnrolled = machine.AddMembership(adderDefaultOrganizationId, tenantRoles2, + tenantFeatures2); if (!adderEnrolled.IsSuccessful) { return adderEnrolled.Error; @@ -223,7 +249,7 @@ public async Task> RegisterPersonAsync(ICallerC { profile = existingUser.Value.Profile; if (profile.NotExists() - || profile.Type != UserProfileType.Person + || profile.Classification != UserProfileClassification.Person || profile.EmailAddress.HasNoValue()) { return Error.EntityNotFound(Resources.EndUsersApplication_NotPersonProfile); @@ -271,7 +297,8 @@ public async Task> RegisterPersonAsync(ICallerC profile = profiled.Value; var permittedOperators = GetPermittedOperators(); var (platformRoles, platformFeatures, tenantRoles, tenantFeatures) = - EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Person, context.IsAuthenticated, username, + EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.CreatingPerson, context.IsAuthenticated, + username, permittedOperators); var registered = unregisteredUser.Register(platformRoles, platformFeatures, username); if (!registered.IsSuccessful) @@ -328,9 +355,7 @@ public async Task> CreateMembershipForCallerAsync(ICal var user = retrieved.Value; var (_, _, tenantRoles, tenantFeatures) = - EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Person, context.IsAuthenticated, - Optional.None, - Optional>.None); + EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.CreatingOrg, context.IsAuthenticated); var membered = user.AddMembership(organizationId.ToId(), tenantRoles, tenantFeatures); if (!membered.IsSuccessful) { @@ -516,6 +541,42 @@ public async Task> AssignTenantRolesAsync( return assignee.ToUserWithMemberships(); } + private async Task> WithGetOptionsAsync(ICallerContext caller, + List memberships, GetOptions options, CancellationToken cancellationToken) + { + var ids = memberships + .Where(membership => membership.Status.Value.ToEnumOrDefault(EndUserStatus.Unregistered) + == EndUserStatus.Registered) + .Select(membership => membership.UserId.Value).ToList(); + + var profiles = new List(); + if (ids.Count > 0) + { + var retrieved = + await _userProfilesService.GetAllProfilesPrivateAsync(caller, ids, options, cancellationToken); + if (retrieved.IsSuccessful) + { + profiles = retrieved.Value; + } + } + + return memberships.ConvertAll(membership => + { + var member = membership.ToMembership(); + member.Profile = membership.Status.Value.ToEnumOrDefault(EndUserStatus.Unregistered) + == EndUserStatus.Unregistered + ? membership.ToUnregisteredUserProfile() + : profiles.First(profile => profile.UserId == membership.UserId); + + return member; + }); + } + + private static bool IsMember(Identifier userId, List members) + { + return members.Any(ms => ms.UserId.Value.EqualsIgnoreCase(userId)); + } + private async Task, Error>> FindRegisteredPersonOrInvitedGuestByEmailAddressAsync(ICallerContext caller, EmailAddress emailAddress, CancellationToken cancellationToken) @@ -622,11 +683,29 @@ private record EndUserWithProfile(EndUserRoot User, UserProfile? Profile); internal static class EndUserConversionExtensions { + public static MembershipWithUserProfile ToMembership(this MembershipJoinInvitation membership) + { + var dto = new MembershipWithUserProfile + { + Id = membership.Id.Value, + UserId = membership.UserId.Value, + IsDefault = membership.IsDefault, + OrganizationId = membership.UserId.Value, + Status = membership.Status.Value.ToEnumOrDefault(EndUserStatus.Unregistered), + Roles = membership.Roles.Value.ToList(), + Features = membership.Features.Value.ToList(), + Profile = null! + }; + + return dto; + } + public static Membership ToMembership(this EndUsersDomain.Membership ms) { return new Membership { Id = ms.Id, + UserId = ms.RootId.Value, IsDefault = ms.IsDefault, OrganizationId = ms.OrganizationId.Value, Features = ms.Features.ToList(), @@ -645,6 +724,25 @@ public static RegisteredEndUser ToRegisteredUser(this EndUserRoot user, Identifi return registeredUser; } + public static UserProfile ToUnregisteredUserProfile(this MembershipJoinInvitation membership) + { + var dto = new UserProfile + { + Id = membership.UserId.Value, + UserId = membership.UserId.Value, + EmailAddress = membership.InvitedEmailAddress.Value, + DisplayName = membership.InvitedEmailAddress.Value, + Name = new Application.Resources.Shared.PersonName + { + FirstName = membership.InvitedEmailAddress.Value, + LastName = null + }, + Classification = UserProfileClassification.Person + }; + + return dto; + } + public static EndUser ToUser(this EndUserRoot user) { return new EndUser diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs index 8c2e37f5..7daf27d8 100644 --- a/src/EndUsersApplication/IEndUsersApplication.cs +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -24,6 +24,10 @@ Task> GetMembershipsAsync(ICallerContext c Task> GetPersonAsync(ICallerContext context, string id, CancellationToken cancellationToken); + Task, Error>> ListMembershipsForOrganizationAsync( + ICallerContext caller, + string organizationId, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken); + Task> RegisterMachineAsync(ICallerContext context, string name, string? timezone, string? countryCode, CancellationToken cancellationToken); diff --git a/src/EndUsersApplication/IInvitationsApplication.cs b/src/EndUsersApplication/IInvitationsApplication.cs index 0ed68df4..7d656f22 100644 --- a/src/EndUsersApplication/IInvitationsApplication.cs +++ b/src/EndUsersApplication/IInvitationsApplication.cs @@ -9,6 +9,9 @@ public interface IInvitationsApplication Task> InviteGuestAsync(ICallerContext context, string emailAddress, CancellationToken cancellationToken); + Task> InviteMemberToOrganizationAsync(ICallerContext context, string organizationId, + string? userId, string? emailAddress, CancellationToken cancellationToken); + Task> ResendGuestInvitationAsync(ICallerContext context, string token, CancellationToken cancellationToken); diff --git a/src/EndUsersApplication/InvitationsApplication.cs b/src/EndUsersApplication/InvitationsApplication.cs index 54496cb3..832908cc 100644 --- a/src/EndUsersApplication/InvitationsApplication.cs +++ b/src/EndUsersApplication/InvitationsApplication.cs @@ -3,19 +3,21 @@ using Application.Resources.Shared; using Application.Services.Shared; using Common; +using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Services.Shared.DomainServices; using Domain.Shared; using EndUsersApplication.Persistence; using EndUsersDomain; +using Membership = Application.Resources.Shared.Membership; namespace EndUsersApplication; public class InvitationsApplication : IInvitationsApplication { private readonly IIdentifierFactory _idFactory; - private readonly IInvitationRepository _repository; + private readonly IInvitationRepository _invitationsRepository; private readonly INotificationsService _notificationsService; private readonly IRecorder _recorder; private readonly ITokensService _tokensService; @@ -23,102 +25,100 @@ public class InvitationsApplication : IInvitationsApplication public InvitationsApplication(IRecorder recorder, IIdentifierFactory idFactory, ITokensService tokensService, INotificationsService notificationsService, IUserProfilesService userProfilesService, - IInvitationRepository repository) + IInvitationRepository invitationsRepository) { _recorder = recorder; _idFactory = idFactory; _tokensService = tokensService; _notificationsService = notificationsService; _userProfilesService = userProfilesService; - _repository = repository; + _invitationsRepository = invitationsRepository; } public async Task> InviteGuestAsync(ICallerContext context, string emailAddress, CancellationToken cancellationToken) { - var retrievedInviter = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); - if (!retrievedInviter.IsSuccessful) + var invited = await InviteGuestByEmailInternalAsync(context, emailAddress, cancellationToken); + if (!invited.IsSuccessful) { - return retrievedInviter.Error; + return invited.Error; } - var inviter = retrievedInviter.Value; - - var email = EmailAddress.Create(emailAddress); - if (!email.IsSuccessful) + var invitee = invited.Value.Invitee; + var saved = await _invitationsRepository.SaveAsync(invitee, cancellationToken); + if (!saved.IsSuccessful) { - return email.Error; + return saved.Error; } - var retrievedGuest = - await _repository.FindInvitedGuestByEmailAddressAsync(email.Value, cancellationToken); - if (!retrievedGuest.IsSuccessful) + _recorder.TraceInformation(context.ToCall(), "Guest {Id} was invited", invitee.Id); + _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.GuestInvited, + new Dictionary + { + { nameof(EndUserRoot.Id), invitee.Id }, + { nameof(UserProfile.EmailAddress), emailAddress } + }); + + return invited.Value.Invitee.ToInvitation(invited.Value.Profile); + } + + public async Task> InviteMemberToOrganizationAsync(ICallerContext context, + string organizationId, string? userId, string? emailAddress, CancellationToken cancellationToken) + { + if (emailAddress.HasNoValue() && userId.HasNoValue()) { - return retrievedGuest.Error; + return Error.RuleViolation(Resources + .InvitationsApplication_InviteToOrganizationWithoutEmailAddressOrUserId); } EndUserRoot invitee; - if (retrievedGuest.Value.HasValue) + if (emailAddress.HasValue()) { - invitee = retrievedGuest.Value.Value; - if (invitee.Status == UserStatus.Registered) + var invited = await InviteGuestByEmailInternalAsync(context, emailAddress, cancellationToken); + if (!invited.IsSuccessful) { - return Error.EntityExists(Resources.EndUsersApplication_GuestAlreadyRegistered); + return invited.Error; } + + invitee = invited.Value.Invitee; } else { - var retrievedEmailOwner = - await _userProfilesService.FindPersonByEmailAddressPrivateAsync(context, emailAddress, - cancellationToken); - if (!retrievedEmailOwner.IsSuccessful) + var invited = await InviteGuestByUserIdInternalAsync(context, userId!, cancellationToken); + if (!invited.IsSuccessful) { - return retrievedEmailOwner.Error; - } - - if (retrievedEmailOwner.Value.HasValue) - { - return Error.EntityExists(Resources.EndUsersApplication_GuestAlreadyRegistered); + return invited.Error; } - var created = EndUserRoot.Create(_recorder, _idFactory, UserClassification.Person); - if (!created.IsSuccessful) - { - return created.Error; - } - - invitee = created.Value; + invitee = invited.Value.Invitee; } - var invited = await invitee.InviteGuestAsync(_tokensService, inviter.Id, email.Value, - async (inviterId, newToken) => - await SendInvitationNotificationAsync(context, inviterId, newToken, invitee, cancellationToken)); - if (!invited.IsSuccessful) + var (_, _, tenantRoles, tenantFeatures) = + EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.InvitingMemberToOrg, + context.IsAuthenticated); + var enrolled = invitee.AddMembership(organizationId.ToId(), tenantRoles, tenantFeatures); + if (!enrolled.IsSuccessful) { - return invited.Error; + return enrolled.Error; } - var saved = await _repository.SaveAsync(invitee, cancellationToken); + var membership = invitee.FindMembership(organizationId.ToId()); + var saved = await _invitationsRepository.SaveAsync(invitee, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; } - _recorder.TraceInformation(context.ToCall(), "Guest {Id} was invited", invitee.Id); - _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.GuestInvited, - new Dictionary - { - { nameof(EndUserRoot.Id), invitee.Id }, - { nameof(UserProfile.EmailAddress), invitee.GuestInvitation.InviteeEmailAddress!.Address } - }); + _recorder.TraceInformation(context.ToCall(), + "EndUser {Id} has been invited to organization {Organization}", saved.Value.Id, organizationId); - return invitee.ToInvitation(); + return membership.Value.ToMembership(); } public async Task> ResendGuestInvitationAsync(ICallerContext context, string token, CancellationToken cancellationToken) { - var retrievedInviter = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); + var retrievedInviter = await _invitationsRepository.LoadAsync(context.ToCallerId(), cancellationToken); if (!retrievedInviter.IsSuccessful) { return retrievedInviter.Error; @@ -126,7 +126,7 @@ public async Task> ResendGuestInvitationAsync(ICallerContext conte var inviter = retrievedInviter.Value; - var retrievedGuest = await _repository.FindInvitedGuestByTokenAsync(token, cancellationToken); + var retrievedGuest = await _invitationsRepository.FindInvitedGuestByTokenAsync(token, cancellationToken); if (!retrievedGuest.IsSuccessful) { return retrievedGuest.Error; @@ -147,7 +147,7 @@ public async Task> ResendGuestInvitationAsync(ICallerContext conte return invited.Error; } - var saved = await _repository.SaveAsync(invitee, cancellationToken); + var saved = await _invitationsRepository.SaveAsync(invitee, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; @@ -167,7 +167,7 @@ public async Task> ResendGuestInvitationAsync(ICallerContext conte public async Task> VerifyGuestInvitationAsync(ICallerContext context, string token, CancellationToken cancellationToken) { - var retrievedGuest = await _repository.FindInvitedGuestByTokenAsync(token, cancellationToken); + var retrievedGuest = await _invitationsRepository.FindInvitedGuestByTokenAsync(token, cancellationToken); if (!retrievedGuest.IsSuccessful) { return retrievedGuest.Error; @@ -189,6 +189,124 @@ public async Task> VerifyGuestInvitationAsync(ICallerC return invitee.ToInvitation(); } + private async Task> InviteGuestByEmailInternalAsync( + ICallerContext context, + string emailAddress, CancellationToken cancellationToken) + { + var retrievedInviter = await _invitationsRepository.LoadAsync(context.ToCallerId(), cancellationToken); + if (!retrievedInviter.IsSuccessful) + { + return retrievedInviter.Error; + } + + var inviter = retrievedInviter.Value; + + var email = EmailAddress.Create(emailAddress); + if (!email.IsSuccessful) + { + return email.Error; + } + + var retrievedGuest = + await _invitationsRepository.FindInvitedGuestByEmailAddressAsync(email.Value, cancellationToken); + if (!retrievedGuest.IsSuccessful) + { + return retrievedGuest.Error; + } + + EndUserRoot invitee; + if (retrievedGuest.Value.HasValue) + { + invitee = retrievedGuest.Value.Value; + if (invitee.Status == UserStatus.Registered) + { + return Error.EntityExists(Resources.EndUsersApplication_GuestAlreadyRegistered); + } + } + else + { + var retrievedEmailOwner = + await _userProfilesService.FindPersonByEmailAddressPrivateAsync(context, emailAddress, + cancellationToken); + if (!retrievedEmailOwner.IsSuccessful) + { + return retrievedEmailOwner.Error; + } + + if (retrievedEmailOwner.Value.HasValue) + { + var retrievedInvitee = + await _invitationsRepository.LoadAsync(retrievedEmailOwner.Value.Value.UserId.ToId(), + cancellationToken); + if (!retrievedInvitee.IsSuccessful) + { + return retrievedInvitee.Error; + } + + return (retrievedInvitee.Value, retrievedEmailOwner.Value.Value); + } + + var created = EndUserRoot.Create(_recorder, _idFactory, UserClassification.Person); + if (!created.IsSuccessful) + { + return created.Error; + } + + invitee = created.Value; + } + + var invited = await invitee.InviteGuestAsync(_tokensService, inviter.Id, email.Value, + async (inviterId, newToken) => + await SendInvitationNotificationAsync(context, inviterId, newToken, invitee, cancellationToken)); + if (!invited.IsSuccessful) + { + return invited.Error; + } + + return (invitee, null); + } + + private async Task> InviteGuestByUserIdInternalAsync( + ICallerContext context, + string userId, CancellationToken cancellationToken) + { + var retrievedInviter = await _invitationsRepository.LoadAsync(context.ToCallerId(), cancellationToken); + if (!retrievedInviter.IsSuccessful) + { + return retrievedInviter.Error; + } + + var inviter = retrievedInviter.Value; + + var retrievedInvitee = await _invitationsRepository.LoadAsync(userId.ToId(), cancellationToken); + if (!retrievedInvitee.IsSuccessful) + { + return retrievedInvitee.Error; + } + + var invitee = retrievedInvitee.Value; + if (invitee.IsRegistered) + { + return (invitee, null); + } + + var email = EmailAddress.Create(invitee.GuestInvitation.InviteeEmailAddress!.Address); + if (!email.IsSuccessful) + { + return email.Error; + } + + var invited = await invitee.InviteGuestAsync(_tokensService, inviter.Id, email.Value, + async (inviterId, newToken) => + await SendInvitationNotificationAsync(context, inviterId, newToken, invitee, cancellationToken)); + if (!invited.IsSuccessful) + { + return invited.Error; + } + + return (invitee, null); + } + private async Task> SendInvitationNotificationAsync(ICallerContext context, Identifier inviterId, string token, EndUserRoot invitee, CancellationToken cancellationToken) { @@ -216,8 +334,18 @@ await _notificationsService.NotifyGuestInvitationToPlatformAsync(context, token, internal static class InvitationConversionExtensions { - public static Invitation ToInvitation(this EndUserRoot invitee) + public static Invitation ToInvitation(this EndUserRoot invitee, UserProfile? profile = null) { + if (profile.Exists()) + { + return new Invitation + { + EmailAddress = profile.EmailAddress!, + FirstName = profile.Name.FirstName, + LastName = profile.Name.LastName + }; + } + var assumedName = invitee.GuessGuestInvitationName(); return new Invitation { diff --git a/src/EndUsersApplication/Persistence/IEndUserRepository.cs b/src/EndUsersApplication/Persistence/IEndUserRepository.cs index d41e9f1a..cd08c43b 100644 --- a/src/EndUsersApplication/Persistence/IEndUserRepository.cs +++ b/src/EndUsersApplication/Persistence/IEndUserRepository.cs @@ -1,6 +1,8 @@ +using Application.Interfaces; using Application.Persistence.Interfaces; using Common; using Domain.Common.ValueObjects; +using EndUsersApplication.Persistence.ReadModels; using EndUsersDomain; namespace EndUsersApplication.Persistence; @@ -10,4 +12,8 @@ public interface IEndUserRepository : IApplicationRepository Task> LoadAsync(Identifier id, CancellationToken cancellationToken); Task> SaveAsync(EndUserRoot user, CancellationToken cancellationToken); + + Task, Error>> SearchAllMembershipsByOrganizationAsync( + Identifier organizationId, + SearchOptions searchOptions, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs b/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs new file mode 100644 index 00000000..8440e1ca --- /dev/null +++ b/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs @@ -0,0 +1,24 @@ +using Application.Persistence.Common; +using Common; +using Domain.Shared; +using QueryAny; + +namespace EndUsersApplication.Persistence.ReadModels; + +[EntityName("Membership")] +public class MembershipJoinInvitation : ReadModelEntity +{ + public Optional UserId { get; set; } + + public Optional OrganizationId { get; set; } + + public bool IsDefault { get; set; } + + public Optional Roles { get; set; } + + public Optional Features { get; set; } + + public Optional InvitedEmailAddress { get; set; } + + public Optional Status { get; set; } +} \ No newline at end of file diff --git a/src/EndUsersApplication/Resources.Designer.cs b/src/EndUsersApplication/Resources.Designer.cs index 35ebad1a..1a1d8f75 100644 --- a/src/EndUsersApplication/Resources.Designer.cs +++ b/src/EndUsersApplication/Resources.Designer.cs @@ -68,6 +68,15 @@ internal static string EndUsersApplication_AcceptedInvitationWithExistingEmailAd } } + /// + /// Looks up a localized string similar to The caller is not a member of this organization. + /// + internal static string EndUsersApplication_CallerNotMember { + get { + return ResourceManager.GetString("EndUsersApplication_CallerNotMember", resourceCulture); + } + } + /// /// Looks up a localized string similar to This guest is already registered as a user. /// @@ -103,5 +112,14 @@ internal static string EndUsersApplication_NotPersonProfile { return ResourceManager.GetString("EndUsersApplication_NotPersonProfile", resourceCulture); } } + + /// + /// Looks up a localized string similar to Cannot invite a user to an organization without either an email or user ID. + /// + internal static string InvitationsApplication_InviteToOrganizationWithoutEmailAddressOrUserId { + get { + return ResourceManager.GetString("InvitationsApplication_InviteToOrganizationWithoutEmailAddressOrUserId", resourceCulture); + } + } } } diff --git a/src/EndUsersApplication/Resources.resx b/src/EndUsersApplication/Resources.resx index 9cc996c3..c80ddd58 100644 --- a/src/EndUsersApplication/Resources.resx +++ b/src/EndUsersApplication/Resources.resx @@ -39,4 +39,10 @@ A user with this email address is already registered + + The caller is not a member of this organization + + + Cannot invite a user to an organization without either an email or user ID + \ No newline at end of file diff --git a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs index 59aeffd8..459b1ae4 100644 --- a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs +++ b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs @@ -140,14 +140,6 @@ public void WhenEnsureInvariantsAndRegisteredPersonStillInvited_ThenReturnsError result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestAlreadyRegistered); } - [Fact] - public void WhenAddMembershipAndNotRegistered_ThenReturnsError() - { - var result = _user.AddMembership("anorganizationid".ToId(), Roles.Create(), Features.Create()); - - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotRegistered); - } - [Fact] public void WhenAddMembershipAndAlreadyMember_ThenReturns() { diff --git a/src/EndUsersDomain.UnitTests/MembershipSpec.cs b/src/EndUsersDomain.UnitTests/MembershipSpec.cs index 456e96f4..23e0c59f 100644 --- a/src/EndUsersDomain.UnitTests/MembershipSpec.cs +++ b/src/EndUsersDomain.UnitTests/MembershipSpec.cs @@ -1,4 +1,5 @@ using Common; +using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Interfaces.Authorization; @@ -77,11 +78,30 @@ public void WhenMembershipFeatureAssignedEventRaised_ThenAssigned() _membership.Features.HasFeature(feature).Should(); } + [Fact] + public void WhenEnsureInvariantsAndMissingDefaultRole_ThenReturnsError() + { + _membership.As() + .RaiseEvent(Events.MembershipFeatureAssigned.Create("arootid".ToId(), + "anorganizationid".ToId(), "amembershipid".ToId(), Feature.Create(Membership.DefaultFeature).Value), + true); + + var result = _membership.EnsureInvariants(); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.Membership_MissingDefaultRole.Format(Membership.DefaultRole.Name)); + } + [Fact] public void WhenEnsureInvariantsAndMissingDefaultFeature_ThenReturnsError() { + _membership.As() + .RaiseEvent(Events.MembershipRoleAssigned.Create("arootid".ToId(), + "anorganizationid".ToId(), "amembershipid".ToId(), Role.Create(Membership.DefaultRole).Value), true); + var result = _membership.EnsureInvariants(); - result.Should().BeError(ErrorCode.RuleViolation, Resources.Membership_MissingDefaultFeature); + result.Should().BeError(ErrorCode.RuleViolation, + Resources.Membership_MissingDefaultFeature.Format(Membership.DefaultFeature.Name)); } } \ No newline at end of file diff --git a/src/EndUsersDomain.UnitTests/MembershipsSpec.cs b/src/EndUsersDomain.UnitTests/MembershipsSpec.cs index a9901aa2..1e639a7f 100644 --- a/src/EndUsersDomain.UnitTests/MembershipsSpec.cs +++ b/src/EndUsersDomain.UnitTests/MembershipsSpec.cs @@ -163,11 +163,12 @@ public void WhenEnsureInvariantsAndMoreThanOneForEachOrganization_ThenReturnsErr private Membership CreateMembership(string organizationId = "anorganizationid", bool isDefault = true) { + var roles = Roles.Create(Membership.DefaultRole).Value; var features = Features.Create(Membership.DefaultFeature).Value; var membership = Membership.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok).Value; membership.As() .RaiseEvent(Events.MembershipAdded.Create("arootid".ToId(), - organizationId.ToId(), isDefault, Roles.Empty, features), true); + organizationId.ToId(), isDefault, roles, features), true); return membership; } } \ No newline at end of file diff --git a/src/EndUsersDomain/EndUserRoot.cs b/src/EndUsersDomain/EndUserRoot.cs index 12ba865f..34cb5afd 100644 --- a/src/EndUsersDomain/EndUserRoot.cs +++ b/src/EndUsersDomain/EndUserRoot.cs @@ -323,10 +323,7 @@ public Result AcceptGuestInvitation(Identifier acceptedById, EmailAddress public Result AddMembership(Identifier organizationId, Roles tenantRoles, Features tenantFeatures) { - if (!IsRegistered) - { - return Error.RuleViolation(Resources.EndUserRoot_NotRegistered); - } + //TODO: check that the adder is a member of this organization, and an owner of it var existing = Memberships.FindByOrganizationId(organizationId); if (existing.HasValue) @@ -497,39 +494,57 @@ public Optional FindMembership(Identifier organizationId) /// EXTEND: change this to assign initial roles and features for persons and machines /// public static (Roles PlatformRoles, Features PlatformFeatures, Roles TenantRoles, Features TenantFeatures) - GetInitialRolesAndFeatures(UserClassification classification, bool isAuthenticated, - Optional username, Optional> permittedOperators) + GetInitialRolesAndFeatures(RolesAndFeaturesUseCase useCase, bool isAuthenticated, + EmailAddress? username = null, List? permittedOperators = null) { var platformRoles = Roles.Create(); platformRoles = platformRoles.Add(PlatformRoles.Standard).Value; - if (username.HasValue && permittedOperators.HasValue) + if (username.Exists() && permittedOperators.Exists()) { - if (permittedOperators.Value + if (permittedOperators .Select(x => x.Address) - .ContainsIgnoreCase(username.Value)) + .ContainsIgnoreCase(username)) { platformRoles = platformRoles.Add(PlatformRoles.Operations).Value; } } - var tenantRoles = Roles.Create(); - tenantRoles = tenantRoles.Add(TenantRoles.Member).Value; - var platformFeatures = Features.Create(); + Roles tenantRoles; var tenantFeatures = Features.Create(); - if (classification == UserClassification.Machine) - { - platformFeatures = platformFeatures.Add(isAuthenticated - ? PlatformFeatures.PaidTrial - : PlatformFeatures.Basic).Value; - tenantFeatures = tenantFeatures.Add(isAuthenticated - ? TenantFeatures.PaidTrial - : TenantFeatures.Basic).Value; - } - else - { - platformFeatures = platformFeatures.Add(PlatformFeatures.PaidTrial).Value; - tenantFeatures = tenantFeatures.Add(TenantFeatures.PaidTrial).Value; + switch (useCase) + { + case RolesAndFeaturesUseCase.CreatingMachine: + platformFeatures = platformFeatures.Add(isAuthenticated + ? PlatformFeatures.PaidTrial + : PlatformFeatures.Basic).Value; + tenantRoles = Roles.Create(TenantRoles.Owner, TenantRoles.Member).Value; + tenantFeatures = tenantFeatures.Add(isAuthenticated + ? TenantFeatures.PaidTrial + : TenantFeatures.Basic).Value; + break; + + case RolesAndFeaturesUseCase.CreatingPerson: + case RolesAndFeaturesUseCase.CreatingOrg: + platformFeatures = platformFeatures.Add(PlatformFeatures.PaidTrial).Value; + tenantRoles = Roles.Create(TenantRoles.BillingAdmin, TenantRoles.Owner, TenantRoles.Member).Value; + tenantFeatures = tenantFeatures.Add(TenantFeatures.PaidTrial).Value; + break; + + case RolesAndFeaturesUseCase.InvitingMemberToOrg: + platformFeatures = platformFeatures.Add(PlatformFeatures.PaidTrial).Value; + tenantFeatures = tenantFeatures.Add(TenantFeatures.PaidTrial).Value; + tenantRoles = Roles.Create(TenantRoles.Member).Value; + break; + + case RolesAndFeaturesUseCase.InvitingMachineToCreatorOrg: + platformFeatures = platformFeatures.Add(PlatformFeatures.PaidTrial).Value; + tenantRoles = Roles.Create(TenantRoles.Member).Value; + tenantFeatures = tenantFeatures.Add(TenantFeatures.PaidTrial).Value; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(useCase), useCase, null); } return (platformRoles, platformFeatures, tenantRoles, tenantFeatures); diff --git a/src/EndUsersDomain/Membership.cs b/src/EndUsersDomain/Membership.cs index ef1578cd..6ab5203a 100644 --- a/src/EndUsersDomain/Membership.cs +++ b/src/EndUsersDomain/Membership.cs @@ -1,4 +1,5 @@ using Common; +using Common.Extensions; using Domain.Common.Entities; using Domain.Common.Identity; using Domain.Common.ValueObjects; @@ -10,6 +11,7 @@ namespace EndUsersDomain; public sealed class Membership : EntityBase { + internal static readonly RoleLevel DefaultRole = TenantRoles.Member; internal static readonly FeatureLevel DefaultFeature = TenantFeatures.Basic; public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, @@ -111,9 +113,13 @@ public override Result EnsureInvariants() return ensureInvariants.Error; } + if (!Roles.HasRole(DefaultRole)) + { + return Error.RuleViolation(Resources.Membership_MissingDefaultRole.Format(DefaultRole.Name)); + } if (!Features.HasFeature(DefaultFeature)) { - return Error.RuleViolation(Resources.Membership_MissingDefaultFeature); + return Error.RuleViolation(Resources.Membership_MissingDefaultFeature.Format(DefaultFeature.Name)); } return Result.Ok; diff --git a/src/EndUsersDomain/Resources.Designer.cs b/src/EndUsersDomain/Resources.Designer.cs index f9a52617..a0e363b2 100644 --- a/src/EndUsersDomain/Resources.Designer.cs +++ b/src/EndUsersDomain/Resources.Designer.cs @@ -258,7 +258,7 @@ internal static string GuestInvitation_NotInvited { } /// - /// Looks up a localized string similar to A membership must always have the default feature set. + /// Looks up a localized string similar to A membership must always have at least the feature '{0}'. /// internal static string Membership_MissingDefaultFeature { get { @@ -266,6 +266,15 @@ internal static string Membership_MissingDefaultFeature { } } + /// + /// Looks up a localized string similar to A membership must always have at least the role '{0}'. + /// + internal static string Membership_MissingDefaultRole { + get { + return ResourceManager.GetString("Membership_MissingDefaultRole", resourceCulture); + } + } + /// /// Looks up a localized string similar to Cannot have multiple memberships for the same organization. /// diff --git a/src/EndUsersDomain/Resources.resx b/src/EndUsersDomain/Resources.resx index 540872c0..aed397d8 100644 --- a/src/EndUsersDomain/Resources.resx +++ b/src/EndUsersDomain/Resources.resx @@ -75,8 +75,11 @@ The assigner is not an owner of the organization + + A membership must always have at least the role '{0}' + - A membership must always have the default feature set + A membership must always have at least the feature '{0}' The assigner is not a member of the operations team diff --git a/src/EndUsersDomain/RolesAndFeaturesUseCase.cs b/src/EndUsersDomain/RolesAndFeaturesUseCase.cs new file mode 100644 index 00000000..0f72a286 --- /dev/null +++ b/src/EndUsersDomain/RolesAndFeaturesUseCase.cs @@ -0,0 +1,10 @@ +namespace EndUsersDomain; + +public enum RolesAndFeaturesUseCase +{ + CreatingMachine, + CreatingPerson, + CreatingOrg, + InvitingMemberToOrg, + InvitingMachineToCreatorOrg +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure.IntegrationTests/InvitationsApiSpec.cs b/src/EndUsersInfrastructure.IntegrationTests/InvitationsApiSpec.cs index 044fa8c5..6ccb5d32 100644 --- a/src/EndUsersInfrastructure.IntegrationTests/InvitationsApiSpec.cs +++ b/src/EndUsersInfrastructure.IntegrationTests/InvitationsApiSpec.cs @@ -74,7 +74,7 @@ await Api.PostAsync(new InviteGuestRequest } [Fact] - public async Task WhenInviteUserAsGuestAndAlreadyRegistered_ThenReturnsError() + public async Task WhenInviteUserAsGuestAndAlreadyRegistered_ThenDoesNothing() { var login = await LoginUserAsync(); var emailAddress = CreateRandomEmailAddress(); @@ -95,7 +95,9 @@ await Api.PostAsync(new InviteGuestRequest Email = emailAddress }, req => req.SetJWTBearerToken(login.AccessToken)); - result.StatusCode.Should().Be(HttpStatusCode.Conflict); + result.Content.Value.Invitation!.EmailAddress.Should().Be(emailAddress); + result.Content.Value.Invitation!.FirstName.Should().Be("afirstname"); + result.Content.Value.Invitation!.LastName.Should().Be("alastname"); _notificationService.LastGuestInvitationEmailRecipient.Should().BeNull(); } diff --git a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs index c585dee1..476013cf 100644 --- a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs +++ b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs @@ -13,10 +13,13 @@ namespace EndUsersInfrastructure.ApplicationServices; public class EndUsersInProcessServiceClient : IEndUsersService { private readonly IEndUsersApplication _endUsersApplication; + private readonly IInvitationsApplication _invitationsApplication; - public EndUsersInProcessServiceClient(IEndUsersApplication endUsersApplication) + public EndUsersInProcessServiceClient(IEndUsersApplication endUsersApplication, + IInvitationsApplication invitationsApplication) { _endUsersApplication = endUsersApplication; + _invitationsApplication = invitationsApplication; } public async Task> CreateMembershipForCallerPrivateAsync(ICallerContext caller, @@ -39,6 +42,22 @@ public async Task> GetMembershipsPrivateAs return await _endUsersApplication.GetMembershipsAsync(caller, id, cancellationToken); } + public async Task> InviteMemberToOrganizationPrivateAsync(ICallerContext caller, + string organizationId, string? userId, string? emailAddress, + CancellationToken cancellationToken) + { + return await _invitationsApplication.InviteMemberToOrganizationAsync(caller, organizationId, userId, + emailAddress, cancellationToken); + } + + public async Task, Error>> ListMembershipsForOrganizationAsync( + ICallerContext caller, string organizationId, SearchOptions searchOptions, + GetOptions getOptions, CancellationToken cancellationToken) + { + return await _endUsersApplication.ListMembershipsForOrganizationAsync(caller, organizationId, searchOptions, + getOptions, cancellationToken); + } + public async Task> RegisterPersonPrivateAsync(ICallerContext caller, string? invitationToken, string emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) diff --git a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs index e1206c77..0931c3ac 100644 --- a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs +++ b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs @@ -1,6 +1,7 @@ +using Application.Interfaces; +using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Interfaces; using EndUsersApplication.Persistence; @@ -8,11 +9,15 @@ using EndUsersDomain; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; +using QueryAny; +using EndUser = EndUsersApplication.Persistence.ReadModels.EndUser; +using Tasks = Common.Extensions.Tasks; namespace EndUsersInfrastructure.Persistence; public class EndUserRepository : IEndUserRepository { + private readonly ISnapshottingQueryStore _membershipUserQueries; private readonly ISnapshottingQueryStore _userQueries; private readonly IEventSourcingDddCommandStore _users; @@ -20,6 +25,7 @@ public EndUserRepository(IRecorder recorder, IDomainFactory domainFactory, IEventSourcingDddCommandStore usersStore, IDataStore store) { _userQueries = new SnapshottingQueryStore(recorder, domainFactory, store); + _membershipUserQueries = new SnapshottingQueryStore(recorder, domainFactory, store); _users = usersStore; } @@ -27,6 +33,7 @@ public async Task> DestroyAllAsync(CancellationToken cancellationT { return await Tasks.WhenAllAsync( _userQueries.DestroyAllAsync(cancellationToken), + _membershipUserQueries.DestroyAllAsync(cancellationToken), _users.DestroyAllAsync(cancellationToken)); } @@ -51,4 +58,31 @@ public async Task> SaveAsync(EndUserRoot user, Cancel return user; } + + public async Task, Error>> SearchAllMembershipsByOrganizationAsync( + Identifier organizationId, SearchOptions searchOptions, CancellationToken cancellationToken) + { + var query = Query.From() + .Join(mje => mje.UserId, inv => inv.Id) + .Where(mje => mje.OrganizationId, ConditionOperator.EqualTo, organizationId) + .Select(mje => mje.UserId) + .Select(mje => mje.Roles) + .Select(mje => mje.Features) + .Select(mje => mje.OrganizationId) + .Select(mje => mje.IsDefault) + .Select(mje => mje.LastPersistedAtUtc) + .SelectFromJoin(mje => mje.InvitedEmailAddress, inv => inv.InvitedEmailAddress) + .SelectFromJoin(mje => mje.Status, inv => inv.Status) + .OrderBy(mje => mje.LastPersistedAtUtc) + .WithSearchOptions(searchOptions); + + var queried = await _membershipUserQueries.QueryAsync(query, cancellationToken: cancellationToken); + if (!queried.IsSuccessful) + { + return queried.Error; + } + + var memberships = queried.Value.Results; + return memberships; + } } \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs index 79433d1e..1a278cff 100644 --- a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs @@ -322,7 +322,7 @@ public async Task WhenRegisterPersonAsyncAndNotExists_ThenCreatesAndSendsConfirm { Id = "anid", UserId = "auserid", - Type = UserProfileType.Person, + Classification = UserProfileClassification.Person, Name = new PersonName { FirstName = "aname" diff --git a/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs index a77f6d53..a825329d 100644 --- a/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs @@ -45,16 +45,17 @@ public async Task WhenIssueTokensAsync_ThenReturnsTokens() Id = "anid", Roles = new List { PlatformRoles.Standard.Name }, Features = new List { PlatformFeatures.Basic.Name }, - Memberships = new List - { - new() + Memberships = + [ + new Membership { Id = "amembershipid", + UserId = "auserid", OrganizationId = "anorganizationid", Roles = new List { TenantRoles.Member.Name }, Features = new List { TenantFeatures.Basic.Name } } - } + ] }; var result = await _service.IssueTokensAsync(user); diff --git a/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs b/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs index f9eed5b7..9635ea7e 100644 --- a/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs +++ b/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs @@ -1,7 +1,7 @@ namespace Infrastructure.Web.Api.Interfaces; /// -/// Defines the request for a specific Tenant +/// Defines a request for a specific tenant /// public interface ITenantedRequest { diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationRequest.cs index ece4291b..937af194 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationRequest.cs @@ -6,5 +6,5 @@ namespace Infrastructure.Web.Api.Operations.Shared.Organizations; [Authorize(Roles.Platform_Standard)] public class GetOrganizationRequest : UnTenantedRequest, IUnTenantedOrganizationRequest { - public required string Id { get; set; } + public string? Id { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationRequest.cs new file mode 100644 index 00000000..f5ecbf00 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationRequest.cs @@ -0,0 +1,15 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations/{Id}/members", ServiceOperation.Post, AccessType.Token)] +[Authorize(Roles.Tenant_Owner, Features.Tenant_Basic)] +public class InviteMemberToOrganizationRequest : UnTenantedRequest, + IUnTenantedOrganizationRequest +{ + public string? Email { get; set; } + + public string? UserId { get; set; } + + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationResponse.cs new file mode 100644 index 00000000..a151898d --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +public class InviteMemberToOrganizationResponse : IWebResponse +{ + public Organization? Organization { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/ListMembersForOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/ListMembersForOrganizationRequest.cs new file mode 100644 index 00000000..3c3c46c7 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/ListMembersForOrganizationRequest.cs @@ -0,0 +1,11 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations/{Id}/members", ServiceOperation.Search, AccessType.Token)] +[Authorize(Roles.Tenant_Member, Features.Tenant_Basic)] +public class ListMembersForOrganizationRequest : UnTenantedSearchRequest, + IUnTenantedOrganizationRequest +{ + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/ListMembersForOrganizationResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/ListMembersForOrganizationResponse.cs new file mode 100644 index 00000000..801c6eeb --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/ListMembersForOrganizationResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +public class ListMembersForOrganizationResponse : SearchResponse +{ + public List? Members { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs index 05395e66..b79944fb 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs @@ -431,14 +431,15 @@ public async Task WhenInvokeAndUnRequiredTenantIdAndIsAMember_ThenSetsTenantAndC .ReturnsAsync(new EndUserWithMemberships { Id = "auserid", - Memberships = new List - { - new() + Memberships = + [ + new Membership { Id = "amembershipid", + UserId = "auserid", OrganizationId = "atenantid" } - } + ] }); await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, @@ -490,14 +491,15 @@ public async Task WhenInvokeAndNoRequiredTenantIdButNoDefaultOrganization_ThenRe .ReturnsAsync(new EndUserWithMemberships { Id = "auserid", - Memberships = new List - { - new() + Memberships = + [ + new Membership { Id = "amembershipid", + UserId = "auserid", OrganizationId = "atenantid" } - } + ] }); await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, @@ -527,15 +529,16 @@ public async Task WhenInvokeAndNoRequiredTenantIdButHasDefaultOrganization_ThenS .ReturnsAsync(new EndUserWithMemberships { Id = "auserid", - Memberships = new List - { - new() + Memberships = + [ + new Membership { Id = "amembershipid", + UserId = "auserid", IsDefault = true, OrganizationId = "adefaultorganizationid" } - } + ] }); await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, @@ -588,14 +591,15 @@ public async Task WhenInvokeAndRequiredTenantIdAndIsAMember_ThenSetsTenantAndCon .ReturnsAsync(new EndUserWithMemberships { Id = "auserid", - Memberships = new List - { - new() + Memberships = + [ + new Membership { Id = "amembershipid", + UserId = "auserid", OrganizationId = "atenantid" } - } + ] }); await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index d4d981de..3cd6df7c 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -188,13 +188,12 @@ protected async Task LoginUserAsync(LoginUser who = LoginUser.Pers var person = await RegisterUserAsync(emailAddress, firstName); - return await ReAuthenticateUserAsync(person.Credential!.User, who); + return await ReAuthenticateUserAsync(person.Credential!.User); } - protected async Task ReAuthenticateUserAsync(RegisteredEndUser user, - LoginUser who = LoginUser.PersonA) + protected async Task ReAuthenticateUserAsync(RegisteredEndUser user) { - var emailAddress = GetEmailForPerson(who); + var emailAddress = user.Profile!.EmailAddress!; var login = await Api.PostAsync(new AuthenticatePasswordRequest { diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs index 76969144..e1ebb26c 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs @@ -5,6 +5,7 @@ using Common; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Interfaces.Services; using FluentAssertions; @@ -90,6 +91,7 @@ public async Task WhenCreateSharedOrganizationAsync_ThenReturnsSharedOrganizatio .ReturnsAsync(new Membership { Id = "amembershipid", + UserId = "auserid", OrganizationId = "anorganizationid", IsDefault = false }); @@ -196,7 +198,7 @@ public async Task WhenChangeSettingsAndNotExists_ThenReturnsError() public async Task WhenChangeSettings_ThenReturnsSettings() { var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - Ownership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + Ownership.Shared, "auserid".ToId(), DisplayName.Create("aname").Value).Value; org.CreateSettings(Settings.Create(new Dictionary { { "aname1", Setting.Create("anoldvalue", true).Value }, @@ -216,7 +218,7 @@ public async Task WhenChangeSettings_ThenReturnsSettings() result.Should().BeSuccess(); _repository.Verify(rep => rep.SaveAsync(It.Is(o => o.Name == "aname" - && o.Ownership == Ownership.Personal + && o.Ownership == Ownership.Shared && o.CreatedById == "auserid" && o.Settings.Properties.Count == 4 && o.Settings.Properties["aname1"].Value.As() == "anewvalue" @@ -228,4 +230,176 @@ public async Task WhenChangeSettings_ThenReturnsSettings() tss.CreateForTenantAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + + [Fact] + public async Task WhenInviteMemberToOrganizationAsyncAndNotExist_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.InviteMemberToOrganizationAsync(_caller.Object, "anorganizationid", + "auserid", null, CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenInviteMemberToOrganizationAsync_ThenInvites() + { + _caller.Setup(cc => cc.Roles) + .Returns(new ICallerContext.CallerRoles(Array.Empty(), new[] { TenantRoles.Owner })); + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + Ownership.Shared, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + _endUsersService.Setup(eus => + eus.InviteMemberToOrganizationPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new Membership + { + Id = "amembershipid", + UserId = "auserid", + OrganizationId = "anorganizationid", + IsDefault = false + }); + + var result = await _application.InviteMemberToOrganizationAsync(_caller.Object, "anorganizationid", + "auserid", null, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be("aname"); + result.Value.CreatedById.Should().Be("auserid"); + result.Value.Ownership.Should().Be(OrganizationOwnership.Shared); + _endUsersService.Verify(eus => + eus.InviteMemberToOrganizationPrivateAsync(_caller.Object, "anorganizationid", "auserid", null, + CancellationToken.None)); + } + + [Fact] + public async Task WhenListMembersForOrganizationAsyncAndNotExist_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.ListMembersForOrganizationAsync(_caller.Object, "anorganizationid", + new SearchOptions(), new GetOptions(), CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenListMembersForOrganizationAsyncWithUnregisteredUser_ThenReturnsMemberships() + { + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + Ownership.Shared, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + _endUsersService.Setup(eus => eus.ListMembershipsForOrganizationAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new SearchResults + { + Results = + [ + new MembershipWithUserProfile + { + Id = "amembershipid", + UserId = "auserid", + Status = EndUserStatus.Unregistered, + Roles = ["arole1", "arole2", "arole3"], + Features = ["afeature1", "afeature2", "afeature3"], + Profile = new UserProfile + { + Id = "aprofileid", + UserId = "auserid", + EmailAddress = "anemailaddress", + Name = new PersonName + { + FirstName = "anemailaddress" + }, + DisplayName = "anemailaddress" + }, + OrganizationId = "anorganizationid", + IsDefault = false + } + ] + }); + + var result = await _application.ListMembersForOrganizationAsync(_caller.Object, "anorganizationid", + new SearchOptions(), new GetOptions(), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Results.Count.Should().Be(1); + result.Value.Results[0].Id.Should().Be("amembershipid"); + result.Value.Results[0].UserId.Should().Be("auserid"); + result.Value.Results[0].IsRegistered.Should().BeFalse(); + result.Value.Results[0].IsOwner.Should().BeFalse(); + result.Value.Results[0].EmailAddress.Should().Be("anemailaddress"); + result.Value.Results[0].Name.FirstName.Should().Be("anemailaddress"); + result.Value.Results[0].Name.LastName.Should().BeNull(); + result.Value.Results[0].Roles.Should().ContainInOrder("arole1", "arole2", "arole3"); + result.Value.Results[0].Features.Should().ContainInOrder("afeature1", "afeature2", "afeature3"); + } + + [Fact] + public async Task WhenListMembersForOrganizationAsyncWithRegisteredUsers_ThenReturnsMemberships() + { + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + Ownership.Shared, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + _endUsersService.Setup(eus => eus.ListMembershipsForOrganizationAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new SearchResults + { + Results = + [ + new MembershipWithUserProfile + { + Id = "amembershipid", + UserId = "auserid", + Status = EndUserStatus.Registered, + Roles = ["arole1", "arole2", "arole3"], + Features = ["afeature1", "afeature2", "afeature3"], + OrganizationId = "anorganizationid", + Profile = new UserProfile + { + Id = "aprofileid", + UserId = "auserid", + EmailAddress = "anemailaddress", + Name = new PersonName + { + FirstName = "afirstname", + LastName = "alastname" + }, + DisplayName = "adisplayname" + }, + IsDefault = false + } + ] + }); + + var result = await _application.ListMembersForOrganizationAsync(_caller.Object, "anorganizationid", + new SearchOptions(), new GetOptions(), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Results.Count.Should().Be(1); + result.Value.Results[0].Id.Should().Be("amembershipid"); + result.Value.Results[0].UserId.Should().Be("auserid"); + result.Value.Results[0].IsRegistered.Should().BeTrue(); + result.Value.Results[0].IsOwner.Should().BeFalse(); + result.Value.Results[0].EmailAddress.Should().Be("anemailaddress"); + result.Value.Results[0].Name.FirstName.Should().Be("afirstname"); + result.Value.Results[0].Name.LastName.Should().Be("alastname"); + result.Value.Results[0].Roles.Should().ContainInOrder("arole1", "arole2", "arole3"); + result.Value.Results[0].Features.Should().ContainInOrder("afeature1", "afeature2", "afeature3"); + } } \ No newline at end of file diff --git a/src/OrganizationsApplication/IOrganizationsApplication.cs b/src/OrganizationsApplication/IOrganizationsApplication.cs index 4c1560fe..382f0eb5 100644 --- a/src/OrganizationsApplication/IOrganizationsApplication.cs +++ b/src/OrganizationsApplication/IOrganizationsApplication.cs @@ -26,4 +26,10 @@ Task> GetOrganizationSettingsAsync(ICall Task> GetSettingsAsync(ICallerContext caller, string id, CancellationToken cancellationToken); + + Task> InviteMemberToOrganizationAsync(ICallerContext caller, string id, string? userId, + string? emailAddress, CancellationToken cancellationToken); + + Task, Error>> ListMembersForOrganizationAsync(ICallerContext caller, + string? id, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/OrganizationsApplication/OrganizationsApplication.cs b/src/OrganizationsApplication/OrganizationsApplication.cs index ec8606da..a94ceb16 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.cs +++ b/src/OrganizationsApplication/OrganizationsApplication.cs @@ -7,7 +7,9 @@ using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Interfaces.Authorization; using Domain.Interfaces.Services; +using Domain.Shared; using OrganizationsApplication.Persistence; using OrganizationsDomain; @@ -189,10 +191,92 @@ public async Task> GetSettingsAsync(ICallerContext return settings.ToSettings(); } + + public async Task> InviteMemberToOrganizationAsync(ICallerContext caller, string id, + string? userId, string? emailAddress, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var organization = retrieved.Value; + var inviterRoles = Roles.Create(caller.Roles.Tenant); + if (!inviterRoles.IsSuccessful) + { + return inviterRoles.Error; + } + + Identifier? addedUserId = null; + var added = await organization.AddMembershipAsync(caller.ToCallerId(), inviterRoles.Value, async () => + { + var membership = + await _endUsersService.InviteMemberToOrganizationPrivateAsync(caller, id, userId, emailAddress, + cancellationToken); + if (!membership.IsSuccessful) + { + return membership.Error; + } + + addedUserId = membership.Value.UserId.ToId(); + return Result.Ok; + }); + if (!added.IsSuccessful) + { + return added.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Organization {Id} has invited {UserId} to be a member", + organization.Id, addedUserId!); + + return organization.ToOrganization(); + } + + public async Task, Error>> ListMembersForOrganizationAsync( + ICallerContext caller, string? id, SearchOptions searchOptions, + GetOptions getOptions, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var organization = retrieved.Value; + var memberships = + await _endUsersService.ListMembershipsForOrganizationAsync(caller, organization.Id, searchOptions, + getOptions, cancellationToken); + if (!memberships.IsSuccessful) + { + return memberships.Error; + } + + return searchOptions.ApplyWithMetadata(memberships.Value.Results.ConvertAll(x => x.ToMember())); + } } internal static class OrganizationConversionExtensions { + public static OrganizationMember ToMember(this MembershipWithUserProfile membership) + { + var dto = new OrganizationMember + { + Id = membership.Id, + UserId = membership.UserId, + IsDefault = membership.IsDefault, + IsRegistered = membership.Status == EndUserStatus.Registered, + IsOwner = membership.Roles.Contains(TenantRoles.Owner.Name), + Roles = membership.Roles, + Features = membership.Features, + EmailAddress = membership.Profile.EmailAddress, + Name = membership.Profile.Name, + Classification = membership.Profile.Classification + }; + + return dto; + } + public static Organization ToOrganization(this OrganizationRoot organization) { return new Organization diff --git a/src/OrganizationsApplication/OrganizationsApplication.csproj b/src/OrganizationsApplication/OrganizationsApplication.csproj index f1e837b2..1f42228f 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.csproj +++ b/src/OrganizationsApplication/OrganizationsApplication.csproj @@ -11,6 +11,7 @@ + diff --git a/src/OrganizationsDomain/OrganizationRoot.cs b/src/OrganizationsDomain/OrganizationRoot.cs index fe573b0f..20661f94 100644 --- a/src/OrganizationsDomain/OrganizationRoot.cs +++ b/src/OrganizationsDomain/OrganizationRoot.cs @@ -3,12 +3,16 @@ using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Interfaces; +using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Interfaces.Services; using Domain.Interfaces.ValueObjects; +using Domain.Shared; namespace OrganizationsDomain; +public delegate Task> Callback(); + public sealed class OrganizationRoot : AggregateRootBase { private readonly ITenantSettingService _tenantSettingService; @@ -126,6 +130,16 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco } } + public async Task> AddMembershipAsync(Identifier inviterId, Roles inviterRoles, Callback onPermitted) + { + if (!IsOwner(inviterRoles)) + { + return Error.RoleViolation(Resources.OrganizationRoot_AddMembership_NotOrgOwner); + } + + return await onPermitted(); + } + public Result CreateSettings(Settings settings) { foreach (var (key, value) in settings.Properties) @@ -167,4 +181,9 @@ public Result UpdateSettings(Settings settings) return Result.Ok; } + + private static bool IsOwner(Roles roles) + { + return roles.HasRole(TenantRoles.Owner); + } } \ No newline at end of file diff --git a/src/OrganizationsDomain/OrganizationsDomain.csproj b/src/OrganizationsDomain/OrganizationsDomain.csproj index 98374b83..bf791e77 100644 --- a/src/OrganizationsDomain/OrganizationsDomain.csproj +++ b/src/OrganizationsDomain/OrganizationsDomain.csproj @@ -6,6 +6,7 @@ + diff --git a/src/OrganizationsDomain/Resources.Designer.cs b/src/OrganizationsDomain/Resources.Designer.cs index 7fe9ae50..ee174d4c 100644 --- a/src/OrganizationsDomain/Resources.Designer.cs +++ b/src/OrganizationsDomain/Resources.Designer.cs @@ -68,6 +68,15 @@ internal static string OrganizationDisplayName_InvalidName { } } + /// + /// Looks up a localized string similar to You must be an organization owner to perform this action. + /// + internal static string OrganizationRoot_AddMembership_NotOrgOwner { + get { + return ResourceManager.GetString("OrganizationRoot_AddMembership_NotOrgOwner", resourceCulture); + } + } + /// /// Looks up a localized string similar to The data type of the value: '{0}' is unsupported. /// diff --git a/src/OrganizationsDomain/Resources.resx b/src/OrganizationsDomain/Resources.resx index 7ad336d3..478c517f 100644 --- a/src/OrganizationsDomain/Resources.resx +++ b/src/OrganizationsDomain/Resources.resx @@ -33,4 +33,7 @@ The value type of the value: '{0}' is unsupported + + You must be an organization owner to perform this action + \ No newline at end of file diff --git a/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs index 54a540fb..118e50c6 100644 --- a/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs +++ b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs @@ -1,7 +1,9 @@ using ApiHost1; using Application.Resources.Shared; +using Domain.Interfaces.Authorization; using FluentAssertions; using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Identities; using Infrastructure.Web.Api.Operations.Shared.Organizations; using IntegrationTesting.WebApi.Common; using Xunit; @@ -12,13 +14,15 @@ namespace OrganizationsInfrastructure.IntegrationTests; [Collection("API")] public class OrganizationsApiSpec : WebApiSpec { + private static int _invitationCount; + public OrganizationsApiSpec(WebApiSetup setup) : base(setup) { EmptyAllRepositories(); } [Fact] - public async Task WhenGetDefaultOrganization_ThenReturnsOrganization() + public async Task WhenGetOrganization_ThenReturnsOrganization() { var login = await LoginUserAsync(); @@ -35,7 +39,7 @@ public async Task WhenGetDefaultOrganization_ThenReturnsOrganization() [Fact] public async Task WhenCreateOrganization_ThenReturnsOrganization() { - var login = await LoginUserAsync(LoginUser.Operator); + var login = await LoginUserAsync(); var result = await Api.PostAsync(new CreateOrganizationRequest { @@ -46,4 +50,85 @@ public async Task WhenCreateOrganization_ThenReturnsOrganization() result.Content.Value.Organization!.Name.Should().Be("anorganizationname"); result.Content.Value.Organization!.Ownership.Should().Be(OrganizationOwnership.Shared); } + + [Fact] + public async Task WhenInviteMembersToOrganization_ThenAddsMembers() + { + var loginA = await LoginUserAsync(); + var loginB = await LoginUserAsync(LoginUser.PersonB); + var loginC = CreateRandomEmailAddress(); + + var organization = await Api.PostAsync(new CreateOrganizationRequest + { + Name = "anorganizationname" + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + loginA = await ReAuthenticateUserAsync(loginA.User); + var organizationId = organization.Content.Value.Organization!.Id; + await Api.PostAsync(new InviteMemberToOrganizationRequest + { + Id = organizationId, + Email = loginB.User.Profile!.EmailAddress + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + await Api.PostAsync(new InviteMemberToOrganizationRequest + { + Id = organizationId, + Email = loginC + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + var machine = await Api.PostAsync(new RegisterMachineRequest + { + Name = "amachinename" + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + var members = await Api.GetAsync(new ListMembersForOrganizationRequest + { + Id = organizationId, + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + members.Content.Value.Members!.Count.Should().Be(4); + members.Content.Value.Members[0].IsDefault.Should().BeTrue(); + members.Content.Value.Members[0].IsOwner.Should().BeTrue(); + members.Content.Value.Members[0].IsRegistered.Should().BeTrue(); + members.Content.Value.Members[0].UserId.Should().Be(loginA.User.Id); + members.Content.Value.Members[0].EmailAddress.Should().Be(loginA.User.Profile!.EmailAddress); + members.Content.Value.Members[0].Name.FirstName.Should().Be("persona"); + members.Content.Value.Members[0].Name.LastName.Should().Be("alastname"); + members.Content.Value.Members[0].Classification.Should().Be(UserProfileClassification.Person); + members.Content.Value.Members[0].Roles.Should().ContainInOrder(TenantRoles.BillingAdmin.Name, + TenantRoles.Owner.Name, TenantRoles.Member.Name); + members.Content.Value.Members[1].IsDefault.Should().BeTrue(); + members.Content.Value.Members[1].IsOwner.Should().BeFalse(); + members.Content.Value.Members[1].IsRegistered.Should().BeTrue(); + members.Content.Value.Members[1].UserId.Should().Be(loginB.User.Id); + members.Content.Value.Members[1].EmailAddress.Should().Be(loginB.User.Profile!.EmailAddress); + members.Content.Value.Members[1].Name.FirstName.Should().Be("personb"); + members.Content.Value.Members[1].Name.LastName.Should().Be("alastname"); + members.Content.Value.Members[1].Classification.Should().Be(UserProfileClassification.Person); + members.Content.Value.Members[1].Roles.Should().ContainSingle(role => role == TenantRoles.Member.Name); + members.Content.Value.Members[2].IsDefault.Should().BeTrue(); + members.Content.Value.Members[2].IsOwner.Should().BeFalse(); + members.Content.Value.Members[2].IsRegistered.Should().BeFalse(); + members.Content.Value.Members[2].UserId.Should().NotBeNullOrEmpty(); + members.Content.Value.Members[2].EmailAddress.Should().Be(loginC); + members.Content.Value.Members[2].Name.FirstName.Should().Be(loginC); + members.Content.Value.Members[2].Name.LastName.Should().BeNull(); + members.Content.Value.Members[2].Classification.Should().Be(UserProfileClassification.Person); + members.Content.Value.Members[2].Roles.Should().ContainSingle(role => role == TenantRoles.Member.Name); + members.Content.Value.Members[3].IsDefault.Should().BeTrue(); + members.Content.Value.Members[3].IsOwner.Should().BeFalse(); + members.Content.Value.Members[3].IsRegistered.Should().BeTrue(); + members.Content.Value.Members[3].UserId.Should().Be(machine.Content.Value.Machine!.Id); + members.Content.Value.Members[3].EmailAddress.Should().BeNull(); + members.Content.Value.Members[3].Name.FirstName.Should().Be("amachinename"); + members.Content.Value.Members[3].Name.LastName.Should().BeNull(); + members.Content.Value.Members[3].Classification.Should().Be(UserProfileClassification.Machine); + members.Content.Value.Members[3].Roles.Should().ContainSingle(role => role == TenantRoles.Member.Name); + } + + private static string CreateRandomEmailAddress() + { + return $"aninvitee{++_invitationCount}@company.com"; + } } \ No newline at end of file diff --git a/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/InviteMemberToOrganizationRequestValidatorSpec.cs b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/InviteMemberToOrganizationRequestValidatorSpec.cs new file mode 100644 index 00000000..3713f88c --- /dev/null +++ b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/InviteMemberToOrganizationRequestValidatorSpec.cs @@ -0,0 +1,77 @@ +using Domain.Common.Identity; +using FluentAssertions; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Organizations; +using OrganizationsInfrastructure.Api.Organizations; +using UnitTesting.Common.Validation; +using Xunit; + +namespace OrganizationsInfrastructure.UnitTests.Api.Organizations; + +[Trait("Category", "Unit")] +public class InviteMemberToOrganizationRequestValidatorSpec +{ + private readonly InviteMemberToOrganizationRequest _dto; + private readonly InviteMemberToOrganizationRequestValidator _validator; + + public InviteMemberToOrganizationRequestValidatorSpec() + { + _validator = new InviteMemberToOrganizationRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new InviteMemberToOrganizationRequest + { + Id = "anid", + UserId = "anid" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenUserIdAndEmailMissing_ThenThrows() + { + _dto.UserId = null; + _dto.Email = null; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.InviteOrganizationMemberRequestValidator_MissingUserIdAndEmail); + } + + [Fact] + public void WhenEmailIsInvalid_ThenThrows() + { + _dto.UserId = null; + _dto.Email = "notanemail"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.InviteOrganizationMemberRequestValidator_InvalidUserEmail); + } + + [Fact] + public void WhenEmailIsValid_ThenSucceeds() + { + _dto.UserId = null; + _dto.Email = "auser@company.com"; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenEmailAndUserIdIsValid_ThenSucceeds() + { + _dto.UserId = "anid"; + _dto.Email = "auser@company.com"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.InviteOrganizationMemberRequestValidator_BothUserIdAndEmail); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/InviteMemberToOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/InviteMemberToOrganizationRequestValidator.cs new file mode 100644 index 00000000..ad60aa13 --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/InviteMemberToOrganizationRequestValidator.cs @@ -0,0 +1,34 @@ +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Interfaces.Validations; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Organizations; + +namespace OrganizationsInfrastructure.Api.Organizations; + +public class InviteMemberToOrganizationRequestValidator : AbstractValidator +{ + public InviteMemberToOrganizationRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.Id) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + RuleFor(req => req.UserId) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId) + .When(req => req.UserId.HasValue()); + RuleFor(req => req.Email) + .IsEmailAddress() + .WithMessage(Resources.InviteOrganizationMemberRequestValidator_InvalidUserEmail) + .When(req => req.Email.HasValue()); + RuleFor(req => req) + .Null() + .WithMessage(Resources.InviteOrganizationMemberRequestValidator_MissingUserIdAndEmail) + .When(req => req.UserId.HasNoValue() && req.Email.HasNoValue()); + RuleFor(req => req) + .Null() + .WithMessage(Resources.InviteOrganizationMemberRequestValidator_BothUserIdAndEmail) + .When(req => req.UserId.HasValue() && req.Email.HasValue()); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs b/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs index 7cb00924..da31cabe 100644 --- a/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs +++ b/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs @@ -33,14 +33,14 @@ public async Task> Get(GetOr CancellationToken cancellationToken) { var organization = - await _organizationsApplication.GetOrganizationAsync(_contextFactory.Create(), request.Id, + await _organizationsApplication.GetOrganizationAsync(_contextFactory.Create(), request.Id!, cancellationToken); return () => organization.HandleApplicationResult(org => new GetOrganizationResponse { Organization = org }); } - + #if TESTINGONLY public async Task> GetSettings( GetOrganizationSettingsRequest request, CancellationToken cancellationToken) @@ -58,4 +58,31 @@ await _organizationsApplication.GetOrganizationSettingsAsync(_contextFactory.Cre }); } #endif + + public async Task> InviteMember( + InviteMemberToOrganizationRequest request, + CancellationToken cancellationToken) + { + var organization = + await _organizationsApplication.InviteMemberToOrganizationAsync(_contextFactory.Create(), request.Id!, + request.UserId, request.Email, + cancellationToken); + + return () => organization.HandleApplicationResult(org => + new PostResult(new InviteMemberToOrganizationResponse + { Organization = org })); + } + + public async Task> ListMembers( + ListMembersForOrganizationRequest request, + CancellationToken cancellationToken) + { + var members = + await _organizationsApplication.ListMembersForOrganizationAsync(_contextFactory.Create(), request.Id, + request.ToSearchOptions(), request.ToGetOptions(), cancellationToken); + + return () => + members.HandleApplicationResult(m => + new ListMembersForOrganizationResponse { Members = m.Results, Metadata = m.Metadata }); + } } \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Resources.Designer.cs b/src/OrganizationsInfrastructure/Resources.Designer.cs index e5a93d97..4068e4a4 100644 --- a/src/OrganizationsInfrastructure/Resources.Designer.cs +++ b/src/OrganizationsInfrastructure/Resources.Designer.cs @@ -67,5 +67,32 @@ internal static string CreateOrganizationRequestValidator_InvalidName { return ResourceManager.GetString("CreateOrganizationRequestValidator_InvalidName", resourceCulture); } } + + /// + /// Looks up a localized string similar to Only the 'Email' or the 'UserId' can be provided, not both. + /// + internal static string InviteOrganizationMemberRequestValidator_BothUserIdAndEmail { + get { + return ResourceManager.GetString("InviteOrganizationMemberRequestValidator_BothUserIdAndEmail", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Email' is either missing or invalid. + /// + internal static string InviteOrganizationMemberRequestValidator_InvalidUserEmail { + get { + return ResourceManager.GetString("InviteOrganizationMemberRequestValidator_InvalidUserEmail", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Both the 'Email' and 'UserId' are missing. + /// + internal static string InviteOrganizationMemberRequestValidator_MissingUserIdAndEmail { + get { + return ResourceManager.GetString("InviteOrganizationMemberRequestValidator_MissingUserIdAndEmail", resourceCulture); + } + } } } diff --git a/src/OrganizationsInfrastructure/Resources.resx b/src/OrganizationsInfrastructure/Resources.resx index 038730c4..79c7f5e3 100644 --- a/src/OrganizationsInfrastructure/Resources.resx +++ b/src/OrganizationsInfrastructure/Resources.resx @@ -27,4 +27,13 @@ The 'Name' is either missing or invalid + + The 'Email' is either missing or invalid + + + Both the 'Email' and 'UserId' are missing + + + Only the 'Email' or the 'UserId' can be provided, not both + \ No newline at end of file diff --git a/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs b/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs index f854e5f5..ce994d2d 100644 --- a/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs +++ b/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs @@ -51,7 +51,8 @@ public async Task WhenCreateProfileForAnyAndExistsForUserId_ThenReturnsError() _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user.ToOptional()); - var result = await _application.CreateProfileAsync(_caller.Object, UserProfileType.Person, "apersonid", + var result = await _application.CreateProfileAsync(_caller.Object, UserProfileClassification.Person, + "apersonid", "anemailaddress", "afirstname", "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), CancellationToken.None); @@ -66,7 +67,8 @@ public async Task WhenCreateProfileForAnyAndExistsForEmailAddress_ThenReturnsErr _repository.Setup(rep => rep.FindByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user.ToOptional()); - var result = await _application.CreateProfileAsync(_caller.Object, UserProfileType.Person, "apersonid", + var result = await _application.CreateProfileAsync(_caller.Object, UserProfileClassification.Person, + "apersonid", "auser@company.com", "afirstname", "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), CancellationToken.None); @@ -77,12 +79,13 @@ public async Task WhenCreateProfileForAnyAndExistsForEmailAddress_ThenReturnsErr [Fact] public async Task WhenCreateProfileAsyncForMachine_ThenCreatesProfile() { - var result = await _application.CreateProfileAsync(_caller.Object, UserProfileType.Machine, "amachineid", + var result = await _application.CreateProfileAsync(_caller.Object, UserProfileClassification.Machine, + "amachineid", "anemailaddress", "afirstname", "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), CancellationToken.None); result.Value.UserId.Should().Be("amachineid".ToId()); - result.Value.Type.Should().Be(UserProfileType.Machine); + result.Value.Classification.Should().Be(UserProfileClassification.Machine); result.Value.DisplayName.Should().Be("afirstname"); result.Value.Name.FirstName.Should().Be("afirstname"); result.Value.Name.LastName.Should().BeNull(); @@ -108,12 +111,13 @@ public async Task WhenCreateProfileAsyncForMachine_ThenCreatesProfile() [Fact] public async Task WhenCreateProfileAsyncForPerson_ThenCreatesProfile() { - var result = await _application.CreateProfileAsync(_caller.Object, UserProfileType.Person, "apersonid", + var result = await _application.CreateProfileAsync(_caller.Object, UserProfileClassification.Person, + "apersonid", "auser@company.com", "afirstname", "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), CancellationToken.None); result.Value.UserId.Should().Be("apersonid".ToId()); - result.Value.Type.Should().Be(UserProfileType.Person); + result.Value.Classification.Should().Be(UserProfileClassification.Person); result.Value.DisplayName.Should().Be("afirstname"); result.Value.Name.FirstName.Should().Be("afirstname"); result.Value.Name.LastName.Should().Be("alastname"); @@ -309,4 +313,32 @@ public async Task WhenGetProfileAsync_ThenReturnsProfile() result.Value.Timezone.Should().Be(Timezones.Default.ToString()); result.Value.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); } + + [Fact] + public async Task WhenGetAllProfilesAsyncAndNoIds_ThenReturnsProfiles() + { + var result = await _application.GetAllProfilesAsync(_caller.Object, new List(), new GetOptions(), + CancellationToken.None); + + result.Value.Should().BeEmpty(); + } + + [Fact] + public async Task WhenGetAllProfilesAsync_ThenReturnsProfiles() + { + var profile = UserProfileRoot.Create(_recorder.Object, _idFactory.Object, ProfileType.Person, "auserid".ToId(), + PersonName.Create("afirstname", "alastname").Value).Value; + _repository.Setup(rep => + rep.SearchAllByUserIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List + { + profile + }); + + var result = await _application.GetAllProfilesAsync(_caller.Object, new List { "auserid" }, + new GetOptions(), CancellationToken.None); + + result.Value.Count.Should().Be(1); + result.Value[0].Id.Should().Be("anid"); + } } \ No newline at end of file diff --git a/src/UserProfilesApplication/IUserProfilesApplication.cs b/src/UserProfilesApplication/IUserProfilesApplication.cs index c9b9372b..535a0eea 100644 --- a/src/UserProfilesApplication/IUserProfilesApplication.cs +++ b/src/UserProfilesApplication/IUserProfilesApplication.cs @@ -15,13 +15,17 @@ Task> ChangeProfileAsync(ICallerContext caller, strin string? lastName, string? displayName, string? phoneNumber, string? timezone, CancellationToken cancellationToken); - Task> CreateProfileAsync(ICallerContext caller, UserProfileType type, string userId, + Task> CreateProfileAsync(ICallerContext caller, UserProfileClassification classification, + string userId, string? emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, CancellationToken cancellationToken); Task, Error>> FindPersonByEmailAddressAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken); + Task, Error>> GetAllProfilesAsync(ICallerContext caller, List ids, + GetOptions options, CancellationToken cancellationToken); + Task> GetProfileAsync(ICallerContext caller, string userId, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/UserProfilesApplication/Persistence/IUserProfileRepository.cs b/src/UserProfilesApplication/Persistence/IUserProfileRepository.cs index 66fa9a6c..142e35cd 100644 --- a/src/UserProfilesApplication/Persistence/IUserProfileRepository.cs +++ b/src/UserProfilesApplication/Persistence/IUserProfileRepository.cs @@ -17,4 +17,7 @@ Task, Error>> FindByUserIdAsync(Identifier user Task> LoadAsync(Identifier id, CancellationToken cancellationToken); Task> SaveAsync(UserProfileRoot profile, CancellationToken cancellationToken); + + Task, Error>> SearchAllByUserIdsAsync(List ids, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/UserProfilesApplication/UserProfilesApplication.cs b/src/UserProfilesApplication/UserProfilesApplication.cs index cfde1a97..4bd1dd5c 100644 --- a/src/UserProfilesApplication/UserProfilesApplication.cs +++ b/src/UserProfilesApplication/UserProfilesApplication.cs @@ -26,11 +26,12 @@ public UserProfilesApplication(IRecorder recorder, IIdentifierFactory identifier _repository = repository; } - public async Task> CreateProfileAsync(ICallerContext caller, UserProfileType type, + public async Task> CreateProfileAsync(ICallerContext caller, + UserProfileClassification classification, string userId, string? emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, CancellationToken cancellationToken) { - if (type == UserProfileType.Person && emailAddress.HasNoValue()) + if (classification == UserProfileClassification.Person && emailAddress.HasNoValue()) { return Error.RuleViolation(Resources.UserProfilesApplication_PersonMustHaveEmailAddress); } @@ -46,7 +47,7 @@ public async Task> CreateProfileAsync(ICallerContext return Error.EntityExists(Resources.UserProfilesApplication_ProfileExistsForUser); } - if (type == UserProfileType.Person && emailAddress.HasValue()) + if (classification == UserProfileClassification.Person && emailAddress.HasValue()) { var email = EmailAddress.Create(emailAddress); if (!email.IsSuccessful) @@ -66,7 +67,7 @@ public async Task> CreateProfileAsync(ICallerContext } } - var name = PersonName.Create(firstName, type == UserProfileType.Person + var name = PersonName.Create(firstName, classification == UserProfileClassification.Person ? lastName : Optional.None); if (!name.IsSuccessful) @@ -74,7 +75,8 @@ public async Task> CreateProfileAsync(ICallerContext return name.Error; } - var created = UserProfileRoot.Create(_recorder, _identifierFactory, type.ToEnumOrDefault(ProfileType.Person), + var created = UserProfileRoot.Create(_recorder, _identifierFactory, + classification.ToEnumOrDefault(ProfileType.Person), userId.ToId(), name.Value); if (!created.IsSuccessful) { @@ -82,7 +84,7 @@ public async Task> CreateProfileAsync(ICallerContext } var profile = created.Value; - if (type == UserProfileType.Person) + if (classification == UserProfileClassification.Person) { var email2 = EmailAddress.Create(emailAddress!); if (!email2.IsSuccessful) @@ -328,6 +330,35 @@ public async Task> ChangeContactAddressAsync(ICallerC return saved.Value.ToProfile(); } + + public async Task, Error>> GetAllProfilesAsync(ICallerContext caller, List ids, + GetOptions options, CancellationToken cancellationToken) + { + if (ids.HasNone()) + { + return new List(); + } + + var retrieved = + await _repository.SearchAllByUserIdsAsync(ids.Select(id => id.ToId()).ToList(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var profiles = retrieved.Value; + if (profiles.HasNone()) + { + return new List(); + } + + _recorder.TraceInformation(caller.ToCall(), + "Profiles were retrieved for {ExpectedCount} users, and returned {ActualCount} profiles", ids.Count, + profiles.Count); + return profiles + .ConvertAll(profile => profile.ToProfile()) + .ToList(); + } } internal static class UserProfileConversionExtensions @@ -337,7 +368,7 @@ public static UserProfile ToProfile(this UserProfileRoot profile) return new UserProfile { Id = profile.Id, - Type = profile.Type.ToEnumOrDefault(UserProfileType.Person), + Classification = profile.Type.ToEnumOrDefault(UserProfileClassification.Person), UserId = profile.UserId, Name = profile.Name.ToName(), DisplayName = profile.DisplayName.ValueOrDefault!, diff --git a/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs b/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs index b2dc9d66..2c433894 100644 --- a/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs +++ b/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs @@ -19,7 +19,8 @@ public async Task> CreateMachineProfilePrivateAsync(I string machineId, string name, string? timezone, string? countryCode, CancellationToken cancellationToken) { - return await _userProfilesApplication.CreateProfileAsync(caller, UserProfileType.Machine, machineId, null, name, + return await _userProfilesApplication.CreateProfileAsync(caller, UserProfileClassification.Machine, machineId, + null, name, null, timezone, countryCode, cancellationToken); } @@ -27,7 +28,8 @@ public async Task> CreatePersonProfilePrivateAsync(IC string personId, string emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, CancellationToken cancellationToken) { - return await _userProfilesApplication.CreateProfileAsync(caller, UserProfileType.Person, personId, emailAddress, + return await _userProfilesApplication.CreateProfileAsync(caller, UserProfileClassification.Person, personId, + emailAddress, firstName, lastName, timezone, countryCode, cancellationToken); } @@ -37,6 +39,12 @@ public async Task, Error>> FindPersonByEmailAddress return await _userProfilesApplication.FindPersonByEmailAddressAsync(caller, emailAddress, cancellationToken); } + public async Task, Error>> GetAllProfilesPrivateAsync(ICallerContext caller, + List ids, GetOptions options, CancellationToken cancellationToken) + { + return await _userProfilesApplication.GetAllProfilesAsync(caller, ids, options, cancellationToken); + } + public async Task> GetProfilePrivateAsync(ICallerContext caller, string userId, CancellationToken cancellationToken) { diff --git a/src/UserProfilesInfrastructure/Persistence/UserProfileRepository.cs b/src/UserProfilesInfrastructure/Persistence/UserProfileRepository.cs index 7bd889eb..c356cc5d 100644 --- a/src/UserProfilesInfrastructure/Persistence/UserProfileRepository.cs +++ b/src/UserProfilesInfrastructure/Persistence/UserProfileRepository.cs @@ -55,6 +55,16 @@ public async Task> SaveAsync(UserProfileRoot prof return profile; } + public async Task, Error>> SearchAllByUserIdsAsync(List ids, + CancellationToken cancellationToken) + { + var tasks = await Task.WhenAll(ids.Select(async id => await FindByUserIdAsync(id, cancellationToken))); + return tasks.ToList() + .Where(task => task is { IsSuccessful: true, HasValue: true }) + .Select(task => task.Value.Value) + .ToList(); + } + public async Task, Error>> FindByUserIdAsync(Identifier userId, CancellationToken cancellationToken) {