outbox checkpoint
This commit is contained in:
@@ -70,10 +70,10 @@
|
|||||||
"username": "service-account-mccn-api",
|
"username": "service-account-mccn-api",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"serviceAccountClientId": "mccn-api",
|
"serviceAccountClientId": "mccn-api",
|
||||||
"clientRoleMappings": {
|
"clientRoles": {
|
||||||
"realm-management": [
|
"realm-management": [
|
||||||
{ "name": "manage-users" },
|
"manage-users",
|
||||||
{ "name": "view-users" }
|
"view-users"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
Mccn.slnx
13
Mccn.slnx
@@ -12,21 +12,18 @@
|
|||||||
<Folder Name="/src/Modules/" />
|
<Folder Name="/src/Modules/" />
|
||||||
<Folder Name="/src/Modules/Hello/">
|
<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.Application/Mccn.Modules.Hello.Application.csproj" />
|
||||||
<Project
|
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj" />
|
||||||
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.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj" />
|
||||||
<Project
|
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj" />
|
||||||
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.Presentation/Mccn.Modules.Hello.Presentation.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/src/Modules/Users/">
|
<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.Application/Mccn.Modules.Users.Application.csproj" />
|
||||||
<Project
|
<Project Path="src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj" />
|
||||||
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.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.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj" />
|
||||||
<Project
|
<Project Path="src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj" />
|
||||||
Path="src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.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.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.UnitTests/Mccn.Modules.Users.UnitTests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 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:
|
mccn.jaeger:
|
||||||
image: jaegertracing/all-in-one:latest
|
image: jaegertracing/all-in-one:latest
|
||||||
container_name: mccn.jaeger
|
container_name: mccn.jaeger
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Mccn.Modules.Hello.Infrastructure.Database;
|
||||||
using Mccn.Modules.Users.Infrastructure.Database;
|
using Mccn.Modules.Users.Infrastructure.Database;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ internal static class MigrationExtensions
|
|||||||
{
|
{
|
||||||
using IServiceScope scope = app.Services.CreateScope();
|
using IServiceScope scope = app.Services.CreateScope();
|
||||||
await MigrateAsync<UsersDbContext>(scope.ServiceProvider);
|
await MigrateAsync<UsersDbContext>(scope.ServiceProvider);
|
||||||
|
await MigrateAsync<HelloDbContext>(scope.ServiceProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task MigrateAsync<TContext>(IServiceProvider serviceProvider) where TContext : DbContext
|
private static async Task MigrateAsync<TContext>(IServiceProvider serviceProvider) where TContext : DbContext
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ POST {{baseAddress}}/users/register
|
|||||||
Content-Type: {{content_type}}
|
Content-Type: {{content_type}}
|
||||||
|
|
||||||
{
|
{
|
||||||
"email": "user@example.com",
|
"email": "andrej+test6@mccn.dev",
|
||||||
"firstName": "Jane",
|
"firstName": "Andrej",
|
||||||
"lastName": "Doe",
|
"lastName": "Jovanovic",
|
||||||
"password": "Test1234!@#$anin149141"
|
"password": "BestOfTheBest123"
|
||||||
}
|
}
|
||||||
|
|
||||||
### Get user profile (requires auth)
|
### Get user profile (requires auth)
|
||||||
@@ -68,3 +68,8 @@ GET {{baseAddress}}/hello?name=World
|
|||||||
### Say hello to the authenticated user (requires auth)
|
### Say hello to the authenticated user (requires auth)
|
||||||
GET {{baseAddress}}/hello/me
|
GET {{baseAddress}}/hello/me
|
||||||
Authorization: Bearer {{token}}
|
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.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
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(
|
builder.Services.AddApplication(
|
||||||
typeof(RegisterUserCommand).Assembly,
|
typeof(RegisterUserCommand).Assembly,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Mccn.Common.Application.Behaviors;
|
using Mccn.Common.Application.Behaviors;
|
||||||
|
using Mccn.Common.Application.EventBus;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
@@ -18,6 +19,24 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||||
|
|
||||||
|
RegisterDomainEventHandlers(services, moduleAssemblies);
|
||||||
|
|
||||||
return services;
|
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
|
public abstract class Entity
|
||||||
{
|
{
|
||||||
|
private readonly List<IDomainEvent> _domainEvents = [];
|
||||||
|
|
||||||
protected Entity(Guid id)
|
protected Entity(Guid id)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
@@ -12,4 +14,10 @@ public abstract class Entity
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id { get; init; }
|
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.Authentication;
|
||||||
using Mccn.Common.Infrastructure.Caching;
|
using Mccn.Common.Infrastructure.Caching;
|
||||||
|
using Mccn.Common.Infrastructure.EventBus;
|
||||||
using Mccn.Common.Infrastructure.ExceptionHandlers;
|
using Mccn.Common.Infrastructure.ExceptionHandlers;
|
||||||
using Mccn.Common.Infrastructure.Observability;
|
using Mccn.Common.Infrastructure.Observability;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
namespace Mccn.Common.Infrastructure;
|
namespace Mccn.Common.Infrastructure;
|
||||||
|
|
||||||
@@ -11,7 +15,8 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddInfrastructure(
|
public static IServiceCollection AddInfrastructure(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration,
|
||||||
|
params Action<IRegistrationConfigurator>[] moduleConsumers)
|
||||||
{
|
{
|
||||||
services.AddJwtAuthentication(configuration);
|
services.AddJwtAuthentication(configuration);
|
||||||
|
|
||||||
@@ -26,6 +31,24 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.AddHttpContextAccessor();
|
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;
|
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>
|
||||||
|
|
||||||
<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.AspNetCore.Authentication.JwtBearer" Version="10.0.5"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" 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.Exporter.OpenTelemetryProtocol" Version="1.15.0"/>
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" 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.AspNetCore" Version="1.15.1"/>
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0"/>
|
<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.AspNetCore" Version="10.0.0"/>
|
||||||
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
|
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class ObservabilityExtensions
|
|||||||
tracing
|
tracing
|
||||||
.AddAspNetCoreInstrumentation()
|
.AddAspNetCoreInstrumentation()
|
||||||
.AddHttpClientInstrumentation()
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddSource("MassTransit")
|
||||||
.AddOtlpExporter(options =>
|
.AddOtlpExporter(options =>
|
||||||
{
|
{
|
||||||
options.Endpoint = new Uri(
|
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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Users\Mccn.Modules.Users.IntegrationEvents\Mccn.Modules.Users.IntegrationEvents.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<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.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
namespace Mccn.Modules.Hello.Infrastructure;
|
namespace Mccn.Modules.Hello.Infrastructure;
|
||||||
|
|
||||||
public static class DependencyInjection
|
public static class DependencyInjection
|
||||||
{
|
{
|
||||||
|
public static void ConfigureConsumers(IRegistrationConfigurator configurator)
|
||||||
|
{
|
||||||
|
configurator.AddConsumer<IntegrationEventConsumer<UserRegisteredIntegrationEvent>>();
|
||||||
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddHelloModule(
|
public static IServiceCollection AddHelloModule(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Hello module has no database/infrastructure dependencies.
|
services.AddDbContext<HelloDbContext>(options =>
|
||||||
// This is where you'd add them when the module grows.
|
{
|
||||||
|
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;
|
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"/>
|
<ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj"/>
|
||||||
</ItemGroup>
|
</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>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj"/>
|
<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"/>
|
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||||
</ItemGroup>
|
</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 lastName,
|
||||||
string identityId)
|
string identityId)
|
||||||
{
|
{
|
||||||
return new User
|
var user = new User
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Email = email,
|
Email = email,
|
||||||
@@ -28,5 +28,15 @@ public sealed class User : Entity
|
|||||||
LastName = lastName,
|
LastName = lastName,
|
||||||
IdentityId = identityId
|
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
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasDefaultSchema("users")
|
.HasDefaultSchema("users")
|
||||||
.HasAnnotation("ProductVersion", "10.0.5")
|
.HasAnnotation("ProductVersion", "10.0.4")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
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 =>
|
modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
|
using Mccn.Common.Infrastructure.Outbox;
|
||||||
using Mccn.Modules.Users.Domain.Users;
|
using Mccn.Modules.Users.Domain.Users;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Mccn.Modules.Users.Infrastructure.Database;
|
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<User> Users { get; set; }
|
||||||
|
public DbSet<OutboxMessage> OutboxMessages { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
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.Application.Abstractions;
|
||||||
using Mccn.Modules.Users.Domain.Users;
|
using Mccn.Modules.Users.Domain.Users;
|
||||||
using Mccn.Modules.Users.Infrastructure.Database;
|
using Mccn.Modules.Users.Infrastructure.Database;
|
||||||
using Mccn.Modules.Users.Infrastructure.Keycloak;
|
using Mccn.Modules.Users.Infrastructure.Keycloak;
|
||||||
|
using Mccn.Modules.Users.Infrastructure.Outbox;
|
||||||
using Mccn.Modules.Users.Infrastructure.Users;
|
using Mccn.Modules.Users.Infrastructure.Users;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
namespace Mccn.Modules.Users.Infrastructure;
|
namespace Mccn.Modules.Users.Infrastructure;
|
||||||
|
|
||||||
public static class DependencyInjection
|
public static class DependencyInjection
|
||||||
{
|
{
|
||||||
|
public static void ConfigureConsumers(IRegistrationConfigurator configurator)
|
||||||
|
{
|
||||||
|
// The Users module publishes events but does not consume any.
|
||||||
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddUsersModule(
|
public static IServiceCollection AddUsersModule(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddDbContext<UsersDbContext>(options =>
|
services.AddSingleton<InsertOutboxMessagesInterceptor>();
|
||||||
|
|
||||||
|
services.AddDbContext<UsersDbContext>((sp, options) =>
|
||||||
{
|
{
|
||||||
options.UseNpgsql(
|
options.UseNpgsql(
|
||||||
configuration.GetConnectionString("Database"),
|
configuration.GetConnectionString("Database"),
|
||||||
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users))
|
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users))
|
||||||
.UseSnakeCaseNamingConvention();
|
.UseSnakeCaseNamingConvention()
|
||||||
|
.AddInterceptors(sp.GetRequiredService<InsertOutboxMessagesInterceptor>());
|
||||||
});
|
});
|
||||||
|
|
||||||
services.Configure<KeycloakOptions>(
|
services.Configure<KeycloakOptions>(
|
||||||
@@ -40,6 +52,15 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IUserRepository, UserRepository>();
|
services.AddScoped<IUserRepository, UserRepository>();
|
||||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
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;
|
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 UsersNamespace = "Mccn.Modules.Users";
|
||||||
protected const string HelloNamespace = "Mccn.Modules.Hello";
|
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,12 +20,17 @@ public class ModuleTests : BaseTest
|
|||||||
typeof(DependencyInjection).Assembly
|
typeof(DependencyInjection).Assembly
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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)
|
Types.InAssemblies(usersAssemblies)
|
||||||
.Should()
|
.Should()
|
||||||
.NotHaveDependencyOn(HelloNamespace)
|
.NotHaveDependencyOn(ns)
|
||||||
.GetResult()
|
.GetResult()
|
||||||
.ShouldBeSuccessful();
|
.ShouldBeSuccessful();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HelloModule_ShouldNotHaveDependencyOn_UsersModule()
|
public void HelloModule_ShouldNotHaveDependencyOn_UsersModule()
|
||||||
@@ -37,10 +42,16 @@ public class ModuleTests : BaseTest
|
|||||||
typeof(Modules.Hello.Infrastructure.DependencyInjection).Assembly
|
typeof(Modules.Hello.Infrastructure.DependencyInjection).Assembly
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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)
|
Types.InAssemblies(helloAssemblies)
|
||||||
.Should()
|
.Should()
|
||||||
.NotHaveDependencyOn(UsersNamespace)
|
.NotHaveDependencyOn(ns)
|
||||||
.GetResult()
|
.GetResult()
|
||||||
.ShouldBeSuccessful();
|
.ShouldBeSuccessful();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user