diff --git a/docs/design-principles/0000-all-use-cases.md b/docs/design-principles/0000-all-use-cases.md index 63b1e862..ac131a95 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 -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 +7. Un-invite a member from the organization +8. Assign roles to a member +9. Unassign roles from a member +10. List all members of the organization +11. Delete the organization (must be no remaining members) ### 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..cffaf327 Binary files /dev/null and b/docs/images/Event Flows - Generic.png differ diff --git a/docs/images/Physical-Architecture-AWS.png b/docs/images/Physical-Architecture-AWS.png index ab1f9ec9..ce740536 100644 Binary files a/docs/images/Physical-Architecture-AWS.png and b/docs/images/Physical-Architecture-AWS.png differ diff --git a/docs/images/Physical-Architecture-Azure.png b/docs/images/Physical-Architecture-Azure.png index 0de057cb..348c4f7f 100644 Binary files a/docs/images/Physical-Architecture-Azure.png and b/docs/images/Physical-Architecture-Azure.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..b09aeaec 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.Interfaces/Audits.Designer.cs b/src/Application.Interfaces/Audits.Designer.cs index feb009c3..f262f831 100644 --- a/src/Application.Interfaces/Audits.Designer.cs +++ b/src/Application.Interfaces/Audits.Designer.cs @@ -86,6 +86,15 @@ public static string EndUserApplication_TenantRolesAssigned { } } + /// + /// Looks up a localized string similar to EndUser.TenantRolesUnassigned. + /// + public static string EndUserApplication_TenantRolesUnassigned { + get { + return ResourceManager.GetString("EndUserApplication_TenantRolesUnassigned", resourceCulture); + } + } + /// /// Looks up a localized string similar to EndUser.Registered.TermsAccepted. /// diff --git a/src/Application.Interfaces/Audits.resx b/src/Application.Interfaces/Audits.resx index 7dcfe3bc..47da3f3a 100644 --- a/src/Application.Interfaces/Audits.resx +++ b/src/Application.Interfaces/Audits.resx @@ -45,6 +45,9 @@ EndUser.TenantRolesAssigned + + EndUser.TenantRolesUnassigned + EndUser.PlatformRolesAssigned diff --git a/src/Application.Resources.Shared/EndUser.cs b/src/Application.Resources.Shared/EndUser.cs index bd52f492..bbc682ca 100644 --- a/src/Application.Resources.Shared/EndUser.cs +++ b/src/Application.Resources.Shared/EndUser.cs @@ -53,18 +53,20 @@ public class Membership : IIdentifiableResource public required string OrganizationId { get; set; } - public List Roles { get; set; } = new(); + public OrganizationOwnership Ownership { get; set; } - public required string Id { get; set; } + public List Roles { get; set; } = new(); public required string UserId { get; set; } + + public required string Id { get; set; } } public class MembershipWithUserProfile : Membership { - public EndUserStatus Status { get; set; } - public required UserProfile Profile { get; set; } + + public EndUserStatus Status { get; set; } } public class Invitation 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/Common/Error.cs b/src/Common/Error.cs index 348acc57..7613afbd 100644 --- a/src/Common/Error.cs +++ b/src/Common/Error.cs @@ -59,6 +59,15 @@ public Error Wrap(ErrorCode code, string message) } #endif + /// + /// Whether this error is of the specified + /// and optional + /// + public bool Is(ErrorCode code, string? message = null) + { + return code == Code && (message == null || message == Message); + } + /// /// Creates a error /// @@ -144,6 +153,14 @@ public static Error Unexpected(string? message = null) return new Error(ErrorCode.Unexpected, message); } + /// + /// Creates a error + /// + public static Error EntityDeleted(string? message = null) + { + return new Error(ErrorCode.EntityDeleted, message); + } + public override string ToString() { return $"{Code}: {Message}"; @@ -167,5 +184,6 @@ public enum ErrorCode NotAuthenticated, ForbiddenAccess, NotSubscribed, - Unexpected + Unexpected, + EntityDeleted } \ No newline at end of file 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/EndUsers/MembershipRoleUnassigned.cs b/src/Domain.Events.Shared/EndUsers/MembershipRoleUnassigned.cs new file mode 100644 index 00000000..fc2eced2 --- /dev/null +++ b/src/Domain.Events.Shared/EndUsers/MembershipRoleUnassigned.cs @@ -0,0 +1,23 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.EndUsers; + +public sealed class MembershipRoleUnassigned : DomainEvent +{ + public MembershipRoleUnassigned(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public MembershipRoleUnassigned() + { + } + + public required string MembershipId { get; set; } + + public required string OrganizationId { get; set; } + + public required string Role { 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/Domain.Events.Shared/Organizations/RoleAssigned.cs b/src/Domain.Events.Shared/Organizations/RoleAssigned.cs new file mode 100644 index 00000000..7c24b638 --- /dev/null +++ b/src/Domain.Events.Shared/Organizations/RoleAssigned.cs @@ -0,0 +1,23 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.Organizations; + +public sealed class RoleAssigned : DomainEvent +{ + public RoleAssigned(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public RoleAssigned() + { + } + + public required string AssignedById { get; set; } + + public required string Role { get; set; } + + public required string UserId { get; set; } +} \ No newline at end of file diff --git a/src/Domain.Events.Shared/Organizations/RoleUnassigned.cs b/src/Domain.Events.Shared/Organizations/RoleUnassigned.cs new file mode 100644 index 00000000..63fc76e6 --- /dev/null +++ b/src/Domain.Events.Shared/Organizations/RoleUnassigned.cs @@ -0,0 +1,23 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.Organizations; + +public sealed class RoleUnassigned : DomainEvent +{ + public RoleUnassigned(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public RoleUnassigned() + { + } + + public required string Role { get; set; } + + public required string UnassignedById { get; set; } + + public required string UserId { get; set; } +} \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/FeaturesSpec.cs b/src/Domain.Shared.UnitTests/FeaturesSpec.cs index 0c2021a9..4c4d2622 100644 --- a/src/Domain.Shared.UnitTests/FeaturesSpec.cs +++ b/src/Domain.Shared.UnitTests/FeaturesSpec.cs @@ -37,7 +37,7 @@ public void WhenCreateWithAnUnknownName_ThenReturnsValue() [Fact] public void WhenCreateWithAKnownName_ThenReturnsValue() { - var result = Feature.Create(PlatformFeatures.Basic.Name); + var result = Feature.Create(PlatformFeatures.Basic); result.Should().BeSuccess(); result.Value.Identifier.Should().Be(PlatformFeatures.Basic.Name); @@ -50,7 +50,7 @@ public class FeaturesSpec [Fact] public void WhenCreate_ThenReturnsError() { - var result = Features.Create(); + var result = Features.Empty; result.Items.Should().BeEmpty(); } @@ -66,10 +66,10 @@ public void WhenCreateWithSingleEmpty_ThenReturnsError() [Fact] public void WhenCreateWithSingle_ThenReturnsValue() { - var result = Features.Create(PlatformFeatures.Basic.Name); + var result = Features.Create(PlatformFeatures.Basic); result.Should().BeSuccess(); - result.Value.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value); + result.Value.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic).Value); } #if TESTINGONLY @@ -80,8 +80,8 @@ public void WhenCreateWithFeatureLevel_ThenReturnsValue() result.Should().BeSuccess(); result.Value.Items.Should().ContainInOrder(Feature.Create( - PlatformFeatures.TestingOnlySuperUser.Name).Value, - Feature.Create(PlatformFeatures.TestingOnly.Name).Value); + PlatformFeatures.TestingOnlySuperUser).Value, + Feature.Create(PlatformFeatures.TestingOnly).Value); } #endif @@ -97,18 +97,18 @@ public void WhenCreateWithListContainingInvalidItem_ThenReturnsError() [Fact] public void WhenCreateWithListContainingValidItems_ThenReturnsValue() { - var result = Features.Create(PlatformFeatures.Basic.Name, PlatformFeatures.TestingOnly.Name); + var result = Features.Create(PlatformFeatures.Basic, PlatformFeatures.TestingOnly); result.Should().BeSuccess(); - result.Value.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value, - Feature.Create(PlatformFeatures.TestingOnly.Name).Value); + result.Value.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic).Value, + Feature.Create(PlatformFeatures.TestingOnly).Value); } #endif [Fact] public void WhenAddStringWithUnknownName_ThenAddsFeature() { - var features = Features.Create(); + var features = Features.Empty; var result = features.Add("anunknownfeature"); @@ -119,7 +119,7 @@ public void WhenAddStringWithUnknownName_ThenAddsFeature() [Fact] public void WhenAddStringWithKnownName_ThenAddsFeature() { - var features = Features.Create(); + var features = Features.Empty; var result = features.Add(PlatformFeatures.Basic.Name); @@ -137,16 +137,16 @@ public void WhenAddFeatureLevel_ThenReturnsValue() result.Should().BeSuccess(); result.Value.Items.Should().ContainInOrder( - Feature.Create(PlatformFeatures.Basic.Name).Value, - Feature.Create(PlatformFeatures.TestingOnlySuperUser.Name).Value, - Feature.Create(PlatformFeatures.TestingOnly.Name).Value); + Feature.Create(PlatformFeatures.Basic).Value, + Feature.Create(PlatformFeatures.TestingOnlySuperUser).Value, + Feature.Create(PlatformFeatures.TestingOnly).Value); } #endif [Fact] public void WhenAddFeatureAndExists_ThenDoesNotAdd() { - var features = Features.Create(); + var features = Features.Empty; features.Add(PlatformFeatures.Basic.Name); var result = features.Add(PlatformFeatures.Basic.Name); @@ -159,22 +159,22 @@ public void WhenAddFeatureAndExists_ThenDoesNotAdd() [Fact] public void WhenAddFeatureAndNotExists_ThenAdds() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; - var result = features.Add(PlatformFeatures.TestingOnly.Name); + var result = features.Add(PlatformFeatures.TestingOnly); result.Should().BeSuccess(); - result.Value.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value, - Feature.Create(PlatformFeatures.TestingOnly.Name).Value); + result.Value.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic).Value, + Feature.Create(PlatformFeatures.TestingOnly).Value); } #endif [Fact] public void WhenClear_ThenRemovesAllItems() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; var result = features.Clear(); @@ -184,8 +184,8 @@ public void WhenClear_ThenRemovesAllItems() [Fact] public void WhenHasAnyAndSome_ThenReturnsTrue() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; var result = features.HasAny(); @@ -195,7 +195,7 @@ public void WhenHasAnyAndSome_ThenReturnsTrue() [Fact] public void WhenHasAnyAndNone_ThenReturnsFalse() { - var features = Features.Create(); + var features = Features.Empty; var result = features.HasAny(); @@ -205,8 +205,8 @@ public void WhenHasAnyAndNone_ThenReturnsFalse() [Fact] public void WhenHasNoneAndSome_ThenReturnsFalse() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; var result = features.HasNone(); @@ -216,7 +216,7 @@ public void WhenHasNoneAndSome_ThenReturnsFalse() [Fact] public void WhenHasNoneAndNone_ThenReturnsTrue() { - var features = Features.Create(); + var features = Features.Empty; var result = features.HasNone(); @@ -226,7 +226,7 @@ public void WhenHasNoneAndNone_ThenReturnsTrue() [Fact] public void WhenHasLevelAndInvalidName_ThenReturnsFalse() { - var features = Features.Create(); + var features = Features.Empty; var result = features.HasFeature("anunknownfeature"); @@ -237,10 +237,10 @@ public void WhenHasLevelAndInvalidName_ThenReturnsFalse() [Fact] public void WhenHasHasLevelAndNoMatch_ThenReturnsFalse() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; - var result = features.HasFeature(PlatformFeatures.TestingOnly.Name); + var result = features.HasFeature(PlatformFeatures.TestingOnly); result.Should().BeFalse(); } @@ -249,10 +249,10 @@ public void WhenHasHasLevelAndNoMatch_ThenReturnsFalse() [Fact] public void WhenHasHasLevelAndMatching_ThenReturnsTrue() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; - var result = features.HasFeature(PlatformFeatures.Basic.Name); + var result = features.HasFeature(PlatformFeatures.Basic); result.Should().BeTrue(); } @@ -260,8 +260,8 @@ public void WhenHasHasLevelAndMatching_ThenReturnsTrue() [Fact] public void WhenRemoveAndInvalidName_ThenDoesNotRemove() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; var result = features.Remove("anunknownfeature"); @@ -272,8 +272,8 @@ public void WhenRemoveAndInvalidName_ThenDoesNotRemove() [Fact] public void WhenRemoveAndNoMatch_ThenDoesNotRemove() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; var result = features.Remove(PlatformFeatures.TestingOnly.Name); @@ -284,10 +284,10 @@ public void WhenRemoveAndNoMatch_ThenDoesNotRemove() [Fact] public void WhenRemoveAndMatches_ThenRemoves() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; - var result = features.Remove(PlatformFeatures.Basic.Name); + var result = features.Remove(PlatformFeatures.Basic); result.Items.Should().BeEmpty(); } @@ -296,9 +296,9 @@ public void WhenRemoveAndMatches_ThenRemoves() [Fact] public void WhenToList_ThenReturnsStringList() { - var features = Features.Create(); - features = features.Add(PlatformFeatures.Basic.Name).Value; - features = features.Add(PlatformFeatures.TestingOnly.Name).Value; + var features = Features.Empty; + features = features.Add(PlatformFeatures.Basic).Value; + features = features.Add(PlatformFeatures.TestingOnly).Value; var result = features.ToList(); diff --git a/src/Domain.Shared.UnitTests/RolesSpec.cs b/src/Domain.Shared.UnitTests/RolesSpec.cs index ee766fee..7eea048c 100644 --- a/src/Domain.Shared.UnitTests/RolesSpec.cs +++ b/src/Domain.Shared.UnitTests/RolesSpec.cs @@ -50,7 +50,7 @@ public class RolesSpec [Fact] public void WhenCreate_ThenReturnsError() { - var result = Roles.Create(); + var result = Roles.Empty; result.Items.Should().BeEmpty(); } @@ -108,7 +108,7 @@ public void WhenCreateWithListContainingValidItems_ThenReturnsValue() [Fact] public void WhenAddStringWithUnknownName_ThenAddsRole() { - var roles = Roles.Create(); + var roles = Roles.Empty; var result = roles.Add("anunknownrole"); @@ -119,7 +119,7 @@ public void WhenAddStringWithUnknownName_ThenAddsRole() [Fact] public void WhenAddStringWithKnownName_ThenAddsRole() { - var roles = Roles.Create(); + var roles = Roles.Empty; var result = roles.Add(PlatformRoles.Standard.Name); @@ -145,7 +145,7 @@ public void WhenAddRoleLevel_ThenReturnsValue() [Fact] public void WhenAddRoleAndExists_ThenDoesNotAdd() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles.Add(PlatformRoles.Standard.Name); var result = roles.Add(PlatformRoles.Standard.Name); @@ -158,7 +158,7 @@ public void WhenAddRoleAndExists_ThenDoesNotAdd() [Fact] public void WhenAddRoleAndNotExists_ThenAdds() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; var result = roles.Add(PlatformRoles.TestingOnly.Name); @@ -172,7 +172,7 @@ public void WhenAddRoleAndNotExists_ThenAdds() [Fact] public void WhenClear_ThenRemovesAllItems() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; var result = roles.Clear(); @@ -183,7 +183,7 @@ public void WhenClear_ThenRemovesAllItems() [Fact] public void WhenHasAnyAndSome_ThenReturnsTrue() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; var result = roles.HasAny(); @@ -194,7 +194,7 @@ public void WhenHasAnyAndSome_ThenReturnsTrue() [Fact] public void WhenHasAnyAndNone_ThenReturnsFalse() { - var roles = Roles.Create(); + var roles = Roles.Empty; var result = roles.HasAny(); @@ -204,7 +204,7 @@ public void WhenHasAnyAndNone_ThenReturnsFalse() [Fact] public void WhenHasNoneAndSome_ThenReturnsFalse() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; var result = roles.HasNone(); @@ -215,7 +215,7 @@ public void WhenHasNoneAndSome_ThenReturnsFalse() [Fact] public void WhenHasNoneAndNone_ThenReturnsTrue() { - var roles = Roles.Create(); + var roles = Roles.Empty; var result = roles.HasNone(); @@ -225,7 +225,7 @@ public void WhenHasNoneAndNone_ThenReturnsTrue() [Fact] public void WhenHasRoleAndInvalidName_ThenReturnsFalse() { - var roles = Roles.Create(); + var roles = Roles.Empty; var result = roles.HasRole("anunknownrole"); @@ -236,7 +236,7 @@ public void WhenHasRoleAndInvalidName_ThenReturnsFalse() [Fact] public void WhenHasRoleAndNoMatch_ThenReturnsFalse() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; var result = roles.HasRole(PlatformRoles.TestingOnly.Name); @@ -248,7 +248,7 @@ public void WhenHasRoleAndNoMatch_ThenReturnsFalse() [Fact] public void WhenHasRoleAndMatching_ThenReturnsTrue() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; var result = roles.HasRole(PlatformRoles.Standard.Name); @@ -259,7 +259,7 @@ public void WhenHasRoleAndMatching_ThenReturnsTrue() [Fact] public void WhenRemoveAndInvalidName_ThenDoesNotRemove() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; var result = roles.Remove("anunknownrole"); @@ -271,7 +271,7 @@ public void WhenRemoveAndInvalidName_ThenDoesNotRemove() [Fact] public void WhenRemoveAndNoMatch_ThenDoesNotRemove() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; var result = roles.Remove(PlatformRoles.TestingOnly.Name); @@ -283,7 +283,7 @@ public void WhenRemoveAndNoMatch_ThenDoesNotRemove() [Fact] public void WhenRemoveAndMatches_ThenRemoves() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; var result = roles.Remove(PlatformRoles.Standard.Name); @@ -295,7 +295,7 @@ public void WhenRemoveAndMatches_ThenRemoves() [Fact] public void WhenToList_ThenReturnsStringList() { - var roles = Roles.Create(); + var roles = Roles.Empty; roles = roles.Add(PlatformRoles.Standard.Name).Value; roles = roles.Add(PlatformRoles.TestingOnly.Name).Value; diff --git a/src/Domain.Shared/Features.cs b/src/Domain.Shared/Features.cs index f81a76b8..f0536c68 100644 --- a/src/Domain.Shared/Features.cs +++ b/src/Domain.Shared/Features.cs @@ -50,6 +50,23 @@ public static Result Create(params string[] features) return new Features(list); } + public static Result Create(params FeatureLevel[] features) + { + var list = new List(); + foreach (var feature in features) + { + var feat = Feature.Create(feature); + if (!feat.IsSuccessful) + { + return feat.Error; + } + + list.Add(feat.Value); + } + + return new Features(list); + } + private Features() : base(new List()) { } @@ -182,6 +199,17 @@ public Features Remove(Feature feature) return new Features(Value); } + public Features Remove(FeatureLevel feature) + { + var feat = Feature.Create(feature); + if (!feat.IsSuccessful) + { + return this; + } + + return Remove(feat.Value); + } + [SkipImmutabilityCheck] public List ToList() { diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplication.DomainEventHandlersSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplication.DomainEventHandlersSpec.cs index 69b1ab7e..13428c32 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplication.DomainEventHandlersSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplication.DomainEventHandlersSpec.cs @@ -2,6 +2,7 @@ using Application.Services.Shared; using Common; using Common.Configuration; +using Domain.Common.Events; using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Interfaces.Authorization; @@ -97,4 +98,98 @@ public async Task HandleOrganizationCreatedAsync_ThenAddsMembership() && eu.Memberships[0].Features.HasFeature(TenantFeatures.Basic) ), It.IsAny())); } + + [Fact] + public async Task WhenHandleOrganizationRoleAssignedAsync_ThenAssigns() + { + var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Empty, + EndUserProfile.Create("afirstname").Value, Optional.None); + assigner.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, + Features.Create(TenantFeatures.Basic).Value); + _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) + .ReturnsAsync(assigner); + var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, + EndUserProfile.Create("afirstname").Value, Optional.None); + assignee.AddMembership(assignee, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic).Value); + _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) + .ReturnsAsync(assignee); + var domainEvent = Events.RoleAssigned("anorganizationid".ToId(), "anassignerid".ToId(), "anassigneeid".ToId(), + Role.Create(TenantRoles.TestingOnly).Value); + + var result = + await _application.HandleOrganizationRoleAssignedAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _endUserRepository.Verify(rep => rep.SaveAsync(It.Is(eu => + eu.Memberships[0].Roles.HasRole(TenantRoles.Member) + && eu.Memberships[0].Roles.HasRole(TenantRoles.TestingOnly) + ), It.IsAny())); + _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "anid", + Audits.EndUserApplication_TenantRolesAssigned, It.IsAny(), + It.IsAny())); + } + + [Fact] + public async Task WhenHandleOrganizationRoleUnassignedAsync_ThenAssigns() + { + var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Empty, + EndUserProfile.Create("afirstname").Value, Optional.None); + assigner.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, + Features.Create(TenantFeatures.Basic).Value); + _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) + .ReturnsAsync(assigner); + var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, + EndUserProfile.Create("afirstname").Value, Optional.None); + assignee.AddMembership(assignee, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic).Value); + assignee.AssignMembershipRoles(assigner, "anorganizationid".ToId(), + Roles.Create(TenantRoles.TestingOnly).Value); + _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) + .ReturnsAsync(assignee); + var domainEvent = Events.RoleUnassigned("anorganizationid".ToId(), "anassignerid".ToId(), "anassigneeid".ToId(), + Role.Create(TenantRoles.TestingOnly).Value); + + var result = + await _application.HandleOrganizationRoleUnassignedAsync(_caller.Object, domainEvent, + CancellationToken.None); + + result.Should().BeSuccess(); + _endUserRepository.Verify(rep => rep.SaveAsync(It.Is(eu => + eu.Memberships[0].Roles.HasRole(TenantRoles.Member) + && !eu.Memberships[0].Roles.HasRole(TenantRoles.TestingOnly) + ), It.IsAny())); + _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "anid", + Audits.EndUserApplication_TenantRolesUnassigned, It.IsAny(), + It.IsAny())); + } + + [Fact] + public async Task WhenHandleOrganizationDeletedAsync_ThenRemovesMembership() + { + var deleter = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + deleter.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(TenantFeatures.Basic).Value, + EndUserProfile.Create("afirstname").Value, Optional.None); + deleter.AddMembership(deleter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Create(TenantFeatures.Basic).Value); + _endUserRepository.Setup(rep => rep.LoadAsync("adeleterid".ToId(), It.IsAny())) + .ReturnsAsync(deleter); + var domainEvent = Global.StreamDeleted.Create("anorganizationid".ToId(), "adeleterid".ToId()); + + var result = + await _application.HandleOrganizationDeletedAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _endUserRepository.Verify(rep => rep.SaveAsync(It.Is(eu => + eu.Memberships.Count == 0 + ), It.IsAny())); + } } \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index 3be6979b..7f751c40 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -666,7 +666,7 @@ public async Task WhenAssignPlatformRolesAsync_ThenAssigns() _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) .ReturnsAsync(assignee); var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), + assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Empty, EndUserProfile.Create("afirstname").Value, Optional.None); _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) .ReturnsAsync(assigner); @@ -693,7 +693,7 @@ public async Task WhenUnassignPlatformRolesAsync_ThenUnassigns() _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) .ReturnsAsync(assignee); var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), + assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Empty, EndUserProfile.Create("afirstname").Value, Optional.None); _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) .ReturnsAsync(assigner); @@ -707,40 +707,6 @@ public async Task WhenUnassignPlatformRolesAsync_ThenUnassigns() } #endif -#if TESTINGONLY - [Fact] - public async Task WhenAssignTenantRolesAsync_ThenAssigns() - { - _caller.Setup(cc => cc.CallerId) - .Returns("anassignerid"); - var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), - EndUserProfile.Create("afirstname").Value, Optional.None); - assigner.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), - Roles.Create(TenantRoles.Owner).Value, - Features.Create(TenantFeatures.Basic).Value); - _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) - .ReturnsAsync(assigner); - var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, - EndUserProfile.Create("afirstname").Value, Optional.None); - assignee.AddMembership(assignee, OrganizationOwnership.Shared, "anorganizationid".ToId(), - Roles.Create(TenantRoles.Member).Value, - Features.Create(TenantFeatures.Basic).Value); - _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) - .ReturnsAsync(assignee); - - var result = await _application.AssignTenantRolesAsync(_caller.Object, "anorganizationid", "anassigneeid", - [TenantRoles.TestingOnly.Name], - CancellationToken.None); - - result.Should().BeSuccess(); - result.Value.Roles.Should().ContainInOrder(PlatformRoles.Standard.Name); - result.Value.Memberships[0].Roles.Should() - .ContainInOrder(TenantRoles.Member.Name, TenantRoles.TestingOnly.Name); - } -#endif - [Fact] public async Task WhenFindPersonByEmailAsyncAndNotExists_ThenReturnsNone() { @@ -826,4 +792,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.Empty, + 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..8c8d0839 100644 --- a/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs +++ b/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs @@ -1,9 +1,15 @@ +using Application.Common.Extensions; using Application.Interfaces; using Application.Resources.Shared; using Common; using Common.Extensions; +using Domain.Common.Events; using Domain.Common.ValueObjects; using Domain.Events.Shared.Organizations; +using Domain.Shared; +using Domain.Shared.EndUsers; +using EndUsersDomain; +using Membership = Application.Resources.Shared.Membership; namespace EndUsersApplication; @@ -22,4 +28,220 @@ public async Task> HandleOrganizationCreatedAsync(ICallerContext c return Result.Ok; } + + public async Task> HandleOrganizationDeletedAsync(ICallerContext caller, + Global.StreamDeleted domainEvent, + CancellationToken cancellationToken) + { + var deleted = await RemoveMembershipFromDeletedOrganizationAsync(caller, domainEvent.RootId.ToId(), + domainEvent.DeletedById.ToId(), cancellationToken); + if (!deleted.IsSuccessful) + { + return deleted.Error; + } + + return Result.Ok; + } + + public async Task> HandleOrganizationRoleAssignedAsync(ICallerContext caller, + RoleAssigned domainEvent, + CancellationToken cancellationToken) + { + var assigned = await AssignTenantRolesAsync(caller, domainEvent.AssignedById.ToId(), domainEvent.RootId.ToId(), + domainEvent.UserId.ToId(), [domainEvent.Role], cancellationToken); + if (!assigned.IsSuccessful) + { + return assigned.Error; + } + + return Result.Ok; + } + + public async Task> HandleOrganizationRoleUnassignedAsync(ICallerContext caller, + RoleUnassigned domainEvent, + CancellationToken cancellationToken) + { + var assigned = await UnassignTenantRolesAsync(caller, domainEvent.UnassignedById.ToId(), + domainEvent.RootId.ToId(), domainEvent.UserId.ToId(), + [domainEvent.Role], cancellationToken); + if (!assigned.IsSuccessful) + { + return assigned.Error; + } + + return Result.Ok; + } + + private async Task> RemoveMembershipFromDeletedOrganizationAsync(ICallerContext caller, + Identifier organizationId, Identifier deletedById, CancellationToken cancellationToken) + { + var retrievedDeleter = await _endUserRepository.LoadAsync(deletedById, cancellationToken); + if (!retrievedDeleter.IsSuccessful) + { + return retrievedDeleter.Error; + } + + var deleter = retrievedDeleter.Value; + var removed = deleter.RemoveMembership(deleter, organizationId); + if (!removed.IsSuccessful) + { + return removed.Error; + } + + var saved = await _endUserRepository.SaveAsync(deleter, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + deleter = saved.Value; + _recorder.TraceInformation(caller.ToCall(), + "EndUser {Id} has been removed from the deleted organization {Organization}", + deleter.Id, organizationId); + + return Result.Ok; + } + + private async Task> CreateMembershipAsync(ICallerContext caller, + 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, caller.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; + } + + inviter = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "EndUser {Id} has become a member of organization {Organization}", + inviter.Id, organizationId); + + var membership = inviter.FindMembership(organizationId); + if (!membership.HasValue) + { + return Error.EntityNotFound(Resources.EndUsersApplication_MembershipNotFound); + } + + return membership.Value.ToMembership(); + } + + private async Task> AssignTenantRolesAsync(ICallerContext caller, Identifier assignerId, + Identifier organizationId, Identifier assigneeId, List roles, CancellationToken cancellationToken) + { + var retrievedAssigner = await _endUserRepository.LoadAsync(assignerId, cancellationToken); + if (!retrievedAssigner.IsSuccessful) + { + return retrievedAssigner.Error; + } + + var retrievedAssignee = await _endUserRepository.LoadAsync(assigneeId, cancellationToken); + if (!retrievedAssignee.IsSuccessful) + { + return retrievedAssignee.Error; + } + + var assigner = retrievedAssigner.Value; + var assignee = retrievedAssignee.Value; + var assigneeRoles = Roles.Create(roles.ToArray()); + if (!assigneeRoles.IsSuccessful) + { + return assigneeRoles.Error; + } + + var assigned = assignee.AssignMembershipRoles(assigner, organizationId, assigneeRoles.Value); + if (!assigned.IsSuccessful) + { + return assigned.Error; + } + + var membership = assigned.Value; + var saved = await _endUserRepository.SaveAsync(assignee, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + assignee = saved.Value; + _recorder.TraceInformation(caller.ToCall(), + "EndUser {Id} has been assigned tenant roles {Roles} to membership {Membership}", + assignee.Id, roles.JoinAsOredChoices(), membership.Id); + _recorder.AuditAgainst(caller.ToCall(), assignee.Id, + Audits.EndUserApplication_TenantRolesAssigned, + "EndUser {AssignerId} assigned the tenant roles {Roles} to assignee {AssigneeId} for membership {Membership}", + assigner.Id, roles.JoinAsOredChoices(), assignee.Id, membership.Id); + + return Result.Ok; + } + + private async Task> UnassignTenantRolesAsync(ICallerContext caller, Identifier unassignerId, + Identifier organizationId, Identifier assigneeId, List roles, CancellationToken cancellationToken) + { + var retrievedAssigner = await _endUserRepository.LoadAsync(unassignerId, cancellationToken); + if (!retrievedAssigner.IsSuccessful) + { + return retrievedAssigner.Error; + } + + var retrievedAssignee = await _endUserRepository.LoadAsync(assigneeId, cancellationToken); + if (!retrievedAssignee.IsSuccessful) + { + return retrievedAssignee.Error; + } + + var assigner = retrievedAssigner.Value; + var assignee = retrievedAssignee.Value; + var assigneeRoles = Roles.Create(roles.ToArray()); + if (!assigneeRoles.IsSuccessful) + { + return assigneeRoles.Error; + } + + var assigned = assignee.UnassignMembershipRoles(assigner, organizationId, assigneeRoles.Value); + if (!assigned.IsSuccessful) + { + return assigned.Error; + } + + var membership = assigned.Value; + var saved = await _endUserRepository.SaveAsync(assignee, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + assignee = saved.Value; + _recorder.TraceInformation(caller.ToCall(), + "EndUser {Id} has been unassigned tenant roles {Roles} from membership {Membership}", + assignee.Id, roles.JoinAsOredChoices(), membership.Id); + _recorder.AuditAgainst(caller.ToCall(), assignee.Id, + Audits.EndUserApplication_TenantRolesUnassigned, + "EndUser {AssignerId} unassigned the tenant roles {Roles} from assignee {AssigneeId} for membership {Membership}", + assigner.Id, roles.JoinAsOredChoices(), assignee.Id, membership.Id); + + return Result.Ok; + } } \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index 9a5ee4b5..5a5928ea 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,98 +465,34 @@ public async Task> UnassignPlatformRolesAsync(ICallerCont return updated.Value.ToUser(); } - public async Task> AssignTenantRolesAsync(ICallerContext context, - string organizationId, - string id, List roles, CancellationToken cancellationToken) - { - var retrievedAssignee = await _endUserRepository.LoadAsync(id.ToId(), cancellationToken); - if (!retrievedAssignee.IsSuccessful) - { - return retrievedAssignee.Error; - } - - var retrievedAssigner = await _endUserRepository.LoadAsync(context.ToCallerId(), cancellationToken); - if (!retrievedAssigner.IsSuccessful) - { - return retrievedAssigner.Error; - } - - var assignee = retrievedAssignee.Value; - var assigner = retrievedAssigner.Value; - var assigneeRoles = Roles.Create(roles.ToArray()); - if (!assigneeRoles.IsSuccessful) - { - return assigneeRoles.Error; - } - - var assigned = assignee.AssignMembershipRoles(assigner, organizationId.ToId(), assigneeRoles.Value); - if (!assigned.IsSuccessful) - { - return assigned.Error; - } - - var membership = assigned.Value; - var updated = await _endUserRepository.SaveAsync(assignee, cancellationToken); - if (!updated.IsSuccessful) - { - return updated.Error; - } - - _recorder.TraceInformation(context.ToCall(), - "EndUser {Id} has been assigned tenant roles {Roles} to membership {Membership}", - assignee.Id, roles.JoinAsOredChoices(), membership.Id); - _recorder.AuditAgainst(context.ToCall(), assignee.Id, - Audits.EndUserApplication_TenantRolesAssigned, - "EndUser {AssignerId} assigned the tenant roles {Roles} to assignee {AssigneeId} for membership {Membership}", - assigner.Id, roles.JoinAsOredChoices(), assignee.Id, membership.Id); - - return assignee.ToUserWithMemberships(); - } - - private async Task> CreateMembershipAsync(ICallerContext context, - Identifier createdById, Identifier organizationId, OrganizationOwnership ownership, + public async Task> ChangeDefaultMembershipAsync(ICallerContext caller, string organizationId, CancellationToken cancellationToken) { - var retrievedInviter = await _endUserRepository.LoadAsync(createdById, cancellationToken); - if (!retrievedInviter.IsSuccessful) + var userId = caller.ToCallerId(); + var retrieved = await _endUserRepository.LoadAsync(userId, cancellationToken); + if (!retrieved.IsSuccessful) { - return retrievedInviter.Error; + return retrieved.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) + var user = retrieved.Value; + var changed = user.ChangeDefaultMembership(organizationId.ToId()); + if (!changed.IsSuccessful) { - return membered.Error; + return changed.Error; } - var saved = await _endUserRepository.SaveAsync(inviter, cancellationToken); + var saved = await _endUserRepository.SaveAsync(user, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; } - _recorder.TraceInformation(context.ToCall(), "EndUser {Id} has become a member of organization {Organization}", - inviter.Id, organizationId); + user = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Default membership changed for user {Id} to {OrganizationId}", + user.Id, organizationId); - var membership = saved.Value.FindMembership(organizationId); - if (!membership.HasValue) - { - return Error.EntityNotFound(Resources.EndUsersApplication_MembershipNotFound); - } - - return membership.Value.ToMembership(); + return user.ToUser(); } private async Task> WithGetOptionsAsync(ICallerContext caller, @@ -690,6 +645,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 +655,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.DomainEventHandlers.cs b/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs index e1c80b1c..13b8bb1c 100644 --- a/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs +++ b/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs @@ -1,5 +1,6 @@ using Application.Interfaces; using Common; +using Domain.Common.Events; using Domain.Events.Shared.Organizations; namespace EndUsersApplication; @@ -8,4 +9,13 @@ partial interface IEndUsersApplication { Task> HandleOrganizationCreatedAsync(ICallerContext caller, Created domainEvent, CancellationToken cancellationToken); + + Task> HandleOrganizationDeletedAsync(ICallerContext caller, Global.StreamDeleted domainEvent, + CancellationToken cancellationToken); + + Task> HandleOrganizationRoleAssignedAsync(ICallerContext caller, RoleAssigned domainEvent, + CancellationToken cancellationToken); + + Task> HandleOrganizationRoleUnassignedAsync(ICallerContext caller, RoleUnassigned domainEvent, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs index 2bfe90d8..8118e58c 100644 --- a/src/EndUsersApplication/IEndUsersApplication.cs +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -9,9 +9,8 @@ public partial interface IEndUsersApplication Task> AssignPlatformRolesAsync(ICallerContext context, string id, List roles, CancellationToken cancellationToken); - Task> AssignTenantRolesAsync(ICallerContext context, string organizationId, - 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 +20,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..66101bda 100644 --- a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs +++ b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs @@ -26,8 +26,8 @@ private static EndUserRoot CreateOrgOwner(Mock recorder, string organ { var owner = EndUserRoot.Create(recorder.Object, "anownerid".ToIdentifierFactory(), classification) .Value; - owner.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + owner.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("orgowner@company.com").Value); owner.AddMembership(owner, OrganizationOwnership.Shared, organizationId.ToId(), Roles.Create(TenantRoles.Owner).Value, Features.Empty); @@ -40,8 +40,8 @@ private static EndUserRoot CreateOrgMember(Mock recorder, string orga var owner = EndUserRoot .Create(recorder.Object, "amemberid".ToIdentifierFactory(), UserClassification.Person) .Value; - owner.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + owner.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("orgowner@company.com").Value); owner.AddMembership(owner, OrganizationOwnership.Shared, organizationId.ToId(), Roles.Create(TenantRoles.Member).Value, Features.Empty); @@ -53,8 +53,8 @@ private static EndUserRoot CreateOperator(Mock recorder, Mock Task.FromResult(Result.Ok)); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, userProfile, emailAddress); + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, userProfile, emailAddress); _user.Access.Should().Be(UserAccess.Enabled); _user.Status.Should().Be(UserStatus.Registered); _user.Classification.Should().Be(UserClassification.Person); - _user.Roles.Items.Should().ContainInOrder(Role.Create(PlatformRoles.Standard.Name).Value); - _user.Features.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value); + _user.Roles.Items.Should().ContainInOrder(Role.Create(PlatformRoles.Standard).Value); + _user.Features.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic).Value); _user.GuestInvitation.IsAccepted.Should().BeTrue(); _user.GuestInvitation.AcceptedEmailAddress.Should().Be(emailAddress); _user.Events[2].Should().BeOfType(); @@ -125,22 +125,22 @@ await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailA [Fact] public void WhenRegister_ThenRegistered() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.Access.Should().Be(UserAccess.Enabled); _user.Status.Should().Be(UserStatus.Registered); _user.Classification.Should().Be(UserClassification.Person); - _user.Roles.Items.Should().ContainInOrder(Role.Create(PlatformRoles.Standard.Name).Value); - _user.Features.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value); + _user.Roles.Items.Should().ContainInOrder(Role.Create(PlatformRoles.Standard).Value); + _user.Features.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic).Value); _user.Events.Last().Should().BeOfType(); } [Fact] public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultRole_ThenReturnsError() { - _user.Register(Roles.Empty, Features.Create(PlatformFeatures.Basic.Name).Value, + _user.Register(Roles.Empty, Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var result = _user.EnsureInvariants(); @@ -151,7 +151,7 @@ public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultRole_ThenR [Fact] public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultFeature_ThenReturnsError() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Empty, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); @@ -164,8 +164,8 @@ public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultFeature_Th public void WhenEnsureInvariantsAndRegisteredPersonStillInvited_ThenReturnsError() { var emailAddress = EmailAddress.Create("auser@company.com").Value; - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, emailAddress); #if TESTINGONLY _user.TestingOnly_InviteGuest(emailAddress); @@ -180,8 +180,8 @@ public void WhenEnsureInvariantsAndRegisteredPersonStillInvited_ThenReturnsError public void WhenAddMembershipByNonOwner_ThenReturnsError() { var inviter = CreateOrgMember(_recorder, "anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var result = _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), @@ -195,8 +195,8 @@ public void WhenAddMembershipByNonOwner_ThenReturnsError() public void WhenAddMembershipAndAlreadyMember_ThenReturns() { var inviter = CreateOrgOwner(_recorder, "anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), Roles.Empty, Features.Empty); @@ -208,11 +208,27 @@ public void WhenAddMembershipAndAlreadyMember_ThenReturns() result.Should().BeSuccess(); } + [Fact] + public void WhenAddMembershipAndAlreadyPersonalOrg_ThenReturnsError() + { + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).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() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var inviter = CreateOrgOwner(_recorder, "anorganizationid"); @@ -227,14 +243,14 @@ public void WhenAddMembershipToPersonsSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] public void WhenAddMembershipToPersonsPersonalOrganization_ThenReturnsError() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var inviter = CreateOrgOwner(_recorder, "anorganizationid"); @@ -242,14 +258,14 @@ 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] public void WhenAddMembershipToMachinesPersonalOrganization_ThenReturnsError() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var inviter = CreateOrgOwner(_recorder, "anorganizationid", UserClassification.Machine); var roles = Roles.Create(TenantRoles.Member).Value; @@ -259,14 +275,14 @@ public void WhenAddMembershipToMachinesPersonalOrganization_ThenReturnsError() roles, features); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + Resources.EndUserRoot_AddMembership_SharedOwnershipRequired); } [Fact] public void WhenAddMembershipToMachinesSharedOrganization_ThenAddsMembership() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var inviter = CreateOrgOwner(_recorder, "anorganizationid", UserClassification.Machine); var roles = Roles.Create(TenantRoles.Member).Value; @@ -281,14 +297,14 @@ public void WhenAddMembershipToMachinesSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] public void WhenAddMembershipToSelfPersonalOrganization_ThenAddsMembership() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var roles = Roles.Create(TenantRoles.Member).Value; var features = Features.Create(TenantFeatures.Basic).Value; @@ -302,14 +318,14 @@ public void WhenAddMembershipToSelfPersonalOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] public void WhenAddMembershipToSelfSharedOrganization_ThenAddsMembership() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var roles = Roles.Create(TenantRoles.Member).Value; var features = Features.Create(TenantFeatures.Basic).Value; @@ -323,15 +339,15 @@ public void WhenAddMembershipToSelfSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] public void WhenAddMembership_ThenAddsMembershipAsDefaultWithRolesAndFeatures() { var inviter = CreateOrgOwner(_recorder, "anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var roles = Roles.Create(TenantRoles.Member).Value; var features = Features.Create(TenantFeatures.Basic).Value; @@ -345,15 +361,15 @@ public void WhenAddMembership_ThenAddsMembershipAsDefaultWithRolesAndFeatures() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] public void WhenAddMembershipAndAlreadyHasMembership_ThenChangesToDefaultMembership() { var inviter = CreateOrgOwner(_recorder, "anorganizationid2"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var roles = Roles.Create(TenantRoles.Member).Value; var features = Features.Create(TenantFeatures.Basic).Value; @@ -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 @@ -408,8 +424,8 @@ public void WhenAssignMembershipFeaturesAndNoMembership_ThenReturnsError() public void WhenAssignMembershipFeaturesAndFeatureNotAssignable_ThenReturnsError() { var assigner = CreateOrgOwner(_recorder, "anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, @@ -422,13 +438,37 @@ public void WhenAssignMembershipFeaturesAndFeatureNotAssignable_ThenReturnsError Resources.EndUserRoot_UnassignableTenantFeature.Format("anunknownfeature")); } +#if TESTINGONLY + [Fact] + public void WhenAssignMembershipFeaturesAndHasFeature_ThenDoesNothing() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic, TenantFeatures.TestingOnly).Value); + + var result = _user.AssignMembershipFeatures(assigner, "anorganizationid".ToId(), + Features.Create(TenantFeatures.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Memberships[0].Roles.Should() + .Be(Roles.Create(TenantRoles.Member).Value); + _user.Memberships[0].Features.Should() + .Be(Features.Create(TenantFeatures.Basic, TenantFeatures.TestingOnly).Value); + _user.Events.Should().NotContainItemsAssignableTo(); + } +#endif + #if TESTINGONLY [Fact] public void WhenAssignMembershipFeatures_ThenAssigns() { var assigner = CreateOrgOwner(_recorder, "anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, @@ -438,9 +478,9 @@ public void WhenAssignMembershipFeatures_ThenAssigns() Features.Create(TenantFeatures.TestingOnly).Value); result.Should().BeSuccess(); - _user.Memberships[0].Roles.Should().Be(Roles.Create(TenantRoles.Member.Name).Value); + _user.Memberships[0].Roles.Should().Be(Roles.Create(TenantRoles.Member).Value); _user.Memberships[0].Features.Should() - .Be(Features.Create(TenantFeatures.Basic.Name, TenantFeatures.TestingOnly.Name).Value); + .Be(Features.Create(TenantFeatures.Basic, TenantFeatures.TestingOnly).Value); _user.Events.Last().Should().BeOfType(); } #endif @@ -463,8 +503,8 @@ public void WhenAssignMembershipRolesAndAssignerNotOwner_ThenReturnsError() public void WhenAssignMembershipRolesAndRoleNotAssignable_ThenReturnsError() { var assigner = CreateOrgOwner(_recorder, "anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, @@ -474,16 +514,40 @@ public void WhenAssignMembershipRolesAndRoleNotAssignable_ThenReturnsError() Roles.Create("anunknownrole").Value); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_UnassignableTenantRole.Format("anunknownrole")); + Resources.EndUserRoot_NotAssignableTenantRole.Format("anunknownrole")); } +#if TESTINGONLY + [Fact] + public void WhenAssignMembershipRolesAndAlreadyHasRole_ThenDoesNothing() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member, TenantRoles.TestingOnly).Value, + Features.Create(TenantFeatures.Basic).Value); + + var result = _user.AssignMembershipRoles(assigner, "anorganizationid".ToId(), + Roles.Create(TenantRoles.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Memberships[0].Roles.Should() + .Be(Roles.Create(TenantRoles.Member, TenantRoles.TestingOnly).Value); + _user.Memberships[0].Features.Should() + .Be(Features.Create(TenantFeatures.Basic).Value); + _user.Events.Should().NotContainItemsAssignableTo(); + } +#endif + #if TESTINGONLY [Fact] public void WhenAssignMembershipRoles_ThenAssigns() { var assigner = CreateOrgOwner(_recorder, "anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, @@ -494,13 +558,93 @@ public void WhenAssignMembershipRoles_ThenAssigns() result.Should().BeSuccess(); _user.Memberships[0].Roles.Should() - .Be(Roles.Create(TenantRoles.Member.Name, TenantRoles.TestingOnly.Name).Value); + .Be(Roles.Create(TenantRoles.Member, TenantRoles.TestingOnly).Value); _user.Memberships[0].Features.Should() - .Be(Features.Create(TenantFeatures.Basic.Name).Value); + .Be(Features.Create(TenantFeatures.Basic).Value); _user.Events.Last().Should().BeOfType(); } #endif +#if TESTINGONLY + [Fact] + public void WhenUnassignMembershipRolesAndAssignerNotOwner_ThenReturnsError() + { + var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) + .Value; + + var result = _user.UnassignMembershipRoles(assigner, "anorganizationid".ToId(), + Roles.Create(TenantRoles.TestingOnly).Value); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.EndUserRoot_NotOrganizationOwner); + } +#endif + + [Fact] + public void WhenUnassignMembershipRolesAndRoleNotAssignable_ThenReturnsError() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic).Value); + + var result = _user.UnassignMembershipRoles(assigner, "anorganizationid".ToId(), + Roles.Create("anunknownrole").Value); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_NotAssignableTenantRole.Format("anunknownrole")); + } + +#if TESTINGONLY + [Fact] + public void WhenUnassignMembershipRolesAndNotHaveRole_ThenDoesNothing() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic).Value); + + var result = _user.UnassignMembershipRoles(assigner, "anorganizationid".ToId(), + Roles.Create(TenantRoles.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Memberships[0].Roles.Should() + .Be(Roles.Create(TenantRoles.Member).Value); + _user.Memberships[0].Features.Should() + .Be(Features.Create(TenantFeatures.Basic).Value); + _user.Events.Should().NotContainItemsAssignableTo(); + } +#endif + +#if TESTINGONLY + [Fact] + public void WhenUnassignMembershipRoles_ThenUnassigns() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member, TenantRoles.TestingOnly).Value, + Features.Create(TenantFeatures.Basic).Value); + + var result = _user.UnassignMembershipRoles(assigner, "anorganizationid".ToId(), + Roles.Create(TenantRoles.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Memberships[0].Roles.Should() + .Be(Roles.Create(TenantRoles.Member).Value); + _user.Memberships[0].Features.Should() + .Be(Features.Create(TenantFeatures.Basic).Value); + _user.Events.Last().Should().BeOfType(); + } +#endif + #if TESTINGONLY [Fact] public void WhenAssignPlatformFeaturesAndAssignerNotOperator_ThenReturnsError() @@ -522,9 +666,24 @@ public void WhenAssignPlatformFeaturesAndFeatureNotAssignable_ThenReturnsError() var result = _user.AssignPlatformFeatures(assigner, Features.Create("anunknownfeature").Value); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_UnassignablePlatformFeature.Format("anunknownfeature")); + Resources.EndUserRoot_NotAssignablePlatformFeature.Format("anunknownfeature")); } +#if TESTINGONLY + [Fact] + public void WhenAssignPlatformFeaturesAndHasFeature_ThenDoesNothing() + { + var assigner = CreateOperator(_recorder, _identifierFactory); + _user.AssignPlatformFeatures(assigner, Features.Create(PlatformFeatures.TestingOnly).Value); + + var result = _user.AssignPlatformFeatures(assigner, Features.Create(PlatformFeatures.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Roles.HasNone().Should().BeTrue(); + _user.Features.Should().Be(Features.Create(PlatformFeatures.TestingOnly).Value); + } +#endif + #if TESTINGONLY [Fact] public void WhenAssignPlatformFeatures_ThenAssigns() @@ -535,7 +694,7 @@ public void WhenAssignPlatformFeatures_ThenAssigns() result.Should().BeSuccess(); _user.Roles.HasNone().Should().BeTrue(); - _user.Features.Should().Be(Features.Create(PlatformFeatures.TestingOnly.Name).Value); + _user.Features.Should().Be(Features.Create(PlatformFeatures.TestingOnly).Value); _user.Events.Last().Should().BeOfType(); } #endif @@ -561,9 +720,24 @@ public void WhenAssignPlatformRolesAndRoleNotAssignable_ThenReturnsError() var result = _user.AssignPlatformRoles(assigner, Roles.Create("anunknownrole").Value); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_UnassignablePlatformRole.Format("anunknownrole")); + Resources.EndUserRoot_NotAssignablePlatformRole.Format("anunknownrole")); } +#if TESTINGONLY + [Fact] + public void WhenAssignPlatformRolesAndHasRole_ThenDoesNothing() + { + var assigner = CreateOperator(_recorder, _identifierFactory); + _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + + var result = _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Roles.Should().Be(Roles.Create(PlatformRoles.TestingOnly).Value); + _user.Features.HasNone().Should().BeTrue(); + } +#endif + #if TESTINGONLY [Fact] public void WhenAssignPlatformRoles_ThenAssigns() @@ -573,7 +747,7 @@ public void WhenAssignPlatformRoles_ThenAssigns() var result = _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); result.Should().BeSuccess(); - _user.Roles.Should().Be(Roles.Create(PlatformRoles.TestingOnly.Name).Value); + _user.Roles.Should().Be(Roles.Create(PlatformRoles.TestingOnly).Value); _user.Features.HasNone().Should().BeTrue(); _user.Events.Last().Should().BeOfType(); } @@ -600,19 +774,19 @@ public void WhenUnassignPlatformRolesAndRoleNotAssignable_ThenReturnsError() var result = _user.UnassignPlatformRoles(assigner, Roles.Create("anunknownrole").Value); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_UnassignablePlatformRole.Format("anunknownrole")); + Resources.EndUserRoot_NotAssignablePlatformRole.Format("anunknownrole")); } #if TESTINGONLY [Fact] - public void WhenUnassignPlatformRolesAndUserNotAssignedRole_ThenReturnsError() + public void WhenUnassignPlatformRolesAndUserNotAssignedRole_ThenDoesNothing() { var assigner = CreateOperator(_recorder, _identifierFactory); var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); - result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_CannotUnassignUnassignedRole.Format(PlatformRoles.TestingOnly.Name)); + result.Should().BeSuccess(); + _user.Events.Should().NotContainItemsAssignableTo(); } #endif @@ -625,7 +799,7 @@ public void WhenUnassignPlatformRolesAndStandardRole_ThenReturnsError() var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.Standard).Value); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_CannotUnassignBaselinePlatformRole.Format(PlatformRoles.Standard.Name)); + Resources.EndUserRoot_CannotUnassignBaselinePlatformRole.Format(PlatformRoles.Standard)); } #endif @@ -871,6 +1045,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")] @@ -923,8 +1203,8 @@ public void WhenEnsureInvariantsAndMachineIsNotRegistered_ThenReturnsError() [Fact] public void WhenAddMembershipToPersonsSharedOrganization_ThenAddsMembership() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var inviter = CreateOrgOwner(_recorder, "anorganizationid"); @@ -939,14 +1219,14 @@ public void WhenAddMembershipToPersonsSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] public void WhenAddMembershipToPersonsPersonalOrganization_ThenReturnsError() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var inviter = CreateOrgOwner(_recorder, "anorganizationid"); @@ -954,14 +1234,14 @@ 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] public void WhenAddMembershipToMachinesPersonalOrganization_ThenReturnsError() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var inviter = CreateOrgOwner(_recorder, "anorganizationid", UserClassification.Machine); var roles = Roles.Create(TenantRoles.Member).Value; @@ -971,14 +1251,14 @@ public void WhenAddMembershipToMachinesPersonalOrganization_ThenReturnsError() roles, features); result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + Resources.EndUserRoot_AddMembership_SharedOwnershipRequired); } [Fact] public void WhenAddMembershipToMachinesSharedOrganization_ThenAddsMembership() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var inviter = CreateOrgOwner(_recorder, "anorganizationid", UserClassification.Machine); var roles = Roles.Create(TenantRoles.Member).Value; @@ -993,14 +1273,14 @@ public void WhenAddMembershipToMachinesSharedOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] public void WhenAddMembershipToSelfPersonalOrganization_ThenAddsMembership() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var roles = Roles.Create(TenantRoles.Member).Value; var features = Features.Create(TenantFeatures.Basic).Value; @@ -1014,14 +1294,14 @@ public void WhenAddMembershipToSelfPersonalOrganization_ThenAddsMembership() && ms.IsDefault && ms.Roles == roles && ms.Features == features); - _user.Events.Last().Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); } [Fact] public void WhenAddMembershipToSelfSharedOrganization_ThenAddsMembership() { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + _user.Register(Roles.Create(PlatformRoles.Standard).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var roles = Roles.Create(TenantRoles.Member).Value; var features = Features.Create(TenantFeatures.Basic).Value; @@ -1035,7 +1315,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.UnitTests/MembershipSpec.cs b/src/EndUsersDomain.UnitTests/MembershipSpec.cs index a4a5a843..8d6ae150 100644 --- a/src/EndUsersDomain.UnitTests/MembershipSpec.cs +++ b/src/EndUsersDomain.UnitTests/MembershipSpec.cs @@ -41,8 +41,8 @@ public void WhenConstructed_ThenAssigned() [Fact] public void WhenMembershipAddedEventRaised_ThenAssigned() { - var roles = Roles.Create(); - var features = Features.Create(); + var roles = Roles.Empty; + var features = Features.Empty; _membership.As() .RaiseEvent(Events.MembershipAdded("arootid".ToId(), "anorganizationid".ToId(), diff --git a/src/EndUsersDomain/EndUserRoot.cs b/src/EndUsersDomain/EndUserRoot.cs index ec070712..4082bfe7 100644 --- a/src/EndUsersDomain/EndUserRoot.cs +++ b/src/EndUsersDomain/EndUserRoot.cs @@ -42,7 +42,7 @@ private EndUserRoot(IRecorder recorder, IIdentifierFactory idFactory, ISingleVal public Membership DefaultMembership => Memberships.DefaultMembership; - public Features Features { get; private set; } = Features.Create(); + public Features Features { get; private set; } = Features.Empty; public GuestInvitation GuestInvitation { get; private set; } = GuestInvitation.Empty; @@ -54,7 +54,7 @@ private EndUserRoot(IRecorder recorder, IIdentifierFactory idFactory, ISingleVal public Memberships Memberships { get; } = new(); - public Roles Roles { get; private set; } = Roles.Create(); + public Roles Roles { get; private set; } = Roles.Empty; public UserStatus Status { get; private set; } @@ -113,8 +113,8 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco Access = created.Access; Status = created.Status; Classification = created.Classification; - Features = Features.Create(); - Roles = Roles.Create(); + Features = Features.Empty; + Roles = Roles.Empty; return Result.Ok; } @@ -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()) { @@ -194,98 +209,120 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return Result.Ok; } - case MembershipRoleAssigned added: + case MembershipRoleAssigned assigned: { - var membershipId = added.MembershipId.ToId(); + var membershipId = assigned.MembershipId.ToId(); var membership = Memberships.FindByMembershipId(membershipId); if (!membership.HasValue) { return Error.RuleViolation(Resources.EndUserRoot_NoMembership); } - var assigned = RaiseEventToChildEntity(added, membership.Value); - if (!assigned.IsSuccessful) + var forwarded = RaiseEventToChildEntity(assigned, membership.Value); + if (!forwarded.IsSuccessful) { - return assigned.Error; + return forwarded.Error; } - Recorder.TraceDebug(null, "EndUser {Id} added role {Role} to membership {MembershipId}", Id, added.Role, + Recorder.TraceDebug(null, "EndUser {Id} assigned role {Role} to membership {MembershipId}", Id, + assigned.Role, membershipId); return Result.Ok; } - case MembershipFeatureAssigned added: + case MembershipRoleUnassigned unassigned: { - var membershipId = added.MembershipId.ToId(); + var membershipId = unassigned.MembershipId.ToId(); var membership = Memberships.FindByMembershipId(membershipId); if (!membership.HasValue) { return Error.RuleViolation(Resources.EndUserRoot_NoMembership); } - var assigned = RaiseEventToChildEntity(added, membership.Value); - if (!assigned.IsSuccessful) + var forwarded = RaiseEventToChildEntity(unassigned, membership.Value); + if (!forwarded.IsSuccessful) { - return assigned.Error; + return forwarded.Error; } - Recorder.TraceDebug(null, "EndUser {Id} added feature {Role} to membership {MembershipId}", Id, - added.Feature, membershipId); + Recorder.TraceDebug(null, "EndUser {Id} unassigned role {Role} from membership {MembershipId}", Id, + unassigned.Role, + membershipId); return Result.Ok; } - case PlatformRoleAssigned added: + case MembershipFeatureAssigned assigned: { - var roles = Roles.Add(added.Role); + var membershipId = assigned.MembershipId.ToId(); + var membership = Memberships.FindByMembershipId(membershipId); + if (!membership.HasValue) + { + return Error.RuleViolation(Resources.EndUserRoot_NoMembership); + } + + var forwarded = RaiseEventToChildEntity(assigned, membership.Value); + if (!forwarded.IsSuccessful) + { + return forwarded.Error; + } + + Recorder.TraceDebug(null, "EndUser {Id} assigned feature {Role} to membership {MembershipId}", Id, + assigned.Feature, membershipId); + return Result.Ok; + } + + case PlatformRoleAssigned assigned: + { + var roles = Roles.Add(assigned.Role); if (!roles.IsSuccessful) { return roles.Error; } Roles = roles.Value; - Recorder.TraceDebug(null, "EndUser {Id} added role {Role}", Id, added.Role); + Recorder.TraceDebug(null, "EndUser {Id} assigned role {Role}", Id, assigned.Role); return Result.Ok; } - case PlatformRoleUnassigned added: + case PlatformRoleUnassigned unassigned: { - var roles = Roles.Remove(added.Role); + var roles = Roles.Remove(unassigned.Role); Roles = roles; - Recorder.TraceDebug(null, "EndUser {Id} removed role {Role}", Id, added.Role); + Recorder.TraceDebug(null, "EndUser {Id} unassigned role {Role}", Id, unassigned.Role); return Result.Ok; } - case PlatformFeatureAssigned added: + case PlatformFeatureAssigned assigned: { - var features = Features.Add(added.Feature); + var features = Features.Add(assigned.Feature); if (!features.IsSuccessful) { return features.Error; } Features = features.Value; - Recorder.TraceDebug(null, "EndUser {Id} added feature {Feature}", Id, added.Feature); + Recorder.TraceDebug(null, "EndUser {Id} assigned feature {Feature}", Id, assigned.Feature); return Result.Ok; } - case GuestInvitationCreated added: + case GuestInvitationCreated created: { - var inviteeEmailAddress = EmailAddress.Create(added.EmailAddress); + var inviteeEmailAddress = EmailAddress.Create(created.EmailAddress); if (!inviteeEmailAddress.IsSuccessful) { return inviteeEmailAddress.Error; } var invited = GuestInvitation.IsStillOpen - ? GuestInvitation.Renew(added.Token, inviteeEmailAddress.Value) - : GuestInvitation.Invite(added.Token, inviteeEmailAddress.Value, added.InvitedById.ToId()); + ? GuestInvitation.Renew(created.Token, inviteeEmailAddress.Value) + : GuestInvitation.Invite(created.Token, inviteeEmailAddress.Value, created.InvitedById.ToId()); if (!invited.IsSuccessful) { return invited.Error; } GuestInvitation = invited.Value; - Recorder.TraceDebug(null, "EndUser {Id} invited as a guest by {InvitedBy}", Id, added.InvitedById); + Recorder.TraceDebug(null, "EndUser {Id} invited as a guest by {InvitedBy}", Id, created.InvitedById); return Result.Ok; } @@ -342,10 +379,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,12 +408,12 @@ 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)); } public Result AssignMembershipFeatures(EndUserRoot assigner, Identifier organizationId, - Features tenantFeatures) + Features featuresToAssign) { if (!IsOrganizationOwner(assigner, organizationId)) { @@ -383,9 +426,9 @@ public Result AssignMembershipFeatures(EndUserRoot assigner, Identifier o return Error.RuleViolation(Resources.EndUserRoot_NoMembership.Format(organizationId)); } - if (tenantFeatures.HasAny()) + if (featuresToAssign.HasAny()) { - foreach (var feature in tenantFeatures.Items) + foreach (var feature in featuresToAssign.Items) { if (!TenantFeatures.IsTenantAssignableFeature(feature.Identifier)) { @@ -393,6 +436,11 @@ public Result AssignMembershipFeatures(EndUserRoot assigner, Identifier o Resources.EndUserRoot_UnassignableTenantFeature.Format(feature.Identifier)); } + if (membership.Value.Features.HasFeature(feature.Identifier)) + { + return Result.Ok; + } + var addedFeature = RaiseChangeEvent( EndUsersDomain.Events.MembershipFeatureAssigned(Id, organizationId, membership.Value.Id, @@ -408,7 +456,7 @@ public Result AssignMembershipFeatures(EndUserRoot assigner, Identifier o } public Result AssignMembershipRoles(EndUserRoot assigner, Identifier organizationId, - Roles tenantRoles) + Roles rolesToAssign) { if (!IsOrganizationOwner(assigner, organizationId)) { @@ -421,44 +469,56 @@ public Result AssignMembershipRoles(EndUserRoot assigner, Ide return Error.RuleViolation(Resources.EndUserRoot_NoMembership.Format(organizationId)); } - if (tenantRoles.HasAny()) + if (rolesToAssign.HasNone()) { - foreach (var role in tenantRoles.Items) + return membership.Value; + } + + foreach (var role in rolesToAssign.Items) + { + if (!TenantRoles.IsTenantAssignableRole(role.Identifier)) { - if (!TenantRoles.IsTenantAssignableRole(role.Identifier)) - { - return Error.RuleViolation(Resources.EndUserRoot_UnassignableTenantRole.Format(role.Identifier)); - } + return Error.RuleViolation(Resources.EndUserRoot_NotAssignableTenantRole.Format(role.Identifier)); + } - var addedRole = - RaiseChangeEvent( - EndUsersDomain.Events.MembershipRoleAssigned(Id, organizationId, membership.Value.Id, - role)); - if (!addedRole.IsSuccessful) - { - return addedRole.Error; - } + if (membership.Value.Roles.HasRole(role.Identifier)) + { + return membership.Value; + } + + var addedRole = + RaiseChangeEvent( + EndUsersDomain.Events.MembershipRoleAssigned(Id, organizationId, membership.Value.Id, + role)); + if (!addedRole.IsSuccessful) + { + return addedRole.Error; } } return membership.Value; } - public Result AssignPlatformFeatures(EndUserRoot assigner, Features platformFeatures) + public Result AssignPlatformFeatures(EndUserRoot assigner, Features featuresToAssign) { if (!IsPlatformOperator(assigner)) { return Error.RuleViolation(Resources.EndUserRoot_NotOperator); } - if (platformFeatures.HasAny()) + if (featuresToAssign.HasAny()) { - foreach (var feature in platformFeatures.Items) + foreach (var feature in featuresToAssign.Items) { if (!PlatformFeatures.IsPlatformAssignableFeature(feature.Identifier)) { return Error.RuleViolation( - Resources.EndUserRoot_UnassignablePlatformFeature.Format(feature.Identifier)); + Resources.EndUserRoot_NotAssignablePlatformFeature.Format(feature.Identifier)); + } + + if (Features.HasFeature(feature.Identifier)) + { + return Result.Ok; } var addedFeature = @@ -474,20 +534,25 @@ public Result AssignPlatformFeatures(EndUserRoot assigner, Features platf return Result.Ok; } - public Result AssignPlatformRoles(EndUserRoot assigner, Roles platformRoles) + public Result AssignPlatformRoles(EndUserRoot assigner, Roles rolesToAssign) { if (!IsPlatformOperator(assigner)) { return Error.RuleViolation(Resources.EndUserRoot_NotOperator); } - if (platformRoles.HasAny()) + if (rolesToAssign.HasAny()) { - foreach (var role in platformRoles.Items) + foreach (var role in rolesToAssign.Items) { if (!PlatformRoles.IsPlatformAssignableRole(role.Identifier)) { - return Error.RuleViolation(Resources.EndUserRoot_UnassignablePlatformRole.Format(role.Identifier)); + return Error.RuleViolation(Resources.EndUserRoot_NotAssignablePlatformRole.Format(role.Identifier)); + } + + if (Roles.HasRole(role.Identifier)) + { + return Result.Ok; } var addedRole = @@ -503,6 +568,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); @@ -515,7 +599,7 @@ public static (Roles PlatformRoles, Features PlatformFeatures, Roles TenantRoles GetInitialRolesAndFeatures(RolesAndFeaturesUseCase useCase, bool isAuthenticated, EmailAddress? username = null, List? permittedOperators = null) { - var platformRoles = Roles.Create(); + var platformRoles = Roles.Empty; platformRoles = platformRoles.Add(PlatformRoles.Standard).Value; if (username.Exists() && permittedOperators.Exists()) { @@ -527,9 +611,9 @@ public static (Roles PlatformRoles, Features PlatformFeatures, Roles TenantRoles } } - var platformFeatures = Features.Create(); + var platformFeatures = Features.Empty; Roles tenantRoles; - var tenantFeatures = Features.Create(); + var tenantFeatures = Features.Empty; switch (useCase) { case RolesAndFeaturesUseCase.CreatingMachine: @@ -632,6 +716,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() { @@ -646,20 +770,64 @@ public void TestingOnly_InviteGuest(EmailAddress emailAddress) } #endif - public Result UnassignPlatformRoles(EndUserRoot assigner, Roles platformRoles) + public Result UnassignMembershipRoles(EndUserRoot unassigner, Identifier organizationId, + Roles rolesToUnassign) + { + if (!IsOrganizationOwner(unassigner, organizationId)) + { + return Error.RoleViolation(Resources.EndUserRoot_NotOrganizationOwner); + } + + var membership = Memberships.FindByOrganizationId(organizationId); + if (!membership.HasValue) + { + return Error.RuleViolation(Resources.EndUserRoot_NoMembership.Format(organizationId)); + } + + if (rolesToUnassign.HasNone()) + { + return membership.Value; + } + + foreach (var role in rolesToUnassign.Items) + { + if (!TenantRoles.IsTenantAssignableRole(role.Identifier)) + { + return Error.RuleViolation(Resources.EndUserRoot_NotAssignableTenantRole.Format(role.Identifier)); + } + + if (!membership.Value.Roles.HasRole(role.Identifier)) + { + return membership.Value; + } + + var removedRole = + RaiseChangeEvent( + EndUsersDomain.Events.MembershipRoleUnassigned(Id, organizationId, membership.Value.Id, + role)); + if (!removedRole.IsSuccessful) + { + return removedRole.Error; + } + } + + return membership.Value; + } + + public Result UnassignPlatformRoles(EndUserRoot assigner, Roles rolesToUnassign) { if (!IsPlatformOperator(assigner)) { return Error.RuleViolation(Resources.EndUserRoot_NotOperator); } - if (platformRoles.HasAny()) + if (rolesToUnassign.HasAny()) { - foreach (var role in platformRoles.Items) + foreach (var role in rolesToUnassign.Items) { if (!PlatformRoles.IsPlatformAssignableRole(role.Identifier)) { - return Error.RuleViolation(Resources.EndUserRoot_UnassignablePlatformRole.Format(role.Identifier)); + return Error.RuleViolation(Resources.EndUserRoot_NotAssignablePlatformRole.Format(role.Identifier)); } if (role.Identifier == PlatformRoles.Standard.Name) @@ -671,13 +839,10 @@ public Result UnassignPlatformRoles(EndUserRoot assigner, Roles platformR if (!Roles.HasRole(role.Identifier)) { - return Error.RuleViolation( - Resources.EndUserRoot_CannotUnassignUnassignedRole.Format(role.Identifier)); + return Result.Ok; } - var removedRole = - RaiseChangeEvent( - EndUsersDomain.Events.PlatformRoleUnassigned(Id, role)); + var removedRole = RaiseChangeEvent(EndUsersDomain.Events.PlatformRoleUnassigned(Id, role)); if (!removedRole.IsSuccessful) { return removedRole.Error; diff --git a/src/EndUsersDomain/Events.cs b/src/EndUsersDomain/Events.cs index 89c70f0c..cecc95f9 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,9 +80,19 @@ 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) + Identifier membershipId, Role role) { return new MembershipRoleAssigned(id) { @@ -92,6 +102,17 @@ public static MembershipRoleAssigned MembershipRoleAssigned(Identifier id, Ident }; } + public static MembershipRoleUnassigned MembershipRoleUnassigned(Identifier id, Identifier organizationId, + Identifier membershipId, Role role) + { + return new MembershipRoleUnassigned(id) + { + OrganizationId = organizationId, + MembershipId = membershipId, + Role = role.Identifier + }; + } + public static PlatformFeatureAssigned PlatformFeatureAssigned(Identifier id, Feature feature) { return new PlatformFeatureAssigned(id) diff --git a/src/EndUsersDomain/Membership.cs b/src/EndUsersDomain/Membership.cs index 9f7136cd..e9adc439 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) { @@ -83,27 +83,34 @@ protected override Result OnStateChanged(IDomainEvent @event) return Result.Ok; } - case MembershipRoleAssigned added: + case MembershipRoleAssigned assigned: { - var role = Roles.Add(added.Role); - if (!role.IsSuccessful) + var roles = Roles.Add(assigned.Role); + if (!roles.IsSuccessful) { - return role.Error; + return roles.Error; } - Roles = role.Value; + Roles = roles.Value; return Result.Ok; } - case MembershipFeatureAssigned added: + case MembershipRoleUnassigned unassigned: { - var feature = Features.Add(added.Feature); - if (!feature.IsSuccessful) + var roles = Roles.Remove(unassigned.Role); + Roles = roles; + return Result.Ok; + } + + case MembershipFeatureAssigned assigned: + { + var features = Features.Add(assigned.Feature); + if (!features.IsSuccessful) { - return feature.Error; + return features.Error; } - Features = feature.Value; + Features = features.Value; return Result.Ok; } 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..d5e63a01 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); } } @@ -186,65 +195,74 @@ internal static string EndUserRoot_NoMembership { } /// - /// Looks up a localized string similar to The assigner is not a member of the operations team. + /// Looks up a localized string similar to The feature '{0}' is not a supported platform feature. /// - internal static string EndUserRoot_NotOperator { + internal static string EndUserRoot_NotAssignablePlatformFeature { get { - return ResourceManager.GetString("EndUserRoot_NotOperator", resourceCulture); + return ResourceManager.GetString("EndUserRoot_NotAssignablePlatformFeature", resourceCulture); } } /// - /// Looks up a localized string similar to The assigner is not an owner of the organization. + /// Looks up a localized string similar to The role '{0}' is not a supported platform role. /// - internal static string EndUserRoot_NotOrganizationOwner { + internal static string EndUserRoot_NotAssignablePlatformRole { get { - return ResourceManager.GetString("EndUserRoot_NotOrganizationOwner", resourceCulture); + return ResourceManager.GetString("EndUserRoot_NotAssignablePlatformRole", resourceCulture); } } /// - /// Looks up a localized string similar to This user is not yet registered. + /// Looks up a localized string similar to The role '{0}' is not a supported tenant role. /// - internal static string EndUserRoot_NotRegistered { + internal static string EndUserRoot_NotAssignableTenantRole { get { - return ResourceManager.GetString("EndUserRoot_NotRegistered", resourceCulture); + return ResourceManager.GetString("EndUserRoot_NotAssignableTenantRole", resourceCulture); } } /// - /// Looks up a localized string similar to The feature '{0}' is not a supported platform feature. + /// Looks up a localized string similar to The assigner is not a member of the operations team. /// - internal static string EndUserRoot_UnassignablePlatformFeature { + internal static string EndUserRoot_NotOperator { get { - return ResourceManager.GetString("EndUserRoot_UnassignablePlatformFeature", resourceCulture); + return ResourceManager.GetString("EndUserRoot_NotOperator", resourceCulture); } } /// - /// Looks up a localized string similar to The role '{0}' is not a supported platform role. + /// Looks up a localized string similar to The assigner is not an owner of the organization. /// - internal static string EndUserRoot_UnassignablePlatformRole { + internal static string EndUserRoot_NotOrganizationOwner { get { - return ResourceManager.GetString("EndUserRoot_UnassignablePlatformRole", resourceCulture); + return ResourceManager.GetString("EndUserRoot_NotOrganizationOwner", resourceCulture); } } /// - /// Looks up a localized string similar to The feature '{0}' is not a supported tenant feature. + /// Looks up a localized string similar to This user is not yet registered. /// - internal static string EndUserRoot_UnassignableTenantFeature { + internal static string EndUserRoot_NotRegistered { get { - return ResourceManager.GetString("EndUserRoot_UnassignableTenantFeature", resourceCulture); + return ResourceManager.GetString("EndUserRoot_NotRegistered", resourceCulture); } } /// - /// Looks up a localized string similar to The role '{0}' is not a supported tenant role. + /// 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 tenant feature. /// - internal static string EndUserRoot_UnassignableTenantRole { + internal static string EndUserRoot_UnassignableTenantFeature { get { - return ResourceManager.GetString("EndUserRoot_UnassignableTenantRole", resourceCulture); + return ResourceManager.GetString("EndUserRoot_UnassignableTenantFeature", resourceCulture); } } @@ -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..d85c792a 100644 --- a/src/EndUsersDomain/Resources.resx +++ b/src/EndUsersDomain/Resources.resx @@ -36,13 +36,13 @@ The membership does not exist - + The role '{0}' is not a supported tenant role - + The role '{0}' is not a supported platform role - + The feature '{0}' is not a supported platform feature @@ -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/MembershipsApiSpec.cs b/src/EndUsersInfrastructure.IntegrationTests/MembershipsApiSpec.cs new file mode 100644 index 00000000..402107c2 --- /dev/null +++ b/src/EndUsersInfrastructure.IntegrationTests/MembershipsApiSpec.cs @@ -0,0 +1,80 @@ +using System.Net; +using ApiHost1; +using Application.Resources.Shared; +using FluentAssertions; +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; + +namespace EndUsersInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.API")] +[Collection("API")] +public class MembershipsApiSpec : WebApiSpec +{ + public MembershipsApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + } + + [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); + } + + [Fact] + public async Task WhenListMembershipsForCaller_ThenReturnsMemberships() + { + var login = await LoginUserAsync(); + + var result = await Api.PostAsync(new CreateOrganizationRequest + { + 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("aname"); + result.Content.Value.Organization!.Ownership.Should().Be(OrganizationOwnership.Shared); + + login = await ReAuthenticateUserAsync(login.User); + login.User.Profile!.DefaultOrganizationId.Should().Be(organizationId); + + var memberships = await Api.GetAsync(new ListMembershipsForCallerRequest(), + req => req.SetJWTBearerToken(login.AccessToken)); + + memberships.Content.Value.Memberships!.Count.Should().Be(2); + memberships.Content.Value.Memberships![0].OrganizationId.Should().NotBeNull(); + memberships.Content.Value.Memberships![0].Ownership.Should().Be(OrganizationOwnership.Personal); + memberships.Content.Value.Memberships![1].OrganizationId.Should().Be(organizationId); + memberships.Content.Value.Memberships![1].Ownership.Should().Be(OrganizationOwnership.Shared); + } + + private static void OverrideDependencies(IServiceCollection services) + { + // Override dependencies here + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure.UnitTests/Api/Memberships/ChangeDefaultOrganizationRequestValidatorSpec.cs b/src/EndUsersInfrastructure.UnitTests/Api/Memberships/ChangeDefaultOrganizationRequestValidatorSpec.cs new file mode 100644 index 00000000..7892419f --- /dev/null +++ b/src/EndUsersInfrastructure.UnitTests/Api/Memberships/ChangeDefaultOrganizationRequestValidatorSpec.cs @@ -0,0 +1,29 @@ +using Domain.Common.Identity; +using EndUsersInfrastructure.Api.Memberships; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.EndUsers; +using Xunit; + +namespace EndUsersInfrastructure.UnitTests.Api.Memberships; + +[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/EndUsersApi.cs b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs index e85a0e42..cff1adef 100644 --- a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs +++ b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs @@ -18,18 +18,18 @@ 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> UnassignPlatformRoles( UnassignPlatformRolesRequest request, CancellationToken cancellationToken) { var user = @@ -37,7 +37,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/ChangeDefaultOrganizationRequestValidator.cs b/src/EndUsersInfrastructure/Api/Memberships/ChangeDefaultOrganizationRequestValidator.cs new file mode 100644 index 00000000..86484842 --- /dev/null +++ b/src/EndUsersInfrastructure/Api/Memberships/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.Memberships; + +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/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..76c5cf0b --- /dev/null +++ b/src/EndUsersInfrastructure/Api/Memberships/MembershipsApi.cs @@ -0,0 +1,39 @@ +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> 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> 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..0e0a34be 100644 --- a/src/EndUsersInfrastructure/Notifications/EndUserNotificationConsumer.cs +++ b/src/EndUsersInfrastructure/Notifications/EndUserNotificationConsumer.cs @@ -1,4 +1,5 @@ using Common; +using Domain.Common.Events; using Domain.Events.Shared.Organizations; using Domain.Interfaces.Entities; using EndUsersApplication; @@ -25,14 +26,29 @@ public async Task> NotifyAsync(IDomainEvent domainEvent, Cancellat { switch (domainEvent) { + case Global.StreamDeleted deleted: + return await _endUsersApplication.HandleOrganizationDeletedAsync(_callerContextFactory.Create(), + deleted, cancellationToken); + case Created created: 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); + + case RoleAssigned assigned: + return await _endUsersApplication.HandleOrganizationRoleAssignedAsync( + _callerContextFactory.Create(), assigned, cancellationToken); + + case RoleUnassigned unassigned: + return await _endUsersApplication.HandleOrganizationRoleUnassignedAsync( + _callerContextFactory.Create(), unassigned, 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..060b6866 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 MembershipDefaultChanged e: + case MembershipRemoved e: + return await _memberships.HandleDeleteAsync(e.MembershipId, cancellationToken); + + 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) @@ -107,8 +113,22 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven dto.Roles = roles.Value; }, cancellationToken); + case MembershipRoleUnassigned e: + return await _memberships.HandleUpdateAsync(e.RootId, dto => + { + var roles = dto.Roles.HasValue + ? dto.Roles.Value.Remove(e.Role) + : new Result(Roles.Empty); + if (!roles.IsSuccessful) + { + return; + } + + dto.Roles = roles.Value; + }, 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 +142,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,11 +156,11 @@ 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) - : Roles.Create(e.Role); + : new Result(Roles.Empty); if (!roles.IsSuccessful) { return; @@ -150,7 +170,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 +184,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 +192,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..1096f169 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,8 @@ public async Task WhenIssueTokensAsync_ThenReturnsTokens() Id = "amembershipid", UserId = "auserid", OrganizationId = "anorganizationid", - Roles = new List { TenantRoles.Member.Name }, - Features = new List { TenantFeatures.Basic.Name } + 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..81597278 100644 --- a/src/IdentityInfrastructure/Persistence/ReadModels/APIKeyProjection.cs +++ b/src/IdentityInfrastructure/Persistence/ReadModels/APIKeyProjection.cs @@ -2,7 +2,6 @@ using Application.Persistence.Interfaces; using Common; using Domain.Common.Events; -using Domain.Common.ValueObjects; using Domain.Events.Shared.Identities.APIKeys; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -30,7 +29,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 +37,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(); @@ -48,7 +47,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven return true; case Global.StreamDeleted e: - return await _authTokens.HandleDeleteAsync(e.RootId.ToId(), cancellationToken); + return await _authTokens.HandleDeleteAsync(e.RootId, cancellationToken); default: return false; 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.Persistence.Common.UnitTests/EventSourcingDddCommandStoreSpec.cs b/src/Infrastructure.Persistence.Common.UnitTests/EventSourcingDddCommandStoreSpec.cs index 7f92dbb1..d45e4112 100644 --- a/src/Infrastructure.Persistence.Common.UnitTests/EventSourcingDddCommandStoreSpec.cs +++ b/src/Infrastructure.Persistence.Common.UnitTests/EventSourcingDddCommandStoreSpec.cs @@ -117,7 +117,7 @@ public async Task WhenLoadAndPreviouslyTombstoned_ThenReturnsError() var result = await _store.LoadAsync("anid".ToId(), CancellationToken.None); - result.Should().BeError(ErrorCode.EntityNotFound, Resources.IEventSourcingDddCommandStore_StreamTombstoned); + result.Should().BeError(ErrorCode.EntityDeleted, Resources.IEventSourcingDddCommandStore_StreamTombstoned); } [Fact] diff --git a/src/Infrastructure.Persistence.Common/EventSourcingDddCommandStore.cs b/src/Infrastructure.Persistence.Common/EventSourcingDddCommandStore.cs index 29fe2105..25efea71 100644 --- a/src/Infrastructure.Persistence.Common/EventSourcingDddCommandStore.cs +++ b/src/Infrastructure.Persistence.Common/EventSourcingDddCommandStore.cs @@ -73,7 +73,7 @@ public async Task> LoadAsync(Identifier id, Cancel if (IsTombstoned(events)) { - return Error.EntityNotFound(Resources.IEventSourcingDddCommandStore_StreamTombstoned); + return Error.EntityDeleted(Resources.IEventSourcingDddCommandStore_StreamTombstoned); } var lastPersistedAtUtc = events.Last().LastPersistedAtUtc; diff --git a/src/Infrastructure.Web.Api.Common/Extensions/ErrorExtensions.cs b/src/Infrastructure.Web.Api.Common/Extensions/ErrorExtensions.cs index 4f7fff9b..33839a47 100644 --- a/src/Infrastructure.Web.Api.Common/Extensions/ErrorExtensions.cs +++ b/src/Infrastructure.Web.Api.Common/Extensions/ErrorExtensions.cs @@ -22,6 +22,7 @@ public static HttpError ToHttpError(this Error error) ErrorCode.NotAuthenticated => HttpErrorCode.Unauthorized, ErrorCode.ForbiddenAccess => HttpErrorCode.Forbidden, ErrorCode.NotSubscribed => HttpErrorCode.PaymentRequired, + ErrorCode.EntityDeleted => HttpErrorCode.MethodNotAllowed, _ => HttpErrorCode.InternalServerError }; 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..6c09e4f7 --- /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("/memberships/me/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/AssignRolesToOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/AssignRolesToOrganizationRequest.cs new file mode 100644 index 00000000..7db44e18 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/AssignRolesToOrganizationRequest.cs @@ -0,0 +1,15 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations/{Id}/roles/assign", OperationMethod.PutPatch, AccessType.Token)] +[Authorize(Interfaces.Roles.Tenant_Owner, Features.Tenant_PaidTrial)] +public class AssignRolesToOrganizationRequest : UnTenantedRequest, + IUnTenantedOrganizationRequest +{ + public List Roles { get; set; } = []; + + 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/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/DeleteOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/DeleteOrganizationRequest.cs new file mode 100644 index 00000000..3f39727a --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/DeleteOrganizationRequest.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations/{Id}", OperationMethod.Delete, AccessType.Token)] +[Authorize(Roles.Tenant_Owner, Features.Tenant_PaidTrial)] +public class DeleteOrganizationRequest : UnTenantedDeleteRequest, IUnTenantedOrganizationRequest +{ + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/InviteMemberToOrganizationRequest.cs 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/Organizations/UnassignRolesFromOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/UnassignRolesFromOrganizationRequest.cs new file mode 100644 index 00000000..1c8383b1 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/UnassignRolesFromOrganizationRequest.cs @@ -0,0 +1,15 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations/{Id}/roles/unassign", OperationMethod.PutPatch, AccessType.Token)] +[Authorize(Interfaces.Roles.Tenant_Owner, Features.Tenant_PaidTrial)] +public class UnassignRolesFromOrganizationRequest : UnTenantedRequest, + IUnTenantedOrganizationRequest +{ + public List Roles { get; set; } = []; + + 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/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..1e7ef7da 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs @@ -257,7 +257,7 @@ await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFac _tenantDetective.Object); context.Response.Should().BeAProblem(HttpStatusCode.BadRequest, - Resources.MultiTenancyMiddleware_MissingTenantId); + Resources.MultiTenancyMiddleware_MissingDefaultOrganization); _tenantDetective.Verify(td => td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); @@ -468,7 +468,7 @@ await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFac _tenantDetective.Object); context.Response.Should().BeAProblem(HttpStatusCode.BadRequest, - Resources.MultiTenancyMiddleware_MissingTenantId); + Resources.MultiTenancyMiddleware_MissingDefaultOrganization); _tenantDetective.Verify(td => td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); @@ -506,7 +506,7 @@ await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFac _tenantDetective.Object); context.Response.Should().BeAProblem(HttpStatusCode.BadRequest, - Resources.MultiTenancyMiddleware_MissingTenantId); + Resources.MultiTenancyMiddleware_MissingDefaultOrganization); _tenantDetective.Verify(td => td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); diff --git a/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs index 62c6396a..b29d87a3 100644 --- a/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs @@ -109,6 +109,11 @@ private static bool MissingRequiredTenantIdFromRequest(TenantDetectionResult det private async Task> VerifyDefaultOrganizationIdForCallerAsync(ICallerContext caller, List? memberships, CancellationToken cancellationToken) { + if (!caller.IsAuthenticated) + { + return Error.Validation(Resources.MultiTenancyMiddleware_MissingDefaultOrganization); + } + if (memberships.NotExists()) { var retrievedMemberships = await GetMembershipsForCallerAsync(caller, cancellationToken); @@ -126,7 +131,7 @@ private static bool MissingRequiredTenantIdFromRequest(TenantDetectionResult det return defaultOrganizationId; } - return Error.Validation(Resources.MultiTenancyMiddleware_MissingTenantId); + return Error.Validation(Resources.MultiTenancyMiddleware_MissingDefaultOrganization); } private async Task> VerifyCallerMembershipAsync(ICallerContext caller, List? memberships, diff --git a/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs b/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs index 25e4e376..8749c9dc 100644 --- a/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs +++ b/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs @@ -231,11 +231,11 @@ internal static string MultiTenancyMiddleware_InvalidTenantId { } /// - /// Looks up a localized string similar to The 'OrganizationId' is missing from this request. + /// Looks up a localized string similar to The ID of the organization is missing from this request. /// - internal static string MultiTenancyMiddleware_MissingTenantId { + internal static string MultiTenancyMiddleware_MissingDefaultOrganization { get { - return ResourceManager.GetString("MultiTenancyMiddleware_MissingTenantId", resourceCulture); + return ResourceManager.GetString("MultiTenancyMiddleware_MissingDefaultOrganization", resourceCulture); } } diff --git a/src/Infrastructure.Web.Hosting.Common/Resources.resx b/src/Infrastructure.Web.Hosting.Common/Resources.resx index 18b07870..955cdf2b 100644 --- a/src/Infrastructure.Web.Hosting.Common/Resources.resx +++ b/src/Infrastructure.Web.Hosting.Common/Resources.resx @@ -90,8 +90,8 @@ The 'OrganizationId' of the request is invalid - - The 'OrganizationId' is missing from this request + + The ID of the organization is missing from this request The authenticated user is not a member of organization '{0}' 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..a1efe851 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,10 +45,10 @@ 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(); @@ -54,8 +57,8 @@ public OrganizationsApplicationDomainEventHandlersSpec() .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, _identifierFactory.Object, + _tenantSettingsService.Object, _tenantSettingService.Object, endUsersService.Object, imagesService.Object, _repository.Object); } @@ -104,4 +107,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..41e9e551 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs @@ -60,8 +60,8 @@ public OrganizationsApplicationSpec() .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, _imagesService.Object, _repository.Object); } @@ -144,7 +144,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 +246,32 @@ public async Task WhenInviteMemberToOrganizationAsyncAndNoUserIdNorEmail_ThenRet } [Fact] - public async Task WhenInviteMemberToOrganizationAsync_ThenInvites() + public async Task WhenInviteMemberToOrganizationAsyncWithUserEmail_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); + + 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())); + } + + [Fact] + public async Task WhenInviteMemberToOrganizationAsyncWithUserId_ThenInvites() { _caller.Setup(cc => cc.Roles) .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); @@ -266,6 +291,7 @@ 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())); } @@ -564,4 +590,204 @@ 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())); + } + + [Fact] + public async Task WhenAssignRolesToOrganizationAsyncAndNotExists_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = + await _application.AssignRolesToOrganizationAsync(_caller.Object, "anorganizationid", "auserid", + new List(), CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenAssignRolesToOrganizationAsync_ThenAssigns() + { + _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; + org.AddMembership("auserid".ToId()); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = + await _application.AssignRolesToOrganizationAsync(_caller.Object, "anorganizationid", "auserid", + new List(), CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(root => + root.Id == "anid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenUnassignRolesFromOrganizationAsyncAndNotExists_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = + await _application.UnassignRolesFromOrganizationAsync(_caller.Object, "anorganizationid", "auserid", + new List(), CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenUnassignRolesFromOrganizationAsync_ThenUnassigns() + { + _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; + org.AddMembership("auserid".ToId()); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = + await _application.UnassignRolesFromOrganizationAsync(_caller.Object, "anorganizationid", "auserid", + new List(), CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(root => + root.Id == "anid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeleteOrganizationAsyncAndNotExist_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = + await _application.DeleteOrganizationAsync(_caller.Object, "anorganizationid", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenDeleteOrganizationAsync_ThenDeletes() + { + _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; + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = + await _application.DeleteOrganizationAsync(_caller.Object, "anorganizationid", CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(root => + root.IsDeleted + ), 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..3e67bb33 100644 --- a/src/OrganizationsApplication/IOrganizationsApplication.cs +++ b/src/OrganizationsApplication/IOrganizationsApplication.cs @@ -7,9 +7,15 @@ namespace OrganizationsApplication; public partial interface IOrganizationsApplication { + Task> AssignRolesToOrganizationAsync(ICallerContext caller, string id, string userId, + List roles, CancellationToken cancellationToken); + 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); @@ -19,6 +25,8 @@ Task> CreateSharedOrganizationAsync(ICallerContext c Task> DeleteAvatarAsync(ICallerContext caller, string id, CancellationToken cancellationToken); + Task> DeleteOrganizationAsync(ICallerContext caller, string? id, CancellationToken cancellationToken); + Task> GetOrganizationAsync(ICallerContext caller, string id, CancellationToken cancellationToken); @@ -35,4 +43,11 @@ Task> InviteMemberToOrganizationAsync(ICallerContext Task, Error>> ListMembersForOrganizationAsync(ICallerContext caller, string? id, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken); + + Task> UnassignRolesFromOrganizationAsync(ICallerContext caller, string id, + string userId, + List roles, 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..c73e2f37 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,94 @@ 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) + { + //Note: this may occur after an organization is deleted, and the owner removed + if (retrieved.Error.Is(ErrorCode.EntityDeleted)) + { + _recorder.TraceInformation(caller.ToCall(), "Already deleted organization {Id}", organizationId); + return Result.Ok; + } + + 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..2f856897 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.cs +++ b/src/OrganizationsApplication/OrganizationsApplication.cs @@ -41,6 +41,49 @@ public OrganizationsApplication(IRecorder recorder, IIdentifierFactory identifie _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) { @@ -96,6 +139,41 @@ public async Task> CreateSharedOrganizationAsync(ICa return created.Value; } + public async Task> DeleteOrganizationAsync(ICallerContext caller, string? id, + CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var deleterRoles = Roles.Create(caller.Roles.Tenant); + if (!deleterRoles.IsSuccessful) + { + return deleterRoles.Error; + } + + var org = retrieved.Value; + var deleterId = caller.ToCallerId(); + var deleted = org.DeleteOrganization(deleterId, deleterRoles.Value); + if (!deleted.IsSuccessful) + { + return deleted.Error; + } + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + org = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Deleted organization: {Id}", org.Id); + + return Result.Ok; + } + public async Task> GetOrganizationAsync(ICallerContext caller, string id, CancellationToken cancellationToken) { @@ -156,7 +234,7 @@ public async Task> InviteMemberToOrganizationAsync(I return retrieved.Error; } - var organization = retrieved.Value; + var org = retrieved.Value; var inviterRoles = Roles.Create(caller.Roles.Tenant); if (!inviterRoles.IsSuccessful) { @@ -171,51 +249,86 @@ public async Task> InviteMemberToOrganizationAsync(I return email.Error; } - var added = organization.AddMembership(caller.ToCallerId(), inviterRoles.Value, Optional.None, + var invited = org.InviteMember(caller.ToCallerId(), inviterRoles.Value, Optional.None, email.Value); - if (!added.IsSuccessful) + if (!invited.IsSuccessful) { - return added.Error; + return invited.Error; } - var saved = await _repository.SaveAsync(organization, cancellationToken); + var saved = await _repository.SaveAsync(org, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; } - organization = saved.Value; + org = saved.Value; _recorder.TraceInformation(caller.ToCall(), "Organization {Id} has invited {UserEmail} to be a member", - organization.Id, emailAddress); + org.Id, emailAddress); - return organization.ToOrganization(); + return org.ToOrganization(); } if (userId.HasValue()) { - var added = organization.AddMembership(caller.ToCallerId(), inviterRoles.Value, userId.ToId(), + var invited = org.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); + var saved = await _repository.SaveAsync(org, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; } - organization = saved.Value; + org = saved.Value; _recorder.TraceInformation(caller.ToCall(), "Organization {Id} has invited {UserId} to be a member", - organization.Id, userId); + org.Id, userId); - return organization.ToOrganization(); + return org.ToOrganization(); } return Error.RuleViolation(Resources.OrganizationApplication_InvitedNoUserNorEmail); } + 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, Error>> ListMembersForOrganizationAsync( ICallerContext caller, string? id, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken) @@ -226,18 +339,102 @@ public async Task, Error>> ListMembersF return retrieved.Error; } - var organization = retrieved.Value; + var org = retrieved.Value; var memberships = - await _endUsersService.ListMembershipsForOrganizationAsync(caller, organization.Id, searchOptions, + await _endUsersService.ListMembershipsForOrganizationAsync(caller, org.Id, searchOptions, getOptions, cancellationToken); if (!memberships.IsSuccessful) { return memberships.Error; } + _recorder.TraceInformation(caller.ToCall(), "Organization {Id} listed its members", org.Id); + return searchOptions.ApplyWithMetadata(memberships.Value.Results.ConvertAll(x => x.ToMember())); } + public async Task> AssignRolesToOrganizationAsync(ICallerContext caller, string id, + string userId, List roles, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var assignerRoles = Roles.Create(caller.Roles.Tenant); + if (!assignerRoles.IsSuccessful) + { + return assignerRoles.Error; + } + + var rolesToAssign = Roles.Create(roles.ToArray()); + if (!rolesToAssign.IsSuccessful) + { + return rolesToAssign.Error; + } + + var org = retrieved.Value; + var assignerId = caller.ToCallerId(); + var assigned = org.AssignRoles(assignerId, assignerRoles.Value, userId.ToId(), rolesToAssign.Value); + if (!assigned.IsSuccessful) + { + return assigned.Error; + } + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + org = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Organization {Id} assigned roles for {User}", org.Id, userId); + + return org.ToOrganization(); + } + + public async Task> UnassignRolesFromOrganizationAsync(ICallerContext caller, string id, + string userId, List roles, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var assignerRoles = Roles.Create(caller.Roles.Tenant); + if (!assignerRoles.IsSuccessful) + { + return assignerRoles.Error; + } + + var rolesToUnassign = Roles.Create(roles.ToArray()); + if (!rolesToUnassign.IsSuccessful) + { + return rolesToUnassign.Error; + } + + var org = retrieved.Value; + var assignerId = caller.ToCallerId(); + var unassigned = org.UnassignRoles(assignerId, assignerRoles.Value, userId.ToId(), rolesToUnassign.Value); + if (!unassigned.IsSuccessful) + { + return unassigned.Error; + } + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + org = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Organization {Id} unassigned roles for {User}", org.Id, userId); + + return org.ToOrganization(); + } + public async Task> ChangeAvatarAsync(ICallerContext caller, string id, FileUpload upload, CancellationToken cancellationToken) { @@ -337,13 +534,13 @@ private async Task> CreateOrganizationInternalAsync( return newSettings.Error; } - var organizationSettings = newSettings.Value.ToSettings(); - if (!organizationSettings.IsSuccessful) + var settings = newSettings.Value.ToSettings(); + if (!settings.IsSuccessful) { - return organizationSettings.Error; + return settings.Error; } - var configured = org.CreateSettings(organizationSettings.Value); + var configured = org.CreateSettings(settings.Value); if (!configured.IsSuccessful) { return configured.Error; 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..8c2c8902 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); + result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_UserNotOrgOwner); } [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_UserNotOrgOwner); + } + + [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] @@ -152,7 +200,7 @@ public async Task WhenChangeAvatarAsyncAndNotOwner_ThenReturnsError() _ => Task.FromResult>(Avatar.Create("animageid".ToId(), "aurl").Value), _ => Task.FromResult(Result.Ok)); - result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_NotOrgOwner); + result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_UserNotOrgOwner); } [Fact] @@ -202,7 +250,7 @@ public async Task WhenDeleteAvatarAsyncAndNotOwner_ThenReturnsError() { var result = await _org.DeleteAvatarAsync("anotheruserid".ToId(), Roles.Empty, _ => Task.FromResult(Result.Ok)); - result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_NotOrgOwner); + result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_UserNotOrgOwner); } [Fact] @@ -233,4 +281,158 @@ 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(); + } + + [Fact] + public void WhenAssignRolesAndNotOwner_ThenReturnsError() + { + var result = _org.AssignRoles("anassignerid".ToId(), Roles.Empty, "auserid".ToId(), Roles.Empty); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_UserNotOrgOwner); + } + + [Fact] + public void WhenAssignRolesAndNotMember_ThenReturnsError() + { + var result = _org.AssignRoles("anassignerid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), + Roles.Empty); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.OrganizationRoot_UserNotMember); + } + + [Fact] + public void WhenAssignRolesAndNotAssignableRole_ThenReturnsError() + { + _org.AddMembership("auserid".ToId()); + + var result = _org.AssignRoles("anassignerid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), + Roles.Create("arole").Value); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.OrganizationRoot_RoleNotAssignable.Format("arole")); + } + + [Fact] + public void WhenAssignRoles_ThenAssigns() + { + _org.AddMembership("auserid".ToId()); + + var result = _org.AssignRoles("anassignerid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), + Roles.Create(TenantRoles.BillingAdmin).Value); + + result.Should().BeSuccess(); + _org.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenUnassignRolesAndNotOwner_ThenReturnsError() + { + var result = _org.UnassignRoles("anassignerid".ToId(), Roles.Empty, "auserid".ToId(), Roles.Empty); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_UserNotOrgOwner); + } + + [Fact] + public void WhenUnassignRolesAndNotMember_ThenReturnsError() + { + var result = _org.UnassignRoles("anassignerid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), + Roles.Empty); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.OrganizationRoot_UserNotMember); + } + + [Fact] + public void WhenUnassignRolesAndNotAssignableRole_ThenReturnsError() + { + _org.AddMembership("auserid".ToId()); + + var result = _org.UnassignRoles("anassignerid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), + Roles.Create("arole").Value); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.OrganizationRoot_RoleNotAssignable.Format("arole")); + } + + [Fact] + public void WhenUnassignRoles_ThenUnassigns() + { + _org.AddMembership("auserid".ToId()); + + var result = _org.UnassignRoles("anassignerid".ToId(), Roles.Create(TenantRoles.Owner).Value, "auserid".ToId(), + Roles.Create(TenantRoles.Owner).Value); + + result.Should().BeSuccess(); + _org.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenDeleteOrganizationAndNotOwner_ThenReturnsError() + { + var result = _org.DeleteOrganization("adeleterid".ToId(), Roles.Empty); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_UserNotOrgOwner); + } + + [Fact] + public void WhenDeleteOrganizationAndHasOtherMembers_ThenReturnsError() + { + _org.AddMembership("auserid1".ToId()); + + var result = _org.DeleteOrganization("adeleterid".ToId(), Roles.Create(TenantRoles.Owner).Value); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.OrganizationRoot_DeleteOrganization_MembersStillExist); + } + + [Fact] + public void WhenDeleteOrganization_ThenDeletes() + { + var result = _org.DeleteOrganization("adeleterid".ToId(), Roles.Create(TenantRoles.Owner).Value); + + result.Should().BeSuccess(); + _org.IsDeleted.Value.Should().BeTrue(); + } } \ No newline at end of file diff --git a/src/OrganizationsDomain/Events.cs b/src/OrganizationsDomain/Events.cs index a196e272..ed91a599 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,59 @@ 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 RoleAssigned RoleAssigned(Identifier id, Identifier assignerId, Identifier userId, Role role) + { + return new RoleAssigned(id) + { + AssignedById = assignerId, + UserId = userId, + Role = role.Identifier + }; + } + + public static RoleUnassigned RoleUnassigned(Identifier id, Identifier unassignerId, Identifier userId, Role role) + { + return new RoleUnassigned(id) + { + UnassignedById = unassignerId, + UserId = userId, + Role = role.Identifier + }; + } + 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..8bf7d689 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,40 +191,83 @@ 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; + } + + case RoleAssigned assigned: + { + Recorder.TraceDebug(null, "Organization {Id} assigned role {Role} to {User}", Id, assigned.Role, + assigned.UserId); + return Result.Ok; + } + + case RoleUnassigned unassigned: + { + Recorder.TraceDebug(null, "Organization {Id} unassigned role {Role} to {User}", Id, unassigned.Role, + unassigned.UserId); + 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)) + if (Memberships.HasMember(userId)) { - return Error.RoleViolation(Resources.OrganizationRoot_NotOrgOwner); + return Result.Ok; } - if (Ownership == OrganizationOwnership.Personal) + return RaiseChangeEvent(OrganizationsDomain.Events.MembershipAdded(Id, userId)); + } + + public Result AssignRoles(Identifier assignerId, Roles assignerRoles, Identifier userId, Roles rolesToAssign) + { + if (!IsOwner(assignerRoles)) { - return Error.RuleViolation(Resources.OrganizationRoot_AddMembership_PersonalOrgMembershipNotAllowed); + return Error.RoleViolation(Resources.OrganizationRoot_UserNotOrgOwner); } - if (!userId.HasValue - && !emailAddress.HasValue) + if (!IsMember(userId)) + { + return Error.RuleViolation(Resources.OrganizationRoot_UserNotMember); + } + + foreach (var role in rolesToAssign.Items) { - return Error.RuleViolation(Resources.OrganizationRoot_AddMembership_UserIdAndEmailMissing); + if (!TenantRoles.IsTenantAssignableRole(role)) + { + return Error.RuleViolation(Resources.OrganizationRoot_RoleNotAssignable.Format(role)); + } + + var assigned = RaiseChangeEvent(OrganizationsDomain.Events.RoleAssigned(Id, assignerId, userId, role)); + if (!assigned.IsSuccessful) + { + return assigned.Error; + } } - return RaiseChangeEvent(OrganizationsDomain.Events.MembershipAdded(Id, inviterId, userId, emailAddress)); + return Result.Ok; } public async Task> ChangeAvatarAsync(Identifier modifierId, Roles modifierRoles, - CreateAvatarAction onCreateNew, - RemoveAvatarAction onRemoveOld) + CreateAvatarAction onCreateNew, RemoveAvatarAction onRemoveOld) { if (!IsOwner(modifierRoles)) { - return Error.RoleViolation(Resources.OrganizationRoot_NotOrgOwner); + return Error.RoleViolation(Resources.OrganizationRoot_UserNotOrgOwner); } var existingAvatarId = Avatar.HasValue @@ -220,6 +291,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_UserNotOrgOwner); + } + + return RaiseChangeEvent(OrganizationsDomain.Events.NameChanged(Id, name)); + } + public Result CreateSettings(Settings settings) { foreach (var (key, value) in settings.Properties) @@ -239,7 +320,7 @@ public async Task> DeleteAvatarAsync(Identifier deleterId, Roles d { if (!IsOwner(deleterRoles)) { - return Error.RoleViolation(Resources.OrganizationRoot_NotOrgOwner); + return Error.RoleViolation(Resources.OrganizationRoot_UserNotOrgOwner); } if (!Avatar.HasValue) @@ -257,6 +338,60 @@ public async Task> DeleteAvatarAsync(Identifier deleterId, Roles d return RaiseChangeEvent(OrganizationsDomain.Events.AvatarRemoved(Id, avatarId)); } + public Result DeleteOrganization(Identifier deleterId, Roles deleterRoles) + { + if (!IsOwner(deleterRoles)) + { + return Error.RoleViolation(Resources.OrganizationRoot_UserNotOrgOwner); + } + + //TODO: Must be the BillingBuyer + //TODO: BillingBuyer.CanBeUnsubscribed must be true + + var otherMembers = Memberships.Members + .Select(m => m.UserId) + .Except(new[] { deleterId }) + .ToList(); + if (otherMembers.HasAny()) + { + return Error.RuleViolation(Resources.OrganizationRoot_DeleteOrganization_MembersStillExist); + } + + return RaisePermanentDeleteEvent(deleterId); + } + + public Result InviteMember(Identifier inviterId, Roles inviterRoles, Optional userId, + Optional emailAddress) + { + if (!IsOwner(inviterRoles)) + { + return Error.RoleViolation(Resources.OrganizationRoot_UserNotOrgOwner); + } + + 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 +399,58 @@ public void TestingOnly_ChangeOwnership(OrganizationOwnership ownership) } #endif + public Result UnassignRoles(Identifier assignerId, Roles assignerRoles, Identifier userId, + Roles rolesToUnassign) + { + if (!IsOwner(assignerRoles)) + { + return Error.RoleViolation(Resources.OrganizationRoot_UserNotOrgOwner); + } + + if (!IsMember(userId)) + { + return Error.RuleViolation(Resources.OrganizationRoot_UserNotMember); + } + + foreach (var role in rolesToUnassign.Items) + { + if (!TenantRoles.IsTenantAssignableRole(role)) + { + return Error.RuleViolation(Resources.OrganizationRoot_RoleNotAssignable.Format(role)); + } + + var assigned = RaiseChangeEvent(OrganizationsDomain.Events.RoleUnassigned(Id, assignerId, userId, role)); + if (!assigned.IsSuccessful) + { + return assigned.Error; + } + } + + return Result.Ok; + } + + public Result UnInviteMember(Identifier removerId, Roles removerRoles, Identifier userId) + { + if (!IsOwner(removerRoles)) + { + return Error.RoleViolation(Resources.OrganizationRoot_UserNotOrgOwner); + } + + 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) @@ -296,4 +483,9 @@ private static bool IsOwner(Roles roles) { return roles.HasRole(TenantRoles.Owner); } + + private bool IsMember(Identifier userId) + { + return Memberships.HasMember(userId); + } } \ No newline at end of file diff --git a/src/OrganizationsDomain/Resources.Designer.cs b/src/OrganizationsDomain/Resources.Designer.cs index 4d4e9859..ebe12966 100644 --- a/src/OrganizationsDomain/Resources.Designer.cs +++ b/src/OrganizationsDomain/Resources.Designer.cs @@ -69,29 +69,38 @@ 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 delete this organization while any others members still exist. They must be removed from the organization first. /// - internal static string OrganizationRoot_AddMembership_UserIdAndEmailMissing { + internal static string OrganizationRoot_DeleteOrganization_MembersStillExist { get { - return ResourceManager.GetString("OrganizationRoot_AddMembership_UserIdAndEmailMissing", resourceCulture); + return ResourceManager.GetString("OrganizationRoot_DeleteOrganization_MembersStillExist", resourceCulture); } } /// - /// Looks up a localized string similar to Must be a person to create a 'Shared' organization. + /// Looks up a localized string similar to Cannot add another user to a 'Personal' organization. /// - internal static string OrganizationRoot_Create_SharedRequiresPerson { + internal static string OrganizationRoot_InviteMember_PersonalOrgMembershipNotAllowed { get { - return ResourceManager.GetString("OrganizationRoot_Create_SharedRequiresPerson", resourceCulture); + return ResourceManager.GetString("OrganizationRoot_InviteMember_PersonalOrgMembershipNotAllowed", resourceCulture); + } + } + + /// + /// 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_InviteMember_UserIdAndEmailMissing { + get { + return ResourceManager.GetString("OrganizationRoot_InviteMember_UserIdAndEmailMissing", resourceCulture); } } @@ -104,12 +113,39 @@ internal static string OrganizationRoot_NoAvatar { } } + /// + /// Looks up a localized string similar to Role '{0}' is not assignable to a user. + /// + internal static string OrganizationRoot_RoleNotAssignable { + get { + return ResourceManager.GetString("OrganizationRoot_RoleNotAssignable", resourceCulture); + } + } + + /// + /// 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 User must be a member of this organization. + /// + internal static string OrganizationRoot_UserNotMember { + get { + return ResourceManager.GetString("OrganizationRoot_UserNotMember", resourceCulture); + } + } + /// /// Looks up a localized string similar to Must be an organization owner to perform this action. /// - internal static string OrganizationRoot_NotOrgOwner { + internal static string OrganizationRoot_UserNotOrgOwner { get { - return ResourceManager.GetString("OrganizationRoot_NotOrgOwner", resourceCulture); + return ResourceManager.GetString("OrganizationRoot_UserNotOrgOwner", resourceCulture); } } diff --git a/src/OrganizationsDomain/Resources.resx b/src/OrganizationsDomain/Resources.resx index 708d277a..078a3156 100644 --- a/src/OrganizationsDomain/Resources.resx +++ b/src/OrganizationsDomain/Resources.resx @@ -36,16 +36,28 @@ Must be a person to create a 'Shared' organization - + Must be an organization owner to perform this action - + + User must be a member of this organization + + 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 + + + Role '{0}' is not assignable to a user + + + Cannot delete this organization while any others members still exist. They must be removed from the organization first \ No newline at end of file diff --git a/src/OrganizationsDomain/Validations.cs b/src/OrganizationsDomain/Validations.cs index 5da412d6..929180a0 100644 --- a/src/OrganizationsDomain/Validations.cs +++ b/src/OrganizationsDomain/Validations.cs @@ -4,6 +4,9 @@ namespace OrganizationsDomain; public static class Validations { + public static readonly Validation DisplayName = CommonValidations.DescriptiveName(); + public static readonly Validation Role = CommonValidations.RoleLevel; + public static class Avatar { public const long MaxSizeInBytes = 134_217_728; //approx 100MB @@ -14,6 +17,4 @@ public static class Avatar "image/gif" }; } - - public static readonly Validation DisplayName = CommonValidations.DescriptiveName(); } \ No newline at end of file diff --git a/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs index 4a1a0cf6..96b9ae33 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,336 @@ 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); + } + + [Fact] + public async Task WhenAssignRoles_ThenAssigns() + { + 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)); + + await Api.PutAsync(new AssignRolesToOrganizationRequest + { + Id = organizationId, + UserId = loginB.User.Id, + Roles = [TenantRoles.Owner.Name] + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + var memberships = await Api.GetAsync(new ListMembershipsForCallerRequest(), + req => req.SetJWTBearerToken(loginB.AccessToken)); + + memberships.Content.Value.Memberships!.Count.Should().Be(2); + memberships.Content.Value.Memberships![0].OrganizationId.Should().NotBeNull(); + memberships.Content.Value.Memberships![0].Ownership.Should().Be(OrganizationOwnership.Personal); + memberships.Content.Value.Memberships![0].Roles.Should().ContainInOrder(TenantRoles.Owner.Name); + memberships.Content.Value.Memberships![1].OrganizationId.Should().Be(organizationId); + memberships.Content.Value.Memberships![1].Ownership.Should().Be(OrganizationOwnership.Shared); + memberships.Content.Value.Memberships![1].Roles.Should() + .ContainInOrder(TenantRoles.Member.Name, TenantRoles.Owner.Name); + } + + [Fact] + public async Task WhenUnassignAssignedRole_ThenUnassigns() + { + 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)); + + await Api.PutAsync(new AssignRolesToOrganizationRequest + { + Id = organizationId, + UserId = loginB.User.Id, + Roles = [TenantRoles.Owner.Name] + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + await Api.PutAsync(new UnassignRolesFromOrganizationRequest + { + Id = organizationId, + UserId = loginB.User.Id, + Roles = [TenantRoles.Owner.Name] + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + var memberships = await Api.GetAsync(new ListMembershipsForCallerRequest(), + req => req.SetJWTBearerToken(loginB.AccessToken)); + + memberships.Content.Value.Memberships!.Count.Should().Be(2); + memberships.Content.Value.Memberships![0].OrganizationId.Should().NotBeNull(); + memberships.Content.Value.Memberships![0].Ownership.Should().Be(OrganizationOwnership.Personal); + memberships.Content.Value.Memberships![0].Roles.Should().ContainInOrder(TenantRoles.Owner.Name); + memberships.Content.Value.Memberships![1].OrganizationId.Should().Be(organizationId); + memberships.Content.Value.Memberships![1].Ownership.Should().Be(OrganizationOwnership.Shared); + memberships.Content.Value.Memberships![1].Roles.Should().ContainInOrder(TenantRoles.Member.Name); + } + + [Fact] + public async Task WhenDeleteAndHasMembers_ThenReturnsError() + { + 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 result = await Api.DeleteAsync(new DeleteOrganizationRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task WhenDeleteAndHasNoMembers_ThenDeletes() + { + var loginA = await LoginUserAsync(); + + 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; + + var result = await Api.DeleteAsync(new DeleteOrganizationRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + private static string CreateRandomEmailAddress() { return $"aninvitee{++_invitationCount}@company.com"; diff --git a/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/AssignOrganisationRoleRequestValidatorSpec.cs b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/AssignOrganisationRoleRequestValidatorSpec.cs new file mode 100644 index 00000000..52e318e4 --- /dev/null +++ b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/AssignOrganisationRoleRequestValidatorSpec.cs @@ -0,0 +1,66 @@ +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 AssignOrganisationRoleRequestValidatorSpec +{ + private readonly AssignRolesToOrganizationRequest _dto; + private readonly AssignRolesToOrganizationRequestValidator _validator; + + public AssignOrganisationRoleRequestValidatorSpec() + { + _validator = new AssignRolesToOrganizationRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new AssignRolesToOrganizationRequest + { + Id = "anid", + UserId = "anid", + Roles = ["arole"] + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenRolesIsNull_ThenThrows() + { + _dto.Roles = null!; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssignRolesToOrganizationRequestValidator_InvalidRoles); + } + + [Fact] + public void WhenRolesIsEmpty_ThenThrows() + { + _dto.Roles = []; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssignRolesToOrganizationRequestValidator_InvalidRoles); + } + + [Fact] + public void WhenARoleIsInvalid_ThenThrows() + { + _dto.Roles = ["aninvalidrole^"]; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssignRolesToOrganizationRequestValidator_InvalidRole); + } +} \ No newline at end of file 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.UnitTests/Api/Organizations/UnassignOrganisationRoleRequestValidatorSpec.cs b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/UnassignOrganisationRoleRequestValidatorSpec.cs new file mode 100644 index 00000000..80917a25 --- /dev/null +++ b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/UnassignOrganisationRoleRequestValidatorSpec.cs @@ -0,0 +1,66 @@ +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 UnassignOrganisationRoleRequestValidatorSpec +{ + private readonly UnassignRolesFromOrganizationRequest _dto; + private readonly UnassignRolesFromOrganizationRequestValidator _validator; + + public UnassignOrganisationRoleRequestValidatorSpec() + { + _validator = new UnassignRolesFromOrganizationRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new UnassignRolesFromOrganizationRequest + { + Id = "anid", + UserId = "anid", + Roles = ["arole"] + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenRolesIsNull_ThenThrows() + { + _dto.Roles = null!; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssignRolesToOrganizationRequestValidator_InvalidRoles); + } + + [Fact] + public void WhenRolesIsEmpty_ThenThrows() + { + _dto.Roles = []; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssignRolesToOrganizationRequestValidator_InvalidRoles); + } + + [Fact] + public void WhenARoleIsInvalid_ThenThrows() + { + _dto.Roles = ["aninvalidrole^"]; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssignRolesToOrganizationRequestValidator_InvalidRole); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/AssignRolesToOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/AssignRolesToOrganizationRequestValidator.cs new file mode 100644 index 00000000..e45e52f1 --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/AssignRolesToOrganizationRequestValidator.cs @@ -0,0 +1,27 @@ +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 AssignRolesToOrganizationRequestValidator : AbstractValidator +{ + public AssignRolesToOrganizationRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.Id) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + RuleFor(req => req.UserId) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + RuleFor(req => req.Roles) + .NotEmpty() + .WithMessage(Resources.AssignRolesToOrganizationRequestValidator_InvalidRoles); + RuleFor(req => req.Roles) + .ForEach(req => req.Matches(Validations.Role) + .WithMessage(Resources.AssignRolesToOrganizationRequestValidator_InvalidRole)); + } +} \ 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/DeleteOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/DeleteOrganizationRequestValidator.cs new file mode 100644 index 00000000..22059a65 --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/DeleteOrganizationRequestValidator.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 DeleteOrganizationRequestValidator : AbstractValidator +{ + public DeleteOrganizationRequestValidator(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/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..9336a9a1 100644 --- a/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs +++ b/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs @@ -25,6 +25,17 @@ public OrganizationsApi(IHttpContextAccessor httpContextAccessor, IFileUploadSer _organizationsApplication = organizationsApplication; } + public async Task> AssignRoles( + AssignRolesToOrganizationRequest request, CancellationToken cancellationToken) + { + var organization = await _organizationsApplication.AssignRolesToOrganizationAsync(_contextFactory.Create(), + request.Id!, request.UserId, request.Roles, cancellationToken); + + return () => + organization.HandleApplicationResult(org => + new GetOrganizationResponse { Organization = org }); + } + public async Task> ChangeAvatar( ChangeOrganizationAvatarRequest request, CancellationToken cancellationToken) { @@ -45,6 +56,18 @@ 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(org => + new GetOrganizationResponse + { Organization = org }); + } + public async Task> Create(CreateOrganizationRequest request, CancellationToken cancellationToken) { @@ -56,6 +79,15 @@ await _organizationsApplication.CreateSharedOrganizationAsync(_contextFactory.Cr new PostResult(new GetOrganizationResponse { Organization = org })); } + public async Task Delete(DeleteOrganizationRequest request, CancellationToken cancellationToken) + { + var organization = + await _organizationsApplication.DeleteOrganizationAsync(_contextFactory.Create(), request.Id, + cancellationToken); + + return () => organization.HandleApplicationResult(); + } + public async Task> DeleteAvatar( DeleteOrganizationAvatarRequest request, CancellationToken cancellationToken) { @@ -123,4 +155,26 @@ await _organizationsApplication.ListMembersForOrganizationAsync(_contextFactory. members.HandleApplicationResult(m => new ListMembersForOrganizationResponse { Members = m.Results, Metadata = m.Metadata }); } + + public async Task> UnassignRoles( + UnassignRolesFromOrganizationRequest request, CancellationToken cancellationToken) + { + var organization = await _organizationsApplication.UnassignRolesFromOrganizationAsync(_contextFactory.Create(), + request.Id!, request.UserId, request.Roles, cancellationToken); + + return () => + organization.HandleApplicationResult(org => + new GetOrganizationResponse { Organization = org }); + } + + 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/Api/Organizations/UnassignRolesFromOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/UnassignRolesFromOrganizationRequestValidator.cs new file mode 100644 index 00000000..2c20ad4b --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/UnassignRolesFromOrganizationRequestValidator.cs @@ -0,0 +1,27 @@ +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 UnassignRolesFromOrganizationRequestValidator : AbstractValidator +{ + public UnassignRolesFromOrganizationRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.Id) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + RuleFor(req => req.UserId) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + RuleFor(req => req.Roles) + .NotEmpty() + .WithMessage(Resources.AssignRolesToOrganizationRequestValidator_InvalidRoles); + RuleFor(req => req.Roles) + .ForEach(req => req.Matches(Validations.Role) + .WithMessage(Resources.AssignRolesToOrganizationRequestValidator_InvalidRole)); + } +} \ 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/Persistence/ReadModels/OrganizationProjection.cs b/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs index 2e256e78..7ae93188 100644 --- a/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs +++ b/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs @@ -1,7 +1,7 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; -using Domain.Common.ValueObjects; +using Domain.Common.Events; using Domain.Events.Shared.Organizations; using Domain.Interfaces; using Domain.Interfaces.Entities; @@ -29,7 +29,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 +43,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 +61,26 @@ 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); + + case RoleAssigned _: + return true; + + case RoleUnassigned _: + return true; + + case Global.StreamDeleted e: + return await _organizations.HandleDeleteAsync(e.RootId, cancellationToken); + default: return false; } diff --git a/src/OrganizationsInfrastructure/Resources.Designer.cs b/src/OrganizationsInfrastructure/Resources.Designer.cs index 4068e4a4..7e1758c6 100644 --- a/src/OrganizationsInfrastructure/Resources.Designer.cs +++ b/src/OrganizationsInfrastructure/Resources.Designer.cs @@ -59,6 +59,33 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to The 'Role' is invalid. + /// + internal static string AssignRolesToOrganizationRequestValidator_InvalidRole { + get { + return ResourceManager.GetString("AssignRolesToOrganizationRequestValidator_InvalidRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Roles' is either empty or contains invalid roles. + /// + internal static string AssignRolesToOrganizationRequestValidator_InvalidRoles { + get { + return ResourceManager.GetString("AssignRolesToOrganizationRequestValidator_InvalidRoles", resourceCulture); + } + } + + /// + /// 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..2b5acf90 100644 --- a/src/OrganizationsInfrastructure/Resources.resx +++ b/src/OrganizationsInfrastructure/Resources.resx @@ -36,4 +36,13 @@ Only the 'Email' or the 'UserId' can be provided, not both + + The 'Name' is invalid + + + The 'Roles' is either empty or contains invalid roles + + + The 'Role' is invalid + \ No newline at end of file diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 45f85459..33354bc2 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -338,6 +338,7 @@ True True True + True True True True @@ -559,7 +560,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 +583,7 @@ public sealed class $name$ : SingleValueObjectBase<$name$, $datatype$> return new $name$(parts[0]); }; } -}$END$$SELECTION$ +} True True A DELETE API definition @@ -601,7 +602,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 +645,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 +690,7 @@ public sealed class $name$ : ValueObjectBase<$name$> public $datatype$ $param2$ { get; } public $datatype$ $param3$ { get; } -}$END$$SELECTION$ +} True True cs @@ -757,7 +758,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 +821,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 +1013,7 @@ public class $filename$ [Fact] public async Task When$condition$_Then$outcome$() { - $END$ + Assert.Fail();$END$ } True True @@ -1083,6 +1085,7 @@ public class $Action$$Resource$Response : SearchResponse public List<$Resource$>? $Resource$s { get; set; } } SaaStack + False True True cs @@ -1194,6 +1197,7 @@ public class $Action$$Resource$Response : IWebResponse #if TESTINGONLY $SELECTION$$END$ #endif + False True True A GET API definition @@ -1212,7 +1216,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 +1255,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 +1343,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 +1374,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 +1395,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);