hopefully not a horribly foolish refactoring

This commit is contained in:
john 2025-05-11 23:26:09 +02:00
parent 59d660165f
commit 1ecaf64dea
82 changed files with 782 additions and 398 deletions

View file

@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Mvc;
namespace Femto.Api.Controllers.Auth;
[ApiController]
[Route("auth")]
public class AuthController : ControllerBase
{
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request)
{
return new LoginResponse(Guid.Parse("0196960c-6296-7532-ba66-8fabb38c6ae0"), "johnbotris", "token");
}
[HttpPost("signup")]
public async Task<ActionResult<SignupResponse>> Signup([FromBody] SignupRequest request)
{
throw new NotImplementedException();
}
[HttpPost("delete-session")]
public async Task<ActionResult> DeleteSession([FromBody] DeleteSessionRequest request)
{
// TODO
return Ok(new {});
}
}

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record DeleteSessionRequest(string SessionToken);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record LoginRequest(string Username, string Password);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record LoginResponse(Guid UserId, string Username, string SessionToken);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record SignupRequest(string Username, string Password, string SignupCode, string? Email);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record SignupResponse(Guid UserId, string Username, string SessionToken);

View file

@ -1,40 +0,0 @@
using Femto.Api.Controllers.Authors.Dto;
using Femto.Modules.Blog.Domain.Posts.Commands.GetPosts;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace Femto.Api.Controllers.Authors;
[ApiController]
[Route("authors")]
public class AuthorsController(IMediator mediator) : ControllerBase
{
[HttpGet("{username}/posts")]
public async Task<ActionResult<GetAuthorPostsResponse>> GetAuthorPosts(
string username,
[FromQuery] GetAuthorPostsSearchParams searchParams,
CancellationToken cancellationToken
)
{
var res = await mediator.Send(
new GetPostsQuery
{
Username = username,
Amount = searchParams.Amount ?? 20,
From = searchParams.From,
},
cancellationToken
);
return new GetAuthorPostsResponse(
res.Posts.Select(p => new AuthorPostDto(
p.PostId,
p.Text,
p.Media.Select(m => m.Url),
p.CreatedAt,
new AuthoPostAuthorDto(p.Author.AuthorId, p.Author.Username)
)),
res.Next
);
}
}

View file

@ -1,6 +0,0 @@
using JetBrains.Annotations;
namespace Femto.Api.Controllers.Authors.Dto;
[PublicAPI]
public record AuthoPostAuthorDto(Guid AuthorId, string Username);

View file

@ -1,6 +0,0 @@
using JetBrains.Annotations;
namespace Femto.Api.Controllers.Authors.Dto;
[PublicAPI]
public record AuthorPostDto(Guid PostId, string Content, IEnumerable<Uri> Media, DateTimeOffset CreatedAt, AuthoPostAuthorDto Author );

View file

@ -1,6 +0,0 @@
using JetBrains.Annotations;
namespace Femto.Api.Controllers.Authors.Dto;
[PublicAPI]
public record GetAuthorPostsResponse(IEnumerable<AuthorPostDto> Posts, Guid? Next);

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Authors.Dto;
public record GetAuthorPostsSearchParams(Guid? From, int? Amount);

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto; namespace Femto.Api.Controllers.Posts.Dto;
public record CreatePostRequest(Guid AuthorId, string Content, IEnumerable<Uri> Media); public record CreatePostRequest(Guid AuthorId, string Content, IEnumerable<CreatePostRequestMedia> Media);

View file

@ -0,0 +1,6 @@
using JetBrains.Annotations;
namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI]
public record CreatePostRequestMedia(Guid MediaId, Uri Url, string? Type, int? Width, int? Height);

View file

@ -3,4 +3,4 @@ using JetBrains.Annotations;
namespace Femto.Api.Controllers.Posts.Dto; namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI] [PublicAPI]
public record GetPublicPostsSearchParams(Guid? From, int? Amount); public record GetPublicPostsSearchParams(Guid? From, int? Amount, Guid? AuthorId, string? Author);

View file

@ -7,6 +7,6 @@ public record PublicPostDto(
PublicPostAuthorDto Author, PublicPostAuthorDto Author,
Guid PostId, Guid PostId,
string Content, string Content,
IEnumerable<Uri> Media, IEnumerable<PublicPostMediaDto> Media,
DateTimeOffset CreatedAt DateTimeOffset CreatedAt
); );

View file

@ -0,0 +1,6 @@
using JetBrains.Annotations;
namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI]
public record PublicPostMediaDto(Uri Url, int? Width, int? Height);

View file

@ -1,6 +1,8 @@
using Femto.Api.Controllers.Posts.Dto; using Femto.Api.Controllers.Posts.Dto;
using Femto.Modules.Blog.Domain.Posts.Commands.CreatePost; using Femto.Modules.Blog;
using Femto.Modules.Blog.Domain.Posts.Commands.GetPosts; using Femto.Modules.Blog.Application;
using Femto.Modules.Blog.Application.Commands.CreatePost;
using Femto.Modules.Blog.Application.Queries.GetPosts;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -8,7 +10,7 @@ namespace Femto.Api.Controllers.Posts;
[ApiController] [ApiController]
[Route("posts")] [Route("posts")]
public class PostsController(IMediator mediator) : ControllerBase public class PostsController(IBlogModule blogModule) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<GetAllPublicPostsResponse>> GetAllPublicPosts( public async Task<ActionResult<GetAllPublicPostsResponse>> GetAllPublicPosts(
@ -16,11 +18,13 @@ public class PostsController(IMediator mediator) : ControllerBase
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var res = await mediator.Send( var res = await blogModule.PostQuery(
new GetPostsQuery new GetPostsQuery
{ {
From = searchParams.From, From = searchParams.From,
Amount = searchParams.Amount ?? 20 Amount = searchParams.Amount ?? 20,
AuthorId = searchParams.AuthorId,
Author = searchParams.Author,
}, },
cancellationToken cancellationToken
); );
@ -30,7 +34,7 @@ public class PostsController(IMediator mediator) : ControllerBase
new PublicPostAuthorDto(p.Author.AuthorId, p.Author.Username), new PublicPostAuthorDto(p.Author.AuthorId, p.Author.Username),
p.PostId, p.PostId,
p.Text, p.Text,
p.Media.Select(m => m.Url), p.Media.Select(m => new PublicPostMediaDto(m.Url, m.Width, m.Height)),
p.CreatedAt p.CreatedAt
)), )),
res.Next res.Next
@ -43,8 +47,22 @@ public class PostsController(IMediator mediator) : ControllerBase
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var guid = await mediator.Send( var guid = await blogModule.PostCommand(
new CreatePostCommand(req.AuthorId, req.Content, req.Media), new CreatePostCommand(
req.AuthorId,
req.Content,
req.Media.Select(
(media, idx) =>
new CreatePostMedia(
media.MediaId,
media.Url,
media.Type,
idx,
media.Width,
media.Height
)
)
),
cancellationToken cancellationToken
); );

View file

@ -9,6 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Autofac" Version="8.3.0" />
<PackageReference Include="MediatR" Version="12.5.0" /> <PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3"/>
@ -17,6 +18,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Femto.Modules.Blog\Femto.Modules.Blog.csproj" /> <ProjectReference Include="..\Femto.Modules.Blog\Femto.Modules.Blog.csproj" />
<ProjectReference Include="..\Femto.Modules.Authentication\Femto.Modules.Authentication.csproj" />
<ProjectReference Include="..\Femto.Modules.Media\Femto.Modules.Media.csproj" /> <ProjectReference Include="..\Femto.Modules.Media\Femto.Modules.Media.csproj" />
</ItemGroup> </ItemGroup>

View file

@ -1,8 +1,8 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Femto.Modules.Blog; using Femto.Modules.Blog.Application;
using Femto.Modules.Media; using Femto.Modules.Media;
using Quartz; using Femto.Modules.Media.Application;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -16,13 +16,9 @@ var blobStorageRoot = builder.Configuration.GetValue<string>("BlobStorageRoot");
if (blobStorageRoot is null) if (blobStorageRoot is null)
throw new Exception("no blob storage root found"); throw new Exception("no blob storage root found");
builder.Services.AddQuartzHostedService(options => builder.Services.InitializeBlogModule(databaseConnectionString);
{ builder.Services.InitializeMediaModule(databaseConnectionString, blobStorageRoot);
options.WaitForJobsToComplete = true; // builder.Services.UseIdentityModule(databaseConnectionString);
});
builder.Services.UseBlogModule(databaseConnectionString);
builder.Services.UseMediaModule(databaseConnectionString, blobStorageRoot);
builder.Services.AddControllers(); builder.Services.AddControllers();
@ -36,6 +32,7 @@ builder.Services.AddCors(options =>
} }
); );
}); });
builder builder
.Services.AddControllers() .Services.AddControllers()
.AddJsonOptions(options => .AddJsonOptions(options =>

View file

@ -1,3 +1,7 @@
namespace Femto.Common.Domain; namespace Femto.Common.Domain;
public class DomainException(string message) : Exception(message); public class DomainException : Exception
{
public DomainException(string message, Exception innerException) : base(message, innerException) {}
public DomainException(string message) : base(message) {}
}

View file

@ -0,0 +1,7 @@
using MediatR;
namespace Femto.Common.Domain;
public interface ICommand : IRequest;
public interface ICommand<out T> : IRequest<T>;

View file

@ -0,0 +1,5 @@
using MediatR;
namespace Femto.Common.Domain;
public interface IQuery<out T> : IRequest<T>;

View file

@ -8,9 +8,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" Version="12.5.0" /> <PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.4" />
<ItemGroup> <PackageReference Include="Npgsql" Version="9.0.3" />
<Folder Include="Infrastructure\" /> <PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,8 +1,7 @@
using System.Data; using System.Data;
using Microsoft.Data.SqlClient;
using Npgsql; using Npgsql;
namespace Femto.Modules.Blog.Infrastructure.DbConnection; namespace Femto.Common.Infrastructure.DbConnection;
public class DbConnectionFactory(string connectionString) : IDbConnectionFactory public class DbConnectionFactory(string connectionString) : IDbConnectionFactory
{ {

View file

@ -1,7 +1,6 @@
using System.Data; using System.Data;
using Microsoft.Data.SqlClient;
namespace Femto.Modules.Blog.Infrastructure.DbConnection; namespace Femto.Common.Infrastructure.DbConnection;
public interface IDbConnectionFactory public interface IDbConnectionFactory
{ {

View file

@ -0,0 +1,18 @@
namespace Femto.Common.Infrastructure.Outbox;
/// <summary>
/// A mapping based on the CLR type name
/// Brittle in the case of types being moved as they will then not be able to be handled by the outbox. but simple in implementation
/// </summary>
public class ClrTypenameMessageMapping : IOutboxMessageMapping
{
public Type? GetTypeOfEvent(string eventName)
{
return Type.GetType(eventName);
}
public string? GetEventName(Type eventType)
{
return eventType.AssemblyQualifiedName;
}
}

View file

@ -0,0 +1,9 @@
using Microsoft.EntityFrameworkCore;
namespace Femto.Common.Infrastructure.Outbox;
public interface IOutboxContext
{
DbSet<OutboxEntry> Outbox { get; }
}

View file

@ -0,0 +1,8 @@
using MediatR;
namespace Femto.Common.Infrastructure.Outbox;
public interface IOutboxMessageHandler
{
Task Publish<TNotification>(TNotification notification, CancellationToken executionContextCancellationToken);
}

View file

@ -0,0 +1,7 @@
namespace Femto.Common.Infrastructure.Outbox;
public interface IOutboxMessageMapping
{
Type? GetTypeOfEvent(string eventName);
string? GetEventName(Type eventType);
}

View file

@ -0,0 +1,29 @@
using System.Reflection;
using System.Text.Json;
using Femto.Common.Attributes;
using Femto.Common.Integration;
using Microsoft.Extensions.DependencyInjection;
namespace Femto.Common.Infrastructure.Outbox;
public class Outbox<TContext>(TContext context, IOutboxMessageMapping mapping) where TContext : IOutboxContext
{
public async Task AddMessage<TMessage>(
Guid aggregateId,
TMessage message,
CancellationToken cancellationToken
)
where TMessage : IIntegrationEvent
{
var eventName = mapping.GetEventName(typeof(TMessage));
if (eventName is null)
throw new InvalidOperationException(
$"{typeof(TMessage).Name} does not have EventType attribute"
);
await context.Outbox.AddAsync(
new(message.EventId, aggregateId, eventName, JsonSerializer.Serialize(message)),
cancellationToken
);
}
}

View file

@ -1,6 +1,6 @@
namespace Femto.Modules.Blog.Infrastructure.Integration.Outbox; namespace Femto.Common.Infrastructure.Outbox;
internal class OutboxEntry public class OutboxEntry
{ {
private const int MaxRetries = 5; private const int MaxRetries = 5;

View file

@ -0,0 +1,6 @@
namespace Femto.Common.Infrastructure.Outbox;
public static class OutboxEventNameRegistry
{
}

View file

@ -1,31 +1,37 @@
using System.Text.Json; using System.Text.Json;
using Femto.Modules.Blog.Data;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Quartz; using Quartz;
namespace Femto.Modules.Blog.Infrastructure.Integration.Outbox; namespace Femto.Common.Infrastructure.Outbox;
[DisallowConcurrentExecution] [DisallowConcurrentExecution]
internal class MailmanJob( public class OutboxProcessor<TContext>(
Outbox outbox, TContext context,
BlogContext context, ILogger<OutboxProcessor<TContext>> logger,
ILogger<MailmanJob> logger, IOutboxMessageMapping mapping,
IMediator mediator IOutboxMessageHandler handler
) : IJob ) : IJob
where TContext : DbContext, IOutboxContext
{ {
public async Task Execute(IJobExecutionContext executionContext) public async Task Execute(IJobExecutionContext executionContext)
{ {
try try
{ {
var messages = await outbox.GetPendingMessages(executionContext.CancellationToken); var now = DateTime.UtcNow;
var messages = await context
.Outbox.Where(message => message.Status == OutboxEntryStatus.Pending)
.Where(message => message.NextRetryAt == null || message.NextRetryAt <= now)
.OrderBy(message => message.CreatedAt)
.ToListAsync(executionContext.CancellationToken);
logger.LogTrace("loaded {Count} outbox messages to process", messages.Count); logger.LogTrace("loaded {Count} outbox messages to process", messages.Count);
foreach (var message in messages) foreach (var message in messages)
{ {
try try
{ {
var notificationType = OutboxMessageTypeRegistry.GetType(message.EventType); var notificationType = mapping.GetTypeOfEvent(message.EventType);
if (notificationType is null) if (notificationType is null)
{ {
@ -50,7 +56,7 @@ internal class MailmanJob(
message.AggregateId message.AggregateId
); );
await mediator.Publish(notification, executionContext.CancellationToken); await handler.Publish(notification, executionContext.CancellationToken);
message.Succeed(); message.Succeed();
} }

View file

@ -0,0 +1,41 @@
using System.Reflection;
using Femto.Common.Attributes;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Quartz;
namespace Femto.Common.Infrastructure.Outbox;
public static class OutboxServiceExtension
{
public static void AddOutbox<TContext>(
this IServiceCollection services,
Func<IServiceProvider, TContext>? contextFactory = null
)
where TContext : DbContext, IOutboxContext
{
services.AddSingleton<IOutboxMessageMapping, ClrTypenameMessageMapping>();
services.AddScoped<IOutboxContext>(c =>
contextFactory?.Invoke(c) ?? c.GetRequiredService<TContext>()
);
services.AddScoped<Outbox<TContext>>();
services.AddQuartz(q =>
{
var jobKey = JobKey.Create(nameof(OutboxProcessor<TContext>));
q.AddJob<OutboxProcessor<TContext>>(jobKey)
.AddTrigger(trigger =>
trigger
.ForJob(jobKey)
.WithSimpleSchedule(schedule =>
schedule.WithIntervalInSeconds(1).RepeatForever()
)
);
});
}
}

View file

@ -1,12 +1,12 @@
using Femto.Common.Domain; using Femto.Common.Domain;
using Femto.Modules.Blog.Data;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Femto.Modules.Blog.Infrastructure.PipelineBehaviours; namespace Femto.Common.Infrastructure;
internal class SaveChangesPipelineBehaviour<TRequest, TResponse>( public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
BlogContext context, DbContext context,
IPublisher publisher, IPublisher publisher,
ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger
) : IPipelineBehavior<TRequest, TResponse> ) : IPipelineBehavior<TRequest, TResponse>
@ -22,8 +22,7 @@ internal class SaveChangesPipelineBehaviour<TRequest, TResponse>(
if (context.ChangeTracker.HasChanges()) if (context.ChangeTracker.HasChanges())
{ {
await this.EmitDomainEvents(cancellationToken);
await EmitDomainEvents(cancellationToken);
logger.LogDebug("saving changes"); logger.LogDebug("saving changes");
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);

View file

@ -1,21 +1,24 @@
using System.Collections.Immutable;
using System.Reflection; using System.Reflection;
using Femto.Common.Attributes; using Femto.Common.Attributes;
using MediatR; using MediatR;
namespace Femto.Modules.Blog.Contracts; namespace Femto.Common.Util;
public static class Module public static class EventTypeMapping
{ {
public static IDictionary<string, Type> GetIntegrationEventTypes() public static IDictionary<string, Type> GetEventTypeMapping(Assembly assembly)
{ {
var mapping = new Dictionary<string, Type>(); var mapping = new Dictionary<string, Type>();
var types = typeof(Module).Assembly.GetTypes(); var types = assembly.GetTypes();
foreach (var type in types) foreach (var type in types)
{ {
if (!typeof(INotification).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface) if (
!typeof(INotification).IsAssignableFrom(type)
|| type.IsAbstract
|| type.IsInterface
)
continue; continue;
var attribute = type.GetCustomAttribute<EventTypeAttribute>(); var attribute = type.GetCustomAttribute<EventTypeAttribute>();

View file

@ -22,6 +22,9 @@ CREATE TABLE blog.post_media
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
post_id uuid NOT NULL REFERENCES blog.post (id) ON DELETE CASCADE, post_id uuid NOT NULL REFERENCES blog.post (id) ON DELETE CASCADE,
url text NOT NULL, url text NOT NULL,
type varchar(64),
width int,
height int,
ordering int NOT NULL ordering int NOT NULL
); );

View file

@ -0,0 +1,23 @@
using Femto.Modules.Authentication.Data;
using Femto.Modules.Authentication.Models;
namespace Femto.Modules.Authentication.Contracts;
internal class AuthenticationService(AuthenticationContext 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

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

View file

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

View file

@ -0,0 +1,16 @@
using Femto.Common.Infrastructure.Outbox;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Authentication.Data;
internal class AuthenticationContext : DbContext, IOutboxContext
{
public virtual DbSet<OutboxEntry> Outbox { get; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.HasDefaultSchema("authn");
builder.ApplyConfigurationsFromAssembly(typeof(AuthenticationContext).Assembly);
}
}

View file

@ -0,0 +1,14 @@
using Femto.Modules.Authentication.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Femto.Modules.Authentication.Data.Configurations;
internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration<UserIdentity>
{
public void Configure(EntityTypeBuilder<UserIdentity> builder)
{
builder.ToTable("user_identity");
builder.OwnsOne(u => u.Password).WithOwner().HasForeignKey("user_id");
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="8.3.0" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Geralt" Version="3.3.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,5 @@
using Femto.Common.Domain;
namespace Femto.Modules.Authentication.Models.Events;
internal record UserWasCreatedEvent(UserIdentity User) : DomainEvent;

View file

@ -0,0 +1,52 @@
using System.Text;
using Femto.Common.Domain;
using Femto.Modules.Authentication.Contracts;
using Femto.Modules.Authentication.Models.Events;
using Geralt;
namespace Femto.Modules.Authentication.Models;
internal class UserIdentity : Entity
{
public Guid Id { get; private set; }
public string Username { get; private set; }
public UserPassword Password { get; private set; }
private UserIdentity()
{
}
public UserIdentity(string username)
{
this.Id = Guid.CreateVersion7();
this.Username = username;
this.AddDomainEvent(new UserWasCreatedEvent(this));
}
public UserIdentity WithPassword(string password)
{
this.SetPassword(password);
return this;
}
public void SetPassword(string password)
{
var hash = new byte[128];
try
{
Argon2id.ComputeHash(hash, Encoding.UTF8.GetBytes(password), 3, 67108864);
}
catch (Exception e)
{
throw new SetPasswordError("Failed to hash password", e);
}
this.Password = new UserPassword(this.Id, hash, new byte[128]);
}
}
public class SetPasswordError(string message, Exception inner) : DomainException(message, inner);

View file

@ -0,0 +1,19 @@
namespace Femto.Modules.Authentication.Models;
internal class UserPassword
{
public Guid Id { get; private set; }
public byte[] Hash { get; private set; }
public byte[] Salt { get; private set; }
private UserPassword() {}
public UserPassword(Guid id, byte[] hash, byte[] salt)
{
Id = id;
Hash = hash;
Salt = salt;
}
}

View file

@ -0,0 +1,57 @@
using Femto.Common.Infrastructure.Outbox;
using Femto.Modules.Authentication.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Authentication;
public static class Module
{
public static void UseIdentityModule(this IServiceCollection services, string connectionString)
{
services.AddDbContext<AuthenticationContext>(
builder =>
{
builder.UseNpgsql(connectionString);
builder.UseSnakeCaseNamingConvention();
});
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(Module).Assembly));
services.AddDbContext<AuthenticationContext>(builder =>
{
builder.UseNpgsql(
connectionString,
o =>
{
o.MapEnum<OutboxEntryStatus>("outbox_status");
}
);
builder.UseSnakeCaseNamingConvention();
var loggerFactory = LoggerFactory.Create(b =>
{
// b.AddConsole();
// .AddFilter(
// (category, level) =>
// category == DbLoggerCategory.Database.Command.Name
// && level == LogLevel.Debug
// );
});
builder.UseLoggerFactory(loggerFactory);
builder.EnableSensitiveDataLogging();
});
// services.AddOutbox<AuthenticationContext>();
services.AddMediatR(c =>
{
c.RegisterServicesFromAssembly(typeof(Module).Assembly);
});
services.AddTransient<Outbox<AuthenticationContext>, Outbox<AuthenticationContext>>();
}
}

View file

@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Folder Include="Queries\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,11 @@
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Blog.Application;
public class BlogApplication(IHost host) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await host.RunAsync(stoppingToken);
}
}

View file

@ -1,11 +1,10 @@
using Femto.Common.Infrastructure.Outbox;
using Femto.Modules.Blog.Domain.Posts; using Femto.Modules.Blog.Domain.Posts;
using Femto.Modules.Blog.Infrastructure.Integration;
using Femto.Modules.Blog.Infrastructure.Integration.Outbox;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Data; namespace Femto.Modules.Blog.Application;
internal class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options) 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<OutboxEntry> Outbox { get; set; } public virtual DbSet<OutboxEntry> Outbox { get; set; }

View file

@ -0,0 +1,38 @@
using Femto.Common.Domain;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Blog.Application;
internal class BlogModule(IHost host) : IBlogModule
{
public async Task PostCommand(ICommand command, CancellationToken cancellationToken = default)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(command, cancellationToken);
}
public async Task<TResponse> PostCommand<TResponse>(
ICommand<TResponse> command,
CancellationToken cancellationToken = default
)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(command, cancellationToken);
return response;
}
public async Task<TResponse> PostQuery<TResponse>(
IQuery<TResponse> query,
CancellationToken cancellationToken = default
)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(query, cancellationToken);
return response;
}
}

View file

@ -0,0 +1,62 @@
using Femto.Common.Infrastructure;
using Femto.Common.Infrastructure.DbConnection;
using Femto.Common.Infrastructure.Outbox;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Quartz;
namespace Femto.Modules.Blog.Application;
public static class BlogStartup
{
public static void InitializeBlogModule(
this IServiceCollection rootContainer,
string connectionString
)
{
var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString));
var host = hostBuilder.Build();
rootContainer.AddHostedService(services => new BlogApplication(host));
rootContainer.AddScoped<IBlogModule>(_ => new BlogModule(host));
}
private static void ConfigureServices(this IServiceCollection services, string connectionString)
{
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
services.AddDbContext<BlogContext>(builder =>
{
builder.UseNpgsql(
connectionString,
o =>
{
o.MapEnum<OutboxEntryStatus>("outbox_status");
}
);
builder.UseSnakeCaseNamingConvention();
var loggerFactory = LoggerFactory.Create(b => { });
builder.UseLoggerFactory(loggerFactory);
builder.EnableSensitiveDataLogging();
});
services.AddMediatR(c =>
{
c.RegisterServicesFromAssembly(typeof(BlogStartup).Assembly);
});
services.AddScoped<DbContext>(s => s.GetRequiredService<BlogContext>());
services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(SaveChangesPipelineBehaviour<,>)
);
}
}

View file

@ -0,0 +1,8 @@
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Application.Commands.CreatePost;
public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable<CreatePostMedia> Media)
: ICommand<Guid>;
public record CreatePostMedia(Guid MediaId, Uri Url, string? Type, int Order, int? Width, int? Height);

View file

@ -1,7 +1,7 @@
using Femto.Modules.Blog.Data; using Femto.Modules.Blog.Domain.Posts;
using MediatR; using MediatR;
namespace Femto.Modules.Blog.Domain.Posts.Commands.CreatePost; namespace Femto.Modules.Blog.Application.Commands.CreatePost;
internal class CreatePostCommandHandler(BlogContext context) internal class CreatePostCommandHandler(BlogContext context)
: IRequestHandler<CreatePostCommand, Guid> : IRequestHandler<CreatePostCommand, Guid>
@ -11,7 +11,16 @@ internal class CreatePostCommandHandler(BlogContext context)
var post = new Post( var post = new Post(
request.AuthorId, request.AuthorId,
request.Content, request.Content,
request.Media.Select((url, idx) => new PostMedia(Guid.CreateVersion7(), url, idx)).ToList() request
.Media.Select(media => new PostMedia(
media.MediaId,
media.Url,
media.Type,
media.Order,
media.Width,
media.Height
))
.ToList()
); );
await context.AddAsync(post, cancellationToken); await context.AddAsync(post, cancellationToken);

View file

@ -1,9 +1,8 @@
using Femto.Modules.Blog.Infrastructure.Integration; using Femto.Common.Infrastructure.Outbox;
using Femto.Modules.Blog.Infrastructure.Integration.Outbox;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Femto.Modules.Blog.Data.Configurations; namespace Femto.Modules.Blog.Application.Configurations;
internal class OutboxEntryConfiguration : IEntityTypeConfiguration<OutboxEntry> internal class OutboxEntryConfiguration : IEntityTypeConfiguration<OutboxEntry>
{ {
@ -11,7 +10,6 @@ internal class OutboxEntryConfiguration : IEntityTypeConfiguration<OutboxEntry>
{ {
builder.ToTable("outbox"); builder.ToTable("outbox");
builder.Property(x => x.Payload) builder.Property(x => x.Payload).HasColumnType("jsonb");
.HasColumnType("jsonb");
} }
} }

View file

@ -2,7 +2,7 @@ using Femto.Modules.Blog.Domain.Posts;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Femto.Modules.Blog.Data.Configurations; namespace Femto.Modules.Blog.Application.Configurations;
internal class PostConfiguration : IEntityTypeConfiguration<Post> internal class PostConfiguration : IEntityTypeConfiguration<Post>
{ {

View file

@ -0,0 +1,18 @@
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Application;
public interface IBlogModule
{
Task PostCommand(ICommand command, CancellationToken cancellationToken = default);
Task<TResponse> PostCommand<TResponse>(
ICommand<TResponse> command,
CancellationToken cancellationToken = default
);
Task<TResponse> PostQuery<TResponse>(
IQuery<TResponse> query,
CancellationToken cancellationToken = default
);
}

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto; namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record GetPostsQueryResult(IList<PostDto> Posts, Guid? Next); public record GetPostsQueryResult(IList<PostDto> Posts, Guid? Next);

View file

@ -0,0 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostAuthorDto(Guid AuthorId, string Username);

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto; namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostDto(Guid PostId, string Text, IList<PostMediaDto> Media, DateTimeOffset CreatedAt, PostAuthorDto Author); public record PostDto(Guid PostId, string Text, IList<PostMediaDto> Media, DateTimeOffset CreatedAt, PostAuthorDto Author);

View file

@ -0,0 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostMediaDto(Uri Url, int? Width, int? Height);

View file

@ -1,21 +1,21 @@
using Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto; using Femto.Common.Domain;
using MediatR; using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts; namespace Femto.Modules.Blog.Application.Queries.GetPosts;
public class GetPostsQuery : IRequest<GetPostsQueryResult> public class GetPostsQuery : IQuery<GetPostsQueryResult>
{ {
public string? Username { get; init; }
public Guid? From { get; init; } public Guid? From { get; init; }
public int Amount { get; init; } = 20; public int Amount { get; init; } = 20;
public Guid? AuthorGuid { get; init; } public Guid? AuthorId { get; init; }
public string? Author { get; set; }
/// <summary> /// <summary>
/// Default is to load in reverse chronological order /// Default is to load in reverse chronological order
/// TODO this is not exposed on the client as it probably wouldn't work that well /// TODO this is not exposed on the client as it probably wouldn't work that well
/// </summary> /// </summary>
public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward; public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
} }
public enum GetPostsDirection public enum GetPostsDirection

View file

@ -1,9 +1,9 @@
using Dapper; using Dapper;
using Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto; using Femto.Common.Infrastructure.DbConnection;
using Femto.Modules.Blog.Infrastructure.DbConnection; using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
using MediatR; using MediatR;
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts; namespace Femto.Modules.Blog.Application.Queries.GetPosts;
public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
: IRequestHandler<GetPostsQuery, GetPostsQueryResult> : IRequestHandler<GetPostsQuery, GetPostsQueryResult>
@ -15,12 +15,6 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
{ {
using var conn = connectionFactory.GetConnection(); using var conn = connectionFactory.GetConnection();
if (query.Username is not null && query.AuthorGuid is not null)
throw new ArgumentException(
"Cannot specify both username and authorGuid",
nameof(query)
);
var orderBy = query.Direction is GetPostsDirection.Backward ? "desc" : "asc"; var orderBy = query.Direction is GetPostsDirection.Backward ? "desc" : "asc";
var pageFilter = query.Direction is GetPostsDirection.Backward ? "<=" : ">="; var pageFilter = query.Direction is GetPostsDirection.Backward ? "<=" : ">=";
@ -40,6 +34,8 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
page.id as PostId, page.id as PostId,
page.content as Content, page.content as Content,
blog.post_media.url as MediaUrl, blog.post_media.url as MediaUrl,
blog.post_media.width as MediaWidth,
blog.post_media.height as MediaHeight,
page.posted_on as PostedOn, page.posted_on as PostedOn,
page.Username, page.Username,
page.AuthorId page.AuthorId
@ -52,8 +48,8 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
sql, sql,
new new
{ {
username = query.Username, username = query.Author,
authorGuid = query.AuthorGuid, authorGuid = query.AuthorId,
cursor = query.From, cursor = query.From,
// load an extra one to take for the curst // load an extra one to take for the curst
amount = query.Amount + 1, amount = query.Amount + 1,
@ -68,9 +64,20 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
var postId = group.Key; var postId = group.Key;
var post = group.First(); var post = group.First();
var media = group var media = group
.Select(row => row.MediaUrl) .Select(row =>
.OfType<string>() {
.Select(url => new PostMediaDto(new Uri(url))) if (row.MediaUrl is not null)
{
return new PostMediaDto(
new Uri(row.MediaUrl),
row.MediaHeight,
row.MediaHeight
);
}
else
return null;
})
.OfType<PostMediaDto>()
.ToList(); .ToList();
return new PostDto( return new PostDto(
postId, postId,
@ -92,6 +99,9 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
public Guid PostId { get; set; } public Guid PostId { get; set; }
public string Content { get; set; } public string Content { get; set; }
public string? MediaUrl { get; set; } public string? MediaUrl { get; set; }
public string? MediaType { get; set; }
public int? MediaWidth { get; set; }
public int? MediaHeight { get; set; }
public DateTimeOffset PostedOn { get; set; } public DateTimeOffset PostedOn { get; set; }
public Guid AuthorId { get; set; } public Guid AuthorId { get; set; }
public string Username { get; set; } public string Username { get; set; }

View file

@ -1,7 +0,0 @@
namespace Femto.Modules.Blog.Domain.Authors;
internal class Author
{
public string Id { get; private set; } = null!;
public string Name { get; private set; } = null!;
}

View file

@ -1,6 +0,0 @@
using MediatR;
namespace Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable<Uri> Media)
: IRequest<Guid>;

View file

@ -1,3 +0,0 @@
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto;
public record PostAuthorDto(Guid AuthorId, string Username);

View file

@ -1,3 +0,0 @@
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto;
public record PostMediaDto(Uri Url);

View file

@ -1,20 +0,0 @@
using Femto.Modules.Blog.Contracts.Events;
using Femto.Modules.Blog.Domain.Posts.Events;
using Femto.Modules.Blog.Infrastructure.Integration;
using Femto.Modules.Blog.Infrastructure.Integration.Outbox;
using MediatR;
namespace Femto.Modules.Blog.Domain.Posts.Handlers;
internal class PostCreatedHandler(Outbox outbox) : INotificationHandler<PostCreated>
{
public async Task Handle(PostCreated notification, CancellationToken cancellationToken)
{
var post = notification.Post;
await outbox.AddMessage(
post.Id,
new PostCreatedIntegrationEvent(Guid.CreateVersion7(), post.Id, post.Media.Select(m => m.Id)),
cancellationToken
);
}
}

View file

@ -4,14 +4,20 @@ internal class PostMedia
{ {
public Guid Id { get; private set; } public Guid Id { get; private set; }
public Uri Url { get; private set; } public Uri Url { get; private set; }
public string? Type { get; private set; }
public int Ordering { get; private set; } public int Ordering { get; private set; }
public int? Width { get; private set; }
public int? Height { get; private set; }
private PostMedia() {} private PostMedia() {}
public PostMedia(Guid id, Uri url, int ordering) public PostMedia(Guid id, Uri url, string type, int ordering, int? width, int? height)
{ {
this.Id = id; this.Id = id;
this.Url = url; this.Url = url;
this.Type = type;
this.Ordering = ordering; this.Ordering = ordering;
this.Width = width;
this.Height = height;
} }
} }

View file

@ -1,7 +1,7 @@
using Femto.Common.Attributes; using Femto.Common.Attributes;
using Femto.Common.Integration; using Femto.Common.Integration;
namespace Femto.Modules.Blog.Contracts.Events; namespace Femto.Modules.Blog.Events;
[EventType("post.created")] [EventType("post.created")]
public record PostCreatedIntegrationEvent(Guid EventId, Guid PostId, IEnumerable<Guid> MediaIds) public record PostCreatedIntegrationEvent(Guid EventId, Guid PostId, IEnumerable<Guid> MediaIds)

View file

@ -9,12 +9,15 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Autofac" Version="8.3.0" />
<PackageReference Include="Dapper" Version="2.1.66" /> <PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" /> <PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Quartz" Version="3.14.0" /> <PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" /> <PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
@ -41,7 +44,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" /> <ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
<ProjectReference Include="..\Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,4 +1,4 @@
using Femto.Modules.Blog.Contracts.Events; using Femto.Modules.Blog.Events;
using MediatR; using MediatR;
namespace Femto.Modules.Blog.Handlers; namespace Femto.Modules.Blog.Handlers;

View file

@ -1,40 +0,0 @@
using System.Reflection;
using System.Text.Json;
using Femto.Common.Attributes;
using Femto.Common.Integration;
using Femto.Modules.Blog.Data;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Infrastructure.Integration.Outbox;
internal class Outbox(BlogContext context)
{
public async Task AddMessage<TMessage>(Guid aggregateId, TMessage message, CancellationToken cancellationToken)
where TMessage : IIntegrationEvent
{
var eventType = typeof(TMessage).GetCustomAttribute<EventTypeAttribute>();
if (eventType is null)
throw new InvalidOperationException($"{typeof(TMessage).Name} does not have EventType attribute");
await context.Outbox.AddAsync(
new(
message.EventId,
aggregateId,
eventType.Name,
JsonSerializer.Serialize(message)
),
cancellationToken
);
}
public async Task<IList<OutboxEntry>> GetPendingMessages(CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
return await context
.Outbox.Where(message => message.Status == OutboxEntryStatus.Pending)
.Where(message => message.NextRetryAt == null || message.NextRetryAt <= now)
.OrderBy(message => message.CreatedAt)
.ToListAsync(cancellationToken);
}
}

View file

@ -1,20 +0,0 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace Femto.Modules.Blog.Infrastructure.Integration.Outbox;
internal static class OutboxMessageTypeRegistry
{
private static readonly ConcurrentDictionary<string, Type> Mapping = new();
public static void RegisterOutboxMessages(IImmutableDictionary<string, Type> mapping)
{
foreach (var (key, value) in mapping)
{
Mapping.TryAdd(key, value);
}
}
public static Type? GetType(string eventName) => Mapping.GetValueOrDefault(eventName);
}

View file

@ -1,92 +0,0 @@
using System.Collections.Immutable;
using Femto.Modules.Blog.Data;
using Femto.Modules.Blog.Infrastructure;
using Femto.Modules.Blog.Infrastructure.DbConnection;
using Femto.Modules.Blog.Infrastructure.Integration;
using Femto.Modules.Blog.Infrastructure.Integration.Outbox;
using Femto.Modules.Blog.Infrastructure.PipelineBehaviours;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Quartz;
namespace Femto.Modules.Blog;
public static class Module
{
public static void UseBlogModule(this IServiceCollection services, string connectionString)
{
OutboxMessageTypeRegistry.RegisterOutboxMessages(
Contracts.Module.GetIntegrationEventTypes().ToImmutableDictionary()
);
services.AddDbContext<BlogContext>(builder =>
{
builder.UseNpgsql(
connectionString,
o =>
{
o.MapEnum<OutboxEntryStatus>("outbox_status");
}
);
;
builder.UseSnakeCaseNamingConvention();
var loggerFactory = LoggerFactory.Create(b =>
{
// b.AddConsole();
// .AddFilter(
// (category, level) =>
// category == DbLoggerCategory.Database.Command.Name
// && level == LogLevel.Debug
// );
});
builder.UseLoggerFactory(loggerFactory);
builder.EnableSensitiveDataLogging();
});
services.AddMediatR(c =>
{
c.RegisterServicesFromAssembly(typeof(Module).Assembly);
});
services.AddQuartz(q =>
{
q.AddMailmanJob();
});
services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
services.SetupMediatrPipeline();
services.AddTransient<Outbox, Outbox>();
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
}
private static void SetupMediatrPipeline(this IServiceCollection services)
{
services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(SaveChangesPipelineBehaviour<,>)
);
}
private static void AddMailmanJob(this IServiceCollectionQuartzConfigurator q)
{
var jobKey = JobKey.Create(nameof(MailmanJob));
q.AddJob<MailmanJob>(jobKey)
.AddTrigger(trigger =>
trigger
.ForJob(jobKey)
.WithSimpleSchedule(schedule =>
schedule.WithIntervalInSeconds(1).RepeatForever()
)
);
}
}

View file

@ -0,0 +1,9 @@
using Femto.Common.Domain;
namespace Femto.Modules.Media.Application;
public interface IMediaModule
{
Task<TResult> PostCommand<TResult>(ICommand<TResult> command);
Task<TResult> PostQuery<TResult>(IQuery<TResult> query);
}

View file

@ -0,0 +1,11 @@
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Media.Application;
public class MediaApplication(IHost host) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await host.RunAsync(stoppingToken);
}
}

View file

@ -0,0 +1,25 @@
using Femto.Common.Domain;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Media.Application;
public class MediaModule(IHost host) : IMediaModule
{
public async Task<TResult> PostCommand<TResult>(ICommand<TResult> command)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(command);
return response;
}
public async Task<TResult> PostQuery<TResult>(IQuery<TResult> query)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(query);
return response;
}
}

View file

@ -0,0 +1,30 @@
using Femto.Modules.Media.Data;
using Femto.Modules.Media.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Media.Application;
public static class Startup
{
public static void InitializeMediaModule(this IServiceCollection rootContainer, string connectionString, string storageRoot)
{
var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services =>
{
services.AddDbContext<MediaContext>(builder =>
{
builder.UseNpgsql(connectionString);
builder.UseSnakeCaseNamingConvention();
});
services.AddTransient<IStorageProvider>(s => new FilesystemStorageProvider(storageRoot));
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(Startup).Assembly));
});
var host = hostBuilder.Build();
rootContainer.AddTransient<IMediaModule>(_ => new MediaModule(host));
}
}

View file

@ -20,9 +20,11 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Autofac" Version="8.3.0" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" 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" />
</ItemGroup> </ItemGroup>

View file

@ -1,22 +0,0 @@
using Femto.Modules.Media.Data;
using Femto.Modules.Media.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Media;
public static class Module
{
public static void UseMediaModule(this IServiceCollection services, string connectionString, string storageRoot)
{
services.AddDbContext<MediaContext>(builder =>
{
builder.UseNpgsql(connectionString);
builder.UseSnakeCaseNamingConvention();
});
services.AddTransient<IStorageProvider>(s => new FilesystemStorageProvider(storageRoot));
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(Module).Assembly));
}
}

View file

@ -10,10 +10,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Docs", "Femto.Docs\Fe
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Common", "Femto.Common\Femto.Common.csproj", "{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Common", "Femto.Common\Femto.Common.csproj", "{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Blog.Contracts", "Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj", "{35C42036-D53B-42EB-9A1C-B540E55F4FD0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Media", "Femto.Modules.Media\Femto.Modules.Media.csproj", "{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Media", "Femto.Modules.Media\Femto.Modules.Media.csproj", "{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Authentication", "Femto.Modules.Authentication\Femto.Modules.Authentication.csproj", "{7E138EF6-E075-4896-93C0-923024F0CA78}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -40,14 +40,14 @@ Global
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Debug|Any CPU.Build.0 = Debug|Any CPU {52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Debug|Any CPU.Build.0 = Debug|Any CPU
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Release|Any CPU.ActiveCfg = Release|Any CPU {52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Release|Any CPU.ActiveCfg = Release|Any CPU
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Release|Any CPU.Build.0 = Release|Any CPU {52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Release|Any CPU.Build.0 = Release|Any CPU
{35C42036-D53B-42EB-9A1C-B540E55F4FD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{35C42036-D53B-42EB-9A1C-B540E55F4FD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{35C42036-D53B-42EB-9A1C-B540E55F4FD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{35C42036-D53B-42EB-9A1C-B540E55F4FD0}.Release|Any CPU.Build.0 = Release|Any CPU
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Debug|Any CPU.Build.0 = Debug|Any CPU {AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Release|Any CPU.ActiveCfg = Release|Any CPU {AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Release|Any CPU.Build.0 = Release|Any CPU {AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Release|Any CPU.Build.0 = Release|Any CPU
{7E138EF6-E075-4896-93C0-923024F0CA78}.Debug|Any CPU.ActiveCfg = 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.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
EndGlobalSection EndGlobalSection