From a39453667ac5056621bc7c82ad91c65215f37b08 Mon Sep 17 00:00:00 2001 From: Dualxyz Date: Sun, 15 Mar 2026 17:11:31 +0100 Subject: [PATCH] outbox checkpoint --- .files/mccn-realm-export.json | 6 +- Mccn.slnx | 45 ++++--- docker-compose.yml | 12 ++ .../Extensions/MigrationExtensions.cs | 2 + src/API/Mccn.Api/Mccn.Api.http | 13 +- src/API/Mccn.Api/Program.cs | 5 +- .../DependencyInjection.cs | 19 +++ .../EventBus/DomainEventHandler.cs | 12 ++ .../EventBus/IDomainEventHandler.cs | 14 +++ .../EventBus/IEventBus.cs | 7 ++ .../EventBus/IIntegrationEvent.cs | 7 ++ .../EventBus/IIntegrationEventHandler.cs | 12 ++ .../EventBus/IntegrationEvent.cs | 3 + .../EventBus/IntegrationEventHandler.cs | 10 ++ .../Abstractions/DomainEvent.cs | 3 + .../Mccn.Common.Domain/Abstractions/Entity.cs | 8 ++ .../Abstractions/IDomainEvent.cs | 7 ++ .../DependencyInjection.cs | 25 +++- .../EventBus/EventBus.cs | 13 ++ .../Inbox/InboxMessage.cs | 11 ++ .../Inbox/InboxMessageConsumer.cs | 7 ++ .../Mccn.Common.Infrastructure.csproj | 4 + .../Observability/ObservabilityExtensions.cs | 1 + .../Outbox/InsertOutboxMessagesInterceptor.cs | 43 +++++++ .../Outbox/OutboxMessage.cs | 11 ++ .../Abstractions/IUnitOfWork.cs | 6 + .../Mccn.Modules.Hello.Application.csproj | 1 + .../WelcomedUsers/GetWelcomedUsersQuery.cs | 5 + .../GetWelcomedUsersQueryHandler.cs | 16 +++ .../WelcomedUsers/IWelcomedUserRepository.cs | 14 +++ .../UserRegisteredIntegrationEventHandler.cs | 26 ++++ .../WelcomedUsers/WelcomedUserResponse.cs | 8 ++ .../InboxMessageConfiguration.cs | 16 +++ .../InboxMessageConsumerConfiguration.cs | 14 +++ .../WelcomedUserConfiguration.cs | 17 +++ .../Database/HelloDbContext.cs | 23 ++++ .../Database/Models/WelcomedUser.cs | 11 ++ .../Database/Schemas.cs | 6 + .../DependencyInjection.cs | 42 ++++++- .../Inbox/IntegrationEventConsumer.cs | 28 +++++ .../Inbox/ProcessInboxJob.cs | 103 +++++++++++++++ .../Mccn.Modules.Hello.Infrastructure.csproj | 6 + .../20260315153129_InitialCreate.Designer.cs | 118 ++++++++++++++++++ .../20260315153129_InitialCreate.cs | 81 ++++++++++++ .../Migrations/HelloDbContextModelSnapshot.cs | 115 +++++++++++++++++ .../WelcomedUsers/UnitOfWork.cs | 10 ++ .../WelcomedUsers/WelcomedUserRepository.cs | 44 +++++++ .../Hello/GetWelcomedUsers.cs | 25 ++++ .../Mccn.Modules.Users.Application.csproj | 1 + .../UserRegisteredDomainEventHandler.cs | 22 ++++ .../Mccn.Modules.Users.Domain/Users/User.cs | 12 +- .../Users/UserRegisteredDomainEvent.cs | 11 ++ .../OutboxMessageConfiguration.cs | 16 +++ ...260315153115_AddOutboxMessages.Designer.cs | 111 ++++++++++++++++ .../20260315153115_AddOutboxMessages.cs | 40 ++++++ .../Migrations/UsersDbContextModelSnapshot.cs | 37 +++++- .../Database/UsersDbContext.cs | 9 +- .../DependencyInjection.cs | 25 +++- .../Outbox/ProcessOutboxJob.cs | 78 ++++++++++++ ...ccn.Modules.Users.IntegrationEvents.csproj | 7 ++ .../UserRegisteredIntegrationEvent.cs | 11 ++ .../Abstractions/BaseTest.cs | 19 +++ .../Layers/ModuleTests.cs | 31 +++-- 63 files changed, 1410 insertions(+), 55 deletions(-) create mode 100644 src/Common/Mccn.Common.Application/EventBus/DomainEventHandler.cs create mode 100644 src/Common/Mccn.Common.Application/EventBus/IDomainEventHandler.cs create mode 100644 src/Common/Mccn.Common.Application/EventBus/IEventBus.cs create mode 100644 src/Common/Mccn.Common.Application/EventBus/IIntegrationEvent.cs create mode 100644 src/Common/Mccn.Common.Application/EventBus/IIntegrationEventHandler.cs create mode 100644 src/Common/Mccn.Common.Application/EventBus/IntegrationEvent.cs create mode 100644 src/Common/Mccn.Common.Application/EventBus/IntegrationEventHandler.cs create mode 100644 src/Common/Mccn.Common.Domain/Abstractions/DomainEvent.cs create mode 100644 src/Common/Mccn.Common.Domain/Abstractions/IDomainEvent.cs create mode 100644 src/Common/Mccn.Common.Infrastructure/EventBus/EventBus.cs create mode 100644 src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessage.cs create mode 100644 src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessageConsumer.cs create mode 100644 src/Common/Mccn.Common.Infrastructure/Outbox/InsertOutboxMessagesInterceptor.cs create mode 100644 src/Common/Mccn.Common.Infrastructure/Outbox/OutboxMessage.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Application/Abstractions/IUnitOfWork.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQuery.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQueryHandler.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/IWelcomedUserRepository.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/UserRegisteredIntegrationEventHandler.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/WelcomedUserResponse.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConfiguration.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConsumerConfiguration.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/WelcomedUserConfiguration.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/HelloDbContext.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Models/WelcomedUser.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Schemas.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/IntegrationEventConsumer.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/ProcessInboxJob.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.Designer.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/HelloDbContextModelSnapshot.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/UnitOfWork.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/WelcomedUserRepository.cs create mode 100644 src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetWelcomedUsers.cs create mode 100644 src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/UserRegisteredDomainEventHandler.cs create mode 100644 src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs create mode 100644 src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Configurations/OutboxMessageConfiguration.cs create mode 100644 src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.Designer.cs create mode 100644 src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.cs create mode 100644 src/Modules/Users/Mccn.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs create mode 100644 src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj create mode 100644 src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs diff --git a/.files/mccn-realm-export.json b/.files/mccn-realm-export.json index e79dc15..5b7441a 100644 --- a/.files/mccn-realm-export.json +++ b/.files/mccn-realm-export.json @@ -70,10 +70,10 @@ "username": "service-account-mccn-api", "enabled": true, "serviceAccountClientId": "mccn-api", - "clientRoleMappings": { + "clientRoles": { "realm-management": [ - { "name": "manage-users" }, - { "name": "view-users" } + "manage-users", + "view-users" ] } } diff --git a/Mccn.slnx b/Mccn.slnx index 5547c63..6e1fe3f 100644 --- a/Mccn.slnx +++ b/Mccn.slnx @@ -1,36 +1,33 @@ - + - + - - - - + + + + - + - - - - - + + + + + - - - - - - - + + + + + + + + - + diff --git a/docker-compose.yml b/docker-compose.yml index 7d4aada..cb23933 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,18 @@ services: ports: - 6379:6379 + mccn.queue: + image: rabbitmq:4-management + container_name: mccn.queue + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + volumes: + - ./.containers/queue:/var/lib/rabbitmq + ports: + - 5672:5672 + - 15672:15672 + mccn.jaeger: image: jaegertracing/all-in-one:latest container_name: mccn.jaeger diff --git a/src/API/Mccn.Api/Extensions/MigrationExtensions.cs b/src/API/Mccn.Api/Extensions/MigrationExtensions.cs index 1898896..852bc09 100644 --- a/src/API/Mccn.Api/Extensions/MigrationExtensions.cs +++ b/src/API/Mccn.Api/Extensions/MigrationExtensions.cs @@ -1,3 +1,4 @@ +using Mccn.Modules.Hello.Infrastructure.Database; using Mccn.Modules.Users.Infrastructure.Database; using Microsoft.EntityFrameworkCore; @@ -9,6 +10,7 @@ internal static class MigrationExtensions { using IServiceScope scope = app.Services.CreateScope(); await MigrateAsync(scope.ServiceProvider); + await MigrateAsync(scope.ServiceProvider); } private static async Task MigrateAsync(IServiceProvider serviceProvider) where TContext : DbContext diff --git a/src/API/Mccn.Api/Mccn.Api.http b/src/API/Mccn.Api/Mccn.Api.http index ff48036..a10a317 100644 --- a/src/API/Mccn.Api/Mccn.Api.http +++ b/src/API/Mccn.Api/Mccn.Api.http @@ -43,10 +43,10 @@ POST {{baseAddress}}/users/register Content-Type: {{content_type}} { - "email": "user@example.com", - "firstName": "Jane", - "lastName": "Doe", - "password": "Test1234!@#$anin149141" + "email": "andrej+test6@mccn.dev", + "firstName": "Andrej", + "lastName": "Jovanovic", + "password": "BestOfTheBest123" } ### Get user profile (requires auth) @@ -68,3 +68,8 @@ GET {{baseAddress}}/hello?name=World ### Say hello to the authenticated user (requires auth) GET {{baseAddress}}/hello/me Authorization: Bearer {{token}} + +### Get welcomed users — lists every user the Hello module was notified about via the inbox. +# Register a user first (see above), wait ~10 seconds for the outbox and inbox jobs to run, +# then call this endpoint to confirm the integration event was received and processed. +GET {{baseAddress}}/hello/welcomed-users diff --git a/src/API/Mccn.Api/Program.cs b/src/API/Mccn.Api/Program.cs index 4f697ae..9492699 100644 --- a/src/API/Mccn.Api/Program.cs +++ b/src/API/Mccn.Api/Program.cs @@ -17,7 +17,10 @@ builder.Host.UseSerilog((context, config) => { config.ReadFrom.Configuration(con builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddInfrastructure( + builder.Configuration, + Mccn.Modules.Users.Infrastructure.DependencyInjection.ConfigureConsumers, + Mccn.Modules.Hello.Infrastructure.DependencyInjection.ConfigureConsumers); builder.Services.AddApplication( typeof(RegisterUserCommand).Assembly, diff --git a/src/Common/Mccn.Common.Application/DependencyInjection.cs b/src/Common/Mccn.Common.Application/DependencyInjection.cs index 03e4a34..cb6b58e 100644 --- a/src/Common/Mccn.Common.Application/DependencyInjection.cs +++ b/src/Common/Mccn.Common.Application/DependencyInjection.cs @@ -1,6 +1,7 @@ using System.Reflection; using FluentValidation; using Mccn.Common.Application.Behaviors; +using Mccn.Common.Application.EventBus; using MediatR; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +19,24 @@ public static class DependencyInjection services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + RegisterDomainEventHandlers(services, moduleAssemblies); + return services; } + + private static void RegisterDomainEventHandlers(IServiceCollection services, Assembly[] assemblies) + { + foreach (Assembly assembly in assemblies) + { + IEnumerable<(Type Implementation, Type Interface)> handlers = assembly.GetTypes() + .Where(t => !t.IsAbstract && !t.IsInterface) + .SelectMany(t => t.GetInterfaces() + .Where(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>)) + .Select(i => (Implementation: t, Interface: i))); + + foreach ((Type implementation, Type iface) in handlers) + services.AddTransient(iface, implementation); + } + } } \ No newline at end of file diff --git a/src/Common/Mccn.Common.Application/EventBus/DomainEventHandler.cs b/src/Common/Mccn.Common.Application/EventBus/DomainEventHandler.cs new file mode 100644 index 0000000..43b1751 --- /dev/null +++ b/src/Common/Mccn.Common.Application/EventBus/DomainEventHandler.cs @@ -0,0 +1,12 @@ +using Mccn.Common.Domain.Abstractions; + +namespace Mccn.Common.Application.EventBus; + +public abstract class DomainEventHandler : IDomainEventHandler + where TDomainEvent : IDomainEvent +{ + public abstract Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken); + + Task IDomainEventHandler.Handle(IDomainEvent domainEvent, CancellationToken cancellationToken) => + Handle((TDomainEvent)domainEvent, cancellationToken); +} diff --git a/src/Common/Mccn.Common.Application/EventBus/IDomainEventHandler.cs b/src/Common/Mccn.Common.Application/EventBus/IDomainEventHandler.cs new file mode 100644 index 0000000..9722a56 --- /dev/null +++ b/src/Common/Mccn.Common.Application/EventBus/IDomainEventHandler.cs @@ -0,0 +1,14 @@ +using Mccn.Common.Domain.Abstractions; + +namespace Mccn.Common.Application.EventBus; + +public interface IDomainEventHandler +{ + Task Handle(IDomainEvent domainEvent, CancellationToken cancellationToken); +} + +public interface IDomainEventHandler : IDomainEventHandler + where TDomainEvent : IDomainEvent +{ + Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken); +} diff --git a/src/Common/Mccn.Common.Application/EventBus/IEventBus.cs b/src/Common/Mccn.Common.Application/EventBus/IEventBus.cs new file mode 100644 index 0000000..88b68a6 --- /dev/null +++ b/src/Common/Mccn.Common.Application/EventBus/IEventBus.cs @@ -0,0 +1,7 @@ +namespace Mccn.Common.Application.EventBus; + +public interface IEventBus +{ + Task PublishAsync(T integrationEvent, CancellationToken cancellationToken = default) + where T : IIntegrationEvent; +} diff --git a/src/Common/Mccn.Common.Application/EventBus/IIntegrationEvent.cs b/src/Common/Mccn.Common.Application/EventBus/IIntegrationEvent.cs new file mode 100644 index 0000000..4018483 --- /dev/null +++ b/src/Common/Mccn.Common.Application/EventBus/IIntegrationEvent.cs @@ -0,0 +1,7 @@ +namespace Mccn.Common.Application.EventBus; + +public interface IIntegrationEvent +{ + Guid Id { get; } + DateTime OccurredOnUtc { get; } +} diff --git a/src/Common/Mccn.Common.Application/EventBus/IIntegrationEventHandler.cs b/src/Common/Mccn.Common.Application/EventBus/IIntegrationEventHandler.cs new file mode 100644 index 0000000..11e7568 --- /dev/null +++ b/src/Common/Mccn.Common.Application/EventBus/IIntegrationEventHandler.cs @@ -0,0 +1,12 @@ +namespace Mccn.Common.Application.EventBus; + +public interface IIntegrationEventHandler +{ + Task Handle(IIntegrationEvent integrationEvent, CancellationToken cancellationToken); +} + +public interface IIntegrationEventHandler : IIntegrationEventHandler + where TIntegrationEvent : IIntegrationEvent +{ + Task Handle(TIntegrationEvent integrationEvent, CancellationToken cancellationToken); +} diff --git a/src/Common/Mccn.Common.Application/EventBus/IntegrationEvent.cs b/src/Common/Mccn.Common.Application/EventBus/IntegrationEvent.cs new file mode 100644 index 0000000..6f4e852 --- /dev/null +++ b/src/Common/Mccn.Common.Application/EventBus/IntegrationEvent.cs @@ -0,0 +1,3 @@ +namespace Mccn.Common.Application.EventBus; + +public abstract record IntegrationEvent(Guid Id, DateTime OccurredOnUtc) : IIntegrationEvent; diff --git a/src/Common/Mccn.Common.Application/EventBus/IntegrationEventHandler.cs b/src/Common/Mccn.Common.Application/EventBus/IntegrationEventHandler.cs new file mode 100644 index 0000000..38c678d --- /dev/null +++ b/src/Common/Mccn.Common.Application/EventBus/IntegrationEventHandler.cs @@ -0,0 +1,10 @@ +namespace Mccn.Common.Application.EventBus; + +public abstract class IntegrationEventHandler : IIntegrationEventHandler + where TIntegrationEvent : IIntegrationEvent +{ + public abstract Task Handle(TIntegrationEvent integrationEvent, CancellationToken cancellationToken); + + Task IIntegrationEventHandler.Handle(IIntegrationEvent integrationEvent, CancellationToken cancellationToken) => + Handle((TIntegrationEvent)integrationEvent, cancellationToken); +} diff --git a/src/Common/Mccn.Common.Domain/Abstractions/DomainEvent.cs b/src/Common/Mccn.Common.Domain/Abstractions/DomainEvent.cs new file mode 100644 index 0000000..ab4d046 --- /dev/null +++ b/src/Common/Mccn.Common.Domain/Abstractions/DomainEvent.cs @@ -0,0 +1,3 @@ +namespace Mccn.Common.Domain.Abstractions; + +public abstract record DomainEvent(Guid Id, DateTime OccurredOnUtc) : IDomainEvent; diff --git a/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs b/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs index bc393eb..9c73e72 100644 --- a/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs +++ b/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs @@ -2,6 +2,8 @@ namespace Mccn.Common.Domain.Abstractions; public abstract class Entity { + private readonly List _domainEvents = []; + protected Entity(Guid id) { Id = id; @@ -12,4 +14,10 @@ public abstract class Entity } public Guid Id { get; init; } + + public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly(); + + protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent); + + public void ClearDomainEvents() => _domainEvents.Clear(); } \ No newline at end of file diff --git a/src/Common/Mccn.Common.Domain/Abstractions/IDomainEvent.cs b/src/Common/Mccn.Common.Domain/Abstractions/IDomainEvent.cs new file mode 100644 index 0000000..8fb27a4 --- /dev/null +++ b/src/Common/Mccn.Common.Domain/Abstractions/IDomainEvent.cs @@ -0,0 +1,7 @@ +namespace Mccn.Common.Domain.Abstractions; + +public interface IDomainEvent +{ + Guid Id { get; } + DateTime OccurredOnUtc { get; } +} diff --git a/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs b/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs index 8144b54..492a8d3 100644 --- a/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs +++ b/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs @@ -1,9 +1,13 @@ +using MassTransit; +using Mccn.Common.Application.EventBus; using Mccn.Common.Infrastructure.Authentication; using Mccn.Common.Infrastructure.Caching; +using Mccn.Common.Infrastructure.EventBus; using Mccn.Common.Infrastructure.ExceptionHandlers; using Mccn.Common.Infrastructure.Observability; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Quartz; namespace Mccn.Common.Infrastructure; @@ -11,7 +15,8 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure( this IServiceCollection services, - IConfiguration configuration) + IConfiguration configuration, + params Action[] moduleConsumers) { services.AddJwtAuthentication(configuration); @@ -26,6 +31,24 @@ public static class DependencyInjection services.AddHttpContextAccessor(); + services.AddMassTransit(configure => + { + foreach (Action configureConsumers in moduleConsumers) + configureConsumers(configure); + + configure.SetKebabCaseEndpointNameFormatter(); + configure.UsingRabbitMq((context, cfg) => + { + cfg.Host(configuration.GetConnectionString("MessageBroker")); + cfg.ConfigureEndpoints(context); + }); + }); + + services.AddScoped(); + + services.AddQuartz(); + services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); + return services; } } \ No newline at end of file diff --git a/src/Common/Mccn.Common.Infrastructure/EventBus/EventBus.cs b/src/Common/Mccn.Common.Infrastructure/EventBus/EventBus.cs new file mode 100644 index 0000000..839fa4e --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/EventBus/EventBus.cs @@ -0,0 +1,13 @@ +using MassTransit; +using Mccn.Common.Application.EventBus; + +namespace Mccn.Common.Infrastructure.EventBus; + +internal sealed class EventBus(IBus bus) : IEventBus +{ + public async Task PublishAsync(T integrationEvent, CancellationToken cancellationToken = default) + where T : IIntegrationEvent + { + await bus.Publish(integrationEvent, cancellationToken); + } +} diff --git a/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessage.cs b/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessage.cs new file mode 100644 index 0000000..203509b --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessage.cs @@ -0,0 +1,11 @@ +namespace Mccn.Common.Infrastructure.Inbox; + +public sealed class InboxMessage +{ + public Guid Id { get; init; } + public string Type { get; init; } = string.Empty; + public string Content { get; init; } = string.Empty; + public DateTime OccurredOnUtc { get; init; } + public DateTime? ProcessedOnUtc { get; set; } + public string? Error { get; set; } +} diff --git a/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessageConsumer.cs b/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessageConsumer.cs new file mode 100644 index 0000000..d97135f --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessageConsumer.cs @@ -0,0 +1,7 @@ +namespace Mccn.Common.Infrastructure.Inbox; + +public sealed class InboxMessageConsumer +{ + public Guid InboxMessageId { get; init; } + public string Name { get; init; } = string.Empty; +} diff --git a/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj b/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj index d6ec4a2..51f3d6f 100644 --- a/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj +++ b/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj @@ -10,12 +10,16 @@ + + + + diff --git a/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs b/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs index 8225517..8a207b6 100644 --- a/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs +++ b/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs @@ -19,6 +19,7 @@ public static class ObservabilityExtensions tracing .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() + .AddSource("MassTransit") .AddOtlpExporter(options => { options.Endpoint = new Uri( diff --git a/src/Common/Mccn.Common.Infrastructure/Outbox/InsertOutboxMessagesInterceptor.cs b/src/Common/Mccn.Common.Infrastructure/Outbox/InsertOutboxMessagesInterceptor.cs new file mode 100644 index 0000000..2407a79 --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/Outbox/InsertOutboxMessagesInterceptor.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using Mccn.Common.Domain.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Mccn.Common.Infrastructure.Outbox; + +public sealed class InsertOutboxMessagesInterceptor : SaveChangesInterceptor +{ + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is not null) + InsertOutboxMessages(eventData.Context); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void InsertOutboxMessages(DbContext context) + { + List outboxMessages = context.ChangeTracker + .Entries() + .Select(e => e.Entity) + .SelectMany(entity => + { + IReadOnlyList domainEvents = entity.DomainEvents; + entity.ClearDomainEvents(); + return domainEvents; + }) + .Select(domainEvent => new OutboxMessage + { + Id = domainEvent.Id, + Type = domainEvent.GetType().Name, + Content = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()), + OccurredOnUtc = domainEvent.OccurredOnUtc + }) + .ToList(); + + context.Set().AddRange(outboxMessages); + } +} diff --git a/src/Common/Mccn.Common.Infrastructure/Outbox/OutboxMessage.cs b/src/Common/Mccn.Common.Infrastructure/Outbox/OutboxMessage.cs new file mode 100644 index 0000000..2acc616 --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/Outbox/OutboxMessage.cs @@ -0,0 +1,11 @@ +namespace Mccn.Common.Infrastructure.Outbox; + +public sealed class OutboxMessage +{ + public Guid Id { get; init; } + public string Type { get; init; } = string.Empty; + public string Content { get; init; } = string.Empty; + public DateTime OccurredOnUtc { get; init; } + public DateTime? ProcessedOnUtc { get; set; } + public string? Error { get; set; } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/Abstractions/IUnitOfWork.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/Abstractions/IUnitOfWork.cs new file mode 100644 index 0000000..1d84f80 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/Abstractions/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Mccn.Modules.Hello.Application.Abstractions; + +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj b/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj index 28d75a0..2926f86 100644 --- a/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj @@ -2,6 +2,7 @@ + diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQuery.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQuery.cs new file mode 100644 index 0000000..398746a --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQuery.cs @@ -0,0 +1,5 @@ +using Mccn.Common.Application.Abstractions; + +namespace Mccn.Modules.Hello.Application.WelcomedUsers; + +public sealed record GetWelcomedUsersQuery : IQuery>; diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQueryHandler.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQueryHandler.cs new file mode 100644 index 0000000..d16455e --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQueryHandler.cs @@ -0,0 +1,16 @@ +using Mccn.Common.Application.Abstractions; +using Mccn.Common.Domain.Abstractions; + +namespace Mccn.Modules.Hello.Application.WelcomedUsers; + +internal sealed class GetWelcomedUsersQueryHandler(IWelcomedUserRepository repository) + : IQueryHandler> +{ + public async Task>> Handle( + GetWelcomedUsersQuery request, + CancellationToken cancellationToken) + { + IReadOnlyList users = await repository.GetAllAsync(cancellationToken); + return Result.Success(users); + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/IWelcomedUserRepository.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/IWelcomedUserRepository.cs new file mode 100644 index 0000000..f8e7518 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/IWelcomedUserRepository.cs @@ -0,0 +1,14 @@ +namespace Mccn.Modules.Hello.Application.WelcomedUsers; + +public interface IWelcomedUserRepository +{ + Task AddAsync( + Guid userId, + string email, + string firstName, + string lastName, + DateTime welcomedAt, + CancellationToken cancellationToken = default); + + Task> GetAllAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/UserRegisteredIntegrationEventHandler.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/UserRegisteredIntegrationEventHandler.cs new file mode 100644 index 0000000..c7c0f3e --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/UserRegisteredIntegrationEventHandler.cs @@ -0,0 +1,26 @@ +using Mccn.Common.Application.EventBus; +using Mccn.Modules.Hello.Application.Abstractions; +using Mccn.Modules.Users.IntegrationEvents; + +namespace Mccn.Modules.Hello.Application.WelcomedUsers; + +public sealed class UserRegisteredIntegrationEventHandler( + IWelcomedUserRepository repository, + IUnitOfWork unitOfWork) + : IntegrationEventHandler +{ + public override async Task Handle( + UserRegisteredIntegrationEvent integrationEvent, + CancellationToken cancellationToken) + { + await repository.AddAsync( + integrationEvent.UserId, + integrationEvent.Email, + integrationEvent.FirstName, + integrationEvent.LastName, + integrationEvent.OccurredOnUtc, + cancellationToken); + + await unitOfWork.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/WelcomedUserResponse.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/WelcomedUserResponse.cs new file mode 100644 index 0000000..c8cb945 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/WelcomedUserResponse.cs @@ -0,0 +1,8 @@ +namespace Mccn.Modules.Hello.Application.WelcomedUsers; + +public sealed record WelcomedUserResponse( + Guid UserId, + string Email, + string FirstName, + string LastName, + DateTime WelcomedAt); diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConfiguration.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConfiguration.cs new file mode 100644 index 0000000..af8d37e --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConfiguration.cs @@ -0,0 +1,16 @@ +using Mccn.Common.Infrastructure.Inbox; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Mccn.Modules.Hello.Infrastructure.Database.Configurations; + +internal sealed class InboxMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("inbox_messages"); + builder.HasKey(i => i.Id); + builder.Property(i => i.Type).IsRequired(); + builder.Property(i => i.Content).IsRequired(); + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConsumerConfiguration.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConsumerConfiguration.cs new file mode 100644 index 0000000..2b29ecf --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConsumerConfiguration.cs @@ -0,0 +1,14 @@ +using Mccn.Common.Infrastructure.Inbox; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Mccn.Modules.Hello.Infrastructure.Database.Configurations; + +internal sealed class InboxMessageConsumerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("inbox_message_consumers"); + builder.HasKey(c => new { c.InboxMessageId, c.Name }); + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/WelcomedUserConfiguration.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/WelcomedUserConfiguration.cs new file mode 100644 index 0000000..58a8617 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/WelcomedUserConfiguration.cs @@ -0,0 +1,17 @@ +using Mccn.Modules.Hello.Infrastructure.Database.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Mccn.Modules.Hello.Infrastructure.Database.Configurations; + +internal sealed class WelcomedUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("welcomed_users"); + builder.HasKey(w => w.Id); + builder.Property(w => w.Email).IsRequired(); + builder.Property(w => w.FirstName).IsRequired(); + builder.Property(w => w.LastName).IsRequired(); + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/HelloDbContext.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/HelloDbContext.cs new file mode 100644 index 0000000..15aac48 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/HelloDbContext.cs @@ -0,0 +1,23 @@ +using Mccn.Common.Infrastructure.Inbox; +using Mccn.Modules.Hello.Infrastructure.Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Mccn.Modules.Hello.Infrastructure.Database; + +public sealed class HelloDbContext : DbContext +{ + public HelloDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet WelcomedUsers { get; set; } + public DbSet InboxMessages { get; set; } + public DbSet InboxMessageConsumers { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(Schemas.Hello); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(HelloDbContext).Assembly); + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Models/WelcomedUser.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Models/WelcomedUser.cs new file mode 100644 index 0000000..464ea72 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Models/WelcomedUser.cs @@ -0,0 +1,11 @@ +namespace Mccn.Modules.Hello.Infrastructure.Database.Models; + +public sealed class WelcomedUser +{ + public Guid Id { get; init; } + public Guid UserId { get; init; } + public string Email { get; init; } = string.Empty; + public string FirstName { get; init; } = string.Empty; + public string LastName { get; init; } = string.Empty; + public DateTime WelcomedAt { get; init; } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Schemas.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Schemas.cs new file mode 100644 index 0000000..b4fde24 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Schemas.cs @@ -0,0 +1,6 @@ +namespace Mccn.Modules.Hello.Infrastructure.Database; + +internal static class Schemas +{ + internal const string Hello = "hello"; +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs index 60833ce..a31597d 100644 --- a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs @@ -1,16 +1,54 @@ +using MassTransit; +using Mccn.Common.Application.EventBus; +using Mccn.Modules.Hello.Application.Abstractions; +using Mccn.Modules.Hello.Application.WelcomedUsers; +using Mccn.Modules.Hello.Infrastructure.Database; +using Mccn.Modules.Hello.Infrastructure.Inbox; +using Mccn.Modules.Hello.Infrastructure.WelcomedUsers; +using Mccn.Modules.Users.IntegrationEvents; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Quartz; namespace Mccn.Modules.Hello.Infrastructure; public static class DependencyInjection { + public static void ConfigureConsumers(IRegistrationConfigurator configurator) + { + configurator.AddConsumer>(); + } + public static IServiceCollection AddHelloModule( this IServiceCollection services, IConfiguration configuration) { - // Hello module has no database/infrastructure dependencies. - // This is where you'd add them when the module grows. + services.AddDbContext(options => + { + options.UseNpgsql( + configuration.GetConnectionString("Database"), + o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Hello)) + .UseSnakeCaseNamingConvention(); + }); + + services.AddScoped(); + services.AddScoped(); + + // Register integration event handlers so ProcessInboxJob can resolve them. + services.AddScoped< + IIntegrationEventHandler, + UserRegisteredIntegrationEventHandler>(); + + services.AddQuartz(q => + { + var jobKey = new JobKey(nameof(ProcessInboxJob)); + q.AddJob(opts => opts.WithIdentity(jobKey)); + q.AddTrigger(opts => opts + .ForJob(jobKey) + .WithSimpleSchedule(s => s.WithIntervalInSeconds(10).RepeatForever())); + }); + return services; } } \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/IntegrationEventConsumer.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/IntegrationEventConsumer.cs new file mode 100644 index 0000000..5c45364 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/IntegrationEventConsumer.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using MassTransit; +using Mccn.Common.Application.EventBus; +using Mccn.Common.Infrastructure.Inbox; +using Mccn.Modules.Hello.Infrastructure.Database; + +namespace Mccn.Modules.Hello.Infrastructure.Inbox; + +internal sealed class IntegrationEventConsumer(HelloDbContext dbContext) + : IConsumer + where TIntegrationEvent : class, IIntegrationEvent +{ + public async Task Consume(ConsumeContext context) + { + TIntegrationEvent integrationEvent = context.Message; + + var inboxMessage = new InboxMessage + { + Id = integrationEvent.Id, + Type = integrationEvent.GetType().Name, + Content = JsonSerializer.Serialize(integrationEvent, integrationEvent.GetType()), + OccurredOnUtc = integrationEvent.OccurredOnUtc + }; + + await dbContext.InboxMessages.AddAsync(inboxMessage, context.CancellationToken); + await dbContext.SaveChangesAsync(context.CancellationToken); + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/ProcessInboxJob.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/ProcessInboxJob.cs new file mode 100644 index 0000000..fa96387 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/ProcessInboxJob.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using System.Text.Json; +using Mccn.Common.Application.EventBus; +using Mccn.Common.Infrastructure.Inbox; +using Mccn.Modules.Hello.Application.WelcomedUsers; +using Mccn.Modules.Hello.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Quartz; + +namespace Mccn.Modules.Hello.Infrastructure.Inbox; + +[DisallowConcurrentExecution] +internal sealed class ProcessInboxJob( + HelloDbContext dbContext, + IServiceScopeFactory serviceScopeFactory, + ILogger logger) : IJob +{ + // Scan the Application assembly for integration event handler types. + private static readonly Assembly ApplicationAssembly = + typeof(UserRegisteredIntegrationEventHandler).Assembly; + + public async Task Execute(IJobExecutionContext context) + { + List messages = await dbContext.InboxMessages + .Where(m => m.ProcessedOnUtc == null) + .OrderBy(m => m.OccurredOnUtc) + .Take(20) + .ToListAsync(context.CancellationToken); + + foreach (InboxMessage message in messages) + { + try + { + Type? eventType = ApplicationAssembly.GetReferencedAssemblies() + .Select(Assembly.Load) + .Concat([ApplicationAssembly]) + .SelectMany(a => a.GetTypes()) + .FirstOrDefault(t => t.Name == message.Type); + + if (eventType is null) + { + logger.LogWarning("Could not find integration event type {Type}", message.Type); + message.ProcessedOnUtc = DateTime.UtcNow; + message.Error = $"Unknown integration event type: {message.Type}"; + continue; + } + + IIntegrationEvent? integrationEvent = + (IIntegrationEvent?)JsonSerializer.Deserialize(message.Content, eventType); + + if (integrationEvent is null) + { + message.ProcessedOnUtc = DateTime.UtcNow; + message.Error = "Failed to deserialize integration event."; + continue; + } + + using IServiceScope scope = serviceScopeFactory.CreateScope(); + + Type handlerInterfaceType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType); + + IEnumerable handlers = scope.ServiceProvider + .GetServices(handlerInterfaceType) + .Cast(); + + foreach (IIntegrationEventHandler handler in handlers) + { + string consumerName = handler.GetType().Name; + + bool alreadyProcessed = await dbContext.InboxMessageConsumers + .AnyAsync( + c => c.InboxMessageId == message.Id && c.Name == consumerName, + context.CancellationToken); + + if (alreadyProcessed) + continue; + + await handler.Handle(integrationEvent, context.CancellationToken); + + dbContext.InboxMessageConsumers.Add(new InboxMessageConsumer + { + InboxMessageId = message.Id, + Name = consumerName + }); + + await dbContext.SaveChangesAsync(context.CancellationToken); + } + + message.ProcessedOnUtc = DateTime.UtcNow; + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing inbox message {MessageId}", message.Id); + message.Error = ex.Message; + message.ProcessedOnUtc = DateTime.UtcNow; + } + } + + await dbContext.SaveChangesAsync(context.CancellationToken); + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj index 8edd226..053687b 100644 --- a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj @@ -5,6 +5,12 @@ + + + + + + net10.0 enable diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.Designer.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.Designer.cs new file mode 100644 index 0000000..fc7a701 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.Designer.cs @@ -0,0 +1,118 @@ +// +using System; +using Mccn.Modules.Hello.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mccn.Modules.Hello.Infrastructure.Migrations +{ + [DbContext(typeof(HelloDbContext))] + [Migration("20260315153129_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hello") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mccn.Common.Infrastructure.Inbox.InboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_inbox_messages"); + + b.ToTable("inbox_messages", "hello"); + }); + + modelBuilder.Entity("Mccn.Common.Infrastructure.Inbox.InboxMessageConsumer", b => + { + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("InboxMessageId", "Name") + .HasName("pk_inbox_message_consumers"); + + b.ToTable("inbox_message_consumers", "hello"); + }); + + modelBuilder.Entity("Mccn.Modules.Hello.Infrastructure.Database.Models.WelcomedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WelcomedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("welcomed_at"); + + b.HasKey("Id") + .HasName("pk_welcomed_users"); + + b.ToTable("welcomed_users", "hello"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.cs new file mode 100644 index 0000000..b3cc652 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Mccn.Modules.Hello.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "hello"); + + migrationBuilder.CreateTable( + name: "inbox_message_consumers", + schema: "hello", + columns: table => new + { + inbox_message_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_inbox_message_consumers", x => new { x.inbox_message_id, x.name }); + }); + + migrationBuilder.CreateTable( + name: "inbox_messages", + schema: "hello", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "text", nullable: false), + content = table.Column(type: "text", nullable: false), + occurred_on_utc = table.Column(type: "timestamp with time zone", nullable: false), + processed_on_utc = table.Column(type: "timestamp with time zone", nullable: true), + error = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_inbox_messages", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "welcomed_users", + schema: "hello", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "text", nullable: false), + first_name = table.Column(type: "text", nullable: false), + last_name = table.Column(type: "text", nullable: false), + welcomed_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_welcomed_users", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "inbox_message_consumers", + schema: "hello"); + + migrationBuilder.DropTable( + name: "inbox_messages", + schema: "hello"); + + migrationBuilder.DropTable( + name: "welcomed_users", + schema: "hello"); + } + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/HelloDbContextModelSnapshot.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/HelloDbContextModelSnapshot.cs new file mode 100644 index 0000000..8cc4e5f --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/HelloDbContextModelSnapshot.cs @@ -0,0 +1,115 @@ +// +using System; +using Mccn.Modules.Hello.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mccn.Modules.Hello.Infrastructure.Migrations +{ + [DbContext(typeof(HelloDbContext))] + partial class HelloDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("hello") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mccn.Common.Infrastructure.Inbox.InboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_inbox_messages"); + + b.ToTable("inbox_messages", "hello"); + }); + + modelBuilder.Entity("Mccn.Common.Infrastructure.Inbox.InboxMessageConsumer", b => + { + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("InboxMessageId", "Name") + .HasName("pk_inbox_message_consumers"); + + b.ToTable("inbox_message_consumers", "hello"); + }); + + modelBuilder.Entity("Mccn.Modules.Hello.Infrastructure.Database.Models.WelcomedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WelcomedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("welcomed_at"); + + b.HasKey("Id") + .HasName("pk_welcomed_users"); + + b.ToTable("welcomed_users", "hello"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/UnitOfWork.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/UnitOfWork.cs new file mode 100644 index 0000000..92d745b --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/UnitOfWork.cs @@ -0,0 +1,10 @@ +using Mccn.Modules.Hello.Application.Abstractions; +using Mccn.Modules.Hello.Infrastructure.Database; + +namespace Mccn.Modules.Hello.Infrastructure.WelcomedUsers; + +internal sealed class UnitOfWork(HelloDbContext dbContext) : IUnitOfWork +{ + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) => + await dbContext.SaveChangesAsync(cancellationToken); +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/WelcomedUserRepository.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/WelcomedUserRepository.cs new file mode 100644 index 0000000..08cc868 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/WelcomedUserRepository.cs @@ -0,0 +1,44 @@ +using Mccn.Modules.Hello.Application.WelcomedUsers; +using Mccn.Modules.Hello.Infrastructure.Database; +using Mccn.Modules.Hello.Infrastructure.Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Mccn.Modules.Hello.Infrastructure.WelcomedUsers; + +internal sealed class WelcomedUserRepository(HelloDbContext dbContext) : IWelcomedUserRepository +{ + public async Task AddAsync( + Guid userId, + string email, + string firstName, + string lastName, + DateTime welcomedAt, + CancellationToken cancellationToken = default) + { + var welcomedUser = new WelcomedUser + { + Id = Guid.NewGuid(), + UserId = userId, + Email = email, + FirstName = firstName, + LastName = lastName, + WelcomedAt = welcomedAt + }; + + await dbContext.WelcomedUsers.AddAsync(welcomedUser, cancellationToken); + } + + public async Task> GetAllAsync( + CancellationToken cancellationToken = default) + { + return await dbContext.WelcomedUsers + .OrderByDescending(w => w.WelcomedAt) + .Select(w => new WelcomedUserResponse( + w.UserId, + w.Email, + w.FirstName, + w.LastName, + w.WelcomedAt)) + .ToListAsync(cancellationToken); + } +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetWelcomedUsers.cs b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetWelcomedUsers.cs new file mode 100644 index 0000000..cbbb839 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetWelcomedUsers.cs @@ -0,0 +1,25 @@ +using Mccn.Common.Domain.Abstractions; +using Mccn.Common.Presentation.Endpoints; +using Mccn.Modules.Hello.Application.WelcomedUsers; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Mccn.Modules.Hello.Presentation.Hello; + +internal sealed class GetWelcomedUsers : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("hello/welcomed-users", async (ISender sender) => + { + Result> result = + await sender.Send(new GetWelcomedUsersQuery()); + + return Results.Ok(result.Value); + }) + .WithTags("Hello") + .AllowAnonymous(); + } +} diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj b/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj index 6cefc33..0200c41 100644 --- a/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj @@ -2,6 +2,7 @@ + diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/UserRegisteredDomainEventHandler.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/UserRegisteredDomainEventHandler.cs new file mode 100644 index 0000000..7097884 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/UserRegisteredDomainEventHandler.cs @@ -0,0 +1,22 @@ +using Mccn.Common.Application.EventBus; +using Mccn.Modules.Users.Domain.Users; +using Mccn.Modules.Users.IntegrationEvents; + +namespace Mccn.Modules.Users.Application.Users.Register; + +internal sealed class UserRegisteredDomainEventHandler(IEventBus eventBus) + : DomainEventHandler +{ + public override async Task Handle( + UserRegisteredDomainEvent domainEvent, + CancellationToken cancellationToken) + { + await eventBus.PublishAsync(new UserRegisteredIntegrationEvent( + domainEvent.Id, + domainEvent.OccurredOnUtc, + domainEvent.UserId, + domainEvent.Email, + domainEvent.FirstName, + domainEvent.LastName), cancellationToken); + } +} diff --git a/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs index 2210280..f4af6b7 100644 --- a/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs +++ b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs @@ -20,7 +20,7 @@ public sealed class User : Entity string lastName, string identityId) { - return new User + var user = new User { Id = id, Email = email, @@ -28,5 +28,15 @@ public sealed class User : Entity LastName = lastName, IdentityId = identityId }; + + user.RaiseDomainEvent(new UserRegisteredDomainEvent( + Guid.NewGuid(), + DateTime.UtcNow, + user.Id, + user.Email, + user.FirstName, + user.LastName)); + + return user; } } \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs new file mode 100644 index 0000000..4162ebf --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs @@ -0,0 +1,11 @@ +using Mccn.Common.Domain.Abstractions; + +namespace Mccn.Modules.Users.Domain.Users; + +public sealed record UserRegisteredDomainEvent( + Guid Id, + DateTime OccurredOnUtc, + Guid UserId, + string Email, + string FirstName, + string LastName) : DomainEvent(Id, OccurredOnUtc); diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Configurations/OutboxMessageConfiguration.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Configurations/OutboxMessageConfiguration.cs new file mode 100644 index 0000000..b0e8685 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Configurations/OutboxMessageConfiguration.cs @@ -0,0 +1,16 @@ +using Mccn.Common.Infrastructure.Outbox; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Mccn.Modules.Users.Infrastructure.Database.Configurations; + +internal sealed class OutboxMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("outbox_messages"); + builder.HasKey(o => o.Id); + builder.Property(o => o.Type).IsRequired(); + builder.Property(o => o.Content).IsRequired(); + } +} diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.Designer.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.Designer.cs new file mode 100644 index 0000000..5914678 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.Designer.cs @@ -0,0 +1,111 @@ +// +using System; +using Mccn.Modules.Users.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mccn.Modules.Users.Infrastructure.Database.Migrations +{ + [DbContext(typeof(UsersDbContext))] + [Migration("20260315153115_AddOutboxMessages")] + partial class AddOutboxMessages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("users") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mccn.Common.Infrastructure.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.ToTable("outbox_messages", "users"); + }); + + modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("IdentityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("identity_id"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("IdentityId") + .IsUnique() + .HasDatabaseName("ix_users_identity_id"); + + b.ToTable("users", "users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.cs new file mode 100644 index 0000000..525c162 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Mccn.Modules.Users.Infrastructure.Database.Migrations +{ + /// + public partial class AddOutboxMessages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "outbox_messages", + schema: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "text", nullable: false), + content = table.Column(type: "text", nullable: false), + occurred_on_utc = table.Column(type: "timestamp with time zone", nullable: false), + processed_on_utc = table.Column(type: "timestamp with time zone", nullable: true), + error = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_outbox_messages", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "outbox_messages", + schema: "users"); + } + } +} diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs index 22ac259..a3cf7b7 100644 --- a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs @@ -18,11 +18,46 @@ namespace Mccn.Modules.Users.Infrastructure.Database.Migrations #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("users") - .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("ProductVersion", "10.0.4") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Mccn.Common.Infrastructure.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.ToTable("outbox_messages", "users"); + }); + modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b => { b.Property("Id") diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs index a298e89..2b86e46 100644 --- a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs @@ -1,16 +1,13 @@ +using Mccn.Common.Infrastructure.Outbox; using Mccn.Modules.Users.Domain.Users; using Microsoft.EntityFrameworkCore; namespace Mccn.Modules.Users.Infrastructure.Database; -public sealed class UsersDbContext : DbContext +public sealed class UsersDbContext(DbContextOptions options) : DbContext(options) { - public UsersDbContext(DbContextOptions options) - : base(options) - { - } - public DbSet Users { get; set; } + public DbSet OutboxMessages { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs index ab44d8d..1a274b1 100644 --- a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs @@ -1,26 +1,38 @@ +using MassTransit; +using Mccn.Common.Infrastructure.Outbox; using Mccn.Modules.Users.Application.Abstractions; using Mccn.Modules.Users.Domain.Users; using Mccn.Modules.Users.Infrastructure.Database; using Mccn.Modules.Users.Infrastructure.Keycloak; +using Mccn.Modules.Users.Infrastructure.Outbox; using Mccn.Modules.Users.Infrastructure.Users; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Quartz; namespace Mccn.Modules.Users.Infrastructure; public static class DependencyInjection { + public static void ConfigureConsumers(IRegistrationConfigurator configurator) + { + // The Users module publishes events but does not consume any. + } + public static IServiceCollection AddUsersModule( this IServiceCollection services, IConfiguration configuration) { - services.AddDbContext(options => + services.AddSingleton(); + + services.AddDbContext((sp, options) => { options.UseNpgsql( configuration.GetConnectionString("Database"), o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users)) - .UseSnakeCaseNamingConvention(); + .UseSnakeCaseNamingConvention() + .AddInterceptors(sp.GetRequiredService()); }); services.Configure( @@ -40,6 +52,15 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); + services.AddQuartz(q => + { + var jobKey = new JobKey(nameof(ProcessOutboxJob)); + q.AddJob(opts => opts.WithIdentity(jobKey)); + q.AddTrigger(opts => opts + .ForJob(jobKey) + .WithSimpleSchedule(s => s.WithIntervalInSeconds(10).RepeatForever())); + }); + return services; } } \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs new file mode 100644 index 0000000..3a82824 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using System.Text.Json; +using Mccn.Common.Application.EventBus; +using Mccn.Common.Domain.Abstractions; +using Mccn.Common.Infrastructure.Outbox; +using Mccn.Modules.Users.Domain.Users; +using Mccn.Modules.Users.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Quartz; + +namespace Mccn.Modules.Users.Infrastructure.Outbox; + +[DisallowConcurrentExecution] +internal sealed class ProcessOutboxJob( + UsersDbContext dbContext, + IServiceScopeFactory serviceScopeFactory, + ILogger logger) : IJob +{ + private static readonly Assembly DomainAssembly = typeof(User).Assembly; + + public async Task Execute(IJobExecutionContext context) + { + List messages = await dbContext.OutboxMessages + .Where(m => m.ProcessedOnUtc == null) + .OrderBy(m => m.OccurredOnUtc) + .Take(20) + .ToListAsync(context.CancellationToken); + + foreach (OutboxMessage message in messages) + { + try + { + Type? eventType = DomainAssembly.GetTypes() + .FirstOrDefault(t => t.Name == message.Type); + + if (eventType is null) + { + logger.LogWarning("Could not find domain event type {Type}", message.Type); + message.ProcessedOnUtc = DateTime.UtcNow; + message.Error = $"Unknown domain event type: {message.Type}"; + continue; + } + + IDomainEvent? domainEvent = (IDomainEvent?)JsonSerializer.Deserialize(message.Content, eventType); + + if (domainEvent is null) + { + message.ProcessedOnUtc = DateTime.UtcNow; + message.Error = "Failed to deserialize domain event."; + continue; + } + + using IServiceScope scope = serviceScopeFactory.CreateScope(); + + Type handlerInterfaceType = typeof(IDomainEventHandler<>).MakeGenericType(eventType); + + IEnumerable handlers = scope.ServiceProvider + .GetServices(handlerInterfaceType) + .Cast(); + + foreach (IDomainEventHandler handler in handlers) + await handler.Handle(domainEvent, context.CancellationToken); + + message.ProcessedOnUtc = DateTime.UtcNow; + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing outbox message {MessageId}", message.Id); + message.Error = ex.Message; + message.ProcessedOnUtc = DateTime.UtcNow; + } + } + + await dbContext.SaveChangesAsync(context.CancellationToken); + } +} diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj b/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj new file mode 100644 index 0000000..a322519 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs b/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs new file mode 100644 index 0000000..2ecc470 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs @@ -0,0 +1,11 @@ +using Mccn.Common.Application.EventBus; + +namespace Mccn.Modules.Users.IntegrationEvents; + +public sealed record UserRegisteredIntegrationEvent( + Guid Id, + DateTime OccurredOnUtc, + Guid UserId, + string Email, + string FirstName, + string LastName) : IntegrationEvent(Id, OccurredOnUtc); diff --git a/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs b/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs index 8997951..a0976d5 100644 --- a/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs +++ b/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs @@ -4,4 +4,23 @@ public abstract class BaseTest { protected const string UsersNamespace = "Mccn.Modules.Users"; protected const string HelloNamespace = "Mccn.Modules.Hello"; + + // Implementation namespaces checked for cross-module isolation. + // *.IntegrationEvents is excluded because it is a public contract assembly + // that other modules are explicitly allowed to reference. + protected static readonly string[] UsersImplementationNamespaces = + [ + "Mccn.Modules.Users.Domain", + "Mccn.Modules.Users.Application", + "Mccn.Modules.Users.Infrastructure", + "Mccn.Modules.Users.Presentation" + ]; + + protected static readonly string[] HelloImplementationNamespaces = + [ + "Mccn.Modules.Hello.Domain", + "Mccn.Modules.Hello.Application", + "Mccn.Modules.Hello.Infrastructure", + "Mccn.Modules.Hello.Presentation" + ]; } \ No newline at end of file diff --git a/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs b/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs index 281bb1f..b02f6c8 100644 --- a/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs +++ b/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs @@ -20,11 +20,16 @@ public class ModuleTests : BaseTest typeof(DependencyInjection).Assembly ]; - Types.InAssemblies(usersAssemblies) - .Should() - .NotHaveDependencyOn(HelloNamespace) - .GetResult() - .ShouldBeSuccessful(); + // Check each Hello implementation namespace individually. + // Hello.IntegrationEvents would be an allowed reference (public contract), but Hello has none. + foreach (string ns in HelloImplementationNamespaces) + { + Types.InAssemblies(usersAssemblies) + .Should() + .NotHaveDependencyOn(ns) + .GetResult() + .ShouldBeSuccessful(); + } } [Fact] @@ -37,10 +42,16 @@ public class ModuleTests : BaseTest typeof(Modules.Hello.Infrastructure.DependencyInjection).Assembly ]; - Types.InAssemblies(helloAssemblies) - .Should() - .NotHaveDependencyOn(UsersNamespace) - .GetResult() - .ShouldBeSuccessful(); + // Check each Users implementation namespace individually. + // Users.IntegrationEvents is explicitly excluded as it is a public contract + // that other modules are permitted to reference. + foreach (string ns in UsersImplementationNamespaces) + { + Types.InAssemblies(helloAssemblies) + .Should() + .NotHaveDependencyOn(ns) + .GetResult() + .ShouldBeSuccessful(); + } } } \ No newline at end of file