diff --git a/docs/design-principles/0050-domain-driven-design.md b/docs/design-principles/0050-domain-driven-design.md index 568b127c..fa7bc2f9 100644 --- a/docs/design-principles/0050-domain-driven-design.md +++ b/docs/design-principles/0050-domain-driven-design.md @@ -4,7 +4,7 @@ ## Design Principles -1. We want to design our software according to the use cases we understand from the real world. Thus, focusing on [modeling behaviors](../decisions/0040-modeling.md) in the software rather than modeling the data required to enable the software to operate (a.k.a Data-Modeling). +1. We want to design our software according to the use cases we understand from the real world. Thus, focusing on [modeling behaviors](../decisions/0040-modeling.md) in the software rather than modeling the data (a.k.a Data-Modeling) required to enable the software to operate. Data modeling is too database and technology focused. 2. We want to define discrete boundaries of "state" changes, and thus, we want to use the notion of "aggregates" to do that where an aggregate represents the smallest atomic unit of state change. 3. We want to define at least one root aggregate per explicit subdomain. (Ideally, one per subdomain). 4. We want aggregates to be the source/producer of "domain events" that represent atomic units of change, and have them communicated to other subdomains (either: remotely over HTTP, or in-process), using a pub-sub mechanism. @@ -15,30 +15,31 @@ 9. When an Aggregate's state change is persisted, the domain events (that it created) will be published to any consumers registered to receive them. These consumers may be other subdomains in the same process, or running in other processes. 10. 11. We want to re-use the design principles of DDD, such as "AggregateRoots", "Entities", "ValueObjects", "Repositories", "Factories", "Subdomains" etc. Where: -1. All Aggregates, Entities, and ValueObjects will be instantiated by class factories. They will validate all data entering the domain. They will return `Result` for any validation errors. -2. They will ensure that no Aggregate ("Aggregate" being the graph of all child/ancestor Entities and ValueObjects) is ever in an invalid state at any time. -3. Aggregates will verify their own "invariants" whenever there is a state change. Invariants are the things about an aggregate that don't change. -4. ValueObjects will always be immutable and only "equal" based on their internal state. -5. Entities and Aggregates will be mutable, and "equal" by their unique identifier (despite differences in their internal value). -6. A subdomain defines the initial bounded context for a Root Aggregate. Other bounded contexts will evolve as the product evolves. -12. We want to leverage Hexagonal/Onion/Clean architecture principles: -1. The Application Layer defines one (or more) external interfaces/contracts for the Subdomain. -2. All dependencies only point inward. i.e., The Domain Layer strictly has no dependency on the Application Layer, nor on the Infrastructure Layer. In contrast, the Infrastructure Layer and Application Layer can have dependencies on the Domain Layer (directly or indirectly). -3. We want to avoid building [Transaction Scripts (and anemic domain models)](https://martinfowler.com/eaaCatalog/transactionScript.html) in the Application Layer, as that encourages tight coupling, and anemic domain models. -4. Application interfaces/contracts will be composed of commands and queries (CQRS): +12. All Aggregates, Entities, and ValueObjects will be instantiated by class factories. They will validate all data entering the domain. They will return `Result` for any validation errors. +13. They will ensure that no Aggregate ("Aggregate" being the graph of all child/ancestor Entities and ValueObjects) is ever in an invalid state at any time. +14. Aggregates will verify their own "invariants" whenever there is a state change. Invariants are the things about an aggregate that don't change. +15. ValueObjects will always be immutable and only "equal" based on their internal state. +16. Entities and Aggregates will be mutable, and "equal" by their unique identifier (despite differences in their internal value). +17. A subdomain defines the initial bounded context for a Root Aggregate. Other bounded contexts will evolve as the product evolves. +18. We want to leverage Hexagonal/Onion/Clean architecture principles: +19. The Application Layer defines one (or more) external interfaces/contracts for the Subdomain. +20. All dependencies only point inward. i.e., The Domain Layer strictly has no dependency on the Application Layer, nor on the Infrastructure Layer. In contrast, the Infrastructure Layer and Application Layer can have dependencies on the Domain Layer (directly or indirectly). +21. We want to avoid building [Transaction Scripts (and anemic domain models)](https://martinfowler.com/eaaCatalog/transactionScript.html) in the Application Layer, as that encourages tight coupling, and anemic domain models. +22. Application interfaces/contracts will be composed of commands and queries (CQRS): 1. For "commands" it will delegate the command to one (or more) Root Aggregates (i.e., that results in a change of state). - 2. For "queries" it will delegate the query directly to a read model in a DataStore. In rare cases, the query may involve an Aggregate to check access rules. -5. The Application Layer delegates all decisions (in a command or query) to an Aggregate. The only decisions that should exist in the Application Layer, are: - 1. The statelessness of the contract. Stateless or Stateful. i.e., Where to pull data from (which ApplicationService/Repository) and where to push it (which ApplicationService/Repository) and when. - 2. Which Aggregate use case to invoke, when. -6. The Application Layer is responsible for providing the Domain Layer with all the data it needs to execute the specific use case. -7. The Application Layer is responsible for converting all data it obtains (either from Repository/ApplicationService, or from its contract parameters) into ValueObjects that the Domain Layer requires. -8. The Application Layer will use the [Tell Don't Ask](https://martinfowler.com/bliki/TellDontAsk.html) pattern to instruct the Aggregate to execute the use case. -9. The Application Layer will convert all changed domain states (in any command or query responses) into shared DTOs (e.g., Resources) that can be shared with all ApplicationServices. e.g., A REST API is one such ApplicationService, as is any adapter to any 3rd party system. -10. We want to use [Dependency Injection](0060-dependency-injection.md) to inject the necessary dependencies into the Application Layer (ApplicationServices) and Domain Layer (DomainServices), such that they remain decoupled from adapter implementations. -11. There will be far fewer DomainServices than ApplicationServices. Both kinds of services will be designed in terms of the needs of the consuming subdomain component (and perhaps combined into a more general abstraction if being consumed by multiple subdomains). They shall be as domain-specific as possible and not be described in terms of the technology that implements them (upholding the SOLID principle of [ISP](https://en.wikipedia.org/wiki/Interface_segregation_principle)). -12. The Application Layer can be exposed by any number of interfaces (e.g., a REST API, a Queue, or a Service Bus) -13. We want to capture, measure, and monitor usage activity, diagnostics, and audit key events in the system by using the [Recorder](0030-recording.md). + 2. For "queries" it will delegate the query directly to a read model in an `IDataStore`. In rare cases, the query may involve an Aggregate to check access rules. +23. The Application Layer delegates all decisions (in a command or query) to an Aggregate. The only decisions that should exist in the Application Layer, are: + 1. The statelessness of the contract. Stateless or Stateful. + 2. Where to pull data from (which ApplicationService/Repository) and where to push it (which ApplicationService/Repository) and when. + 3. Which Aggregate use case to invoke, when. +24. The Application Layer is responsible for providing the Domain Layer with all the data it needs to execute the specific use case. Also, may require domain services for data processing. +25. The Application Layer is responsible for converting all data it obtains (either from Repository/ApplicationService, or from its contract parameters) into ValueObjects that the Domain Layer requires. Domain Layer does not accept variables in anything but primitives and ValueObjects. +26. The Application Layer will use the [Tell Don't Ask](https://martinfowler.com/bliki/TellDontAsk.html) pattern to instruct the Aggregate to execute the use case, no matter how complex the use case is (or isn't). The [Law of Demeter](https://en.wikipedia.org/wiki/Law_of_Demeter) also applies to reading any data from an Aggregate. +27. The Application Layer will convert all changed domain states (in any command or query responses) into shared DTOs (e.g., Resources) that can be shared with all other Application Services. e.g., A REST API is one such Application Service, as is any adapter to any 3rd party system. +28. We want to use [Dependency Injection](0060-dependency-injection.md) to inject the necessary dependencies into the Application Layer (as Application Services) and Domain Layer (as Domain Services), such that they remain decoupled from concrete adapter implementations. +29. We expect that there will be far fewer Domain Services than Application Services. Both kinds of services will be designed in terms of the needs of the consuming subdomain component (and perhaps combined into a more general abstraction if being consumed by multiple subdomains). They shall be as domain-specific as possible and not be described in terms of the technology that implements them (upholding the SOLID principle of [ISP](https://en.wikipedia.org/wiki/Interface_segregation_principle)). +30. The Application Layer can be exposed to any number of interfaces (e.g., a REST API, a Queue, or a Service Bus) or as consumer of domain events notifications. +31. We want to capture, measure, and monitor usage activity, diagnostics, and audit key events in the system by using the [Recorder](0030-recording.md). ## Implementation @@ -48,32 +49,36 @@ In DDD parlance, a "Generic" subdomain is one that is generic to all products. I A "Core" subdomain is unique to your product and a vital characteristic of it. -> That is not to say that the Generic subdomains are not vital; they are absolutely vital for operation, it is just that it is not vital that you build them yourself. +> That is not to say that the Generic subdomains are not vital; they are absolutely vital for operation. It is just that it is not vital that you build them yourself. Subdomains -For example, in this codebase, we have provided 2 "Core" subdomains (`Cars` and `Bookings`) that together define the larger "domain" of this example product to be `Car Sharing`. The other subdomains are all Generic subdomains: +Among the subdomains will exist one or more "bounded contexts". In DDD, a bounded context is simply a defined boundary within which the semantics of the aggregates and use cases in the subdomains are well-known and unambiguous. Given that in the real world, many aspects of the same thing/concept are only relevant in specific contexts. That is exactly what a bounded context is meant to be. A codified specific context, where meaning is clear, using names of things that can other meanings in different contexts. -- `EndUsers`, `Organizations`, `Profiles,` and `Subscriptions` are all generic subdomains related to the multi-tenancy and identity management within a typical SaaS product. -- `AuthN`, `Auxillary`, `Images` (and `Workers`) are all generic subdomains related to the operation of a typical SaaS product. +For example, in this codebase, we have provided two "Core" subdomains: `Cars` and `Bookings`. Together, these two subdomains alone are understood together to represent the "core" of a bounded context of borrowing cars with the "Car Sharing" domain. + +The other subdomains are all "Generic" subdomains, and form another more technical bounded context where they work together to provide consistency and rules that govern things like multi-tenancy, ownership and payment authority, etc: + +- `EndUsers`, `Organizations`, `UserProfiles`, `Identities`, and `Subscriptions` are all generic subdomains related to the multi-tenancy and identity management within a typical SaaS product. +- `Ancillary`, `Images` (and `Workers`) are all generic subdomains related to the operation of a typical SaaS product. #### Your first few Subdomains -When starting a new product, many developers who are unfamiliar with DDD wonder how to get started defining the new core subdomains of their products. It is a fair question. They are (more often than not) used to thinking about software design in terms of the data tables, columns, and datatypes required by relational databases (a.k.a data modeling). So, they expect that there is some similar way of working in DDD that easily translates. This is not really the case. +When starting a new product, many developers who are unfamiliar with DDD wonder how to start defining the new core subdomains of their products. It is a fair question. They are (more often than not) used to thinking about software design in terms of the data tables, columns, and data types required by relational databases (a.k.a. data modeling). So, they expect that there is some similar way of working in DDD that can be easily translated. This is not really the case. -There has been a lot of work done on what is called "strategic" design in DDD. Whole books have been written on it; some like [Eric Evans's "blue book"](https://www.domainlanguage.com/ddd/) are very comprehensive and well worth the read. They guide you through designing the subdomains and bounded contexts of your software, using context maps and things like that. Most of that material makes a lot of sense, but getting started with applying it before you have any software built is still quite challenging, particularly when building software products. +A lot of work has been done on what is called "strategic" design in DDD. Whole books have been written on it; some like [Eric Evans's "blue book"](https://www.domainlanguage.com/ddd/) are very comprehensive and well worth the read. They guide you through understanding and designing the subdomains and bounded contexts of your software using context maps and things like that. Most of that material makes a lot of sense, but getting started with applying it before you have any software built is still quite challenging, particularly when building software products from scratch. However, what is not so hard is answering this question: -Q1. What are the use cases for using the software you want to build? a.k.a What, specifically, should your software do? +Q. What are the use cases for using the software you want to build? a.k.a What, specifically, should your software do? If you can answer that question, then you are already designing the "Subdomains" of your domain. -We recommend that you just get started focusing on your use cases, and the database design will fall out of the back of that process. That would be very scary and risky if you were data modeling, but you are not doing that now, you are domain modeling instead. +We recommend that you just get started focusing on your use cases, and the database design will fall out of the back of that process. It would be very scary and risky if you were data modeling, but you are not doing that now; you are doing "domain modeling" instead. Just take the answer to the question above, then group the use cases into groups around a common concept, and then make each one of those concepts a new subdomain. -What should you call the subdomains? +Q. What should you call the subdomains? Well, best call them the same things that you use to describe them in the real world. This will eventually become your ubiquitous language. @@ -81,7 +86,7 @@ Well, best call them the same things that you use to describe them in the real w > > So, for example, it is relatively safe to use the simple noun "Car" to represent the physical object (of ~30,000 parts, the thing that one gets in and drives away), even though you are only modeling very basic details about it, like its color, mileage, location, make, model and year, number of windows, car seats, etc. However, what if your specific business decides to include using bicycles, boats, buses or trucks later on? Would "Vehicle" have been a better option to start with? Context matters. -In the "Car Sharing" world (the example domain in the codebase that you are learning from), we know that this domain is all about people (end-users) making reservations (bookings on some future schedule) to use a car (owned by someone else), finding the car at some location, then gaining access to the car (digital or physical key), then driving the car on some journey over some route for some time, eventually, returning the car to some location, and being charged for its use. +In the example "Car Sharing" world (the example domain in the codebase that you are learning from), we know that this domain is all about people (end-users) making reservations (bookings on some future schedule) to use a car (owned by someone else), finding the car at some location, then gaining access to the car (digital or physical key), then driving the car on some journey over some route for some time, eventually, returning the car to some location, and being charged for its use. So, from that simple "mental model" of the real world, we know that we are likely to have these initial core subdomains: @@ -102,7 +107,7 @@ For the proposed subdomains that don't have use cases specifically yet, they won > For example, you may not be able to come up with a use case for manipulating a Location or a Trip just yet, or for that matter, an Unavailability. That is fine. A car may go on a Trip and have a start and end Location, and while it is booked, it is Unavailable for hire by someone else. But this does not mean that those concepts need to be subdomains in their own rights. Indeed, Unavailability has no meaning without the context of a Car, so it is probably not separate from a Car. -So, Unavailability starts out as an entity of a Car, and Location can start out as two value objects of a Booking, i.e, PickUpLocation and DropoffLocation. Trips could be a child entity of a Booking since you can do several of them during a Booking. +So, "Unavailability" starts out as an entity of a Car, and "Location" can start out as two value objects of a Booking, i.e., "Pick up" location and "Drop off" Location. Trips could be a child entity of a Booking since you can do several of them during a Booking. As time goes on, and as you explore your product opportunities further, it is very common that subdomains both diverge (and split up) or converge (and merge together). This is very normal, and unavoidable, and unpredictable, and unknowable ahead of time. So we recommend that you don't try to second guess the future since it is likely to change as you move forward. @@ -117,9 +122,9 @@ A quick note about [Bounded Contexts](https://martinfowler.com/bliki/BoundedCont Bounded Contexts are another vital concept in Strategic DDD design, and often one that is difficult to implement in code. -Your software does not need to **explicitly** model its bounded contexts for the sake of it. +Your software does not actually need to **explicitly** model its bounded contexts for the sake of it, nor for completeness. -> They are more of a high-level design attribute than anything else, most useful in context maps. +> They are more of a high-level design attribute than anything else, and they are most useful in context maps used for design and solving design problems. > > Yes, they are useful for drawing context maps and the like (at later dates), but they are not necessary to get started on modeling the real world with DDD design. @@ -175,7 +180,7 @@ public sealed class CarRoot : AggregateRootBase #### Creation -When a new aggregate is created, it must be constructed with a class factory method. This method will then be called from the Application Layer when a new aggregate needs to be created. +When a new aggregate is created, it must be constructed using a class factory method. This method will then be called from the Application Layer when a new aggregate needs to be created. > Instantiating an aggregate using a constructor directly is not permitted from outside of the domain. @@ -440,7 +445,7 @@ Aggregates may have conditions within states that are "invariant". That is, thin Sometimes, those invariants are only true at certain times (in certain states). Sometimes, they are true at all times (in all states). -> For example, for a Booking to be made for a Car, the car must not only exist, but it must be roadworthy, as well as have availability at that time. Availability can change over time (as bookings are made), but roadworthiness may not change. When the car is first created (as an aggregate), it may not have the details it needs to pass the roadworthiness test yet. Those details may come in a future domain event, so the invariant can be applied only after that time. +> For example, for a Booking to be made for a Car, the car must not only exist, but it must be roadworthy, as well as have availability at that time. Availability can change over time (as bookings are made), but road worthiness may not change. When the car is first created (as an aggregate), it may not have the details it needs to pass the road worthiness test yet. Those details may come in a future domain event, so the invariant can be applied only after that time. Invariants are strictly checked/validated in three places in a subdomain: @@ -959,13 +964,34 @@ The only invariants that need verifying are when the value object is constructed ### Event Notifications -TODO: How do we notify consumers of produced domain events? +In the design of most distributed systems of the nature of this system (or, of systems that are expected to evolve into distributed systems later), it is common to decouple each of the subdomains from each other. De-coupling effectively is absolutely vital to allowing the system to change, grow and evolve over time. Lack of effective de-coupling (at the technical level) is the main reason most software systems devolve into big-balls-of-mud, simply because of coupling. + +There are several techniques for de-coupling your subdomains, including: separating layers, using ports and adapters, starting with a modular monoliths and decomposing it into microservices later etc. - +Another one of these techniques is the use of Event-Driven Architecture (EDA), where change in communicated within and across boundaries. + +EDA relies on the fact that your system will emit "domain events", that it can share both within specific bounded contexts (as "domain events"), and externally to other systems (as "integration events". + +> When sharing events within a bounded context (or within the same process) the process can remain consistent, we call these "domain events". +> +> When sharing events across bounded contexts (or across processes and hosts) these events are called "integration events". +In SaaStack: +1. We use "domain events" to communicate changes (within the Domain Layer) and within all aggregates and entities. Regardless of whether we are using event sourcing for persistence or not. +2. We publish all "domain events" whenever the state of any aggregate is saved in any repository, via the `EventSourcingDddCommandStore` or via the `SnapshottingDddCommandStore`. +3. We treat "domain events" and "integration events" slightly differently: + 1. "domain events" are published synchronously and handled synchronously after the aggregate is saved, and are always consistent. + 2. "integration events" are published synchronously, but are expected to be handled asynchronously (by a message broker) and be eventually consistent. +> We assume that all "domain events" are only ever published to other subdomains that are in the same "bounded context" and thus, also in the same host process. When this is not true, for example, if subdomains of the same bounded context are split into separate host processes, then these subdomains will need to communicate with "integration events" instead, and they will be eventually consistent. +The synchronous publication of all "domain events" is handled automatically by the `IEventNotifyingStoreNotificationRelay` (after events have first been projected by the `IEventNotifyingStoreProjectionRelay`). +![Eventing](../images/Persistence-Eventing.png) +Domain 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 o 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/Persistence-Eventing.png b/docs/images/Persistence-Eventing.png index 08d0f2f9..e52e1515 100644 Binary files a/docs/images/Persistence-Eventing.png and b/docs/images/Persistence-Eventing.png differ diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx index 0bfbbcd9..198e8733 100644 Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ diff --git a/docs/images/Subdomains.png b/docs/images/Subdomains.png index 66a97cd6..3af5bd0d 100644 Binary files a/docs/images/Subdomains.png and b/docs/images/Subdomains.png differ diff --git a/src/AncillaryDomain/Events.cs b/src/AncillaryDomain/Events.cs index f9d815b8..a65abb95 100644 --- a/src/AncillaryDomain/Events.cs +++ b/src/AncillaryDomain/Events.cs @@ -12,40 +12,32 @@ public static class EmailDelivery public static Domain.Events.Shared.Ancillary.EmailDelivery.Created Created(Identifier id, QueuedMessageId messageId) { - return new Domain.Events.Shared.Ancillary.EmailDelivery.Created + return new Domain.Events.Shared.Ancillary.EmailDelivery.Created(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, MessageId = messageId }; } public static DeliveryAttempted DeliveryAttempted(Identifier id, DateTime when) { - return new DeliveryAttempted + return new DeliveryAttempted(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, When = when }; } public static DeliveryFailed DeliveryFailed(Identifier id, DateTime when) { - return new DeliveryFailed + return new DeliveryFailed(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, When = when }; } public static DeliverySucceeded DeliverySucceeded(Identifier id, DateTime when) { - return new DeliverySucceeded + return new DeliverySucceeded(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, When = when }; } @@ -53,10 +45,8 @@ public static DeliverySucceeded DeliverySucceeded(Identifier id, DateTime when) public static EmailDetailsChanged EmailDetailsChanged(Identifier id, string subject, string body, EmailRecipient to) { - return new EmailDetailsChanged + return new EmailDetailsChanged(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, Subject = subject, Body = body, ToEmailAddress = to.EmailAddress, @@ -70,10 +60,8 @@ public static class Audits public static Created Created(Identifier id, Identifier againstId, Optional organizationId, string auditCode, Optional messageTemplate, TemplateArguments templateArguments) { - return new Created + return new Created(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, OrganizationId = organizationId.HasValue ? organizationId.Value.Text : null, diff --git a/src/Application.Persistence.Interfaces/EventStreamChangeEvent.cs b/src/Application.Persistence.Interfaces/EventStreamChangeEvent.cs index 150be808..77c260ae 100644 --- a/src/Application.Persistence.Interfaces/EventStreamChangeEvent.cs +++ b/src/Application.Persistence.Interfaces/EventStreamChangeEvent.cs @@ -10,7 +10,7 @@ public class EventStreamChangeEvent { public required string Data { get; set; } - public required string EntityType { get; set; } + public required string RootAggregateType { get; set; } public required string EventType { get; set; } diff --git a/src/Application.Services.Shared/IEndUsersService.cs b/src/Application.Services.Shared/IEndUsersService.cs index b6a8888b..a6e19e9b 100644 --- a/src/Application.Services.Shared/IEndUsersService.cs +++ b/src/Application.Services.Shared/IEndUsersService.cs @@ -6,9 +6,6 @@ namespace Application.Services.Shared; public interface IEndUsersService { - Task> CreateMembershipForCallerPrivateAsync(ICallerContext caller, string organizationId, - CancellationToken cancellationToken); - Task, Error>> FindPersonByEmailPrivateAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken); diff --git a/src/Application.Services.Shared/IOrganizationsService.cs b/src/Application.Services.Shared/IOrganizationsService.cs index 101abac0..16fab2be 100644 --- a/src/Application.Services.Shared/IOrganizationsService.cs +++ b/src/Application.Services.Shared/IOrganizationsService.cs @@ -1,6 +1,5 @@ using Application.Interfaces; using Application.Interfaces.Services; -using Application.Resources.Shared; using Common; namespace Application.Services.Shared; @@ -10,9 +9,6 @@ public interface IOrganizationsService Task> ChangeSettingsPrivateAsync(ICallerContext caller, string id, TenantSettings settings, CancellationToken cancellationToken); - Task> CreateOrganizationPrivateAsync(ICallerContext caller, string creatorId, - string name, OrganizationOwnership ownership, CancellationToken cancellationToken); - Task> GetSettingsPrivateAsync(ICallerContext caller, string id, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Services.Shared/IUserProfilesService.cs b/src/Application.Services.Shared/IUserProfilesService.cs index 62bc3705..3d128aad 100644 --- a/src/Application.Services.Shared/IUserProfilesService.cs +++ b/src/Application.Services.Shared/IUserProfilesService.cs @@ -6,14 +6,6 @@ namespace Application.Services.Shared; public interface IUserProfilesService { - Task> CreateMachineProfilePrivateAsync(ICallerContext caller, string machineId, - string name, - string? timezone, string? countryCode, CancellationToken cancellationToken); - - Task> CreatePersonProfilePrivateAsync(ICallerContext caller, string personId, - string emailAddress, - string firstName, string? lastName, string? timezone, string? countryCode, CancellationToken cancellationToken); - Task, Error>> FindPersonByEmailAddressPrivateAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken); diff --git a/src/BookingsDomain.UnitTests/TripSpec.cs b/src/BookingsDomain.UnitTests/TripSpec.cs index 139ed65e..1dad60c2 100644 --- a/src/BookingsDomain.UnitTests/TripSpec.cs +++ b/src/BookingsDomain.UnitTests/TripSpec.cs @@ -1,7 +1,6 @@ using Common; using Domain.Common.Identity; using Domain.Common.ValueObjects; -using Domain.Events.Shared.Bookings; using FluentAssertions; using Moq; using UnitTesting.Common; @@ -21,7 +20,7 @@ public TripSpec() var idFactory = new FixedIdentifierFactory("anid"); _trip = Trip.Create(recorder.Object, idFactory, _ => Result.Ok).Value; - _trip.RaiseChangeEvent(TripAdded.Create("arootid".ToId(), "anorganizationid".ToId())); + _trip.RaiseChangeEvent(Events.TripAdded("arootid".ToId(), "anorganizationid".ToId())); } [Fact] diff --git a/src/BookingsDomain/BookingRoot.cs b/src/BookingsDomain/BookingRoot.cs index 15f3a4ec..d455ec8d 100644 --- a/src/BookingsDomain/BookingRoot.cs +++ b/src/BookingsDomain/BookingRoot.cs @@ -195,7 +195,7 @@ public Result StartTrip(Location from) return Error.RuleViolation(Resources.BookingRoot_ReservationRequiresCar); } - var added = RaiseChangeEvent(TripAdded.Create(Id, OrganizationId)); + var added = RaiseChangeEvent(BookingsDomain.Events.TripAdded(Id, OrganizationId)); if (!added.IsSuccessful) { return added.Error; diff --git a/src/BookingsDomain/Events.cs b/src/BookingsDomain/Events.cs index f0af4d7c..79d07207 100644 --- a/src/BookingsDomain/Events.cs +++ b/src/BookingsDomain/Events.cs @@ -7,66 +7,65 @@ public static class Events { public static CarChanged CarChanged(Identifier id, Identifier organizationId, Identifier carId) { - return new CarChanged + return new CarChanged(id) { - RootId = id, OrganizationId = organizationId, - CarId = carId, - OccurredUtc = DateTime.UtcNow + CarId = carId }; } public static Created Created(Identifier id, Identifier organizationId) { - return new Created + return new Created(id) { - RootId = id, - OrganizationId = organizationId, - OccurredUtc = DateTime.UtcNow + OrganizationId = organizationId }; } public static ReservationMade ReservationMade(Identifier id, Identifier organizationId, Identifier borrowerId, DateTime start, DateTime end) { - return new ReservationMade + return new ReservationMade(id) { - RootId = id, OrganizationId = organizationId, BorrowerId = borrowerId, Start = start, - End = end, - OccurredUtc = DateTime.UtcNow + End = end + }; + } + + public static TripAdded TripAdded(Identifier id, Identifier organizationId) + { + return new TripAdded(id) + { + OrganizationId = organizationId, + TripId = null }; } public static TripBegan TripBegan(Identifier id, Identifier organizationId, Identifier tripId, DateTime beganAt, Location from) { - return new TripBegan + return new TripBegan(id) { - RootId = id, OrganizationId = organizationId, TripId = tripId, BeganAt = beganAt, - BeganFrom = from, - OccurredUtc = DateTime.UtcNow + BeganFrom = from }; } public static TripEnded TripEnded(Identifier id, Identifier organizationId, Identifier tripId, DateTime beganAt, Location from, DateTime endedAt, Location to) { - return new TripEnded + return new TripEnded(id) { - RootId = id, OrganizationId = organizationId, TripId = tripId, BeganAt = beganAt, BeganFrom = from, EndedAt = endedAt, - EndedTo = to, - OccurredUtc = DateTime.UtcNow + EndedTo = to }; } } \ No newline at end of file diff --git a/src/CarsApplication.UnitTests/CarsApplicationSpec.cs b/src/CarsApplication.UnitTests/CarsApplicationSpec.cs index 26df4673..3ceeca48 100644 --- a/src/CarsApplication.UnitTests/CarsApplicationSpec.cs +++ b/src/CarsApplication.UnitTests/CarsApplicationSpec.cs @@ -329,7 +329,7 @@ public async Task WhenSearchAllAvailableCarsAsync_ThenReturnsAllAvailableCars() ManufactureModel = "amodel", ManufactureYear = 2023, OrganizationId = "anorganizationid", - Status = "astatus", + Status = CarStatus.Registered, VehicleOwnerId = "anownerid" } })); @@ -348,7 +348,7 @@ await _application.SearchAllAvailableCarsAsync(_caller.Object, "anorganizationid result.Value.Results[0].Owner!.Id.Should().Be("anownerid"); result.Value.Results[0].Plate!.Jurisdiction.Should().Be("ajurisdiction"); result.Value.Results[0].Plate!.Number.Should().Be("aplate"); - result.Value.Results[0].Status.Should().Be("astatus"); + result.Value.Results[0].Status.Should().Be(CarStatus.Registered.ToString()); } [Fact] @@ -369,7 +369,7 @@ public async Task WhenSearchAllCarsAsync_ThenReturnsAllCars() ManufactureModel = "amodel", ManufactureYear = 2023, OrganizationId = "anorganizationid", - Status = "astatus", + Status = CarStatus.Registered, VehicleOwnerId = "anownerid" } })); @@ -388,7 +388,7 @@ public async Task WhenSearchAllCarsAsync_ThenReturnsAllCars() result.Value.Results[0].Owner!.Id.Should().Be("anownerid"); result.Value.Results[0].Plate!.Jurisdiction.Should().Be("ajurisdiction"); result.Value.Results[0].Plate!.Number.Should().Be("aplate"); - result.Value.Results[0].Status.Should().Be("astatus"); + result.Value.Results[0].Status.Should().Be(CarStatus.Registered.ToString()); } [Fact] diff --git a/src/CarsApplication/CarsApplication.cs b/src/CarsApplication/CarsApplication.cs index 83014023..66b4c178 100644 --- a/src/CarsApplication/CarsApplication.cs +++ b/src/CarsApplication/CarsApplication.cs @@ -344,7 +344,7 @@ public static Car ToCar(this Persistence.ReadModels.Car car) }, Plate = new CarLicensePlate { Jurisdiction = car.LicenseJurisdiction, Number = car.LicenseNumber }, - Status = car.Status + Status = car.Status.ToString() }; } diff --git a/src/CarsApplication/Persistence/ReadModels/Car.cs b/src/CarsApplication/Persistence/ReadModels/Car.cs index e0f667f3..e822bdbb 100644 --- a/src/CarsApplication/Persistence/ReadModels/Car.cs +++ b/src/CarsApplication/Persistence/ReadModels/Car.cs @@ -1,6 +1,7 @@ using Application.Persistence.Common; using CarsDomain; using Common; +using Domain.Shared.Cars; using QueryAny; namespace CarsApplication.Persistence.ReadModels; @@ -22,7 +23,7 @@ public class Car : ReadModelEntity public Optional OrganizationId { get; set; } - public Optional Status { get; set; } + public Optional Status { get; set; } public Optional VehicleOwnerId { get; set; } } \ No newline at end of file diff --git a/src/CarsDomain/CarRoot.cs b/src/CarsDomain/CarRoot.cs index 3ddb56cb..f6b1d8bd 100644 --- a/src/CarsDomain/CarRoot.cs +++ b/src/CarsDomain/CarRoot.cs @@ -92,7 +92,7 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case Created created: { OrganizationId = created.OrganizationId.ToId(); - Status = created.Status.ToEnum(); + Status = created.Status; return Result.Ok; } @@ -143,7 +143,7 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco } License = plate.Value; - Status = changed.Status.ToEnum(); + Status = changed.Status; Recorder.TraceDebug(null, "Car {Id} registration changed to {Jurisdiction}, {Number}", Id, changed.Jurisdiction, changed.Number); return Result.Ok; diff --git a/src/CarsDomain/Events.cs b/src/CarsDomain/Events.cs index 50fbb653..b39edd37 100644 --- a/src/CarsDomain/Events.cs +++ b/src/CarsDomain/Events.cs @@ -1,5 +1,6 @@ using Domain.Common.ValueObjects; using Domain.Events.Shared.Cars; +using Domain.Shared.Cars; namespace CarsDomain; @@ -7,51 +8,43 @@ public static class Events { public static Created Created(Identifier id, Identifier organizationId) { - return new Created + return new Created(id) { - RootId = id, OrganizationId = organizationId, - OccurredUtc = DateTime.UtcNow, - Status = CarStatus.Unregistered.ToString() + Status = CarStatus.Unregistered }; } public static ManufacturerChanged ManufacturerChanged(Identifier id, Identifier organizationId, Manufacturer manufacturer) { - return new ManufacturerChanged + return new ManufacturerChanged(id) { - RootId = id, OrganizationId = organizationId, Year = manufacturer.Year, Make = manufacturer.Make, - Model = manufacturer.Model, - OccurredUtc = DateTime.UtcNow + Model = manufacturer.Model }; } public static OwnershipChanged OwnershipChanged(Identifier id, Identifier organizationId, VehicleOwner owner) { - return new OwnershipChanged + return new OwnershipChanged(id) { - RootId = id, OrganizationId = organizationId, Owner = owner.OwnerId, - Managers = new List { owner.OwnerId }, - OccurredUtc = DateTime.UtcNow + Managers = [owner.OwnerId] }; } public static RegistrationChanged RegistrationChanged(Identifier id, Identifier organizationId, LicensePlate plate) { - return new RegistrationChanged + return new RegistrationChanged(id) { - RootId = id, OrganizationId = organizationId, Jurisdiction = plate.Jurisdiction, Number = plate.Number, - Status = CarStatus.Registered.ToString(), - OccurredUtc = DateTime.UtcNow + Status = CarStatus.Registered }; } @@ -59,28 +52,24 @@ public static UnavailabilitySlotAdded UnavailabilitySlotAdded(Identifier id, Ide TimeSlot slot, CausedBy causedBy) { - return new UnavailabilitySlotAdded + return new UnavailabilitySlotAdded(id) { - RootId = id, OrganizationId = organizationId, From = slot.From, To = slot.To, CausedByReason = causedBy.Reason, CausedByReference = causedBy.Reference, - UnavailabilityId = null, - OccurredUtc = DateTime.UtcNow + UnavailabilityId = null }; } public static UnavailabilitySlotRemoved UnavailabilitySlotRemoved(Identifier id, Identifier organizationId, Identifier unavailabilityId) { - return new UnavailabilitySlotRemoved + return new UnavailabilitySlotRemoved(id) { - RootId = id, OrganizationId = organizationId, - UnavailabilityId = unavailabilityId, - OccurredUtc = DateTime.UtcNow + UnavailabilityId = unavailabilityId }; } } \ No newline at end of file diff --git a/src/CarsInfrastructure/Persistence/CarRepository.cs b/src/CarsInfrastructure/Persistence/CarRepository.cs index d3a9afd3..63a8c952 100644 --- a/src/CarsInfrastructure/Persistence/CarRepository.cs +++ b/src/CarsInfrastructure/Persistence/CarRepository.cs @@ -7,6 +7,7 @@ using Common; using Domain.Common.ValueObjects; using Domain.Interfaces; +using Domain.Shared.Cars; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; using QueryAny; @@ -89,7 +90,7 @@ public async Task, Error>> SearchAllAvailableCarsAsync var queriedCars = await _carQueries.QueryAsync(Query.From() .Where(u => u.OrganizationId, ConditionOperator.EqualTo, organizationId) - .AndWhere(c => c.Status, ConditionOperator.EqualTo, CarStatus.Registered.ToString()) + .AndWhere(c => c.Status, ConditionOperator.EqualTo, CarStatus.Registered) .WithSearchOptions(searchOptions), cancellationToken: cancellationToken); if (!queriedCars.IsSuccessful) { diff --git a/src/Domain.Common.UnitTests/ChangeEventTypeMigratorSpec.cs b/src/Domain.Common.UnitTests/ChangeEventTypeMigratorSpec.cs index 83ccb12e..780d9729 100644 --- a/src/Domain.Common.UnitTests/ChangeEventTypeMigratorSpec.cs +++ b/src/Domain.Common.UnitTests/ChangeEventTypeMigratorSpec.cs @@ -1,7 +1,6 @@ using Common; using Common.Extensions; using Domain.Common.Extensions; -using Domain.Interfaces.Entities; using FluentAssertions; using UnitTesting.Common; using Xunit; @@ -23,7 +22,7 @@ public ChangeEventTypeMigratorSpec() [Fact] public void WhenRehydrateAndTypeKnown_ThenReturnsNewInstance() { - var eventJson = new TestChangeEvent { RootId = "anentityid" }.ToEventJson(); + var eventJson = new TestChangeEvent().ToEventJson(); var result = _migrator.Rehydrate("aneventid", eventJson, typeof(TestChangeEvent).AssemblyQualifiedName!).Value; result.Should().BeOfType(); @@ -33,7 +32,7 @@ public void WhenRehydrateAndTypeKnown_ThenReturnsNewInstance() [Fact] public void WhenRehydrateAndUnknownType_ThenReturnsError() { - var eventJson = new TestChangeEvent { RootId = "anentityid" }.ToEventJson(); + var eventJson = new TestChangeEvent().ToEventJson(); var result = _migrator.Rehydrate("aneventid", eventJson, "anunknowntype"); @@ -45,7 +44,7 @@ public void WhenRehydrateAndUnknownType_ThenReturnsError() public void WhenRehydrateAndUnknownTypeAndMappingStillNotExist_ThenReturnsError() { _mappings.Add("anunknowntype", "anotherunknowntype"); - var eventJson = new TestChangeEvent { RootId = "anentityid" }.ToEventJson(); + var eventJson = new TestChangeEvent().ToEventJson(); var result = _migrator.Rehydrate("aneventid", eventJson, "anunknowntype"); @@ -57,7 +56,7 @@ public void WhenRehydrateAndUnknownTypeAndMappingStillNotExist_ThenReturnsError( public void WhenRehydrateAndUnknownTypeAndMappingExists_ThenReturnsNewInstance() { _mappings.Add("anunknowntype", typeof(TestRenamedChangeEvent).AssemblyQualifiedName!); - var eventJson = new TestChangeEvent { RootId = "anentityid" }.ToEventJson(); + var eventJson = new TestChangeEvent().ToEventJson(); var result = _migrator.Rehydrate("aneventid", eventJson, "anunknowntype").Value; result.Should().BeOfType(); @@ -65,16 +64,16 @@ public void WhenRehydrateAndUnknownTypeAndMappingExists_ThenReturnsNewInstance() } } -public class TestChangeEvent : IDomainEvent +public class TestChangeEvent : DomainEvent { - public string RootId { get; set; } = "anid"; - - public DateTime OccurredUtc { get; set; } = DateTime.UtcNow; + public TestChangeEvent() : base("anentityid") + { + } } -public class TestRenamedChangeEvent : IDomainEvent +public class TestRenamedChangeEvent : DomainEvent { - public string RootId { get; set; } = "anid"; - - public DateTime OccurredUtc { get; set; } = DateTime.UtcNow; + public TestRenamedChangeEvent() : base("anid") + { + } } \ No newline at end of file diff --git a/src/Domain.Common.UnitTests/Domain.Common.UnitTests.csproj b/src/Domain.Common.UnitTests/Domain.Common.UnitTests.csproj index aa40f38e..571e51d1 100644 --- a/src/Domain.Common.UnitTests/Domain.Common.UnitTests.csproj +++ b/src/Domain.Common.UnitTests/Domain.Common.UnitTests.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Domain.Common.UnitTests/Entities/AggregateRootBaseSpec.cs b/src/Domain.Common.UnitTests/Entities/AggregateRootBaseSpec.cs index dd3128ab..1b543c02 100644 --- a/src/Domain.Common.UnitTests/Entities/AggregateRootBaseSpec.cs +++ b/src/Domain.Common.UnitTests/Entities/AggregateRootBaseSpec.cs @@ -161,8 +161,8 @@ public void WhenChangeProperty_ThenRaisesEventAndModified() _aggregate.Events.Count.Should().Be(2); _aggregate.Events[0].Should().BeOfType(); - _aggregate.Events.Last().Should().BeEquivalentTo(new TestAggregateRoot.ChangeEvent - { APropertyName = "achangedvalue" }); + _aggregate.Events.Last().As().APropertyName.Should().Be("achangedvalue"); + _aggregate.Events.Last().OccurredUtc.Should().BeNear(DateTime.UtcNow); _aggregate.LastModifiedAtUtc.Should().BeNear(DateTime.UtcNow); } diff --git a/src/Domain.Common.UnitTests/Entities/TestAggregateRoot.cs b/src/Domain.Common.UnitTests/Entities/TestAggregateRoot.cs index 53f867a2..f0624572 100644 --- a/src/Domain.Common.UnitTests/Entities/TestAggregateRoot.cs +++ b/src/Domain.Common.UnitTests/Entities/TestAggregateRoot.cs @@ -43,20 +43,20 @@ public void ChangeProperty(string value) RaiseChangeEvent(new ChangeEvent { APropertyName = value }); } - public class CreateEvent : IDomainEvent + public class CreateEvent : DomainEvent { - public string RootId { get; set; } = "anid"; - - public DateTime OccurredUtc { get; set; } = DateTime.UtcNow; + public CreateEvent() : base("anid") + { + } } - public class ChangeEvent : IDomainEvent + public class ChangeEvent : DomainEvent { - public required string APropertyName { get; set; } + public ChangeEvent() : base("anid") + { + } - public string RootId { get; set; } = "anid"; - - public DateTime OccurredUtc { get; set; } + public required string APropertyName { get; set; } } } diff --git a/src/Domain.Common/DomainEvent.cs b/src/Domain.Common/DomainEvent.cs new file mode 100644 index 00000000..272a5d34 --- /dev/null +++ b/src/Domain.Common/DomainEvent.cs @@ -0,0 +1,31 @@ +using Domain.Interfaces.Entities; + +namespace Domain.Common; + +/// +/// Defines a base class for domain events +/// +#pragma warning disable SAASDDD043 +#pragma warning disable SAASDDD041 +#pragma warning disable SAASDDD042 +public abstract class DomainEvent : IDomainEvent +#pragma warning restore SAASDDD042 +#pragma warning restore SAASDDD041 +#pragma warning restore SAASDDD043 +{ + protected DomainEvent() + { + RootId = null!; + OccurredUtc = DateTime.UtcNow; + } + + protected DomainEvent(string rootId) + { + RootId = rootId; + OccurredUtc = DateTime.UtcNow; + } + + public string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } +} \ No newline at end of file diff --git a/src/Domain.Common/Entities/AggregateRootBase.cs b/src/Domain.Common/Entities/AggregateRootBase.cs index 1f9aceb4..f9f5ec84 100644 --- a/src/Domain.Common/Entities/AggregateRootBase.cs +++ b/src/Domain.Common/Entities/AggregateRootBase.cs @@ -314,12 +314,12 @@ protected Result RaiseCreateEvent(IDomainEvent @event) /// /// Raises the to an new instance of the /// - protected Result RaiseEventToChildEntity(bool isReconstituting, - TChangeEvent @event, + protected Result RaiseEventToChildEntity(bool isReconstituting, + TDomainEvent @event, Func> childEntityFactory, - Expression> eventChildId) + Expression> eventChildId) where TEntity : IEventingEntity - where TChangeEvent : IDomainEvent + where TDomainEvent : IDomainEvent { var identifierFactory = isReconstituting ? GetChildId().ToIdentifierFactory() @@ -351,9 +351,9 @@ void SetChildId(ISingleValueObject entityId) /// Raises the to an new instance of the /// // ReSharper disable once MemberCanBeMadeStatic.Global - protected Result RaiseEventToChildEntity(TChangeEvent @event, TEntity childEntity) + protected Result RaiseEventToChildEntity(TDomainEvent @event, TEntity childEntity) where TEntity : IEventingEntity - where TChangeEvent : IDomainEvent + where TDomainEvent : IDomainEvent { return childEntity.HandleStateChanged(@event); } diff --git a/src/Domain.Common/Events/Global.cs b/src/Domain.Common/Events/Global.cs index 681c8435..79c88295 100644 --- a/src/Domain.Common/Events/Global.cs +++ b/src/Domain.Common/Events/Global.cs @@ -1,5 +1,6 @@ using Domain.Common.ValueObjects; using Domain.Interfaces.Entities; +using JetBrains.Annotations; namespace Domain.Common.Events; @@ -8,24 +9,27 @@ public static class Global /// /// Defines an event raised when a stream is deleted /// - public class StreamDeleted : ITombstoneEvent + public class StreamDeleted : DomainEvent, ITombstoneEvent { public static StreamDeleted Create(Identifier id, Identifier deletedById) { - return new StreamDeleted + return new StreamDeleted(id) { - RootId = id, DeletedById = deletedById, - IsTombstone = true, - OccurredUtc = DateTime.UtcNow + IsTombstone = true }; } - public required string DeletedById { get; set; } + public StreamDeleted(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public StreamDeleted() + { + } - public required DateTime OccurredUtc { get; set; } + public required string DeletedById { get; set; } public required bool IsTombstone { get; set; } } diff --git a/src/Domain.Events.Shared/Ancillary/Audits/Created.cs b/src/Domain.Events.Shared/Ancillary/Audits/Created.cs index cd25f570..3067e394 100644 --- a/src/Domain.Events.Shared/Ancillary/Audits/Created.cs +++ b/src/Domain.Events.Shared/Ancillary/Audits/Created.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Ancillary.Audits; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { + public Created(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public Created() + { + } + public required string AgainstId { get; set; } public required string AuditCode { get; set; } @@ -13,8 +24,4 @@ public sealed class Created : IDomainEvent public string? OrganizationId { get; set; } public required List TemplateArguments { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Ancillary/EmailDelivery/Created.cs b/src/Domain.Events.Shared/Ancillary/EmailDelivery/Created.cs index 142d5392..764d240c 100644 --- a/src/Domain.Events.Shared/Ancillary/EmailDelivery/Created.cs +++ b/src/Domain.Events.Shared/Ancillary/EmailDelivery/Created.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Ancillary.EmailDelivery; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { - public required string MessageId { get; set; } + public Created(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public Created() + { + } - public required DateTime OccurredUtc { get; set; } + public required string MessageId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliveryAttempted.cs b/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliveryAttempted.cs index 05dc3fbb..a283e09f 100644 --- a/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliveryAttempted.cs +++ b/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliveryAttempted.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Ancillary.EmailDelivery; -public sealed class DeliveryAttempted : IDomainEvent +public sealed class DeliveryAttempted : DomainEvent { - public required DateTime When { get; set; } + public DeliveryAttempted(Identifier id) : base(id) + { + } - public required DateTime OccurredUtc { get; set; } + [UsedImplicitly] + public DeliveryAttempted() + { + } - public required string RootId { get; set; } + public required DateTime When { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliveryFailed.cs b/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliveryFailed.cs index 5339e65a..f2eef431 100644 --- a/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliveryFailed.cs +++ b/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliveryFailed.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Ancillary.EmailDelivery; -public sealed class DeliveryFailed : IDomainEvent +public sealed class DeliveryFailed : DomainEvent { - public required DateTime When { get; set; } + public DeliveryFailed(Identifier id) : base(id) + { + } - public required DateTime OccurredUtc { get; set; } + [UsedImplicitly] + public DeliveryFailed() + { + } - public required string RootId { get; set; } + public required DateTime When { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliverySucceeded.cs b/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliverySucceeded.cs index f13df0b6..6c39974d 100644 --- a/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliverySucceeded.cs +++ b/src/Domain.Events.Shared/Ancillary/EmailDelivery/DeliverySucceeded.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Ancillary.EmailDelivery; -public sealed class DeliverySucceeded : IDomainEvent +public sealed class DeliverySucceeded : DomainEvent { - public required DateTime When { get; set; } + public DeliverySucceeded(Identifier id) : base(id) + { + } - public required DateTime OccurredUtc { get; set; } + [UsedImplicitly] + public DeliverySucceeded() + { + } - public required string RootId { get; set; } + public required DateTime When { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Ancillary/EmailDelivery/EmailDetailsChanged.cs b/src/Domain.Events.Shared/Ancillary/EmailDelivery/EmailDetailsChanged.cs index 194d69ae..9378b798 100644 --- a/src/Domain.Events.Shared/Ancillary/EmailDelivery/EmailDetailsChanged.cs +++ b/src/Domain.Events.Shared/Ancillary/EmailDelivery/EmailDetailsChanged.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Ancillary.EmailDelivery; -public sealed class EmailDetailsChanged : IDomainEvent +public sealed class EmailDetailsChanged : DomainEvent { + public EmailDetailsChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public EmailDetailsChanged() + { + } + public required string Body { get; set; } public required string Subject { get; set; } @@ -11,8 +22,4 @@ public sealed class EmailDetailsChanged : IDomainEvent public required string ToDisplayName { get; set; } public required string ToEmailAddress { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Bookings/CarChanged.cs b/src/Domain.Events.Shared/Bookings/CarChanged.cs index d073ad6c..55ea4082 100644 --- a/src/Domain.Events.Shared/Bookings/CarChanged.cs +++ b/src/Domain.Events.Shared/Bookings/CarChanged.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Bookings; -public sealed class CarChanged : IDomainEvent +public sealed class CarChanged : DomainEvent { - public required string CarId { get; set; } + public CarChanged(Identifier id) : base(id) + { + } - public required string OrganizationId { get; set; } + [UsedImplicitly] + public CarChanged() + { + } - public required string RootId { get; set; } + public required string CarId { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string OrganizationId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Bookings/Created.cs b/src/Domain.Events.Shared/Bookings/Created.cs index dca639f9..6d0f4ebc 100644 --- a/src/Domain.Events.Shared/Bookings/Created.cs +++ b/src/Domain.Events.Shared/Bookings/Created.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Bookings; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { - public required string OrganizationId { get; set; } + public Created(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public Created() + { + } - public required DateTime OccurredUtc { get; set; } + public required string OrganizationId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Bookings/ReservationMade.cs b/src/Domain.Events.Shared/Bookings/ReservationMade.cs index 3e257ba7..10fc8f94 100644 --- a/src/Domain.Events.Shared/Bookings/ReservationMade.cs +++ b/src/Domain.Events.Shared/Bookings/ReservationMade.cs @@ -1,11 +1,22 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Bookings; #pragma warning disable SAASDDD043 -public sealed class ReservationMade : IDomainEvent +public sealed class ReservationMade : DomainEvent #pragma warning restore SAASDDD043 { + public ReservationMade(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public ReservationMade() + { + } + public required string BorrowerId { get; set; } public required DateTime End { get; set; } @@ -13,8 +24,4 @@ public sealed class ReservationMade : IDomainEvent public required string OrganizationId { get; set; } public required DateTime Start { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Bookings/TripAdded.cs b/src/Domain.Events.Shared/Bookings/TripAdded.cs index 79cca4c3..3b15d63d 100644 --- a/src/Domain.Events.Shared/Bookings/TripAdded.cs +++ b/src/Domain.Events.Shared/Bookings/TripAdded.cs @@ -1,26 +1,21 @@ +using Domain.Common; using Domain.Common.ValueObjects; -using Domain.Interfaces.Entities; +using JetBrains.Annotations; namespace Domain.Events.Shared.Bookings; -public sealed class TripAdded : IDomainEvent +public sealed class TripAdded : DomainEvent { - public static TripAdded Create(Identifier id, Identifier organizationId) + public TripAdded(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public TripAdded() { - return new TripAdded - { - RootId = id, - OrganizationId = organizationId, - TripId = null, - OccurredUtc = DateTime.UtcNow - }; } public required string OrganizationId { get; set; } public string? TripId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Bookings/TripBegan.cs b/src/Domain.Events.Shared/Bookings/TripBegan.cs index 7f3fea0e..07b7bd16 100644 --- a/src/Domain.Events.Shared/Bookings/TripBegan.cs +++ b/src/Domain.Events.Shared/Bookings/TripBegan.cs @@ -1,11 +1,22 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Bookings; #pragma warning disable SAASDDD043 -public sealed class TripBegan : IDomainEvent +public sealed class TripBegan : DomainEvent #pragma warning restore SAASDDD043 { + public TripBegan(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public TripBegan() + { + } + public required DateTime BeganAt { get; set; } public required string BeganFrom { get; set; } @@ -13,8 +24,4 @@ public sealed class TripBegan : IDomainEvent public required string OrganizationId { get; set; } public required string TripId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Bookings/TripEnded.cs b/src/Domain.Events.Shared/Bookings/TripEnded.cs index 2215c618..a0812c31 100644 --- a/src/Domain.Events.Shared/Bookings/TripEnded.cs +++ b/src/Domain.Events.Shared/Bookings/TripEnded.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Bookings; -public sealed class TripEnded : IDomainEvent +public sealed class TripEnded : DomainEvent { + public TripEnded(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public TripEnded() + { + } + public required DateTime BeganAt { get; set; } public required string BeganFrom { get; set; } @@ -15,8 +26,4 @@ public sealed class TripEnded : IDomainEvent public required string OrganizationId { get; set; } public required string TripId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Cars/Created.cs b/src/Domain.Events.Shared/Cars/Created.cs index dacbc524..50b8372a 100644 --- a/src/Domain.Events.Shared/Cars/Created.cs +++ b/src/Domain.Events.Shared/Cars/Created.cs @@ -1,14 +1,22 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using Domain.Shared.Cars; +using JetBrains.Annotations; namespace Domain.Events.Shared.Cars; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { - public required string OrganizationId { get; set; } + public Created(Identifier id) : base(id) + { + } - public required string Status { get; set; } + [UsedImplicitly] + public Created() + { + } - public required string RootId { get; set; } + public required string OrganizationId { get; set; } - public required DateTime OccurredUtc { get; set; } + public required CarStatus Status { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Cars/ManufacturerChanged.cs b/src/Domain.Events.Shared/Cars/ManufacturerChanged.cs index 62de6340..c0761e38 100644 --- a/src/Domain.Events.Shared/Cars/ManufacturerChanged.cs +++ b/src/Domain.Events.Shared/Cars/ManufacturerChanged.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Cars; -public sealed class ManufacturerChanged : IDomainEvent +public sealed class ManufacturerChanged : DomainEvent { + public ManufacturerChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public ManufacturerChanged() + { + } + public required string Make { get; set; } public required string Model { get; set; } @@ -11,8 +22,4 @@ public sealed class ManufacturerChanged : IDomainEvent public required string OrganizationId { get; set; } public required int Year { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Cars/OwnershipChanged.cs b/src/Domain.Events.Shared/Cars/OwnershipChanged.cs index ef2211ac..394843b6 100644 --- a/src/Domain.Events.Shared/Cars/OwnershipChanged.cs +++ b/src/Domain.Events.Shared/Cars/OwnershipChanged.cs @@ -1,16 +1,23 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Cars; -public sealed class OwnershipChanged : IDomainEvent +public sealed class OwnershipChanged : DomainEvent { + public OwnershipChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public OwnershipChanged() + { + } + public required List Managers { get; set; } public required string OrganizationId { get; set; } public required string Owner { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Cars/RegistrationChanged.cs b/src/Domain.Events.Shared/Cars/RegistrationChanged.cs index 79d62301..4dcee246 100644 --- a/src/Domain.Events.Shared/Cars/RegistrationChanged.cs +++ b/src/Domain.Events.Shared/Cars/RegistrationChanged.cs @@ -1,18 +1,26 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using Domain.Shared.Cars; +using JetBrains.Annotations; namespace Domain.Events.Shared.Cars; -public sealed class RegistrationChanged : IDomainEvent +public sealed class RegistrationChanged : DomainEvent { + public RegistrationChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public RegistrationChanged() + { + } + public required string Jurisdiction { get; set; } public required string Number { get; set; } public required string OrganizationId { get; set; } - public required string Status { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } + public required CarStatus Status { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Cars/UnavailabilitySlotAdded.cs b/src/Domain.Events.Shared/Cars/UnavailabilitySlotAdded.cs index 4621c9e4..f976514e 100644 --- a/src/Domain.Events.Shared/Cars/UnavailabilitySlotAdded.cs +++ b/src/Domain.Events.Shared/Cars/UnavailabilitySlotAdded.cs @@ -1,10 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; using Domain.Shared.Cars; +using JetBrains.Annotations; namespace Domain.Events.Shared.Cars; -public sealed class UnavailabilitySlotAdded : IDomainEvent +public sealed class UnavailabilitySlotAdded : DomainEvent { + public UnavailabilitySlotAdded(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public UnavailabilitySlotAdded() + { + } + public required UnavailabilityCausedBy CausedByReason { get; set; } public string? CausedByReference { get; set; } @@ -16,8 +27,4 @@ public sealed class UnavailabilitySlotAdded : IDomainEvent public required DateTime To { get; set; } public string? UnavailabilityId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Cars/UnavailabilitySlotRemoved.cs b/src/Domain.Events.Shared/Cars/UnavailabilitySlotRemoved.cs index 01c302b0..fd48cdf4 100644 --- a/src/Domain.Events.Shared/Cars/UnavailabilitySlotRemoved.cs +++ b/src/Domain.Events.Shared/Cars/UnavailabilitySlotRemoved.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Cars; -public sealed class UnavailabilitySlotRemoved : IDomainEvent +public sealed class UnavailabilitySlotRemoved : DomainEvent { - public required string OrganizationId { get; set; } + public UnavailabilitySlotRemoved(Identifier id) : base(id) + { + } - public required string UnavailabilityId { get; set; } + [UsedImplicitly] + public UnavailabilitySlotRemoved() + { + } - public required string RootId { get; set; } + public required string OrganizationId { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string UnavailabilityId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/Created.cs b/src/Domain.Events.Shared/EndUsers/Created.cs index 9c838918..6ffae9f3 100644 --- a/src/Domain.Events.Shared/EndUsers/Created.cs +++ b/src/Domain.Events.Shared/EndUsers/Created.cs @@ -1,16 +1,24 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using Domain.Shared.EndUsers; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { - public required string Access { get; set; } + public Created(Identifier id) : base(id) + { + } - public required string Classification { get; set; } + [UsedImplicitly] + public Created() + { + } - public required string Status { get; set; } + public UserAccess Access { get; set; } - public required string RootId { get; set; } + public UserClassification Classification { get; set; } - public required DateTime OccurredUtc { get; set; } + public UserStatus Status { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/GuestInvitationAccepted.cs b/src/Domain.Events.Shared/EndUsers/GuestInvitationAccepted.cs index ba4dc3cb..482637e0 100644 --- a/src/Domain.Events.Shared/EndUsers/GuestInvitationAccepted.cs +++ b/src/Domain.Events.Shared/EndUsers/GuestInvitationAccepted.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class GuestInvitationAccepted : IDomainEvent +public sealed class GuestInvitationAccepted : DomainEvent { - public required DateTime AcceptedAtUtc { get; set; } + public GuestInvitationAccepted(Identifier id) : base(id) + { + } - public required string AcceptedEmailAddress { get; set; } + [UsedImplicitly] + public GuestInvitationAccepted() + { + } - public required string RootId { get; set; } + public required DateTime AcceptedAtUtc { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string AcceptedEmailAddress { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/GuestInvitationCreated.cs b/src/Domain.Events.Shared/EndUsers/GuestInvitationCreated.cs index aa2f8bec..e7b59801 100644 --- a/src/Domain.Events.Shared/EndUsers/GuestInvitationCreated.cs +++ b/src/Domain.Events.Shared/EndUsers/GuestInvitationCreated.cs @@ -1,16 +1,23 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class GuestInvitationCreated : IDomainEvent +public sealed class GuestInvitationCreated : DomainEvent { + public GuestInvitationCreated(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public GuestInvitationCreated() + { + } + public required string EmailAddress { get; set; } public required string InvitedById { get; set; } public required string Token { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/MembershipAdded.cs b/src/Domain.Events.Shared/EndUsers/MembershipAdded.cs index 5b677c78..5343c264 100644 --- a/src/Domain.Events.Shared/EndUsers/MembershipAdded.cs +++ b/src/Domain.Events.Shared/EndUsers/MembershipAdded.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class MembershipAdded : IDomainEvent +public sealed class MembershipAdded : DomainEvent { + public MembershipAdded(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public MembershipAdded() + { + } + public required List Features { get; set; } public required bool IsDefault { get; set; } @@ -13,8 +24,4 @@ public sealed class MembershipAdded : IDomainEvent public required string OrganizationId { get; set; } public required List Roles { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs b/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs index 0c75f3e2..cae190b1 100644 --- a/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs +++ b/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class MembershipDefaultChanged : IDomainEvent +public sealed class MembershipDefaultChanged : DomainEvent { - public required string FromMembershipId { get; set; } + public MembershipDefaultChanged(Identifier id) : base(id) + { + } - public required string ToMembershipId { get; set; } + [UsedImplicitly] + public MembershipDefaultChanged() + { + } - public required string RootId { get; set; } + public required string FromMembershipId { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string ToMembershipId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/MembershipFeatureAssigned.cs b/src/Domain.Events.Shared/EndUsers/MembershipFeatureAssigned.cs index b9a5cb73..dc97f9ad 100644 --- a/src/Domain.Events.Shared/EndUsers/MembershipFeatureAssigned.cs +++ b/src/Domain.Events.Shared/EndUsers/MembershipFeatureAssigned.cs @@ -1,16 +1,23 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class MembershipFeatureAssigned : IDomainEvent +public sealed class MembershipFeatureAssigned : DomainEvent { + public MembershipFeatureAssigned(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public MembershipFeatureAssigned() + { + } + public required string Feature { get; set; } public required string MembershipId { get; set; } public required string OrganizationId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/MembershipRoleAssigned.cs b/src/Domain.Events.Shared/EndUsers/MembershipRoleAssigned.cs index 81485346..df6531dd 100644 --- a/src/Domain.Events.Shared/EndUsers/MembershipRoleAssigned.cs +++ b/src/Domain.Events.Shared/EndUsers/MembershipRoleAssigned.cs @@ -1,16 +1,23 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class MembershipRoleAssigned : IDomainEvent +public sealed class MembershipRoleAssigned : DomainEvent { + public MembershipRoleAssigned(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public MembershipRoleAssigned() + { + } + public required string MembershipId { get; set; } public required string OrganizationId { get; set; } public required string Role { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/PlatformFeatureAssigned.cs b/src/Domain.Events.Shared/EndUsers/PlatformFeatureAssigned.cs index 1e953762..406a7c4c 100644 --- a/src/Domain.Events.Shared/EndUsers/PlatformFeatureAssigned.cs +++ b/src/Domain.Events.Shared/EndUsers/PlatformFeatureAssigned.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class PlatformFeatureAssigned : IDomainEvent +public sealed class PlatformFeatureAssigned : DomainEvent { - public required string Feature { get; set; } + public PlatformFeatureAssigned(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public PlatformFeatureAssigned() + { + } - public required DateTime OccurredUtc { get; set; } + public required string Feature { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/PlatformRoleAssigned.cs b/src/Domain.Events.Shared/EndUsers/PlatformRoleAssigned.cs index 41bdce85..c6a4f2da 100644 --- a/src/Domain.Events.Shared/EndUsers/PlatformRoleAssigned.cs +++ b/src/Domain.Events.Shared/EndUsers/PlatformRoleAssigned.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class PlatformRoleAssigned : IDomainEvent +public sealed class PlatformRoleAssigned : DomainEvent { - public required string Role { get; set; } + public PlatformRoleAssigned(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public PlatformRoleAssigned() + { + } - public required DateTime OccurredUtc { get; set; } + public required string Role { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/PlatformRoleUnassigned.cs b/src/Domain.Events.Shared/EndUsers/PlatformRoleUnassigned.cs index c535d3d0..840e6b0a 100644 --- a/src/Domain.Events.Shared/EndUsers/PlatformRoleUnassigned.cs +++ b/src/Domain.Events.Shared/EndUsers/PlatformRoleUnassigned.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class PlatformRoleUnassigned : IDomainEvent +public sealed class PlatformRoleUnassigned : DomainEvent { - public required string Role { get; set; } + public PlatformRoleUnassigned(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public PlatformRoleUnassigned() + { + } - public required DateTime OccurredUtc { get; set; } + public required string Role { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/Registered.cs b/src/Domain.Events.Shared/EndUsers/Registered.cs index 2304bf35..edbc21ae 100644 --- a/src/Domain.Events.Shared/EndUsers/Registered.cs +++ b/src/Domain.Events.Shared/EndUsers/Registered.cs @@ -1,22 +1,32 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using Domain.Shared.EndUsers; +using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; -public sealed class Registered : IDomainEvent +public sealed class Registered : DomainEvent { - public required string Access { get; set; } + public Registered(Identifier id) : base(id) + { + } - public required string Classification { get; set; } + [UsedImplicitly] + public Registered() + { + } + + public UserAccess Access { get; set; } + + public UserClassification Classification { get; set; } public required List Features { get; set; } public required List Roles { get; set; } - public required string Status { get; set; } + public UserStatus Status { get; set; } public string? Username { get; set; } - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } + public required RegisteredUserProfile UserProfile { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/APIKeys/Created.cs b/src/Domain.Events.Shared/Identities/APIKeys/Created.cs index 79c344ea..a93fca10 100644 --- a/src/Domain.Events.Shared/Identities/APIKeys/Created.cs +++ b/src/Domain.Events.Shared/Identities/APIKeys/Created.cs @@ -1,16 +1,23 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.APIKeys; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { + public Created(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public Created() + { + } + public required string KeyHash { get; set; } public required string KeyToken { get; set; } public required string UserId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/APIKeys/KeyVerified.cs b/src/Domain.Events.Shared/Identities/APIKeys/KeyVerified.cs index 1919a516..e03106f9 100644 --- a/src/Domain.Events.Shared/Identities/APIKeys/KeyVerified.cs +++ b/src/Domain.Events.Shared/Identities/APIKeys/KeyVerified.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.APIKeys; -public sealed class KeyVerified : IDomainEvent +public sealed class KeyVerified : DomainEvent { - public required bool IsVerified { get; set; } + public KeyVerified(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public KeyVerified() + { + } - public required DateTime OccurredUtc { get; set; } + public required bool IsVerified { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/APIKeys/ParametersChanged.cs b/src/Domain.Events.Shared/Identities/APIKeys/ParametersChanged.cs index 6af5ffc7..5f85197e 100644 --- a/src/Domain.Events.Shared/Identities/APIKeys/ParametersChanged.cs +++ b/src/Domain.Events.Shared/Identities/APIKeys/ParametersChanged.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.APIKeys; -public sealed class ParametersChanged : IDomainEvent +public sealed class ParametersChanged : DomainEvent { - public required string Description { get; set; } + public ParametersChanged(Identifier id) : base(id) + { + } - public required DateTime ExpiresOn { get; set; } + [UsedImplicitly] + public ParametersChanged() + { + } - public required string RootId { get; set; } + public required string Description { get; set; } - public required DateTime OccurredUtc { get; set; } + public required DateTime ExpiresOn { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/AuthTokens/Created.cs b/src/Domain.Events.Shared/Identities/AuthTokens/Created.cs index 5c8bd04f..ddefbc59 100644 --- a/src/Domain.Events.Shared/Identities/AuthTokens/Created.cs +++ b/src/Domain.Events.Shared/Identities/AuthTokens/Created.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.AuthTokens; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { - public required string UserId { get; set; } + public Created(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public Created() + { + } - public required DateTime OccurredUtc { get; set; } + public required string UserId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/AuthTokens/TokensChanged.cs b/src/Domain.Events.Shared/Identities/AuthTokens/TokensChanged.cs index 3df89691..343c9600 100644 --- a/src/Domain.Events.Shared/Identities/AuthTokens/TokensChanged.cs +++ b/src/Domain.Events.Shared/Identities/AuthTokens/TokensChanged.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.AuthTokens; -public sealed class TokensChanged : IDomainEvent +public sealed class TokensChanged : DomainEvent { + public TokensChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public TokensChanged() + { + } + public required string AccessToken { get; set; } public required DateTime AccessTokenExpiresOn { get; set; } @@ -13,8 +24,4 @@ public sealed class TokensChanged : IDomainEvent public required DateTime RefreshTokenExpiresOn { get; set; } public required string UserId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/AuthTokens/TokensRefreshed.cs b/src/Domain.Events.Shared/Identities/AuthTokens/TokensRefreshed.cs index 654b018a..f95bd49b 100644 --- a/src/Domain.Events.Shared/Identities/AuthTokens/TokensRefreshed.cs +++ b/src/Domain.Events.Shared/Identities/AuthTokens/TokensRefreshed.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.AuthTokens; -public sealed class TokensRefreshed : IDomainEvent +public sealed class TokensRefreshed : DomainEvent { + public TokensRefreshed(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public TokensRefreshed() + { + } + public required string AccessToken { get; set; } public required DateTime AccessTokenExpiresOn { get; set; } @@ -13,8 +24,4 @@ public sealed class TokensRefreshed : IDomainEvent public required DateTime RefreshTokenExpiresOn { get; set; } public required string UserId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/AuthTokens/TokensRevoked.cs b/src/Domain.Events.Shared/Identities/AuthTokens/TokensRevoked.cs index e68bcfa3..f3d23bc7 100644 --- a/src/Domain.Events.Shared/Identities/AuthTokens/TokensRevoked.cs +++ b/src/Domain.Events.Shared/Identities/AuthTokens/TokensRevoked.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.AuthTokens; -public sealed class TokensRevoked : IDomainEvent +public sealed class TokensRevoked : DomainEvent { - public required string UserId { get; set; } + public TokensRevoked(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public TokensRevoked() + { + } - public required DateTime OccurredUtc { get; set; } + public required string UserId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/AccountLocked.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/AccountLocked.cs index 31ebbb83..c95df9dc 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/AccountLocked.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/AccountLocked.cs @@ -1,10 +1,17 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class AccountLocked : IDomainEvent +public sealed class AccountLocked : DomainEvent { - public required string RootId { get; set; } + public AccountLocked(Identifier id) : base(id) + { + } - public required DateTime OccurredUtc { get; set; } + [UsedImplicitly] + public AccountLocked() + { + } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/AccountUnlocked.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/AccountUnlocked.cs index 31dc3fb9..6ba45382 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/AccountUnlocked.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/AccountUnlocked.cs @@ -1,10 +1,17 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class AccountUnlocked : IDomainEvent +public sealed class AccountUnlocked : DomainEvent { - public required string RootId { get; set; } + public AccountUnlocked(Identifier id) : base(id) + { + } - public required DateTime OccurredUtc { get; set; } + [UsedImplicitly] + public AccountUnlocked() + { + } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/Created.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/Created.cs index bd5c3c33..f89a6ae9 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/Created.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/Created.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { - public required string UserId { get; set; } + public Created(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public Created() + { + } - public required DateTime OccurredUtc { get; set; } + public required string UserId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/CredentialsChanged.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/CredentialsChanged.cs index b4e8802e..aff9c681 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/CredentialsChanged.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/CredentialsChanged.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class CredentialsChanged : IDomainEvent +public sealed class CredentialsChanged : DomainEvent { - public required string PasswordHash { get; set; } + public CredentialsChanged(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public CredentialsChanged() + { + } - public required DateTime OccurredUtc { get; set; } + public required string PasswordHash { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordResetCompleted.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordResetCompleted.cs index c13a466c..c1052f60 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordResetCompleted.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordResetCompleted.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class PasswordResetCompleted : IDomainEvent +public sealed class PasswordResetCompleted : DomainEvent { - public required string PasswordHash { get; set; } + public PasswordResetCompleted(Identifier id) : base(id) + { + } - public required string Token { get; set; } + [UsedImplicitly] + public PasswordResetCompleted() + { + } - public required string RootId { get; set; } + public required string PasswordHash { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string Token { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordResetInitiated.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordResetInitiated.cs index cdab4996..6c515124 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordResetInitiated.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordResetInitiated.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class PasswordResetInitiated : IDomainEvent +public sealed class PasswordResetInitiated : DomainEvent { - public required string Token { get; set; } + public PasswordResetInitiated(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public PasswordResetInitiated() + { + } - public required DateTime OccurredUtc { get; set; } + public required string Token { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordVerified.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordVerified.cs index 7033d830..c436b11d 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordVerified.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/PasswordVerified.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class PasswordVerified : IDomainEvent +public sealed class PasswordVerified : DomainEvent { - public required bool AuditAttempt { get; set; } + public PasswordVerified(Identifier id) : base(id) + { + } - public required bool IsVerified { get; set; } + [UsedImplicitly] + public PasswordVerified() + { + } - public required string RootId { get; set; } + public required bool AuditAttempt { get; set; } - public required DateTime OccurredUtc { get; set; } + public required bool IsVerified { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationChanged.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationChanged.cs index 1d339d5e..cd3e63b7 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationChanged.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationChanged.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class RegistrationChanged : IDomainEvent +public sealed class RegistrationChanged : DomainEvent { - public required string EmailAddress { get; set; } + public RegistrationChanged(Identifier id) : base(id) + { + } - public required string Name { get; set; } + [UsedImplicitly] + public RegistrationChanged() + { + } - public required string RootId { get; set; } + public required string EmailAddress { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string Name { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationVerificationCreated.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationVerificationCreated.cs index 55a46fc0..db24ffa9 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationVerificationCreated.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationVerificationCreated.cs @@ -1,12 +1,19 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class RegistrationVerificationCreated : IDomainEvent +public sealed class RegistrationVerificationCreated : DomainEvent { - public required string Token { get; set; } + public RegistrationVerificationCreated(Identifier id) : base(id) + { + } - public required string RootId { get; set; } + [UsedImplicitly] + public RegistrationVerificationCreated() + { + } - public required DateTime OccurredUtc { get; set; } + public required string Token { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationVerificationVerified.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationVerificationVerified.cs index dfbec156..f74c31db 100644 --- a/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationVerificationVerified.cs +++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/RegistrationVerificationVerified.cs @@ -1,10 +1,17 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.PasswordCredentials; -public sealed class RegistrationVerificationVerified : IDomainEvent +public sealed class RegistrationVerificationVerified : DomainEvent { - public required string RootId { get; set; } + public RegistrationVerificationVerified(Identifier id) : base(id) + { + } - public required DateTime OccurredUtc { get; set; } + [UsedImplicitly] + public RegistrationVerificationVerified() + { + } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/SSOUsers/Created.cs b/src/Domain.Events.Shared/Identities/SSOUsers/Created.cs index b7956520..e345f216 100644 --- a/src/Domain.Events.Shared/Identities/SSOUsers/Created.cs +++ b/src/Domain.Events.Shared/Identities/SSOUsers/Created.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.SSOUsers; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { - public required string ProviderName { get; set; } + public Created(Identifier id) : base(id) + { + } - public required string UserId { get; set; } + [UsedImplicitly] + public Created() + { + } - public required string RootId { get; set; } + public required string ProviderName { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string UserId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/SSOUsers/TokensUpdated.cs b/src/Domain.Events.Shared/Identities/SSOUsers/TokensUpdated.cs index 2136f743..8bb8457a 100644 --- a/src/Domain.Events.Shared/Identities/SSOUsers/TokensUpdated.cs +++ b/src/Domain.Events.Shared/Identities/SSOUsers/TokensUpdated.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.Identities.SSOUsers; -public sealed class TokensUpdated : IDomainEvent +public sealed class TokensUpdated : DomainEvent { + public TokensUpdated(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public TokensUpdated() + { + } + public required string CountryCode { get; set; } public required string EmailAddress { get; set; } @@ -15,8 +26,4 @@ public sealed class TokensUpdated : IDomainEvent public required string Timezone { get; set; } public required string Tokens { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Organizations/Created.cs b/src/Domain.Events.Shared/Organizations/Created.cs index 7bd9907d..4aa0f376 100644 --- a/src/Domain.Events.Shared/Organizations/Created.cs +++ b/src/Domain.Events.Shared/Organizations/Created.cs @@ -1,17 +1,24 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; using Domain.Shared.Organizations; +using JetBrains.Annotations; namespace Domain.Events.Shared.Organizations; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { - public required string CreatedById { get; set; } + public Created(Identifier id) : base(id) + { + } - public required string Name { get; set; } + [UsedImplicitly] + public Created() + { + } - public required OrganizationOwnership Ownership { get; set; } + public required string CreatedById { get; set; } - public required string RootId { get; set; } + public required string Name { get; set; } - public required DateTime OccurredUtc { get; set; } + public OrganizationOwnership Ownership { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Organizations/SettingCreated.cs b/src/Domain.Events.Shared/Organizations/SettingCreated.cs index 61e40d02..61fd4efd 100644 --- a/src/Domain.Events.Shared/Organizations/SettingCreated.cs +++ b/src/Domain.Events.Shared/Organizations/SettingCreated.cs @@ -1,10 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; using Domain.Shared.Organizations; +using JetBrains.Annotations; namespace Domain.Events.Shared.Organizations; -public sealed class SettingCreated : IDomainEvent +public sealed class SettingCreated : DomainEvent { + public SettingCreated(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public SettingCreated() + { + } + public required bool IsEncrypted { get; set; } public required string Name { get; set; } @@ -12,8 +23,4 @@ public sealed class SettingCreated : IDomainEvent public required string StringValue { get; set; } public required SettingValueType ValueType { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Organizations/SettingUpdated.cs b/src/Domain.Events.Shared/Organizations/SettingUpdated.cs index c8a5c299..250641d1 100644 --- a/src/Domain.Events.Shared/Organizations/SettingUpdated.cs +++ b/src/Domain.Events.Shared/Organizations/SettingUpdated.cs @@ -1,10 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; using Domain.Shared.Organizations; +using JetBrains.Annotations; namespace Domain.Events.Shared.Organizations; -public sealed class SettingUpdated : IDomainEvent +public sealed class SettingUpdated : DomainEvent { + public SettingUpdated(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public SettingUpdated() + { + } + public required string From { get; set; } public required SettingValueType FromType { get; set; } @@ -16,8 +27,4 @@ public sealed class SettingUpdated : IDomainEvent public required string To { get; set; } public required SettingValueType ToType { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/UserProfiles/ContactAddressChanged.cs b/src/Domain.Events.Shared/UserProfiles/ContactAddressChanged.cs index 8f79a4fc..2ff5db71 100644 --- a/src/Domain.Events.Shared/UserProfiles/ContactAddressChanged.cs +++ b/src/Domain.Events.Shared/UserProfiles/ContactAddressChanged.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.UserProfiles; -public sealed class ContactAddressChanged : IDomainEvent +public sealed class ContactAddressChanged : DomainEvent { + public ContactAddressChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public ContactAddressChanged() + { + } + public string? City { get; set; } public required string CountryCode { get; set; } @@ -19,8 +30,4 @@ public sealed class ContactAddressChanged : IDomainEvent public required string UserId { get; set; } public string? Zip { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/UserProfiles/Created.cs b/src/Domain.Events.Shared/UserProfiles/Created.cs index f28d432e..c8b35d00 100644 --- a/src/Domain.Events.Shared/UserProfiles/Created.cs +++ b/src/Domain.Events.Shared/UserProfiles/Created.cs @@ -1,9 +1,20 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.UserProfiles; -public sealed class Created : IDomainEvent +public sealed class Created : DomainEvent { + public Created(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public Created() + { + } + public required string DisplayName { get; set; } public required string FirstName { get; set; } @@ -13,8 +24,4 @@ public sealed class Created : IDomainEvent public required string Type { get; set; } public required string UserId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/UserProfiles/DisplayNameChanged.cs b/src/Domain.Events.Shared/UserProfiles/DisplayNameChanged.cs index fc23dadf..7940c3a1 100644 --- a/src/Domain.Events.Shared/UserProfiles/DisplayNameChanged.cs +++ b/src/Domain.Events.Shared/UserProfiles/DisplayNameChanged.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.UserProfiles; -public sealed class DisplayNameChanged : IDomainEvent +public sealed class DisplayNameChanged : DomainEvent { - public required string DisplayName { get; set; } + public DisplayNameChanged(Identifier id) : base(id) + { + } - public required string UserId { get; set; } + [UsedImplicitly] + public DisplayNameChanged() + { + } - public required string RootId { get; set; } + public required string DisplayName { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string UserId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/UserProfiles/EmailAddressChanged.cs b/src/Domain.Events.Shared/UserProfiles/EmailAddressChanged.cs index 4c8910fa..8e7d4ede 100644 --- a/src/Domain.Events.Shared/UserProfiles/EmailAddressChanged.cs +++ b/src/Domain.Events.Shared/UserProfiles/EmailAddressChanged.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.UserProfiles; -public sealed class EmailAddressChanged : IDomainEvent +public sealed class EmailAddressChanged : DomainEvent { - public required string EmailAddress { get; set; } + public EmailAddressChanged(Identifier id) : base(id) + { + } - public required string UserId { get; set; } + [UsedImplicitly] + public EmailAddressChanged() + { + } - public required string RootId { get; set; } + public required string EmailAddress { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string UserId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/UserProfiles/NameChanged.cs b/src/Domain.Events.Shared/UserProfiles/NameChanged.cs index 24911fd2..a3e3bae9 100644 --- a/src/Domain.Events.Shared/UserProfiles/NameChanged.cs +++ b/src/Domain.Events.Shared/UserProfiles/NameChanged.cs @@ -1,16 +1,23 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.UserProfiles; -public sealed class NameChanged : IDomainEvent +public sealed class NameChanged : DomainEvent { + public NameChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public NameChanged() + { + } + public required string FirstName { get; set; } public string? LastName { get; set; } public required string UserId { get; set; } - - public required string RootId { get; set; } - - public required DateTime OccurredUtc { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/UserProfiles/PhoneNumberChanged.cs b/src/Domain.Events.Shared/UserProfiles/PhoneNumberChanged.cs index 9a1d92a3..177630b0 100644 --- a/src/Domain.Events.Shared/UserProfiles/PhoneNumberChanged.cs +++ b/src/Domain.Events.Shared/UserProfiles/PhoneNumberChanged.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.UserProfiles; -public sealed class PhoneNumberChanged : IDomainEvent +public sealed class PhoneNumberChanged : DomainEvent { - public required string Number { get; set; } + public PhoneNumberChanged(Identifier id) : base(id) + { + } - public required string UserId { get; set; } + [UsedImplicitly] + public PhoneNumberChanged() + { + } - public required string RootId { get; set; } + public required string Number { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string UserId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/UserProfiles/TimezoneChanged.cs b/src/Domain.Events.Shared/UserProfiles/TimezoneChanged.cs index 1d4cdb23..98b1eea1 100644 --- a/src/Domain.Events.Shared/UserProfiles/TimezoneChanged.cs +++ b/src/Domain.Events.Shared/UserProfiles/TimezoneChanged.cs @@ -1,14 +1,21 @@ -using Domain.Interfaces.Entities; +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; namespace Domain.Events.Shared.UserProfiles; -public sealed class TimezoneChanged : IDomainEvent +public sealed class TimezoneChanged : DomainEvent { - public required string Timezone { get; set; } + public TimezoneChanged(Identifier id) : base(id) + { + } - public required string UserId { get; set; } + [UsedImplicitly] + public TimezoneChanged() + { + } - public required string RootId { get; set; } + public required string Timezone { get; set; } - public required DateTime OccurredUtc { get; set; } + public required string UserId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Interfaces/Entities/IDomainEvent.cs b/src/Domain.Interfaces/Entities/IDomainEvent.cs index 709a280e..47c5d5d3 100644 --- a/src/Domain.Interfaces/Entities/IDomainEvent.cs +++ b/src/Domain.Interfaces/Entities/IDomainEvent.cs @@ -1,7 +1,7 @@ namespace Domain.Interfaces.Entities; /// -/// Defines a domain event to communicate past events of an aggregate +/// Defines a domain event to communicate past events of an aggregate within the same bounded context /// public interface IDomainEvent { diff --git a/src/CarsDomain/CarStatus.cs b/src/Domain.Shared/Cars/CarStatus.cs similarity index 69% rename from src/CarsDomain/CarStatus.cs rename to src/Domain.Shared/Cars/CarStatus.cs index 2be3bf11..b4dd5597 100644 --- a/src/CarsDomain/CarStatus.cs +++ b/src/Domain.Shared/Cars/CarStatus.cs @@ -1,4 +1,4 @@ -namespace CarsDomain; +namespace Domain.Shared.Cars; public enum CarStatus { diff --git a/src/Domain.Shared/EndUsers/RegisteredUserProfile.cs b/src/Domain.Shared/EndUsers/RegisteredUserProfile.cs new file mode 100644 index 00000000..07d6face --- /dev/null +++ b/src/Domain.Shared/EndUsers/RegisteredUserProfile.cs @@ -0,0 +1,12 @@ +namespace Domain.Shared.EndUsers; + +public class RegisteredUserProfile +{ + public required string CountryCode { get; set; } + + public required string FirstName { get; set; } + + public string? LastName { get; set; } + + public required string Timezone { get; set; } +} \ No newline at end of file diff --git a/src/Domain.Shared/EndUsers/UserAccess.cs b/src/Domain.Shared/EndUsers/UserAccess.cs new file mode 100644 index 00000000..2fa5df9e --- /dev/null +++ b/src/Domain.Shared/EndUsers/UserAccess.cs @@ -0,0 +1,7 @@ +namespace Domain.Shared.EndUsers; + +public enum UserAccess +{ + Enabled = 0, + Suspended = 1 // Cannot access the account anymore +} \ No newline at end of file diff --git a/src/EndUsersDomain/UserClassification.cs b/src/Domain.Shared/EndUsers/UserClassification.cs similarity index 80% rename from src/EndUsersDomain/UserClassification.cs rename to src/Domain.Shared/EndUsers/UserClassification.cs index f45f1f40..656481d3 100644 --- a/src/EndUsersDomain/UserClassification.cs +++ b/src/Domain.Shared/EndUsers/UserClassification.cs @@ -1,4 +1,4 @@ -namespace EndUsersDomain; +namespace Domain.Shared.EndUsers; public enum UserClassification { diff --git a/src/Domain.Shared/EndUsers/UserStatus.cs b/src/Domain.Shared/EndUsers/UserStatus.cs new file mode 100644 index 00000000..4f6d95dc --- /dev/null +++ b/src/Domain.Shared/EndUsers/UserStatus.cs @@ -0,0 +1,7 @@ +namespace Domain.Shared.EndUsers; + +public enum UserStatus +{ + Unregistered = 0, // An invited guest + Registered = 1 +} \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplication.DomainEventHandlersSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplication.DomainEventHandlersSpec.cs new file mode 100644 index 00000000..69b1ab7e --- /dev/null +++ b/src/EndUsersApplication.UnitTests/EndUsersApplication.DomainEventHandlersSpec.cs @@ -0,0 +1,100 @@ +using Application.Interfaces; +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Authorization; +using Domain.Interfaces.Entities; +using Domain.Shared; +using Domain.Shared.EndUsers; +using Domain.Shared.Organizations; +using EndUsersApplication.Persistence; +using EndUsersDomain; +using Moq; +using OrganizationsDomain; +using UnitTesting.Common; +using Xunit; +using Events = OrganizationsDomain.Events; +using Membership = EndUsersDomain.Membership; + +namespace EndUsersApplication.UnitTests; + +[Trait("Category", "Unit")] +public class EndUsersApplicationDomainEventHandlersSpec +{ + private readonly EndUsersApplication _application; + private readonly Mock _caller; + private readonly Mock _endUserRepository; + private readonly Mock _idFactory; + private readonly Mock _recorder; + + public EndUsersApplicationDomainEventHandlersSpec() + { + _recorder = new Mock(); + _caller = new Mock(); + _idFactory = new Mock(); + var membershipCounter = 0; + _idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns((IIdentifiableEntity entity) => + { + if (entity is Membership) + { + return $"amembershipid{membershipCounter++}".ToId(); + } + + return "anid".ToId(); + }); + var settings = new Mock(); + settings.Setup( + s => s.Platform.GetString(EndUsersApplication.PermittedOperatorsSettingName, It.IsAny())) + .Returns(""); + _endUserRepository = new Mock(); + _endUserRepository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root)); + var invitationRepository = new Mock(); + var userProfilesService = new Mock(); + var notificationsService = new Mock(); + + _application = + new EndUsersApplication(_recorder.Object, _idFactory.Object, settings.Object, notificationsService.Object, + userProfilesService.Object, invitationRepository.Object, _endUserRepository.Object); + } + + [Fact] + public async Task WhenHandleOrganizationCreatedAsyncAndUserNoExist_ThenReturnsError() + { + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + var domainEvent = Events.Created("anorganizationid".ToId(), OrganizationOwnership.Shared, + "auserid".ToId(), DisplayName.Create("adisplayname").Value); + + var result = + await _application.HandleOrganizationCreatedAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task HandleOrganizationCreatedAsync_ThenAddsMembership() + { + var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + user.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, + EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); + _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(user); + var domainEvent = Events.Created("anorganizationid".ToId(), OrganizationOwnership.Shared, + "auserid".ToId(), DisplayName.Create("adisplayname").Value); + + var result = + await _application.HandleOrganizationCreatedAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _endUserRepository.Verify(rep => rep.SaveAsync(It.Is(eu => + eu.Memberships[0].IsDefault + && eu.Memberships[0].OrganizationId == "anorganizationid".ToId() + && eu.Memberships[0].Roles.HasRole(TenantRoles.Member) + && eu.Memberships[0].Features.HasFeature(TenantFeatures.Basic) + ), It.IsAny())); + } +} \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplication.UnitTests.csproj b/src/EndUsersApplication.UnitTests/EndUsersApplication.UnitTests.csproj index 5253c48e..5479e247 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplication.UnitTests.csproj +++ b/src/EndUsersApplication.UnitTests/EndUsersApplication.UnitTests.csproj @@ -8,6 +8,7 @@ + diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index 4f3abc30..20e102b2 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -10,6 +10,7 @@ using Domain.Interfaces.Entities; using Domain.Services.Shared.DomainServices; using Domain.Shared; +using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; using EndUsersDomain; using FluentAssertions; @@ -30,7 +31,6 @@ public class EndUsersApplicationSpec private readonly Mock _idFactory; private readonly Mock _invitationRepository; private readonly Mock _notificationsService; - private readonly Mock _organizationsService; private readonly Mock _recorder; private readonly Mock _userProfilesService; @@ -56,24 +56,17 @@ public EndUsersApplicationSpec() .Returns(""); _endUserRepository = new Mock(); _endUserRepository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) - .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root)); + .ReturnsAsync((EndUserRoot root, CancellationToken _) => root); + _endUserRepository.Setup(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => root); _invitationRepository = new Mock(); - _organizationsService = new Mock(); - _organizationsService.Setup(os => os.CreateOrganizationPrivateAsync(It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) - .ReturnsAsync(new Organization - { - Id = "anorganizationid", - CreatedById = "auserid", - Name = "aname" - }); _userProfilesService = new Mock(); _notificationsService = new Mock(); _application = new EndUsersApplication(_recorder.Object, _idFactory.Object, settings.Object, - _notificationsService.Object, _organizationsService.Object, _userProfilesService.Object, + _notificationsService.Object, _userProfilesService.Object, _invitationRepository.Object, _endUserRepository.Object); } @@ -109,6 +102,8 @@ public async Task WhenRegisterPersonAsyncAndNotAcceptedTerms_ThenReturnsError() [Fact] public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegistration() { + _caller.Setup(cc => cc.CallId) + .Returns("acallid"); var invitee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; _userProfilesService.Setup(ups => ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), @@ -118,8 +113,7 @@ public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegis rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(invitee.ToOptional()); _userProfilesService.Setup(ups => - ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new UserProfile { @@ -139,6 +133,14 @@ public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegis CountryCode = "acountrycode" } }); + _endUserRepository.Setup(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => + { + // HACK: By this time, domain events have created the default membership + root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + return root; + }); var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com", "afirstname", @@ -161,13 +163,11 @@ public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegis result.Value.Profile.Timezone.Should().Be("atimezone"); _invitationRepository.Verify(rep => rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()), Times.Never); - _organizationsService.Verify(os => - os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname", - OrganizationOwnership.Personal, - It.IsAny())); _userProfilesService.Verify(ups => - ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname", - null, null, It.IsAny())); + ups.GetProfilePrivateAsync(It.Is(cc => + cc.CallId == "acallid" + && cc.IsServiceAccount + ), "anid", It.IsAny())); _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -179,6 +179,8 @@ public async Task WhenRegisterPersonAsyncAndAcceptingGuestInvitation_ThenComplet { _caller.Setup(cc => cc.CallerId) .Returns(CallerConstants.AnonymousUserId); + _caller.Setup(cc => cc.CallId) + .Returns("acallid"); var tokensService = new Mock(); tokensService.Setup(ts => ts.CreateGuestInvitationToken()) .Returns("aninvitationtoken"); @@ -200,8 +202,7 @@ await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(), rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(invitee.ToOptional()); _userProfilesService.Setup(ups => - ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new UserProfile { @@ -221,6 +222,14 @@ await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(), CountryCode = "acountrycode" } }); + _endUserRepository.Setup(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => + { + // HACK: By this time, domain events have created the default membership + root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + return root; + }); var result = await _application.RegisterPersonAsync(_caller.Object, "aninvitationtoken", "auser@company.com", "afirstname", "alastname", null, null, true, CancellationToken.None); @@ -242,13 +251,11 @@ await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(), result.Value.Profile.Timezone.Should().Be("atimezone"); _invitationRepository.Verify(rep => rep.FindInvitedGuestByTokenAsync("aninvitationtoken", It.IsAny())); - _organizationsService.Verify(os => - os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname", - OrganizationOwnership.Personal, - It.IsAny())); _userProfilesService.Verify(ups => - ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname", - null, null, It.IsAny())); + ups.GetProfilePrivateAsync(It.Is(cc => + cc.CallId == "acallid" + && cc.IsServiceAccount + ), "anid", It.IsAny())); _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -258,6 +265,8 @@ await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(), [Fact] public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenRegisters() { + _caller.Setup(cc => cc.CallId) + .Returns("acallid"); _invitationRepository.Setup(rep => rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); @@ -270,8 +279,7 @@ public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenReg rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(invitee.ToOptional()); _userProfilesService.Setup(ups => - ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new UserProfile { @@ -291,11 +299,17 @@ public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenReg CountryCode = "acountrycode" } }); + _endUserRepository.Setup(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => + { + // HACK: By this time, domain events have created the default membership + root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + return root; + }); var result = await _application.RegisterPersonAsync(_caller.Object, "anunknowninvitationtoken", - "auser@company.com", - "afirstname", - "alastname", null, null, true, CancellationToken.None); + "auser@company.com", "afirstname", "alastname", null, null, true, CancellationToken.None); result.Should().BeSuccess(); result.Value.Id.Should().Be("anid"); @@ -314,13 +328,11 @@ public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenReg result.Value.Profile.Timezone.Should().Be("atimezone"); _invitationRepository.Verify(rep => rep.FindInvitedGuestByTokenAsync("anunknowninvitationtoken", It.IsAny())); - _organizationsService.Verify(os => - os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname", - OrganizationOwnership.Personal, - It.IsAny())); _userProfilesService.Verify(ups => - ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname", - null, null, It.IsAny())); + ups.GetProfilePrivateAsync(It.Is(cc => + cc.CallId == "acallid" + && cc.IsServiceAccount + ), "anid", It.IsAny())); _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -370,13 +382,10 @@ public async Task _userProfilesService.Setup(ups => ups.FindPersonByEmailAddressPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); - _organizationsService.Verify(os => - os.CreateOrganizationPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny()), Times.Never); _userProfilesService.Verify(ups => - ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); _notificationsService.Verify( ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -387,7 +396,8 @@ public async Task public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyEmail() { var endUser = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - endUser.Register(Roles.Empty, Features.Empty, EmailAddress.Create("auser@company.com").Value); + endUser.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); _userProfilesService.Setup(ups => ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -440,13 +450,10 @@ public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyE result.Value.Profile.Timezone.Should().Be("atimezone"); _invitationRepository.Verify(rep => rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()), Times.Never); - _organizationsService.Verify(os => - os.CreateOrganizationPrivateAsync(_caller.Object, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny()), Times.Never); _userProfilesService.Verify(ups => - ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(_caller.Object, "anid", "anotheruser@company.com", "afirstname", "atimezone", "acountrycode", CancellationToken.None)); } @@ -454,6 +461,8 @@ public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyE [Fact] public async Task WhenRegisterPersonAsyncAndNeverRegisteredNorInvitedAsGuest_ThenRegisters() { + _caller.Setup(cc => cc.CallId) + .Returns("acallid"); _userProfilesService.Setup(ups => ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -462,8 +471,7 @@ public async Task WhenRegisterPersonAsyncAndNeverRegisteredNorInvitedAsGuest_The rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); _userProfilesService.Setup(ups => - ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new UserProfile { @@ -483,6 +491,14 @@ public async Task WhenRegisterPersonAsyncAndNeverRegisteredNorInvitedAsGuest_The CountryCode = "acountrycode" } }); + _endUserRepository.Setup(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => + { + // HACK: By this time, domain events have created the default membership + root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + return root; + }); var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com", "afirstname", @@ -505,21 +521,19 @@ public async Task WhenRegisterPersonAsyncAndNeverRegisteredNorInvitedAsGuest_The result.Value.Profile.Timezone.Should().Be("atimezone"); _invitationRepository.Verify(rep => rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()), Times.Never); - _organizationsService.Verify(os => - os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname", - OrganizationOwnership.Personal, - It.IsAny())); _userProfilesService.Verify(ups => - ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname", - null, null, It.IsAny())); + ups.GetProfilePrivateAsync(It.Is(cc => + cc.CallId == "acallid" + && cc.IsServiceAccount + ), "anid", It.IsAny())); } [Fact] public async Task WhenRegisterMachineAsyncByAnonymousUser_ThenRegistersWithNoFeatures() { _userProfilesService.Setup(ups => - ups.CreateMachineProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny())) + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(new UserProfile { Id = "aprofileid", @@ -538,6 +552,16 @@ public async Task WhenRegisterMachineAsyncByAnonymousUser_ThenRegistersWithNoFea }); _caller.Setup(cc => cc.IsAuthenticated) .Returns(false); + _caller.Setup(cc => cc.CallId) + .Returns("acallid"); + _endUserRepository.Setup(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => + { + // HACK: By this time, domain events have created the default membership + root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + return root; + }); var result = await _application.RegisterMachineAsync(_caller.Object, "aname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), CancellationToken.None); @@ -557,13 +581,11 @@ public async Task WhenRegisterMachineAsyncByAnonymousUser_ThenRegistersWithNoFea result.Value.Profile.DisplayName.Should().Be("amachinename"); result.Value.Profile.EmailAddress.Should().BeNull(); result.Value.Profile.Timezone.Should().Be("atimezone"); - _organizationsService.Verify(os => - os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "aname", OrganizationOwnership.Personal, - It.IsAny())); _userProfilesService.Verify(ups => - ups.CreateMachineProfilePrivateAsync(_caller.Object, "anid", "aname", Timezones.Default.ToString(), - CountryCodes.Default.ToString(), - It.IsAny())); + ups.GetProfilePrivateAsync(It.Is(cc => + cc.CallId == "acallid" + && cc.IsServiceAccount + ), "anid", It.IsAny())); } [Fact] @@ -571,14 +593,17 @@ public async Task WhenRegisterMachineAsyncByAuthenticatedUser_ThenRegistersWithB { _caller.Setup(cc => cc.IsAuthenticated) .Returns(true); + _caller.Setup(cc => cc.CallId) + .Returns("acallid"); var adder = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(adder); - adder.Register(Roles.Empty, Features.Empty, EmailAddress.Create("auser@company.com").Value); + adder.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); adder.AddMembership("anotherorganizationid".ToId(), Roles.Empty, Features.Empty); _userProfilesService.Setup(ups => - ups.CreateMachineProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny())) + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(new UserProfile { Id = "aprofileid", @@ -607,20 +632,18 @@ public async Task WhenRegisterMachineAsyncByAuthenticatedUser_ThenRegistersWithB result.Value.Roles.Should().ContainSingle(role => role == PlatformRoles.Standard.Name); result.Value.Features.Should().ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name); result.Value.Profile!.Id.Should().Be("aprofileid"); - result.Value.Profile.DefaultOrganizationId.Should().Be("anorganizationid"); + result.Value.Profile.DefaultOrganizationId.Should().Be("anotherorganizationid"); result.Value.Profile.Address.CountryCode.Should().Be("acountrycode"); result.Value.Profile.Name.FirstName.Should().Be("amachinename"); result.Value.Profile.Name.LastName.Should().BeNull(); result.Value.Profile.DisplayName.Should().Be("amachinename"); result.Value.Profile.EmailAddress.Should().BeNull(); result.Value.Profile.Timezone.Should().Be("atimezone"); - _organizationsService.Verify(os => - os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "aname", OrganizationOwnership.Personal, - It.IsAny())); _userProfilesService.Verify(ups => - ups.CreateMachineProfilePrivateAsync(_caller.Object, "anid", "aname", Timezones.Default.ToString(), - CountryCodes.Default.ToString(), - It.IsAny())); + ups.GetProfilePrivateAsync(It.Is(cc => + cc.CallId == "acallid" + && cc.IsServiceAccount + ), "anid", It.IsAny())); } #if TESTINGONLY @@ -631,11 +654,12 @@ public async Task WhenAssignPlatformRolesAsync_ThenAssigns() .Returns("anassignerid"); var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, - Optional.None); + EndUserProfile.Create("afirstname").Value, Optional.None); _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(), Optional.None); + assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), + EndUserProfile.Create("afirstname").Value, Optional.None); _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) .ReturnsAsync(assigner); @@ -656,12 +680,13 @@ public async Task WhenUnassignPlatformRolesAsync_ThenUnassigns() .Returns("anassignerid"); var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; assignee.Register(Roles.Create(PlatformRoles.Standard, PlatformRoles.TestingOnly).Value, - Features.Create(PlatformFeatures.Basic).Value, + Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, Optional.None); _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(), Optional.None); + assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), + EndUserProfile.Create("afirstname").Value, Optional.None); _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) .ReturnsAsync(assigner); @@ -682,13 +707,14 @@ public async Task WhenAssignTenantRolesAsync_ThenAssigns() .Returns("anassignerid"); var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, - Optional.None); + EndUserProfile.Create("afirstname").Value, Optional.None); assignee.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); _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(), Optional.None); + assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), + EndUserProfile.Create("afirstname").Value, Optional.None); assigner.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Owner).Value, Features.Create(TenantFeatures.Basic).Value); _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) @@ -768,7 +794,7 @@ public async Task WhenGetMembershipsAsync_ThenReturnsUser() { var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; user.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, - EmailAddress.Create("auser@company.com").Value); + EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.PaidTrial).Value); _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) @@ -789,37 +815,4 @@ 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 WhenCreateMembershipForCallerAsyncAndUserNoExist_ThenReturnsError() - { - _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Error.EntityNotFound()); - - var result = - await _application.CreateMembershipForCallerAsync(_caller.Object, "anorganizationid", - CancellationToken.None); - - result.Should().BeError(ErrorCode.EntityNotFound); - } - - [Fact] - public async Task WhenCreateMembershipForCallerAsync_ThenAddsMembership() - { - var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - user.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, - EmailAddress.Create("auser@company.com").Value); - _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(user); - - var result = - await _application.CreateMembershipForCallerAsync(_caller.Object, "anorganizationid", - CancellationToken.None); - - result.Should().BeSuccess(); - result.Value.IsDefault.Should().BeTrue(); - result.Value.OrganizationId.Should().Be("anorganizationid"); - result.Value.Roles.Should().ContainSingle(role => role == TenantRoles.Member.Name); - result.Value.Features.Should().ContainSingle(feat => feat == TenantFeatures.Basic.Name); - } } \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs index 86cfcaa3..1166b02f 100644 --- a/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs @@ -8,6 +8,7 @@ using Domain.Interfaces.Entities; using Domain.Services.Shared.DomainServices; using Domain.Shared; +using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; using EndUsersDomain; using FluentAssertions; @@ -65,7 +66,8 @@ public async Task WhenInviteGuestAsyncAndInviteeAlreadyRegistered_ThenReturnsErr .ReturnsAsync(inviter); var invitee = EndUserRoot .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value; - invitee.Register(Roles.Empty, Features.Empty, EmailAddress.Create("aninvitee@company.com").Value); + invitee.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("aninvitee@company.com").Value); _invitationRepository.Setup(rep => rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(invitee.ToOptional()); diff --git a/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs b/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs new file mode 100644 index 00000000..843df232 --- /dev/null +++ b/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs @@ -0,0 +1,25 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Events.Shared.Organizations; + +namespace EndUsersApplication; + +partial class EndUsersApplication +{ + public async Task> HandleOrganizationCreatedAsync(ICallerContext caller, Created domainEvent, + CancellationToken cancellationToken) + { + var ownership = domainEvent.Ownership.ToEnumOrDefault(OrganizationOwnership.Shared); + var membership = await CreateMembershipAsync(caller, domainEvent.CreatedById.ToId(), domainEvent.RootId.ToId(), + ownership, cancellationToken); + if (!membership.IsSuccessful) + { + return membership.Error; + } + + return Result.Ok; + } +} \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index 10d3565f..df72e6ef 100644 --- a/src/EndUsersApplication/EndUsersApplication.cs +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -1,3 +1,4 @@ +using Application.Common; using Application.Common.Extensions; using Application.Interfaces; using Application.Resources.Shared; @@ -8,16 +9,17 @@ using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Shared; +using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; using EndUsersApplication.Persistence.ReadModels; using EndUsersDomain; using EndUser = Application.Resources.Shared.EndUser; using Membership = Application.Resources.Shared.Membership; -using PersonName = Domain.Shared.PersonName; +using PersonName = Application.Resources.Shared.PersonName; namespace EndUsersApplication; -public class EndUsersApplication : IEndUsersApplication +public partial class EndUsersApplication : IEndUsersApplication { internal const string PermittedOperatorsSettingName = "Hosts:EndUsersApi:Authorization:OperatorWhitelist"; private static readonly char[] PermittedOperatorsDelimiters = [';', ',', ' ']; @@ -25,21 +27,19 @@ public class EndUsersApplication : IEndUsersApplication private readonly IIdentifierFactory _idFactory; private readonly IInvitationRepository _invitationRepository; private readonly INotificationsService _notificationsService; - private readonly IOrganizationsService _organizationsService; private readonly IRecorder _recorder; private readonly IConfigurationSettings _settings; private readonly IUserProfilesService _userProfilesService; public EndUsersApplication(IRecorder recorder, IIdentifierFactory idFactory, IConfigurationSettings settings, - INotificationsService notificationsService, IOrganizationsService organizationsService, - IUserProfilesService userProfilesService, IInvitationRepository invitationRepository, + INotificationsService notificationsService, IUserProfilesService userProfilesService, + IInvitationRepository invitationRepository, IEndUserRepository endUserRepository) { _recorder = recorder; _idFactory = idFactory; _settings = settings; _notificationsService = notificationsService; - _organizationsService = organizationsService; _userProfilesService = userProfilesService; _invitationRepository = invitationRepository; _endUserRepository = endUserRepository; @@ -108,38 +108,31 @@ public async Task> RegisterMachineAsync(ICaller return created.Error; } - var machine = created.Value; - var profiled = await _userProfilesService.CreateMachineProfilePrivateAsync(context, machine.Id, name, timezone, - countryCode, cancellationToken); - if (!profiled.IsSuccessful) + var userProfile = EndUserProfile.Create(name, timezone: timezone, countryCode: countryCode); + if (!userProfile.IsSuccessful) { - return profiled.Error; + return userProfile.Error; } - var profile = profiled.Value; - var (platformRoles, platformFeatures, tenantRoles, tenantFeatures) = + var machine = created.Value; + var (platformRoles, platformFeatures, _, _) = EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.CreatingMachine, context.IsAuthenticated); - var registered = machine.Register(platformRoles, platformFeatures, Optional.None); + var registered = + machine.Register(platformRoles, platformFeatures, userProfile.Value, Optional.None); if (!registered.IsSuccessful) { return registered.Error; } - var defaultOrganization = - await _organizationsService.CreateOrganizationPrivateAsync(context, machine.Id, name, - OrganizationOwnership.Personal, cancellationToken); - if (!defaultOrganization.IsSuccessful) + var saved = await _endUserRepository.SaveAsync(machine, true, cancellationToken); + if (!saved.IsSuccessful) { - return defaultOrganization.Error; + return saved.Error; } - var defaultOrganizationId = defaultOrganization.Value.Id.ToId(); - var selfEnrolled = machine.AddMembership(defaultOrganizationId, tenantRoles, - tenantFeatures); - if (!selfEnrolled.IsSuccessful) - { - return selfEnrolled.Error; - } + machine = saved.Value; + _recorder.TraceInformation(context.ToCall(), "Registered machine: {Id}", machine.Id); + _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.MachineRegistered); if (context.IsAuthenticated) { @@ -152,25 +145,35 @@ await _organizationsService.CreateOrganizationPrivateAsync(context, machine.Id, var (_, _, tenantRoles2, tenantFeatures2) = EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.InvitingMachineToCreatorOrg, context.IsAuthenticated); - var adderDefaultOrganizationId = adder.Value.Memberships.DefaultMembership.OrganizationId; + var adderDefaultOrganizationId = adder.Value.DefaultMembership.OrganizationId; var adderEnrolled = machine.AddMembership(adderDefaultOrganizationId, tenantRoles2, tenantFeatures2); if (!adderEnrolled.IsSuccessful) { return adderEnrolled.Error; } + + saved = await _endUserRepository.SaveAsync(saved.Value, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + machine = saved.Value; + _recorder.TraceInformation(context.ToCall(), + "Machine {Id} has become a member of {User} organization {Organization}", + machine.Id, adder.Value.Id, adderDefaultOrganizationId); } - var saved = await _endUserRepository.SaveAsync(machine, cancellationToken); - if (!saved.IsSuccessful) + var defaultOrganizationId = machine.DefaultMembership.OrganizationId; + var serviceCaller = Caller.CreateAsMaintenance(context.CallId); + var profile = await _userProfilesService.GetProfilePrivateAsync(serviceCaller, machine.Id, cancellationToken); + if (!profile.IsSuccessful) { - return saved.Error; + return profile.Error; } - _recorder.TraceInformation(context.ToCall(), "Registered machine: {Id}", machine.Id); - _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.MachineRegistered); - - return machine.ToRegisteredUser(defaultOrganizationId, profile); + return machine.ToRegisteredUser(defaultOrganizationId, profile.Value); } public async Task> RegisterPersonAsync(ICallerContext context, @@ -240,24 +243,24 @@ public async Task> RegisterPersonAsync(ICallerC } EndUserRoot unregisteredUser; - UserProfile? profile; if (existingUser.HasValue) { unregisteredUser = existingUser.Value.User; if (unregisteredUser.Status == UserStatus.Registered) { - profile = existingUser.Value.Profile; - if (profile.NotExists() - || profile.Classification != UserProfileClassification.Person - || profile.EmailAddress.HasNoValue()) + var unregisteredUserProfile = existingUser.Value.Profile; + if (unregisteredUserProfile.NotExists() + || unregisteredUserProfile.Classification != UserProfileClassification.Person + || unregisteredUserProfile.EmailAddress.HasNoValue()) { return Error.EntityNotFound(Resources.EndUsersApplication_NotPersonProfile); } var notified = await _notificationsService.NotifyReRegistrationCourtesyAsync(context, unregisteredUser.Id, - profile.EmailAddress, profile.DisplayName, profile.Timezone, profile.Address.CountryCode, + unregisteredUserProfile.EmailAddress, unregisteredUserProfile.DisplayName, + unregisteredUserProfile.Timezone, unregisteredUserProfile.Address.CountryCode, cancellationToken); if (!notified.IsSuccessful) { @@ -273,7 +276,8 @@ public async Task> RegisterPersonAsync(ICallerC { UsageConstants.Properties.EmailAddress, email } }); - return unregisteredUser.ToRegisteredUser(unregisteredUser.Memberships.DefaultMembership.Id, profile); + return unregisteredUser.ToRegisteredUser(unregisteredUser.DefaultMembership.Id, + unregisteredUserProfile); } } else @@ -287,97 +291,44 @@ public async Task> RegisterPersonAsync(ICallerC unregisteredUser = created.Value; } - var profiled = await _userProfilesService.CreatePersonProfilePrivateAsync(context, unregisteredUser.Id, - username, firstName, lastName, timezone, countryCode, cancellationToken); - if (!profiled.IsSuccessful) + var userProfile = EndUserProfile.Create(firstName, lastName, timezone, countryCode); + if (!userProfile.IsSuccessful) { - return profiled.Error; + return userProfile.Error; } - profile = profiled.Value; var permittedOperators = GetPermittedOperators(); - var (platformRoles, platformFeatures, tenantRoles, tenantFeatures) = + var (platformRoles, platformFeatures, _, _) = EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.CreatingPerson, context.IsAuthenticated, - username, - permittedOperators); - var registered = unregisteredUser.Register(platformRoles, platformFeatures, username); + username, permittedOperators); + var registered = unregisteredUser.Register(platformRoles, platformFeatures, userProfile.Value, username); if (!registered.IsSuccessful) { return registered.Error; } - var organizationName = PersonName.Create(firstName, lastName); - if (!organizationName.IsSuccessful) - { - return organizationName.Error; - } - - var defaultOrganization = - await _organizationsService.CreateOrganizationPrivateAsync(context, unregisteredUser.Id, - organizationName.Value.FullName, OrganizationOwnership.Personal, - cancellationToken); - if (!defaultOrganization.IsSuccessful) - { - return defaultOrganization.Error; - } - - var defaultOrganizationId = defaultOrganization.Value.Id.ToId(); - var enrolled = unregisteredUser.AddMembership(defaultOrganizationId, tenantRoles, - tenantFeatures); - if (!enrolled.IsSuccessful) - { - return enrolled.Error; - } - - var saved = await _endUserRepository.SaveAsync(unregisteredUser, cancellationToken); + var saved = await _endUserRepository.SaveAsync(unregisteredUser, true, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; } - _recorder.TraceInformation(context.ToCall(), "Registered user: {Id}", unregisteredUser.Id); - _recorder.AuditAgainst(context.ToCall(), unregisteredUser.Id, + var person = saved.Value; + _recorder.TraceInformation(context.ToCall(), "Registered user: {Id}", person.Id); + _recorder.AuditAgainst(context.ToCall(), person.Id, Audits.EndUsersApplication_User_Registered_TermsAccepted, - "EndUser {Id} accepted their terms and conditions", unregisteredUser.Id); + "EndUser {Id} accepted their terms and conditions", person.Id); _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.PersonRegistrationCreated); - return unregisteredUser.ToRegisteredUser(defaultOrganizationId, profile); - } - - public async Task> CreateMembershipForCallerAsync(ICallerContext context, - string organizationId, CancellationToken cancellationToken) - { - var retrieved = await _endUserRepository.LoadAsync(context.ToCallerId(), cancellationToken); - if (!retrieved.IsSuccessful) - { - return retrieved.Error; - } - - var user = retrieved.Value; - var (_, _, tenantRoles, tenantFeatures) = - EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.CreatingOrg, context.IsAuthenticated); - var membered = user.AddMembership(organizationId.ToId(), tenantRoles, tenantFeatures); - if (!membered.IsSuccessful) - { - return membered.Error; - } - - var saved = await _endUserRepository.SaveAsync(user, cancellationToken); - if (!saved.IsSuccessful) + var defaultOrganizationId = person.DefaultMembership.OrganizationId; + var serviceCaller = Caller.CreateAsMaintenance(context.CallId); + var profile = await _userProfilesService.GetProfilePrivateAsync(serviceCaller, person.Id, cancellationToken); + if (!profile.IsSuccessful) { - return saved.Error; + return profile.Error; } - _recorder.TraceInformation(context.ToCall(), "EndUser {Id} has become a member of organization {Organization}", - user.Id, organizationId); - - var membership = saved.Value.FindMembership(organizationId.ToId()); - if (!membership.HasValue) - { - return Error.EntityNotFound(Resources.EndUsersApplication_MembershipNotFound); - } - - return membership.Value.ToMembership(); + return person.ToRegisteredUser(defaultOrganizationId, profile.Value); } public async Task, Error>> FindPersonByEmailAddressAsync(ICallerContext context, @@ -541,6 +492,51 @@ public async Task> AssignTenantRolesAsync( return assignee.ToUserWithMemberships(); } + private async Task> CreateMembershipAsync(ICallerContext context, + Identifier createdById, Identifier organizationId, OrganizationOwnership ownership, + CancellationToken cancellationToken) + { + var retrieved = await _endUserRepository.LoadAsync(createdById, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var creator = retrieved.Value; + var useCase = ownership switch + { + OrganizationOwnership.Shared => RolesAndFeaturesUseCase.CreatingOrg, + OrganizationOwnership.Personal => creator.Classification == UserClassification.Person + ? RolesAndFeaturesUseCase.CreatingPerson + : RolesAndFeaturesUseCase.CreatingMachine, + _ => RolesAndFeaturesUseCase.CreatingOrg + }; + var (_, _, tenantRoles, tenantFeatures) = + EndUserRoot.GetInitialRolesAndFeatures(useCase, context.IsAuthenticated); + var membered = creator.AddMembership(organizationId, tenantRoles, tenantFeatures); + if (!membered.IsSuccessful) + { + return membered.Error; + } + + var saved = await _endUserRepository.SaveAsync(creator, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(context.ToCall(), "EndUser {Id} has become a member of organization {Organization}", + creator.Id, organizationId); + + var membership = saved.Value.FindMembership(organizationId); + if (!membership.HasValue) + { + return Error.EntityNotFound(Resources.EndUsersApplication_MembershipNotFound); + } + + return membership.Value.ToMembership(); + } + private async Task> WithGetOptionsAsync(ICallerContext caller, List memberships, GetOptions options, CancellationToken cancellationToken) { @@ -732,7 +728,7 @@ public static UserProfile ToUnregisteredUserProfile(this MembershipJoinInvitatio UserId = membership.UserId.Value, EmailAddress = membership.InvitedEmailAddress.Value, DisplayName = membership.InvitedEmailAddress.Value, - Name = new Application.Resources.Shared.PersonName + Name = new PersonName { FirstName = membership.InvitedEmailAddress.Value, LastName = null diff --git a/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs b/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs new file mode 100644 index 00000000..e1c80b1c --- /dev/null +++ b/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs @@ -0,0 +1,11 @@ +using Application.Interfaces; +using Common; +using Domain.Events.Shared.Organizations; + +namespace EndUsersApplication; + +partial interface IEndUsersApplication +{ + Task> HandleOrganizationCreatedAsync(ICallerContext caller, Created domainEvent, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs index 7daf27d8..a31956ff 100644 --- a/src/EndUsersApplication/IEndUsersApplication.cs +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -4,7 +4,7 @@ namespace EndUsersApplication; -public interface IEndUsersApplication +public partial interface IEndUsersApplication { Task> AssignPlatformRolesAsync(ICallerContext context, string id, List roles, CancellationToken cancellationToken); @@ -13,9 +13,6 @@ Task> AssignTenantRolesAsync(ICallerContex string id, List roles, CancellationToken cancellationToken); - Task> CreateMembershipForCallerAsync(ICallerContext context, string organizationId, - CancellationToken cancellationToken); - Task, Error>> FindPersonByEmailAddressAsync(ICallerContext context, string emailAddress, CancellationToken cancellationToken); diff --git a/src/EndUsersApplication/InvitationsApplication.cs b/src/EndUsersApplication/InvitationsApplication.cs index 832908cc..7f419916 100644 --- a/src/EndUsersApplication/InvitationsApplication.cs +++ b/src/EndUsersApplication/InvitationsApplication.cs @@ -8,6 +8,7 @@ using Domain.Common.ValueObjects; using Domain.Services.Shared.DomainServices; using Domain.Shared; +using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; using EndUsersDomain; using Membership = Application.Resources.Shared.Membership; diff --git a/src/EndUsersApplication/Persistence/IEndUserRepository.cs b/src/EndUsersApplication/Persistence/IEndUserRepository.cs index cd08c43b..cf3c2854 100644 --- a/src/EndUsersApplication/Persistence/IEndUserRepository.cs +++ b/src/EndUsersApplication/Persistence/IEndUserRepository.cs @@ -13,6 +13,8 @@ public interface IEndUserRepository : IApplicationRepository Task> SaveAsync(EndUserRoot user, CancellationToken cancellationToken); + Task> SaveAsync(EndUserRoot user, bool reload, CancellationToken cancellationToken); + Task, Error>> SearchAllMembershipsByOrganizationAsync( Identifier organizationId, SearchOptions searchOptions, CancellationToken cancellationToken); diff --git a/src/EndUsersApplication/Persistence/ReadModels/EndUser.cs b/src/EndUsersApplication/Persistence/ReadModels/EndUser.cs index cbc87d27..c8aea3fb 100644 --- a/src/EndUsersApplication/Persistence/ReadModels/EndUser.cs +++ b/src/EndUsersApplication/Persistence/ReadModels/EndUser.cs @@ -1,6 +1,7 @@ using Application.Persistence.Common; using Common; using Domain.Shared; +using Domain.Shared.EndUsers; using QueryAny; namespace EndUsersApplication.Persistence.ReadModels; @@ -8,15 +9,15 @@ namespace EndUsersApplication.Persistence.ReadModels; [EntityName("EndUser")] public class EndUser : ReadModelEntity { - public Optional Access { get; set; } + public Optional Access { get; set; } - public Optional Classification { get; set; } + public Optional Classification { get; set; } public Optional Features { get; set; } public Optional Roles { get; set; } - public Optional Status { get; set; } + public Optional Status { get; set; } public Optional Username { get; set; } } \ No newline at end of file diff --git a/src/EndUsersApplication/Persistence/ReadModels/Invitation.cs b/src/EndUsersApplication/Persistence/ReadModels/Invitation.cs index ce7c64c3..02b560de 100644 --- a/src/EndUsersApplication/Persistence/ReadModels/Invitation.cs +++ b/src/EndUsersApplication/Persistence/ReadModels/Invitation.cs @@ -1,5 +1,6 @@ using Application.Persistence.Common; using Common; +using Domain.Shared.EndUsers; using QueryAny; namespace EndUsersApplication.Persistence.ReadModels; @@ -15,7 +16,7 @@ public class Invitation : ReadModelEntity public Optional InvitedEmailAddress { get; set; } - public Optional Status { get; set; } + public Optional Status { get; set; } public Optional Token { get; set; } } \ No newline at end of file diff --git a/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs b/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs index 8440e1ca..997ed02a 100644 --- a/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs +++ b/src/EndUsersApplication/Persistence/ReadModels/MembershipJoinInvitation.cs @@ -1,6 +1,7 @@ using Application.Persistence.Common; using Common; using Domain.Shared; +using Domain.Shared.EndUsers; using QueryAny; namespace EndUsersApplication.Persistence.ReadModels; @@ -8,17 +9,17 @@ namespace EndUsersApplication.Persistence.ReadModels; [EntityName("Membership")] public class MembershipJoinInvitation : ReadModelEntity { - public Optional UserId { get; set; } + public Optional Features { get; set; } - public Optional OrganizationId { get; set; } + public Optional InvitedEmailAddress { get; set; } public bool IsDefault { get; set; } - public Optional Roles { get; set; } + public Optional OrganizationId { get; set; } - public Optional Features { get; set; } + public Optional Roles { get; set; } - public Optional InvitedEmailAddress { get; set; } + public Optional Status { get; set; } - public Optional Status { get; set; } + public Optional UserId { get; set; } } \ No newline at end of file diff --git a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs index a81b0f57..7bb9f841 100644 --- a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs +++ b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs @@ -8,6 +8,7 @@ using Domain.Interfaces.Entities; using Domain.Services.Shared.DomainServices; using Domain.Shared; +using Domain.Shared.EndUsers; using FluentAssertions; using Moq; using UnitTesting.Common; @@ -60,10 +61,11 @@ public void WhenConstructed_ThenAssigned() public async Task WhenRegisterAndInvitedAsGuest_ThenAcceptsInvitationAndRegistered() { var emailAddress = EmailAddress.Create("auser@company.com").Value; + var userProfile = EndUserProfile.Create("afirstname").Value; await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, (_, _) => Task.FromResult(Result.Ok)); _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, emailAddress); + Features.Create(PlatformFeatures.Basic.Name).Value, userProfile, emailAddress); _user.Access.Should().Be(UserAccess.Enabled); _user.Status.Should().Be(UserStatus.Registered); @@ -80,7 +82,7 @@ await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailA public void WhenRegister_ThenRegistered() { _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.Access.Should().Be(UserAccess.Enabled); @@ -104,9 +106,8 @@ public void WhenEnsureInvariantsAndMachineIsNotRegistered_ThenReturnsError() [Fact] public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultRole_ThenReturnsError() { - _user.Register(Roles.Create(), - Features.Create(PlatformFeatures.Basic.Name).Value, - EmailAddress.Create("auser@company.com").Value); + _user.Register(Roles.Create(), Features.Create(PlatformFeatures.Basic.Name).Value, + EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var result = _user.EnsureInvariants(); @@ -117,7 +118,7 @@ public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultRole_ThenR public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultFeature_ThenReturnsError() { _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(), + Features.Create(), EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); var result = _user.EnsureInvariants(); @@ -130,7 +131,7 @@ 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, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, emailAddress); #if TESTINGONLY _user.TestingOnly_InviteGuest(emailAddress); @@ -145,7 +146,7 @@ public void WhenEnsureInvariantsAndRegisteredPersonStillInvited_ThenReturnsError public void WhenAddMembershipAndAlreadyMember_ThenReturns() { _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership("anorganizationid".ToId(), Roles.Create(), Features.Create()); @@ -158,7 +159,7 @@ public void WhenAddMembershipAndAlreadyMember_ThenReturns() public void WhenAddMembership_ThenAddsMembershipAsDefaultWithRolesAndFeatures() { _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).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; @@ -178,7 +179,7 @@ public void WhenAddMembership_ThenAddsMembershipAsDefaultWithRolesAndFeatures() public void WhenAddMembershipAndHasMembership_ThenChangesNextToDefaultMembership() { _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).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; @@ -231,7 +232,7 @@ public void WhenAssignMembershipFeaturesAndFeatureNotAssignable_ThenReturnsError { var assigner = CreateOrgOwner("anorganizationid"); _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); @@ -249,7 +250,7 @@ public void WhenAssignMembershipFeatures_ThenAssigns() { var assigner = CreateOrgOwner("anorganizationid"); _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); @@ -283,7 +284,7 @@ public void WhenAssignMembershipRolesAndRoleNotAssignable_ThenReturnsError() { var assigner = CreateOrgOwner("anorganizationid"); _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); @@ -301,7 +302,7 @@ public void WhenAssignMembershipRoles_ThenAssigns() { var assigner = CreateOrgOwner("anorganizationid"); _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); _user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); @@ -463,7 +464,7 @@ public void WhenUnassignPlatformRoles_ThenUnassigns() public async Task WhenInviteAsGuestAndRegistered_ThenDoesNothing() { var emailAddress = EmailAddress.Create("invitee@company.com").Value; - _user.Register(Roles.Empty, Features.Empty, emailAddress); + _user.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, emailAddress); var wasCallbackCalled = false; await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, @@ -582,7 +583,7 @@ await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), public void WhenVerifyGuestInvitationAndAlreadyRegistered_ThenReturnsError() { var emailAddress = EmailAddress.Create("invitee@company.com").Value; - _user.Register(Roles.Empty, Features.Empty, emailAddress); + _user.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, emailAddress); var result = _user.VerifyGuestInvitation(); @@ -639,7 +640,7 @@ public void WhenAcceptGuestInvitationAndAuthenticatedUser_ThenReturnsError() public void WhenAcceptGuestInvitationAndRegistered_ThenReturnsError() { var emailAddress = EmailAddress.Create("auser@company.com").Value; - _user.Register(Roles.Empty, Features.Empty, emailAddress); + _user.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, emailAddress); var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); @@ -690,7 +691,7 @@ private EndUserRoot CreateOrgOwner(string organizationId) { var owner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; owner.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("orgowner@company.com").Value); owner.AddMembership(organizationId.ToId(), Roles.Create(TenantRoles.Owner).Value, Features.Empty); @@ -702,7 +703,7 @@ private EndUserRoot CreateOperator() var @operator = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) .Value; @operator.Register(Roles.Create(PlatformRoles.Standard.Name, PlatformRoles.Operations.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("operator@company.com").Value); return @operator; diff --git a/src/EndUsersDomain/EndUserProfile.cs b/src/EndUsersDomain/EndUserProfile.cs new file mode 100644 index 00000000..62a9b9e9 --- /dev/null +++ b/src/EndUsersDomain/EndUserProfile.cs @@ -0,0 +1,63 @@ +using Common; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Shared; + +namespace EndUsersDomain; + +public sealed class EndUserProfile : ValueObjectBase +{ + public static Result Create(string firstName, string? lastName = null, + string? timezone = null, + string? countryCode = null) + { + var name = PersonName.Create(firstName, lastName); + if (!name.IsSuccessful) + { + return name.Error; + } + + var tz = Timezone.Create(Timezones.FindOrDefault(timezone)); + if (!tz.IsSuccessful) + { + return tz.Error; + } + + var address = Address.Create(CountryCodes.FindOrDefault(countryCode)); + if (!address.IsSuccessful) + { + return address.Error; + } + + return new EndUserProfile(name.Value, tz.Value, address.Value); + } + + private EndUserProfile(PersonName name, Timezone timezone, Address address) + { + Name = name; + Timezone = timezone; + Address = address; + } + + public Address Address { get; } + + public PersonName Name { get; } + + public Timezone Timezone { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, container) => + { + var parts = RehydrateToList(property, false); + return new EndUserProfile(PersonName.Rehydrate()(parts[0]!, container), + Timezone.Rehydrate()(parts[1]!, container), + Address.Rehydrate()(parts[2]!, container)); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { Name, Timezone, Address }; + } +} \ No newline at end of file diff --git a/src/EndUsersDomain/EndUserRoot.cs b/src/EndUsersDomain/EndUserRoot.cs index 5df9aa3e..40f6ff23 100644 --- a/src/EndUsersDomain/EndUserRoot.cs +++ b/src/EndUsersDomain/EndUserRoot.cs @@ -10,6 +10,7 @@ using Domain.Interfaces.ValueObjects; using Domain.Services.Shared.DomainServices; using Domain.Shared; +using Domain.Shared.EndUsers; namespace EndUsersDomain; @@ -38,6 +39,8 @@ private EndUserRoot(IRecorder recorder, IIdentifierFactory idFactory, ISingleVal public UserClassification Classification { get; private set; } + public Membership DefaultMembership => Memberships.DefaultMembership; + public Features Features { get; private set; } = Features.Create(); public GuestInvitation GuestInvitation { get; private set; } = GuestInvitation.Empty; @@ -106,9 +109,9 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco { case Created created: { - Access = created.Access.ToEnumOrDefault(UserAccess.Enabled); - Status = created.Status.ToEnumOrDefault(UserStatus.Unregistered); - Classification = created.Classification.ToEnumOrDefault(UserClassification.Person); + Access = created.Access; + Status = created.Status; + Classification = created.Classification; Features = Features.Create(); Roles = Roles.Create(); return Result.Ok; @@ -116,9 +119,9 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case Registered changed: { - Access = changed.Access.ToEnumOrDefault(UserAccess.Enabled); - Status = changed.Status.ToEnumOrDefault(UserStatus.Unregistered); - Classification = changed.Classification.ToEnumOrDefault(UserClassification.Person); + Access = changed.Access; + Status = changed.Status; + Classification = changed.Classification; var roles = Roles.Create(changed.Roles.ToArray()); if (!roles.IsSuccessful) @@ -576,7 +579,7 @@ public async Task> InviteGuestAsync(ITokensService tokensService, return await onInvited(inviterId, token); } - public Result Register(Roles roles, Features levels, Optional username) + public Result Register(Roles roles, Features levels, EndUserProfile profile, Optional username) { if (Status != UserStatus.Unregistered) { @@ -595,7 +598,7 @@ public Result Register(Roles roles, Features levels, Optional username, - UserClassification classification, - UserAccess access, UserStatus status, - Roles roles, - Features features) + public static Registered Registered(Identifier id, EndUserProfile userProfile, Optional username, + UserClassification classification, UserAccess access, UserStatus status, Roles roles, Features features) { - return new Registered + return new Registered(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, Username = username.ValueOrDefault!, - Classification = classification.ToString(), - Access = access.ToString(), - Status = status.ToString(), + Classification = classification, + Access = access, + Status = status, Roles = roles.ToList(), - Features = features.ToList() + Features = features.ToList(), + UserProfile = new RegisteredUserProfile + { + FirstName = userProfile.Name.FirstName, + LastName = userProfile.Name.LastName.ValueOrDefault!, + Timezone = userProfile.Timezone.ToString(), + CountryCode = userProfile.Address.CountryCode.ToString() + } }; } } \ No newline at end of file diff --git a/src/EndUsersDomain/UserAccess.cs b/src/EndUsersDomain/UserAccess.cs deleted file mode 100644 index 0d8b52fa..00000000 --- a/src/EndUsersDomain/UserAccess.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace EndUsersDomain; - -public enum UserAccess -{ - Enabled = 0, - Suspended = 1 -} \ No newline at end of file diff --git a/src/EndUsersDomain/UserStatus.cs b/src/EndUsersDomain/UserStatus.cs deleted file mode 100644 index 7ee098dd..00000000 --- a/src/EndUsersDomain/UserStatus.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace EndUsersDomain; - -public enum UserStatus -{ - Unregistered = 0, - Registered = 1 -} \ No newline at end of file diff --git a/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs b/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs index 39fcd918..7e87de28 100644 --- a/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs +++ b/src/EndUsersInfrastructure.IntegrationTests/EndUsersApiSpec.cs @@ -1,6 +1,8 @@ using ApiHost1; using Domain.Interfaces.Authorization; +using EndUsersInfrastructure.IntegrationTests.Stubs; using FluentAssertions; +using Infrastructure.Eventing.Interfaces.Notifications; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Operations.Shared.EndUsers; using IntegrationTesting.WebApi.Common; @@ -13,9 +15,22 @@ namespace EndUsersInfrastructure.IntegrationTests; [Collection("API")] public class EndUsersApiSpec : WebApiSpec { + private readonly StubEventNotificationMessageBroker _messageBroker; + public EndUsersApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) { EmptyAllRepositories(); + _messageBroker = setup.GetRequiredService() + .As(); + _messageBroker.Reset(); + } + + [Fact] + public async Task WhenRegisterUser_ThenPublishesRegistrationIntegrationEvent() + { + var login = await LoginUserAsync(); + + _messageBroker.LastPublishedEvent!.RootId.Should().Be(login.User.Id); } [Fact] @@ -58,6 +73,6 @@ await Api.PostAsync(new AssignPlatformRolesRequest private static void OverrideDependencies(IServiceCollection services) { - // Override dependencies here + services.AddSingleton(); } } \ No newline at end of file diff --git a/src/EndUsersInfrastructure.IntegrationTests/Stubs/StubEventNotificationMessageBroker.cs b/src/EndUsersInfrastructure.IntegrationTests/Stubs/StubEventNotificationMessageBroker.cs new file mode 100644 index 00000000..8bde2dbd --- /dev/null +++ b/src/EndUsersInfrastructure.IntegrationTests/Stubs/StubEventNotificationMessageBroker.cs @@ -0,0 +1,20 @@ +using Common; +using Infrastructure.Eventing.Interfaces.Notifications; + +namespace EndUsersInfrastructure.IntegrationTests.Stubs; + +public class StubEventNotificationMessageBroker : IEventNotificationMessageBroker +{ + public IIntegrationEvent? LastPublishedEvent { get; private set; } + + public Task> PublishAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken) + { + LastPublishedEvent = integrationEvent; + return Task.FromResult(Result.Ok); + } + + public void Reset() + { + LastPublishedEvent = null; + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs index 476013cf..97c1fc31 100644 --- a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs +++ b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs @@ -22,13 +22,6 @@ public EndUsersInProcessServiceClient(IEndUsersApplication endUsersApplication, _invitationsApplication = invitationsApplication; } - public async Task> CreateMembershipForCallerPrivateAsync(ICallerContext caller, - string organizationId, - CancellationToken cancellationToken) - { - return await _endUsersApplication.CreateMembershipForCallerAsync(caller, organizationId, cancellationToken); - } - public async Task, Error>> FindPersonByEmailPrivateAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken) { diff --git a/src/EndUsersInfrastructure/EndUsersInfrastructure.csproj b/src/EndUsersInfrastructure/EndUsersInfrastructure.csproj index 347e15f7..e3a8d49f 100644 --- a/src/EndUsersInfrastructure/EndUsersInfrastructure.csproj +++ b/src/EndUsersInfrastructure/EndUsersInfrastructure.csproj @@ -20,6 +20,7 @@ + diff --git a/src/EndUsersInfrastructure/EndUsersModule.cs b/src/EndUsersInfrastructure/EndUsersModule.cs index e69a8bb6..a6d39587 100644 --- a/src/EndUsersInfrastructure/EndUsersModule.cs +++ b/src/EndUsersInfrastructure/EndUsersModule.cs @@ -11,9 +11,12 @@ using EndUsersDomain; using EndUsersInfrastructure.Api.EndUsers; using EndUsersInfrastructure.ApplicationServices; +using EndUsersInfrastructure.Notifications; using EndUsersInfrastructure.Persistence; using EndUsersInfrastructure.Persistence.ReadModels; +using Infrastructure.Eventing.Interfaces.Notifications; using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Interfaces; using Infrastructure.Persistence.Interfaces; using Infrastructure.Web.Hosting.Common; using Microsoft.AspNetCore.Builder; @@ -50,7 +53,6 @@ public Action RegisterServices c.GetRequiredService(), c.GetRequiredServiceForPlatform(), c.GetRequiredService(), - c.GetRequiredService(), c.GetRequiredService(), c.GetRequiredService(), c.GetRequiredService())); @@ -71,10 +73,15 @@ public Action RegisterServices c.GetRequiredService(), c.GetRequiredService>(), c.GetRequiredServiceForPlatform())); - services.RegisterUnTenantedEventing( + services + .AddPerHttpRequest(c => + new EndUserDomainNotificationConsumer(c.GetRequiredService(), + c.GetRequiredService())); + services.RegisterUnTenantedEventing( c => new EndUserProjection(c.GetRequiredService(), c.GetRequiredService(), - c.GetRequiredServiceForPlatform())); + c.GetRequiredServiceForPlatform()), + c => new EndUserNotifier(c.GetRequiredService>())); services.AddSingleton(); }; diff --git a/src/EndUsersInfrastructure/Notifications/EndUserDomainNotificationConsumer.cs b/src/EndUsersInfrastructure/Notifications/EndUserDomainNotificationConsumer.cs new file mode 100644 index 00000000..9f8205b9 --- /dev/null +++ b/src/EndUsersInfrastructure/Notifications/EndUserDomainNotificationConsumer.cs @@ -0,0 +1,34 @@ +using Common; +using Domain.Events.Shared.Organizations; +using Domain.Interfaces.Entities; +using EndUsersApplication; +using Infrastructure.Eventing.Interfaces.Notifications; +using Infrastructure.Interfaces; + +namespace EndUsersInfrastructure.Notifications; + +public class EndUserDomainNotificationConsumer : IDomainEventNotificationConsumer +{ + private readonly ICallerContextFactory _callerContextFactory; + private readonly IEndUsersApplication _endUsersApplication; + + public EndUserDomainNotificationConsumer(ICallerContextFactory callerContextFactory, + IEndUsersApplication endUsersApplication) + { + _callerContextFactory = callerContextFactory; + _endUsersApplication = endUsersApplication; + } + + public async Task> NotifyAsync(IDomainEvent domainEvent, CancellationToken cancellationToken) + { + switch (domainEvent) + { + case Created created: + return await _endUsersApplication.HandleOrganizationCreatedAsync(_callerContextFactory.Create(), + created, cancellationToken); + + default: + return Result.Ok; + } + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Notifications/EndUserIntegrationEventNotificationTranslator.cs b/src/EndUsersInfrastructure/Notifications/EndUserIntegrationEventNotificationTranslator.cs new file mode 100644 index 00000000..a89632a9 --- /dev/null +++ b/src/EndUsersInfrastructure/Notifications/EndUserIntegrationEventNotificationTranslator.cs @@ -0,0 +1,36 @@ +using Common; +using Domain.Events.Shared.EndUsers; +using Domain.Interfaces.Entities; +using Infrastructure.Eventing.Interfaces.Notifications; +using Integration.Events.Shared.EndUsers; + +namespace EndUsersInfrastructure.Notifications; + +/// +/// Provides an example translator of domain events that should be published as integration events +/// +public sealed class + EndUserIntegrationEventNotificationTranslator : IIntegrationEventNotificationTranslator + where TAggregateRoot : IEventingAggregateRoot +{ + public Type RootAggregateType => typeof(TAggregateRoot); + + public async Task, Error>> TranslateAsync(IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + await Task.CompletedTask; + switch (domainEvent) + { + case Registered registered: + return new PersonRegistered(registered.RootId) + { + Features = registered.Features, + Roles = registered.Roles, + Username = registered.Username ?? string.Empty, + UserProfile = registered.UserProfile + }.ToOptional(); + } + + return Optional.None; + } +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Notifications/EndUserNotifier.cs b/src/EndUsersInfrastructure/Notifications/EndUserNotifier.cs new file mode 100644 index 00000000..a4078e26 --- /dev/null +++ b/src/EndUsersInfrastructure/Notifications/EndUserNotifier.cs @@ -0,0 +1,17 @@ +using EndUsersDomain; +using Infrastructure.Eventing.Interfaces.Notifications; + +namespace EndUsersInfrastructure.Notifications; + +public class EndUserNotifier : IEventNotificationRegistration +{ + public EndUserNotifier(IEnumerable domainConsumers) + { + DomainEventConsumers = domainConsumers.ToList(); + } + + public List DomainEventConsumers { get; } + + public IIntegrationEventNotificationTranslator IntegrationEventTranslator => + new EndUserIntegrationEventNotificationTranslator(); +} \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs index 0931c3ac..6f8b218c 100644 --- a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs +++ b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs @@ -4,6 +4,7 @@ using Common; using Domain.Common.ValueObjects; using Domain.Interfaces; +using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; using EndUsersApplication.Persistence.ReadModels; using EndUsersDomain; @@ -49,6 +50,12 @@ public async Task> LoadAsync(Identifier id, Cancellat } public async Task> SaveAsync(EndUserRoot user, CancellationToken cancellationToken) + { + return await SaveAsync(user, false, cancellationToken); + } + + public async Task> SaveAsync(EndUserRoot user, bool reload, + CancellationToken cancellationToken) { var saved = await _users.SaveAsync(user, cancellationToken); if (!saved.IsSuccessful) @@ -56,7 +63,9 @@ public async Task> SaveAsync(EndUserRoot user, Cancel return saved.Error; } - return user; + return reload + ? await LoadAsync(user.Id, cancellationToken) + : user; } public async Task, Error>> SearchAllMembershipsByOrganizationAsync( @@ -72,7 +81,7 @@ public async Task, Error>> SearchAllMember .Select(mje => mje.IsDefault) .Select(mje => mje.LastPersistedAtUtc) .SelectFromJoin(mje => mje.InvitedEmailAddress, inv => inv.InvitedEmailAddress) - .SelectFromJoin(mje => mje.Status, inv => inv.Status) + .SelectFromJoin(mje => mje.Status, inv => inv.Status) .OrderBy(mje => mje.LastPersistedAtUtc) .WithSearchOptions(searchOptions); diff --git a/src/EndUsersInfrastructure/Persistence/InvitationRepository.cs b/src/EndUsersInfrastructure/Persistence/InvitationRepository.cs index cf36e0e9..7ad07597 100644 --- a/src/EndUsersInfrastructure/Persistence/InvitationRepository.cs +++ b/src/EndUsersInfrastructure/Persistence/InvitationRepository.cs @@ -4,6 +4,7 @@ using Domain.Common.ValueObjects; using Domain.Interfaces; using Domain.Shared; +using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; using EndUsersApplication.Persistence.ReadModels; using EndUsersDomain; @@ -37,7 +38,7 @@ public async Task, Error>> FindInvitedGuestByEmailA { var query = Query.From() .Where(eu => eu.InvitedEmailAddress, ConditionOperator.EqualTo, emailAddress.Address) - .AndWhere(eu => eu.Status, ConditionOperator.EqualTo, UserStatus.Unregistered.ToString()); + .AndWhere(eu => eu.Status, ConditionOperator.EqualTo, UserStatus.Unregistered); return await FindFirstByQueryAsync(query, cancellationToken); } @@ -46,7 +47,7 @@ public async Task, Error>> FindInvitedGuestByTokenA { var query = Query.From() .Where(eu => eu.Token, ConditionOperator.EqualTo, token) - .AndWhere(eu => eu.Status, ConditionOperator.EqualTo, UserStatus.Unregistered.ToString()); + .AndWhere(eu => eu.Status, ConditionOperator.EqualTo, UserStatus.Unregistered); return await FindFirstByQueryAsync(query, cancellationToken); } diff --git a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs index cefc777f..244981fd 100644 --- a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs +++ b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs @@ -6,6 +6,7 @@ using Domain.Interfaces; using Domain.Interfaces.Entities; using Domain.Shared; +using Domain.Shared.EndUsers; using EndUsersApplication.Persistence.ReadModels; using EndUsersDomain; using Infrastructure.Persistence.Common; @@ -170,7 +171,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven return await _invitations.HandleUpdateAsync(e.RootId.ToId(), dto => { dto.Token = Optional.None; - dto.Status = UserStatus.Registered.ToString(); + dto.Status = UserStatus.Registered; dto.AcceptedAtUtc = e.AcceptedAtUtc; dto.AcceptedEmailAddress = e.AcceptedEmailAddress; }, cancellationToken); diff --git a/src/IdentityDomain/Events.cs b/src/IdentityDomain/Events.cs index 42b6a3ac..e2fb5c12 100644 --- a/src/IdentityDomain/Events.cs +++ b/src/IdentityDomain/Events.cs @@ -14,11 +14,9 @@ public static class AuthTokens { public static Created Created(Identifier id, Identifier userId) { - return new Created + return new Created(id) { - RootId = id, - UserId = userId, - OccurredUtc = DateTime.UtcNow + UserId = userId }; } @@ -26,15 +24,13 @@ public static TokensChanged TokensChanged(Identifier id, Identifier userId, stri DateTime accessTokenExpiresOn, string refreshToken, DateTime refreshTokenExpiresOn) { - return new TokensChanged + return new TokensChanged(id) { - RootId = id, UserId = userId, AccessToken = accessToken, RefreshToken = refreshToken, AccessTokenExpiresOn = accessTokenExpiresOn, - RefreshTokenExpiresOn = refreshTokenExpiresOn, - OccurredUtc = DateTime.UtcNow + RefreshTokenExpiresOn = refreshTokenExpiresOn }; } @@ -42,25 +38,21 @@ public static TokensRefreshed TokensRefreshed(Identifier id, Identifier userId, DateTime accessTokenExpiresOn, string refreshToken, DateTime refreshTokenExpiresOn) { - return new TokensRefreshed + return new TokensRefreshed(id) { - RootId = id, UserId = userId, AccessToken = accessToken, RefreshToken = refreshToken, AccessTokenExpiresOn = accessTokenExpiresOn, - RefreshTokenExpiresOn = refreshTokenExpiresOn, - OccurredUtc = DateTime.UtcNow + RefreshTokenExpiresOn = refreshTokenExpiresOn }; } public static TokensRevoked TokensRevoked(Identifier id, Identifier userId) { - return new TokensRevoked + return new TokensRevoked(id) { - RootId = id, - UserId = userId, - OccurredUtc = DateTime.UtcNow + UserId = userId }; } } @@ -69,105 +61,79 @@ public static class PasswordCredentials { public static AccountLocked AccountLocked(Identifier id) { - return new AccountLocked - { - RootId = id, - OccurredUtc = DateTime.UtcNow - }; + return new AccountLocked(id); } public static AccountUnlocked AccountUnlocked(Identifier id) { - return new AccountUnlocked - { - RootId = id, - OccurredUtc = DateTime.UtcNow - }; + return new AccountUnlocked(id); } public static Domain.Events.Shared.Identities.PasswordCredentials.Created Created(Identifier id, Identifier userId) { - return new Domain.Events.Shared.Identities.PasswordCredentials.Created + return new Domain.Events.Shared.Identities.PasswordCredentials.Created(id) { - RootId = id, - UserId = userId, - OccurredUtc = DateTime.UtcNow + UserId = userId }; } public static CredentialsChanged CredentialsChanged(Identifier id, string passwordHash) { - return new CredentialsChanged + return new CredentialsChanged(id) { - RootId = id, - PasswordHash = passwordHash, - OccurredUtc = DateTime.UtcNow + PasswordHash = passwordHash }; } public static PasswordResetCompleted PasswordResetCompleted(Identifier id, string token, string passwordHash) { - return new PasswordResetCompleted + return new PasswordResetCompleted(id) { - RootId = id, Token = token, - PasswordHash = passwordHash, - OccurredUtc = DateTime.UtcNow + PasswordHash = passwordHash }; } public static PasswordResetInitiated PasswordResetInitiated(Identifier id, string token) { - return new PasswordResetInitiated + return new PasswordResetInitiated(id) { - RootId = id, - Token = token, - OccurredUtc = DateTime.UtcNow + Token = token }; } public static PasswordVerified PasswordVerified(Identifier id, bool isVerified, bool auditAttempt) { - return new PasswordVerified + return new PasswordVerified(id) { - RootId = id, IsVerified = isVerified, - AuditAttempt = auditAttempt, - OccurredUtc = DateTime.UtcNow + AuditAttempt = auditAttempt }; } public static RegistrationChanged RegistrationChanged(Identifier id, EmailAddress emailAddress, PersonDisplayName name) { - return new RegistrationChanged + return new RegistrationChanged(id) { - RootId = id, EmailAddress = emailAddress, - Name = name, - OccurredUtc = DateTime.UtcNow + Name = name }; } public static RegistrationVerificationCreated RegistrationVerificationCreated(Identifier id, string token) { - return new RegistrationVerificationCreated + return new RegistrationVerificationCreated(id) { - RootId = id, - Token = token, - OccurredUtc = DateTime.UtcNow + Token = token }; } public static RegistrationVerificationVerified RegistrationVerificationVerified(Identifier id) { - return new RegistrationVerificationVerified - { - RootId = id, - OccurredUtc = DateTime.UtcNow - }; + return new RegistrationVerificationVerified(id); } } @@ -176,35 +142,29 @@ public static class APIKeys public static Domain.Events.Shared.Identities.APIKeys.Created Created(Identifier id, Identifier userId, string keyToken, string keyHash) { - return new Domain.Events.Shared.Identities.APIKeys.Created + return new Domain.Events.Shared.Identities.APIKeys.Created(id) { - RootId = id, UserId = userId, KeyToken = keyToken, - KeyHash = keyHash, - OccurredUtc = DateTime.UtcNow + KeyHash = keyHash }; } public static KeyVerified KeyVerified(Identifier id, bool isVerified) { - return new KeyVerified + return new KeyVerified(id) { - RootId = id, - IsVerified = isVerified, - OccurredUtc = DateTime.UtcNow + IsVerified = isVerified }; } public static ParametersChanged ParametersChanged(Identifier id, string description, DateTime expiresOn) { - return new ParametersChanged + return new ParametersChanged(id) { - RootId = id, Description = description, - ExpiresOn = expiresOn, - OccurredUtc = DateTime.UtcNow + ExpiresOn = expiresOn }; } } @@ -214,28 +174,24 @@ public static class SSOUsers public static Domain.Events.Shared.Identities.SSOUsers.Created Created(Identifier id, string providerName, Identifier userId) { - return new Domain.Events.Shared.Identities.SSOUsers.Created + return new Domain.Events.Shared.Identities.SSOUsers.Created(id) { - RootId = id, ProviderName = providerName, - UserId = userId, - OccurredUtc = DateTime.UtcNow + UserId = userId }; } public static TokensUpdated TokensUpdated(Identifier id, string tokens, EmailAddress emailAddress, PersonName name, Timezone timezone, Address address) { - return new TokensUpdated + return new TokensUpdated(id) { - RootId = id, Tokens = tokens, EmailAddress = emailAddress, FirstName = name.FirstName, LastName = name.LastName.ValueOrDefault?.Text, Timezone = timezone.Code.ToString(), - CountryCode = address.CountryCode.ToString(), - OccurredUtc = DateTime.UtcNow + CountryCode = address.CountryCode.ToString() }; } } diff --git a/src/Infrastructure.Eventing.Common.UnitTests/Infrastructure.Eventing.Common.UnitTests.csproj b/src/Infrastructure.Eventing.Common.UnitTests/Infrastructure.Eventing.Common.UnitTests.csproj index 6ec2b4e8..763395c7 100644 --- a/src/Infrastructure.Eventing.Common.UnitTests/Infrastructure.Eventing.Common.UnitTests.csproj +++ b/src/Infrastructure.Eventing.Common.UnitTests/Infrastructure.Eventing.Common.UnitTests.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Infrastructure.Eventing.Common.UnitTests/Notifications/EventNotificationNotifierSpec.cs b/src/Infrastructure.Eventing.Common.UnitTests/Notifications/EventNotificationNotifierSpec.cs index 9bbc7255..af83b570 100644 --- a/src/Infrastructure.Eventing.Common.UnitTests/Notifications/EventNotificationNotifierSpec.cs +++ b/src/Infrastructure.Eventing.Common.UnitTests/Notifications/EventNotificationNotifierSpec.cs @@ -1,6 +1,5 @@ using Application.Persistence.Interfaces; using Common; -using Common.Extensions; using Domain.Common; using Domain.Common.Extensions; using Domain.Common.ValueObjects; @@ -17,6 +16,8 @@ namespace Infrastructure.Eventing.Common.UnitTests.Notifications; [Trait("Category", "Unit")] public sealed class EventNotificationNotifierSpec : IDisposable { + private readonly Mock _domainConsumer; + private readonly Mock _messageBroker; private readonly EventNotificationNotifier _notifier; private readonly Mock _registration; @@ -25,14 +26,22 @@ public EventNotificationNotifierSpec() var recorder = new Mock(); var changeEventTypeMigrator = new ChangeEventTypeMigrator(); _registration = new Mock(); - _registration.Setup(p => p.Producer.RootAggregateType) + _registration.Setup(p => p.IntegrationEventTranslator.RootAggregateType) .Returns(typeof(string)); - _registration.Setup(p => p.Producer.PublishAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromResult, Error>>(Optional.None)); - _registration.Setup(p => p.Consumer.NotifyAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromResult>(true)); + _registration.Setup(p => + p.IntegrationEventTranslator.TranslateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + _domainConsumer = new Mock(); + _domainConsumer.Setup(c => c.NotifyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok); + _messageBroker = new Mock(); + _messageBroker.Setup(mb => mb.PublishAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok); + _registration.Setup(p => p.DomainEventConsumers) + .Returns([_domainConsumer.Object]); var registrations = new List { _registration.Object }; - _notifier = new EventNotificationNotifier(recorder.Object, changeEventTypeMigrator, registrations.ToArray()); + _notifier = new EventNotificationNotifier(recorder.Object, changeEventTypeMigrator, registrations, + _messageBroker.Object); } ~EventNotificationNotifierSpec() @@ -57,25 +66,29 @@ private void Dispose(bool disposing) [Fact] public async Task WhenWriteEventStreamAndNoEvents_ThenReturns() { - await _notifier.WriteEventStreamAsync("astreamname", new List(), + await _notifier.WriteEventStreamAsync("astreamname", [], CancellationToken.None); - _registration.Verify(p => p.Producer.PublishAsync(It.IsAny(), It.IsAny()), + _domainConsumer.Verify(c => c.NotifyAsync(It.IsAny(), It.IsAny()), + Times.Never); + _messageBroker.Verify(mb => mb.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + _registration.Verify( + p => p.IntegrationEventTranslator.TranslateAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task WhenWriteEventStreamAndNoConfiguredConsumer_ThenReturns() + public async Task WhenWriteEventStreamAndNoRegisteredConsumers_ThenReturns() { - _registration.Setup(p => p.Producer.RootAggregateType) + _registration.Setup(p => p.IntegrationEventTranslator.RootAggregateType) .Returns(typeof(string)); - var result = await _notifier.WriteEventStreamAsync("astreamname", new List - { - new() + var result = await _notifier.WriteEventStreamAsync("astreamname", [ + new EventStreamChangeEvent { Data = null!, - EntityType = "atypename", + RootAggregateType = "atypename", EventType = null!, Id = null!, LastPersistedAtUtc = default, @@ -83,23 +96,27 @@ public async Task WhenWriteEventStreamAndNoConfiguredConsumer_ThenReturns() StreamName = null!, Version = 0 } - }, CancellationToken.None); + ], CancellationToken.None); result.Should().BeSuccess(); - _registration.Verify(p => p.Producer.PublishAsync(It.IsAny(), It.IsAny()), + _domainConsumer.Verify(c => c.NotifyAsync(It.IsAny(), It.IsAny()), + Times.Never); + _messageBroker.Verify(mb => mb.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + _registration.Verify( + p => p.IntegrationEventTranslator.TranslateAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task WhenWriteEventStreamAndDeserializationOfEventsFails_ThenReturnsError() { - var result = await _notifier.WriteEventStreamAsync("astreamname", new List - { - new() + var result = await _notifier.WriteEventStreamAsync("astreamname", [ + new EventStreamChangeEvent { Id = "anid", - EntityType = nameof(String), - Data = new TestChangeEvent + RootAggregateType = nameof(String), + Data = new TestDomainEvent { RootId = "aneventid" }.ToEventJson(), @@ -109,200 +126,187 @@ public async Task WhenWriteEventStreamAndDeserializationOfEventsFails_ThenReturn LastPersistedAtUtc = default, StreamName = null! } - }, CancellationToken.None); + ], CancellationToken.None); result.Should().BeError(ErrorCode.RuleViolation); } [Fact] - public async Task WhenWriteEventStreamAndProducerDoesNotPublishEvent_ThenReturns() + public async Task WhenWriteEventStreamAndTranslatorDoesNotTranslateEvent_ThenOnlyNotifiesDomainEvent() { - _registration.Setup(p => p.Producer.PublishAsync(It.IsAny(), It.IsAny())) - .Returns((IDomainEvent _, CancellationToken _) => - Task.FromResult, Error>>(Optional.None)); + _registration.Setup(p => + p.IntegrationEventTranslator.TranslateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IDomainEvent _, CancellationToken _) => Optional.None); - var result = await _notifier.WriteEventStreamAsync("astreamname", new List - { - new() + var result = await _notifier.WriteEventStreamAsync("astreamname", [ + new EventStreamChangeEvent { Id = "anid1", - EntityType = nameof(String), - Data = new TestChangeEvent { RootId = "aneventid1" }.ToEventJson(), + RootAggregateType = nameof(String), + Data = new TestDomainEvent { RootId = "aneventid" }.ToEventJson(), Version = 0, - Metadata = new EventMetadata(typeof(TestChangeEvent).AssemblyQualifiedName!), + Metadata = new EventMetadata(typeof(TestDomainEvent).AssemblyQualifiedName!), EventType = null!, LastPersistedAtUtc = default, StreamName = null! } - }, CancellationToken.None); + ], CancellationToken.None); result.Should().BeSuccess(); - _registration.Verify(p => p.Producer.PublishAsync(It.Is(e => - e.RootId == "aneventid1" + _registration.Verify(p => p.IntegrationEventTranslator.TranslateAsync(It.Is(e => + e.RootId == "aneventid" ), It.IsAny())); - _registration.Verify(p => p.Consumer.NotifyAsync(It.IsAny(), It.IsAny()), + _domainConsumer.Verify(c => c.NotifyAsync(It.Is(ce => + ce.RootId == "aneventid" + ), It.IsAny())); + _messageBroker.Verify(mb => mb.PublishAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task WhenWriteEventStreamAndFirstEverEvent_ThenNotifiesEvents() + public async Task WhenWriteEventStreamWithSingleEvent_ThenNotifiesBothDomainAndIntegrationEvents() { - _registration.Setup(p => p.Producer.PublishAsync(It.IsAny(), It.IsAny())) - .Returns((IDomainEvent @event, CancellationToken _) => - Task.FromResult, Error>>(new TestChangeEvent { RootId = @event.RootId } - .ToOptional())); + _registration.Setup(p => + p.IntegrationEventTranslator.TranslateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IDomainEvent domainEvent, CancellationToken _) => + new TestIntegrationEvent(domainEvent.RootId).ToOptional()); - var result = await _notifier.WriteEventStreamAsync("astreamname", new List - { - new() + var result = await _notifier.WriteEventStreamAsync("astreamname", [ + new EventStreamChangeEvent { Id = "anid1", - EntityType = nameof(String), - Data = new TestChangeEvent { RootId = "aneventid1" }.ToEventJson(), + RootAggregateType = nameof(String), + Data = new TestDomainEvent { RootId = "aneventid" }.ToEventJson(), Version = 0, - Metadata = new EventMetadata(typeof(TestChangeEvent).AssemblyQualifiedName!), - EventType = null!, - LastPersistedAtUtc = default, - StreamName = null! - }, - new() - { - Id = "anid2", - EntityType = nameof(String), - Data = new TestChangeEvent { RootId = "aneventid2" }.ToEventJson(), - Version = 1, - Metadata = new EventMetadata(typeof(TestChangeEvent).AssemblyQualifiedName!), - EventType = null!, - LastPersistedAtUtc = default, - StreamName = null! - }, - new() - { - Id = "anid3", - EntityType = nameof(String), - Data = new TestChangeEvent { RootId = "aneventid3" }.ToEventJson(), - Version = 2, - Metadata = new EventMetadata(typeof(TestChangeEvent).AssemblyQualifiedName!), + Metadata = new EventMetadata(typeof(TestDomainEvent).AssemblyQualifiedName!), EventType = null!, LastPersistedAtUtc = default, StreamName = null! } - }, CancellationToken.None); + ], CancellationToken.None); result.Should().BeSuccess(); - _registration.Verify(p => p.Producer.PublishAsync(It.Is(e => - e.RootId == "aneventid2" + _registration.Verify(p => p.IntegrationEventTranslator.TranslateAsync(It.Is(e => + e.RootId == "aneventid" ), It.IsAny())); - _registration.Verify(p => p.Consumer.NotifyAsync(It.Is(e => - e.RootId == "aneventid2" + _domainConsumer.Verify(c => c.NotifyAsync(It.Is(ce => + ce.RootId == "aneventid" ), It.IsAny())); - _registration.Verify(p => p.Producer.PublishAsync(It.Is(e => - e.RootId == "aneventid3" - ), It.IsAny())); - _registration.Verify(p => p.Consumer.NotifyAsync(It.Is(e => - e.RootId == "aneventid3" + _messageBroker.Verify(mb => mb.PublishAsync(It.Is(ie => + ie.RootId == "aneventid" ), It.IsAny())); } [Fact] - public async Task WhenWriteEventStreamAndConsumerDoesNotHandleEvent_ThenReturnsError() + public async Task WhenWriteEventStreamWithMultipleEvents_ThenNotifiesBothDomainAndIntegrationEvents() { - _registration.Setup(p => p.Producer.PublishAsync(It.IsAny(), It.IsAny())) - .Returns((IDomainEvent @event, CancellationToken _) => - Task.FromResult, Error>>(new TestChangeEvent { RootId = @event.RootId } - .ToOptional())); - _registration.Setup(p => p.Consumer.NotifyAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromResult>(false)); + _registration.Setup(p => + p.IntegrationEventTranslator.TranslateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IDomainEvent domainEvent, CancellationToken _) => + new TestIntegrationEvent(domainEvent.RootId).ToOptional()); - var result = await _notifier.WriteEventStreamAsync("astreamname", new List - { - new() + var result = await _notifier.WriteEventStreamAsync("astreamname", [ + new EventStreamChangeEvent { Id = "anid1", - EntityType = nameof(String), - Data = new TestChangeEvent { RootId = "aneventid1" }.ToEventJson(), + RootAggregateType = nameof(String), + Data = new TestDomainEvent { RootId = "aneventid1" }.ToEventJson(), Version = 0, - Metadata = new EventMetadata(typeof(TestChangeEvent).AssemblyQualifiedName!), - EventType = null!, - LastPersistedAtUtc = default, - StreamName = null! - } - }, CancellationToken.None); - - result.Should().BeError(ErrorCode.RuleViolation, - Resources.EventNotificationNotifier_ConsumerError.Format("IEventNotificationConsumerProxy", "anid1", - typeof(TestChangeEvent).AssemblyQualifiedName!)); - _registration.Verify(p => p.Producer.PublishAsync(It.Is(e => - e.RootId == "aneventid1" - ), It.IsAny())); - _registration.Verify(p => p.Consumer.NotifyAsync(It.Is(e => - e.RootId == "aneventid1" - ), It.IsAny())); - } - - [Fact] - public async Task WhenWriteEventStream_ThenNotifiesEvents() - { - _registration.Setup(p => p.Producer.PublishAsync(It.IsAny(), It.IsAny())) - .Returns((IDomainEvent @event, CancellationToken _) => - Task.FromResult, Error>>(new TestChangeEvent { RootId = @event.RootId } - .ToOptional())); - - var result = await _notifier.WriteEventStreamAsync("astreamname", new List - { - new() - { - Id = "anid1", - EntityType = nameof(String), - Data = new TestChangeEvent { RootId = "aneventid1" }.ToEventJson(), - Version = 3, - Metadata = new EventMetadata(typeof(TestChangeEvent).AssemblyQualifiedName!), + Metadata = new EventMetadata(typeof(TestDomainEvent).AssemblyQualifiedName!), EventType = null!, LastPersistedAtUtc = default, StreamName = null! }, - new() + + new EventStreamChangeEvent { Id = "anid2", - EntityType = nameof(String), - Data = new TestChangeEvent { RootId = "aneventid2" }.ToEventJson(), - Version = 4, - Metadata = new EventMetadata(typeof(TestChangeEvent).AssemblyQualifiedName!), + RootAggregateType = nameof(String), + Data = new TestDomainEvent { RootId = "aneventid2" }.ToEventJson(), + Version = 1, + Metadata = new EventMetadata(typeof(TestDomainEvent).AssemblyQualifiedName!), EventType = null!, LastPersistedAtUtc = default, StreamName = null! }, - new() + + new EventStreamChangeEvent { Id = "anid3", - EntityType = nameof(String), - Data = new TestChangeEvent { RootId = "aneventid3" }.ToEventJson(), - Version = 5, - Metadata = new EventMetadata(typeof(TestChangeEvent).AssemblyQualifiedName!), + RootAggregateType = nameof(String), + Data = new TestDomainEvent { RootId = "aneventid3" }.ToEventJson(), + Version = 2, + Metadata = new EventMetadata(typeof(TestDomainEvent).AssemblyQualifiedName!), EventType = null!, LastPersistedAtUtc = default, StreamName = null! } - }, CancellationToken.None); + ], CancellationToken.None); result.Should().BeSuccess(); - _registration.Verify(p => p.Producer.PublishAsync(It.Is(e => + _registration.Verify(p => p.IntegrationEventTranslator.TranslateAsync(It.Is(e => + e.RootId == "aneventid1" + ), It.IsAny())); + _domainConsumer.Verify(c => c.NotifyAsync(It.Is(e => e.RootId == "aneventid1" ), It.IsAny())); - _registration.Verify(p => p.Consumer.NotifyAsync(It.Is(e => + _messageBroker.Verify(mb => mb.PublishAsync(It.Is(e => e.RootId == "aneventid1" ), It.IsAny())); - _registration.Verify(p => p.Producer.PublishAsync(It.Is(e => + _registration.Verify(p => p.IntegrationEventTranslator.TranslateAsync(It.Is(e => e.RootId == "aneventid2" ), It.IsAny())); - _registration.Verify(p => p.Consumer.NotifyAsync(It.Is(e => + _domainConsumer.Verify(c => c.NotifyAsync(It.Is(e => e.RootId == "aneventid2" ), It.IsAny())); - _registration.Verify(p => p.Producer.PublishAsync(It.Is(e => + _messageBroker.Verify(mb => mb.PublishAsync(It.Is(e => + e.RootId == "aneventid2" + ), It.IsAny())); + _registration.Verify(r => r.IntegrationEventTranslator.TranslateAsync(It.Is(e => + e.RootId == "aneventid3" + ), It.IsAny())); + _domainConsumer.Verify(c => c.NotifyAsync(It.Is(e => e.RootId == "aneventid3" ), It.IsAny())); - _registration.Verify(p => p.Consumer.NotifyAsync(It.Is(e => + _messageBroker.Verify(mb => mb.PublishAsync(It.Is(e => e.RootId == "aneventid3" ), It.IsAny())); } + + [Fact] + public async Task WhenWriteEventStreamAndDomainConsumerReturnsError_ThenStopsAndReturnsError() + { + _registration.Setup(p => + p.IntegrationEventTranslator.TranslateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IDomainEvent domainEvent, CancellationToken _) => + new TestIntegrationEvent(domainEvent.RootId).ToOptional()); + _domainConsumer.Setup(c => c.NotifyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.RuleViolation("amessage")); + + var result = await _notifier.WriteEventStreamAsync("astreamname", [ + new EventStreamChangeEvent + { + Id = "anid1", + RootAggregateType = nameof(String), + Data = new TestDomainEvent { RootId = "aneventid" }.ToEventJson(), + Version = 0, + Metadata = new EventMetadata(typeof(TestDomainEvent).AssemblyQualifiedName!), + EventType = null!, + LastPersistedAtUtc = default, + StreamName = null! + } + ], CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, "amessage"); + _domainConsumer.Verify(c => c.NotifyAsync(It.Is(e => + e.RootId == "aneventid" + ), It.IsAny())); + _registration.Verify( + p => p.IntegrationEventTranslator.TranslateAsync(It.IsAny(), It.IsAny()), + Times.Never); + _messageBroker.Verify(mb => mb.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + + } + } \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common.UnitTests/Notifications/TestChangeEvent.cs b/src/Infrastructure.Eventing.Common.UnitTests/Notifications/TestChangeEvent.cs deleted file mode 100644 index 977bff25..00000000 --- a/src/Infrastructure.Eventing.Common.UnitTests/Notifications/TestChangeEvent.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Domain.Interfaces.Entities; - -namespace Infrastructure.Eventing.Common.UnitTests.Notifications; - -public class TestChangeEvent : IDomainEvent -{ - public DateTime OccurredUtc { get; set; } - - public string RootId { get; set; } = "arootid"; -} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common.UnitTests/Notifications/TestDomainEvent.cs b/src/Infrastructure.Eventing.Common.UnitTests/Notifications/TestDomainEvent.cs new file mode 100644 index 00000000..aa4905e1 --- /dev/null +++ b/src/Infrastructure.Eventing.Common.UnitTests/Notifications/TestDomainEvent.cs @@ -0,0 +1,22 @@ +using Domain.Common; +using Infrastructure.Eventing.Common.Notifications; + +namespace Infrastructure.Eventing.Common.UnitTests.Notifications; + +public class TestDomainEvent : DomainEvent +{ + public TestDomainEvent() : base("arootid") + { + } +} + +public class TestIntegrationEvent : IntegrationEvent +{ + public TestIntegrationEvent() : base("arootid") + { + } + + public TestIntegrationEvent(string rootId) : base(rootId) + { + } +} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common.UnitTests/Projections/ReadModelProjectorSpec.cs b/src/Infrastructure.Eventing.Common.UnitTests/Projections/ReadModelProjectorSpec.cs index 79f579b2..15c0735e 100644 --- a/src/Infrastructure.Eventing.Common.UnitTests/Projections/ReadModelProjectorSpec.cs +++ b/src/Infrastructure.Eventing.Common.UnitTests/Projections/ReadModelProjectorSpec.cs @@ -81,7 +81,7 @@ public async Task WhenWriteEventStreamAsyncAndNoConfiguredProjection_ThenReturns new() { Data = null!, - EntityType = "atypename", + RootAggregateType = "atypename", EventType = null!, Id = null!, LastPersistedAtUtc = default, @@ -117,7 +117,7 @@ public async Task WhenWriteEventStreamAsyncAndEventVersionGreaterThanCheckpoint_ new() { Data = null!, - EntityType = nameof(String), + RootAggregateType = nameof(String), EventType = null!, Id = null!, LastPersistedAtUtc = default, @@ -141,7 +141,7 @@ public async Task WhenWriteEventStreamAsyncAndEventVersionLessThanCheckpoint_The new() { Id = "anid1", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid1" }.ToEventJson(), Version = 4, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), @@ -152,7 +152,7 @@ public async Task WhenWriteEventStreamAsyncAndEventVersionLessThanCheckpoint_The new() { Id = "anid2", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid2" }.ToEventJson(), Version = 5, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), @@ -163,7 +163,7 @@ public async Task WhenWriteEventStreamAsyncAndEventVersionLessThanCheckpoint_The new() { Id = "anid3", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid3" }.ToEventJson(), Version = 6, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), @@ -198,7 +198,7 @@ public async Task WhenWriteEventStreamAsyncAndDeserializationOfEventsFails_ThenR { Id = "anid", LastPersistedAtUtc = default, - EntityType = nameof(String), + RootAggregateType = nameof(String), EventType = null!, Data = new TestEvent { @@ -225,7 +225,7 @@ public async Task WhenWriteEventStreamAsyncAndFirstEverEvent_ThenProjectsEvents( new() { Id = "anid1", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid1" }.ToEventJson(), Version = startingCheckpoint, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), @@ -236,7 +236,7 @@ public async Task WhenWriteEventStreamAsyncAndFirstEverEvent_ThenProjectsEvents( new() { Id = "anid2", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid2" }.ToEventJson(), Version = startingCheckpoint + 1, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), @@ -247,7 +247,7 @@ public async Task WhenWriteEventStreamAsyncAndFirstEverEvent_ThenProjectsEvents( new() { Id = "anid3", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid3" }.ToEventJson(), Version = startingCheckpoint + 2, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), @@ -284,7 +284,7 @@ public async Task WhenWriteEventStreamAsyncAndEventNotHandledByProjection_ThenRe new() { Id = "anid1", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid1" }.ToEventJson(), Version = 3, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), @@ -317,7 +317,7 @@ public async Task WhenWriteEventStreamAsync_ThenProjectsEvents() new() { Id = "anid1", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid1" }.ToEventJson(), Version = 3, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), @@ -328,7 +328,7 @@ public async Task WhenWriteEventStreamAsync_ThenProjectsEvents() new() { Id = "anid2", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid2" }.ToEventJson(), Version = 4, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), @@ -339,7 +339,7 @@ public async Task WhenWriteEventStreamAsync_ThenProjectsEvents() new() { Id = "anid3", - EntityType = nameof(String), + RootAggregateType = nameof(String), Data = new TestEvent { RootId = "aneventid3" }.ToEventJson(), Version = 5, Metadata = new EventMetadata(typeof(TestEvent).AssemblyQualifiedName!), diff --git a/src/Infrastructure.Eventing.Common.UnitTests/Projections/TestEvent.cs b/src/Infrastructure.Eventing.Common.UnitTests/Projections/TestEvent.cs index bcc60ff6..ca9053c3 100644 --- a/src/Infrastructure.Eventing.Common.UnitTests/Projections/TestEvent.cs +++ b/src/Infrastructure.Eventing.Common.UnitTests/Projections/TestEvent.cs @@ -1,10 +1,10 @@ -using Domain.Interfaces.Entities; +using Domain.Common; namespace Infrastructure.Eventing.Common.UnitTests.Projections; -public class TestEvent : IDomainEvent +public class TestEvent : DomainEvent { - public DateTime OccurredUtc { get; set; } - - public string RootId { get; set; } = "arootid"; + public TestEvent() : base("arootid") + { + } } \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common/Notifications/EventNotificationNotifier.cs b/src/Infrastructure.Eventing.Common/Notifications/EventNotificationNotifier.cs index 87ee7948..32dd14f4 100644 --- a/src/Infrastructure.Eventing.Common/Notifications/EventNotificationNotifier.cs +++ b/src/Infrastructure.Eventing.Common/Notifications/EventNotificationNotifier.cs @@ -8,19 +8,21 @@ namespace Infrastructure.Eventing.Common.Notifications; /// -/// Provides a notifier of change events from registered producer to registered consumers +/// Provides a round-robin notifier of domain events to registered consumers /// public sealed class EventNotificationNotifier : IEventNotificationNotifier, IDisposable { + private readonly IEventNotificationMessageBroker _messageBroker; private readonly IEventSourcedChangeEventMigrator _migrator; private readonly IRecorder _recorder; public EventNotificationNotifier(IRecorder recorder, IEventSourcedChangeEventMigrator migrator, - params IEventNotificationRegistration[] registrations) + List registrations, IEventNotificationMessageBroker messageBroker) { _recorder = recorder; Registrations = registrations; _migrator = migrator; + _messageBroker = messageBroker; } ~EventNotificationNotifier() @@ -46,9 +48,7 @@ private void Dispose(bool disposing) foreach (var pair in Registrations) { // ReSharper disable once SuspiciousTypeConversion.Global - (pair.Producer as IDisposable)?.Dispose(); - // ReSharper disable once SuspiciousTypeConversion.Global - (pair.Consumer as IDisposable)?.Dispose(); + (pair.IntegrationEventTranslator as IDisposable)?.Dispose(); } } } @@ -70,77 +70,113 @@ public async Task> WriteEventStreamAsync(string streamName, List RelayEventStreamToAllConsumersInOrderAsync(registration, eventStream, cancellationToken))); + if (results.Any(r => !r.IsSuccessful)) + { + return results.First(r => !r.IsSuccessful).Error; + } + + return Result.Ok; + } + + private async Task> RelayEventStreamToAllConsumersInOrderAsync( + IEventNotificationRegistration registration, List eventStream, + CancellationToken cancellationToken) + { foreach (var changeEvent in eventStream) { - var deserialized = DeserializeEvent(changeEvent, _migrator); + var deserialized = DeserializeChangeEvent(changeEvent, _migrator); if (!deserialized.IsSuccessful) { return deserialized.Error; } - var relayed = await RelayEventAsync(registration.Value, deserialized.Value, changeEvent, - cancellationToken); - if (!relayed.IsSuccessful) + var @event = deserialized.Value; + var domainEventsRelayed = + await RelayDomainEventToAllConsumersAsync(registration, @event, cancellationToken); + if (!domainEventsRelayed.IsSuccessful) { - return relayed.Error; + return domainEventsRelayed.Error; + } + + var integrationEventsRelayed = + await RelayIntegrationEventToBrokerAsync(registration, changeEvent, @event, cancellationToken); + if (!integrationEventsRelayed.IsSuccessful) + { + return integrationEventsRelayed.Error; } } return Result.Ok; } - private async Task> RelayEventAsync(IEventNotificationRegistration registration, - IDomainEvent @event, EventStreamChangeEvent changeEvent, CancellationToken cancellationToken) + private static async Task> RelayDomainEventToAllConsumersAsync( + IEventNotificationRegistration registration, + IDomainEvent @event, CancellationToken cancellationToken) { - var published = await registration.Producer.PublishAsync(@event, cancellationToken); + if (registration.DomainEventConsumers.HasNone()) + { + return Result.Ok; + } + + var results = await Task.WhenAll(registration.DomainEventConsumers + .Select(consumer => consumer.NotifyAsync(@event, cancellationToken))); + if (results.Any(r => !r.IsSuccessful)) + { + return results.First(r => !r.IsSuccessful).Error; + } + + return Result.Ok; + } + + private async Task> RelayIntegrationEventToBrokerAsync( + IEventNotificationRegistration registration, EventStreamChangeEvent changeEvent, IDomainEvent @event, + CancellationToken cancellationToken) + { + var published = await registration.IntegrationEventTranslator.TranslateAsync(@event, cancellationToken); if (!published.IsSuccessful) { return published.Error.Wrap(Resources.EventNotificationNotifier_ProducerError.Format( - registration.Producer.GetType().Name, - changeEvent.Id, changeEvent.Metadata.Fqn)); + registration.IntegrationEventTranslator.GetType().Name, + @event, changeEvent.Metadata.Fqn)); } var publishedEvent = published.Value; if (!publishedEvent.HasValue) { _recorder.TraceInformation(null, - "The producer '{Producer}' chose not publish the event '{Event}' with event type '{Type}'", - registration.Producer.GetType().Name, - changeEvent.Id, changeEvent.Metadata.Fqn); + "The producer '{Producer}' chose not publish the integration event '{Event}' with event type '{Type}'", + registration.IntegrationEventTranslator.GetType().Name, changeEvent.Id, changeEvent.Metadata.Fqn); return Result.Ok; } - var notified = await registration.Consumer.NotifyAsync(publishedEvent.Value, cancellationToken); - if (!notified.IsSuccessful) - { - return notified.Error; - } - - if (!notified.Value) + var integrationEvent = publishedEvent.Value; + var brokered = await _messageBroker.PublishAsync(integrationEvent, cancellationToken); + if (!brokered.IsSuccessful) { - return Error.RuleViolation( - Resources.EventNotificationNotifier_ConsumerError.Format(registration.Consumer.GetType().Name, - changeEvent.Id, changeEvent.Metadata.Fqn)); + return brokered.Error; } return Result.Ok; } - private static Optional GetProducerForStream( - IEnumerable registrations, string entityTypeName) + private static List GetRegistrationsForStream( + IEnumerable registrations, string rootAggregateType) { - return new Optional( - registrations.FirstOrDefault(prj => prj.Producer.RootAggregateType.Name == entityTypeName)); + return registrations + .Where(prj => prj.IntegrationEventTranslator.RootAggregateType.Name == rootAggregateType) + .ToList(); } - private static Result DeserializeEvent(EventStreamChangeEvent changeEvent, + private static Result DeserializeChangeEvent(EventStreamChangeEvent changeEvent, IEventSourcedChangeEventMigrator migrator) { return changeEvent.Metadata.CreateEventFromJson(changeEvent.Id, changeEvent.Data, migrator); diff --git a/src/Infrastructure.Eventing.Common/Notifications/EventNotificationRegistration.cs b/src/Infrastructure.Eventing.Common/Notifications/EventNotificationRegistration.cs index d333201b..cf9047c3 100644 --- a/src/Infrastructure.Eventing.Common/Notifications/EventNotificationRegistration.cs +++ b/src/Infrastructure.Eventing.Common/Notifications/EventNotificationRegistration.cs @@ -3,18 +3,18 @@ namespace Infrastructure.Eventing.Common.Notifications; /// -/// Provides the registration information for both a producer and consumer +/// Provides the registration information for both consumers of domain and integration events /// public sealed class EventNotificationRegistration : IEventNotificationRegistration { - public EventNotificationRegistration(IEventNotificationProducer producer, - IEventNotificationConsumer consumer) + public EventNotificationRegistration(IIntegrationEventNotificationTranslator translator, + List domainEventConsumers) { - Producer = producer; - Consumer = consumer; + DomainEventConsumers = domainEventConsumers; + IntegrationEventTranslator = translator; } - public IEventNotificationProducer Producer { get; } + public IIntegrationEventNotificationTranslator IntegrationEventTranslator { get; } - public IEventNotificationConsumer Consumer { get; } + public List DomainEventConsumers { get; } } \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common/Notifications/IntegrationEvent.cs b/src/Infrastructure.Eventing.Common/Notifications/IntegrationEvent.cs new file mode 100644 index 00000000..e05dccaf --- /dev/null +++ b/src/Infrastructure.Eventing.Common/Notifications/IntegrationEvent.cs @@ -0,0 +1,25 @@ +using Infrastructure.Eventing.Interfaces.Notifications; + +namespace Infrastructure.Eventing.Common.Notifications; + +/// +/// Defines a base class for integration events +/// +public class IntegrationEvent : IIntegrationEvent +{ + protected IntegrationEvent() + { + RootId = null!; + OccurredUtc = DateTime.UtcNow; + } + + protected IntegrationEvent(string rootId) + { + RootId = rootId; + OccurredUtc = DateTime.UtcNow; + } + + public string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common/Notifications/NoOpConsumerRegistration.cs b/src/Infrastructure.Eventing.Common/Notifications/NoOpConsumerRegistration.cs deleted file mode 100644 index c7b30c56..00000000 --- a/src/Infrastructure.Eventing.Common/Notifications/NoOpConsumerRegistration.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Domain.Interfaces.Entities; -using Infrastructure.Eventing.Interfaces.Notifications; - -namespace Infrastructure.Eventing.Common.Notifications; - -/// -/// Provides a registration for a consumer that handles all events but does nothing with them -/// -public sealed class NoOpConsumerRegistration : IEventNotificationRegistration - where TAggregateRoot : IEventingAggregateRoot -{ - public IEventNotificationProducer Producer => new PassThroughEventNotificationProducer(); - - public IEventNotificationConsumer Consumer => new NoOpConsumer(); -} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common/Notifications/NoOpConsumer.cs b/src/Infrastructure.Eventing.Common/Notifications/NoOpDomainEventConsumer.cs similarity index 55% rename from src/Infrastructure.Eventing.Common/Notifications/NoOpConsumer.cs rename to src/Infrastructure.Eventing.Common/Notifications/NoOpDomainEventConsumer.cs index 58634e35..5a290fc2 100644 --- a/src/Infrastructure.Eventing.Common/Notifications/NoOpConsumer.cs +++ b/src/Infrastructure.Eventing.Common/Notifications/NoOpDomainEventConsumer.cs @@ -7,10 +7,10 @@ namespace Infrastructure.Eventing.Common.Notifications; /// /// Provides a consumer that handles all events and does nothing with them /// -public sealed class NoOpConsumer : IEventNotificationConsumer +public sealed class NoOpDomainEventConsumer : IDomainEventNotificationConsumer { - public Task> NotifyAsync(IDomainEvent changeEvent, CancellationToken cancellationToken) + public Task> NotifyAsync(IDomainEvent domainEvent, CancellationToken cancellationToken) { - return Task.FromResult>(true); + return Task.FromResult(Result.Ok); } } \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common/Notifications/NoOpEventNotificationMessageBroker.cs b/src/Infrastructure.Eventing.Common/Notifications/NoOpEventNotificationMessageBroker.cs new file mode 100644 index 00000000..c87f119f --- /dev/null +++ b/src/Infrastructure.Eventing.Common/Notifications/NoOpEventNotificationMessageBroker.cs @@ -0,0 +1,17 @@ +using Common; +using Infrastructure.Eventing.Interfaces.Notifications; + +namespace Infrastructure.Eventing.Common.Notifications; + +/// +/// Provides an implementation of that does nothing. +/// +public class NoOpEventNotificationMessageBroker : IEventNotificationMessageBroker +{ + public async Task> PublishAsync(IIntegrationEvent integrationEvent, + CancellationToken cancellationToken) + { + await Task.CompletedTask; + return Result.Ok; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common/Notifications/NoOpEventNotificationRegistration.cs b/src/Infrastructure.Eventing.Common/Notifications/NoOpEventNotificationRegistration.cs new file mode 100644 index 00000000..5a523ac9 --- /dev/null +++ b/src/Infrastructure.Eventing.Common/Notifications/NoOpEventNotificationRegistration.cs @@ -0,0 +1,16 @@ +using Domain.Interfaces.Entities; +using Infrastructure.Eventing.Interfaces.Notifications; + +namespace Infrastructure.Eventing.Common.Notifications; + +/// +/// Provides a registration that handles no events +/// +public sealed class NoOpEventNotificationRegistration : IEventNotificationRegistration + where TAggregateRoot : IEventingAggregateRoot +{ + public IIntegrationEventNotificationTranslator IntegrationEventTranslator => + new NoOpIntegrationEventNotificationTranslator(); + + public List DomainEventConsumers => [new NoOpDomainEventConsumer()]; +} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common/Notifications/NoOpIntegrationEventNotificationTranslator.cs b/src/Infrastructure.Eventing.Common/Notifications/NoOpIntegrationEventNotificationTranslator.cs new file mode 100644 index 00000000..a9e90201 --- /dev/null +++ b/src/Infrastructure.Eventing.Common/Notifications/NoOpIntegrationEventNotificationTranslator.cs @@ -0,0 +1,20 @@ +using Common; +using Domain.Interfaces.Entities; +using Infrastructure.Eventing.Interfaces.Notifications; + +namespace Infrastructure.Eventing.Common.Notifications; + +/// +/// Provides a translator of domain events that never returns an integration event +/// +public sealed class NoOpIntegrationEventNotificationTranslator : IIntegrationEventNotificationTranslator + where TAggregateRoot : IEventingAggregateRoot +{ + public Type RootAggregateType => typeof(TAggregateRoot); + + public Task, Error>> TranslateAsync(IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + return Task.FromResult, Error>>(Optional.None); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common/Notifications/PassThroughEventNotificationProducer.cs b/src/Infrastructure.Eventing.Common/Notifications/PassThroughEventNotificationProducer.cs deleted file mode 100644 index 790e9979..00000000 --- a/src/Infrastructure.Eventing.Common/Notifications/PassThroughEventNotificationProducer.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Common; -using Domain.Interfaces.Entities; -using Infrastructure.Eventing.Interfaces.Notifications; - -namespace Infrastructure.Eventing.Common.Notifications; - -/// -/// Provides a producer of notification events that simply passes on the published event -/// -public sealed class PassThroughEventNotificationProducer : IEventNotificationProducer - where TAggregateRoot : IEventingAggregateRoot -{ - public Type RootAggregateType => typeof(TAggregateRoot); - - public Task, Error>> PublishAsync(IDomainEvent changeEvent, - CancellationToken cancellationToken) - { - return Task.FromResult, Error>>(new Optional(changeEvent)); - } -} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Common/Projections/ReadModelProjector.cs b/src/Infrastructure.Eventing.Common/Projections/ReadModelProjector.cs index fd8c01ec..08cf7bc6 100644 --- a/src/Infrastructure.Eventing.Common/Projections/ReadModelProjector.cs +++ b/src/Infrastructure.Eventing.Common/Projections/ReadModelProjector.cs @@ -54,7 +54,7 @@ public async Task> WriteEventStreamAsync(string streamName, List - /// Looks up a localized string similar to The consumer '{0}' did not handle the event '{1}' with event type '{2}'. Aborting notifications. - /// - internal static string EventNotificationNotifier_ConsumerError { - get { - return ResourceManager.GetString("EventNotificationNotifier_ConsumerError", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The producer '{0}' did not handle the event '{1}' with event type '{2}'. Aborting notifications. + /// Looks up a localized string similar to The producer '{0}' failed to handle the domain event '{1}' with event type '{2}'. Aborting notifications. /// internal static string EventNotificationNotifier_ProducerError { get { diff --git a/src/Infrastructure.Eventing.Common/Resources.resx b/src/Infrastructure.Eventing.Common/Resources.resx index 9f513598..2976d494 100644 --- a/src/Infrastructure.Eventing.Common/Resources.resx +++ b/src/Infrastructure.Eventing.Common/Resources.resx @@ -37,9 +37,6 @@ The event stream {0} is at checkpoint '{1}', but new events are at version {2}. Perhaps some event history is missing? - The producer '{0}' did not handle the event '{1}' with event type '{2}'. Aborting notifications - - - The consumer '{0}' did not handle the event '{1}' with event type '{2}'. Aborting notifications + The producer '{0}' failed to handle the domain event '{1}' with event type '{2}'. Aborting notifications \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Interfaces/Notifications/IDomainEventNotificationConsumer.cs b/src/Infrastructure.Eventing.Interfaces/Notifications/IDomainEventNotificationConsumer.cs new file mode 100644 index 00000000..0a701b00 --- /dev/null +++ b/src/Infrastructure.Eventing.Interfaces/Notifications/IDomainEventNotificationConsumer.cs @@ -0,0 +1,15 @@ +using Common; +using Domain.Interfaces.Entities; + +namespace Infrastructure.Eventing.Interfaces.Notifications; + +/// +/// Defines a consumer of domain events +/// +public interface IDomainEventNotificationConsumer +{ + /// + /// Handles the notification of a + /// + Task> NotifyAsync(IDomainEvent domainEvent, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationConsumer.cs b/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationConsumer.cs deleted file mode 100644 index 78ce17e0..00000000 --- a/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationConsumer.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Common; -using Domain.Interfaces.Entities; - -namespace Infrastructure.Eventing.Interfaces.Notifications; - -/// -/// Defines a consumer of events -/// -public interface IEventNotificationConsumer -{ - /// - /// Handles the notification of the , and returns whether it was handled or not - /// - Task> NotifyAsync(IDomainEvent changeEvent, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationMessageBroker.cs b/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationMessageBroker.cs new file mode 100644 index 00000000..262e0978 --- /dev/null +++ b/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationMessageBroker.cs @@ -0,0 +1,14 @@ +using Common; + +namespace Infrastructure.Eventing.Interfaces.Notifications; + +/// +/// Defines a message broker for receiving and publishing integration events +/// +public interface IEventNotificationMessageBroker +{ + /// + /// Publishes the to some message broker + /// + Task> PublishAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationProducer.cs b/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationProducer.cs deleted file mode 100644 index 8ab1cf5f..00000000 --- a/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationProducer.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Common; -using Domain.Interfaces.Entities; - -namespace Infrastructure.Eventing.Interfaces.Notifications; - -/// -/// Defines a producer of events from a domain aggregate root -/// -public interface IEventNotificationProducer -{ - /// - /// Returns the type of the root aggregate that produces the events - /// - Type RootAggregateType { get; } - - /// - /// Handles the notification of a new , and returns the actual event to publish - /// to downstream consumers - /// - Task, Error>> PublishAsync(IDomainEvent changeEvent, - CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationRegistration.cs b/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationRegistration.cs index b8010255..79b314c8 100644 --- a/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationRegistration.cs +++ b/src/Infrastructure.Eventing.Interfaces/Notifications/IEventNotificationRegistration.cs @@ -1,17 +1,17 @@ namespace Infrastructure.Eventing.Interfaces.Notifications; /// -/// Defines the registration information for both a notifications producer and a notifications consumer +/// Defines the registration information for both a domain and integration consumers /// public interface IEventNotificationRegistration { /// - /// Returns the consumer of the events + /// Returns the consumers of domain events /// - IEventNotificationConsumer Consumer { get; } + List DomainEventConsumers { get; } /// - /// Returns the producer of the events + /// Returns the translator of integration events /// - IEventNotificationProducer Producer { get; } + IIntegrationEventNotificationTranslator IntegrationEventTranslator { get; } } \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Interfaces/Notifications/IIntegrationEvent.cs b/src/Infrastructure.Eventing.Interfaces/Notifications/IIntegrationEvent.cs new file mode 100644 index 00000000..0b63ec0e --- /dev/null +++ b/src/Infrastructure.Eventing.Interfaces/Notifications/IIntegrationEvent.cs @@ -0,0 +1,17 @@ +namespace Infrastructure.Eventing.Interfaces.Notifications; + +/// +/// Defines an integration event to communicate past events of an aggregate outside the process +/// +public interface IIntegrationEvent +{ + /// + /// Returns the time when the event happened + /// + DateTime OccurredUtc { get; set; } + + /// + /// Returns the ID of the root aggregate + /// + string RootId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Eventing.Interfaces/Notifications/IIntegrationEventNotificationTranslator.cs b/src/Infrastructure.Eventing.Interfaces/Notifications/IIntegrationEventNotificationTranslator.cs new file mode 100644 index 00000000..e26a429d --- /dev/null +++ b/src/Infrastructure.Eventing.Interfaces/Notifications/IIntegrationEventNotificationTranslator.cs @@ -0,0 +1,22 @@ +using Common; +using Domain.Interfaces.Entities; + +namespace Infrastructure.Eventing.Interfaces.Notifications; + +/// +/// Defines a translator of domain events from a domain aggregate root to integration events +/// +public interface IIntegrationEventNotificationTranslator +{ + /// + /// Returns the type of the root aggregate that produces the domain events + /// + Type RootAggregateType { get; } + + /// + /// Handles the notification of a new , and returns an optional + /// event to be published to downstream consumers of integration events + /// + Task, Error>> TranslateAsync(IDomainEvent domainEvent, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/EventHandlerBaseSpec.cs b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/EventHandlerBaseSpec.cs index d1567722..346abfeb 100644 --- a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/EventHandlerBaseSpec.cs +++ b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/EventHandlerBaseSpec.cs @@ -63,7 +63,7 @@ public void WhenEventStreamChangedEventRaisedAndFromDifferentStreams_ThenWritesB Version = 5, Data = null!, Metadata = null!, - EntityType = null!, + RootAggregateType = null!, EventType = null!, LastPersistedAtUtc = default }, @@ -74,7 +74,7 @@ public void WhenEventStreamChangedEventRaisedAndFromDifferentStreams_ThenWritesB Version = 3, Data = null!, Metadata = null!, - EntityType = null!, + RootAggregateType = null!, EventType = null!, LastPersistedAtUtc = default }, @@ -85,7 +85,7 @@ public void WhenEventStreamChangedEventRaisedAndFromDifferentStreams_ThenWritesB Version = 4, Data = null!, Metadata = null!, - EntityType = null!, + RootAggregateType = null!, EventType = null!, LastPersistedAtUtc = default } @@ -122,7 +122,7 @@ public void WhenEventStreamChangedEventRaisedAndFromDifferentStreamsAndWriteFail Version = 5, Data = null!, Metadata = null!, - EntityType = null!, + RootAggregateType = null!, EventType = null!, LastPersistedAtUtc = default }, @@ -133,7 +133,7 @@ public void WhenEventStreamChangedEventRaisedAndFromDifferentStreamsAndWriteFail Version = 3, Data = null!, Metadata = null!, - EntityType = null!, + RootAggregateType = null!, EventType = null!, LastPersistedAtUtc = default }, @@ -144,7 +144,7 @@ public void WhenEventStreamChangedEventRaisedAndFromDifferentStreamsAndWriteFail Version = 4, Data = null!, Metadata = null!, - EntityType = null!, + RootAggregateType = null!, EventType = null!, LastPersistedAtUtc = default } @@ -179,7 +179,7 @@ public void WhenEventStreamChangedEventRaisedAndEventsAreOutOfOrder_ThenReturnsE Version = 5, Data = null!, Metadata = null!, - EntityType = null!, + RootAggregateType = null!, EventType = null!, LastPersistedAtUtc = default }, @@ -190,7 +190,7 @@ public void WhenEventStreamChangedEventRaisedAndEventsAreOutOfOrder_ThenReturnsE Version = 2, Data = null!, Metadata = null!, - EntityType = null!, + RootAggregateType = null!, EventType = null!, LastPersistedAtUtc = default }, @@ -201,7 +201,7 @@ public void WhenEventStreamChangedEventRaisedAndEventsAreOutOfOrder_ThenReturnsE Version = 4, Data = null!, Metadata = null!, - EntityType = null!, + RootAggregateType = null!, EventType = null!, LastPersistedAtUtc = default } diff --git a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/InProcessSynchronousNotificationRelaySpec.cs b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/InProcessSynchronousNotificationRelaySpec.cs index bf53ebbc..3f201267 100644 --- a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/InProcessSynchronousNotificationRelaySpec.cs +++ b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/InProcessSynchronousNotificationRelaySpec.cs @@ -16,8 +16,9 @@ namespace Infrastructure.Hosting.Common.UnitTests.ApplicationServices.Eventing.N [Trait("Category", "Unit")] public class InProcessSynchronousNotificationRelaySpec { - private readonly TestConsumer _consumer; + private readonly TestDomainConsumer _domainConsumer; private readonly EventSourcingDddCommandStore _eventSourcingStore; + private readonly TestMessageBroker _messageBroker; private readonly InProcessSynchronousNotificationRelay _relay; public InProcessSynchronousNotificationRelaySpec() @@ -37,16 +38,18 @@ public InProcessSynchronousNotificationRelaySpec() _eventSourcingStore = new EventSourcingDddCommandStore(recorder.Object, domainFactory.Object, migrator.Object, store.Object); - _consumer = new TestConsumer(); - var registration = new EventNotificationRegistration( - new PassThroughEventNotificationProducer(), - _consumer); + _domainConsumer = new TestDomainConsumer(); + _messageBroker = new TestMessageBroker(); + var registration = + new EventNotificationRegistration( + new NoOpIntegrationEventNotificationTranslator(), [_domainConsumer]); var registrations = new List { registration }; - _relay = new InProcessSynchronousNotificationRelay(recorder.Object, migrator.Object, registrations, + _relay = new InProcessSynchronousNotificationRelay(recorder.Object, migrator.Object, _messageBroker, + registrations, _eventSourcingStore); } @@ -73,8 +76,9 @@ public async Task WhenEventHandlerFired_ThenNotifierNotifies() await _eventSourcingStore.SaveAsync(aggregate, CancellationToken.None); - _consumer.ProjectedEvents.Length.Should().Be(2); - _consumer.ProjectedEvents[0].As().Id.Should().Be("aneventid1"); - _consumer.ProjectedEvents[1].As().Id.Should().Be("aneventid2"); + _domainConsumer.ProjectedEvents.Length.Should().Be(2); + _domainConsumer.ProjectedEvents[0].As().Id.Should().Be("aneventid1"); + _domainConsumer.ProjectedEvents[1].As().Id.Should().Be("aneventid2"); + _messageBroker.ProjectedEvents.Length.Should().Be(0); } } \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/TestConsumer.cs b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/TestDomainConsumer.cs similarity index 56% rename from src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/TestConsumer.cs rename to src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/TestDomainConsumer.cs index e4abedf0..38a511e4 100644 --- a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/TestConsumer.cs +++ b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/TestDomainConsumer.cs @@ -4,16 +4,16 @@ namespace Infrastructure.Hosting.Common.UnitTests.ApplicationServices.Eventing.Notifications; -internal class TestConsumer : IEventNotificationConsumer +internal class TestDomainConsumer : IDomainEventNotificationConsumer { private readonly List _projectedEvents = new(); public IDomainEvent[] ProjectedEvents => _projectedEvents.ToArray(); - public Task> NotifyAsync(IDomainEvent changeEvent, CancellationToken cancellationToken) + public Task> NotifyAsync(IDomainEvent domainEvent, CancellationToken cancellationToken) { - _projectedEvents.Add(changeEvent); + _projectedEvents.Add(domainEvent); - return Task.FromResult>(true); + return Task.FromResult(Result.Ok); } } \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/TestMessageBroker.cs b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/TestMessageBroker.cs new file mode 100644 index 00000000..e45bed6f --- /dev/null +++ b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/Notifications/TestMessageBroker.cs @@ -0,0 +1,18 @@ +using Common; +using Infrastructure.Eventing.Interfaces.Notifications; + +namespace Infrastructure.Hosting.Common.UnitTests.ApplicationServices.Eventing.Notifications; + +internal class TestMessageBroker : IEventNotificationMessageBroker +{ + private readonly List _projectedEvents = new(); + + public IIntegrationEvent[] ProjectedEvents => _projectedEvents.ToArray(); + + public Task> PublishAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken) + { + _projectedEvents.Add(integrationEvent); + + return Task.FromResult(Result.Ok); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/TestEvent.cs b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/TestEvent.cs index 55c62639..f1ef6096 100644 --- a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/TestEvent.cs +++ b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/Eventing/TestEvent.cs @@ -1,12 +1,12 @@ -using Domain.Interfaces.Entities; +using Domain.Common; namespace Infrastructure.Hosting.Common.UnitTests.ApplicationServices.Eventing; -public class TestEvent : IDomainEvent +public class TestEvent : DomainEvent { - public required string Id { get; set; } - - public DateTime OccurredUtc { get; set; } + public TestEvent() : base("arootid") + { + } - public string RootId { get; set; } = "arootid"; + public required string Id { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common.UnitTests/Infrastructure.Hosting.Common.UnitTests.csproj b/src/Infrastructure.Hosting.Common.UnitTests/Infrastructure.Hosting.Common.UnitTests.csproj index 27c20564..97476956 100644 --- a/src/Infrastructure.Hosting.Common.UnitTests/Infrastructure.Hosting.Common.UnitTests.csproj +++ b/src/Infrastructure.Hosting.Common.UnitTests/Infrastructure.Hosting.Common.UnitTests.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Infrastructure.Hosting.Common/ApplicationServices/Eventing/Notifications/InProcessSynchronousNotificationRelay.cs b/src/Infrastructure.Hosting.Common/ApplicationServices/Eventing/Notifications/InProcessSynchronousNotificationRelay.cs index 510276ed..d35413f9 100644 --- a/src/Infrastructure.Hosting.Common/ApplicationServices/Eventing/Notifications/InProcessSynchronousNotificationRelay.cs +++ b/src/Infrastructure.Hosting.Common/ApplicationServices/Eventing/Notifications/InProcessSynchronousNotificationRelay.cs @@ -14,10 +14,11 @@ public class InProcessSynchronousNotificationRelay : EventStreamHandlerBase, IEventNotifyingStoreNotificationRelay { public InProcessSynchronousNotificationRelay(IRecorder recorder, IEventSourcedChangeEventMigrator migrator, + IEventNotificationMessageBroker messageBroker, IEnumerable registrations, params IEventNotifyingStore[] eventingStores) : base(recorder, eventingStores) { - Notifier = new EventNotificationNotifier(recorder, migrator, registrations.ToArray()); + Notifier = new EventNotificationNotifier(recorder, migrator, registrations.ToList(), messageBroker); } protected override void Dispose(bool disposing) diff --git a/src/Infrastructure.Hosting.Common/Extensions/EventingExtensions.cs b/src/Infrastructure.Hosting.Common/Extensions/EventingExtensions.cs index a1795d89..345caf64 100644 --- a/src/Infrastructure.Hosting.Common/Extensions/EventingExtensions.cs +++ b/src/Infrastructure.Hosting.Common/Extensions/EventingExtensions.cs @@ -136,7 +136,7 @@ private static IServiceCollection AddEventing(services, scope, projectionFactory); Eventing.AddNotificationFactory(); - services.AddWithLifetime(scope, notificationFactory); + services.AddPerHttpRequest(notificationFactory); return services; } @@ -212,6 +212,7 @@ IDataStore DataStoreFactory(IServiceProvider c) new InProcessSynchronousNotificationRelay( c.GetRequiredService(), c.GetRequiredService(), + c.GetRequiredService(), Eventing.ResolveNotificationRegistrations(c), Eventing.ResolveNotificationStores(c).ToArray())); diff --git a/src/Infrastructure.Persistence.Common/Extensions/EventNotifyingStoreExtensions.cs b/src/Infrastructure.Persistence.Common/Extensions/EventNotifyingStoreExtensions.cs index 2430f12d..7ec12c1c 100644 --- a/src/Infrastructure.Persistence.Common/Extensions/EventNotifyingStoreExtensions.cs +++ b/src/Infrastructure.Persistence.Common/Extensions/EventNotifyingStoreExtensions.cs @@ -53,7 +53,7 @@ private static EventStreamChangeEvent ToChangeEvent(EventSourcedChangeEvent chan return new EventStreamChangeEvent { Data = changeEvent.Data, - EntityType = changeEvent.EntityType, + RootAggregateType = changeEvent.EntityType, EventType = changeEvent.EventType, Id = changeEvent.Id, LastPersistedAtUtc = changeEvent.LastPersistedAtUtc, diff --git a/src/Infrastructure.Shared/Eventing/Notifications/ExampleEventNotificationMessageBroker.cs b/src/Infrastructure.Shared/Eventing/Notifications/ExampleEventNotificationMessageBroker.cs new file mode 100644 index 00000000..dfd3b60c --- /dev/null +++ b/src/Infrastructure.Shared/Eventing/Notifications/ExampleEventNotificationMessageBroker.cs @@ -0,0 +1,40 @@ +using Application.Common.Extensions; +using Common; +using Infrastructure.Eventing.Interfaces.Notifications; +using Infrastructure.Interfaces; +using Integration.Events.Shared.EndUsers; + +namespace Infrastructure.Shared.Eventing.Notifications; + +/// +/// Provides an example message broker that relays integration events to external systems, +/// as eventually consistent with this process +/// +public class ExampleEventNotificationMessageBroker : IEventNotificationMessageBroker +{ + private readonly ICallerContextFactory _callerContextFactory; + private readonly IRecorder _recorder; + + public ExampleEventNotificationMessageBroker(IRecorder recorder, ICallerContextFactory callerContextFactory) + { + _recorder = recorder; + _callerContextFactory = callerContextFactory; + } + + public async Task> PublishAsync(IIntegrationEvent integrationEvent, + CancellationToken cancellationToken) + { + switch (integrationEvent) + { + case PersonRegistered registered: + await Task.CompletedTask; + _recorder.TraceDebug(_callerContextFactory.Create().ToCall(), + "User {Id} was registered with username {Username}", + registered.RootId, registered.Username); + return Result.Ok; + + default: + return Result.Ok; + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/Infrastructure.Shared.csproj b/src/Infrastructure.Shared/Infrastructure.Shared.csproj index 55c70a62..4bdd521e 100644 --- a/src/Infrastructure.Shared/Infrastructure.Shared.csproj +++ b/src/Infrastructure.Shared/Infrastructure.Shared.csproj @@ -9,10 +9,12 @@ + + diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs index 296a3d02..dd3f99b5 100644 --- a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs @@ -19,6 +19,7 @@ using Infrastructure.Common; using Infrastructure.Common.Extensions; using Infrastructure.Eventing.Common.Projections.ReadModels; +using Infrastructure.Eventing.Interfaces.Notifications; using Infrastructure.Hosting.Common; using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Hosting.Common.Recording; @@ -28,6 +29,7 @@ using Infrastructure.Persistence.Shared.ApplicationServices; using Infrastructure.Shared.ApplicationServices; using Infrastructure.Shared.ApplicationServices.External; +using Infrastructure.Shared.Eventing.Notifications; using Infrastructure.Web.Api.Common; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Common.Validation; @@ -61,7 +63,7 @@ public static class HostExtensions private const string AllowedCORSOriginsSettingName = "Hosts:AllowedCORSOrigins"; private const string CheckPointAggregatePrefix = "check"; private const string LoggingSettingName = "Logging"; - private static readonly char[] AllowedCORSOriginsDelimiters = { ',', ';', ' ' }; + private static readonly char[] AllowedCORSOriginsDelimiters = [',', ';', ' ']; #if TESTINGONLY private static readonly Dictionary StubQueueDrainingServiceQueuedApiMappings = new() { @@ -90,6 +92,7 @@ public static WebApplication ConfigureApiHost(this WebApplicationBuilder appBuil RegisterNotifications(hostOptions.UsesNotifications); modules.RegisterServices(appBuilder.Configuration, services); RegisterApplicationServices(hostOptions.IsMultiTenanted); + RegisterEventing(hostOptions.Persistence.UsesEventing); RegisterPersistence(hostOptions.Persistence.UsesQueues, hostOptions.IsMultiTenanted); RegisterCors(hostOptions.CORS); @@ -380,6 +383,14 @@ void RegisterApplicationServices(bool isMultiTenanted) } } + void RegisterEventing(bool usesEventing) + { + if (usesEventing) + { + services.AddPerHttpRequest(); + } + } + void RegisterPersistence(bool usesQueues, bool isMultiTenanted) { var domainAssemblies = modules.SubdomainAssemblies diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs index 4c19a0ea..b6ceea63 100644 --- a/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs @@ -157,7 +157,7 @@ public static void EnableEventingPropagation(this WebApplication builder, await next(); }); - }, "Pipeline: Event Projections/Notifications is enabled")); + }, "Pipeline: Event Projections/Notifications are enabled")); } /// diff --git a/src/Integration.Events.Shared/EndUsers/PersonRegistered.cs b/src/Integration.Events.Shared/EndUsers/PersonRegistered.cs new file mode 100644 index 00000000..347dec0f --- /dev/null +++ b/src/Integration.Events.Shared/EndUsers/PersonRegistered.cs @@ -0,0 +1,25 @@ +using Domain.Shared.EndUsers; +using Infrastructure.Eventing.Common.Notifications; +using JetBrains.Annotations; + +namespace Integration.Events.Shared.EndUsers; + +public sealed class PersonRegistered : IntegrationEvent +{ + public PersonRegistered(string id) : base(id) + { + } + + [UsedImplicitly] + public PersonRegistered() + { + } + + public required List Features { get; set; } + + public required List Roles { get; set; } + + public required string Username { get; set; } + + public required RegisteredUserProfile UserProfile { get; set; } +} \ No newline at end of file diff --git a/src/Integration.Events.Shared/Integration.Events.Shared.csproj b/src/Integration.Events.Shared/Integration.Events.Shared.csproj new file mode 100644 index 00000000..e51a3ef8 --- /dev/null +++ b/src/Integration.Events.Shared/Integration.Events.Shared.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs new file mode 100644 index 00000000..5167533d --- /dev/null +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs @@ -0,0 +1,105 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Services.Shared; +using Common; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Interfaces.Services; +using Domain.Shared; +using Domain.Shared.EndUsers; +using EndUsersDomain; +using FluentAssertions; +using Moq; +using OrganizationsApplication.Persistence; +using OrganizationsDomain; +using UnitTesting.Common; +using Xunit; +using Events = EndUsersDomain.Events; +using OrganizationOwnership = Domain.Shared.Organizations.OrganizationOwnership; + +namespace OrganizationsApplication.UnitTests; + +[Trait("Category", "Unit")] +public class OrganizationsApplicationDomainEventHandlersSpec +{ + private readonly OrganizationsApplication _application; + private readonly Mock _caller; + private readonly Mock _repository; + private readonly Mock _tenantSettingsService; + + public OrganizationsApplicationDomainEventHandlersSpec() + { + var recorder = new Mock(); + _caller = new Mock(); + var idFactory = new Mock(); + idFactory.Setup(f => f.Create(It.IsAny())) + .Returns("anid".ToId()); + _tenantSettingsService = new Mock(); + _tenantSettingsService.Setup(tss => + tss.CreateForTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new TenantSettings(new Dictionary + { + { "aname", "avalue" } + })); + var tenantSettingService = new Mock(); + tenantSettingService.Setup(tss => tss.Encrypt(It.IsAny())) + .Returns((string value) => value); + tenantSettingService.Setup(tss => tss.Decrypt(It.IsAny())) + .Returns((string value) => value); + var endUsersService = new Mock(); + _repository = new Mock(); + _repository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny())) + .Returns((OrganizationRoot root, CancellationToken _) => + Task.FromResult>(root)); + + _application = new OrganizationsApplication(recorder.Object, idFactory.Object, + _tenantSettingsService.Object, tenantSettingService.Object, endUsersService.Object, _repository.Object); + } + + [Fact] + public async Task WhenHandleEndUserRegisteredForPersonAsync_ThenReturnsOrganization() + { + var domainEvent = Events.Registered("auserid".ToId(), EndUserProfile.Create("afirstname", "alastname").Value, + EmailAddress.Create("auser@company.com").Value, UserClassification.Person, UserAccess.Enabled, + UserStatus.Registered, Roles.Empty, Features.Empty); + + var result = + await _application.HandleEndUserRegisteredAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(org => + org.Name == "afirstname alastname" + && org.Ownership == OrganizationOwnership.Personal + && org.CreatedById == "auserid" + && org.Settings.Properties.Count == 1 + && org.Settings.Properties["aname"].Value.As() == "avalue" + && org.Settings.Properties["aname"].IsEncrypted == false + ), It.IsAny())); + _tenantSettingsService.Verify(tss => + tss.CreateForTenantAsync(_caller.Object, "anid", It.IsAny())); + } + + [Fact] + public async Task WhenHandleEndUserRegisteredForMachineAsync_ThenReturnsOrganization() + { + var domainEvent = Events.Registered("auserid".ToId(), EndUserProfile.Create("amachinename").Value, + EmailAddress.Create("auser@company.com").Value, UserClassification.Machine, UserAccess.Enabled, + UserStatus.Registered, Roles.Empty, Features.Empty); + + var result = + await _application.HandleEndUserRegisteredAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(org => + org.Name == "amachinename" + && org.Ownership == OrganizationOwnership.Personal + && org.CreatedById == "auserid" + && org.Settings.Properties.Count == 1 + && org.Settings.Properties["aname"].Value.As() == "avalue" + && org.Settings.Properties["aname"].IsEncrypted == false + ), It.IsAny())); + _tenantSettingsService.Verify(tss => + tss.CreateForTenantAsync(_caller.Object, "anid", It.IsAny())); + } +} \ No newline at end of file diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.UnitTests.csproj b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.UnitTests.csproj index dca39015..e37be158 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.UnitTests.csproj +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.UnitTests.csproj @@ -8,6 +8,7 @@ + diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs index d9ecb616..817eb7fe 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs @@ -59,43 +59,11 @@ public OrganizationsApplicationSpec() _tenantSettingsService.Object, _tenantSettingService.Object, _endUsersService.Object, _repository.Object); } - [Fact] - public async Task WhenCreateOrganizationAsync_ThenReturnsOrganization() - { - var result = - await _application.CreateOrganizationAsync(_caller.Object, "auserid", "aname", - Application.Resources.Shared.OrganizationOwnership.Personal, CancellationToken.None); - - result.Value.Name.Should().Be("aname"); - result.Value.Ownership.Should().Be(Application.Resources.Shared.OrganizationOwnership.Personal); - result.Value.CreatedById.Should().Be("auserid"); - _repository.Verify(rep => rep.SaveAsync(It.Is(org => - org.Name == "aname" - && org.Ownership == OrganizationOwnership.Personal - && org.CreatedById == "auserid" - && org.Settings.Properties.Count == 1 - && org.Settings.Properties["aname"].Value.As() == "avalue" - && org.Settings.Properties["aname"].IsEncrypted == false - ), It.IsAny())); - _tenantSettingsService.Verify(tss => - tss.CreateForTenantAsync(_caller.Object, "anid", It.IsAny())); - } - [Fact] public async Task WhenCreateSharedOrganizationAsync_ThenReturnsSharedOrganization() { _caller.Setup(c => c.CallerId) .Returns("acallerid"); - _endUsersService.Setup(eus => - eus.CreateMembershipForCallerPrivateAsync(It.IsAny(), It.IsAny(), - It.IsAny())) - .ReturnsAsync(new Membership - { - Id = "amembershipid", - UserId = "auserid", - OrganizationId = "anorganizationid", - IsDefault = false - }); var result = await _application.CreateSharedOrganizationAsync(_caller.Object, "aname", @@ -114,8 +82,6 @@ await _application.CreateSharedOrganizationAsync(_caller.Object, "aname", ), It.IsAny())); _tenantSettingsService.Verify(tss => tss.CreateForTenantAsync(_caller.Object, "anid", It.IsAny())); - _endUsersService.Verify(eus => - eus.CreateMembershipForCallerPrivateAsync(_caller.Object, "anid", It.IsAny())); } [Fact] diff --git a/src/OrganizationsApplication/IOrganizationsApplication.DomainEventHandlers.cs b/src/OrganizationsApplication/IOrganizationsApplication.DomainEventHandlers.cs new file mode 100644 index 00000000..3e7f79ba --- /dev/null +++ b/src/OrganizationsApplication/IOrganizationsApplication.DomainEventHandlers.cs @@ -0,0 +1,11 @@ +using Application.Interfaces; +using Common; +using Domain.Events.Shared.EndUsers; + +namespace OrganizationsApplication; + +partial interface IOrganizationsApplication +{ + 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 382f0eb5..0d3556c5 100644 --- a/src/OrganizationsApplication/IOrganizationsApplication.cs +++ b/src/OrganizationsApplication/IOrganizationsApplication.cs @@ -5,14 +5,11 @@ namespace OrganizationsApplication; -public interface IOrganizationsApplication +public partial interface IOrganizationsApplication { Task> ChangeSettingsAsync(ICallerContext caller, string id, TenantSettings settings, CancellationToken cancellationToken); - Task> CreateOrganizationAsync(ICallerContext caller, string creatorId, string name, - OrganizationOwnership ownership, CancellationToken cancellationToken); - Task> CreateSharedOrganizationAsync(ICallerContext caller, string name, CancellationToken cancellationToken); diff --git a/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs b/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs new file mode 100644 index 00000000..4b04d4d4 --- /dev/null +++ b/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs @@ -0,0 +1,26 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Events.Shared.EndUsers; + +namespace OrganizationsApplication; + +partial class OrganizationsApplication +{ + public async Task> HandleEndUserRegisteredAsync(ICallerContext caller, Registered domainEvent, + CancellationToken cancellationToken) + { + var name = + $"{domainEvent.UserProfile.FirstName}{(domainEvent.UserProfile.LastName.HasValue() ? " " + domainEvent.UserProfile.LastName : string.Empty)}"; + var organization = await CreateOrganizationAsync(caller, domainEvent.RootId.ToId(), name, + OrganizationOwnership.Personal, cancellationToken); + if (!organization.IsSuccessful) + { + return organization.Error; + } + + return Result.Ok; + } +} \ No newline at end of file diff --git a/src/OrganizationsApplication/OrganizationsApplication.cs b/src/OrganizationsApplication/OrganizationsApplication.cs index a2f66c33..630ac469 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.cs +++ b/src/OrganizationsApplication/OrganizationsApplication.cs @@ -16,7 +16,7 @@ namespace OrganizationsApplication; -public class OrganizationsApplication : IOrganizationsApplication +public partial class OrganizationsApplication : IOrganizationsApplication { private readonly IEndUsersService _endUsersService; private readonly IIdentifierFactory _identifierFactory; @@ -71,55 +71,6 @@ public async Task> ChangeSettingsAsync(ICallerContext caller, stri return Result.Ok; } - public async Task> CreateOrganizationAsync(ICallerContext caller, string creatorId, - string name, Application.Resources.Shared.OrganizationOwnership ownership, CancellationToken cancellationToken) - { - var displayName = DisplayName.Create(name); - if (!displayName.IsSuccessful) - { - return displayName.Error; - } - - var created = OrganizationRoot.Create(_recorder, _identifierFactory, _tenantSettingService, - ownership.ToEnumOrDefault(OrganizationOwnership.Shared), creatorId.ToId(), displayName.Value); - if (!created.IsSuccessful) - { - return created.Error; - } - - var org = created.Value; - var newSettings = await _tenantSettingsService.CreateForTenantAsync(caller, org.Id, cancellationToken); - if (!newSettings.IsSuccessful) - { - return newSettings.Error; - } - - var organizationSettings = newSettings.Value.ToSettings(); - if (!organizationSettings.IsSuccessful) - { - return organizationSettings.Error; - } - - var configured = org.CreateSettings(organizationSettings.Value); - if (!configured.IsSuccessful) - { - return configured.Error; - } - - //TODO: Get the billing details for the creator and add the billing subscription for them - - var saved = await _repository.SaveAsync(org, cancellationToken); - if (!saved.IsSuccessful) - { - return saved.Error; - } - - _recorder.TraceInformation(caller.ToCall(), "Created organization: {Id}, by {CreatedBy}", org.Id, - saved.Value.CreatedById); - - return saved.Value.ToOrganization(); - } - public async Task> CreateSharedOrganizationAsync(ICallerContext caller, string name, CancellationToken cancellationToken) { @@ -131,16 +82,7 @@ public async Task> CreateSharedOrganizationAsync(ICa return created.Error; } - //TODO: replaced by a notification! - var organization = created.Value; - var membership = - await _endUsersService.CreateMembershipForCallerPrivateAsync(caller, organization.Id, cancellationToken); - if (!membership.IsSuccessful) - { - return membership.Error; - } - - return organization; + return created.Value; } public async Task> GetOrganizationAsync(ICallerContext caller, string id, @@ -256,6 +198,55 @@ await _endUsersService.ListMembershipsForOrganizationAsync(caller, organization. return searchOptions.ApplyWithMetadata(memberships.Value.Results.ConvertAll(x => x.ToMember())); } + + private async Task> CreateOrganizationAsync(ICallerContext caller, string creatorId, + string name, Application.Resources.Shared.OrganizationOwnership ownership, CancellationToken cancellationToken) + { + var displayName = DisplayName.Create(name); + if (!displayName.IsSuccessful) + { + return displayName.Error; + } + + var created = OrganizationRoot.Create(_recorder, _identifierFactory, _tenantSettingService, + ownership.ToEnumOrDefault(OrganizationOwnership.Shared), creatorId.ToId(), displayName.Value); + if (!created.IsSuccessful) + { + return created.Error; + } + + var org = created.Value; + var newSettings = await _tenantSettingsService.CreateForTenantAsync(caller, org.Id, cancellationToken); + if (!newSettings.IsSuccessful) + { + return newSettings.Error; + } + + var organizationSettings = newSettings.Value.ToSettings(); + if (!organizationSettings.IsSuccessful) + { + return organizationSettings.Error; + } + + var configured = org.CreateSettings(organizationSettings.Value); + if (!configured.IsSuccessful) + { + return configured.Error; + } + + //TODO: Get the billing details for the creator and add the billing subscription for them + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created organization: {Id}, by {CreatedBy}", org.Id, + saved.Value.CreatedById); + + return saved.Value.ToOrganization(); + } } internal static class OrganizationConversionExtensions diff --git a/src/OrganizationsDomain/Events.cs b/src/OrganizationsDomain/Events.cs index a7c7a776..ce9b7ff8 100644 --- a/src/OrganizationsDomain/Events.cs +++ b/src/OrganizationsDomain/Events.cs @@ -9,43 +9,37 @@ public static class Events public static Created Created(Identifier id, OrganizationOwnership ownership, Identifier createdBy, DisplayName name) { - return new Created + return new Created(id) { Name = name, Ownership = ownership, CreatedById = createdBy, - RootId = id, - OccurredUtc = DateTime.UtcNow }; } public static SettingCreated SettingCreated(Identifier id, string name, string value, SettingValueType valueType, bool isEncrypted) { - return new SettingCreated + return new SettingCreated(id) { - RootId = id, Name = name, StringValue = value, ValueType = valueType, - IsEncrypted = isEncrypted, - OccurredUtc = DateTime.UtcNow + IsEncrypted = isEncrypted }; } public static SettingUpdated SettingUpdated(Identifier id, string name, string from, SettingValueType fromType, string to, SettingValueType toType, bool isEncrypted) { - return new SettingUpdated + return new SettingUpdated(id) { - RootId = id, Name = name, From = from, FromType = fromType, To = to, ToType = toType, - IsEncrypted = isEncrypted, - OccurredUtc = DateTime.UtcNow + IsEncrypted = isEncrypted }; } } \ No newline at end of file diff --git a/src/OrganizationsDomain/OrganizationRoot.cs b/src/OrganizationsDomain/OrganizationRoot.cs index 7c741d05..b08e0ec9 100644 --- a/src/OrganizationsDomain/OrganizationRoot.cs +++ b/src/OrganizationsDomain/OrganizationRoot.cs @@ -66,8 +66,6 @@ public override Result EnsureInvariants() return ensureInvariants.Error; } - //TODO: add your other invariant rules here - return Result.Ok; } diff --git a/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs b/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs index 23bb619e..e448a68a 100644 --- a/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs +++ b/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs @@ -1,6 +1,5 @@ using Application.Interfaces; using Application.Interfaces.Services; -using Application.Resources.Shared; using Application.Services.Shared; using Common; using Common.Extensions; @@ -31,13 +30,6 @@ public async Task> ChangeSettingsPrivateAsync(ICallerContext calle return await GetApplication().ChangeSettingsAsync(caller, id, settings, cancellationToken); } - public async Task> CreateOrganizationPrivateAsync(ICallerContext caller, - string creatorId, string name, OrganizationOwnership ownership, CancellationToken cancellationToken) - { - return await GetApplication().CreateOrganizationAsync(caller, creatorId, name, ownership, - cancellationToken); - } - public async Task> GetSettingsPrivateAsync(ICallerContext caller, string id, CancellationToken cancellationToken) { diff --git a/src/OrganizationsInfrastructure/Notifications/OrganizationNotificationConsumer.cs b/src/OrganizationsInfrastructure/Notifications/OrganizationNotificationConsumer.cs new file mode 100644 index 00000000..242f4edb --- /dev/null +++ b/src/OrganizationsInfrastructure/Notifications/OrganizationNotificationConsumer.cs @@ -0,0 +1,34 @@ +using Common; +using Domain.Events.Shared.EndUsers; +using Domain.Interfaces.Entities; +using Infrastructure.Eventing.Interfaces.Notifications; +using Infrastructure.Interfaces; +using OrganizationsApplication; + +namespace OrganizationsInfrastructure.Notifications; + +public class OrganizationNotificationConsumer : IDomainEventNotificationConsumer +{ + private readonly ICallerContextFactory _callerContextFactory; + private readonly IOrganizationsApplication _organizationsApplication; + + public OrganizationNotificationConsumer(ICallerContextFactory callerContextFactory, + IOrganizationsApplication organizationsApplication) + { + _callerContextFactory = callerContextFactory; + _organizationsApplication = organizationsApplication; + } + + public async Task> NotifyAsync(IDomainEvent domainEvent, CancellationToken cancellationToken) + { + switch (domainEvent) + { + case Registered registered: + return await _organizationsApplication.HandleEndUserRegisteredAsync(_callerContextFactory.Create(), + registered, cancellationToken); + + default: + return Result.Ok; + } + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Notifications/OrganizationNotifier.cs b/src/OrganizationsInfrastructure/Notifications/OrganizationNotifier.cs new file mode 100644 index 00000000..62f518b5 --- /dev/null +++ b/src/OrganizationsInfrastructure/Notifications/OrganizationNotifier.cs @@ -0,0 +1,18 @@ +using Infrastructure.Eventing.Common.Notifications; +using Infrastructure.Eventing.Interfaces.Notifications; +using OrganizationsDomain; + +namespace OrganizationsInfrastructure.Notifications; + +public class OrganizationNotifier : IEventNotificationRegistration +{ + public OrganizationNotifier(IEnumerable consumers) + { + DomainEventConsumers = consumers.ToList(); + } + + public List DomainEventConsumers { get; } + + public IIntegrationEventNotificationTranslator IntegrationEventTranslator => + new NoOpIntegrationEventNotificationTranslator(); +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/OrganizationsModule.cs b/src/OrganizationsInfrastructure/OrganizationsModule.cs index f548b555..531c0d05 100644 --- a/src/OrganizationsInfrastructure/OrganizationsModule.cs +++ b/src/OrganizationsInfrastructure/OrganizationsModule.cs @@ -8,7 +8,9 @@ using Domain.Interfaces; using Domain.Interfaces.Services; using Infrastructure.Common.DomainServices; +using Infrastructure.Eventing.Interfaces.Notifications; using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Interfaces; using Infrastructure.Persistence.Interfaces; using Infrastructure.Web.Hosting.Common; using Infrastructure.Web.Hosting.Common.ApplicationServices; @@ -19,6 +21,7 @@ using OrganizationsApplication.Persistence; using OrganizationsDomain; using OrganizationsInfrastructure.ApplicationServices; +using OrganizationsInfrastructure.Notifications; using OrganizationsInfrastructure.Persistence; using OrganizationsInfrastructure.Persistence.ReadModels; @@ -63,10 +66,16 @@ public Action RegisterServices c.GetRequiredService(), c.GetRequiredService>(), c.GetRequiredServiceForPlatform())); - services.RegisterUnTenantedEventing( + services + .AddPerHttpRequest(c => + new OrganizationNotificationConsumer(c.GetRequiredService(), + c.GetRequiredService())); + services.RegisterUnTenantedEventing( c => new OrganizationProjection(c.GetRequiredService(), c.GetRequiredService(), - c.GetRequiredServiceForPlatform())); + c.GetRequiredServiceForPlatform()), + c => new OrganizationNotifier(c + .GetRequiredService>())); services.AddSingleton(c => new OrganizationsInProcessServiceClient(c.LazyGetRequiredService())); diff --git a/src/SaaStack.sln b/src/SaaStack.sln index ad7b9af7..815d9101 100644 --- a/src/SaaStack.sln +++ b/src/SaaStack.sln @@ -344,6 +344,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UserProfilesInfrastructure. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain.Events.Shared", "Domain.Events.Shared\Domain.Events.Shared.csproj", "{3AC2CCAF-A248-4FCA-9C42-BD207E528D27}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Integration.Events.Shared", "Integration.Events.Shared\Integration.Events.Shared.csproj", "{99DC5CFB-1DF8-45E4-9EE8-49D44B637198}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1101,6 +1103,12 @@ Global {3AC2CCAF-A248-4FCA-9C42-BD207E528D27}.Release|Any CPU.Build.0 = Release|Any CPU {3AC2CCAF-A248-4FCA-9C42-BD207E528D27}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU {3AC2CCAF-A248-4FCA-9C42-BD207E528D27}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {99DC5CFB-1DF8-45E4-9EE8-49D44B637198}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {99DC5CFB-1DF8-45E4-9EE8-49D44B637198}.Debug|Any CPU.Build.0 = Debug|Any CPU + {99DC5CFB-1DF8-45E4-9EE8-49D44B637198}.Release|Any CPU.ActiveCfg = Release|Any CPU + {99DC5CFB-1DF8-45E4-9EE8-49D44B637198}.Release|Any CPU.Build.0 = Release|Any CPU + {99DC5CFB-1DF8-45E4-9EE8-49D44B637198}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {99DC5CFB-1DF8-45E4-9EE8-49D44B637198}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F5C77A86-38AF-40E4-82FC-617E624B2754} = {508E7DA4-4DF2-4201-955D-CCF70C41AD05} @@ -1267,5 +1275,6 @@ Global {8CB11E5D-01DE-4FCF-98B1-4998E80D561D} = {153F22CE-3C45-4CF5-991A-01C866EC429F} {A9D1A686-DEBA-4EC6-93E2-290392F41B13} = {153F22CE-3C45-4CF5-991A-01C866EC429F} {3AC2CCAF-A248-4FCA-9C42-BD207E528D27} = {3782A767-2274-4F44-80C6-D6C6EEB9C9A5} + {99DC5CFB-1DF8-45E4-9EE8-49D44B637198} = {3782A767-2274-4F44-80C6-D6C6EEB9C9A5} EndGlobalSection EndGlobal diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 91cc9b75..e9cba390 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -564,6 +564,26 @@ public sealed class $name$ : ValueObjectBase<$name$> public $datatype$ $param3$ { get; } }$END$$SELECTION$ + True + True + True + typeName() + -1 + 0 + True + True + 2.0 + InCSharpFile + jj + True + public $class$(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public $class$() + { + } True True A new test class (xUnit) @@ -774,7 +794,7 @@ public sealed class $name$Root : AggregateRootBase True 2.0 InCSharpFile - event + domainevent True public sealed class $name$ : IDomainEvent { @@ -1107,6 +1127,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True diff --git a/src/Tools.Analyzers.Common/AnalyzerConstants.cs b/src/Tools.Analyzers.Common/AnalyzerConstants.cs index 361a1030..84821739 100644 --- a/src/Tools.Analyzers.Common/AnalyzerConstants.cs +++ b/src/Tools.Analyzers.Common/AnalyzerConstants.cs @@ -27,6 +27,7 @@ public static class Categories { public const string Application = "SaaStackApplication"; public const string Ddd = "SaaStackDDD"; + public const string Eventing = "SaaStackEventing"; public const string Documentation = "SaaStackDocumentation"; public const string WebApi = "SaaStackWebApi"; public const string Host = "SaaStackHosts"; diff --git a/src/Tools.Analyzers.Common/Extensions/SymbolExtensions.cs b/src/Tools.Analyzers.Common/Extensions/SymbolExtensions.cs index 2d20b657..1f2386b4 100644 --- a/src/Tools.Analyzers.Common/Extensions/SymbolExtensions.cs +++ b/src/Tools.Analyzers.Common/Extensions/SymbolExtensions.cs @@ -32,6 +32,59 @@ public static string GetMethodBody(this ISymbol method) return string.Empty; } + public static bool HasParameterlessConstructor(this INamedTypeSymbol symbol) + { + var constructors = symbol.InstanceConstructors; + if (constructors.Length == 0) + { + return true; + } + + return symbol.InstanceConstructors + .Any(c => c.DeclaredAccessibility == Microsoft.CodeAnalysis.Accessibility.Public + && c.Parameters.Length == 0); + } + + public static bool HasPropertiesOfAllowableTypes(this INamedTypeSymbol symbol, + List allowableTypes) + { + var properties = symbol.GetMembers() + .OfType() + .Select(p => p.GetMethod?.ReturnType) + .Where(rt => rt is not null); + + foreach (var property in properties) + { + if (!allowableTypes.Any(allowableType => property!.IsOfType(allowableType))) + { + return false; + } + } + + return true; + } + + public static bool HasPublicGetterAndSetterProperties(this INamedTypeSymbol symbol) + { + var properties = symbol.GetMembers() + .OfType(); + + foreach (var property in properties) + { + if (property.DeclaredAccessibility != Microsoft.CodeAnalysis.Accessibility.Public) + { + return false; + } + + if (property.GetMethod is null || property.SetMethod is null) + { + return false; + } + } + + return true; + } + public static bool IsEnum(this ITypeSymbol symbol) { return symbol.TypeKind == TypeKind.Enum; diff --git a/src/Tools.Analyzers.Common/Extensions/SyntaxFilterExtensions.cs b/src/Tools.Analyzers.Common/Extensions/SyntaxFilterExtensions.cs index e6c9f9e1..2ae0dd54 100644 --- a/src/Tools.Analyzers.Common/Extensions/SyntaxFilterExtensions.cs +++ b/src/Tools.Analyzers.Common/Extensions/SyntaxFilterExtensions.cs @@ -56,6 +56,24 @@ public static class SyntaxFilterExtensions return null; } + public static bool HasParameterlessConstructor(this ClassDeclarationSyntax classDeclarationSyntax) + { + var allConstructors = classDeclarationSyntax.Members.Where(member => member is ConstructorDeclarationSyntax) + .Cast() + .ToList(); + if (allConstructors.Count > 0) + { + var parameterlessConstructors = allConstructors + .Where(constructor => constructor.ParameterList.Parameters.Count == 0 && constructor.IsPublic()); + if (!parameterlessConstructors.Any()) + { + return false; + } + } + + return true; + } + public static bool HasPublicGetterAndSetter(this PropertyDeclarationSyntax propertyDeclarationSyntax) { var propertyAccessibility = new Accessibility(propertyDeclarationSyntax.Modifiers); @@ -106,6 +124,25 @@ public static bool HasPublicGetterAndSetter(this PropertyDeclarationSyntax prope return true; } + public static bool HasPublicGetterAndSetterProperties(this ClassDeclarationSyntax classDeclarationSyntax) + { + var allProperties = classDeclarationSyntax.Members.Where(member => member is PropertyDeclarationSyntax) + .Cast() + .ToList(); + if (allProperties.Count > 0) + { + foreach (var property in allProperties) + { + if (!property.HasPublicGetterAndSetter()) + { + return false; + } + } + } + + return true; + } + public static bool HasPublicSetter(this PropertyDeclarationSyntax propertyDeclarationSyntax) { var propertyAccessibility = new Accessibility(propertyDeclarationSyntax.Modifiers); @@ -133,6 +170,74 @@ public static bool HasPublicSetter(this PropertyDeclarationSyntax propertyDeclar return setterAccessibility is { IsPublic: true, IsStatic: false }; } + public static bool IsDtoOrNullableDto(this PropertyDeclarationSyntax propertyDeclarationSyntax, + SyntaxNodeAnalysisContext context, List allowableTypes) + { + var propertySymbol = context.SemanticModel.GetDeclaredSymbol(propertyDeclarationSyntax); + if (propertySymbol is null) + { + return false; + } + + var getter = propertySymbol.GetMethod; + if (getter is null) + { + return false; + } + + var returnType = propertySymbol.GetMethod!.ReturnType; + if (returnType.IsNullable(context)) + { + if (IsDto(returnType.WithoutNullable(context))) + { + return true; + } + } + + if (IsDto(returnType)) + { + return true; + } + + return false; + + bool IsDto(ITypeSymbol symbol) + { + if (symbol is not INamedTypeSymbol namedTypeSymbol) + { + return false; + } + + if (!namedTypeSymbol.IsReferenceType) //We dont accept any enums, or other value types + { + return false; + } + + if (namedTypeSymbol.IsStatic + || namedTypeSymbol.DeclaredAccessibility != Microsoft.CodeAnalysis.Accessibility.Public) + { + return false; + } + + if (!namedTypeSymbol.HasParameterlessConstructor()) + { + return false; + } + + if (!namedTypeSymbol.HasPublicGetterAndSetterProperties()) + { + return false; + } + + if (!namedTypeSymbol.HasPropertiesOfAllowableTypes(allowableTypes)) + { + return false; + } + + return true; + } + } + public static bool IsEmptyNode(this XmlNodeSyntax nodeSyntax) { if (nodeSyntax is XmlTextSyntax textSyntax) @@ -149,7 +254,7 @@ public static bool IsEmptyNode(this XmlNodeSyntax nodeSyntax) return true; } - public static bool IsEnumType(this PropertyDeclarationSyntax propertyDeclarationSyntax, + public static bool IsEnumOrNullableEnumType(this PropertyDeclarationSyntax propertyDeclarationSyntax, SyntaxNodeAnalysisContext context) { var propertySymbol = context.SemanticModel.GetDeclaredSymbol(propertyDeclarationSyntax); @@ -165,6 +270,14 @@ public static bool IsEnumType(this PropertyDeclarationSyntax propertyDeclaration } var returnType = propertySymbol.GetMethod!.ReturnType; + if (returnType.IsNullable(context)) + { + if (returnType.WithoutNullable(context).IsEnum()) + { + return true; + } + } + if (returnType.IsEnum()) { return true; @@ -305,27 +418,6 @@ public static bool IsNotType(this ParameterSyntax parameterSyntax, Syntax return !isDerivedFrom; } - public static bool IsReferenceType(this PropertyDeclarationSyntax propertyDeclarationSyntax, - SyntaxNodeAnalysisContext context) - { - var propertySymbol = context.SemanticModel.GetDeclaredSymbol(propertyDeclarationSyntax); - if (propertySymbol is null) - { - return false; - } - - var getter = propertySymbol.GetMethod; - if (getter is null) - { - return false; - } - - var returnType = getter.ReturnType; - - return returnType.IsReferenceType; - } - - public static bool IsNullableType(this PropertyDeclarationSyntax propertyDeclarationSyntax, SyntaxNodeAnalysisContext context) { @@ -441,6 +533,26 @@ public static bool IsPublicStaticMethod(this MethodDeclarationSyntax methodDecla return accessibility is { IsPublic: true, IsStatic: true }; } + public static bool IsReferenceType(this PropertyDeclarationSyntax propertyDeclarationSyntax, + SyntaxNodeAnalysisContext context) + { + var propertySymbol = context.SemanticModel.GetDeclaredSymbol(propertyDeclarationSyntax); + if (propertySymbol is null) + { + return false; + } + + var getter = propertySymbol.GetMethod; + if (getter is null) + { + return false; + } + + var returnType = getter.ReturnType; + + return returnType.IsReferenceType; + } + public static bool IsRequired(this MemberDeclarationSyntax memberDeclarationSyntax) { var accessibility = new Accessibility(memberDeclarationSyntax.Modifiers); diff --git a/src/Tools.Analyzers.NonPlatform.UnitTests/DomainDrivenDesignAnalyzerSpec.cs b/src/Tools.Analyzers.NonPlatform.UnitTests/DomainDrivenDesignAnalyzerSpec.cs index 7e0a8018..73a2c614 100644 --- a/src/Tools.Analyzers.NonPlatform.UnitTests/DomainDrivenDesignAnalyzerSpec.cs +++ b/src/Tools.Analyzers.NonPlatform.UnitTests/DomainDrivenDesignAnalyzerSpec.cs @@ -4234,6 +4234,298 @@ public static AClassed Create() await Verify.NoDiagnosticExists(input); } + + [Fact] + public async Task WhenAnyPropertyEnumTypeIsNotRequiredAndNotInitializedAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public AnEnum AProperty { get; set; } +} +public enum AnEnum +{ + AValue +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyEnumValueTypeIsRequiredAndNotInitializedAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + AProperty = AnEnum.AValue, + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required AnEnum AProperty { get; set; } +} +public enum AnEnum +{ + AValue +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyEnumTypeIsInitializedAndNotRequiredAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public AnEnum AProperty { get; set; } = AnEnum.AValue; +} +public enum AnEnum +{ + AValue +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyEnumTypeIsNullableAndNotRequiredAndNotInitialized_ThenNoAlert() + { + const string input = @" +using System; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public AnEnum? AProperty { get; set; } +} +public enum AnEnum +{ + AValue +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyEnumTypeIsNullableAndRequiredAndNotInitialized_ThenNoAlert() + { + const string input = @" +using System; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + AProperty = AnEnum.AValue, + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required AnEnum? AProperty { get; set; } +} +public enum AnEnum +{ + AValue +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyEnumTypeIsNullableAndInitializedAndNotRequired_ThenNoAlert() + { + const string input = @" +using System; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public AnEnum? AProperty { get; set; } = AnEnum.AValue; +} +public enum AnEnum +{ + AValue +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyEnumTypeIsInitializedAndRequiredAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + AProperty = AnEnum.AValue, + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required AnEnum AProperty { get; set; } = AnEnum.AValue; +} +public enum AnEnum +{ + AValue +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyEnumTypeIsInitializedAndNullableAndNotRequired_ThenNoAlert() + { + const string input = @" +using System; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public AnEnum? AProperty { get; set; } = AnEnum.AValue; +} +public enum AnEnum +{ + AValue +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyEnumTypeIsInitializedAndRequiredAndNullable_ThenNoAlert() + { + const string input = @" +using System; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + AProperty = AnEnum.AValue, + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required AnEnum? AProperty { get; set; } = AnEnum.AValue; +} +public enum AnEnum +{ + AValue +}"; + + await Verify.NoDiagnosticExists(input); + } } [Trait("Category", "Unit")] @@ -4472,7 +4764,6 @@ public static AClassed Create() { return new AClassed { - AProperty = AnEnum.AValue, RootId = string.Empty, OccurredUtc = DateTime.UtcNow }; @@ -4482,7 +4773,7 @@ public static AClassed Create() public required DateTime OccurredUtc { get; set; } - public required AnEnum AProperty { get; set; } + public AnEnum AProperty { get; set; } } public enum AnEnum { @@ -4552,6 +4843,83 @@ public static AClassed Create() await Verify.NoDiagnosticExists(input); } + + [Fact] + public async Task WhenAnyPropertyIsNotADto_ThenAlerts() + { + const string input = @" +using System; +using System.Collections.Generic; +using Domain.Interfaces.Entities; +namespace ANamespace; +public sealed class AClassed : IDomainEvent +{ + public static AClassed Create() + { + return new AClassed + { + AProperty = new ADto(), + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required ADto AProperty { get; set; } +} +public class ADto +{ + public string AProperty { get; } +}"; + + await Verify.DiagnosticExists( + DomainDrivenDesignAnalyzer.Rule049, input, 22, 26, "AProperty", AllTypes); + } + + [Fact] + public async Task WhenAnyPropertyIsADto_ThenNoAlert() + { + const string input = @" +using System; +using System.Collections.Generic; +using Domain.Interfaces.Entities; +using AnOtherNamespace; +namespace ANamespace +{ + public sealed class AClassed : IDomainEvent + { + public static AClassed Create() + { + return new AClassed + { + AProperty = new ADto { AProperty1 = string.Empty }, + RootId = string.Empty, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required ADto AProperty { get; set; } + } +} +namespace AnOtherNamespace +{ + public class ADto + { + public required string AProperty1 { get; set; } + + public string? AProperty2 { get; set; } + } +}"; + + await Verify.NoDiagnosticExists(input); + } } } } \ No newline at end of file diff --git a/src/Tools.Analyzers.NonPlatform.UnitTests/EventingAnalyzerSpec.cs b/src/Tools.Analyzers.NonPlatform.UnitTests/EventingAnalyzerSpec.cs new file mode 100644 index 00000000..6dc58edb --- /dev/null +++ b/src/Tools.Analyzers.NonPlatform.UnitTests/EventingAnalyzerSpec.cs @@ -0,0 +1,819 @@ +extern alias NonPlatformAnalyzers; +using Xunit; +using EventingAnalyzer = NonPlatformAnalyzers::Tools.Analyzers.NonPlatform.EventingAnalyzer; +using UsedImplicitly = NonPlatformAnalyzers::JetBrains.Annotations.UsedImplicitlyAttribute; + +namespace Tools.Analyzers.NonPlatform.UnitTests; + +[UsedImplicitly] +public class EventingAnalyzerSpec +{ + [Trait("Category", "Unit")] + public class GivenAnyRule + { + [Fact] + public async Task WhenInExcludedNamespace_ThenNoAlert() + { + const string input = @" +using Infrastructure.Web.Api.Interfaces; +namespace Common; +public sealed class AClass : IWebApiService +{ +}"; + + await Verify.NoDiagnosticExists(input); + } + } + + [UsedImplicitly] + public class GivenAnIntegrationEvent + { + [Trait("Category", "Unit")] + public class GivenAnyIntegrationEvent + { + [Fact] + public async Task WhenNoCtor_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + } + + [Trait("Category", "Unit")] + public class GivenRule010 + { + [Fact] + public async Task WhenIsNotPublic_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +internal sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule010, input, 5, 23, "AClass"); + } + } + + [Trait("Category", "Unit")] + public class GivenRule011 + { + [Fact] + public async Task WhenIsNotSealed_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule011, input, 5, 14, "AClass"); + } + } + + [Trait("Category", "Unit")] + public class GivenRule012 + { + [Fact] + public async Task WhenHasCtorAndNotParameterless_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public AClass(string rootId, DateTime occurredUtc) + { + RootId = rootId; + OccurredUtc = occurredUtc; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule012, input, 5, 21, "AClass"); + } + + [Fact] + public async Task WhenHasCtorAndPrivate_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + private AClass() + { + RootId = string.Empty; + OccurredUtc = DateTime.UtcNow; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule012, input, 5, 21, "AClass"); + } + + [Fact] + public async Task WhenHasCtorAndIsParameterless_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public AClass() + { + RootId = string.Empty; + OccurredUtc = DateTime.UtcNow; + } + + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + } + + [Trait("Category", "Unit")] + public class GivenRule013 + { + [Fact] + public async Task WhenAnyPropertyHasNoSetter_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public string? AProperty { get; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule013, input, 11, 20, "AProperty", null); + } + } + + [Trait("Category", "Unit")] + public class GivenRule014 + { + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsNotRequiredAndNotInitializedAndNotNullable_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public string AProperty { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule014, input, 11, 19, "AProperty"); + } + + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsRequiredAndNotInitializedAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required string AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsInitializedAndNotRequiredAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public string AProperty { get; set; } = string.Empty; +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsNullableAndNotRequiredAndNotInitialized_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public string? AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsNullableAndRequiredAndNotInitialized_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public string? AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsNullableAndInitializedAndNotRequired_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public string? AProperty { get; set; } = string.Empty; +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsInitializedAndRequiredAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required string AProperty { get; set; } = string.Empty; +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsInitializedAndNullableAndNotRequired_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public string? AProperty { get; set; } = string.Empty; +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsInitializedAndRequiredAndNullable_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public string? AProperty { get; set; } = string.Empty; +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyValueTypeIsNotRequiredAndNotInitializedAndNotNullable_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public DateTime AProperty { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule014, input, 11, 21, "AProperty"); + } + + [Fact] + public async Task WhenAnyPropertyValueTypeIsRequiredAndNotInitializedAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required DateTime AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyValueTypeIsInitializedAndNotRequiredAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public DateTime AProperty { get; set; } = DateTime.UtcNow; +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyValueTypeIsNullableAndNotRequiredAndNotInitialized_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public DateTime? AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyValueTypeIsNullableAndRequiredAndNotInitialized_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required DateTime? AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyValueTypeIsNullableAndInitializedAndNotRequired_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public DateTime? AProperty { get; set; } = DateTime.UtcNow; +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyValueTypeIsInitializedAndRequiredAndNotNullable_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required DateTime AProperty { get; set; } = DateTime.UtcNow; +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyValueTypeIsInitializedAndNullableAndNotRequired_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public DateTime? AProperty { get; set; } = DateTime.UtcNow; +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyValueTypeIsInitializedAndRequiredAndNullable_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required DateTime? AProperty { get; set; } = DateTime.UtcNow; +}"; + + await Verify.NoDiagnosticExists(input); + } + } + + [Trait("Category", "Unit")] + public class GivenRule015 + { + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsOptional_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +using Common; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required Optional AProperty { get; set; } +}"; + + await Verify.DiagnosticExists(input, + (EventingAnalyzer.Rule015, 12, 38, "AProperty", null), + (EventingAnalyzer.Rule016, 12, 38, "AProperty", [GivenRule016.AllTypes])); + } + + [Fact] + public async Task WhenAnyPropertyReferenceTypeIsNullable_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public string? AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + } + + [Trait("Category", "Unit")] + public class GivenRule016 + { + public const string AllTypes = + "bool or string or ulong or int or long or double or decimal or System.DateTime or byte"; + + [Fact] + public async Task WhenAnyPropertyIsNotSupportedPrimitive_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required char AProperty { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule016, input, 11, 26, "AProperty", AllTypes); + } + + [Fact] + public async Task WhenAnyPropertyIsNotSupportedListOfPrimitive_ThenAlerts() + { + const string input = @" +using System; +using System.Collections.Generic; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required List AProperty { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule016, input, 12, 32, "AProperty", AllTypes); + } + + [Fact] + public async Task WhenAnyPropertyIsNotSupportedDictionaryOfPrimitive_ThenAlerts() + { + const string input = @" +using System; +using System.Collections.Generic; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required Dictionary AProperty { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule016, input, 12, 46, "AProperty", AllTypes); + } + + [Fact] + public async Task WhenAnyPropertyIsNotSupportedDictionaryKeyType_ThenAlerts() + { + const string input = @" +using System; +using System.Collections.Generic; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required Dictionary AProperty { get; set; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule016, input, 12, 46, "AProperty", AllTypes); + } + + [Fact] + public async Task WhenAnyPropertyIsSupportedPrimitive_ThenNoAlert() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required string AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyIsEnum_ThenAlerts() + { + const string input = @" +using System; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required AnEnum AProperty { get; set; } +} +public enum AnEnum +{ + AValue +} +"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule016, input, 11, 28, "AProperty", AllTypes); + } + + [Fact] + public async Task WhenAnyPropertyIsSupportedListOfPrimitive_ThenNoAlert() + { + const string input = @" +using System; +using System.Collections.Generic; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required List AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyIsSupportedDictionaryOfPrimitive_ThenNoAlert() + { + const string input = @" +using System; +using System.Collections.Generic; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required Dictionary AProperty { get; set; } +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenAnyPropertyIsNotADto_ThenAlerts() + { + const string input = @" +using System; +using System.Collections.Generic; +using Infrastructure.Eventing.Interfaces.Notifications; +namespace ANamespace; +public sealed class AClass : IIntegrationEvent +{ + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required ADto AProperty { get; set; } +} +public class ADto +{ + public string AProperty { get; } +}"; + + await Verify.DiagnosticExists( + EventingAnalyzer.Rule016, input, 12, 26, "AProperty", AllTypes); + } + + [Fact] + public async Task WhenAnyPropertyIsADto_ThenNoAlert() + { + const string input = @" +using System; +using System.Collections.Generic; +using Infrastructure.Eventing.Interfaces.Notifications; +using AnOtherNamespace; +namespace ANamespace +{ + public sealed class AClass : IIntegrationEvent + { + public required string RootId { get; set; } + + public required DateTime OccurredUtc { get; set; } + + public required ADto AProperty { get; set; } + } +} +namespace AnOtherNamespace +{ + public class ADto + { + public required string AProperty1 { get; set; } + + public string? AProperty2 { get; set; } + } +}"; + + await Verify.NoDiagnosticExists(input); + } + } + } +} \ No newline at end of file diff --git a/src/Tools.Analyzers.NonPlatform/AnalyzerReleases.Shipped.md b/src/Tools.Analyzers.NonPlatform/AnalyzerReleases.Shipped.md index d015254a..6c2a1d83 100644 --- a/src/Tools.Analyzers.NonPlatform/AnalyzerReleases.Shipped.md +++ b/src/Tools.Analyzers.NonPlatform/AnalyzerReleases.Shipped.md @@ -39,7 +39,7 @@ SAASDDD035 | SaaStackDDD | Error | ValueObjects must only have immutable methods SAASDDD036 | SaaStackDDD | Warning | ValueObjects should be marked as sealed. SAASDDD040 | SaaStackDDD | Error | DomainEvents must be public - SAASDDD041 | SaaStackDDD | Warning | DomainEvents must be sealed + SAASDDD041 | SaaStackDDD | Warning | DomainEvents should be sealed SAASDDD042 | SaaStackDDD | Error | DomainEvents must have a parameterless constructor SAASDDD043 | SaaStackDDD | Error | DomainEvents must be named in the past tense SAASDDD045 | SaaStackDDD | Error | Create() class factory methods must return correct types @@ -47,6 +47,13 @@ SAASDDD047 | SaaStackDDD | Error | Properties must be marked required or nullable or initialized SAASDDD048 | SaaStackDDD | Error | Properties must be nullable not Optional{T} SAASDDD049 | SaaStackDDD | Error | Properties must be of correct type + SAASEVT010 | SaaStackEventing | Error | IntegrationEvents must be public + SAASEVT011 | SaaStackEventing | Warning | IntegrationEvents should be sealed + SAASEVT012 | SaaStackEventing | Error | IntegrationEvents must have a parameterless constructor + SAASEVT013 | SaaStackEventing | Error | Properties must have public getters and setters + SAASEVT014 | SaaStackEventing | Error | Properties must be marked required or nullable or initialized + SAASEVT015 | SaaStackEventing | Error | Properties must be nullable not Optional{T} + SAASEVT016 | SaaStackEventing | Error | Properties must be of correct type SAASAPP010 | SaaStackApplication | Error | Resources must be public SAASAPP011 | SaaStackApplication | Error | Resources must have a parameterless constructor SAASAPP012 | SaaStackApplication | Error | Properties must have public getters and setters diff --git a/src/Tools.Analyzers.NonPlatform/DomainDrivenDesignAnalyzer.cs b/src/Tools.Analyzers.NonPlatform/DomainDrivenDesignAnalyzer.cs index 1138c884..531dabdd 100644 --- a/src/Tools.Analyzers.NonPlatform/DomainDrivenDesignAnalyzer.cs +++ b/src/Tools.Analyzers.NonPlatform/DomainDrivenDesignAnalyzer.cs @@ -48,7 +48,7 @@ namespace Tools.Analyzers.NonPlatform; /// SAASDDD036: Warning: ValueObjects should be marked as sealed /// DomainEvents: /// SAASDDD040: Error: DomainEvents must be public -/// SAASDDD041: Warning: DomainEvents must be sealed +/// SAASDDD041: Warning: DomainEvents should be sealed /// SAASDDD042: Error: DomainEvents must have a parameterless constructor /// SAASDDD043: Information: DomainEvents must be named in the past tense /// SAASDDD044: Error: DomainEvents must have at least one Create() class factory method @@ -57,7 +57,7 @@ namespace Tools.Analyzers.NonPlatform; /// SAASDDD047: Error: Properties must be required or nullable or initialized /// SAASDDD048: Error: Properties must be nullable, not Optional{T} for interoperability /// SAASDDD049: Error: Properties must have return type of primitives, List{TPrimitive}, Dictionary{string,TPrimitive}, -/// or be enums +/// or be enums, or other DTOs /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public class DomainDrivenDesignAnalyzer : DiagnosticAnalyzer @@ -164,17 +164,17 @@ public class DomainDrivenDesignAnalyzer : DiagnosticAnalyzer AnalyzerConstants.Categories.Ddd, nameof(Resources.SAASDDD035Title), nameof(Resources.SAASDDD035Description), nameof(Resources.SAASDDD035MessageFormat)); internal static readonly DiagnosticDescriptor Rule036 = "SAASDDD036".GetDescriptor(DiagnosticSeverity.Warning, - AnalyzerConstants.Categories.Ddd, nameof(Resources.Diagnostic_Title_ClassMustBeSealed), - nameof(Resources.Diagnostic_Description_ClassMustBeSealed), - nameof(Resources.Diagnostic_MessageFormat_ClassMustBeSealed)); + AnalyzerConstants.Categories.Ddd, nameof(Resources.Diagnostic_Title_ClassShouldBeSealed), + nameof(Resources.Diagnostic_Description_ClassShouldBeSealed), + nameof(Resources.Diagnostic_MessageFormat_ClassShouldBeSealed)); internal static readonly DiagnosticDescriptor Rule040 = "SAASDDD040".GetDescriptor(DiagnosticSeverity.Error, AnalyzerConstants.Categories.Ddd, nameof(Resources.Diagnostic_Title_ClassMustBePublic), nameof(Resources.Diagnostic_Description_ClassMustBePublic), nameof(Resources.Diagnostic_MessageFormat_ClassMustBePublic)); internal static readonly DiagnosticDescriptor Rule041 = "SAASDDD041".GetDescriptor(DiagnosticSeverity.Warning, - AnalyzerConstants.Categories.Ddd, nameof(Resources.Diagnostic_Title_ClassMustBeSealed), - nameof(Resources.Diagnostic_Description_ClassMustBeSealed), - nameof(Resources.Diagnostic_MessageFormat_ClassMustBeSealed)); + AnalyzerConstants.Categories.Ddd, nameof(Resources.Diagnostic_Title_ClassShouldBeSealed), + nameof(Resources.Diagnostic_Description_ClassShouldBeSealed), + nameof(Resources.Diagnostic_MessageFormat_ClassShouldBeSealed)); internal static readonly DiagnosticDescriptor Rule042 = "SAASDDD042".GetDescriptor(DiagnosticSeverity.Error, AnalyzerConstants.Categories.Ddd, nameof(Resources.Diagnostic_Title_ClassMustHaveParameterlessConstructor), nameof(Resources.Diagnostic_Description_ClassMustHaveParameterlessConstructor), @@ -553,7 +553,9 @@ private static void AnalyzeDomainEvent(SyntaxNodeAnalysisContext context) context.ReportDiagnostic(Rule046, property); } - if (!property.IsRequired() && !property.IsInitialized()) + if (!property.IsEnumOrNullableEnumType(context) + && !property.IsRequired() + && !property.IsInitialized()) { if (!property.IsNullableType(context)) { @@ -568,7 +570,8 @@ private static void AnalyzeDomainEvent(SyntaxNodeAnalysisContext context) var allowedReturnTypes = context.GetAllowableDomainEventPropertyReturnTypes(); if (context.HasIncorrectReturnType(property, allowedReturnTypes) - && !property.IsEnumType(context)) + && !property.IsEnumOrNullableEnumType(context) + && !property.IsDtoOrNullableDto(context, allowedReturnTypes.ToList())) { var acceptableReturnTypes = allowedReturnTypes diff --git a/src/Tools.Analyzers.NonPlatform/EventingAnalyzer.cs b/src/Tools.Analyzers.NonPlatform/EventingAnalyzer.cs new file mode 100644 index 00000000..6a586b32 --- /dev/null +++ b/src/Tools.Analyzers.NonPlatform/EventingAnalyzer.cs @@ -0,0 +1,190 @@ +using System.Collections.Immutable; +using Common.Extensions; +using Infrastructure.Eventing.Interfaces.Notifications; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Tools.Analyzers.Common; +using Tools.Analyzers.Common.Extensions; +using Tools.Analyzers.NonPlatform.Extensions; + +namespace Tools.Analyzers.NonPlatform; + +/// +/// An analyzer to correct the implementation of eventing: +/// IntegrationEvents: +/// SAASEVT010: Error: IntegrationEvents must be public +/// SAASEVT011: Warning: IntegrationEvents should be sealed +/// SAASEVT012: Error: IntegrationEvents must have a parameterless constructor +/// SAASEVT013: Error: Properties must have public getters and setters +/// SAASEVT014: Error: Properties must be required or nullable or initialized +/// SAASEVT015: Error: Properties must be nullable, not Optional{T} for interoperability +/// SAASEVT016: Error: Properties must have return type of primitives, List{TPrimitive}, Dictionary{string,TPrimitive}, +/// or be other DTOs +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class EventingAnalyzer : DiagnosticAnalyzer +{ + internal static readonly SpecialType[] AllowableIntegrationEventPrimitives = + { + SpecialType.System_Boolean, + SpecialType.System_String, + SpecialType.System_UInt64, + SpecialType.System_Int32, + SpecialType.System_Int64, + SpecialType.System_Double, + SpecialType.System_Decimal, + SpecialType.System_DateTime, + SpecialType.System_Byte + }; + internal static readonly DiagnosticDescriptor Rule010 = "SAASEVT010".GetDescriptor(DiagnosticSeverity.Error, + AnalyzerConstants.Categories.Eventing, nameof(Resources.Diagnostic_Title_ClassMustBePublic), + nameof(Resources.Diagnostic_Description_ClassMustBePublic), + nameof(Resources.Diagnostic_MessageFormat_ClassMustBePublic)); + internal static readonly DiagnosticDescriptor Rule011 = "SAASEVT011".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.Eventing, nameof(Resources.Diagnostic_Title_ClassShouldBeSealed), + nameof(Resources.Diagnostic_Description_ClassShouldBeSealed), + nameof(Resources.Diagnostic_MessageFormat_ClassShouldBeSealed)); + internal static readonly DiagnosticDescriptor Rule012 = "SAASEVT012".GetDescriptor(DiagnosticSeverity.Error, + AnalyzerConstants.Categories.Eventing, nameof(Resources.Diagnostic_Title_ClassMustHaveParameterlessConstructor), + nameof(Resources.Diagnostic_Description_ClassMustHaveParameterlessConstructor), + nameof(Resources.Diagnostic_MessageFormat_ClassMustHaveParameterlessConstructor)); + internal static readonly DiagnosticDescriptor Rule013 = "SAASEVT013".GetDescriptor(DiagnosticSeverity.Error, + AnalyzerConstants.Categories.Eventing, nameof(Resources.Diagnostic_Title_PropertyMustBeGettableAndSettable), + nameof(Resources.Diagnostic_Description_PropertyMustBeGettableAndSettable), + nameof(Resources.Diagnostic_MessageFormat_PropertyMustBeGettableAndSettable)); + internal static readonly DiagnosticDescriptor Rule014 = "SAASEVT014".GetDescriptor(DiagnosticSeverity.Error, + AnalyzerConstants.Categories.Eventing, nameof(Resources.SAASEVT014Title), + nameof(Resources.SAASEVT014Description), nameof(Resources.SAASEVT014MessageFormat)); + internal static readonly DiagnosticDescriptor Rule015 = "SAASEVT015".GetDescriptor(DiagnosticSeverity.Error, + AnalyzerConstants.Categories.Eventing, nameof(Resources.Diagnostic_Title_PropertyMustBeNullableNotOptional), + nameof(Resources.Diagnostic_Description_PropertyMustBeNullableNotOptional), + nameof(Resources.Diagnostic_MessageFormat_PropertyMustBeNullableNotOptional)); + internal static readonly DiagnosticDescriptor Rule016 = "SAASEVT016".GetDescriptor(DiagnosticSeverity.Error, + AnalyzerConstants.Categories.Eventing, nameof(Resources.SAASEVT016Title), + nameof(Resources.SAASEVT016Description), nameof(Resources.SAASEVT016MessageFormat)); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create( + Rule010, Rule011, Rule012, Rule013, Rule014, Rule015, Rule016); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeIntegrationEvent, SyntaxKind.ClassDeclaration); + } + + private static void AnalyzeIntegrationEvent(SyntaxNodeAnalysisContext context) + { + var methodSyntax = context.Node; + if (methodSyntax is not ClassDeclarationSyntax classDeclarationSyntax) + { + return; + } + + if (context.IsExcludedInNamespace(classDeclarationSyntax, AnalyzerConstants.PlatformNamespaces)) + { + return; + } + + if (classDeclarationSyntax.IsNotType(context)) + { + return; + } + + if (!classDeclarationSyntax.IsPublic()) + { + context.ReportDiagnostic(Rule010, classDeclarationSyntax); + } + + if (!classDeclarationSyntax.IsSealed()) + { + context.ReportDiagnostic(Rule011, classDeclarationSyntax); + } + + if (!classDeclarationSyntax.HasParameterlessConstructor()) + { + context.ReportDiagnostic(Rule012, classDeclarationSyntax); + } + + var allProperties = classDeclarationSyntax.Members.Where(member => member is PropertyDeclarationSyntax) + .Cast() + .ToList(); + if (allProperties.HasAny()) + { + foreach (var property in allProperties) + { + if (!property.HasPublicGetterAndSetter()) + { + context.ReportDiagnostic(Rule013, property); + } + + if (!property.IsRequired() + && !property.IsInitialized()) + { + if (!property.IsNullableType(context)) + { + context.ReportDiagnostic(Rule014, property); + } + } + + if (!property.IsNullableType(context) && property.IsOptionalType(context)) + { + context.ReportDiagnostic(Rule015, property); + } + + var allowedReturnTypes = context.GetAllowableIntegrationEventPropertyReturnTypes(); + if (context.HasIncorrectReturnType(property, allowedReturnTypes) + && !property.IsDtoOrNullableDto(context, allowedReturnTypes.ToList())) + { + var acceptableReturnTypes = + allowedReturnTypes + .Where(allowable => + !allowable.ToDisplayString().StartsWith("System.Collections") + && !allowable.ToDisplayString().EndsWith("?")) + .Select(allowable => allowable.ToDisplayString()).Join(" or "); + context.ReportDiagnostic(Rule016, property, acceptableReturnTypes); + } + } + } + } +} + +internal static class EventingExtensions +{ + private static INamedTypeSymbol[]? _allowableIntegrationEventPropertyReturnTypes; + + public static INamedTypeSymbol[] GetAllowableIntegrationEventPropertyReturnTypes( + this SyntaxNodeAnalysisContext context) + { + // Cache this + if (_allowableIntegrationEventPropertyReturnTypes is null) + { + var stringType = context.Compilation.GetSpecialType(SpecialType.System_String); + var primitiveTypes = EventingAnalyzer.AllowableIntegrationEventPrimitives + .Select(context.Compilation.GetSpecialType).ToArray(); + + var nullableOfType = context.Compilation.GetTypeByMetadataName(typeof(Nullable<>).FullName!)!; + var nullableTypes = primitiveTypes + .Select(primitive => nullableOfType.Construct(primitive)).ToArray(); + + var listOfType = context.Compilation.GetTypeByMetadataName(typeof(List<>).FullName!)!; + var listTypes = primitiveTypes + .Select(primitive => listOfType.Construct(primitive)).ToArray(); + + var dictionaryOfType = context.Compilation.GetTypeByMetadataName(typeof(Dictionary<,>).FullName!)!; + var stringDictionaryTypes = primitiveTypes + .Select(primitive => dictionaryOfType.Construct(stringType, primitive)) + .ToArray(); + + _allowableIntegrationEventPropertyReturnTypes = primitiveTypes + .Concat(nullableTypes) + .Concat(listTypes) + .Concat(stringDictionaryTypes).ToArray(); + } + + return _allowableIntegrationEventPropertyReturnTypes; + } +} \ No newline at end of file diff --git a/src/Tools.Analyzers.NonPlatform/Extensions/SyntaxExtensions.cs b/src/Tools.Analyzers.NonPlatform/Extensions/SyntaxExtensions.cs index 648ed8d0..6c9b9b86 100644 --- a/src/Tools.Analyzers.NonPlatform/Extensions/SyntaxExtensions.cs +++ b/src/Tools.Analyzers.NonPlatform/Extensions/SyntaxExtensions.cs @@ -46,24 +46,6 @@ public static bool HasOnlyPrivateInstanceConstructors(this ClassDeclarationSynta return true; } - public static bool HasParameterlessConstructor(this ClassDeclarationSyntax classDeclarationSyntax) - { - var allConstructors = classDeclarationSyntax.Members.Where(member => member is ConstructorDeclarationSyntax) - .Cast() - .ToList(); - if (allConstructors.HasAny()) - { - var parameterlessConstructors = allConstructors - .Where(constructor => constructor.ParameterList.Parameters.Count == 0 && constructor.IsPublic()); - if (parameterlessConstructors.HasNone()) - { - return false; - } - } - - return true; - } - public static bool HasRouteAttribute(this ClassDeclarationSyntax classDeclarationSyntax, SyntaxNodeAnalysisContext context) { diff --git a/src/Tools.Analyzers.NonPlatform/Resources.Designer.cs b/src/Tools.Analyzers.NonPlatform/Resources.Designer.cs index c7bdab53..e16a0146 100644 --- a/src/Tools.Analyzers.NonPlatform/Resources.Designer.cs +++ b/src/Tools.Analyzers.NonPlatform/Resources.Designer.cs @@ -159,7 +159,7 @@ internal static string Diagnostic_Description_ClassMustBePublic { } /// - /// Looks up a localized string similar to Class should be marked as 'sealed'.. + /// Looks up a localized string similar to Class must be marked as 'sealed'.. /// internal static string Diagnostic_Description_ClassMustBeSealed { get { @@ -176,6 +176,15 @@ internal static string Diagnostic_Description_ClassMustHaveParameterlessConstruc } } + /// + /// Looks up a localized string similar to Class should be marked as 'sealed'.. + /// + internal static string Diagnostic_Description_ClassShouldBeSealed { + get { + return ResourceManager.GetString("Diagnostic_Description_ClassShouldBeSealed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Constructor must be 'private'.. /// @@ -267,7 +276,7 @@ internal static string Diagnostic_MessageFormat_ClassMustBePublic { } /// - /// Looks up a localized string similar to Class '{0}' should be marked as 'sealed'. + /// Looks up a localized string similar to Class '{0}' must be marked as 'sealed'. /// internal static string Diagnostic_MessageFormat_ClassMustBeSealed { get { @@ -284,6 +293,15 @@ internal static string Diagnostic_MessageFormat_ClassMustHaveParameterlessConstr } } + /// + /// Looks up a localized string similar to Class '{0}' should be marked as 'sealed'. + /// + internal static string Diagnostic_MessageFormat_ClassShouldBeSealed { + get { + return ResourceManager.GetString("Diagnostic_MessageFormat_ClassShouldBeSealed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Constructor '{0}' must be 'private'. /// @@ -375,7 +393,7 @@ internal static string Diagnostic_Title_ClassMustBePublic { } /// - /// Looks up a localized string similar to Class should be marked as 'sealed'. + /// Looks up a localized string similar to Class must be marked as 'sealed'. /// internal static string Diagnostic_Title_ClassMustBeSealed { get { @@ -392,6 +410,15 @@ internal static string Diagnostic_Title_ClassMustHaveParameterlessConstructor { } } + /// + /// Looks up a localized string similar to Class should be marked as 'sealed'. + /// + internal static string Diagnostic_Title_ClassShouldBeSealed { + get { + return ResourceManager.GetString("Diagnostic_Title_ClassShouldBeSealed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Wrong accessibility. /// @@ -798,7 +825,7 @@ internal static string SAASDDD049Description { } /// - /// Looks up a localized string similar to Property '{0}' must return one of these primitive types: '{1}', or any Enum, or a List<T>/Dictionary<string, T> of one of those types. + /// Looks up a localized string similar to Property '{0}' must return one of these primitive types: '{1}', or any Enum, or a List<T>/Dictionary<string, T> of one of those types, or another DTO. /// internal static string SAASDDD049MessageFormat { get { @@ -815,6 +842,60 @@ internal static string SAASDDD049Title { } } + /// + /// Looks up a localized string similar to Property must be marked 'required' or be nullable or be initialized.. + /// + internal static string SAASEVT014Description { + get { + return ResourceManager.GetString("SAASEVT014Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property '{0}' must be marked 'required' or be nullable or be initialized. + /// + internal static string SAASEVT014MessageFormat { + get { + return ResourceManager.GetString("SAASEVT014MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wrong declaration. + /// + internal static string SAASEVT014Title { + get { + return ResourceManager.GetString("SAASEVT014Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property must have the return the correct type.. + /// + internal static string SAASEVT016Description { + get { + return ResourceManager.GetString("SAASEVT016Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property '{0}' must return one of these primitive types: '{1}', or a List<T>/Dictionary<string, T> of one of those types, or another DTO. + /// + internal static string SAASEVT016MessageFormat { + get { + return ResourceManager.GetString("SAASEVT016MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wrong return type. + /// + internal static string SAASEVT016Title { + get { + return ResourceManager.GetString("SAASEVT016Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Aggregate or Entity should be registered with an identity prefix. /// diff --git a/src/Tools.Analyzers.NonPlatform/Resources.resx b/src/Tools.Analyzers.NonPlatform/Resources.resx index c75cb6f1..9386c1a1 100644 --- a/src/Tools.Analyzers.NonPlatform/Resources.resx +++ b/src/Tools.Analyzers.NonPlatform/Resources.resx @@ -79,15 +79,24 @@ Property must not be settable - + Class should be marked as 'sealed'. - + Class '{0}' should be marked as 'sealed' - + Class should be marked as 'sealed' + + Class must be marked as 'sealed'. + + + Class '{0}' must be marked as 'sealed' + + + Class must be marked as 'sealed' + Class should be 'public'. @@ -355,12 +364,31 @@ Property must have the return the correct type. - Property '{0}' must return one of these primitive types: '{1}', or any Enum, or a List<T>/Dictionary<string, T> of one of those types + Property '{0}' must return one of these primitive types: '{1}', or any Enum, or a List<T>/Dictionary<string, T> of one of those types, or another DTO Wrong return type + + Property must be marked 'required' or be nullable or be initialized. + + + Property '{0}' must be marked 'required' or be nullable or be initialized + + + Wrong declaration + + + Property must have the return the correct type. + + + Property '{0}' must return one of these primitive types: '{1}', or a List<T>/Dictionary<string, T> of one of those types, or another DTO + + + Wrong return type + + Property must have the return the correct type diff --git a/src/Tools.Analyzers.NonPlatform/Tools.Analyzers.NonPlatform.csproj b/src/Tools.Analyzers.NonPlatform/Tools.Analyzers.NonPlatform.csproj index 28186236..e215436a 100644 --- a/src/Tools.Analyzers.NonPlatform/Tools.Analyzers.NonPlatform.csproj +++ b/src/Tools.Analyzers.NonPlatform/Tools.Analyzers.NonPlatform.csproj @@ -227,6 +227,9 @@ Reference\Domain.Interfaces\DomainFactories.cs + + Reference\Domain.Common\DomainEvent.cs + Reference\Domain.Common\Resources.Designer.cs @@ -293,6 +296,9 @@ Reference\Infrastructure.Web.Hosting.Common\ISubdomainModule.cs + + Reference\Infrastructure.Eventing.Interfaces\Notifications\IIntegrationEvent.cs + diff --git a/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs b/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs new file mode 100644 index 00000000..620e9189 --- /dev/null +++ b/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs @@ -0,0 +1,142 @@ +using Application.Interfaces; +using Common; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Shared; +using Domain.Shared.EndUsers; +using EndUsersDomain; +using Moq; +using UnitTesting.Common; +using UserProfilesApplication.Persistence; +using UserProfilesDomain; +using Xunit; +using Events = EndUsersDomain.Events; +using PersonName = Domain.Shared.PersonName; + +namespace UserProfilesApplication.UnitTests; + +[Trait("Category", "Unit")] +public class UserProfileApplicationDomainEventHandlersSpec +{ + private readonly UserProfilesApplication _application; + private readonly Mock _caller; + private readonly Mock _idFactory; + private readonly Mock _recorder; + private readonly Mock _repository; + + public UserProfileApplicationDomainEventHandlersSpec() + { + _recorder = new Mock(); + _caller = new Mock(); + _idFactory = new Mock(); + _idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + _repository = new Mock(); + _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + _repository.Setup(rep => rep.FindByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((UserProfileRoot root, CancellationToken _) => root); + + _application = new UserProfilesApplication(_recorder.Object, _idFactory.Object, _repository.Object); + } + + [Fact] + public async Task WhenHandleEndUserRegisteredAsyncForPersonWButNoEmail_ThenReturnsError() + { + var domainEvent = Events.Registered("apersonid".ToId(), EndUserProfile.Create("afirstname").Value, + Optional.None, UserClassification.Person, UserAccess.Enabled, + UserStatus.Registered, Roles.Empty, Features.Empty); + + var result = await _application.HandleEndUserRegisteredAsync(_caller.Object, domainEvent, + CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.UserProfilesApplication_PersonMustHaveEmailAddress); + } + + [Fact] + public async Task WhenHandleEndUserRegisteredAsyncForAnyAndExistsForUserId_ThenReturnsError() + { + 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 domainEvent = Events.Registered("apersonid".ToId(), EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value, UserClassification.Person, UserAccess.Enabled, + UserStatus.Registered, Roles.Empty, Features.Empty); + + var result = await _application.HandleEndUserRegisteredAsync(_caller.Object, domainEvent, + CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityExists, Resources.UserProfilesApplication_ProfileExistsForUser); + } + + [Fact] + public async Task WhenHandleEndUserRegisteredAsyncForAnyAndExistsForEmailAddress_ThenReturnsError() + { + var user = UserProfileRoot.Create(_recorder.Object, _idFactory.Object, ProfileType.Person, "auserid".ToId(), + PersonName.Create("afirstname", "alastname").Value).Value; + _repository.Setup(rep => rep.FindByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(user.ToOptional()); + var domainEvent = Events.Registered("apersonid".ToId(), EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value, UserClassification.Person, UserAccess.Enabled, + UserStatus.Registered, Roles.Empty, Features.Empty); + + var result = + await _application.HandleEndUserRegisteredAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityExists, Resources.UserProfilesApplication_ProfileExistsForEmailAddress); + } + + [Fact] + public async Task WhenCreateProfileAsyncForMachine_ThenCreatesProfile() + { + var domainEvent = Events.Registered("amachineid".ToId(), EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("amachine@company.com").Value, UserClassification.Machine, UserAccess.Enabled, + UserStatus.Registered, Roles.Empty, Features.Empty); + + var result = + await _application.HandleEndUserRegisteredAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(up => + up.UserId == "amachineid".ToId() + && up.Type == ProfileType.Machine + && up.DisplayName.Value.Text == "afirstname" + && up.Name.Value.FirstName == "afirstname" + && up.Name.Value.LastName.HasValue == false + && up.EmailAddress.HasValue == false + && up.PhoneNumber.HasValue == false + && up.Address.CountryCode == CountryCodes.Default + && up.Timezone == Timezones.Default + && up.AvatarUrl.HasValue == false + ), It.IsAny())); + } + + [Fact] + public async Task WhenHandleEndUserRegisteredAsyncForPerson_ThenCreatesProfile() + { + var domainEvent = Events.Registered("apersonid".ToId(), EndUserProfile.Create("afirstname", "alastname").Value, + EmailAddress.Create("auser@company.com").Value, UserClassification.Person, UserAccess.Enabled, + UserStatus.Registered, Roles.Empty, Features.Empty); + + var result = + await _application.HandleEndUserRegisteredAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(up => + up.UserId == "apersonid".ToId() + && up.Type == ProfileType.Person + && up.DisplayName.Value.Text == "afirstname" + && up.Name.Value.FirstName == "afirstname" + && up.Name.Value.LastName.Value == "alastname" + && up.EmailAddress.Value == "auser@company.com" + && up.PhoneNumber.HasValue == false + && up.Address.CountryCode == CountryCodes.Default + && up.Timezone == Timezones.Default + && up.AvatarUrl.HasValue == false + ), It.IsAny())); + } +} \ No newline at end of file diff --git a/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs b/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs index ce994d2d..dd5423e5 100644 --- a/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs +++ b/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs @@ -1,5 +1,4 @@ using Application.Interfaces; -using Application.Resources.Shared; using Common; using Domain.Common.Identity; using Domain.Common.ValueObjects; @@ -43,103 +42,6 @@ public UserProfileApplicationSpec() _application = new UserProfilesApplication(_recorder.Object, _idFactory.Object, _repository.Object); } - [Fact] - public async Task WhenCreateProfileForAnyAndExistsForUserId_ThenReturnsError() - { - 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.CreateProfileAsync(_caller.Object, UserProfileClassification.Person, - "apersonid", - "anemailaddress", "afirstname", "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), - CancellationToken.None); - - result.Should().BeError(ErrorCode.EntityExists, Resources.UserProfilesApplication_ProfileExistsForUser); - } - - [Fact] - public async Task WhenCreateProfileForAnyAndExistsForEmailAddress_ThenReturnsError() - { - var user = UserProfileRoot.Create(_recorder.Object, _idFactory.Object, ProfileType.Person, "auserid".ToId(), - PersonName.Create("afirstname", "alastname").Value).Value; - _repository.Setup(rep => rep.FindByEmailAddressAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(user.ToOptional()); - - var result = await _application.CreateProfileAsync(_caller.Object, UserProfileClassification.Person, - "apersonid", - "auser@company.com", "afirstname", "alastname", Timezones.Default.ToString(), - CountryCodes.Default.ToString(), - CancellationToken.None); - - result.Should().BeError(ErrorCode.EntityExists, Resources.UserProfilesApplication_ProfileExistsForEmailAddress); - } - - [Fact] - public async Task WhenCreateProfileAsyncForMachine_ThenCreatesProfile() - { - var result = await _application.CreateProfileAsync(_caller.Object, UserProfileClassification.Machine, - "amachineid", - "anemailaddress", "afirstname", "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), - CancellationToken.None); - - result.Value.UserId.Should().Be("amachineid".ToId()); - result.Value.Classification.Should().Be(UserProfileClassification.Machine); - result.Value.DisplayName.Should().Be("afirstname"); - result.Value.Name.FirstName.Should().Be("afirstname"); - result.Value.Name.LastName.Should().BeNull(); - result.Value.EmailAddress.Should().BeNull(); - result.Value.PhoneNumber.Should().BeNull(); - result.Value.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); - result.Value.Timezone.Should().Be(Timezones.Default.ToString()); - result.Value.AvatarUrl.Should().BeNull(); - _repository.Verify(rep => rep.SaveAsync(It.Is(up => - up.UserId == "amachineid".ToId() - && up.Type == ProfileType.Machine - && up.DisplayName.Value.Text == "afirstname" - && up.Name.Value.FirstName == "afirstname" - && up.Name.Value.LastName.HasValue == false - && up.EmailAddress.HasValue == false - && up.PhoneNumber.HasValue == false - && up.Address.CountryCode == CountryCodes.Default - && up.Timezone == Timezones.Default - && up.AvatarUrl.HasValue == false - ), It.IsAny())); - } - - [Fact] - public async Task WhenCreateProfileAsyncForPerson_ThenCreatesProfile() - { - var result = await _application.CreateProfileAsync(_caller.Object, UserProfileClassification.Person, - "apersonid", - "auser@company.com", "afirstname", "alastname", Timezones.Default.ToString(), - CountryCodes.Default.ToString(), CancellationToken.None); - - result.Value.UserId.Should().Be("apersonid".ToId()); - result.Value.Classification.Should().Be(UserProfileClassification.Person); - result.Value.DisplayName.Should().Be("afirstname"); - result.Value.Name.FirstName.Should().Be("afirstname"); - result.Value.Name.LastName.Should().Be("alastname"); - result.Value.EmailAddress.Should().Be("auser@company.com"); - result.Value.PhoneNumber.Should().BeNull(); - result.Value.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); - result.Value.Timezone.Should().Be(Timezones.Default.ToString()); - result.Value.AvatarUrl.Should().BeNull(); - _repository.Verify(rep => rep.SaveAsync(It.Is(up => - up.UserId == "apersonid".ToId() - && up.Type == ProfileType.Person - && up.DisplayName.Value.Text == "afirstname" - && up.Name.Value.FirstName == "afirstname" - && up.Name.Value.LastName.Value == "alastname" - && up.EmailAddress.Value == "auser@company.com" - && up.PhoneNumber.HasValue == false - && up.Address.CountryCode == CountryCodes.Default - && up.Timezone == Timezones.Default - && up.AvatarUrl.HasValue == false - ), It.IsAny())); - } - [Fact] public async Task WhenFindPersonByEmailAddressAsyncAndNotExists_ThenReturns() { @@ -294,7 +196,28 @@ public async Task WhenGetProfileAsyncAndNotExists_ThenReturnsError() } [Fact] - public async Task WhenGetProfileAsync_ThenReturnsProfile() + public async Task WhenGetProfileAsyncByServiceAccount_ThenReturnsProfile() + { + _caller.Setup(cc => cc.IsServiceAccount) + .Returns(true); + + var profile = 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(profile.ToOptional()); + + var result = await _application.GetProfileAsync(_caller.Object, "auserid", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Name.FirstName.Should().Be("afirstname"); + result.Value.Name.LastName.Should().Be("alastname"); + result.Value.DisplayName.Should().Be("afirstname"); + result.Value.Timezone.Should().Be(Timezones.Default.ToString()); + result.Value.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); + } + + [Fact] + public async Task WhenGetProfileAsyncByOwner_ThenReturnsProfile() { _caller.Setup(cc => cc.CallerId) .Returns("auserid"); diff --git a/src/UserProfilesApplication.UnitTests/UserProfilesApplication.UnitTests.csproj b/src/UserProfilesApplication.UnitTests/UserProfilesApplication.UnitTests.csproj index 70dd1b32..e23e5fed 100644 --- a/src/UserProfilesApplication.UnitTests/UserProfilesApplication.UnitTests.csproj +++ b/src/UserProfilesApplication.UnitTests/UserProfilesApplication.UnitTests.csproj @@ -8,6 +8,7 @@ + diff --git a/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs b/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs new file mode 100644 index 00000000..35303fbe --- /dev/null +++ b/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs @@ -0,0 +1,11 @@ +using Application.Interfaces; +using Common; +using Domain.Events.Shared.EndUsers; + +namespace UserProfilesApplication; + +partial interface IUserProfilesApplication +{ + Task> HandleEndUserRegisteredAsync(ICallerContext caller, Registered domainEvent, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/UserProfilesApplication/IUserProfilesApplication.cs b/src/UserProfilesApplication/IUserProfilesApplication.cs index 535a0eea..475b2afa 100644 --- a/src/UserProfilesApplication/IUserProfilesApplication.cs +++ b/src/UserProfilesApplication/IUserProfilesApplication.cs @@ -4,7 +4,7 @@ namespace UserProfilesApplication; -public interface IUserProfilesApplication +public partial interface IUserProfilesApplication { Task> ChangeContactAddressAsync(ICallerContext caller, string userId, string? line1, string? line2, @@ -15,11 +15,6 @@ Task> ChangeProfileAsync(ICallerContext caller, strin string? lastName, string? displayName, string? phoneNumber, string? timezone, CancellationToken cancellationToken); - Task> CreateProfileAsync(ICallerContext caller, UserProfileClassification classification, - string userId, - string? emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, - CancellationToken cancellationToken); - Task, Error>> FindPersonByEmailAddressAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken); diff --git a/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs b/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs new file mode 100644 index 00000000..6aa210e4 --- /dev/null +++ b/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs @@ -0,0 +1,26 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using Common.Extensions; +using Domain.Events.Shared.EndUsers; + +namespace UserProfilesApplication; + +partial class UserProfilesApplication +{ + public async Task> HandleEndUserRegisteredAsync(ICallerContext caller, Registered domainEvent, + CancellationToken cancellationToken) + { + var classification = domainEvent.Classification.ToEnumOrDefault(UserProfileClassification.Person); + var profile = + await CreateProfileAsync(caller, classification, domainEvent.RootId, domainEvent.Username, + domainEvent.UserProfile.FirstName, domainEvent.UserProfile.LastName, domainEvent.UserProfile.Timezone, + domainEvent.UserProfile.CountryCode, cancellationToken); + if (!profile.IsSuccessful) + { + return profile.Error; + } + + return Result.Ok; + } +} \ No newline at end of file diff --git a/src/UserProfilesApplication/UserProfilesApplication.cs b/src/UserProfilesApplication/UserProfilesApplication.cs index 4bd1dd5c..2d6e66b3 100644 --- a/src/UserProfilesApplication/UserProfilesApplication.cs +++ b/src/UserProfilesApplication/UserProfilesApplication.cs @@ -12,7 +12,7 @@ namespace UserProfilesApplication; -public class UserProfilesApplication : IUserProfilesApplication +public partial class UserProfilesApplication : IUserProfilesApplication { private readonly IIdentifierFactory _identifierFactory; private readonly IRecorder _recorder; @@ -26,114 +26,6 @@ public UserProfilesApplication(IRecorder recorder, IIdentifierFactory identifier _repository = repository; } - public async Task> CreateProfileAsync(ICallerContext caller, - UserProfileClassification classification, - string userId, string? emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, - CancellationToken cancellationToken) - { - if (classification == UserProfileClassification.Person && emailAddress.HasNoValue()) - { - return Error.RuleViolation(Resources.UserProfilesApplication_PersonMustHaveEmailAddress); - } - - var retrievedById = await _repository.FindByUserIdAsync(userId.ToId(), cancellationToken); - if (!retrievedById.IsSuccessful) - { - return retrievedById.Error; - } - - if (retrievedById.Value.HasValue) - { - return Error.EntityExists(Resources.UserProfilesApplication_ProfileExistsForUser); - } - - if (classification == UserProfileClassification.Person && emailAddress.HasValue()) - { - var email = EmailAddress.Create(emailAddress); - if (!email.IsSuccessful) - { - return email.Error; - } - - var retrievedByEmail = await _repository.FindByEmailAddressAsync(email.Value, cancellationToken); - if (!retrievedByEmail.IsSuccessful) - { - return retrievedByEmail.Error; - } - - if (retrievedByEmail.Value.HasValue) - { - return Error.EntityExists(Resources.UserProfilesApplication_ProfileExistsForEmailAddress); - } - } - - var name = PersonName.Create(firstName, classification == UserProfileClassification.Person - ? lastName - : Optional.None); - if (!name.IsSuccessful) - { - return name.Error; - } - - var created = UserProfileRoot.Create(_recorder, _identifierFactory, - classification.ToEnumOrDefault(ProfileType.Person), - userId.ToId(), name.Value); - if (!created.IsSuccessful) - { - return created.Error; - } - - var profile = created.Value; - if (classification == UserProfileClassification.Person) - { - var email2 = EmailAddress.Create(emailAddress!); - if (!email2.IsSuccessful) - { - return email2.Error; - } - - var emailed = profile.SetEmailAddress(userId.ToId(), email2.Value); - if (!emailed.IsSuccessful) - { - return emailed.Error; - } - } - - var address = Address.Create(CountryCodes.FindOrDefault(countryCode)); - if (!address.IsSuccessful) - { - return address.Error; - } - - var contacted = profile.SetContactAddress(userId.ToId(), address.Value); - if (!contacted.IsSuccessful) - { - return contacted.Error; - } - - var tz = Timezone.Create(Timezones.FindOrDefault(timezone)); - if (!tz.IsSuccessful) - { - return tz.Error; - } - - var timezoned = profile.SetTimezone(userId.ToId(), tz.Value); - if (!timezoned.IsSuccessful) - { - return timezoned.Error; - } - - var saved = await _repository.SaveAsync(profile, cancellationToken); - if (!saved.IsSuccessful) - { - return saved.Error; - } - - _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was created for user {userId}", profile.Id, userId); - - return saved.Value.ToProfile(); - } - public async Task, Error>> FindPersonByEmailAddressAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken) { @@ -161,7 +53,8 @@ public async Task, Error>> FindPersonByEmailAddress public async Task> GetProfileAsync(ICallerContext caller, string userId, CancellationToken cancellationToken) { - if (userId != caller.CallerId) + if (!caller.IsServiceAccount + && userId != caller.CallerId) { return Error.ForbiddenAccess(); } @@ -359,6 +252,114 @@ public async Task, Error>> GetAllProfilesAsync(ICallerC .ConvertAll(profile => profile.ToProfile()) .ToList(); } + + private async Task> CreateProfileAsync(ICallerContext caller, + UserProfileClassification classification, string userId, string? emailAddress, string firstName, + string? lastName, string? timezone, string? countryCode, + CancellationToken cancellationToken) + { + if (classification == UserProfileClassification.Person && emailAddress.HasNoValue()) + { + return Error.RuleViolation(Resources.UserProfilesApplication_PersonMustHaveEmailAddress); + } + + var retrievedById = await _repository.FindByUserIdAsync(userId.ToId(), cancellationToken); + if (!retrievedById.IsSuccessful) + { + return retrievedById.Error; + } + + if (retrievedById.Value.HasValue) + { + return Error.EntityExists(Resources.UserProfilesApplication_ProfileExistsForUser); + } + + if (classification == UserProfileClassification.Person && emailAddress.HasValue()) + { + var email = EmailAddress.Create(emailAddress); + if (!email.IsSuccessful) + { + return email.Error; + } + + var retrievedByEmail = await _repository.FindByEmailAddressAsync(email.Value, cancellationToken); + if (!retrievedByEmail.IsSuccessful) + { + return retrievedByEmail.Error; + } + + if (retrievedByEmail.Value.HasValue) + { + return Error.EntityExists(Resources.UserProfilesApplication_ProfileExistsForEmailAddress); + } + } + + var name = PersonName.Create(firstName, classification == UserProfileClassification.Person + ? lastName + : Optional.None); + if (!name.IsSuccessful) + { + return name.Error; + } + + var created = UserProfileRoot.Create(_recorder, _identifierFactory, + classification.ToEnumOrDefault(ProfileType.Person), + userId.ToId(), name.Value); + if (!created.IsSuccessful) + { + return created.Error; + } + + var profile = created.Value; + if (classification == UserProfileClassification.Person) + { + var email2 = EmailAddress.Create(emailAddress!); + if (!email2.IsSuccessful) + { + return email2.Error; + } + + var emailed = profile.SetEmailAddress(userId.ToId(), email2.Value); + if (!emailed.IsSuccessful) + { + return emailed.Error; + } + } + + var address = Address.Create(CountryCodes.FindOrDefault(countryCode)); + if (!address.IsSuccessful) + { + return address.Error; + } + + var contacted = profile.SetContactAddress(userId.ToId(), address.Value); + if (!contacted.IsSuccessful) + { + return contacted.Error; + } + + var tz = Timezone.Create(Timezones.FindOrDefault(timezone)); + if (!tz.IsSuccessful) + { + return tz.Error; + } + + var timezoned = profile.SetTimezone(userId.ToId(), tz.Value); + if (!timezoned.IsSuccessful) + { + return timezoned.Error; + } + + var saved = await _repository.SaveAsync(profile, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was created for user {userId}", profile.Id, userId); + + return saved.Value.ToProfile(); + } } internal static class UserProfileConversionExtensions diff --git a/src/UserProfilesDomain/Events.cs b/src/UserProfilesDomain/Events.cs index d3a4de2c..552d22a8 100644 --- a/src/UserProfilesDomain/Events.cs +++ b/src/UserProfilesDomain/Events.cs @@ -8,10 +8,8 @@ public static class Events { public static ContactAddressChanged ContactAddressChanged(Identifier id, Identifier userId, Address address) { - return new ContactAddressChanged + return new ContactAddressChanged(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, UserId = userId, Line1 = address.Line1, Line2 = address.Line2, @@ -25,10 +23,8 @@ public static ContactAddressChanged ContactAddressChanged(Identifier id, Identif public static Created Created(Identifier id, ProfileType type, Identifier userId, PersonName name) { - return new Created + return new Created(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, UserId = userId, FirstName = name.FirstName, LastName = name.LastName.ValueOrDefault!, @@ -39,10 +35,8 @@ public static Created Created(Identifier id, ProfileType type, Identifier userId public static DisplayNameChanged DisplayNameChanged(Identifier id, Identifier userId, PersonDisplayName name) { - return new DisplayNameChanged + return new DisplayNameChanged(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, UserId = userId, DisplayName = name }; @@ -50,10 +44,8 @@ public static DisplayNameChanged DisplayNameChanged(Identifier id, Identifier us public static EmailAddressChanged EmailAddressChanged(Identifier id, Identifier userId, EmailAddress emailAddress) { - return new EmailAddressChanged + return new EmailAddressChanged(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, UserId = userId, EmailAddress = emailAddress }; @@ -61,10 +53,8 @@ public static EmailAddressChanged EmailAddressChanged(Identifier id, Identifier public static NameChanged NameChanged(Identifier id, Identifier userId, PersonName name) { - return new NameChanged + return new NameChanged(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, UserId = userId, FirstName = name.FirstName, LastName = name.LastName.ValueOrDefault! @@ -73,10 +63,8 @@ public static NameChanged NameChanged(Identifier id, Identifier userId, PersonNa public static PhoneNumberChanged PhoneNumberChanged(Identifier id, Identifier userId, PhoneNumber number) { - return new PhoneNumberChanged + return new PhoneNumberChanged(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, UserId = userId, Number = number }; @@ -84,10 +72,8 @@ public static PhoneNumberChanged PhoneNumberChanged(Identifier id, Identifier us public static TimezoneChanged TimezoneChanged(Identifier id, Identifier userId, Timezone timezone) { - return new TimezoneChanged + return new TimezoneChanged(id) { - RootId = id, - OccurredUtc = DateTime.UtcNow, UserId = userId, Timezone = timezone.Code.ToString() }; diff --git a/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs b/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs index 2c433894..9cbfd7d1 100644 --- a/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs +++ b/src/UserProfilesInfrastructure/ApplicationServices/UserProfilesInProcessServiceClient.cs @@ -15,24 +15,6 @@ public UserProfilesInProcessServiceClient(IUserProfilesApplication userProfilesA _userProfilesApplication = userProfilesApplication; } - public async Task> CreateMachineProfilePrivateAsync(ICallerContext caller, - string machineId, string name, string? timezone, - string? countryCode, CancellationToken cancellationToken) - { - return await _userProfilesApplication.CreateProfileAsync(caller, UserProfileClassification.Machine, machineId, - null, name, - null, timezone, countryCode, cancellationToken); - } - - public async Task> CreatePersonProfilePrivateAsync(ICallerContext caller, - string personId, string emailAddress, string firstName, - string? lastName, string? timezone, string? countryCode, CancellationToken cancellationToken) - { - return await _userProfilesApplication.CreateProfileAsync(caller, UserProfileClassification.Person, personId, - emailAddress, - firstName, lastName, timezone, countryCode, cancellationToken); - } - public async Task, Error>> FindPersonByEmailAddressPrivateAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken) { diff --git a/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs b/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs new file mode 100644 index 00000000..767a9875 --- /dev/null +++ b/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs @@ -0,0 +1,34 @@ +using Common; +using Domain.Events.Shared.EndUsers; +using Domain.Interfaces.Entities; +using Infrastructure.Eventing.Interfaces.Notifications; +using Infrastructure.Interfaces; +using UserProfilesApplication; + +namespace UserProfilesInfrastructure.Notifications; + +public class UserProfileNotificationConsumer : IDomainEventNotificationConsumer +{ + private readonly ICallerContextFactory _callerContextFactory; + private readonly IUserProfilesApplication _userProfilesApplication; + + public UserProfileNotificationConsumer(ICallerContextFactory callerContextFactory, + IUserProfilesApplication userProfilesApplication) + { + _callerContextFactory = callerContextFactory; + _userProfilesApplication = userProfilesApplication; + } + + public async Task> NotifyAsync(IDomainEvent domainEvent, CancellationToken cancellationToken) + { + switch (domainEvent) + { + case Registered registered: + return await _userProfilesApplication.HandleEndUserRegisteredAsync(_callerContextFactory.Create(), + registered, cancellationToken); + + default: + return Result.Ok; + } + } +} \ No newline at end of file diff --git a/src/UserProfilesInfrastructure/Notifications/UserProfileNotifier.cs b/src/UserProfilesInfrastructure/Notifications/UserProfileNotifier.cs new file mode 100644 index 00000000..7fc3c537 --- /dev/null +++ b/src/UserProfilesInfrastructure/Notifications/UserProfileNotifier.cs @@ -0,0 +1,18 @@ +using Infrastructure.Eventing.Common.Notifications; +using Infrastructure.Eventing.Interfaces.Notifications; +using UserProfilesDomain; + +namespace UserProfilesInfrastructure.Notifications; + +public class UserProfileNotifier : IEventNotificationRegistration +{ + public UserProfileNotifier(IEnumerable consumers) + { + DomainEventConsumers = consumers.ToList(); + } + + public List DomainEventConsumers { get; } + + public IIntegrationEventNotificationTranslator IntegrationEventTranslator => + new NoOpIntegrationEventNotificationTranslator(); +} \ No newline at end of file diff --git a/src/UserProfilesInfrastructure/UserProfilesModule.cs b/src/UserProfilesInfrastructure/UserProfilesModule.cs index 0557754a..56a23ac7 100644 --- a/src/UserProfilesInfrastructure/UserProfilesModule.cs +++ b/src/UserProfilesInfrastructure/UserProfilesModule.cs @@ -4,7 +4,9 @@ using Common; using Domain.Common.Identity; using Domain.Interfaces; +using Infrastructure.Eventing.Interfaces.Notifications; using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Interfaces; using Infrastructure.Persistence.Interfaces; using Infrastructure.Web.Hosting.Common; using Microsoft.AspNetCore.Builder; @@ -15,6 +17,7 @@ using UserProfilesDomain; using UserProfilesInfrastructure.Api.Profiles; using UserProfilesInfrastructure.ApplicationServices; +using UserProfilesInfrastructure.Notifications; using UserProfilesInfrastructure.Persistence; using UserProfilesInfrastructure.Persistence.ReadModels; @@ -51,10 +54,16 @@ public Action RegisterServices c.GetRequiredService(), c.GetRequiredService>(), c.GetRequiredServiceForPlatform())); - services.RegisterUnTenantedEventing( + services + .AddPerHttpRequest(c => + new UserProfileNotificationConsumer(c.GetRequiredService(), + c.GetRequiredService())); + services.RegisterUnTenantedEventing( c => new UserProfileProjection(c.GetRequiredService(), c.GetRequiredService(), - c.GetRequiredServiceForPlatform())); + c.GetRequiredServiceForPlatform()), + c => new UserProfileNotifier(c + .GetRequiredService>())); services.AddSingleton(); };