some changes

This commit is contained in:
john 2025-05-17 23:47:19 +02:00
parent 4ec9720541
commit b47bac67ca
37 changed files with 397 additions and 190 deletions

View file

@ -29,8 +29,4 @@
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Infrastructure\" />
</ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,32 @@
using System.Threading.Channels;
using Femto.Common.Integration;
namespace Femto.Api.Infrastructure;
public class EventBus(Channel<IEvent> channel) : BackgroundService, IEventBus
{
private readonly ICollection<IEventBus.Subscriber> _subscribers = [];
public Task Publish<T>(T evt)
where T : IEvent
{
channel.Writer.TryWrite(evt);
return Task.CompletedTask;
}
public void Subscribe(IEventBus.Subscriber subscriber)
{
this._subscribers.Add(subscriber);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var message in channel.Reader.ReadAllAsync(stoppingToken))
{
await Task.WhenAll(
this._subscribers.Select(subscriber => subscriber.Invoke(message, stoppingToken))
);
}
}
}

View file

@ -1,9 +1,12 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.Channels;
using Femto.Api; using Femto.Api;
using Femto.Api.Auth; using Femto.Api.Auth;
using Femto.Api.Infrastructure;
using Femto.Common; using Femto.Common;
using Femto.Common.Domain; using Femto.Common.Domain;
using Femto.Common.Integration;
using Femto.Modules.Auth.Application; using Femto.Modules.Auth.Application;
using Femto.Modules.Blog.Application; using Femto.Modules.Blog.Application;
using Femto.Modules.Media.Application; using Femto.Modules.Media.Application;
@ -27,9 +30,12 @@ if (blobStorageRoot is null)
throw new Exception("no blob storage root found"); throw new Exception("no blob storage root found");
builder.Services.InitializeBlogModule(connectionString); var eventBus = new EventBus(Channel.CreateUnbounded<IEvent>());
builder.Services.AddHostedService(_ => eventBus);
builder.Services.InitializeBlogModule(connectionString, eventBus);
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
builder.Services.InitializeAuthenticationModule(connectionString); builder.Services.InitializeAuthenticationModule(connectionString, eventBus);
builder.Services.AddScoped<CurrentUserContext, CurrentUserContext>(); builder.Services.AddScoped<CurrentUserContext, CurrentUserContext>();
builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>()); builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>());

View file

@ -0,0 +1,35 @@
using Femto.Common.Domain;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Femto.Common.Infrastructure;
public static class DbContextDomainExtensions
{
public static async Task EmitDomainEvents(this DbContext context, ILogger logger, IPublisher publisher, CancellationToken cancellationToken)
{
var domainEvents = context
.ChangeTracker.Entries<Entity>()
.SelectMany(e =>
{
var events = e.Entity.DomainEvents;
e.Entity.ClearDomainEvents();
return events;
})
.ToList();
logger.LogTrace("loaded {Count} domain events", domainEvents.Count);
foreach (var domainEvent in domainEvents)
{
logger.LogTrace(
"publishing {Type} domain event {Id}",
domainEvent.GetType().Name,
domainEvent.EventId
);
await publisher.Publish(domainEvent, cancellationToken);
}
}
}

View file

@ -4,5 +4,5 @@ namespace Femto.Common.Infrastructure.Outbox;
public interface IOutboxMessageHandler public interface IOutboxMessageHandler
{ {
Task Publish<TNotification>(TNotification notification, CancellationToken executionContextCancellationToken); Task HandleMessage<TNotification>(TNotification notification, CancellationToken cancellationToken = default);
} }

View file

@ -13,7 +13,7 @@ public class Outbox<TContext>(TContext context, IOutboxMessageMapping mapping) w
TMessage message, TMessage message,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
where TMessage : IIntegrationEvent where TMessage : IEvent
{ {
var eventName = mapping.GetEventName(typeof(TMessage)); var eventName = mapping.GetEventName(typeof(TMessage));
if (eventName is null) if (eventName is null)

View file

@ -53,7 +53,7 @@ public class OutboxEntry
public enum OutboxEntryStatus public enum OutboxEntryStatus
{ {
Pending, Pending = 0,
Completed, Completed = 1,
Failed Failed = 2,
} }

View file

@ -56,7 +56,7 @@ public class OutboxProcessor<TContext>(
message.AggregateId message.AggregateId
); );
await handler.Publish(notification, executionContext.CancellationToken); await handler.HandleMessage(notification, executionContext.CancellationToken);
message.Succeed(); message.Succeed();
} }

View file

@ -9,13 +9,13 @@ namespace Femto.Common.Infrastructure.Outbox;
public static class OutboxServiceExtension public static class OutboxServiceExtension
{ {
public static void AddOutbox<TContext>( public static void AddOutbox<TContext, TMessageHandler>(
this IServiceCollection services, this IServiceCollection services,
Func<IServiceProvider, TContext>? contextFactory = null Func<IServiceProvider, TContext>? contextFactory = null
) )
where TContext : DbContext, IOutboxContext where TContext : DbContext, IOutboxContext
where TMessageHandler : class, IOutboxMessageHandler
{ {
services.AddSingleton<IOutboxMessageMapping, ClrTypenameMessageMapping>(); services.AddSingleton<IOutboxMessageMapping, ClrTypenameMessageMapping>();
services.AddScoped<IOutboxContext>(c => services.AddScoped<IOutboxContext>(c =>
@ -24,6 +24,8 @@ public static class OutboxServiceExtension
services.AddScoped<Outbox<TContext>>(); services.AddScoped<Outbox<TContext>>();
services.AddScoped<IOutboxMessageHandler, TMessageHandler>();
services.AddQuartz(q => services.AddQuartz(q =>
{ {
var jobKey = JobKey.Create(nameof(OutboxProcessor<TContext>)); var jobKey = JobKey.Create(nameof(OutboxProcessor<TContext>));

View file

@ -19,39 +19,12 @@ public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
) )
{ {
var response = await next(cancellationToken); var response = await next(cancellationToken);
if (context.ChangeTracker.HasChanges()) if (context.ChangeTracker.HasChanges())
{ {
await this.EmitDomainEvents(cancellationToken); await context.EmitDomainEvents(logger, publisher, cancellationToken);
logger.LogDebug("saving changes"); logger.LogDebug("saving changes");
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);
} }
return response; return response;
} }
private async Task EmitDomainEvents(CancellationToken cancellationToken)
{
var domainEvents = context
.ChangeTracker.Entries<Entity>()
.SelectMany(e =>
{
var events = e.Entity.DomainEvents;
e.Entity.ClearDomainEvents();
return events;
})
.ToList();
logger.LogTrace("loaded {Count} domain events", domainEvents.Count);
foreach (var domainEvent in domainEvents)
{
logger.LogTrace(
"publishing {Type} domain event {Id}",
domainEvent.GetType().Name,
domainEvent.EventId
);
await publisher.Publish(domainEvent, cancellationToken);
}
}
} }

View file

@ -0,0 +1,6 @@
namespace Femto.Common.Integration;
public abstract record Event : IEvent
{
public Guid EventId { get; } = Guid.CreateVersion7();
}

View file

@ -2,7 +2,7 @@ using MediatR;
namespace Femto.Common.Integration; namespace Femto.Common.Integration;
public interface IIntegrationEvent : INotification public interface IEvent : INotification
{ {
public Guid EventId { get; } public Guid EventId { get; }
} }

View file

@ -0,0 +1,13 @@
namespace Femto.Common.Integration;
public interface IEventBus : IEventPublisher
{
public delegate Task Subscriber(IEvent evt, CancellationToken cancellationToken);
void Subscribe(Subscriber subscriber);
}
public interface IEventPublisher
{
Task Publish<T>(T evt) where T : IEvent;
}

View file

@ -0,0 +1,24 @@
namespace Femto.Common.Integration;
public interface IEventHandler
{
Task Handle(IEvent evt, CancellationToken cancellationToken = default);
}
public abstract class EventHandler<T> : IEventHandler
where T : IEvent
{
protected abstract Task Handle(T evt, CancellationToken cancellationToken);
public async Task Handle(IEvent evt, CancellationToken cancellationToken = default)
{
if (evt is not T typedEvt)
{
throw new InvalidOperationException(
$"Event {evt.GetType()} is not of type {typeof(T)}"
);
}
await Handle(typedEvt, cancellationToken);
}
}

View file

@ -1,6 +0,0 @@
namespace Femto.Common.Integration;
public interface IIntegrationEventBus
{
void Subscribe<T>() where T : IIntegrationEvent;
}

View file

@ -1,74 +0,0 @@
-- Migration: Init
-- Created at: 25/04/2025 12:14:59
CREATE SCHEMA blog;
CREATE TABLE blog.author
(
id uuid PRIMARY KEY,
username varchar(64) UNIQUE NOT NULL
);
CREATE TABLE blog.post
(
id uuid PRIMARY KEY,
content text NOT NULL,
posted_on timestamptz NOT NULL DEFAULT now(),
author_id uuid NOT NULL REFERENCES blog.author (id) on DELETE CASCADE
);
CREATE TABLE blog.post_media
(
id uuid PRIMARY KEY,
post_id uuid NOT NULL REFERENCES blog.post (id) ON DELETE CASCADE,
url text NOT NULL,
type varchar(64),
width int,
height int,
ordering int NOT NULL
);
CREATE TYPE outbox_status AS ENUM ('pending', 'completed', 'failed');
CREATE TABLE blog.outbox
(
id uuid PRIMARY KEY,
event_type text NOT NULL,
aggregate_id uuid NOT NULL,
payload jsonb NOT NULL,
created_at timestamp DEFAULT now() NOT NULL,
processed_at timestamp,
next_retry_at timestamp,
retry_count int DEFAULT 0 NOT NULL,
last_error text,
status outbox_status DEFAULT 'pending' NOT NULL
);
CREATE SCHEMA media;
CREATE TABLE media.saved_blob
(
id uuid PRIMARY KEY,
uploaded_on timestamp DEFAULT now() NOT NULL,
type varchar(64) NOT NULL,
size int
);
CREATE SCHEMA authn;
CREATE TABLE authn.user_identity
(
id uuid PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash bytea,
password_salt bytea
);
CREATE TABLE authn.user_session
(
id varchar(256) PRIMARY KEY,
user_id uuid NOT NULL REFERENCES authn.user_identity (id) ON DELETE CASCADE,
expires timestamptz NOT NULL
);

View file

@ -0,0 +1,5 @@
using Femto.Common.Integration;
namespace Femto.Modules.Auth.Contracts.Events;
public record UserWasCreatedIntegrationEvent(Guid UserId, string Username) : Event;

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
</ItemGroup>
</Project>

View file

@ -1,28 +1,31 @@
using Femto.Common.Infrastructure; using Femto.Common.Infrastructure;
using Femto.Common.Infrastructure.Outbox;
using Femto.Common.Integration;
using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Infrastructure;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Quartz;
namespace Femto.Modules.Auth.Application; namespace Femto.Modules.Auth.Application;
public static class AuthStartup public static class AuthStartup
{ {
public static void InitializeAuthenticationModule( public static void InitializeAuthenticationModule(this IServiceCollection rootContainer,
this IServiceCollection rootContainer, string connectionString, IEventBus eventBus)
string connectionString
)
{ {
var hostBuilder = Host.CreateDefaultBuilder(); var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString)); hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString, eventBus));
var host = hostBuilder.Build(); var host = hostBuilder.Build();
rootContainer.AddScoped<IAuthModule>(_ => new AuthModule(host)); rootContainer.AddScoped<IAuthModule>(_ => new AuthModule(host));
rootContainer.AddHostedService(services => new AuthApplication(host)); rootContainer.AddHostedService(services => new AuthApplication(host));
eventBus.Subscribe((evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken));
} }
private static void ConfigureServices(IServiceCollection services, string connectionString) private static void ConfigureServices(IServiceCollection services, string connectionString, IEventPublisher publisher)
{ {
services.AddDbContext<AuthContext>(builder => services.AddDbContext<AuthContext>(builder =>
{ {
@ -30,6 +33,13 @@ public static class AuthStartup
builder.UseSnakeCaseNamingConvention(); builder.UseSnakeCaseNamingConvention();
}); });
services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
services.AddOutbox<AuthContext, OutboxMessageHandler>();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly)); services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
services.AddDbContext<AuthContext>(builder => services.AddDbContext<AuthContext>(builder =>
@ -41,9 +51,35 @@ public static class AuthStartup
services.ConfigureDomainServices<AuthContext>(); services.ConfigureDomainServices<AuthContext>();
services.AddMediatR(c => services.AddSingleton(publisher);
}
private static async Task EventSubscriber(
IEvent evt,
IServiceProvider provider,
CancellationToken cancellationToken
)
{ {
c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly); using var scope = provider.CreateScope();
});
var context = scope.ServiceProvider.GetRequiredService<AuthContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AuthApplication>>();
var publisher = scope.ServiceProvider.GetRequiredService<IPublisher>();
IEventHandler? handler = evt switch
{
_ => null,
};
if (handler is null)
return;
await handler.Handle(evt, cancellationToken);
if (context.ChangeTracker.HasChanges())
{
await context.EmitDomainEvents(logger, publisher, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
} }
} }

View file

@ -1,23 +0,0 @@
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Contracts;
internal class AuthenticationService(AuthContext context) : IAuthenticationService
{
public async Task<UserInfo> Register(string username, string password)
{
var user = new UserIdentity(username).WithPassword(password);
await context.AddAsync(user);
await context.SaveChangesAsync();
return new(user.Id, user.Username);
}
public async Task<UserInfo> Authenticate(string username, string password)
{
throw new NotImplementedException();
}
}
public class AuthenticationError(string message, Exception inner) : Exception(message, inner);

View file

@ -1,7 +0,0 @@
namespace Femto.Modules.Auth.Contracts;
public interface IAuthenticationService
{
public Task<UserInfo?> Register(string username, string password);
public Task<UserInfo?> Authenticate(string username, string password);
}

View file

@ -1,3 +0,0 @@
namespace Femto.Modules.Auth.Contracts;
public record UserInfo(Guid UserId, string Username);

View file

@ -14,14 +14,12 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" /> <ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
</ItemGroup> <ProjectReference Include="..\Femto.Modules.Auth.Contracts\Femto.Modules.Auth.Contracts.csproj" />
<ItemGroup>
<Folder Include="Models\DomainEventHandlers\" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,22 @@
using Femto.Common.Infrastructure.Outbox;
using Femto.Common.Integration;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Auth.Infrastructure;
public class OutboxMessageHandler(IEventPublisher publisher, ILogger<OutboxMessageHandler> logger) : IOutboxMessageHandler
{
public async Task HandleMessage<TNotification>(
TNotification notification,
CancellationToken executionContextCancellationToken
)
{
if (notification is IEvent evt)
{
await publisher.Publish(evt);
} else
{
logger.LogWarning("ignoring non IEvent {Type} in outbox message handler", typeof(TNotification));
}
}
}

View file

@ -0,0 +1,20 @@
using Femto.Common.Infrastructure.Outbox;
using Femto.Modules.Auth.Contracts.Events;
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Models.Events;
using MediatR;
namespace Femto.Modules.Auth.Models.DomainEventHandlers;
internal class UserWasCreatedHandler(Outbox<AuthContext> outbox)
: INotificationHandler<UserWasCreatedEvent>
{
public async Task Handle(UserWasCreatedEvent notification, CancellationToken cancellationToken)
{
await outbox.AddMessage(
notification.User.Id,
new UserWasCreatedIntegrationEvent(notification.User.Id, notification.User.Username),
cancellationToken
);
}
}

View file

@ -1,12 +1,11 @@
namespace Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Models;
public class UserSession internal class UserSession
{ {
private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30); private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30);
private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5); private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5);
public string Id { get; private set; } public string Id { get; private set; }
public DateTimeOffset Expires { get; private set; } public DateTimeOffset Expires { get; private set; }
public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer; public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer;
private UserSession() {} private UserSession() {}

View file

@ -1,4 +1,5 @@
using Femto.Common.Infrastructure.Outbox; using Femto.Common.Infrastructure.Outbox;
using Femto.Modules.Blog.Domain.Authors;
using Femto.Modules.Blog.Domain.Posts; using Femto.Modules.Blog.Domain.Posts;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -7,6 +8,7 @@ namespace Femto.Modules.Blog.Application;
internal class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options), IOutboxContext internal class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options), IOutboxContext
{ {
public virtual DbSet<Post> Posts { get; set; } public virtual DbSet<Post> Posts { get; set; }
public virtual DbSet<Author> Authors { get; set; }
public virtual DbSet<OutboxEntry> Outbox { get; set; } public virtual DbSet<OutboxEntry> Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)

View file

@ -1,6 +1,11 @@
using Femto.Common.Infrastructure; using System.Runtime.CompilerServices;
using Femto.Common.Infrastructure;
using Femto.Common.Infrastructure.DbConnection; using Femto.Common.Infrastructure.DbConnection;
using Femto.Common.Infrastructure.Outbox; using Femto.Common.Infrastructure.Outbox;
using Femto.Common.Integration;
using Femto.Modules.Auth.Contracts.Events;
using Femto.Modules.Blog.Handlers;
using Femto.Modules.Blog.Infrastructure;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -14,21 +19,32 @@ public static class BlogStartup
{ {
public static void InitializeBlogModule( public static void InitializeBlogModule(
this IServiceCollection rootContainer, this IServiceCollection rootContainer,
string connectionString string connectionString,
IEventBus bus
) )
{ {
var hostBuilder = Host.CreateDefaultBuilder(); var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString)); hostBuilder.ConfigureServices(services =>
ConfigureServices(services, connectionString, bus)
);
var host = hostBuilder.Build(); var host = hostBuilder.Build();
rootContainer.AddHostedService(services => new BlogApplication(host)); rootContainer.AddHostedService(services => new BlogApplication(host));
rootContainer.AddScoped<IBlogModule>(_ => new BlogModule(host)); rootContainer.AddScoped<IBlogModule>(_ => new BlogModule(host));
bus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
);
} }
private static void ConfigureServices(this IServiceCollection services, string connectionString) private static void ConfigureServices(
this IServiceCollection services,
string connectionString,
IEventPublisher publisher
)
{ {
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString)); services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
@ -47,11 +63,50 @@ public static class BlogStartup
builder.EnableSensitiveDataLogging(); builder.EnableSensitiveDataLogging();
}); });
services.AddOutbox<BlogContext, OutboxMessageHandler>();
services.AddMediatR(c => services.AddMediatR(c =>
{ {
c.RegisterServicesFromAssembly(typeof(BlogStartup).Assembly); c.RegisterServicesFromAssembly(typeof(BlogStartup).Assembly);
}); });
services.ConfigureDomainServices<BlogContext>(); services.ConfigureDomainServices<BlogContext>();
services.AddSingleton(publisher);
}
private static async Task EventSubscriber(
IEvent evt,
IServiceProvider provider,
CancellationToken cancellationToken
)
{
using var scope = provider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<BlogContext>();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<BlogApplication>();
var publisher = scope.ServiceProvider.GetRequiredService<IPublisher>();
// todo inject these
IEventHandler? handler = evt switch
{
UserWasCreatedIntegrationEvent => new UserCreatedEventHandler(
context,
loggerFactory.CreateLogger<UserCreatedEventHandler>()
),
_ => null,
};
if (handler is null)
return;
await handler.Handle(evt, cancellationToken);
if (context.ChangeTracker.HasChanges())
{
await context.EmitDomainEvents(logger, publisher, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
} }
} }

View file

@ -0,0 +1,14 @@
using Femto.Modules.Blog.Domain.Authors;
using Femto.Modules.Blog.Domain.Posts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Femto.Modules.Blog.Application.Configurations;
internal class AuthorConfiguration : IEntityTypeConfiguration<Author>
{
public void Configure(EntityTypeBuilder<Author> table)
{
table.ToTable("author");
}
}

View file

@ -9,7 +9,5 @@ internal class OutboxEntryConfiguration : IEntityTypeConfiguration<OutboxEntry>
public void Configure(EntityTypeBuilder<OutboxEntry> builder) public void Configure(EntityTypeBuilder<OutboxEntry> builder)
{ {
builder.ToTable("outbox"); builder.ToTable("outbox");
builder.Property(x => x.Payload).HasColumnType("jsonb");
} }
} }

View file

@ -0,0 +1,17 @@
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Domain.Authors;
public class Author : Entity
{
public Guid Id { get; private set; }
public string Username { get; private set; } = null!;
private Author() { }
public Author(Guid userId, string username)
{
this.Id = userId;
this.Username = username;
}
}

View file

@ -4,5 +4,5 @@ using Femto.Common.Integration;
namespace Femto.Modules.Blog.Events; namespace Femto.Modules.Blog.Events;
[EventType("post.created")] [EventType("post.created")]
public record PostCreatedIntegrationEvent(Guid EventId, Guid PostId, IEnumerable<Guid> MediaIds) public record PostCreatedEvent(Guid EventId, Guid PostId, IEnumerable<Guid> MediaIds)
: IIntegrationEvent; : IEvent;

View file

@ -38,12 +38,9 @@
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Domain\" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" /> <ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
<ProjectReference Include="..\Femto.Modules.Auth.Contracts\Femto.Modules.Auth.Contracts.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -3,9 +3,9 @@ using MediatR;
namespace Femto.Modules.Blog.Handlers; namespace Femto.Modules.Blog.Handlers;
public class PostCreatedIntegrationEventHandler : INotificationHandler<PostCreatedIntegrationEvent> public class PostCreatedIntegrationEventHandler : INotificationHandler<PostCreatedEvent>
{ {
public async Task Handle(PostCreatedIntegrationEvent notification, CancellationToken cancellationToken) public async Task Handle(PostCreatedEvent notification, CancellationToken cancellationToken)
{ {
// todo // todo
} }

View file

@ -0,0 +1,29 @@
using Femto.Modules.Auth.Contracts.Events;
using Femto.Modules.Blog.Application;
using Femto.Modules.Blog.Domain.Authors;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Blog.Handlers;
internal class UserCreatedEventHandler(BlogContext context, ILogger<UserCreatedEventHandler> logger) : Common.Integration.EventHandler<UserWasCreatedIntegrationEvent>
{
protected override async Task Handle(UserWasCreatedIntegrationEvent evt, CancellationToken cancellationToken)
{
if (await context.Authors.AnyAsync(x => x.Username == evt.Username, cancellationToken))
{
logger.LogError("can't create author: author with username {Username} already exists", evt.Username);
return;
}
if (await context.Authors.AnyAsync(x => x.Id == evt.UserId, cancellationToken))
{
logger.LogError("can't create author: author with id {UserId} already exists", evt.UserId);
return;
}
var author = new Author(evt.UserId, evt.Username);
await context.Authors.AddAsync(author, cancellationToken);
}
}

View file

@ -0,0 +1,22 @@
using Femto.Common.Infrastructure.Outbox;
using Femto.Common.Integration;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Blog.Infrastructure;
public class OutboxMessageHandler(IEventPublisher publisher, ILogger<OutboxMessageHandler> logger) : IOutboxMessageHandler
{
public async Task HandleMessage<TNotification>(
TNotification notification,
CancellationToken executionContextCancellationToken
)
{
if (notification is IEvent evt)
{
await publisher.Publish(evt);
} else
{
logger.LogWarning("ignoring non IEvent {Type} in outbox message handler", typeof(TNotification));
}
}
}

View file

@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Media", "Femt
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Auth", "Femto.Modules.Auth\Femto.Modules.Auth.csproj", "{7E138EF6-E075-4896-93C0-923024F0CA78}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Auth", "Femto.Modules.Auth\Femto.Modules.Auth.csproj", "{7E138EF6-E075-4896-93C0-923024F0CA78}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Auth.Contracts", "Femto.Modules.Auth.Contracts\Femto.Modules.Auth.Contracts.csproj", "{1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -48,6 +50,10 @@ Global
{7E138EF6-E075-4896-93C0-923024F0CA78}.Debug|Any CPU.Build.0 = Debug|Any CPU {7E138EF6-E075-4896-93C0-923024F0CA78}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7E138EF6-E075-4896-93C0-923024F0CA78}.Release|Any CPU.ActiveCfg = Release|Any CPU {7E138EF6-E075-4896-93C0-923024F0CA78}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7E138EF6-E075-4896-93C0-923024F0CA78}.Release|Any CPU.Build.0 = Release|Any CPU {7E138EF6-E075-4896-93C0-923024F0CA78}.Release|Any CPU.Build.0 = Release|Any CPU
{1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
EndGlobalSection EndGlobalSection