diff --git a/docs/design-principles/0000-all-use-cases.md b/docs/design-principles/0000-all-use-cases.md index 63b1e862..226dcc64 100644 --- a/docs/design-principles/0000-all-use-cases.md +++ b/docs/design-principles/0000-all-use-cases.md @@ -66,6 +66,8 @@ These are the main use cases of this product that are exposed via "public" APIs 3. Invite a guest to register on the platform (a referral) 4. Resend an invitation to a guest 5. Guest verifies an invitation is still valid +6. Change the default organization for the current (Authenticated) user +7. List all memberships of the current (Authenticated) user ### Identities @@ -110,16 +112,15 @@ These are the main use cases of this product that are exposed via "public" APIs 1. Create a new (shared) organization for the current user 2. Inspect a specific organization -3. (coming soon) Change the organization's details +3. Change the organization's details 4. Add an Avatar image to the organization 5. Remove the Avatar from the organization 6. Invite another guest or person to an organization (guest by email, or an existing person by email or by ID) -7. (coming soon) Uninvite a member of the organization +7. Un-invite a member from the organization 8. (coming soon) Assign roles to a member 9. (coming soon) Unassign roles from a member -10. (coming soon) List all members of the organization -11. (coming soon) List all memberships of the current (Authenticated) user -12. (coming soon) Delete the organization +10. List all members of the organization +11. (coming soon) Delete the organization ### Subscriptions diff --git a/docs/design-principles/0170-eventing.md b/docs/design-principles/0170-eventing.md index 5c1fbaf5..d614c6e0 100644 --- a/docs/design-principles/0170-eventing.md +++ b/docs/design-principles/0170-eventing.md @@ -89,18 +89,25 @@ Another advantage (only available to event-sourced persistence scheme) is that w ### Event Notifications -Notifications are the mechanism by which subdomains can communicate to other subdomains (or to other processes) about what is happening in the source subdomain. This means that a source subdomain does not have to directly instruct another [dependent] target subdomain to update its state, when the source subdomain state changes. Typically, this is done by a direct synchronous method/API call. Now the target domain can simply react to the appearance of a "domain event" from the source subdomain, and take appropriate action. The coupling of the method/API call is gone. +Notifications are the mechanism by which subdomains can communicate to other subdomains (or to other processes) about what is happening in the source subdomain. This means that a source subdomain does not have to [imperatively] instruct another [dependent] target subdomain to update its state, when the source subdomain state changes. Typically, this is done by a direct synchronous method/API call. -> This is particularly useful when you have highly inter-dependent subdomains, that require that their data be in sync with each other (i.e., `EndUser` memberships with `Organizations`. +Instead, the target domain can simply "observe" and react to the appearance of a "domain event" from the source subdomain, and take appropriate action. -This characteristic is particularly necessary in distributed deployments, where direct calls are HTTP calls, requiring both the source and target subdomains to be responsive to each other. +The coupling of the imperative method/API call is eliminated. -Instead, this decoupling via "integration events" would normally done in distributed systems with a message broker of some kind (i.e., a queue, a message bus, etc.). +> This is particularly useful when you have highly inter-dependent subdomains, that require that their data be in sync with each other (i.e., `EndUser` memberships with `Organizations` and `UserProfiles`. As seen below. + +![Generic Subdomains](../images/Event Flows - Generic.png) + +This eventing capability is particularly necessary in distributed deployments, where direct calls between separately deployed components are realized as HTTP calls (requiring both the source and target subdomains to be synchronously responsive and consistent to each other). + +Instead, decoupling this asynchronously via "integration events" would normally done in distributed systems with a message broker of some kind (i.e., a queue, a message bus, etc.). The synchronous publication of all "domain events" is handled automatically by the `IEventNotifyingStoreNotificationRelay` (after events have first been projected by the `IEventNotifyingStoreProjectionRelay`). -Domain events are published synchronously (round-robin) one at a time: +Domain/Integration events are published synchronously (round-robin) one at a time: 1. First, to all registered `IDomainEventNotificationConsumer` consumers. These consumers can fail and report back errors that are captured synchronously. 2. Then to all registered `IIntegrationEventNotificationTranslator` translators, that have the option to translate a "domain event" into an "integration event" or not. This translation can also fail, and report back errors that are captured synchronously. 3. Finally, if the translator translates a "domain event" into an "integration event" it is then published to the `IEventNotificationMessageBroker` that should send the "integration event" to some external message broker, who will deliver it asynchronous to external consumers. This can also fail, and report back errors that are captured synchronously. + diff --git a/docs/images/Event Flows - Generic.png b/docs/images/Event Flows - Generic.png new file mode 100644 index 00000000..d0b332df Binary files /dev/null and b/docs/images/Event Flows - Generic.png differ diff --git a/docs/images/Ports-And-Adapters.png b/docs/images/Ports-And-Adapters.png index 5ab7622d..7fba2a40 100644 Binary files a/docs/images/Ports-And-Adapters.png and b/docs/images/Ports-And-Adapters.png differ diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx index 68ea7bcc..1f89c8b0 100644 Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ diff --git a/src/AncillaryInfrastructure/Persistence/ReadModels/AuditProjection.cs b/src/AncillaryInfrastructure/Persistence/ReadModels/AuditProjection.cs index 9dc7e3c7..36a5b1f9 100644 --- a/src/AncillaryInfrastructure/Persistence/ReadModels/AuditProjection.cs +++ b/src/AncillaryInfrastructure/Persistence/ReadModels/AuditProjection.cs @@ -3,7 +3,6 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Domain.Common.ValueObjects; using Domain.Events.Shared.Ancillary.Audits; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -29,7 +28,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _audits.HandleCreateAsync(e.RootId.ToId(), dto => + return await _audits.HandleCreateAsync(e.RootId, dto => { dto.OrganizationId = e.OrganizationId; dto.AuditCode = e.AuditCode; diff --git a/src/AncillaryInfrastructure/Persistence/ReadModels/EmailDeliveryProjection.cs b/src/AncillaryInfrastructure/Persistence/ReadModels/EmailDeliveryProjection.cs index ee8ba0ce..8cd65e03 100644 --- a/src/AncillaryInfrastructure/Persistence/ReadModels/EmailDeliveryProjection.cs +++ b/src/AncillaryInfrastructure/Persistence/ReadModels/EmailDeliveryProjection.cs @@ -3,7 +3,6 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Domain.Common.ValueObjects; using Domain.Events.Shared.Ancillary.EmailDelivery; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -29,7 +28,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _deliveries.HandleCreateAsync(e.RootId.ToId(), dto => + return await _deliveries.HandleCreateAsync(e.RootId, dto => { dto.MessageId = e.MessageId; dto.Attempts = DeliveryAttempts.Empty; @@ -40,7 +39,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven cancellationToken); case EmailDetailsChanged e: - return await _deliveries.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _deliveries.HandleUpdateAsync(e.RootId, dto => { dto.Subject = e.Subject; dto.Body = e.Body; @@ -49,7 +48,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case DeliveryAttempted e: - return await _deliveries.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _deliveries.HandleUpdateAsync(e.RootId, dto => { var attempts = dto.Attempts.HasValue ? dto.Attempts.Value.Attempt(e.When) @@ -67,11 +66,11 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case DeliveryFailed e: - return await _deliveries.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.Failed = e.When; }, + return await _deliveries.HandleUpdateAsync(e.RootId, dto => { dto.Failed = e.When; }, cancellationToken); case DeliverySucceeded e: - return await _deliveries.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.Delivered = e.When; }, + return await _deliveries.HandleUpdateAsync(e.RootId, dto => { dto.Delivered = e.When; }, cancellationToken); default: diff --git a/src/Application.Resources.Shared/EndUser.cs b/src/Application.Resources.Shared/EndUser.cs index bd52f492..de515858 100644 --- a/src/Application.Resources.Shared/EndUser.cs +++ b/src/Application.Resources.Shared/EndUser.cs @@ -53,6 +53,8 @@ public class Membership : IIdentifiableResource public required string OrganizationId { get; set; } + public required OrganizationOwnership Ownership { get; set; } + public List Roles { get; set; } = new(); public required string Id { get; set; } diff --git a/src/Application.Resources.Shared/UserProfile.cs b/src/Application.Resources.Shared/UserProfile.cs index 3530ff86..51d8f1bf 100644 --- a/src/Application.Resources.Shared/UserProfile.cs +++ b/src/Application.Resources.Shared/UserProfile.cs @@ -26,7 +26,7 @@ public class UserProfile : IIdentifiableResource public required string Id { get; set; } } -public class UserProfileForCurrent : UserProfileWithDefaultMembership +public class UserProfileForCaller : UserProfileWithDefaultMembership { public List Features { get; set; } = new(); diff --git a/src/CarsInfrastructure/Persistence/ReadModels/CarProjection.cs b/src/CarsInfrastructure/Persistence/ReadModels/CarProjection.cs index 14c826ad..6fda23ca 100644 --- a/src/CarsInfrastructure/Persistence/ReadModels/CarProjection.cs +++ b/src/CarsInfrastructure/Persistence/ReadModels/CarProjection.cs @@ -3,7 +3,6 @@ using CarsApplication.Persistence.ReadModels; using CarsDomain; using Common; -using Domain.Common.ValueObjects; using Domain.Events.Shared.Cars; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -32,7 +31,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _cars.HandleCreateAsync(e.RootId.ToId(), dto => + return await _cars.HandleCreateAsync(e.RootId, dto => { dto.OrganizationId = e.OrganizationId; dto.Status = e.Status; diff --git a/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs b/src/Domain.Events.Shared/EndUsers/DefaultMembershipChanged.cs similarity index 74% rename from src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs rename to src/Domain.Events.Shared/EndUsers/DefaultMembershipChanged.cs index dd0bb3a8..04a6530d 100644 --- a/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs +++ b/src/Domain.Events.Shared/EndUsers/DefaultMembershipChanged.cs @@ -4,14 +4,14 @@ namespace Domain.Events.Shared.EndUsers; -public sealed class MembershipDefaultChanged : DomainEvent +public sealed class DefaultMembershipChanged : DomainEvent { - public MembershipDefaultChanged(Identifier id) : base(id) + public DefaultMembershipChanged(Identifier id) : base(id) { } [UsedImplicitly] - public MembershipDefaultChanged() + public DefaultMembershipChanged() { } diff --git a/src/Domain.Events.Shared/EndUsers/MembershipRemoved.cs b/src/Domain.Events.Shared/EndUsers/MembershipRemoved.cs new file mode 100644 index 00000000..8338183f --- /dev/null +++ b/src/Domain.Events.Shared/EndUsers/MembershipRemoved.cs @@ -0,0 +1,23 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.EndUsers; + +public sealed class MembershipRemoved : DomainEvent +{ + public MembershipRemoved(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public MembershipRemoved() + { + } + + public required string MembershipId { get; set; } + + public required string OrganizationId { get; set; } + + public required string UnInvitedById { get; set; } +} \ No newline at end of file diff --git a/src/Domain.Events.Shared/Organizations/MemberInvited.cs b/src/Domain.Events.Shared/Organizations/MemberInvited.cs new file mode 100644 index 00000000..ef181998 --- /dev/null +++ b/src/Domain.Events.Shared/Organizations/MemberInvited.cs @@ -0,0 +1,23 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.Organizations; + +public sealed class MemberInvited : DomainEvent +{ + public MemberInvited(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public MemberInvited() + { + } + + public string? EmailAddress { get; set; } + + public required string InvitedById { get; set; } + + public string? UserId { get; set; } +} \ No newline at end of file diff --git a/src/Domain.Events.Shared/Organizations/MemberUnInvited.cs b/src/Domain.Events.Shared/Organizations/MemberUnInvited.cs new file mode 100644 index 00000000..1b5ebfe3 --- /dev/null +++ b/src/Domain.Events.Shared/Organizations/MemberUnInvited.cs @@ -0,0 +1,21 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.Organizations; + +public sealed class MemberUnInvited : DomainEvent +{ + public MemberUnInvited(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public MemberUnInvited() + { + } + + public required string UninvitedById { get; set; } + + public required string UserId { get; set; } +} \ No newline at end of file diff --git a/src/Domain.Events.Shared/Organizations/MembershipAdded.cs b/src/Domain.Events.Shared/Organizations/MembershipAdded.cs index 52a1520c..ad6f948c 100644 --- a/src/Domain.Events.Shared/Organizations/MembershipAdded.cs +++ b/src/Domain.Events.Shared/Organizations/MembershipAdded.cs @@ -15,9 +15,5 @@ public MembershipAdded() { } - public string? EmailAddress { get; set; } - - public required string InvitedById { get; set; } - - public string? UserId { get; set; } + public required string UserId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Organizations/MembershipRemoved.cs b/src/Domain.Events.Shared/Organizations/MembershipRemoved.cs new file mode 100644 index 00000000..28e5d245 --- /dev/null +++ b/src/Domain.Events.Shared/Organizations/MembershipRemoved.cs @@ -0,0 +1,19 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.Organizations; + +public sealed class MembershipRemoved : DomainEvent +{ + public MembershipRemoved(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public MembershipRemoved() + { + } + + public required string UserId { get; set; } +} \ No newline at end of file diff --git a/src/Domain.Events.Shared/Organizations/NameChanged.cs b/src/Domain.Events.Shared/Organizations/NameChanged.cs new file mode 100644 index 00000000..a3e82c3a --- /dev/null +++ b/src/Domain.Events.Shared/Organizations/NameChanged.cs @@ -0,0 +1,19 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.Organizations; + +public sealed class NameChanged : DomainEvent +{ + public NameChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public NameChanged() + { + } + + public required string Name { get; set; } +} \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index 3be6979b..7f30c1ed 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -826,4 +826,22 @@ public async Task WhenGetMembershipsAsync_ThenReturnsUser() result.Value.Memberships[0].Roles.Should().ContainSingle(role => role == TenantRoles.Member.Name); result.Value.Memberships[0].Features.Should().ContainSingle(feat => feat == TenantFeatures.PaidTrial.Name); } + + [Fact] + public async Task WhenChangeDefaultMembershipAsync_ThenChanges() + { + var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + user.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), + EndUserProfile.Create("afirstname").Value, Optional.None); + user.AddMembership(user, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, + Features.Create(TenantFeatures.Basic).Value); + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(user); + + var result = await _application.ChangeDefaultMembershipAsync(_caller.Object, "anorganizationid", + CancellationToken.None); + + result.Value.Id.Should().Be("anid"); + } } \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs b/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs index ddf7c64e..2b62ede0 100644 --- a/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs +++ b/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs @@ -58,14 +58,14 @@ public InvitationsApplicationDomainEventHandlersSpec() } [Fact] - public async Task WhenHandleOrganizationMembershipAddedAsyncAndNoUserIdNorEmailAddress_ThenReturnsError() + public async Task WhenHandleOrganizationMemberInvitedAsyncAndNoUserIdNorEmailAddress_ThenReturnsError() { - var domainEvent = Events.MembershipAdded("anorganizationid".ToId(), "aninviterid".ToId(), + var domainEvent = Events.MemberInvited("anorganizationid".ToId(), "aninviterid".ToId(), Optional.None, Optional.None); var result = - await _application.HandleOrganizationMembershipAddedAsync(_caller.Object, domainEvent, + await _application.HandleOrganizationMemberInvitedAsync(_caller.Object, domainEvent, CancellationToken.None); result.Should().BeError(ErrorCode.RuleViolation, @@ -73,7 +73,7 @@ await _application.HandleOrganizationMembershipAddedAsync(_caller.Object, domain } [Fact] - public async Task WhenHandleOrganizationMembershipAddedAsyncWithRegisteredUserEmail_ThenAddsMembership() + public async Task WhenHandleOrganizationMemberInvitedAsyncWithRegisteredUserEmail_ThenAddsMembership() { var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; @@ -103,12 +103,12 @@ public async Task WhenHandleOrganizationMembershipAddedAsyncWithRegisteredUserEm .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; _repository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())) .ReturnsAsync(invitee); - var domainEvent = Events.MembershipAdded("anorganizationid".ToId(), "aninviterid".ToId(), + var domainEvent = Events.MemberInvited("anorganizationid".ToId(), "aninviterid".ToId(), Optional.None, EmailAddress.Create("aninvitee@company.com").Value); var result = - await _application.HandleOrganizationMembershipAddedAsync(_caller.Object, domainEvent, + await _application.HandleOrganizationMemberInvitedAsync(_caller.Object, domainEvent, CancellationToken.None); result.Should().BeSuccess(); @@ -131,7 +131,7 @@ await _application.HandleOrganizationMembershipAddedAsync(_caller.Object, domain } [Fact] - public async Task WhenHandleOrganizationMembershipAddedAsyncWithGuestEmailAddress_ThenAddsMembership() + public async Task WhenHandleOrganizationMemberInvitedAsyncWithGuestEmailAddress_ThenAddsMembership() { var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; @@ -158,12 +158,12 @@ public async Task WhenHandleOrganizationMembershipAddedAsyncWithGuestEmailAddres UserId = "aninviterid", Id = "aprofileid" }); - var domainEvent = Events.MembershipAdded("anorganizationid".ToId(), "aninviterid".ToId(), + var domainEvent = Events.MemberInvited("anorganizationid".ToId(), "aninviterid".ToId(), Optional.None, EmailAddress.Create("aninvitee@company.com").Value); var result = - await _application.HandleOrganizationMembershipAddedAsync(_caller.Object, domainEvent, + await _application.HandleOrganizationMemberInvitedAsync(_caller.Object, domainEvent, CancellationToken.None); result.Should().BeSuccess(); @@ -186,7 +186,7 @@ await _application.HandleOrganizationMembershipAddedAsync(_caller.Object, domain } [Fact] - public async Task WhenHandleOrganizationMembershipAddedAsyncWithUserId_ThenAddsMembership() + public async Task WhenHandleOrganizationMemberInvitedAsyncWithUserId_ThenAddsMembership() { var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; @@ -212,11 +212,11 @@ await invitee.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), UserId = "aninviterid", Id = "aprofileid" }); - var domainEvent = Events.MembershipAdded("anorganizationid".ToId(), "aninviterid".ToId(), "aninviteeid".ToId(), + var domainEvent = Events.MemberInvited("anorganizationid".ToId(), "aninviterid".ToId(), "aninviteeid".ToId(), Optional.None); var result = - await _application.HandleOrganizationMembershipAddedAsync(_caller.Object, domainEvent, + await _application.HandleOrganizationMemberInvitedAsync(_caller.Object, domainEvent, CancellationToken.None); result.Should().BeSuccess(); @@ -236,4 +236,33 @@ await _application.HandleOrganizationMembershipAddedAsync(_caller.Object, domain _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())); _repository.Verify(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny())); } + + [Fact] + public async Task WhenHandleOrganizationMemberUnInvitedAsync_ThenRemovesMembership() + { + var inviter = EndUserRoot + .Create(_recorder.Object, "anuninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + inviter.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Empty); + _repository.Setup(rep => rep.LoadAsync("anuninviterid".ToId(), It.IsAny())) + .ReturnsAsync(inviter); + var invitee = EndUserRoot + .Create(_recorder.Object, "anuninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; + await invitee.InviteGuestAsync(_tokensService.Object, "anuninviterid".ToId(), + EmailAddress.Create("aninvitee@company.com").Value, (_, _) => Task.FromResult(Result.Ok)); + _repository.Setup(rep => rep.LoadAsync("anuninviteeid".ToId(), It.IsAny())) + .ReturnsAsync(invitee); + var domainEvent = + Events.MemberUnInvited("anorganizationid".ToId(), "anuninviterid".ToId(), "anuninviteeid".ToId()); + + var result = await _application.HandleOrganizationMemberUnInvitedAsync(_caller.Object, domainEvent, + CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(eu => + eu.Memberships.Count == 0 + ), It.IsAny())); + _repository.Verify(rep => rep.LoadAsync("anuninviterid".ToId(), It.IsAny())); + _repository.Verify(rep => rep.LoadAsync("anuninviteeid".ToId(), It.IsAny())); + } } \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs b/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs index 843df232..28d05eba 100644 --- a/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs +++ b/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs @@ -1,9 +1,13 @@ +using Application.Common.Extensions; using Application.Interfaces; using Application.Resources.Shared; using Common; using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Events.Shared.Organizations; +using Domain.Shared.EndUsers; +using EndUsersDomain; +using Membership = Application.Resources.Shared.Membership; namespace EndUsersApplication; @@ -22,4 +26,50 @@ public async Task> HandleOrganizationCreatedAsync(ICallerContext c return Result.Ok; } + + private async Task> CreateMembershipAsync(ICallerContext context, + Identifier createdById, Identifier organizationId, OrganizationOwnership ownership, + CancellationToken cancellationToken) + { + var retrievedInviter = await _endUserRepository.LoadAsync(createdById, cancellationToken); + if (!retrievedInviter.IsSuccessful) + { + return retrievedInviter.Error; + } + + var inviter = retrievedInviter.Value; + var useCase = ownership switch + { + OrganizationOwnership.Shared => RolesAndFeaturesUseCase.CreatingOrg, + OrganizationOwnership.Personal => inviter.Classification == UserClassification.Person + ? RolesAndFeaturesUseCase.CreatingPerson + : RolesAndFeaturesUseCase.CreatingMachine, + _ => RolesAndFeaturesUseCase.CreatingOrg + }; + var (_, _, tenantRoles, tenantFeatures) = + EndUserRoot.GetInitialRolesAndFeatures(useCase, context.IsAuthenticated); + var inviterOwnership = ownership.ToEnumOrDefault(Domain.Shared.Organizations.OrganizationOwnership.Shared); + var membered = inviter.AddMembership(inviter, inviterOwnership, organizationId, tenantRoles, tenantFeatures); + if (!membered.IsSuccessful) + { + return membered.Error; + } + + var saved = await _endUserRepository.SaveAsync(inviter, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(context.ToCall(), "EndUser {Id} has become a member of organization {Organization}", + inviter.Id, organizationId); + + var membership = saved.Value.FindMembership(organizationId); + if (!membership.HasValue) + { + return Error.EntityNotFound(Resources.EndUsersApplication_MembershipNotFound); + } + + return membership.Value.ToMembership(); + } } \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index 9a5ee4b5..7890198f 100644 --- a/src/EndUsersApplication/EndUsersApplication.cs +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -61,6 +61,25 @@ public async Task> GetUserAsync(ICallerContext context, s return user.ToUser(); } + public async Task, Error>> ListMembershipsForCallerAsync(ICallerContext caller, + SearchOptions searchOptions, GetOptions getOptions, + CancellationToken cancellationToken) + { + var userId = caller.ToCallerId(); + var retrieved = await _endUserRepository.LoadAsync(userId, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var user = retrieved.Value; + var memberships = user.Memberships.Select(ms => ms.ToMembership()).ToList(); + + _recorder.TraceInformation(caller.ToCall(), "Retrieved memberships for user: {Id}", user.Id); + + return searchOptions.ApplyWithMetadata(memberships); + } + public async Task, Error>> ListMembershipsForOrganizationAsync( ICallerContext caller, string organizationId, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken) @@ -446,6 +465,36 @@ public async Task> UnassignPlatformRolesAsync(ICallerCont return updated.Value.ToUser(); } + public async Task> ChangeDefaultMembershipAsync(ICallerContext caller, string organizationId, + CancellationToken cancellationToken) + { + var userId = caller.ToCallerId(); + var retrieved = await _endUserRepository.LoadAsync(userId, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var user = retrieved.Value; + var changed = user.ChangeDefaultMembership(organizationId.ToId()); + if (!changed.IsSuccessful) + { + return changed.Error; + } + + var saved = await _endUserRepository.SaveAsync(user, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + user = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Default membership changed for user {Id} to {OrganizationId}", + user.Id, organizationId); + + return user.ToUser(); + } + public async Task> AssignTenantRolesAsync(ICallerContext context, string organizationId, string id, List roles, CancellationToken cancellationToken) @@ -477,12 +526,13 @@ public async Task> AssignTenantRolesAsync( } var membership = assigned.Value; - var updated = await _endUserRepository.SaveAsync(assignee, cancellationToken); - if (!updated.IsSuccessful) + var saved = await _endUserRepository.SaveAsync(assignee, cancellationToken); + if (!saved.IsSuccessful) { - return updated.Error; + return saved.Error; } + assignee = saved.Value; _recorder.TraceInformation(context.ToCall(), "EndUser {Id} has been assigned tenant roles {Roles} to membership {Membership}", assignee.Id, roles.JoinAsOredChoices(), membership.Id); @@ -494,52 +544,6 @@ public async Task> AssignTenantRolesAsync( return assignee.ToUserWithMemberships(); } - private async Task> CreateMembershipAsync(ICallerContext context, - Identifier createdById, Identifier organizationId, OrganizationOwnership ownership, - CancellationToken cancellationToken) - { - var retrievedInviter = await _endUserRepository.LoadAsync(createdById, cancellationToken); - if (!retrievedInviter.IsSuccessful) - { - return retrievedInviter.Error; - } - - var inviter = retrievedInviter.Value; - var useCase = ownership switch - { - OrganizationOwnership.Shared => RolesAndFeaturesUseCase.CreatingOrg, - OrganizationOwnership.Personal => inviter.Classification == UserClassification.Person - ? RolesAndFeaturesUseCase.CreatingPerson - : RolesAndFeaturesUseCase.CreatingMachine, - _ => RolesAndFeaturesUseCase.CreatingOrg - }; - var (_, _, tenantRoles, tenantFeatures) = - EndUserRoot.GetInitialRolesAndFeatures(useCase, context.IsAuthenticated); - var inviterOwnership = ownership.ToEnumOrDefault(Domain.Shared.Organizations.OrganizationOwnership.Shared); - var membered = inviter.AddMembership(inviter, inviterOwnership, organizationId, tenantRoles, tenantFeatures); - if (!membered.IsSuccessful) - { - return membered.Error; - } - - var saved = await _endUserRepository.SaveAsync(inviter, cancellationToken); - if (!saved.IsSuccessful) - { - return saved.Error; - } - - _recorder.TraceInformation(context.ToCall(), "EndUser {Id} has become a member of organization {Organization}", - inviter.Id, organizationId); - - var membership = saved.Value.FindMembership(organizationId); - if (!membership.HasValue) - { - return Error.EntityNotFound(Resources.EndUsersApplication_MembershipNotFound); - } - - return membership.Value.ToMembership(); - } - private async Task> WithGetOptionsAsync(ICallerContext caller, List memberships, GetOptions options, CancellationToken cancellationToken) { @@ -690,6 +694,7 @@ public static MembershipWithUserProfile ToMembership(this MembershipJoinInvitati UserId = membership.UserId.Value, IsDefault = membership.IsDefault, OrganizationId = membership.UserId.Value, + Ownership = membership.Ownership.Value.ToEnumOrDefault(OrganizationOwnership.Shared), Status = membership.Status.Value.ToEnumOrDefault(EndUserStatus.Unregistered), Roles = membership.Roles.Value.ToList(), Features = membership.Features.Value.ToList(), @@ -699,16 +704,17 @@ public static MembershipWithUserProfile ToMembership(this MembershipJoinInvitati return dto; } - public static Membership ToMembership(this EndUsersDomain.Membership ms) + public static Membership ToMembership(this EndUsersDomain.Membership membership) { return new Membership { - Id = ms.Id, - UserId = ms.RootId.Value, - IsDefault = ms.IsDefault, - OrganizationId = ms.OrganizationId.Value, - Features = ms.Features.ToList(), - Roles = ms.Roles.ToList() + Id = membership.Id, + UserId = membership.RootId.Value, + IsDefault = membership.IsDefault, + OrganizationId = membership.OrganizationId.Value, + Ownership = membership.Ownership.Value.ToEnumOrDefault(OrganizationOwnership.Shared), + Features = membership.Features.ToList(), + Roles = membership.Roles.ToList() }; } diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs index 2bfe90d8..6370a7a4 100644 --- a/src/EndUsersApplication/IEndUsersApplication.cs +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -13,6 +13,9 @@ Task> AssignTenantRolesAsync(ICallerContex string id, List roles, CancellationToken cancellationToken); + Task> ChangeDefaultMembershipAsync(ICallerContext caller, string organizationId, + CancellationToken cancellationToken); + Task, Error>> FindPersonByEmailAddressAsync(ICallerContext context, string emailAddress, CancellationToken cancellationToken); @@ -21,6 +24,9 @@ Task> GetMembershipsAsync(ICallerContext c Task> GetUserAsync(ICallerContext context, string id, CancellationToken cancellationToken); + Task, Error>> ListMembershipsForCallerAsync(ICallerContext caller, + SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken); + Task, Error>> ListMembershipsForOrganizationAsync( ICallerContext caller, string organizationId, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken); diff --git a/src/EndUsersApplication/IInvitationsApplication.DomainEventHandlers.cs b/src/EndUsersApplication/IInvitationsApplication.DomainEventHandlers.cs index 5d69f92a..b18eaf59 100644 --- a/src/EndUsersApplication/IInvitationsApplication.DomainEventHandlers.cs +++ b/src/EndUsersApplication/IInvitationsApplication.DomainEventHandlers.cs @@ -6,6 +6,9 @@ namespace EndUsersApplication; partial interface IInvitationsApplication { - Task> HandleOrganizationMembershipAddedAsync(ICallerContext caller, MembershipAdded domainEvent, + Task> HandleOrganizationMemberInvitedAsync(ICallerContext caller, MemberInvited domainEvent, + CancellationToken cancellationToken); + + Task> HandleOrganizationMemberUnInvitedAsync(ICallerContext caller, MemberUnInvited domainEvent, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/EndUsersApplication/InvitationsApplication.DomainEventHandlers.cs b/src/EndUsersApplication/InvitationsApplication.DomainEventHandlers.cs index e6f18289..a235a101 100644 --- a/src/EndUsersApplication/InvitationsApplication.DomainEventHandlers.cs +++ b/src/EndUsersApplication/InvitationsApplication.DomainEventHandlers.cs @@ -1,14 +1,19 @@ +using Application.Common.Extensions; using Application.Interfaces; using Common; +using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Events.Shared.Organizations; +using EndUsersDomain; +using Membership = Application.Resources.Shared.Membership; +using OrganizationOwnership = Domain.Shared.Organizations.OrganizationOwnership; namespace EndUsersApplication; partial class InvitationsApplication { - public async Task> HandleOrganizationMembershipAddedAsync(ICallerContext caller, - MembershipAdded domainEvent, CancellationToken cancellationToken) + public async Task> HandleOrganizationMemberInvitedAsync(ICallerContext caller, + MemberInvited domainEvent, CancellationToken cancellationToken) { var membership = await InviteMemberToOrganizationAsync(caller, domainEvent.RootId.ToId(), domainEvent.InvitedById, domainEvent.UserId, domainEvent.EmailAddress, cancellationToken); @@ -19,4 +24,119 @@ public async Task> HandleOrganizationMembershipAddedAsync(ICallerC return Result.Ok; } + + public async Task> HandleOrganizationMemberUnInvitedAsync(ICallerContext caller, + MemberUnInvited domainEvent, + CancellationToken cancellationToken) + { + var membership = await UnInviteMemberFromOrganizationAsync(caller, domainEvent.RootId.ToId(), + domainEvent.UninvitedById, domainEvent.UserId, cancellationToken); + if (!membership.IsSuccessful) + { + return membership.Error; + } + + return Result.Ok; + } + + private async Task> InviteMemberToOrganizationAsync(ICallerContext context, + string organizationId, string invitedById, string? userId, string? emailAddress, + CancellationToken cancellationToken) + { + if (emailAddress.HasNoValue() && userId.HasNoValue()) + { + return Error.RuleViolation(Resources + .InvitationsApplication_InviteMemberToOrganization_NoUserIdNorEmailAddress); + } + + var retrievedInviter = await _repository.LoadAsync(invitedById.ToId(), cancellationToken); + if (!retrievedInviter.IsSuccessful) + { + return retrievedInviter.Error; + } + + var inviter = retrievedInviter.Value; + EndUserRoot invitee = null!; + if (emailAddress.HasValue()) + { + var retrievedByEmail = + await InviteGuestByEmailInternalAsync(context, invitedById, emailAddress, cancellationToken); + if (!retrievedByEmail.IsSuccessful) + { + return retrievedByEmail.Error; + } + + invitee = retrievedByEmail.Value.Invitee; + } + + if (userId.HasValue()) + { + var retrievedById = await InviteGuestByUserIdInternalAsync(context, invitedById, userId, cancellationToken); + if (!retrievedById.IsSuccessful) + { + return retrievedById.Error; + } + + invitee = retrievedById.Value.Invitee; + } + + var (_, _, tenantRoles, tenantFeatures) = + EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.InvitingMemberToOrg, + context.IsAuthenticated); + var enrolled = invitee.AddMembership(inviter, OrganizationOwnership.Shared, organizationId.ToId(), + tenantRoles, tenantFeatures); + if (!enrolled.IsSuccessful) + { + return enrolled.Error; + } + + var membership = invitee.FindMembership(organizationId.ToId()); + var saved = await _repository.SaveAsync(invitee, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + invitee = saved.Value; + _recorder.TraceInformation(context.ToCall(), + "EndUser {Id} has been invited to organization {Organization}", invitee.Id, organizationId); + + return membership.Value.ToMembership(); + } + + private async Task> UnInviteMemberFromOrganizationAsync(ICallerContext context, + string organizationId, string unInvitedById, string? userId, CancellationToken cancellationToken) + { + var retrievedUninviter = await _repository.LoadAsync(unInvitedById.ToId(), cancellationToken); + if (!retrievedUninviter.IsSuccessful) + { + return retrievedUninviter.Error; + } + + var uninviter = retrievedUninviter.Value; + var retrievedUninvitee = await _repository.LoadAsync(userId.ToId(), cancellationToken); + if (!retrievedUninvitee.IsSuccessful) + { + return retrievedUninvitee.Error; + } + + var uninvitee = retrievedUninvitee.Value; + var uninvited = uninvitee.RemoveMembership(uninviter, organizationId.ToId()); + if (!uninvited.IsSuccessful) + { + return uninvited.Error; + } + + var saved = await _repository.SaveAsync(uninvitee, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + uninvitee = saved.Value; + _recorder.TraceInformation(context.ToCall(), + "EndUser {Id} has been uninvited from organization {Organization}", uninvitee.Id, organizationId); + + return Result.Ok; + } } \ No newline at end of file diff --git a/src/EndUsersApplication/InvitationsApplication.cs b/src/EndUsersApplication/InvitationsApplication.cs index 2057c1fe..efc5b9f1 100644 --- a/src/EndUsersApplication/InvitationsApplication.cs +++ b/src/EndUsersApplication/InvitationsApplication.cs @@ -11,17 +11,15 @@ using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; using EndUsersDomain; -using Membership = Application.Resources.Shared.Membership; -using OrganizationOwnership = Domain.Shared.Organizations.OrganizationOwnership; namespace EndUsersApplication; public partial class InvitationsApplication : IInvitationsApplication { private readonly IIdentifierFactory _idFactory; - private readonly IInvitationRepository _repository; private readonly INotificationsService _notificationsService; private readonly IRecorder _recorder; + private readonly IInvitationRepository _repository; private readonly ITokensService _tokensService; private readonly IUserProfilesService _userProfilesService; @@ -40,19 +38,21 @@ public InvitationsApplication(IRecorder recorder, IIdentifierFactory idFactory, public async Task> InviteGuestAsync(ICallerContext context, string emailAddress, CancellationToken cancellationToken) { - var invited = await InviteGuestByEmailInternalAsync(context, context.CallerId, emailAddress, cancellationToken); - if (!invited.IsSuccessful) + var invitedByEmail = + await InviteGuestByEmailInternalAsync(context, context.CallerId, emailAddress, cancellationToken); + if (!invitedByEmail.IsSuccessful) { - return invited.Error; + return invitedByEmail.Error; } - var invitee = invited.Value.Invitee; + var invitee = invitedByEmail.Value.Invitee; var saved = await _repository.SaveAsync(invitee, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; } + invitee = saved.Value; _recorder.TraceInformation(context.ToCall(), "Guest {Id} was invited", invitee.Id); _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.GuestInvited, new Dictionary @@ -61,7 +61,7 @@ public async Task> InviteGuestAsync(ICallerContext con { nameof(UserProfile.EmailAddress), emailAddress } }); - return invited.Value.Invitee.ToInvitation(invited.Value.Profile); + return invitee.ToInvitation(invitedByEmail.Value.Profile); } public async Task> ResendGuestInvitationAsync(ICallerContext context, string token, @@ -138,68 +138,6 @@ public async Task> VerifyGuestInvitationAsync(ICallerC return invitee.ToInvitation(); } - private async Task> InviteMemberToOrganizationAsync(ICallerContext context, - string organizationId, string invitedById, string? userId, string? emailAddress, - CancellationToken cancellationToken) - { - if (emailAddress.HasNoValue() && userId.HasNoValue()) - { - return Error.RuleViolation(Resources - .InvitationsApplication_InviteMemberToOrganization_NoUserIdNorEmailAddress); - } - - var inviter = await _repository.LoadAsync(invitedById.ToId(), cancellationToken); - if (!inviter.IsSuccessful) - { - return inviter.Error; - } - - EndUserRoot invitee = null!; - if (emailAddress.HasValue()) - { - var invited = await InviteGuestByEmailInternalAsync(context, invitedById, emailAddress, cancellationToken); - if (!invited.IsSuccessful) - { - return invited.Error; - } - - invitee = invited.Value.Invitee; - } - - if (userId.HasValue()) - { - var invited = await InviteGuestByUserIdInternalAsync(context, invitedById, userId, cancellationToken); - if (!invited.IsSuccessful) - { - return invited.Error; - } - - invitee = invited.Value.Invitee; - } - - var (_, _, tenantRoles, tenantFeatures) = - EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.InvitingMemberToOrg, - context.IsAuthenticated); - var enrolled = invitee.AddMembership(inviter.Value, OrganizationOwnership.Shared, organizationId.ToId(), - tenantRoles, tenantFeatures); - if (!enrolled.IsSuccessful) - { - return enrolled.Error; - } - - var membership = invitee.FindMembership(organizationId.ToId()); - var saved = await _repository.SaveAsync(invitee, cancellationToken); - if (!saved.IsSuccessful) - { - return saved.Error; - } - - _recorder.TraceInformation(context.ToCall(), - "EndUser {Id} has been invited to organization {Organization}", saved.Value.Id, organizationId); - - return membership.Value.ToMembership(); - } - private async Task> InviteGuestByEmailInternalAsync( ICallerContext context, string invitedById, string emailAddress, CancellationToken cancellationToken) { @@ -265,9 +203,7 @@ await _repository.LoadAsync(retrievedEmailOwner.Value.Value.UserId.ToId(), invitee = created.Value; } - var invited = await invitee.InviteGuestAsync(_tokensService, inviter.Id, email.Value, - async (inviterId, newToken) => - await SendInvitationNotificationAsync(context, inviterId, newToken, invitee, cancellationToken)); + var invited = await InviteGuestInternalAsync(context, inviter, invitee, email.Value, cancellationToken); if (!invited.IsSuccessful) { return invited.Error; @@ -277,8 +213,7 @@ await _repository.LoadAsync(retrievedEmailOwner.Value.Value.UserId.ToId(), } private async Task> InviteGuestByUserIdInternalAsync( - ICallerContext context, string invitedById, - string userId, CancellationToken cancellationToken) + ICallerContext context, string invitedById, string userId, CancellationToken cancellationToken) { var retrievedInviter = await _repository.LoadAsync(invitedById.ToId(), cancellationToken); if (!retrievedInviter.IsSuccessful) @@ -306,9 +241,7 @@ await _repository.LoadAsync(retrievedEmailOwner.Value.Value.UserId.ToId(), return email.Error; } - var invited = await invitee.InviteGuestAsync(_tokensService, inviter.Id, email.Value, - async (inviterId, newToken) => - await SendInvitationNotificationAsync(context, inviterId, newToken, invitee, cancellationToken)); + var invited = await InviteGuestInternalAsync(context, inviter, invitee, email.Value, cancellationToken); if (!invited.IsSuccessful) { return invited.Error; @@ -317,6 +250,20 @@ await _repository.LoadAsync(retrievedEmailOwner.Value.Value.UserId.ToId(), return (invitee, null); } + private async Task> InviteGuestInternalAsync(ICallerContext caller, EndUserRoot inviter, + EndUserRoot invitee, EmailAddress emailAddress, CancellationToken cancellationToken) + { + var invited = await invitee.InviteGuestAsync(_tokensService, inviter.Id, emailAddress, + async (inviterId, newToken) => + await SendInvitationNotificationAsync(caller, inviterId, newToken, invitee, cancellationToken)); + if (!invited.IsSuccessful) + { + return invited.Error; + } + + return invitee; + } + private async Task> SendInvitationNotificationAsync(ICallerContext context, Identifier inviterId, string token, EndUserRoot invitee, CancellationToken cancellationToken) { diff --git a/src/EndUsersApplication/Persistence/ReadModels/Membership.cs b/src/EndUsersApplication/Persistence/ReadModels/Membership.cs index 5a795488..05faeeea 100644 --- a/src/EndUsersApplication/Persistence/ReadModels/Membership.cs +++ b/src/EndUsersApplication/Persistence/ReadModels/Membership.cs @@ -1,6 +1,7 @@ using Application.Persistence.Common; using Common; using Domain.Shared; +using Domain.Shared.Organizations; using QueryAny; namespace EndUsersApplication.Persistence.ReadModels; @@ -14,6 +15,8 @@ public class Membership : ReadModelEntity public Optional OrganizationId { get; set; } + public Optional Ownership { get; set; } + public Optional Roles { get; set; } public Optional UserId { get; set; } diff --git a/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs b/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs index 997ed02a..625b83d4 100644 --- a/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs +++ b/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs @@ -2,6 +2,7 @@ using Common; using Domain.Shared; using Domain.Shared.EndUsers; +using Domain.Shared.Organizations; using QueryAny; namespace EndUsersApplication.Persistence.ReadModels; @@ -17,6 +18,8 @@ public class MembershipJoinInvitation : ReadModelEntity public Optional OrganizationId { get; set; } + public Optional Ownership { get; set; } + public Optional Roles { get; set; } public Optional Status { get; set; } diff --git a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs index 31e65417..b3139aea 100644 --- a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs +++ b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs @@ -208,6 +208,22 @@ public void WhenAddMembershipAndAlreadyMember_ThenReturns() result.Should().BeSuccess(); } + [Fact] + public void WhenAddMembershipAndAlreadyPersonalOrg_ThenReturnsError() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(_user, OrganizationOwnership.Personal, "anorganizationid1".ToId(), Roles.Empty, + Features.Empty); + + var result = _user.AddMembership(_user, OrganizationOwnership.Personal, "anorganizationid2".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Empty); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_AddMembership_OnlyOnePersonalOrganization); + } + [Fact] public void WhenAddMembershipToPersonsSharedOrganization_ThenAddsMembership() { @@ -227,7 +243,7 @@ public void WhenAddMembershipToPersonsSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] @@ -242,7 +258,7 @@ public void WhenAddMembershipToPersonsPersonalOrganization_ThenReturnsError() Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + Resources.EndUserRoot_AddMembership_SharedOwnershipRequired); } [Fact] @@ -259,7 +275,7 @@ public void WhenAddMembershipToMachinesPersonalOrganization_ThenReturnsError() roles, features); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + Resources.EndUserRoot_AddMembership_SharedOwnershipRequired); } [Fact] @@ -281,7 +297,7 @@ public void WhenAddMembershipToMachinesSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] @@ -302,7 +318,7 @@ public void WhenAddMembershipToSelfPersonalOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] @@ -323,7 +339,7 @@ public void WhenAddMembershipToSelfSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] @@ -345,7 +361,7 @@ public void WhenAddMembership_ThenAddsMembershipAsDefaultWithRolesAndFeatures() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] @@ -373,7 +389,7 @@ public void WhenAddMembershipAndAlreadyHasMembership_ThenChangesToDefaultMembers && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } #if TESTINGONLY @@ -871,6 +887,112 @@ await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailA _user.GuestInvitation.IsAccepted.Should().BeTrue(); _user.GuestInvitation.AcceptedEmailAddress.Should().Be(emailAddress); } + + [Fact] + public void WhenChangeDefaultMembershipAndNotAMember_ThenReturnsError() + { + var result = _user.ChangeDefaultMembership("anorganizationid".ToId()); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_NoMembership.Format("anorganizationid")); + } + + [Fact] + public void WhenChangeDefaultMembershipAndAlreadyDefault_ThenReturns() + { + _user.AddMembership(_user, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); + + var result = _user.ChangeDefaultMembership("anorganizationid".ToId()); + + result.Should().BeSuccess(); + _user.Memberships.DefaultMembership.OrganizationId.Value.Should().Be("anorganizationid".ToId()); + } + + [Fact] + public void WhenChangeDefaultMembership_ThenChangesDefault() + { + _user.AddMembership(_user, OrganizationOwnership.Shared, "anorganizationid1".ToId(), + Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); + _user.AddMembership(_user, OrganizationOwnership.Shared, "anorganizationid2".ToId(), + Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); + + var result = _user.ChangeDefaultMembership("anorganizationid1".ToId()); + + result.Should().BeSuccess(); + _user.Memberships.DefaultMembership.OrganizationId.Value.Should().Be("anorganizationid1".ToId()); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenRemoveMembershipAndNotOwner_ThenReturnsError() + { + var result = _user.RemoveMembership(_user, "anorganizationid".ToId()); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.EndUserRoot_NotOrganizationOwner); + } + + [Fact] + public void WhenRemoveMembershipAndNotMember_ThenDoesNothing() + { + var uninviter = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) + .Value; + uninviter.AddMembership(uninviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Create(TenantFeatures.Basic).Value); + + var result = _user.RemoveMembership(uninviter, "anorganizationid".ToId()); + + result.Should().BeSuccess(); + _user.Memberships.HasNone().Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenRemoveMembershipAndIsNotDefaultMembership_ThenRemoves() + { + var uninviter = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) + .Value; + uninviter.AddMembership(uninviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Create(TenantFeatures.Basic).Value); + _user.AddMembership(uninviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); + + var result = _user.RemoveMembership(uninviter, "anorganizationid".ToId()); + + result.Should().BeSuccess(); + _user.Memberships.HasNone().Should().BeTrue(); + _user.Events.Count.Should().Be(4); + _user.Events[0].Should().BeOfType(); + _user.Events[1].Should().BeOfType(); + _user.Events[2].Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenRemoveMembershipAndIsDefaultMembership_ThenRemovesAndResetsDefault() + { + var uninviter = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) + .Value; + uninviter.AddMembership(uninviter, OrganizationOwnership.Shared, "anorganizationid2".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Create(TenantFeatures.Basic).Value); + _user.AddMembership(_user, OrganizationOwnership.Personal, "anorganizationid1".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Create(TenantFeatures.Basic).Value); + _user.AddMembership(uninviter, OrganizationOwnership.Shared, "anorganizationid2".ToId(), + Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); + + var result = _user.RemoveMembership(uninviter, "anorganizationid2".ToId()); + + result.Should().BeSuccess(); + _user.Memberships.Count.Should().Be(1); + _user.Events.Count.Should().Be(7); + _user.Events[0].Should().BeOfType(); + _user.Events[1].Should().BeOfType(); + _user.Events[2].Should().BeOfType(); + _user.Events[3].Should().BeOfType(); + _user.Events[4].Should().BeOfType(); + _user.Events[5].Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); + } } [Trait("Category", "Unit")] @@ -939,7 +1061,7 @@ public void WhenAddMembershipToPersonsSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] @@ -954,7 +1076,7 @@ public void WhenAddMembershipToPersonsPersonalOrganization_ThenReturnsError() Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + Resources.EndUserRoot_AddMembership_SharedOwnershipRequired); } [Fact] @@ -971,7 +1093,7 @@ public void WhenAddMembershipToMachinesPersonalOrganization_ThenReturnsError() roles, features); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + Resources.EndUserRoot_AddMembership_SharedOwnershipRequired); } [Fact] @@ -993,7 +1115,7 @@ public void WhenAddMembershipToMachinesSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] @@ -1014,7 +1136,7 @@ public void WhenAddMembershipToSelfPersonalOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] @@ -1035,7 +1157,7 @@ public void WhenAddMembershipToSelfSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } } } \ No newline at end of file diff --git a/src/EndUsersDomain/EndUserRoot.cs b/src/EndUsersDomain/EndUserRoot.cs index ec070712..335e4458 100644 --- a/src/EndUsersDomain/EndUserRoot.cs +++ b/src/EndUsersDomain/EndUserRoot.cs @@ -158,7 +158,22 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return Result.Ok; } - case MembershipDefaultChanged changed: + case MembershipRemoved removed: + { + var membership = Memberships.FindByMembershipId(removed.MembershipId.ToId()); + if (!membership.HasValue) + { + return Error.RuleViolation(Resources.EndUserRoot_MissingMembership); + } + + Memberships.Remove(membership.Value.Id); + Recorder.TraceDebug(null, + "EndUser {Id} removed membership {Membership} from organization {Organization}", Id, + membership.Value.Id, removed.OrganizationId); + return Result.Ok; + } + + case DefaultMembershipChanged changed: { if (changed.FromMembershipId.Exists()) { @@ -342,10 +357,16 @@ public Result AddMembership(EndUserRoot adder, OrganizationOwnership owne if (ownership == OrganizationOwnership.Personal) { - return Error.RuleViolation(Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + return Error.RuleViolation(Resources.EndUserRoot_AddMembership_SharedOwnershipRequired); } } + if (ownership == OrganizationOwnership.Personal + && Memberships.HasPersonalOrganization) + { + return Error.RuleViolation(Resources.EndUserRoot_AddMembership_OnlyOnePersonalOrganization); + } + var existing = Memberships.FindByOrganizationId(organizationId); if (existing.HasValue) { @@ -365,7 +386,7 @@ public Result AddMembership(EndUserRoot adder, OrganizationOwnership owne : Memberships.DefaultMembership.Id.ToOptional(); var addedMembership = Memberships.FindByOrganizationId(organizationId); - return RaiseChangeEvent(EndUsersDomain.Events.MembershipDefaultChanged(Id, defaultMembershipId, + return RaiseChangeEvent(EndUsersDomain.Events.DefaultMembershipChanged(Id, defaultMembershipId, addedMembership.Value.Id, addedMembership.Value.OrganizationId, tenantRoles, tenantFeatures)); } @@ -503,6 +524,25 @@ public Result AssignPlatformRoles(EndUserRoot assigner, Roles platformRol return Result.Ok; } + public Result ChangeDefaultMembership(Identifier organizationId) + { + var targetMembership = Memberships.FindByOrganizationId(organizationId); + if (!targetMembership.HasValue) + { + return Error.RuleViolation(Resources.EndUserRoot_NoMembership.Format(organizationId)); + } + + var defaultMembership = Memberships.DefaultMembership; + if (defaultMembership.Equals(targetMembership.Value)) + { + return Result.Ok; + } + + return RaiseChangeEvent(EndUsersDomain.Events.DefaultMembershipChanged(Id, defaultMembership.Id.ToOptional(), + targetMembership.Value.Id, targetMembership.Value.OrganizationId, targetMembership.Value.Roles, + targetMembership.Value.Features)); + } + public Optional FindMembership(Identifier organizationId) { return Memberships.FindByOrganizationId(organizationId); @@ -632,6 +672,46 @@ public async Task> ReInviteGuestAsync(ITokensService tokensService return await InviteGuestAsync(tokensService, inviterId, GuestInvitation.InviteeEmailAddress!, onInvited); } + public Result RemoveMembership(EndUserRoot remover, Identifier organizationId) + { + if (!IsOrganizationOwner(remover, organizationId)) + { + return Error.RoleViolation(Resources.EndUserRoot_NotOrganizationOwner); + } + + var membership = Memberships.FindByOrganizationId(organizationId); + if (!membership.HasValue) + { + return Result.Ok; + } + + if (membership.Value.Ownership == OrganizationOwnership.Personal) + { + return Error.RuleViolation(Resources.EndUserRoot_RemoveMembership_SharedOwnershipRequired); + } + + var isLastMembership = Memberships.Count == 1; + if (!isLastMembership) + { + if (membership.Value.Equals(Memberships.DefaultMembership)) + { + var defaultMembership = Memberships.DefaultMembership; + var newDefaultMembership = Memberships.FindNextDefaultMembership(); + var defaulted = RaiseChangeEvent(EndUsersDomain.Events.DefaultMembershipChanged(Id, + defaultMembership.Id, + newDefaultMembership.Id, newDefaultMembership.OrganizationId, newDefaultMembership.Roles, + newDefaultMembership.Features)); + if (!defaulted.IsSuccessful) + { + return defaulted.Error; + } + } + } + + return RaiseChangeEvent( + EndUsersDomain.Events.MembershipRemoved(Id, membership.Value.Id, organizationId, remover.Id)); + } + #if TESTINGONLY public void TestingOnly_ExpireGuestInvitation() { diff --git a/src/EndUsersDomain/Events.cs b/src/EndUsersDomain/Events.cs index 89c70f0c..7794900a 100644 --- a/src/EndUsersDomain/Events.cs +++ b/src/EndUsersDomain/Events.cs @@ -20,6 +20,20 @@ public static Created Created(Identifier id, UserClassification classification) }; } + public static DefaultMembershipChanged DefaultMembershipChanged(Identifier id, + Optional fromMembershipId, Identifier toMembershipId, Identifier toOrganizationId, Roles roles, + Features features) + { + return new DefaultMembershipChanged(id) + { + FromMembershipId = fromMembershipId.ValueOrDefault!, + ToMembershipId = toMembershipId, + ToOrganizationId = toOrganizationId, + Roles = roles.ToList(), + Features = features.ToList() + }; + } + public static GuestInvitationAccepted GuestInvitationAccepted(Identifier id, EmailAddress emailAddress) { return new GuestInvitationAccepted(id) @@ -55,20 +69,6 @@ public static MembershipAdded MembershipAdded(Identifier id, Identifier organiza }; } - public static MembershipDefaultChanged MembershipDefaultChanged(Identifier id, - Optional fromMembershipId, - Identifier toMembershipId, Identifier toOrganizationId, Roles roles, Features features) - { - return new MembershipDefaultChanged(id) - { - FromMembershipId = fromMembershipId.ValueOrDefault!, - ToMembershipId = toMembershipId, - ToOrganizationId = toOrganizationId, - Roles = roles.ToList(), - Features = features.ToList() - }; - } - public static MembershipFeatureAssigned MembershipFeatureAssigned(Identifier id, Identifier organizationId, Identifier membershipId, Feature feature) { @@ -80,6 +80,17 @@ public static MembershipFeatureAssigned MembershipFeatureAssigned(Identifier id, }; } + public static MembershipRemoved MembershipRemoved(Identifier id, Identifier membershipId, Identifier organizationId, + Identifier uninviterId) + { + return new MembershipRemoved(id) + { + MembershipId = membershipId, + OrganizationId = organizationId, + UnInvitedById = uninviterId + }; + } + public static MembershipRoleAssigned MembershipRoleAssigned(Identifier id, Identifier organizationId, Identifier membershipId, Role role) diff --git a/src/EndUsersDomain/Membership.cs b/src/EndUsersDomain/Membership.cs index 9f7136cd..23c04f7e 100644 --- a/src/EndUsersDomain/Membership.cs +++ b/src/EndUsersDomain/Membership.cs @@ -31,16 +31,16 @@ private Membership(IRecorder recorder, IIdentifierFactory idFactory, public bool IsDefault { get; private set; } + public bool IsShared => Ownership is { HasValue: true, Value: OrganizationOwnership.Shared }; + public Optional OrganizationId { get; private set; } = Optional.None; + public Optional Ownership { get; private set; } + public Roles Roles { get; private set; } = Roles.Empty; public Optional RootId { get; private set; } = Optional.None; - public Optional Ownership { get; private set; } - - public bool IsShared => Ownership is { HasValue: true, Value: OrganizationOwnership.Shared }; - protected override Result OnStateChanged(IDomainEvent @event) { switch (@event) @@ -68,7 +68,7 @@ protected override Result OnStateChanged(IDomainEvent @event) return Result.Ok; } - case MembershipDefaultChanged changed: + case DefaultMembershipChanged changed: { if (changed.FromMembershipId == Id) { diff --git a/src/EndUsersDomain/Memberships.cs b/src/EndUsersDomain/Memberships.cs index a4a90b62..1e29ad25 100644 --- a/src/EndUsersDomain/Memberships.cs +++ b/src/EndUsersDomain/Memberships.cs @@ -2,6 +2,7 @@ using Common; using Common.Extensions; using Domain.Common.ValueObjects; +using Domain.Shared.Organizations; namespace EndUsersDomain; @@ -11,6 +12,8 @@ public class Memberships : IReadOnlyList public Membership DefaultMembership => _memberships.First(ms => ms.IsDefault); + public bool HasPersonalOrganization => _memberships.Any(ms => ms.Ownership == OrganizationOwnership.Personal); + public Result EnsureInvariants() { foreach (var membership in _memberships) @@ -82,6 +85,22 @@ public Optional FindByOrganizationId(Identifier organizationId) .SingleOrDefault(ms => ms.OrganizationId == organizationId); } + public Membership FindNextDefaultMembership() + { + var next = _memberships + .Except(new[] { DefaultMembership }) + // ReSharper disable once SimplifyLinqExpressionUseMinByAndMaxBy + .OrderByDescending(ms => ms.CreatedAtUtc) + .FirstOrDefault(); + + if (next.NotExists()) + { + throw new InvalidOperationException(Resources.Memberships_MissingNextDefaultMembership); + } + + return next; + } + public void Remove(Identifier membershipId) { var membership = _memberships.Find(ms => ms.Id == membershipId); diff --git a/src/EndUsersDomain/Resources.Designer.cs b/src/EndUsersDomain/Resources.Designer.cs index dbf907c0..d451266b 100644 --- a/src/EndUsersDomain/Resources.Designer.cs +++ b/src/EndUsersDomain/Resources.Designer.cs @@ -60,7 +60,7 @@ internal Resources() { } /// - /// Looks up a localized string similar to Cannot add a membership to machines organization. + /// Looks up a localized string similar to Cannot add a membership to machine's organization. /// internal static string EndUserRoot_AddMembership_MachineCannotHaveMemberships { get { @@ -69,11 +69,20 @@ internal static string EndUserRoot_AddMembership_MachineCannotHaveMemberships { } /// - /// Looks up a localized string similar to The adder cannot add a membership to a personal organization. + /// Looks up a localized string similar to Cannot add another 'Personal' organization. /// - internal static string EndUserRoot_Addmembership_SharedOwnershipRequired { + internal static string EndUserRoot_AddMembership_OnlyOnePersonalOrganization { get { - return ResourceManager.GetString("EndUserRoot_Addmembership_SharedOwnershipRequired", resourceCulture); + return ResourceManager.GetString("EndUserRoot_AddMembership_OnlyOnePersonalOrganization", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The adder cannot add a membership to a 'Personal' organization. + /// + internal static string EndUserRoot_AddMembership_SharedOwnershipRequired { + get { + return ResourceManager.GetString("EndUserRoot_AddMembership_SharedOwnershipRequired", resourceCulture); } } @@ -212,6 +221,15 @@ internal static string EndUserRoot_NotRegistered { } } + /// + /// Looks up a localized string similar to The remover cannot remove a membership from a 'Personal' organization. + /// + internal static string EndUserRoot_RemoveMembership_SharedOwnershipRequired { + get { + return ResourceManager.GetString("EndUserRoot_RemoveMembership_SharedOwnershipRequired", resourceCulture); + } + } + /// /// Looks up a localized string similar to The feature '{0}' is not a supported platform feature. /// @@ -302,6 +320,15 @@ internal static string Memberships_DuplicateMemberships { } } + /// + /// Looks up a localized string similar to There are no more remaining memberships for this user. + /// + internal static string Memberships_MissingNextDefaultMembership { + get { + return ResourceManager.GetString("Memberships_MissingNextDefaultMembership", resourceCulture); + } + } + /// /// Looks up a localized string similar to Only one membership can be the default membership. /// diff --git a/src/EndUsersDomain/Resources.resx b/src/EndUsersDomain/Resources.resx index a772a3ac..bada24fc 100644 --- a/src/EndUsersDomain/Resources.resx +++ b/src/EndUsersDomain/Resources.resx @@ -75,8 +75,11 @@ The assigner is not an owner of the organization - - The adder cannot add a membership to a personal organization + + The adder cannot add a membership to a 'Personal' organization + + + The remover cannot remove a membership from a 'Personal' organization A membership must always have at least the role '{0}' @@ -109,6 +112,12 @@ This guest invitation cannot be accepted by an authenticated user - Cannot add a membership to machines organization + Cannot add a membership to machine's organization + + + Cannot add another 'Personal' organization + + + There are no more remaining memberships for this user \ No newline at end of file diff --git a/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs b/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs index 7c2124a9..c1fbaa17 100644 --- a/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs +++ b/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs @@ -1,3 +1,4 @@ +using System.Net; using ApiHost1; using Domain.Interfaces.Authorization; using EndUsersInfrastructure.IntegrationTests.Stubs; @@ -5,6 +6,7 @@ using Infrastructure.Eventing.Interfaces.Notifications; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Operations.Shared.EndUsers; +using Infrastructure.Web.Api.Operations.Shared.Organizations; using IntegrationTesting.WebApi.Common; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -71,6 +73,31 @@ await Api.PostAsync(new AssignPlatformRolesRequest #endif } + [Fact] + public async Task WhenChangeDefaultOrganization_ThenChangesDefault() + { + var login = await LoginUserAsync(); + + var organizationId1 = login.User.Profile!.DefaultOrganizationId!; + var organization2 = await Api.PostAsync(new CreateOrganizationRequest + { + Name = "aname" + }, req => req.SetJWTBearerToken(login.AccessToken)); + + var organizationId2 = organization2.Content.Value.Organization!.Id; + login = await ReAuthenticateUserAsync(login.User); + login.User.Profile!.DefaultOrganizationId.Should().Be(organizationId2); + + var result = await Api.PutAsync(new ChangeDefaultOrganizationRequest + { + OrganizationId = organizationId1 + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.Accepted); + login = await ReAuthenticateUserAsync(login.User); + login.User.Profile!.DefaultOrganizationId.Should().Be(organizationId1); + } + private static void OverrideDependencies(IServiceCollection services) { services.AddSingleton(); diff --git a/src/EndUsersInfrastructure.UnitTests/Api/EndUsers/ChangeDefaultOrganizationRequestValidatorSpec.cs b/src/EndUsersInfrastructure.UnitTests/Api/EndUsers/ChangeDefaultOrganizationRequestValidatorSpec.cs new file mode 100644 index 00000000..5dfeea9e --- /dev/null +++ b/src/EndUsersInfrastructure.UnitTests/Api/EndUsers/ChangeDefaultOrganizationRequestValidatorSpec.cs @@ -0,0 +1,29 @@ +using Domain.Common.Identity; +using EndUsersInfrastructure.Api.EndUsers; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; +using Xunit; + +namespace EndUsersInfrastructure.UnitTests.Api.EndUsers; + +[Trait("Category", "Unit")] +public class ChangeDefaultOrganizationRequestValidatorSpec +{ + private readonly ChangeDefaultOrganizationRequest _dto; + private readonly ChangeDefaultOrganizationRequestValidator _validator; + + public ChangeDefaultOrganizationRequestValidatorSpec() + { + _validator = new ChangeDefaultOrganizationRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new ChangeDefaultOrganizationRequest + { + OrganizationId = "anid" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/EndUsers/ChangeDefaultOrganizationRequestValidator.cs b/src/EndUsersInfrastructure/Api/EndUsers/ChangeDefaultOrganizationRequestValidator.cs new file mode 100644 index 00000000..014460a0 --- /dev/null +++ b/src/EndUsersInfrastructure/Api/EndUsers/ChangeDefaultOrganizationRequestValidator.cs @@ -0,0 +1,17 @@ +using Domain.Common.Identity; +using Domain.Interfaces.Validations; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; + +namespace EndUsersInfrastructure.Api.EndUsers; + +public class ChangeDefaultOrganizationRequestValidator : AbstractValidator +{ + public ChangeDefaultOrganizationRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.OrganizationId) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs index e85a0e42..73da3c81 100644 --- a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs +++ b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs @@ -18,18 +18,27 @@ public EndUsersApi(ICallerContextFactory contextFactory, IEndUsersApplication en _endUsersApplication = endUsersApplication; } - public async Task> AssignPlatformRoles( + public async Task> AssignPlatformRoles( AssignPlatformRolesRequest request, CancellationToken cancellationToken) { var user = await _endUsersApplication.AssignPlatformRolesAsync(_contextFactory.Create(), request.Id, request.Roles ?? new List(), cancellationToken); - return () => user.HandleApplicationResult(usr => - new PostResult(new AssignPlatformRolesResponse { User = usr })); + return () => user.HandleApplicationResult(usr => + new PostResult(new GetUserResponse { User = usr })); } - public async Task> UnassignPlatformRoles( + public async Task> ChangeDefaultOrganization( + ChangeDefaultOrganizationRequest request, CancellationToken cancellationToken) + { + var user = await _endUsersApplication.ChangeDefaultMembershipAsync(_contextFactory.Create(), + request.OrganizationId, cancellationToken); + + return () => user.HandleApplicationResult(x => new GetUserResponse { User = x }); + } + + public async Task> UnassignPlatformRoles( UnassignPlatformRolesRequest request, CancellationToken cancellationToken) { var user = @@ -37,7 +46,7 @@ await _endUsersApplication.UnassignPlatformRolesAsync(_contextFactory.Create(), request.Roles ?? new List(), cancellationToken); return () => - user.HandleApplicationResult(usr => new AssignPlatformRolesResponse + user.HandleApplicationResult(usr => new GetUserResponse { User = usr }); } } \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/Memberships/ListMembershipsForCallerRequestValidator.cs b/src/EndUsersInfrastructure/Api/Memberships/ListMembershipsForCallerRequestValidator.cs new file mode 100644 index 00000000..fa47d054 --- /dev/null +++ b/src/EndUsersInfrastructure/Api/Memberships/ListMembershipsForCallerRequestValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; + +namespace EndUsersInfrastructure.Api.Memberships; + +public class ListMembershipsForCallerRequestValidator : AbstractValidator +{ + public ListMembershipsForCallerRequestValidator(IHasSearchOptionsValidator hasSearchOptionsValidator) + { + Include(hasSearchOptionsValidator); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/Memberships/MembershipsApi.cs b/src/EndUsersInfrastructure/Api/Memberships/MembershipsApi.cs new file mode 100644 index 00000000..0dfdc31d --- /dev/null +++ b/src/EndUsersInfrastructure/Api/Memberships/MembershipsApi.cs @@ -0,0 +1,30 @@ +using Application.Resources.Shared; +using EndUsersApplication; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; + +namespace EndUsersInfrastructure.Api.Memberships; + +public class MembershipsApi : IWebApiService +{ + private readonly ICallerContextFactory _contextFactory; + private readonly IEndUsersApplication _endUsersApplication; + + public MembershipsApi(ICallerContextFactory contextFactory, IEndUsersApplication endUsersApplication) + { + _contextFactory = contextFactory; + _endUsersApplication = endUsersApplication; + } + + public async Task> ListMembershipsForCaller( + ListMembershipsForCallerRequest request, CancellationToken cancellationToken) + { + var memberships = await _endUsersApplication.ListMembershipsForCallerAsync(_contextFactory.Create(), + request.ToSearchOptions(), request.ToGetOptions(), cancellationToken); + + return () => memberships.HandleApplicationResult(ms => new ListMembershipsForCallerResponse + { Memberships = ms.Results, Metadata = ms.Metadata }); + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Notifications/EndUserNotificationConsumer.cs b/src/EndUsersInfrastructure/Notifications/EndUserNotificationConsumer.cs index b0c199e3..e65add0f 100644 --- a/src/EndUsersInfrastructure/Notifications/EndUserNotificationConsumer.cs +++ b/src/EndUsersInfrastructure/Notifications/EndUserNotificationConsumer.cs @@ -29,10 +29,13 @@ public async Task> NotifyAsync(IDomainEvent domainEvent, Cancellat return await _endUsersApplication.HandleOrganizationCreatedAsync(_callerContextFactory.Create(), created, cancellationToken); - case MembershipAdded added: - return await _invitationsApplication.HandleOrganizationMembershipAddedAsync( - _callerContextFactory.Create(), - added, cancellationToken); + case MemberInvited added: + return await _invitationsApplication.HandleOrganizationMemberInvitedAsync( + _callerContextFactory.Create(), added, cancellationToken); + + case MemberUnInvited removed: + return await _invitationsApplication.HandleOrganizationMemberUnInvitedAsync( + _callerContextFactory.Create(), removed, cancellationToken); default: return Result.Ok; diff --git a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs index 6f8b218c..2b6eed80 100644 --- a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs +++ b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs @@ -78,6 +78,7 @@ public async Task, Error>> SearchAllMember .Select(mje => mje.Roles) .Select(mje => mje.Features) .Select(mje => mje.OrganizationId) + .Select(mje => mje.Ownership) .Select(mje => mje.IsDefault) .Select(mje => mje.LastPersistedAtUtc) .SelectFromJoin(mje => mje.InvitedEmailAddress, inv => inv.InvitedEmailAddress) diff --git a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs index b0fa240a..1858c8c4 100644 --- a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs +++ b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs @@ -39,17 +39,17 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await Tasks.WhenAllAsync(_users.HandleCreateAsync(e.RootId.ToId(), dto => + return await Tasks.WhenAllAsync(_users.HandleCreateAsync(e.RootId, dto => { dto.Classification = e.Classification; dto.Access = e.Access; dto.Status = e.Status; }, cancellationToken), - _invitations.HandleCreateAsync(e.RootId.ToId(), dto => { dto.Status = e.Status; }, + _invitations.HandleCreateAsync(e.RootId, dto => { dto.Status = e.Status; }, cancellationToken)); case Registered e: - return await Tasks.WhenAllAsync(_users.HandleUpdateAsync(e.RootId.ToId(), dto => + return await Tasks.WhenAllAsync(_users.HandleUpdateAsync(e.RootId, dto => { dto.Classification = e.Classification; dto.Access = e.Access; @@ -58,20 +58,26 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven dto.Roles = Roles.Create(e.Roles.ToArray()).Value; dto.Features = Features.Create(e.Features.ToArray()).Value; }, cancellationToken), - _invitations.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.Status = e.Status; }, + _invitations.HandleUpdateAsync(e.RootId, dto => { dto.Status = e.Status; }, cancellationToken)); case MembershipAdded e: - return await _memberships.HandleCreateAsync(e.MembershipId.ToId(), dto => - { - dto.IsDefault = e.IsDefault; - dto.UserId = e.RootId; - dto.OrganizationId = e.OrganizationId; - dto.Roles = Roles.Create(e.Roles.ToArray()).Value; - dto.Features = Features.Create(e.Features.ToArray()).Value; - }, cancellationToken); + return e.MembershipId.HasValue() + ? await _memberships.HandleCreateAsync(e.MembershipId, dto => + { + dto.IsDefault = e.IsDefault; + dto.UserId = e.RootId; + dto.OrganizationId = e.OrganizationId; + dto.Ownership = e.Ownership; + dto.Roles = Roles.Create(e.Roles.ToArray()).Value; + dto.Features = Features.Create(e.Features.ToArray()).Value; + }, cancellationToken) + : true; + + case MembershipRemoved e: + return await _memberships.HandleDeleteAsync(e.MembershipId, cancellationToken); - case MembershipDefaultChanged e: + case DefaultMembershipChanged e: { if (e.FromMembershipId.Exists()) { @@ -94,7 +100,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven } case MembershipRoleAssigned e: - return await _memberships.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _memberships.HandleUpdateAsync(e.RootId, dto => { var roles = dto.Roles.HasValue ? dto.Roles.Value.Add(e.Role) @@ -108,7 +114,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case MembershipFeatureAssigned e: - return await _memberships.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _memberships.HandleUpdateAsync(e.RootId, dto => { var features = dto.Features.HasValue ? dto.Features.Value.Add(e.Feature) @@ -122,7 +128,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case PlatformRoleAssigned e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _users.HandleUpdateAsync(e.RootId, dto => { var roles = dto.Roles.HasValue ? dto.Roles.Value.Add(e.Role) @@ -136,7 +142,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case PlatformRoleUnassigned e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _users.HandleUpdateAsync(e.RootId, dto => { var roles = dto.Roles.HasValue ? dto.Roles.Value.Remove(e.Role) @@ -150,7 +156,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case PlatformFeatureAssigned e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _users.HandleUpdateAsync(e.RootId, dto => { var features = dto.Features.HasValue ? dto.Features.Value.Add(e.Feature) @@ -164,7 +170,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case GuestInvitationCreated e: - return await _invitations.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _invitations.HandleUpdateAsync(e.RootId, dto => { dto.InvitedEmailAddress = e.EmailAddress; dto.Token = e.Token; @@ -172,7 +178,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case GuestInvitationAccepted e: - return await _invitations.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _invitations.HandleUpdateAsync(e.RootId, dto => { dto.Token = Optional.None; dto.Status = UserStatus.Registered; diff --git a/src/IdentityApplication/PasswordCredentialsApplication.cs b/src/IdentityApplication/PasswordCredentialsApplication.cs index 413230e6..2f49c270 100644 --- a/src/IdentityApplication/PasswordCredentialsApplication.cs +++ b/src/IdentityApplication/PasswordCredentialsApplication.cs @@ -321,7 +321,6 @@ public async Task> VerifyPasswordResetAsync(ICallerContext caller, return Result.Ok; } -#if TESTINGONLY public async Task> CompletePasswordResetAsync(ICallerContext caller, string token, string password, CancellationToken cancellationToken) { @@ -360,6 +359,7 @@ public async Task> CompletePasswordResetAsync(ICallerContext calle return Result.Ok; } +#if TESTINGONLY public async Task> GetPersonRegistrationConfirmationAsync( ICallerContext context, string userId, CancellationToken cancellationToken) diff --git a/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs index 7470fb8b..d5faa1b1 100644 --- a/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs @@ -43,8 +43,8 @@ public async Task WhenIssueTokensAsync_ThenReturnsTokens() Access = EndUserAccess.Enabled, Status = EndUserStatus.Unregistered, Id = "anid", - Roles = new List { PlatformRoles.Standard.Name }, - Features = new List { PlatformFeatures.Basic.Name }, + Roles = [PlatformRoles.Standard.Name], + Features = [PlatformFeatures.Basic.Name], Memberships = [ new Membership @@ -52,8 +52,9 @@ public async Task WhenIssueTokensAsync_ThenReturnsTokens() Id = "amembershipid", UserId = "auserid", OrganizationId = "anorganizationid", - Roles = new List { TenantRoles.Member.Name }, - Features = new List { TenantFeatures.Basic.Name } + Ownership = OrganizationOwnership.Shared, + Roles = [TenantRoles.Member.Name], + Features = [TenantFeatures.Basic.Name] } ] }; diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/APIKeyProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/APIKeyProjection.cs index c8b38a46..1f160e8f 100644 --- a/src/IdentityInfrastructure/Persistence/ReadModels/APIKeyProjection.cs +++ b/src/IdentityInfrastructure/Persistence/ReadModels/APIKeyProjection.cs @@ -30,7 +30,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _authTokens.HandleCreateAsync(e.RootId.ToId(), dto => + return await _authTokens.HandleCreateAsync(e.RootId, dto => { dto.UserId = e.UserId; dto.KeyToken = e.KeyToken; @@ -38,7 +38,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven cancellationToken); case ParametersChanged e: - return await _authTokens.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _authTokens.HandleUpdateAsync(e.RootId, dto => { dto.Description = e.Description; dto.ExpiresOn = e.ExpiresOn.ToOptional(); diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/AuthTokensProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/AuthTokensProjection.cs index f3ba9b59..ad09c926 100644 --- a/src/IdentityInfrastructure/Persistence/ReadModels/AuthTokensProjection.cs +++ b/src/IdentityInfrastructure/Persistence/ReadModels/AuthTokensProjection.cs @@ -1,7 +1,6 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Domain.Common.ValueObjects; using Domain.Events.Shared.Identities.AuthTokens; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -29,11 +28,11 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _authTokens.HandleCreateAsync(e.RootId.ToId(), dto => { dto.UserId = e.UserId; }, + return await _authTokens.HandleCreateAsync(e.RootId, dto => { dto.UserId = e.UserId; }, cancellationToken); case TokensChanged e: - return await _authTokens.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _authTokens.HandleUpdateAsync(e.RootId, dto => { dto.AccessToken = e.AccessToken; dto.RefreshToken = e.RefreshToken; @@ -42,7 +41,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case TokensRefreshed e: - return await _authTokens.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _authTokens.HandleUpdateAsync(e.RootId, dto => { dto.AccessToken = e.AccessToken; dto.RefreshToken = e.RefreshToken; @@ -51,7 +50,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case TokensRevoked e: - return await _authTokens.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _authTokens.HandleUpdateAsync(e.RootId, dto => { dto.AccessToken = Optional.None; dto.RefreshToken = Optional.None; diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs index 4f5bad49..c76a234f 100644 --- a/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs +++ b/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs @@ -1,7 +1,6 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Domain.Common.ValueObjects; using Domain.Events.Shared.Identities.PasswordCredentials; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -29,7 +28,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _credentials.HandleCreateAsync(e.RootId.ToId(), dto => + return await _credentials.HandleCreateAsync(e.RootId, dto => { dto.UserId = e.UserId; dto.RegistrationVerified = false; @@ -38,11 +37,11 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven cancellationToken); case CredentialsChanged e: - return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + return await _credentials.HandleUpdateAsync(e.RootId, dto => { dto.PasswordResetToken = Optional.None; }, cancellationToken); case RegistrationChanged e: - return await _credentials.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _credentials.HandleUpdateAsync(e.RootId, dto => { dto.UserName = e.Name; dto.UserEmailAddress = e.EmailAddress; @@ -52,32 +51,32 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven return true; case AccountLocked e: - return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + return await _credentials.HandleUpdateAsync(e.RootId, dto => { dto.AccountLocked = true; }, cancellationToken); case AccountUnlocked e: - return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + return await _credentials.HandleUpdateAsync(e.RootId, dto => { dto.AccountLocked = false; }, cancellationToken); case RegistrationVerificationCreated e: - return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + return await _credentials.HandleUpdateAsync(e.RootId, dto => { dto.RegistrationVerificationToken = e.Token; }, cancellationToken); case RegistrationVerificationVerified e: - return await _credentials.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _credentials.HandleUpdateAsync(e.RootId, dto => { dto.RegistrationVerificationToken = Optional.None; dto.RegistrationVerified = true; }, cancellationToken); case PasswordResetInitiated e: - return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + return await _credentials.HandleUpdateAsync(e.RootId, dto => { dto.PasswordResetToken = e.Token; }, cancellationToken); case PasswordResetCompleted e: - return await _credentials.HandleUpdateAsync(e.RootId.ToId(), + return await _credentials.HandleUpdateAsync(e.RootId, dto => { dto.PasswordResetToken = Optional.None; }, cancellationToken); default: diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs index 24070cdb..f666a48e 100644 --- a/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs +++ b/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs @@ -1,7 +1,6 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Domain.Common.ValueObjects; using Domain.Events.Shared.Identities.SSOUsers; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -29,7 +28,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _users.HandleCreateAsync(e.RootId.ToId(), dto => + return await _users.HandleCreateAsync(e.RootId, dto => { dto.UserId = e.UserId; dto.ProviderName = e.ProviderName; @@ -37,7 +36,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven cancellationToken); case TokensUpdated e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.Tokens = e.Tokens; dto.EmailAddress = e.EmailAddress; diff --git a/src/ImagesInfrastructure/Persistence/ReadModels/ImageProjection.cs b/src/ImagesInfrastructure/Persistence/ReadModels/ImageProjection.cs index a619f0d5..c6bd29c2 100644 --- a/src/ImagesInfrastructure/Persistence/ReadModels/ImageProjection.cs +++ b/src/ImagesInfrastructure/Persistence/ReadModels/ImageProjection.cs @@ -1,7 +1,6 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Domain.Common.ValueObjects; using Domain.Events.Shared.Images; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -29,11 +28,11 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _images.HandleCreateAsync(e.RootId.ToId(), dto => { dto.ContentType = e.ContentType; }, + return await _images.HandleCreateAsync(e.RootId, dto => { dto.ContentType = e.ContentType; }, cancellationToken); case DetailsChanged e: - return await _images.HandleUpdateAsync(e.RootId.ToId(), + return await _images.HandleUpdateAsync(e.RootId, dto => { dto.Description = e.Description; @@ -41,7 +40,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case AttributesChanged e: - return await _images.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.Size = e.Size; }, + return await _images.HandleUpdateAsync(e.RootId, dto => { dto.Size = e.Size; }, cancellationToken); default: diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/AssignPlatformRolesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/AssignPlatformRolesRequest.cs index 2271722a..6b891db5 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/AssignPlatformRolesRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/AssignPlatformRolesRequest.cs @@ -2,9 +2,9 @@ namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; -[Route("/users/{id}/roles", OperationMethod.Post, AccessType.Token)] +[Route("/users/{Id}/roles", OperationMethod.Post, AccessType.Token)] [Authorize(Interfaces.Roles.Platform_Operations)] -public class AssignPlatformRolesRequest : UnTenantedRequest +public class AssignPlatformRolesRequest : UnTenantedRequest { public required string Id { get; set; } diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ChangeDefaultOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ChangeDefaultOrganizationRequest.cs new file mode 100644 index 00000000..f822a756 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ChangeDefaultOrganizationRequest.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; + +[Route("/users/memberships/default", OperationMethod.PutPatch, AccessType.Token)] +[Authorize(Roles.Platform_Standard, Features.Platform_PaidTrial)] +public class ChangeDefaultOrganizationRequest : UnTenantedRequest +{ + public required string OrganizationId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/AssignPlatformRolesResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/GetUserResponse.cs similarity index 76% rename from src/Infrastructure.Web.Api.Operations.Shared/EndUsers/AssignPlatformRolesResponse.cs rename to src/Infrastructure.Web.Api.Operations.Shared/EndUsers/GetUserResponse.cs index acce72cc..cfec26b6 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/AssignPlatformRolesResponse.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/GetUserResponse.cs @@ -3,7 +3,7 @@ namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; -public class AssignPlatformRolesResponse : IWebResponse +public class GetUserResponse : IWebResponse { public EndUser? User { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ListMembershipsForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ListMembershipsForCallerRequest.cs new file mode 100644 index 00000000..ddbe5974 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ListMembershipsForCallerRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; + +[Route("/memberships/me", OperationMethod.Search, AccessType.Token)] +[Authorize(Roles.Platform_Standard, Features.Platform_Basic)] +public class ListMembershipsForCallerRequest : UnTenantedSearchRequest +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ListMembershipsForCallerResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ListMembershipsForCallerResponse.cs new file mode 100644 index 00000000..b1c2dc81 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/ListMembershipsForCallerResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; + +public class ListMembershipsForCallerResponse : SearchResponse +{ + public List? Memberships { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/UnassignPlatformRolesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/UnassignPlatformRolesRequest.cs index d5b17637..fc2a68b8 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/UnassignPlatformRolesRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/EndUsers/UnassignPlatformRolesRequest.cs @@ -4,7 +4,7 @@ namespace Infrastructure.Web.Api.Operations.Shared.EndUsers; [Route("/users/{id}/roles", OperationMethod.PutPatch, AccessType.Token)] [Authorize(Interfaces.Roles.Platform_Operations)] -public class UnassignPlatformRolesRequest : UnTenantedRequest +public class UnassignPlatformRolesRequest : UnTenantedRequest { public required string Id { get; set; } diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/ChangeOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/ChangeOrganizationRequest.cs new file mode 100644 index 00000000..32991cc0 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/ChangeOrganizationRequest.cs @@ -0,0 +1,13 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations/{Id}", OperationMethod.PutPatch, AccessType.Token)] +[Authorize(Roles.Tenant_Owner, Features.Tenant_Basic)] +public class ChangeOrganizationRequest : UnTenantedRequest, + IUnTenantedOrganizationRequest +{ + public string? Id { get; set; } + + public string? Name { 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 index c367a646..3eae9ff3 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationRequest.cs @@ -3,7 +3,7 @@ namespace Infrastructure.Web.Api.Operations.Shared.Organizations; [Route("/organizations/{Id}/members", OperationMethod.Post, AccessType.Token)] -[Authorize(Roles.Tenant_Owner, Features.Tenant_Basic)] +[Authorize(Roles.Tenant_Owner, Features.Platform_PaidTrial)] public class InviteMemberToOrganizationRequest : UnTenantedRequest, IUnTenantedOrganizationRequest { diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/UnInviteMemberFromOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/UnInviteMemberFromOrganizationRequest.cs new file mode 100644 index 00000000..e84f768b --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/UnInviteMemberFromOrganizationRequest.cs @@ -0,0 +1,13 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations/{Id}/members/{UserId}", OperationMethod.Delete, AccessType.Token)] +[Authorize(Roles.Tenant_Owner, Features.Platform_PaidTrial)] +public class UnInviteMemberFromOrganizationRequest : UnTenantedRequest, + IUnTenantedOrganizationRequest +{ + public required 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/UnInviteMemberFromOrganizationResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/UnInviteMemberFromOrganizationResponse.cs new file mode 100644 index 00000000..fa39c809 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/UnInviteMemberFromOrganizationResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +public class UnInviteMemberFromOrganizationResponse : IWebResponse +{ + public Organization? Organization { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetProfileForCallerRequest.cs similarity index 63% rename from src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileRequest.cs rename to src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetProfileForCallerRequest.cs index 28aec38d..b93b9699 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetProfileForCallerRequest.cs @@ -3,6 +3,6 @@ namespace Infrastructure.Web.Api.Operations.Shared.UserProfiles; [Route("/profiles/me", OperationMethod.Get)] -public class GetCurrentProfileRequest : UnTenantedRequest +public class GetProfileForCallerRequest : UnTenantedRequest { } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetProfileForCallerResponse.cs similarity index 56% rename from src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileResponse.cs rename to src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetProfileForCallerResponse.cs index a461ebcf..c788ea8b 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileResponse.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetProfileForCallerResponse.cs @@ -3,7 +3,7 @@ namespace Infrastructure.Web.Api.Operations.Shared.UserProfiles; -public class GetCurrentProfileResponse : IWebResponse +public class GetProfileForCallerResponse : IWebResponse { - public UserProfileForCurrent? Profile { get; set; } + public UserProfileForCaller? Profile { 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 b79944fb..bb04315b 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs @@ -437,7 +437,8 @@ public async Task WhenInvokeAndUnRequiredTenantIdAndIsAMember_ThenSetsTenantAndC { Id = "amembershipid", UserId = "auserid", - OrganizationId = "atenantid" + OrganizationId = "atenantid", + Ownership = OrganizationOwnership.Shared } ] }); @@ -497,7 +498,8 @@ public async Task WhenInvokeAndNoRequiredTenantIdButNoDefaultOrganization_ThenRe { Id = "amembershipid", UserId = "auserid", - OrganizationId = "atenantid" + OrganizationId = "atenantid", + Ownership = OrganizationOwnership.Shared } ] }); @@ -536,7 +538,8 @@ public async Task WhenInvokeAndNoRequiredTenantIdButHasDefaultOrganization_ThenS Id = "amembershipid", UserId = "auserid", IsDefault = true, - OrganizationId = "adefaultorganizationid" + OrganizationId = "adefaultorganizationid", + Ownership = OrganizationOwnership.Shared } ] }); @@ -597,7 +600,8 @@ public async Task WhenInvokeAndRequiredTenantIdAndIsAMember_ThenSetsTenantAndCon { Id = "amembershipid", UserId = "auserid", - OrganizationId = "atenantid" + OrganizationId = "atenantid", + Ownership = OrganizationOwnership.Shared } ] }); diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index 178b5e76..69d8774f 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -8,8 +8,10 @@ using Common.Extensions; using Common.FeatureFlags; using FluentAssertions; +using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Operations.Shared.Identities; using Infrastructure.Web.Api.Operations.Shared.TestingOnly; +using Infrastructure.Web.Api.Operations.Shared.UserProfiles; using Infrastructure.Web.Common.Clients; using Infrastructure.Web.Interfaces.Clients; using IntegrationTesting.WebApi.Common.Stubs; @@ -212,6 +214,9 @@ protected async Task ReAuthenticateUserAsync(RegisteredEndUser use var accessToken = login.Content.Value.Tokens!.AccessToken.Value; var refreshToken = login.Content.Value.Tokens.RefreshToken.Value; + var profile = await Api.GetAsync(new GetProfileForCallerRequest(), req => req.SetJWTBearerToken(accessToken)); + user.Profile = profile.Content.Value.Profile; + return new LoginDetails(accessToken, refreshToken, user); } diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs index 9aeb23c0..ef99560a 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs @@ -25,15 +25,18 @@ public class OrganizationsApplicationDomainEventHandlersSpec { private readonly OrganizationsApplication _application; private readonly Mock _caller; + private readonly Mock _identifierFactory; + private readonly Mock _recorder; private readonly Mock _repository; + private readonly Mock _tenantSettingService; private readonly Mock _tenantSettingsService; public OrganizationsApplicationDomainEventHandlersSpec() { - var recorder = new Mock(); + _recorder = new Mock(); _caller = new Mock(); - var idFactory = new Mock(); - idFactory.Setup(f => f.Create(It.IsAny())) + _identifierFactory = new Mock(); + _identifierFactory.Setup(f => f.Create(It.IsAny())) .Returns("anid".ToId()); _tenantSettingsService = new Mock(); _tenantSettingsService.Setup(tss => @@ -42,21 +45,22 @@ public OrganizationsApplicationDomainEventHandlersSpec() { { "aname", "avalue" } })); - var tenantSettingService = new Mock(); - tenantSettingService.Setup(tss => tss.Encrypt(It.IsAny())) + _tenantSettingService = new Mock(); + _tenantSettingService.Setup(tss => tss.Encrypt(It.IsAny())) .Returns((string value) => value); - tenantSettingService.Setup(tss => tss.Decrypt(It.IsAny())) + _tenantSettingService.Setup(tss => tss.Decrypt(It.IsAny())) .Returns((string value) => value); var endUsersService = new Mock(); var imagesService = new Mock(); + var userProfilesService = new Mock(); _repository = new Mock(); _repository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny())) .Returns((OrganizationRoot root, CancellationToken _) => Task.FromResult>(root)); - _application = new OrganizationsApplication(recorder.Object, idFactory.Object, - _tenantSettingsService.Object, tenantSettingService.Object, endUsersService.Object, imagesService.Object, - _repository.Object); + _application = new OrganizationsApplication(_recorder.Object, _identifierFactory.Object, + _tenantSettingsService.Object, _tenantSettingService.Object, endUsersService.Object, + userProfilesService.Object, imagesService.Object, _repository.Object); } [Fact] @@ -104,4 +108,46 @@ public async Task WhenHandleEndUserRegisteredForMachineAsync_ThenReturnsOrganiza _tenantSettingsService.Verify(tss => tss.CreateForTenantAsync(_caller.Object, "anid", It.IsAny())); } + + [Fact] + public async Task WhenHandleEndUserMembershipAddedAsync_ThenAddsMembership() + { + var domainEvent = Events.MembershipAdded("auserid".ToId(), "anorganizationid".ToId(), + OrganizationOwnership.Shared, false, Roles.Empty, Features.Empty); + var org = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, _tenantSettingService.Object, + OrganizationOwnership.Shared, "anownerid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = + await _application.HandleEndUserMembershipAddedAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(root => + root.Memberships.Members.Count == 1 + && root.Memberships.Members[0].UserId == "auserid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenHandleEndUserMembershipRemovedAsync_ThenRemovesMembership() + { + var domainEvent = Events.MembershipRemoved("auserid".ToId(), "amembershipid".ToId(), "anorganizationid".ToId(), + "auserid".ToId()); + var org = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, _tenantSettingService.Object, + OrganizationOwnership.Shared, "anownerid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; + org.AddMembership("auserid".ToId()); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = + await _application.HandleEndUserMembershipRemovedAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(root => + root.Memberships.Members.Count == 0 + ), It.IsAny())); + } } \ No newline at end of file diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs index 89a4839f..723bfeaa 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs @@ -33,6 +33,7 @@ public class OrganizationsApplicationSpec private readonly Mock _repository; private readonly Mock _tenantSettingService; private readonly Mock _tenantSettingsService; + private readonly Mock _userProfilesService; public OrganizationsApplicationSpec() { @@ -54,14 +55,15 @@ public OrganizationsApplicationSpec() _tenantSettingService.Setup(tss => tss.Decrypt(It.IsAny())) .Returns((string value) => value); _endUsersService = new Mock(); + _userProfilesService = new Mock(); _imagesService = new Mock(); _repository = new Mock(); _repository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny())) .Returns((OrganizationRoot root, CancellationToken _) => Task.FromResult>(root)); - _application = new OrganizationsApplication(_recorder.Object, _idFactory.Object, - _tenantSettingsService.Object, _tenantSettingService.Object, _endUsersService.Object, _imagesService.Object, + _application = new OrganizationsApplication(_recorder.Object, _idFactory.Object, _tenantSettingsService.Object, + _tenantSettingService.Object, _endUsersService.Object, _userProfilesService.Object, _imagesService.Object, _repository.Object); } @@ -144,7 +146,7 @@ public async Task WhenGetSettingsAsyncAndNotExists_ThenReturnsError() } [Fact] - public async Task WhenGetSettingsasync_ThenReturnsSettings() + public async Task WhenGetSettingsAsync_ThenReturnsSettings() { var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, @@ -246,7 +248,82 @@ public async Task WhenInviteMemberToOrganizationAsyncAndNoUserIdNorEmail_ThenRet } [Fact] - public async Task WhenInviteMemberToOrganizationAsync_ThenInvites() + public async Task WhenInviteMemberToOrganizationAsyncWithUnregisteredUserEmail_ThenInvites() + { + _caller.Setup(cc => cc.Roles) + .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + OrganizationOwnership.Shared, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + _userProfilesService.Setup(ups => + ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = await _application.InviteMemberToOrganizationAsync(_caller.Object, "anorganizationid", + null, "auser@company.com", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be("aname"); + result.Value.CreatedById.Should().Be("auserid"); + result.Value.Ownership.Should().Be(Application.Resources.Shared.OrganizationOwnership.Shared); + _repository.Verify(rep => rep.SaveAsync(It.Is(o => + o.Id == "anid" + && o.Memberships.Count == 0 + ), It.IsAny())); + _userProfilesService.Verify(ups => + ups.FindPersonByEmailAddressPrivateAsync(_caller.Object, "auser@company.com", + It.IsAny())); + } + + [Fact] + public async Task WhenInviteMemberToOrganizationAsyncWithRegisteredUserEmail_ThenInvites() + { + _caller.Setup(cc => cc.Roles) + .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + OrganizationOwnership.Shared, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + _userProfilesService.Setup(ups => + ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UserProfile + { + Id = "aprofileid", + UserId = "auserid", + EmailAddress = "anemailaddress", + DisplayName = "adisplayname", + Name = new PersonName + { + FirstName = "afirstname", + LastName = "alastname" + } + }.ToOptional()); + + var result = await _application.InviteMemberToOrganizationAsync(_caller.Object, "anorganizationid", + null, "auser@company.com", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be("aname"); + result.Value.CreatedById.Should().Be("auserid"); + result.Value.Ownership.Should().Be(Application.Resources.Shared.OrganizationOwnership.Shared); + _repository.Verify(rep => rep.SaveAsync(It.Is(o => + o.Id == "anid" + && o.Memberships.Count == 0 + ), It.IsAny())); + _userProfilesService.Verify(ups => + ups.FindPersonByEmailAddressPrivateAsync(_caller.Object, "auser@company.com", + It.IsAny())); + } + + [Fact] + public async Task WhenInviteMemberToOrganizationAsyncWithUserId_ThenInvites() { _caller.Setup(cc => cc.Roles) .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); @@ -266,7 +343,11 @@ public async Task WhenInviteMemberToOrganizationAsync_ThenInvites() result.Value.Ownership.Should().Be(Application.Resources.Shared.OrganizationOwnership.Shared); _repository.Verify(rep => rep.SaveAsync(It.Is(o => o.Id == "anid" + && o.Memberships.Count == 0 ), It.IsAny())); + _userProfilesService.Verify(ups => + ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [Fact] @@ -317,6 +398,7 @@ public async Task WhenListMembersForOrganizationAsyncWithUnregisteredUser_ThenRe DisplayName = "anemailaddress" }, OrganizationId = "anorganizationid", + Ownership = Application.Resources.Shared.OrganizationOwnership.Shared, IsDefault = false } ] @@ -362,6 +444,7 @@ public async Task WhenListMembersForOrganizationAsyncWithRegisteredUsers_ThenRet Roles = ["arole1", "arole2", "arole3"], Features = ["afeature1", "afeature2", "afeature3"], OrganizationId = "anorganizationid", + Ownership = Application.Resources.Shared.OrganizationOwnership.Shared, Profile = new UserProfile { Id = "aprofileid", @@ -564,4 +647,99 @@ await org.ChangeAvatarAsync("auserid".ToId(), Roles.Create(TenantRoles.Owner).Va _imagesService.Verify( isv => isv.DeleteImageAsync(_caller.Object, "anoldimageid", It.IsAny())); } + + [Fact] + public async Task WhenChangeDetailsAsyncAndNotExists_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = + await _application.ChangeDetailsAsync(_caller.Object, "auserid", "aname", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenChangeDetailsAsyncAndNotOwner_ThenReturnsError() + { + _caller.Setup(cc => cc.Roles) + .Returns(new ICallerContext.CallerRoles()); + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = + await _application.ChangeDetailsAsync(_caller.Object, "auserid", "aname", CancellationToken.None); + + result.Should().BeError(ErrorCode.RoleViolation); + } + + [Fact] + public async Task WhenChangeDetailsAsync_ThenReturnsOrganization() + { + _caller.Setup(cc => cc.Roles) + .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; + await org.ChangeAvatarAsync("auserid".ToId(), Roles.Create(TenantRoles.Owner).Value, + _ => Task.FromResult>(Avatar.Create("anoldimageid".ToId(), "aurl").Value), + _ => Task.FromResult(Result.Ok)); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + _imagesService.Setup(isv => + isv.DeleteImageAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Result.Ok); + + var result = + await _application.ChangeDetailsAsync(_caller.Object, "auserid", "anewname", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be("anewname"); + _repository.Verify(rep => rep.SaveAsync(It.Is(profile => + profile.Name == "anewname" + ), It.IsAny())); + } + + [Fact] + public async Task WhenUnInviteMemberFromOrganizationAsyncAndNotExists_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = + await _application.UnInviteMemberFromOrganizationAsync(_caller.Object, "anid", "auserid", + CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenUnInviteMemberFromOrganizationAsync_ThenRemovesMembership() + { + _caller.Setup(cc => cc.Roles) + .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + OrganizationOwnership.Shared, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; + org.InviteMember("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), + Optional.None); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = + await _application.UnInviteMemberFromOrganizationAsync(_caller.Object, "anid", "auserid", + CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(o => + o.Memberships.Count == 0 + ), It.IsAny())); + } } \ No newline at end of file diff --git a/src/OrganizationsApplication/IOrganizationsApplication.DomainEventHandlers.cs b/src/OrganizationsApplication/IOrganizationsApplication.DomainEventHandlers.cs index 3e7f79ba..9cc08376 100644 --- a/src/OrganizationsApplication/IOrganizationsApplication.DomainEventHandlers.cs +++ b/src/OrganizationsApplication/IOrganizationsApplication.DomainEventHandlers.cs @@ -6,6 +6,12 @@ namespace OrganizationsApplication; partial interface IOrganizationsApplication { + Task> HandleEndUserMembershipAddedAsync(ICallerContext caller, MembershipAdded domainEvent, + CancellationToken cancellationToken); + + Task> HandleEndUserMembershipRemovedAsync(ICallerContext caller, MembershipRemoved domainEvent, + CancellationToken cancellationToken); + Task> HandleEndUserRegisteredAsync(ICallerContext caller, Registered domainEvent, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/OrganizationsApplication/IOrganizationsApplication.cs b/src/OrganizationsApplication/IOrganizationsApplication.cs index 92b943c3..d9f1541d 100644 --- a/src/OrganizationsApplication/IOrganizationsApplication.cs +++ b/src/OrganizationsApplication/IOrganizationsApplication.cs @@ -10,6 +10,9 @@ public partial interface IOrganizationsApplication Task> ChangeAvatarAsync(ICallerContext caller, string id, FileUpload upload, CancellationToken cancellationToken); + Task> ChangeDetailsAsync(ICallerContext caller, string id, string? name, + CancellationToken cancellationToken); + Task> ChangeSettingsAsync(ICallerContext caller, string id, TenantSettings settings, CancellationToken cancellationToken); @@ -35,4 +38,7 @@ Task> InviteMemberToOrganizationAsync(ICallerContext Task, Error>> ListMembersForOrganizationAsync(ICallerContext caller, string? id, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken); + + Task> UnInviteMemberFromOrganizationAsync(ICallerContext caller, string id, + string userId, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs b/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs index bdac6995..c83bdf17 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs +++ b/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs @@ -1,3 +1,4 @@ +using Application.Common.Extensions; using Application.Interfaces; using Common; using Common.Extensions; @@ -24,4 +25,87 @@ public async Task> HandleEndUserRegisteredAsync(ICallerContext cal return Result.Ok; } + + public async Task> HandleEndUserMembershipAddedAsync(ICallerContext caller, + MembershipAdded domainEvent, CancellationToken cancellationToken) + { + var organization = await AddMembershipInternalAsync(caller, domainEvent.RootId.ToId(), + domainEvent.OrganizationId.ToId(), cancellationToken); + if (!organization.IsSuccessful) + { + return organization.Error; + } + + return Result.Ok; + } + + public async Task> HandleEndUserMembershipRemovedAsync(ICallerContext caller, + MembershipRemoved domainEvent, CancellationToken cancellationToken) + { + var organization = await RemoveMembershipInternalAsync(caller, domainEvent.RootId.ToId(), + domainEvent.OrganizationId.ToId(), cancellationToken); + if (!organization.IsSuccessful) + { + return organization.Error; + } + + return Result.Ok; + } + + private async Task> RemoveMembershipInternalAsync(ICallerContext caller, Identifier userId, + Identifier organizationId, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(organizationId, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var org = retrieved.Value; + var removed = org.RemoveMembership(userId); + if (!removed.IsSuccessful) + { + return removed.Error; + } + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + org = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Removed organization {Id} membership for {User}", org.Id, userId); + + return Result.Ok; + } + + private async Task> AddMembershipInternalAsync(ICallerContext caller, Identifier userId, + Identifier organizationId, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(organizationId, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var org = retrieved.Value; + var added = org.AddMembership(userId); + if (!added.IsSuccessful) + { + return added.Error; + } + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + org = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Added organization {Id} membership for {User}", org.Id, + userId); + + return Result.Ok; + } } \ No newline at end of file diff --git a/src/OrganizationsApplication/OrganizationsApplication.cs b/src/OrganizationsApplication/OrganizationsApplication.cs index b1b0ace0..508096f9 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.cs +++ b/src/OrganizationsApplication/OrganizationsApplication.cs @@ -26,21 +26,66 @@ public partial class OrganizationsApplication : IOrganizationsApplication private readonly IOrganizationRepository _repository; private readonly ITenantSettingService _tenantSettingService; private readonly ITenantSettingsService _tenantSettingsService; + private readonly IUserProfilesService _userProfilesService; public OrganizationsApplication(IRecorder recorder, IIdentifierFactory identifierFactory, ITenantSettingsService tenantSettingsService, ITenantSettingService tenantSettingService, - IEndUsersService endUsersService, IImagesService imagesService, + IEndUsersService endUsersService, IUserProfilesService userProfilesService, IImagesService imagesService, IOrganizationRepository repository) { _recorder = recorder; _identifierFactory = identifierFactory; _tenantSettingService = tenantSettingService; _endUsersService = endUsersService; + _userProfilesService = userProfilesService; _imagesService = imagesService; _tenantSettingsService = tenantSettingsService; _repository = repository; } + public async Task> ChangeDetailsAsync(ICallerContext caller, string id, + string? name, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var org = retrieved.Value; + if (name.HasValue()) + { + var modifierRoles = Roles.Create(caller.Roles.Tenant); + if (!modifierRoles.IsSuccessful) + { + return modifierRoles.Error; + } + + var orgName = DisplayName.Create(name); + if (!orgName.IsSuccessful) + { + return orgName.Error; + } + + var changed = org.ChangeName(caller.ToCallerId(), modifierRoles.Value, orgName.Value); + if (!changed.IsSuccessful) + { + return changed.Error; + } + } + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + org = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Changed organization: {Id}", org.Id); + + return org.ToOrganization(); + } + public async Task> ChangeSettingsAsync(ICallerContext caller, string id, TenantSettings settings, CancellationToken cancellationToken) { @@ -171,11 +216,32 @@ public async Task> InviteMemberToOrganizationAsync(I return email.Error; } - var added = organization.AddMembership(caller.ToCallerId(), inviterRoles.Value, Optional.None, - email.Value); - if (!added.IsSuccessful) + var retrievedEmailOwner = + await _userProfilesService.FindPersonByEmailAddressPrivateAsync(caller, emailAddress, + cancellationToken); + if (!retrievedEmailOwner.IsSuccessful) { - return added.Error; + return retrievedEmailOwner.Error; + } + + if (retrievedEmailOwner.Value.HasValue) + { + var emailOwnerId = retrievedEmailOwner.Value.Value.UserId.ToId(); + var invited = organization.InviteMember(caller.ToCallerId(), inviterRoles.Value, emailOwnerId, + Optional.None); + if (!invited.IsSuccessful) + { + return invited.Error; + } + } + else + { + var invited = organization.InviteMember(caller.ToCallerId(), inviterRoles.Value, + Optional.None, email.Value); + if (!invited.IsSuccessful) + { + return invited.Error; + } } var saved = await _repository.SaveAsync(organization, cancellationToken); @@ -193,11 +259,11 @@ public async Task> InviteMemberToOrganizationAsync(I if (userId.HasValue()) { - var added = organization.AddMembership(caller.ToCallerId(), inviterRoles.Value, userId.ToId(), + var invited = organization.InviteMember(caller.ToCallerId(), inviterRoles.Value, userId.ToId(), Optional.None); - if (!added.IsSuccessful) + if (!invited.IsSuccessful) { - return added.Error; + return invited.Error; } var saved = await _repository.SaveAsync(organization, cancellationToken); @@ -238,6 +304,41 @@ await _endUsersService.ListMembershipsForOrganizationAsync(caller, organization. return searchOptions.ApplyWithMetadata(memberships.Value.Results.ConvertAll(x => x.ToMember())); } + public async Task> UnInviteMemberFromOrganizationAsync(ICallerContext caller, string id, + string userId, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var removerRoles = Roles.Create(caller.Roles.Tenant); + if (!removerRoles.IsSuccessful) + { + return removerRoles.Error; + } + + var org = retrieved.Value; + var uninvited = org.UnInviteMember(caller.ToCallerId(), removerRoles.Value, userId.ToId()); + if (!uninvited.IsSuccessful) + { + return uninvited.Error; + } + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + org = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Uninvited member {UserId} from organization: {Id}", userId, + org.Id); + + return org.ToOrganization(); + } + public async Task> ChangeAvatarAsync(ICallerContext caller, string id, FileUpload upload, CancellationToken cancellationToken) { diff --git a/src/OrganizationsDomain.UnitTests/MembershipsSpec.cs b/src/OrganizationsDomain.UnitTests/MembershipsSpec.cs new file mode 100644 index 00000000..cf42d3f8 --- /dev/null +++ b/src/OrganizationsDomain.UnitTests/MembershipsSpec.cs @@ -0,0 +1,97 @@ +using Domain.Common.ValueObjects; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace OrganizationsDomain.UnitTests; + +[Trait("Category", "Unit")] +public class MembershipsSpec +{ + [Fact] + public void WhenCreateWithNeitherUserIdNorEmail_ThenReturnsError() + { + var result = Memberships.Create([]); + + result.Should().BeSuccess(); + result.Value.Count.Should().Be(0); + } + + [Fact] + public void WhenHasMemberAndEmpty_ThenReturnsFalse() + { + var result = Memberships.Empty.HasMember("auserid".ToId()); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenHasMemberAndNotMatching_ThenReturnsFalse() + { + var memberships = Memberships.Create( + [Membership.Create("anorganizationid", "anotheruserid".ToId()).Value]).Value; + + var result = memberships.HasMember("auserid".ToId()); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenHasMemberAndMatching_ThenReturnsTrue() + { + var memberships = Memberships.Create([Membership.Create("anorganizationid", "auserid".ToId()).Value]) + .Value; + + var result = memberships.HasMember("auserid".ToId()); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenAddAndUserExists_ThenReturnsOriginal() + { + var memberships = Memberships.Create([Membership.Create("anorganizationid", "auserid".ToId()).Value]) + .Value; + + var result = memberships.Add(Membership.Create("anorganizationid", "auserid".ToId()).Value); + + result.Count.Should().Be(1); + result.Should().BeSameAs(memberships); + } + + [Fact] + public void WhenAddAndUserNotExists_ThenReturnsNew() + { + var memberships = Memberships.Create([Membership.Create("anorganizationid", "auserid1".ToId()).Value]) + .Value; + + var result = memberships.Add(Membership.Create("anorganizationid", "auserid2".ToId()).Value); + + result.Count.Should().Be(2); + result.HasMember("auserid1".ToId()).Should().BeTrue(); + result.HasMember("auserid2".ToId()).Should().BeTrue(); + } + + [Fact] + public void WhenRemoveAndUserNotExists_ThenReturnsOriginal() + { + var memberships = Memberships.Create([Membership.Create("anorganizationid", "auserid1".ToId()).Value]) + .Value; + + var result = memberships.Remove("auserid2".ToId()); + + result.Count.Should().Be(1); + result.Should().BeSameAs(memberships); + } + + [Fact] + public void WhenRemoveAndUserExists_ThenReturnsRemoved() + { + var memberships = Memberships.Create([Membership.Create("anorganizationid", "auserid1".ToId()).Value]) + .Value; + + var result = memberships.Remove("auserid1".ToId()); + + result.Count.Should().Be(0); + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs b/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs index 9bb1ad9c..ecb03c68 100644 --- a/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs +++ b/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs @@ -19,21 +19,24 @@ namespace OrganizationsDomain.UnitTests; [Trait("Category", "Unit")] public class OrganizationRootSpec { + private readonly Mock _identifierFactory; private readonly OrganizationRoot _org; + private readonly Mock _recorder; + private readonly Mock _tenantSettingService; public OrganizationRootSpec() { - var recorder = new Mock(); - var identifierFactory = new Mock(); - identifierFactory.Setup(idf => idf.Create(It.IsAny())) + _recorder = new Mock(); + _identifierFactory = new Mock(); + _identifierFactory.Setup(idf => idf.Create(It.IsAny())) .Returns((IIdentifiableEntity _) => "anid".ToId()); - var tenantSettingService = new Mock(); - tenantSettingService.Setup(tss => tss.Encrypt(It.IsAny())) + _tenantSettingService = new Mock(); + _tenantSettingService.Setup(tss => tss.Encrypt(It.IsAny())) .Returns((string value) => value); - tenantSettingService.Setup(tss => tss.Decrypt(It.IsAny())) + _tenantSettingService.Setup(tss => tss.Decrypt(It.IsAny())) .Returns((string value) => value); - _org = OrganizationRoot.Create(recorder.Object, identifierFactory.Object, tenantSettingService.Object, + _org = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, _tenantSettingService.Object, OrganizationOwnership.Shared, "acreatorid".ToId(), UserClassification.Person, DisplayName.Create("aname").Value).Value; } @@ -47,6 +50,7 @@ public void WhenCreateWithMachineUser_ThenReturnsError() result.Should().BeError(ErrorCode.RuleViolation, Resources.OrganizationRoot_Create_SharedRequiresPerson); } + [Fact] public void WhenCreate_ThenAssigns() { @@ -94,55 +98,99 @@ public void WhenUpdateSettings_ThenAddsAndUpdatesSettings() } [Fact] - public void WhenAddMembershipAndInviterNotOwner_ThenReturnsError() + public void WhenInviteMemberAndInviterNotOwner_ThenReturnsError() { - var result = _org.AddMembership("aninviterid".ToId(), Roles.Empty, Optional.None, + var result = _org.InviteMember("aninviterid".ToId(), Roles.Empty, Optional.None, Optional.None); result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_NotOrgOwner); } [Fact] - public void WhenAddMembershipAndNoUser_ThenReturnsError() + public void WhenInviteMemberAndNoUser_ThenReturnsError() { - var result = _org.AddMembership("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, + var result = _org.InviteMember("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, Optional.None, Optional.None); result.Should().BeError(ErrorCode.RuleViolation, - Resources.OrganizationRoot_AddMembership_UserIdAndEmailMissing); + Resources.OrganizationRoot_InviteMember_UserIdAndEmailMissing); } [Fact] - public void WhenAddMembershipAndNotShared_ThenReturnsError() + public void WhenInviteMemberAndNotShared_ThenReturnsError() { #if TESTINGONLY _org.TestingOnly_ChangeOwnership(OrganizationOwnership.Personal); #endif - var result = _org.AddMembership("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, + var result = _org.InviteMember("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, Optional.None, Optional.None); result.Should().BeError(ErrorCode.RuleViolation, - Resources.OrganizationRoot_AddMembership_PersonalOrgMembershipNotAllowed); + Resources.OrganizationRoot_InviteMember_PersonalOrgMembershipNotAllowed); } [Fact] - public void WhenAddMembershipWithUserId_ThenAddsMembership() + public void WhenInviteMemberWithUserId_ThenAddsMembership() { - var result = _org.AddMembership("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, + var result = _org.InviteMember("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), Optional.None); result.Should().BeSuccess(); - _org.Events.Last().Should().BeOfType(); + _org.Events.Last().Should().BeOfType(); } [Fact] - public void WhenAddMembershipWithEmailAddress_ThenAddsMembership() + public void WhenInviteMemberWithEmailAddress_ThenAddsMembership() { - var result = _org.AddMembership("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, + var result = _org.InviteMember("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, Optional.None, EmailAddress.Create("auser@company.com").Value); result.Should().BeSuccess(); - _org.Events.Last().Should().BeOfType(); + _org.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenUnInviteMemberAndRemoverNotOwner_ThenReturnsError() + { + var result = _org.UnInviteMember("aremoverid".ToId(), Roles.Empty, "auserid".ToId()); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_NotOrgOwner); + } + + [Fact] + public void WhenUnInviteMemberAndPersonalOrg_ThenReturnsError() + { + var org = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, + _tenantSettingService.Object, OrganizationOwnership.Personal, "acreatorid".ToId(), + UserClassification.Person, DisplayName.Create("aname").Value).Value; + + var result = org.UnInviteMember("aremoverid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId()); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.OrganizationRoot_UnInviteMember_PersonalOrg); + } + + [Fact] + public void WhenUnInviteMemberAndNotMember_ThenDoesNothing() + { + _org.InviteMember("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), + Optional.None); + + var result = + _org.UnInviteMember("aremoverid".ToId(), Roles.Create(TenantRoles.Owner).Value, "anotheruserid".ToId()); + + result.Should().BeSuccess(); + } + + [Fact] + public void WhenUnInviteMemberAndIsMember_ThenRemovesMember() + { + _org.InviteMember("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), + Optional.None); + + var result = + _org.UnInviteMember("aremoverid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId()); + + result.Should().BeSuccess(); } [Fact] @@ -233,4 +281,50 @@ await _org.ChangeAvatarAsync("auserid".ToId(), Roles.Create(TenantRoles.Owner).V _org.Avatar.HasValue.Should().BeFalse(); _org.Events.Last().Should().BeOfType(); } + + [Fact] + public void WhenAddMembershipAndExists_ThenDoesNothing() + { + _org.AddMembership("auserid".ToId()); + + var result = _org.AddMembership("auserid".ToId()); + + result.Should().BeSuccess(); + _org.Memberships.Count.Should().Be(1); + _org.Memberships.Members[0].UserId.Should().Be("auserid".ToId()); + _org.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenAddMembership_ThenAdded() + { + var result = _org.AddMembership("auserid".ToId()); + + result.Should().BeSuccess(); + _org.Memberships.Count.Should().Be(1); + _org.Memberships.Members[0].UserId.Should().Be("auserid".ToId()); + _org.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenRemoveMembershipAndNoTExist_ThenDoesNothing() + { + var result = _org.RemoveMembership("auserid".ToId()); + + result.Should().BeSuccess(); + _org.Memberships.Count.Should().Be(0); + _org.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenRemoveMembership_ThenRemoves() + { + _org.AddMembership("auserid".ToId()); + + var result = _org.RemoveMembership("auserid".ToId()); + + result.Should().BeSuccess(); + _org.Memberships.Count.Should().Be(0); + _org.Events.Last().Should().BeOfType(); + } } \ No newline at end of file diff --git a/src/OrganizationsDomain/Events.cs b/src/OrganizationsDomain/Events.cs index a196e272..76762bb5 100644 --- a/src/OrganizationsDomain/Events.cs +++ b/src/OrganizationsDomain/Events.cs @@ -36,10 +36,10 @@ public static Created Created(Identifier id, OrganizationOwnership ownership, Id }; } - public static MembershipAdded MembershipAdded(Identifier id, Identifier invitedById, Optional userId, + public static MemberInvited MemberInvited(Identifier id, Identifier invitedById, Optional userId, Optional userEmailAddress) { - return new MembershipAdded(id) + return new MemberInvited(id) { InvitedById = invitedById, UserId = userId.ValueOrDefault!, @@ -47,6 +47,39 @@ public static MembershipAdded MembershipAdded(Identifier id, Identifier invitedB }; } + public static MembershipAdded MembershipAdded(Identifier id, Identifier userId) + { + return new MembershipAdded(id) + { + UserId = userId + }; + } + + public static MembershipRemoved MembershipRemoved(Identifier id, Identifier userId) + { + return new MembershipRemoved(id) + { + UserId = userId + }; + } + + public static MemberUnInvited MemberUnInvited(Identifier id, Identifier unInvitedById, Identifier userId) + { + return new MemberUnInvited(id) + { + UninvitedById = unInvitedById, + UserId = userId + }; + } + + public static NameChanged NameChanged(Identifier id, DisplayName name) + { + return new NameChanged(id) + { + Name = name + }; + } + public static SettingCreated SettingCreated(Identifier id, string name, string value, SettingValueType valueType, bool isEncrypted) { diff --git a/src/OrganizationsDomain/Membership.cs b/src/OrganizationsDomain/Membership.cs new file mode 100644 index 00000000..534cd924 --- /dev/null +++ b/src/OrganizationsDomain/Membership.cs @@ -0,0 +1,48 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace OrganizationsDomain; + +public sealed class Membership : ValueObjectBase +{ + public static Result Create(string organizationId, string userId) + { + if (organizationId.IsNotValuedParameter(nameof(organizationId), out var error1)) + { + return error1; + } + + if (userId.IsNotValuedParameter(nameof(userId), out var error2)) + { + return error2; + } + + return new Membership(organizationId, userId.ToId()); + } + + private Membership(string organizationId, Identifier userId) + { + UserId = userId; + OrganizationId = organizationId; + } + + public string OrganizationId { get; } + + public Identifier UserId { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, container) => + { + var parts = RehydrateToList(property, false); + return new Membership(parts[0]!, Identifier.Rehydrate()(parts[1]!, container)); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object?[] { OrganizationId, UserId }; + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain/Memberships.cs b/src/OrganizationsDomain/Memberships.cs new file mode 100644 index 00000000..caa5e9b4 --- /dev/null +++ b/src/OrganizationsDomain/Memberships.cs @@ -0,0 +1,69 @@ +using Common; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.ValueObjects; + +namespace OrganizationsDomain; + +public sealed class Memberships : SingleValueObjectBase> +{ + public static readonly Memberships Empty = new(new List()); + + public static Result Create(List value) + { + return new Memberships(value); + } + + private Memberships(List value) : base(value) + { + } + + private Memberships(IEnumerable membership) : base(membership.ToList()) + { + } + + public int Count => Value.Count; + + public List Members => Value; + + public static ValueObjectFactory Rehydrate() + { + return (property, container) => + { + var items = RehydrateToList(property, true, true); + var memberships = items.Select(item => Membership.Rehydrate()(item!, container)); + return new Memberships(memberships); + }; + } + + public Memberships Add(Membership membership) + { + if (HasMember(membership.UserId)) + { + return this; + } + + var memberships = Value.ToList(); + memberships.Add(membership); + return new Memberships(memberships); + } + + [SkipImmutabilityCheck] + public bool HasMember(Identifier userId) + { + return Value.Any(ms => ms.UserId == userId); + } + + public Memberships Remove(string userId) + { + var membership = Value.FirstOrDefault(ms => ms.UserId == userId); + if (membership is not null) + { + var memberships = Value.ToList(); + memberships.Remove(membership); + return new Memberships(memberships); + } + + return this; + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain/OrganizationRoot.cs b/src/OrganizationsDomain/OrganizationRoot.cs index d3f0a8aa..e2816691 100644 --- a/src/OrganizationsDomain/OrganizationRoot.cs +++ b/src/OrganizationsDomain/OrganizationRoot.cs @@ -53,6 +53,8 @@ private OrganizationRoot(IRecorder recorder, IIdentifierFactory idFactory, public Identifier CreatedById { get; private set; } = Identifier.Empty(); + public Memberships Memberships { get; private set; } = Memberships.Empty; + public DisplayName Name { get; private set; } = DisplayName.Empty; public OrganizationOwnership Ownership { get; private set; } @@ -136,10 +138,36 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case MembershipAdded added: { - Recorder.TraceDebug(null, "Organization {Id} added membership for {User}", Id, - (added.EmailAddress.HasValue() - ? added.EmailAddress - : added.UserId)!); + var membership = Membership.Create(added.RootId, added.UserId); + if (!membership.IsSuccessful) + { + return membership.Error; + } + + Memberships = Memberships.Add(membership.Value); + Recorder.TraceDebug(null, "Organization {Id} added member {User}", Id, added.UserId); + return Result.Ok; + } + + case MembershipRemoved removed: + { + Memberships = Memberships.Remove(removed.UserId); + Recorder.TraceDebug(null, "Organization {Id} removed member {User}", Id, removed.UserId); + return Result.Ok; + } + + case MemberInvited invited: + { + Recorder.TraceDebug(null, "Organization {Id} invited member {User}", Id, + (invited.EmailAddress.HasValue() + ? invited.EmailAddress + : invited.UserId)!); + return Result.Ok; + } + + case MemberUnInvited unInvited: + { + Recorder.TraceDebug(null, "Organization {Id} uninvited member {User}", Id, unInvited.UserId); return Result.Ok; } @@ -163,36 +191,36 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return Result.Ok; } + case NameChanged changed: + { + var name = DisplayName.Create(changed.Name); + if (!name.IsSuccessful) + { + return name.Error; + } + + Name = name.Value; + Recorder.TraceDebug(null, "Organization {Id} changed name", Id); + return Result.Ok; + } + default: return HandleUnKnownStateChangedEvent(@event); } } - public Result AddMembership(Identifier inviterId, Roles inviterRoles, Optional userId, - Optional emailAddress) + public Result AddMembership(Identifier userId) { - if (!IsOwner(inviterRoles)) - { - return Error.RoleViolation(Resources.OrganizationRoot_NotOrgOwner); - } - - if (Ownership == OrganizationOwnership.Personal) + if (Memberships.HasMember(userId)) { - return Error.RuleViolation(Resources.OrganizationRoot_AddMembership_PersonalOrgMembershipNotAllowed); + return Result.Ok; } - if (!userId.HasValue - && !emailAddress.HasValue) - { - return Error.RuleViolation(Resources.OrganizationRoot_AddMembership_UserIdAndEmailMissing); - } - - return RaiseChangeEvent(OrganizationsDomain.Events.MembershipAdded(Id, inviterId, userId, emailAddress)); + return RaiseChangeEvent(OrganizationsDomain.Events.MembershipAdded(Id, userId)); } public async Task> ChangeAvatarAsync(Identifier modifierId, Roles modifierRoles, - CreateAvatarAction onCreateNew, - RemoveAvatarAction onRemoveOld) + CreateAvatarAction onCreateNew, RemoveAvatarAction onRemoveOld) { if (!IsOwner(modifierRoles)) { @@ -220,6 +248,16 @@ public async Task> ChangeAvatarAsync(Identifier modifierId, Roles return RaiseChangeEvent(OrganizationsDomain.Events.AvatarAdded(Id, created.Value)); } + public Result ChangeName(Identifier modifierId, Roles modifierRoles, DisplayName name) + { + if (!IsOwner(modifierRoles)) + { + return Error.RoleViolation(Resources.OrganizationRoot_NotOrgOwner); + } + + return RaiseChangeEvent(OrganizationsDomain.Events.NameChanged(Id, name)); + } + public Result CreateSettings(Settings settings) { foreach (var (key, value) in settings.Properties) @@ -257,6 +295,38 @@ public async Task> DeleteAvatarAsync(Identifier deleterId, Roles d return RaiseChangeEvent(OrganizationsDomain.Events.AvatarRemoved(Id, avatarId)); } + public Result InviteMember(Identifier inviterId, Roles inviterRoles, Optional userId, + Optional emailAddress) + { + if (!IsOwner(inviterRoles)) + { + return Error.RoleViolation(Resources.OrganizationRoot_NotOrgOwner); + } + + if (Ownership == OrganizationOwnership.Personal) + { + return Error.RuleViolation(Resources.OrganizationRoot_InviteMember_PersonalOrgMembershipNotAllowed); + } + + if (!userId.HasValue + && !emailAddress.HasValue) + { + return Error.RuleViolation(Resources.OrganizationRoot_InviteMember_UserIdAndEmailMissing); + } + + return RaiseChangeEvent(OrganizationsDomain.Events.MemberInvited(Id, inviterId, userId, emailAddress)); + } + + public Result RemoveMembership(Identifier userId) + { + if (!Memberships.HasMember(userId)) + { + return Result.Ok; + } + + return RaiseChangeEvent(OrganizationsDomain.Events.MembershipRemoved(Id, userId)); + } + #if TESTINGONLY public void TestingOnly_ChangeOwnership(OrganizationOwnership ownership) { @@ -264,6 +334,28 @@ public void TestingOnly_ChangeOwnership(OrganizationOwnership ownership) } #endif + public Result UnInviteMember(Identifier removerId, Roles removerRoles, Identifier userId) + { + if (!IsOwner(removerRoles)) + { + return Error.RoleViolation(Resources.OrganizationRoot_NotOrgOwner); + } + + if (Ownership == OrganizationOwnership.Personal) + { + return Error.RuleViolation(Resources.OrganizationRoot_UnInviteMember_PersonalOrg); + } + + if (!Memberships.HasMember(userId)) + { + return Result.Ok; + } + + //TODO: cannot remove if BillingBuyer + + return RaiseChangeEvent(OrganizationsDomain.Events.MemberUnInvited(Id, removerId, userId)); + } + public Result UpdateSettings(Settings settings) { foreach (var (key, value) in settings.Properties) diff --git a/src/OrganizationsDomain/Resources.Designer.cs b/src/OrganizationsDomain/Resources.Designer.cs index 4d4e9859..ae7e6857 100644 --- a/src/OrganizationsDomain/Resources.Designer.cs +++ b/src/OrganizationsDomain/Resources.Designer.cs @@ -69,29 +69,29 @@ internal static string OrganizationDisplayName_InvalidName { } /// - /// Looks up a localized string similar to Cannot add another user to a Personal organization. + /// Looks up a localized string similar to Must be a person to create a 'Shared' organization. /// - internal static string OrganizationRoot_AddMembership_PersonalOrgMembershipNotAllowed { + internal static string OrganizationRoot_Create_SharedRequiresPerson { get { - return ResourceManager.GetString("OrganizationRoot_AddMembership_PersonalOrgMembershipNotAllowed", resourceCulture); + return ResourceManager.GetString("OrganizationRoot_Create_SharedRequiresPerson", resourceCulture); } } /// - /// Looks up a localized string similar to Both the ID of the user and an email for the user is missing. + /// Looks up a localized string similar to Cannot add another user to a 'Personal' organization. /// - internal static string OrganizationRoot_AddMembership_UserIdAndEmailMissing { + internal static string OrganizationRoot_InviteMember_PersonalOrgMembershipNotAllowed { get { - return ResourceManager.GetString("OrganizationRoot_AddMembership_UserIdAndEmailMissing", resourceCulture); + return ResourceManager.GetString("OrganizationRoot_InviteMember_PersonalOrgMembershipNotAllowed", resourceCulture); } } /// - /// Looks up a localized string similar to Must be a person to create a 'Shared' organization. + /// Looks up a localized string similar to Both the ID of the user and an email for the user is missing. /// - internal static string OrganizationRoot_Create_SharedRequiresPerson { + internal static string OrganizationRoot_InviteMember_UserIdAndEmailMissing { get { - return ResourceManager.GetString("OrganizationRoot_Create_SharedRequiresPerson", resourceCulture); + return ResourceManager.GetString("OrganizationRoot_InviteMember_UserIdAndEmailMissing", resourceCulture); } } @@ -113,6 +113,15 @@ internal static string OrganizationRoot_NotOrgOwner { } } + /// + /// Looks up a localized string similar to Cannot remove any members from a 'Personal' organization. + /// + internal static string OrganizationRoot_UnInviteMember_PersonalOrg { + get { + return ResourceManager.GetString("OrganizationRoot_UnInviteMember_PersonalOrg", 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 708d277a..bd2f763d 100644 --- a/src/OrganizationsDomain/Resources.resx +++ b/src/OrganizationsDomain/Resources.resx @@ -39,13 +39,16 @@ Must be an organization owner to perform this action - + Both the ID of the user and an email for the user is missing This organization has no avatar to delete - - Cannot add another user to a Personal organization + + Cannot add another user to a 'Personal' organization + + + Cannot remove any members from a 'Personal' organization \ No newline at end of file diff --git a/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs index 4a1a0cf6..1e014dd6 100644 --- a/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs +++ b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs @@ -5,6 +5,7 @@ using FluentAssertions; using Infrastructure.Web.Api.Common; using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; using Infrastructure.Web.Api.Operations.Shared.Identities; using Infrastructure.Web.Api.Operations.Shared.Organizations; using Infrastructure.Web.Interfaces.Clients; @@ -46,12 +47,33 @@ public async Task WhenCreateOrganization_ThenReturnsOrganization() var result = await Api.PostAsync(new CreateOrganizationRequest { - Name = "anorganizationname" + Name = "aname" }, req => req.SetJWTBearerToken(login.AccessToken)); + var organizationId = result.Content.Value.Organization!.Id; result.Content.Value.Organization!.CreatedById.Should().Be(login.User.Id); - result.Content.Value.Organization!.Name.Should().Be("anorganizationname"); + result.Content.Value.Organization!.Name.Should().Be("aname"); result.Content.Value.Organization!.Ownership.Should().Be(OrganizationOwnership.Shared); + + login = await ReAuthenticateUserAsync(login.User); + login.User.Profile!.DefaultOrganizationId.Should().Be(organizationId); + + var members = await Api.GetAsync(new ListMembersForOrganizationRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(login.AccessToken)); + + members.Content.Value.Members!.Count.Should().Be(1); + 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(login.User.Id); + members.Content.Value.Members[0].EmailAddress.Should().Be(login.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); } [Fact] @@ -63,7 +85,7 @@ public async Task WhenInviteMembersToOrganization_ThenAddsMembers() var organization = await Api.PostAsync(new CreateOrganizationRequest { - Name = "anorganizationname" + Name = "aname" }, req => req.SetJWTBearerToken(loginA.AccessToken)); loginA = await ReAuthenticateUserAsync(loginA.User); @@ -80,6 +102,7 @@ await Api.PostAsync(new InviteMemberToOrganizationRequest Email = loginC }, req => req.SetJWTBearerToken(loginA.AccessToken)); + //Automatically adds the machine to loginA organization var machine = await Api.PostAsync(new RegisterMachineRequest { Name = "amachinename" @@ -154,7 +177,7 @@ public async Task WhenChangeAvatarByOrgMember_ThenForbidden() var organization = await Api.PostAsync(new CreateOrganizationRequest { - Name = "anorganizationname" + Name = "aname" }, req => req.SetJWTBearerToken(loginA.AccessToken)); loginA = await ReAuthenticateUserAsync(loginA.User); @@ -209,6 +232,203 @@ await Api.PutAsync(new ChangeOrganizationAvatarRequest result.Content.Value.Organization!.AvatarUrl.Should().BeNull(); } + [Fact] + public async Task WhenChangeDetails_ThenDeletes() + { + var login = await LoginUserAsync(); + + var organizationId = login.User.Profile!.DefaultOrganizationId!; + var result = await Api.PutAsync(new ChangeOrganizationRequest + { + Id = organizationId, + Name = "anewname" + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Organization!.Name.Should().Be("anewname"); + } + + [Fact] + public async Task WhenUnInviteGuestFromOrganization_ThenRemovesMember() + { + var loginA = await LoginUserAsync(); + var loginC = CreateRandomEmailAddress(); + + var organization = await Api.PostAsync(new CreateOrganizationRequest + { + Name = "aname" + }, 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 = loginC + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + var members = await Api.GetAsync(new ListMembersForOrganizationRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + var loginCId = members.Content.Value.Members![1].UserId; + + await Api.DeleteAsync(new UnInviteMemberFromOrganizationRequest + { + Id = organizationId, + UserId = loginCId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + members = await Api.GetAsync(new ListMembersForOrganizationRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + members.Content.Value.Members!.Count.Should().Be(1); + 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); + } + + [Fact] + public async Task WhenUnInviteRegisteredUserFromOrganization_ThenRemovesMember() + { + var loginA = await LoginUserAsync(); + var loginB = await LoginUserAsync(LoginUser.PersonB); + + var organization = await Api.PostAsync(new CreateOrganizationRequest + { + Name = "aname" + }, 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)); + + var members = await Api.GetAsync(new ListMembersForOrganizationRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + var loginBId = members.Content.Value.Members![1].UserId; + + await Api.DeleteAsync(new UnInviteMemberFromOrganizationRequest + { + Id = organizationId, + UserId = loginBId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + members = await Api.GetAsync(new ListMembersForOrganizationRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + members.Content.Value.Members!.Count.Should().Be(1); + 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); + + loginB = await ReAuthenticateUserAsync(loginB.User); + var memberships = await Api.GetAsync(new ListMembershipsForCallerRequest(), + req => req.SetJWTBearerToken(loginB.AccessToken)); + + memberships.Content.Value.Memberships!.Count.Should().Be(1); + memberships.Content.Value.Memberships![0].OrganizationId.Should().NotBeNull(); + memberships.Content.Value.Memberships![0].Ownership.Should().Be(OrganizationOwnership.Personal); + } + + [Fact] + public async Task WhenUnInviteMembersFromOrganization_ThenRemovesMembers() + { + var loginA = await LoginUserAsync(); + var loginB = await LoginUserAsync(LoginUser.PersonB); + var loginC = CreateRandomEmailAddress(); + + var organization = await Api.PostAsync(new CreateOrganizationRequest + { + Name = "aname" + }, 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)); + + //Automatically adds the machine to loginA organization + 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)); + var loginBId = members.Content.Value.Members![1].UserId; + var loginCId = members.Content.Value.Members![2].UserId; + var machineId = members.Content.Value.Members![3].UserId; + + await Api.DeleteAsync(new UnInviteMemberFromOrganizationRequest + { + Id = organizationId, + UserId = loginBId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + await Api.DeleteAsync(new UnInviteMemberFromOrganizationRequest + { + Id = organizationId, + UserId = loginCId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + await Api.DeleteAsync(new UnInviteMemberFromOrganizationRequest + { + Id = organizationId, + UserId = machineId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + members = await Api.GetAsync(new ListMembersForOrganizationRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + members.Content.Value.Members!.Count.Should().Be(1); + 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); + } + private static string CreateRandomEmailAddress() { return $"aninvitee{++_invitationCount}@company.com"; diff --git a/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/ChangeOrganizationRequestValidatorSpec.cs b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/ChangeOrganizationRequestValidatorSpec.cs new file mode 100644 index 00000000..8889752f --- /dev/null +++ b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/ChangeOrganizationRequestValidatorSpec.cs @@ -0,0 +1,43 @@ +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 ChangeOrganizationRequestValidatorSpec +{ + private readonly ChangeOrganizationRequest _dto; + private readonly ChangeOrganizationRequestValidator _validator; + + public ChangeOrganizationRequestValidatorSpec() + { + _validator = new ChangeOrganizationRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new ChangeOrganizationRequest + { + Id = "anid", + Name = "aname" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenNameIsInvalid_ThenThrows() + { + _dto.Name = "aninvalidname^"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.ChangeOrganizationRequestValidator_InvalidName); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/ChangeOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/ChangeOrganizationRequestValidator.cs new file mode 100644 index 00000000..6e53e013 --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/ChangeOrganizationRequestValidator.cs @@ -0,0 +1,23 @@ +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; +using OrganizationsDomain; + +namespace OrganizationsInfrastructure.Api.Organizations; + +public class ChangeOrganizationRequestValidator : AbstractValidator +{ + public ChangeOrganizationRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.Id) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + RuleFor(req => req.Name) + .Matches(Validations.DisplayName) + .When(req => req.Name.Exists()) + .WithMessage(Resources.ChangeOrganizationRequestValidator_InvalidName); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/CreateOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/CreateOrganizationRequestValidator.cs index 20d3b773..e0bf7ded 100644 --- a/src/OrganizationsInfrastructure/Api/Organizations/CreateOrganizationRequestValidator.cs +++ b/src/OrganizationsInfrastructure/Api/Organizations/CreateOrganizationRequestValidator.cs @@ -9,7 +9,7 @@ public class CreateOrganizationRequestValidator : AbstractValidator x.Name) + RuleFor(req => req.Name) .NotEmpty() .Matches(Validations.DisplayName) .WithMessage(Resources.CreateOrganizationRequestValidator_InvalidName); diff --git a/src/OrganizationsInfrastructure/Api/Organizations/GetOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/GetOrganizationRequestValidator.cs index 501e493b..a0c87b62 100644 --- a/src/OrganizationsInfrastructure/Api/Organizations/GetOrganizationRequestValidator.cs +++ b/src/OrganizationsInfrastructure/Api/Organizations/GetOrganizationRequestValidator.cs @@ -10,7 +10,7 @@ public class GetOrganizationRequestValidator : AbstractValidator x.Id) + RuleFor(req => req.Id) .IsEntityId(identifierFactory) .WithMessage(CommonValidationResources.AnyValidator_InvalidId); } diff --git a/src/OrganizationsInfrastructure/Api/Organizations/ListMembersForOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/ListMembersForOrganizationRequestValidator.cs new file mode 100644 index 00000000..84af6730 --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/ListMembersForOrganizationRequestValidator.cs @@ -0,0 +1,17 @@ +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 ListMembersForOrganizationRequestValidator : AbstractValidator +{ + public ListMembersForOrganizationRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.Id) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs b/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs index 73433477..ad8df705 100644 --- a/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs +++ b/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs @@ -45,6 +45,17 @@ await _organizationsApplication.ChangeAvatarAsync(_contextFactory.Create(), requ new GetOrganizationResponse { Organization = o }); } + public async Task> ChangeOrganization( + ChangeOrganizationRequest request, CancellationToken cancellationToken) + { + var organization = await _organizationsApplication.ChangeDetailsAsync(_contextFactory.Create(), + request.Id!, request.Name, cancellationToken); + + return () => + organization.HandleApplicationResult(x => new GetOrganizationResponse + { Organization = x }); + } + public async Task> Create(CreateOrganizationRequest request, CancellationToken cancellationToken) { @@ -123,4 +134,15 @@ await _organizationsApplication.ListMembersForOrganizationAsync(_contextFactory. members.HandleApplicationResult(m => new ListMembersForOrganizationResponse { Members = m.Results, Metadata = m.Metadata }); } + + public async Task> UnInviteMember( + UnInviteMemberFromOrganizationRequest request, CancellationToken cancellationToken) + { + var organization = await _organizationsApplication.UnInviteMemberFromOrganizationAsync(_contextFactory.Create(), + request.Id!, request.UserId, cancellationToken); + + return () => + organization.HandleApplicationResult(org => + new UnInviteMemberFromOrganizationResponse { Organization = org }); + } } \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/UnInviteMemberFromOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/UnInviteMemberFromOrganizationRequestValidator.cs new file mode 100644 index 00000000..8b641571 --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/UnInviteMemberFromOrganizationRequestValidator.cs @@ -0,0 +1,20 @@ +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 UnInviteMemberFromOrganizationRequestValidator : AbstractValidator +{ + public UnInviteMemberFromOrganizationRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.Id) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + RuleFor(req => req.UserId) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Notifications/OrganizationNotificationConsumer.cs b/src/OrganizationsInfrastructure/Notifications/OrganizationNotificationConsumer.cs index 242f4edb..6665f3ad 100644 --- a/src/OrganizationsInfrastructure/Notifications/OrganizationNotificationConsumer.cs +++ b/src/OrganizationsInfrastructure/Notifications/OrganizationNotificationConsumer.cs @@ -27,6 +27,14 @@ public async Task> NotifyAsync(IDomainEvent domainEvent, Cancellat return await _organizationsApplication.HandleEndUserRegisteredAsync(_callerContextFactory.Create(), registered, cancellationToken); + case MembershipAdded added: + return await _organizationsApplication.HandleEndUserMembershipAddedAsync( + _callerContextFactory.Create(), added, cancellationToken); + + case MembershipRemoved removed: + return await _organizationsApplication.HandleEndUserMembershipRemovedAsync( + _callerContextFactory.Create(), removed, cancellationToken); + default: return Result.Ok; } diff --git a/src/OrganizationsInfrastructure/OrganizationsModule.cs b/src/OrganizationsInfrastructure/OrganizationsModule.cs index e2238eca..d8227ca9 100644 --- a/src/OrganizationsInfrastructure/OrganizationsModule.cs +++ b/src/OrganizationsInfrastructure/OrganizationsModule.cs @@ -60,6 +60,7 @@ public Action RegisterServices c.GetRequiredService(), c.GetRequiredService(), c.GetRequiredService(), + c.GetRequiredService(), c.GetRequiredService(), c.GetRequiredService())); services.AddSingleton(c => new OrganizationRepository( diff --git a/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs b/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs index 2e256e78..06ec154d 100644 --- a/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs +++ b/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs @@ -1,7 +1,6 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Domain.Common.ValueObjects; using Domain.Events.Shared.Organizations; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -29,7 +28,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _organizations.HandleCreateAsync(e.RootId.ToId(), dto => + return await _organizations.HandleCreateAsync(e.RootId, dto => { dto.Name = e.Name; dto.Ownership = e.Ownership; @@ -43,8 +42,17 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven case MembershipAdded _: return true; + case MembershipRemoved _: + return true; + + case MemberInvited _: + return true; + + case MemberUnInvited _: + return true; + case AvatarAdded e: - return await _organizations.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _organizations.HandleUpdateAsync(e.RootId, dto => { dto.AvatarId = e.AvatarId; dto.AvatarUrl = e.AvatarUrl; @@ -52,13 +60,17 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven cancellationToken); case AvatarRemoved e: - return await _organizations.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _organizations.HandleUpdateAsync(e.RootId, dto => { dto.AvatarId = Optional.None; dto.AvatarUrl = Optional.None; }, cancellationToken); + case NameChanged e: + return await _organizations.HandleUpdateAsync(e.RootId, dto => { dto.Name = e.Name; }, + cancellationToken); + default: return false; } diff --git a/src/OrganizationsInfrastructure/Resources.Designer.cs b/src/OrganizationsInfrastructure/Resources.Designer.cs index 4068e4a4..d78653ce 100644 --- a/src/OrganizationsInfrastructure/Resources.Designer.cs +++ b/src/OrganizationsInfrastructure/Resources.Designer.cs @@ -59,6 +59,15 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to The 'Name' is invalid. + /// + internal static string ChangeOrganizationRequestValidator_InvalidName { + get { + return ResourceManager.GetString("ChangeOrganizationRequestValidator_InvalidName", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'Name' is either missing or invalid. /// diff --git a/src/OrganizationsInfrastructure/Resources.resx b/src/OrganizationsInfrastructure/Resources.resx index 79c7f5e3..43d0a4dc 100644 --- a/src/OrganizationsInfrastructure/Resources.resx +++ b/src/OrganizationsInfrastructure/Resources.resx @@ -36,4 +36,7 @@ Only the 'Email' or the 'UserId' can be provided, not both + + The 'Name' is invalid + \ No newline at end of file diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 45f85459..9cf1e82e 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -559,7 +559,7 @@ using Domain.Common; using Domain.Common.ValueObjects; public sealed class $name$ : SingleValueObjectBase<$name$, $datatype$> -{ +{$END$$SELECTION$ public static Result<$name$, Error> Create($datatype$ value) { if (value.IsNotValuedParameter(nameof(value), out var error)) @@ -582,7 +582,7 @@ public sealed class $name$ : SingleValueObjectBase<$name$, $datatype$> return new $name$(parts[0]); }; } -}$END$$SELECTION$ +} True True A DELETE API definition @@ -601,7 +601,7 @@ public sealed class $name$ : SingleValueObjectBase<$name$, $datatype$> deleteapi True public async Task<ApiDeleteResult> $Action$$Resource$($Action$$Resource$Request request, CancellationToken cancellationToken) -{ +{$END$$SELECTION$ var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); return () => $resource$.HandleApplicationResult(); @@ -644,7 +644,7 @@ using Domain.Common; using Domain.Common.ValueObjects; public sealed class $name$ : ValueObjectBase<$name$> -{ +{$END$$SELECTION$ public static Result<$name$, Error> Create($datatype$ $value1$, $datatype$ $value2$, $datatype$ $value3$) { if ($value1$.IsNotValuedParameter(nameof($value1$), out var error1)) @@ -689,7 +689,7 @@ public sealed class $name$ : ValueObjectBase<$name$> public $datatype$ $param2$ { get; } public $datatype$ $param3$ { get; } -}$END$$SELECTION$ +} True True cs @@ -757,7 +757,7 @@ public class $Action$$Resource$RequestValidator : AbstractValidator<$Action$$ postapi True public async Task<ApiPostResult<$Resource$, $Action$$Resource$Response>> $Action$$Resource$($Action$$Resource$Request request, CancellationToken cancellationToken) -{ +{$END$$SELECTION$ var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); return () => $resource$.HandleApplicationResult<$Resource$, $Action$$Resource$Response>(x => new PostResult<$Action$$Resource$Response>(new $Action$$Resource$Response { $Resource$ = x })); @@ -820,11 +820,12 @@ public sealed class $class$ : DomainEvent searchapi True public async Task<ApiSearchResult<$Resource$, $Action$$Resource$sResponse>> $Action$$Resource$s($Action$$Resource$sRequest request, CancellationToken cancellationToken) -{ +{$END$$SELECTION$ var $resource$s = await _application.$Action$$Resource$sAsync(_contextFactory.Create(), request.ToSearchOptions(), request.ToGetOptions(), cancellationToken); return () => $resource$s.HandleApplicationResult(x => new $Action$$Resource$sResponse { $Resource$s = x.Results, Metadata = x.Metadata }); } + False False True True @@ -1011,7 +1012,7 @@ public class $filename$ [Fact] public async Task When$condition$_Then$outcome$() { - $END$ + Assert.Fail();$END$ } True True @@ -1083,6 +1084,7 @@ public class $Action$$Resource$Response : SearchResponse public List<$Resource$>? $Resource$s { get; set; } } SaaStack + False True True cs @@ -1194,6 +1196,7 @@ public class $Action$$Resource$Response : IWebResponse #if TESTINGONLY $SELECTION$$END$ #endif + False True True A GET API definition @@ -1212,7 +1215,7 @@ public class $Action$$Resource$Response : IWebResponse getapi True public async Task<ApiGetResult<$Resource$, $Action$$Resource$Response>> $Action$$Resource$($Action$$Resource$Request request, CancellationToken cancellationToken) -{ +{$END$$SELECTION$ var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); return () => $resource$.HandleApplicationResult<$Resource$, $Action$$Resource$Response>(x => new $Action$$Resource$Response { $Resource$ = x }); @@ -1251,7 +1254,7 @@ using QueryAny; // TODO: DELETE this attribute if EventSourcing, LEAVE if Snapshotting [EntityName("$name$")] public sealed class $name$Root : AggregateRootBase -{ +{$END$$SELECTION$ public static Result<$name$Root, Error> Create(IRecorder recorder, IIdentifierFactory idFactory, Identifier organizationId) { var root = new $name$Root(recorder, idFactory); @@ -1339,36 +1342,19 @@ public sealed class $name$Root : AggregateRootBase public Name? $propertyname$ { get; private set; } public Identifier OrganizationId { get; private set; } = Identifier.Empty(); -}$END$$SELECTION$ - True - True - A DDD domain event - True - 0 - True - True - 2.0 - InCSharpFile - domainevent - True - public sealed class $name$ : IDomainEvent -{ - public static $name$ Create(Identifier id, Identifier organizationId) - { - return new $name$ - { - RootId = id, - OrganizationId = organizationId, - OccurredUtc = DateTime.UtcNow - }; - } - - public required string OrganizationId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } -}$END$$SELECTION$ +} + False + + + + + + + + + + + True True A PUTPATCH API definition @@ -1387,7 +1373,7 @@ public sealed class $name$Root : AggregateRootBase putpatchapi True public async Task<ApiPutPatchResult<$Resource$, $Action$$Resource$Response>> $Action$$Resource$($Action$$Resource$Request request, CancellationToken cancellationToken) -{ +{$END$$SELECTION$ var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); return () => $resource$.HandleApplicationResult<$Resource$, $Action$$Resource$Response>(x => new $Action$$Resource$Response { $Resource$ = x }); @@ -1408,8 +1394,9 @@ public sealed class $name$Root : AggregateRootBase [Fact] public void When$condition$_Then$outcome$() { - $END$ + Assert.Fail();$END$ } + False True True True diff --git a/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs b/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs index d4ad48d5..d4faa65c 100644 --- a/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs +++ b/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs @@ -207,14 +207,14 @@ public async Task WhenHandleEndUserRegisteredAsyncForPersonAndHasDefaultAvatar_T [Fact] public async Task WhenHandleEndUserDefaultOrganizationChangedAsync_ThenSetsDefaultOrganization() { - var domainEvent = Events.MembershipDefaultChanged("auserid".ToId(), "amembershipid".ToId(), + var domainEvent = Events.DefaultMembershipChanged("auserid".ToId(), "amembershipid".ToId(), "amembershipid".ToId(), "anorganizationid".ToId(), Roles.Empty, Features.Empty); var user = UserProfileRoot.Create(_recorder.Object, _idFactory.Object, ProfileType.Person, "auserid".ToId(), PersonName.Create("afirstname", "alastname").Value).Value; _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user.ToOptional()); - var result = await _application.HandleEndUserDefaultOrganizationChangedAsync(_caller.Object, + var result = await _application.HandleEndUserDefaultMembershipChangedAsync(_caller.Object, domainEvent, CancellationToken.None); result.Should().BeSuccess(); diff --git a/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs b/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs index 6b0fb0b9..24e37804 100644 --- a/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs +++ b/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs @@ -9,7 +9,7 @@ partial interface IUserProfilesApplication Task> HandleEndUserRegisteredAsync(ICallerContext caller, Registered domainEvent, CancellationToken cancellationToken); - Task> HandleEndUserDefaultOrganizationChangedAsync(ICallerContext caller, - MembershipDefaultChanged domainEvent, + Task> HandleEndUserDefaultMembershipChangedAsync(ICallerContext caller, + DefaultMembershipChanged domainEvent, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/UserProfilesApplication/IUserProfilesApplication.cs b/src/UserProfilesApplication/IUserProfilesApplication.cs index 03e76c11..5cb236f5 100644 --- a/src/UserProfilesApplication/IUserProfilesApplication.cs +++ b/src/UserProfilesApplication/IUserProfilesApplication.cs @@ -27,7 +27,7 @@ Task, Error>> FindPersonByEmailAddressAsync(ICaller Task, Error>> GetAllProfilesAsync(ICallerContext caller, List ids, GetOptions options, CancellationToken cancellationToken); - Task> GetCurrentUserProfileAsync(ICallerContext caller, + Task> GetCurrentUserProfileAsync(ICallerContext caller, CancellationToken cancellationToken); Task> GetProfileAsync(ICallerContext caller, string userId, diff --git a/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs b/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs index 6519c5c7..f158faaf 100644 --- a/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs +++ b/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs @@ -29,8 +29,8 @@ await CreateProfileAsync(caller, classification, domainEvent.RootId, domainEvent return Result.Ok; } - public async Task> HandleEndUserDefaultOrganizationChangedAsync(ICallerContext caller, - MembershipDefaultChanged domainEvent, + public async Task> HandleEndUserDefaultMembershipChangedAsync(ICallerContext caller, + DefaultMembershipChanged domainEvent, CancellationToken cancellationToken) { var profile = await UpdateDefaultOrganizationAsync(caller, domainEvent.RootId, domainEvent.ToOrganizationId, diff --git a/src/UserProfilesApplication/UserProfilesApplication.cs b/src/UserProfilesApplication/UserProfilesApplication.cs index 853766ed..0ae2e157 100644 --- a/src/UserProfilesApplication/UserProfilesApplication.cs +++ b/src/UserProfilesApplication/UserProfilesApplication.cs @@ -130,12 +130,12 @@ public async Task, Error>> FindPersonByEmailAddress return Optional.None; } - public async Task> GetCurrentUserProfileAsync(ICallerContext caller, + public async Task> GetCurrentUserProfileAsync(ICallerContext caller, CancellationToken cancellationToken) { if (!caller.IsAuthenticated) { - return new UserProfileForCurrent + return new UserProfileForCaller { Address = new ProfileAddress { @@ -381,7 +381,6 @@ public async Task, Error>> GetAllProfilesAsync(ICallerC .ToList(); } - private async Task> ChangeAvatarInternalAsync(ICallerContext caller, Identifier modifierId, UserProfileRoot profile, FileUpload upload, CancellationToken cancellationToken) { @@ -406,9 +405,9 @@ private async Task> ChangeAvatarInternalAsync(ICallerContext calle internal static class UserProfileConversionExtensions { - public static UserProfileForCurrent ToCurrentProfile(this UserProfileRoot profile, ICallerContext caller) + public static UserProfileForCaller ToCurrentProfile(this UserProfileRoot profile, ICallerContext caller) { - var dto = profile.ToProfile().Convert(); + var dto = profile.ToProfile().Convert(); dto.IsAuthenticated = caller.IsAuthenticated; dto.Roles = caller.Roles.Platform.Select(rol => rol.Name).ToList(); dto.Features = caller.Features.Platform.Select(feat => feat.Name).ToList(); diff --git a/src/UserProfilesInfrastructure.IntegrationTests/UserProfileApiSpec.cs b/src/UserProfilesInfrastructure.IntegrationTests/UserProfileApiSpec.cs index 091cbb89..f148b8e1 100644 --- a/src/UserProfilesInfrastructure.IntegrationTests/UserProfileApiSpec.cs +++ b/src/UserProfilesInfrastructure.IntegrationTests/UserProfileApiSpec.cs @@ -157,9 +157,9 @@ await Api.PutAsync(new ChangeProfileAvatarRequest } [Fact] - public async Task WhenGetCurrentUserForAnonymous_ThenNotAuthenticated() + public async Task WhenGetProfileForCallerForAnonymous_ThenNotAuthenticated() { - var result = await Api.GetAsync(new GetCurrentProfileRequest()); + var result = await Api.GetAsync(new GetProfileForCallerRequest()); result.Content.Value.Profile!.IsAuthenticated.Should().BeFalse(); result.Content.Value.Profile.Id.Should().Be(CallerConstants.AnonymousUserId); @@ -168,11 +168,11 @@ public async Task WhenGetCurrentUserForAnonymous_ThenNotAuthenticated() } [Fact] - public async Task WhenGetCurrentUserForAuthenticated_ThenAuthenticated() + public async Task WhenGetProfileForCallerForAuthenticated_ThenAuthenticated() { var login = await LoginUserAsync(); - var result = await Api.GetAsync(new GetCurrentProfileRequest(), + var result = await Api.GetAsync(new GetProfileForCallerRequest(), req => req.SetJWTBearerToken(login.AccessToken)); result.Content.Value.Profile!.IsAuthenticated.Should().BeTrue(); diff --git a/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs b/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs index a7905a95..3ed37a30 100644 --- a/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs +++ b/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs @@ -84,14 +84,14 @@ await _userProfilesApplication.DeleteProfileAvatarAsync(_contextFactory.Create() new DeleteProfileAvatarResponse { Profile = pro }); } - public async Task> GetCurrentProfile( - GetCurrentProfileRequest request, CancellationToken cancellationToken) + public async Task> GetProfileForCaller( + GetProfileForCallerRequest request, CancellationToken cancellationToken) { var profile = await _userProfilesApplication.GetCurrentUserProfileAsync(_contextFactory.Create(), cancellationToken); return () => - profile.HandleApplicationResult(pro => - new GetCurrentProfileResponse { Profile = pro }); + profile.HandleApplicationResult(pro => + new GetProfileForCallerResponse { Profile = pro }); } } \ No newline at end of file diff --git a/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs b/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs index 231aad91..ecd73129 100644 --- a/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs +++ b/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs @@ -27,8 +27,8 @@ public async Task> NotifyAsync(IDomainEvent domainEvent, Cancellat return await _userProfilesApplication.HandleEndUserRegisteredAsync(_callerContextFactory.Create(), registered, cancellationToken); - case MembershipDefaultChanged changed: - return await _userProfilesApplication.HandleEndUserDefaultOrganizationChangedAsync( + case DefaultMembershipChanged changed: + return await _userProfilesApplication.HandleEndUserDefaultMembershipChangedAsync( _callerContextFactory.Create(), changed, cancellationToken); diff --git a/src/UserProfilesInfrastructure/Persistence/ReadModels/UserProfileProjection.cs b/src/UserProfilesInfrastructure/Persistence/ReadModels/UserProfileProjection.cs index 614e3d93..8d839848 100644 --- a/src/UserProfilesInfrastructure/Persistence/ReadModels/UserProfileProjection.cs +++ b/src/UserProfilesInfrastructure/Persistence/ReadModels/UserProfileProjection.cs @@ -1,7 +1,6 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Domain.Common.ValueObjects; using Domain.Events.Shared.UserProfiles; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -29,7 +28,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven switch (changeEvent) { case Created e: - return await _users.HandleCreateAsync(e.RootId.ToId(), dto => + return await _users.HandleCreateAsync(e.RootId, dto => { dto.Type = e.Type; dto.UserId = e.UserId; @@ -39,34 +38,34 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case NameChanged e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.FirstName = e.FirstName; dto.LastName = e.LastName; }, cancellationToken); case DisplayNameChanged e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.DisplayName = e.DisplayName; }, + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.DisplayName = e.DisplayName; }, cancellationToken); case EmailAddressChanged e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.EmailAddress = e.EmailAddress; }, + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.EmailAddress = e.EmailAddress; }, cancellationToken); case PhoneNumberChanged e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.PhoneNumber = e.Number; }, + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.PhoneNumber = e.Number; }, cancellationToken); case TimezoneChanged e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.Timezone = e.Timezone; }, + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.Timezone = e.Timezone; }, cancellationToken); case ContactAddressChanged e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.CountryCode = e.CountryCode; }, + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.CountryCode = e.CountryCode; }, cancellationToken); case AvatarAdded e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.AvatarId = e.AvatarId; dto.AvatarUrl = e.AvatarUrl; @@ -74,7 +73,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven cancellationToken); case AvatarRemoved e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.AvatarId = Optional.None; dto.AvatarUrl = Optional.None; @@ -82,7 +81,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven cancellationToken); case DefaultOrganizationChanged e: - return await _users.HandleUpdateAsync(e.RootId.ToId(), + return await _users.HandleUpdateAsync(e.RootId, dto => { dto.DefaultOrganizationId = e.ToOrganizationId; }, cancellationToken);