outbox checkpoint
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
45
Mccn.slnx
45
Mccn.slnx
@@ -1,36 +1,33 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/"/>
|
||||
<Folder Name="/src/" />
|
||||
<Folder Name="/src/API/">
|
||||
<Project Path="src/API/Mccn.Api/Mccn.Api.csproj"/>
|
||||
<Project Path="src/API/Mccn.Api/Mccn.Api.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Common/">
|
||||
<Project Path="src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj"/>
|
||||
<Project Path="src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj"/>
|
||||
<Project Path="src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj"/>
|
||||
<Project Path="src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj"/>
|
||||
<Project Path="src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj" />
|
||||
<Project Path="src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj" />
|
||||
<Project Path="src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj" />
|
||||
<Project Path="src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Modules/"/>
|
||||
<Folder Name="/src/Modules/" />
|
||||
<Folder Name="/src/Modules/Hello/">
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj"/>
|
||||
<Project
|
||||
Path="src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj"/>
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj"/>
|
||||
<Project
|
||||
Path="src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj"/>
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj"/>
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj" />
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj" />
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj" />
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj" />
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Modules/Users/">
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj"/>
|
||||
<Project
|
||||
Path="src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj"/>
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj"/>
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj"/>
|
||||
<Project
|
||||
Path="src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj"/>
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj"/>
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj"/>
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj" />
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj" />
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj" />
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj" />
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj" />
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj" />
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj" />
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/test/">
|
||||
<Project Path="test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj"/>
|
||||
<Project Path="test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<UsersDbContext>(scope.ServiceProvider);
|
||||
await MigrateAsync<HelloDbContext>(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
private static async Task MigrateAsync<TContext>(IServiceProvider serviceProvider) where TContext : DbContext
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
|
||||
namespace Mccn.Common.Application.EventBus;
|
||||
|
||||
public abstract class DomainEventHandler<TDomainEvent> : IDomainEventHandler<TDomainEvent>
|
||||
where TDomainEvent : IDomainEvent
|
||||
{
|
||||
public abstract Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken);
|
||||
|
||||
Task IDomainEventHandler.Handle(IDomainEvent domainEvent, CancellationToken cancellationToken) =>
|
||||
Handle((TDomainEvent)domainEvent, cancellationToken);
|
||||
}
|
||||
@@ -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<in TDomainEvent> : IDomainEventHandler
|
||||
where TDomainEvent : IDomainEvent
|
||||
{
|
||||
Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
7
src/Common/Mccn.Common.Application/EventBus/IEventBus.cs
Normal file
7
src/Common/Mccn.Common.Application/EventBus/IEventBus.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Mccn.Common.Application.EventBus;
|
||||
|
||||
public interface IEventBus
|
||||
{
|
||||
Task PublishAsync<T>(T integrationEvent, CancellationToken cancellationToken = default)
|
||||
where T : IIntegrationEvent;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Mccn.Common.Application.EventBus;
|
||||
|
||||
public interface IIntegrationEvent
|
||||
{
|
||||
Guid Id { get; }
|
||||
DateTime OccurredOnUtc { get; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Mccn.Common.Application.EventBus;
|
||||
|
||||
public interface IIntegrationEventHandler
|
||||
{
|
||||
Task Handle(IIntegrationEvent integrationEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IIntegrationEventHandler<in TIntegrationEvent> : IIntegrationEventHandler
|
||||
where TIntegrationEvent : IIntegrationEvent
|
||||
{
|
||||
Task Handle(TIntegrationEvent integrationEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Mccn.Common.Application.EventBus;
|
||||
|
||||
public abstract record IntegrationEvent(Guid Id, DateTime OccurredOnUtc) : IIntegrationEvent;
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Mccn.Common.Application.EventBus;
|
||||
|
||||
public abstract class IntegrationEventHandler<TIntegrationEvent> : IIntegrationEventHandler<TIntegrationEvent>
|
||||
where TIntegrationEvent : IIntegrationEvent
|
||||
{
|
||||
public abstract Task Handle(TIntegrationEvent integrationEvent, CancellationToken cancellationToken);
|
||||
|
||||
Task IIntegrationEventHandler.Handle(IIntegrationEvent integrationEvent, CancellationToken cancellationToken) =>
|
||||
Handle((TIntegrationEvent)integrationEvent, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Mccn.Common.Domain.Abstractions;
|
||||
|
||||
public abstract record DomainEvent(Guid Id, DateTime OccurredOnUtc) : IDomainEvent;
|
||||
@@ -2,6 +2,8 @@ namespace Mccn.Common.Domain.Abstractions;
|
||||
|
||||
public abstract class Entity
|
||||
{
|
||||
private readonly List<IDomainEvent> _domainEvents = [];
|
||||
|
||||
protected Entity(Guid id)
|
||||
{
|
||||
Id = id;
|
||||
@@ -12,4 +14,10 @@ public abstract class Entity
|
||||
}
|
||||
|
||||
public Guid Id { get; init; }
|
||||
|
||||
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
|
||||
|
||||
protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
|
||||
|
||||
public void ClearDomainEvents() => _domainEvents.Clear();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Mccn.Common.Domain.Abstractions;
|
||||
|
||||
public interface IDomainEvent
|
||||
{
|
||||
Guid Id { get; }
|
||||
DateTime OccurredOnUtc { get; }
|
||||
}
|
||||
@@ -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<IRegistrationConfigurator>[] moduleConsumers)
|
||||
{
|
||||
services.AddJwtAuthentication(configuration);
|
||||
|
||||
@@ -26,6 +31,24 @@ public static class DependencyInjection
|
||||
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddMassTransit(configure =>
|
||||
{
|
||||
foreach (Action<IRegistrationConfigurator> configureConsumers in moduleConsumers)
|
||||
configureConsumers(configure);
|
||||
|
||||
configure.SetKebabCaseEndpointNameFormatter();
|
||||
configure.UsingRabbitMq((context, cfg) =>
|
||||
{
|
||||
cfg.Host(configuration.GetConnectionString("MessageBroker"));
|
||||
cfg.ConfigureEndpoints(context);
|
||||
});
|
||||
});
|
||||
|
||||
services.AddScoped<IEventBus, EventBus.EventBus>();
|
||||
|
||||
services.AddQuartz();
|
||||
services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
13
src/Common/Mccn.Common.Infrastructure/EventBus/EventBus.cs
Normal file
13
src/Common/Mccn.Common.Infrastructure/EventBus/EventBus.cs
Normal file
@@ -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>(T integrationEvent, CancellationToken cancellationToken = default)
|
||||
where T : IIntegrationEvent
|
||||
{
|
||||
await bus.Publish(integrationEvent, cancellationToken);
|
||||
}
|
||||
}
|
||||
11
src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessage.cs
Normal file
11
src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessage.cs
Normal file
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,12 +10,16 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.3.7"/>
|
||||
<PackageReference Include="MassTransit.RabbitMQ" Version="8.3.7"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0"/>
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -19,6 +19,7 @@ public static class ObservabilityExtensions
|
||||
tracing
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddSource("MassTransit")
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(
|
||||
|
||||
@@ -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<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData,
|
||||
InterceptionResult<int> 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<OutboxMessage> outboxMessages = context.ChangeTracker
|
||||
.Entries<Entity>()
|
||||
.Select(e => e.Entity)
|
||||
.SelectMany(entity =>
|
||||
{
|
||||
IReadOnlyList<IDomainEvent> 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<OutboxMessage>().AddRange(outboxMessages);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Mccn.Modules.Hello.Application.Abstractions;
|
||||
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||
<ProjectReference Include="..\..\Users\Mccn.Modules.Users.IntegrationEvents\Mccn.Modules.Users.IntegrationEvents.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using Mccn.Common.Application.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Hello.Application.WelcomedUsers;
|
||||
|
||||
public sealed record GetWelcomedUsersQuery : IQuery<IReadOnlyList<WelcomedUserResponse>>;
|
||||
@@ -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<GetWelcomedUsersQuery, IReadOnlyList<WelcomedUserResponse>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<WelcomedUserResponse>>> Handle(
|
||||
GetWelcomedUsersQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<WelcomedUserResponse> users = await repository.GetAllAsync(cancellationToken);
|
||||
return Result.Success(users);
|
||||
}
|
||||
}
|
||||
@@ -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<IReadOnlyList<WelcomedUserResponse>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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<UserRegisteredIntegrationEvent>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Mccn.Modules.Hello.Application.WelcomedUsers;
|
||||
|
||||
public sealed record WelcomedUserResponse(
|
||||
Guid UserId,
|
||||
string Email,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime WelcomedAt);
|
||||
@@ -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<InboxMessage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InboxMessage> builder)
|
||||
{
|
||||
builder.ToTable("inbox_messages");
|
||||
builder.HasKey(i => i.Id);
|
||||
builder.Property(i => i.Type).IsRequired();
|
||||
builder.Property(i => i.Content).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -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<InboxMessageConsumer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InboxMessageConsumer> builder)
|
||||
{
|
||||
builder.ToTable("inbox_message_consumers");
|
||||
builder.HasKey(c => new { c.InboxMessageId, c.Name });
|
||||
}
|
||||
}
|
||||
@@ -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<WelcomedUser>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WelcomedUser> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<HelloDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<WelcomedUser> WelcomedUsers { get; set; }
|
||||
public DbSet<InboxMessage> InboxMessages { get; set; }
|
||||
public DbSet<InboxMessageConsumer> InboxMessageConsumers { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(Schemas.Hello);
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(HelloDbContext).Assembly);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Mccn.Modules.Hello.Infrastructure.Database;
|
||||
|
||||
internal static class Schemas
|
||||
{
|
||||
internal const string Hello = "hello";
|
||||
}
|
||||
@@ -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<IntegrationEventConsumer<UserRegisteredIntegrationEvent>>();
|
||||
}
|
||||
|
||||
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<HelloDbContext>(options =>
|
||||
{
|
||||
options.UseNpgsql(
|
||||
configuration.GetConnectionString("Database"),
|
||||
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Hello))
|
||||
.UseSnakeCaseNamingConvention();
|
||||
});
|
||||
|
||||
services.AddScoped<IWelcomedUserRepository, WelcomedUserRepository>();
|
||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
|
||||
// Register integration event handlers so ProcessInboxJob can resolve them.
|
||||
services.AddScoped<
|
||||
IIntegrationEventHandler<UserRegisteredIntegrationEvent>,
|
||||
UserRegisteredIntegrationEventHandler>();
|
||||
|
||||
services.AddQuartz(q =>
|
||||
{
|
||||
var jobKey = new JobKey(nameof(ProcessInboxJob));
|
||||
q.AddJob<ProcessInboxJob>(opts => opts.WithIdentity(jobKey));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(jobKey)
|
||||
.WithSimpleSchedule(s => s.WithIntervalInSeconds(10).RepeatForever()));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -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<TIntegrationEvent>(HelloDbContext dbContext)
|
||||
: IConsumer<TIntegrationEvent>
|
||||
where TIntegrationEvent : class, IIntegrationEvent
|
||||
{
|
||||
public async Task Consume(ConsumeContext<TIntegrationEvent> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<ProcessInboxJob> 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<InboxMessage> 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<IIntegrationEventHandler> handlers = scope.ServiceProvider
|
||||
.GetServices(handlerInterfaceType)
|
||||
.Cast<IIntegrationEventHandler>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,12 @@
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
118
src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.Designer.cs
generated
Normal file
118
src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,118 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("error");
|
||||
|
||||
b.Property<DateTime>("OccurredOnUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_on_utc");
|
||||
|
||||
b.Property<DateTime?>("ProcessedOnUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_on_utc");
|
||||
|
||||
b.Property<string>("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<Guid>("InboxMessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("inbox_message_id");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<DateTime>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Mccn.Modules.Hello.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>(type: "uuid", nullable: false),
|
||||
name = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
|
||||
type = table.Column<string>(type: "text", nullable: false),
|
||||
content = table.Column<string>(type: "text", nullable: false),
|
||||
occurred_on_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
processed_on_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
error = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
email = table.Column<string>(type: "text", nullable: false),
|
||||
first_name = table.Column<string>(type: "text", nullable: false),
|
||||
last_name = table.Column<string>(type: "text", nullable: false),
|
||||
welcomed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_welcomed_users", x => x.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// <auto-generated />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("error");
|
||||
|
||||
b.Property<DateTime>("OccurredOnUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_on_utc");
|
||||
|
||||
b.Property<DateTime?>("ProcessedOnUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_on_utc");
|
||||
|
||||
b.Property<string>("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<Guid>("InboxMessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("inbox_message_id");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<DateTime>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<IReadOnlyList<WelcomedUserResponse>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<IReadOnlyList<WelcomedUserResponse>> result =
|
||||
await sender.Send(new GetWelcomedUsersQuery());
|
||||
|
||||
return Results.Ok(result.Value);
|
||||
})
|
||||
.WithTags("Hello")
|
||||
.AllowAnonymous();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj"/>
|
||||
<ProjectReference Include="..\Mccn.Modules.Users.IntegrationEvents\Mccn.Modules.Users.IntegrationEvents.csproj"/>
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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<UserRegisteredDomainEvent>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<OutboxMessage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OutboxMessage> builder)
|
||||
{
|
||||
builder.ToTable("outbox_messages");
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Type).IsRequired();
|
||||
builder.Property(o => o.Content).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("error");
|
||||
|
||||
b.Property<DateTime>("OccurredOnUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_on_utc");
|
||||
|
||||
b.Property<DateTime?>("ProcessedOnUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_on_utc");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("IdentityId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("identity_id");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOutboxMessages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox_messages",
|
||||
schema: "users",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
type = table.Column<string>(type: "text", nullable: false),
|
||||
content = table.Column<string>(type: "text", nullable: false),
|
||||
occurred_on_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
processed_on_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
error = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_outbox_messages", x => x.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox_messages",
|
||||
schema: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("error");
|
||||
|
||||
b.Property<DateTime>("OccurredOnUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_on_utc");
|
||||
|
||||
b.Property<DateTime?>("ProcessedOnUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_on_utc");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
|
||||
@@ -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<UsersDbContext> options) : DbContext(options)
|
||||
{
|
||||
public UsersDbContext(DbContextOptions<UsersDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<User> Users { get; set; }
|
||||
public DbSet<OutboxMessage> OutboxMessages { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -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<UsersDbContext>(options =>
|
||||
services.AddSingleton<InsertOutboxMessagesInterceptor>();
|
||||
|
||||
services.AddDbContext<UsersDbContext>((sp, options) =>
|
||||
{
|
||||
options.UseNpgsql(
|
||||
configuration.GetConnectionString("Database"),
|
||||
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users))
|
||||
.UseSnakeCaseNamingConvention();
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.AddInterceptors(sp.GetRequiredService<InsertOutboxMessagesInterceptor>());
|
||||
});
|
||||
|
||||
services.Configure<KeycloakOptions>(
|
||||
@@ -40,6 +52,15 @@ public static class DependencyInjection
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
|
||||
services.AddQuartz(q =>
|
||||
{
|
||||
var jobKey = new JobKey(nameof(ProcessOutboxJob));
|
||||
q.AddJob<ProcessOutboxJob>(opts => opts.WithIdentity(jobKey));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(jobKey)
|
||||
.WithSimpleSchedule(s => s.WithIntervalInSeconds(10).RepeatForever()));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -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<ProcessOutboxJob> logger) : IJob
|
||||
{
|
||||
private static readonly Assembly DomainAssembly = typeof(User).Assembly;
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
List<OutboxMessage> 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<IDomainEventHandler> handlers = scope.ServiceProvider
|
||||
.GetServices(handlerInterfaceType)
|
||||
.Cast<IDomainEventHandler>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user