diff --git a/Femto.Api/Femto.Api.csproj b/Femto.Api/Femto.Api.csproj
index fdf0752..4625094 100644
--- a/Femto.Api/Femto.Api.csproj
+++ b/Femto.Api/Femto.Api.csproj
@@ -29,8 +29,4 @@
-
-
-
-
diff --git a/Femto.Api/Infrastructure/EventBus.cs b/Femto.Api/Infrastructure/EventBus.cs
new file mode 100644
index 0000000..0f4d32c
--- /dev/null
+++ b/Femto.Api/Infrastructure/EventBus.cs
@@ -0,0 +1,32 @@
+using System.Threading.Channels;
+using Femto.Common.Integration;
+
+namespace Femto.Api.Infrastructure;
+
+public class EventBus(Channel channel) : BackgroundService, IEventBus
+{
+ private readonly ICollection _subscribers = [];
+
+ public Task Publish(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))
+ );
+ }
+ }
+}
diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs
index 7efeeb7..f683641 100644
--- a/Femto.Api/Program.cs
+++ b/Femto.Api/Program.cs
@@ -1,9 +1,12 @@
using System.Text.Json;
using System.Text.Json.Serialization;
+using System.Threading.Channels;
using Femto.Api;
using Femto.Api.Auth;
+using Femto.Api.Infrastructure;
using Femto.Common;
using Femto.Common.Domain;
+using Femto.Common.Integration;
using Femto.Modules.Auth.Application;
using Femto.Modules.Blog.Application;
using Femto.Modules.Media.Application;
@@ -27,9 +30,12 @@ if (blobStorageRoot is null)
throw new Exception("no blob storage root found");
-builder.Services.InitializeBlogModule(connectionString);
+var eventBus = new EventBus(Channel.CreateUnbounded());
+builder.Services.AddHostedService(_ => eventBus);
+
+builder.Services.InitializeBlogModule(connectionString, eventBus);
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
-builder.Services.InitializeAuthenticationModule(connectionString);
+builder.Services.InitializeAuthenticationModule(connectionString, eventBus);
builder.Services.AddScoped();
builder.Services.AddScoped(s => s.GetRequiredService());
diff --git a/Femto.Common/Infrastructure/DbContextDomainExtensions.cs b/Femto.Common/Infrastructure/DbContextDomainExtensions.cs
new file mode 100644
index 0000000..b3e1c7c
--- /dev/null
+++ b/Femto.Common/Infrastructure/DbContextDomainExtensions.cs
@@ -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()
+ .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);
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/Femto.Common/Infrastructure/Outbox/IOutboxMessageHandler.cs b/Femto.Common/Infrastructure/Outbox/IOutboxMessageHandler.cs
index c58eee9..9c3b327 100644
--- a/Femto.Common/Infrastructure/Outbox/IOutboxMessageHandler.cs
+++ b/Femto.Common/Infrastructure/Outbox/IOutboxMessageHandler.cs
@@ -4,5 +4,5 @@ namespace Femto.Common.Infrastructure.Outbox;
public interface IOutboxMessageHandler
{
- Task Publish(TNotification notification, CancellationToken executionContextCancellationToken);
+ Task HandleMessage(TNotification notification, CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/Femto.Common/Infrastructure/Outbox/Outbox.cs b/Femto.Common/Infrastructure/Outbox/Outbox.cs
index e2878d5..a201fa9 100644
--- a/Femto.Common/Infrastructure/Outbox/Outbox.cs
+++ b/Femto.Common/Infrastructure/Outbox/Outbox.cs
@@ -13,7 +13,7 @@ public class Outbox(TContext context, IOutboxMessageMapping mapping) w
TMessage message,
CancellationToken cancellationToken
)
- where TMessage : IIntegrationEvent
+ where TMessage : IEvent
{
var eventName = mapping.GetEventName(typeof(TMessage));
if (eventName is null)
diff --git a/Femto.Common/Infrastructure/Outbox/OutboxEntry.cs b/Femto.Common/Infrastructure/Outbox/OutboxEntry.cs
index b34a4aa..1d5aeda 100644
--- a/Femto.Common/Infrastructure/Outbox/OutboxEntry.cs
+++ b/Femto.Common/Infrastructure/Outbox/OutboxEntry.cs
@@ -3,7 +3,7 @@ namespace Femto.Common.Infrastructure.Outbox;
public class OutboxEntry
{
private const int MaxRetries = 5;
-
+
public Guid Id { get; private set; }
public string EventType { get; private set; } = null!;
@@ -18,7 +18,7 @@ public class OutboxEntry
public int RetryCount { get; private set; } = 0;
public string? LastError { get; private set; }
public OutboxEntryStatus Status { get; private set; }
-
+
private OutboxEntry() { }
public OutboxEntry(Guid eventId, Guid aggregateId, string eventType, string payload)
@@ -35,7 +35,7 @@ public class OutboxEntry
this.ProcessedAt = DateTime.UtcNow;
this.Status = OutboxEntryStatus.Completed;
}
-
+
public void Fail(string error)
{
if (this.RetryCount >= MaxRetries)
@@ -53,7 +53,7 @@ public class OutboxEntry
public enum OutboxEntryStatus
{
- Pending,
- Completed,
- Failed
-}
\ No newline at end of file
+ Pending = 0,
+ Completed = 1,
+ Failed = 2,
+}
diff --git a/Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs b/Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs
index 46f72b6..00fcddf 100644
--- a/Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs
+++ b/Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs
@@ -56,7 +56,7 @@ public class OutboxProcessor(
message.AggregateId
);
- await handler.Publish(notification, executionContext.CancellationToken);
+ await handler.HandleMessage(notification, executionContext.CancellationToken);
message.Succeed();
}
diff --git a/Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs b/Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs
index d2bdfbc..5aaa2ba 100644
--- a/Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs
+++ b/Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs
@@ -9,21 +9,23 @@ namespace Femto.Common.Infrastructure.Outbox;
public static class OutboxServiceExtension
{
- public static void AddOutbox(
+ public static void AddOutbox(
this IServiceCollection services,
Func? contextFactory = null
)
where TContext : DbContext, IOutboxContext
+ where TMessageHandler : class, IOutboxMessageHandler
{
-
services.AddSingleton();
-
+
services.AddScoped(c =>
contextFactory?.Invoke(c) ?? c.GetRequiredService()
);
services.AddScoped>();
+ services.AddScoped();
+
services.AddQuartz(q =>
{
var jobKey = JobKey.Create(nameof(OutboxProcessor));
diff --git a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
index dc6719d..d9aaf03 100644
--- a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
+++ b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
@@ -19,39 +19,12 @@ public class SaveChangesPipelineBehaviour(
)
{
var response = await next(cancellationToken);
-
if (context.ChangeTracker.HasChanges())
{
- await this.EmitDomainEvents(cancellationToken);
-
+ await context.EmitDomainEvents(logger, publisher, cancellationToken);
logger.LogDebug("saving changes");
await context.SaveChangesAsync(cancellationToken);
}
return response;
}
-
- private async Task EmitDomainEvents(CancellationToken cancellationToken)
- {
- var domainEvents = context
- .ChangeTracker.Entries()
- .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);
- }
- }
}
diff --git a/Femto.Common/Integration/Event.cs b/Femto.Common/Integration/Event.cs
new file mode 100644
index 0000000..7983830
--- /dev/null
+++ b/Femto.Common/Integration/Event.cs
@@ -0,0 +1,6 @@
+namespace Femto.Common.Integration;
+
+public abstract record Event : IEvent
+{
+ public Guid EventId { get; } = Guid.CreateVersion7();
+}
\ No newline at end of file
diff --git a/Femto.Common/Integration/IIntegrationEvent.cs b/Femto.Common/Integration/IEvent.cs
similarity index 63%
rename from Femto.Common/Integration/IIntegrationEvent.cs
rename to Femto.Common/Integration/IEvent.cs
index 8872940..39143cd 100644
--- a/Femto.Common/Integration/IIntegrationEvent.cs
+++ b/Femto.Common/Integration/IEvent.cs
@@ -2,7 +2,7 @@ using MediatR;
namespace Femto.Common.Integration;
-public interface IIntegrationEvent : INotification
+public interface IEvent : INotification
{
public Guid EventId { get; }
}
\ No newline at end of file
diff --git a/Femto.Common/Integration/IEventBus.cs b/Femto.Common/Integration/IEventBus.cs
new file mode 100644
index 0000000..4082bc1
--- /dev/null
+++ b/Femto.Common/Integration/IEventBus.cs
@@ -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 evt) where T : IEvent;
+}
\ No newline at end of file
diff --git a/Femto.Common/Integration/IEventHandler.cs b/Femto.Common/Integration/IEventHandler.cs
new file mode 100644
index 0000000..5456948
--- /dev/null
+++ b/Femto.Common/Integration/IEventHandler.cs
@@ -0,0 +1,24 @@
+namespace Femto.Common.Integration;
+
+public interface IEventHandler
+{
+ Task Handle(IEvent evt, CancellationToken cancellationToken = default);
+}
+
+public abstract class EventHandler : 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);
+ }
+}
diff --git a/Femto.Common/Integration/IIntegrationEventBus.cs b/Femto.Common/Integration/IIntegrationEventBus.cs
deleted file mode 100644
index 087106c..0000000
--- a/Femto.Common/Integration/IIntegrationEventBus.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Femto.Common.Integration;
-
-public interface IIntegrationEventBus
-{
- void Subscribe() where T : IIntegrationEvent;
-}
\ No newline at end of file
diff --git a/Femto.Database/Migrations/20250425121459_Init.sql b/Femto.Database/Migrations/20250425121459_Init.sql
deleted file mode 100644
index f42403f..0000000
--- a/Femto.Database/Migrations/20250425121459_Init.sql
+++ /dev/null
@@ -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
-);
diff --git a/Femto.Modules.Auth.Contracts/Events/UserWasCreatedIntegrationEvent.cs b/Femto.Modules.Auth.Contracts/Events/UserWasCreatedIntegrationEvent.cs
new file mode 100644
index 0000000..b73020a
--- /dev/null
+++ b/Femto.Modules.Auth.Contracts/Events/UserWasCreatedIntegrationEvent.cs
@@ -0,0 +1,5 @@
+using Femto.Common.Integration;
+
+namespace Femto.Modules.Auth.Contracts.Events;
+
+public record UserWasCreatedIntegrationEvent(Guid UserId, string Username) : Event;
\ No newline at end of file
diff --git a/Femto.Modules.Auth.Contracts/Femto.Modules.Auth.Contracts.csproj b/Femto.Modules.Auth.Contracts/Femto.Modules.Auth.Contracts.csproj
new file mode 100644
index 0000000..861f5b5
--- /dev/null
+++ b/Femto.Modules.Auth.Contracts/Femto.Modules.Auth.Contracts.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs
index 90bf459..feede26 100644
--- a/Femto.Modules.Auth/Application/AuthStartup.cs
+++ b/Femto.Modules.Auth/Application/AuthStartup.cs
@@ -1,34 +1,44 @@
using Femto.Common.Infrastructure;
+using Femto.Common.Infrastructure.Outbox;
+using Femto.Common.Integration;
using Femto.Modules.Auth.Data;
+using Femto.Modules.Auth.Infrastructure;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using Quartz;
namespace Femto.Modules.Auth.Application;
public static class AuthStartup
{
- public static void InitializeAuthenticationModule(
- this IServiceCollection rootContainer,
- string connectionString
- )
+ public static void InitializeAuthenticationModule(this IServiceCollection rootContainer,
+ string connectionString, IEventBus eventBus)
{
var hostBuilder = Host.CreateDefaultBuilder();
- hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString));
+ hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString, eventBus));
var host = hostBuilder.Build();
rootContainer.AddScoped(_ => new AuthModule(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(builder =>
{
builder.UseNpgsql(connectionString);
builder.UseSnakeCaseNamingConvention();
});
+
+ services.AddQuartzHostedService(options =>
+ {
+ options.WaitForJobsToComplete = true;
+ });
+
+ services.AddOutbox();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
@@ -41,9 +51,35 @@ public static class AuthStartup
services.ConfigureDomainServices();
- services.AddMediatR(c =>
+ services.AddSingleton(publisher);
+ }
+
+ private static async Task EventSubscriber(
+ IEvent evt,
+ IServiceProvider provider,
+ CancellationToken cancellationToken
+ )
+ {
+ using var scope = provider.CreateScope();
+
+ var context = scope.ServiceProvider.GetRequiredService();
+ var logger = scope.ServiceProvider.GetRequiredService>();
+ var publisher = scope.ServiceProvider.GetRequiredService();
+
+ IEventHandler? handler = evt switch
{
- c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly);
- });
+ _ => 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);
+ }
}
}
diff --git a/Femto.Modules.Auth/Contracts/AuthenticationService.cs b/Femto.Modules.Auth/Contracts/AuthenticationService.cs
deleted file mode 100644
index 81f48d6..0000000
--- a/Femto.Modules.Auth/Contracts/AuthenticationService.cs
+++ /dev/null
@@ -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 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 Authenticate(string username, string password)
- {
- throw new NotImplementedException();
- }
-}
-
-public class AuthenticationError(string message, Exception inner) : Exception(message, inner);
diff --git a/Femto.Modules.Auth/Contracts/IAuthenticationService.cs b/Femto.Modules.Auth/Contracts/IAuthenticationService.cs
deleted file mode 100644
index 19aea57..0000000
--- a/Femto.Modules.Auth/Contracts/IAuthenticationService.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace Femto.Modules.Auth.Contracts;
-
-public interface IAuthenticationService
-{
- public Task Register(string username, string password);
- public Task Authenticate(string username, string password);
-}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Contracts/UserInfo.cs b/Femto.Modules.Auth/Contracts/UserInfo.cs
deleted file mode 100644
index dee7448..0000000
--- a/Femto.Modules.Auth/Contracts/UserInfo.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace Femto.Modules.Auth.Contracts;
-
-public record UserInfo(Guid UserId, string Username);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Femto.Modules.Auth.csproj b/Femto.Modules.Auth/Femto.Modules.Auth.csproj
index bd6d1eb..3bb2c77 100644
--- a/Femto.Modules.Auth/Femto.Modules.Auth.csproj
+++ b/Femto.Modules.Auth/Femto.Modules.Auth.csproj
@@ -14,14 +14,12 @@
+
-
-
-
-
+
diff --git a/Femto.Modules.Auth/Infrastructure/OutboxMessageHandler.cs b/Femto.Modules.Auth/Infrastructure/OutboxMessageHandler.cs
new file mode 100644
index 0000000..bbddf03
--- /dev/null
+++ b/Femto.Modules.Auth/Infrastructure/OutboxMessageHandler.cs
@@ -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 logger) : IOutboxMessageHandler
+{
+ public async Task HandleMessage(
+ 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));
+ }
+ }
+}
diff --git a/Femto.Modules.Auth/Models/DomainEventHandlers/UserWasCreatedHandler.cs b/Femto.Modules.Auth/Models/DomainEventHandlers/UserWasCreatedHandler.cs
new file mode 100644
index 0000000..2a020cc
--- /dev/null
+++ b/Femto.Modules.Auth/Models/DomainEventHandlers/UserWasCreatedHandler.cs
@@ -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 outbox)
+ : INotificationHandler
+{
+ public async Task Handle(UserWasCreatedEvent notification, CancellationToken cancellationToken)
+ {
+ await outbox.AddMessage(
+ notification.User.Id,
+ new UserWasCreatedIntegrationEvent(notification.User.Id, notification.User.Username),
+ cancellationToken
+ );
+ }
+}
diff --git a/Femto.Modules.Auth/Models/UserSession.cs b/Femto.Modules.Auth/Models/UserSession.cs
index b74a365..7deb251 100644
--- a/Femto.Modules.Auth/Models/UserSession.cs
+++ b/Femto.Modules.Auth/Models/UserSession.cs
@@ -1,12 +1,11 @@
namespace Femto.Modules.Auth.Models;
-public class UserSession
+internal class UserSession
{
private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30);
private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5);
public string Id { get; private set; }
public DateTimeOffset Expires { get; private set; }
-
public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer;
private UserSession() {}
diff --git a/Femto.Modules.Blog/Application/BlogContext.cs b/Femto.Modules.Blog/Application/BlogContext.cs
index b90f59a..8b930d3 100644
--- a/Femto.Modules.Blog/Application/BlogContext.cs
+++ b/Femto.Modules.Blog/Application/BlogContext.cs
@@ -1,4 +1,5 @@
using Femto.Common.Infrastructure.Outbox;
+using Femto.Modules.Blog.Domain.Authors;
using Femto.Modules.Blog.Domain.Posts;
using Microsoft.EntityFrameworkCore;
@@ -7,6 +8,7 @@ namespace Femto.Modules.Blog.Application;
internal class BlogContext(DbContextOptions options) : DbContext(options), IOutboxContext
{
public virtual DbSet Posts { get; set; }
+ public virtual DbSet Authors { get; set; }
public virtual DbSet Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
diff --git a/Femto.Modules.Blog/Application/BlogStartup.cs b/Femto.Modules.Blog/Application/BlogStartup.cs
index fe30c48..98bf8b3 100644
--- a/Femto.Modules.Blog/Application/BlogStartup.cs
+++ b/Femto.Modules.Blog/Application/BlogStartup.cs
@@ -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.Outbox;
+using Femto.Common.Integration;
+using Femto.Modules.Auth.Contracts.Events;
+using Femto.Modules.Blog.Handlers;
+using Femto.Modules.Blog.Infrastructure;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -14,21 +19,32 @@ public static class BlogStartup
{
public static void InitializeBlogModule(
this IServiceCollection rootContainer,
- string connectionString
+ string connectionString,
+ IEventBus bus
)
{
var hostBuilder = Host.CreateDefaultBuilder();
- hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString));
+ hostBuilder.ConfigureServices(services =>
+ ConfigureServices(services, connectionString, bus)
+ );
var host = hostBuilder.Build();
rootContainer.AddHostedService(services => new BlogApplication(host));
rootContainer.AddScoped(_ => 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(_ => new DbConnectionFactory(connectionString));
@@ -46,6 +62,8 @@ public static class BlogStartup
builder.UseLoggerFactory(loggerFactory);
builder.EnableSensitiveDataLogging();
});
+
+ services.AddOutbox();
services.AddMediatR(c =>
{
@@ -53,5 +71,42 @@ public static class BlogStartup
});
services.ConfigureDomainServices();
+
+ services.AddSingleton(publisher);
+ }
+
+ private static async Task EventSubscriber(
+ IEvent evt,
+ IServiceProvider provider,
+ CancellationToken cancellationToken
+ )
+ {
+ using var scope = provider.CreateScope();
+
+ var context = scope.ServiceProvider.GetRequiredService();
+ var loggerFactory = scope.ServiceProvider.GetRequiredService();
+ var logger = loggerFactory.CreateLogger();
+ var publisher = scope.ServiceProvider.GetRequiredService();
+
+ // todo inject these
+ IEventHandler? handler = evt switch
+ {
+ UserWasCreatedIntegrationEvent => new UserCreatedEventHandler(
+ context,
+ loggerFactory.CreateLogger()
+ ),
+ _ => 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);
+ }
}
}
diff --git a/Femto.Modules.Blog/Application/Configurations/AuthorConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/AuthorConfiguration.cs
new file mode 100644
index 0000000..9a7756c
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Configurations/AuthorConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder table)
+ {
+ table.ToTable("author");
+ }
+}
diff --git a/Femto.Modules.Blog/Application/Configurations/OutboxEntryConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/OutboxEntryConfiguration.cs
index 854903e..0fe66e5 100644
--- a/Femto.Modules.Blog/Application/Configurations/OutboxEntryConfiguration.cs
+++ b/Femto.Modules.Blog/Application/Configurations/OutboxEntryConfiguration.cs
@@ -9,7 +9,5 @@ internal class OutboxEntryConfiguration : IEntityTypeConfiguration
public void Configure(EntityTypeBuilder builder)
{
builder.ToTable("outbox");
-
- builder.Property(x => x.Payload).HasColumnType("jsonb");
}
}
diff --git a/Femto.Modules.Blog/Domain/Authors/Author.cs b/Femto.Modules.Blog/Domain/Authors/Author.cs
new file mode 100644
index 0000000..4595144
--- /dev/null
+++ b/Femto.Modules.Blog/Domain/Authors/Author.cs
@@ -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;
+ }
+}
diff --git a/Femto.Modules.Blog/Events/PostCreatedIntegrationEvent.cs b/Femto.Modules.Blog/Events/PostCreatedEvent.cs
similarity index 51%
rename from Femto.Modules.Blog/Events/PostCreatedIntegrationEvent.cs
rename to Femto.Modules.Blog/Events/PostCreatedEvent.cs
index ef339bb..36c355d 100644
--- a/Femto.Modules.Blog/Events/PostCreatedIntegrationEvent.cs
+++ b/Femto.Modules.Blog/Events/PostCreatedEvent.cs
@@ -4,5 +4,5 @@ using Femto.Common.Integration;
namespace Femto.Modules.Blog.Events;
[EventType("post.created")]
-public record PostCreatedIntegrationEvent(Guid EventId, Guid PostId, IEnumerable MediaIds)
- : IIntegrationEvent;
+public record PostCreatedEvent(Guid EventId, Guid PostId, IEnumerable MediaIds)
+ : IEvent;
diff --git a/Femto.Modules.Blog/Femto.Modules.Blog.csproj b/Femto.Modules.Blog/Femto.Modules.Blog.csproj
index 7944afa..08c6b99 100644
--- a/Femto.Modules.Blog/Femto.Modules.Blog.csproj
+++ b/Femto.Modules.Blog/Femto.Modules.Blog.csproj
@@ -38,12 +38,9 @@
-
-
-
-
+
diff --git a/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs b/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs
index c9c0be0..a6a221f 100644
--- a/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs
+++ b/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs
@@ -3,9 +3,9 @@ using MediatR;
namespace Femto.Modules.Blog.Handlers;
-public class PostCreatedIntegrationEventHandler : INotificationHandler
+public class PostCreatedIntegrationEventHandler : INotificationHandler
{
- public async Task Handle(PostCreatedIntegrationEvent notification, CancellationToken cancellationToken)
+ public async Task Handle(PostCreatedEvent notification, CancellationToken cancellationToken)
{
// todo
}
diff --git a/Femto.Modules.Blog/Handlers/UserCreatedEventHandler.cs b/Femto.Modules.Blog/Handlers/UserCreatedEventHandler.cs
new file mode 100644
index 0000000..9d72e1c
--- /dev/null
+++ b/Femto.Modules.Blog/Handlers/UserCreatedEventHandler.cs
@@ -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 logger) : Common.Integration.EventHandler
+{
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Infrastructure/OutboxMessageHandler.cs b/Femto.Modules.Blog/Infrastructure/OutboxMessageHandler.cs
new file mode 100644
index 0000000..c3beb4e
--- /dev/null
+++ b/Femto.Modules.Blog/Infrastructure/OutboxMessageHandler.cs
@@ -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 logger) : IOutboxMessageHandler
+{
+ public async Task HandleMessage(
+ 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));
+ }
+ }
+}
diff --git a/FemtoBackend.sln b/FemtoBackend.sln
index 52f0715..8916568 100644
--- a/FemtoBackend.sln
+++ b/FemtoBackend.sln
@@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Media", "Femt
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Auth", "Femto.Modules.Auth\Femto.Modules.Auth.csproj", "{7E138EF6-E075-4896-93C0-923024F0CA78}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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}.Release|Any CPU.ActiveCfg = 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
GlobalSection(NestedProjects) = preSolution
EndGlobalSection