init
This commit is contained in:
commit
ab2e20f7e1
72 changed files with 2000 additions and 0 deletions
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
out/
|
||||||
|
*.dll
|
||||||
|
*.exe
|
||||||
|
*.app
|
||||||
|
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.suo
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
*.sln.iml
|
||||||
|
.idea/.idea.DotSettings
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
*.nupkg
|
||||||
|
.nuget/
|
||||||
|
packages/
|
||||||
|
*.snupkg
|
||||||
|
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
secrets.json
|
||||||
|
appsettings.*.json
|
||||||
|
!appsettings.json
|
28
Femto.Api/Controllers/Authors/AuthorsController.cs
Normal file
28
Femto.Api/Controllers/Authors/AuthorsController.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
using Femto.Api.Controllers.Authors.Dto;
|
||||||
|
using Femto.Modules.Blog.Domain.Posts.Commands.GetAuthorPosts;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Femto.Api.Controllers.Authors;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("authors")]
|
||||||
|
public class AuthorsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("{authorId}/posts")]
|
||||||
|
public async Task<ActionResult<GetAuthorPostsResponse>> GetAuthorPosts(
|
||||||
|
Guid authorId,
|
||||||
|
[FromQuery] GetAuthorPostsSearchParams searchParams,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var posts = await mediator.Send(
|
||||||
|
new GetAuthorPostsQuery(authorId, searchParams.Cursor, searchParams.Count),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return new GetAuthorPostsResponse(
|
||||||
|
posts.Select(p => new AuthorPostDto(p.PostId, p.Text, p.Media.Select(m => m.Url)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
|
namespace Femto.Api.Controllers.Authors.Dto;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record GetAuthorPostsResponse(IEnumerable<AuthorPostDto> Posts);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record AuthorPostDto(Guid PostId, string Content, IEnumerable<Uri> Media);
|
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Authors;
|
||||||
|
|
||||||
|
public record GetAuthorPostsSearchParams(Guid? Cursor, int? Count);
|
3
Femto.Api/Controllers/Posts/Dto/CreatePostRequest.cs
Normal file
3
Femto.Api/Controllers/Posts/Dto/CreatePostRequest.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
|
public record CreatePostRequest(Guid AuthorId, string Content, IEnumerable<Uri> Media);
|
3
Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs
Normal file
3
Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
|
public record CreatePostResponse(Guid PostId);
|
6
Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
Normal file
6
Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
|
public record GetPostResponse
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
25
Femto.Api/Controllers/Posts/PostsController.cs
Normal file
25
Femto.Api/Controllers/Posts/PostsController.cs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
using Femto.Api.Controllers.Posts.Dto;
|
||||||
|
using Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Femto.Api.Controllers.Posts;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("posts")]
|
||||||
|
public class PostsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<CreatePostResponse>> Post(
|
||||||
|
[FromBody] CreatePostRequest req,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var guid = await mediator.Send(
|
||||||
|
new CreatePostCommand(req.AuthorId, req.Content, req.Media),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return new CreatePostResponse(guid);
|
||||||
|
}
|
||||||
|
}
|
28
Femto.Api/Femto.Api.csproj
Normal file
28
Femto.Api/Femto.Api.csproj
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
<LangVersion>13</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3"/>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Femto.Modules.Blog\Femto.Modules.Blog.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Microsoft.Data.SqlClient">
|
||||||
|
<HintPath>..\..\..\..\.nuget\packages\microsoft.data.sqlclient\6.0.1\ref\net9.0\Microsoft.Data.SqlClient.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
39
Femto.Api/Program.cs
Normal file
39
Femto.Api/Program.cs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Femto.Modules.Blog;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
var databaseConnectionString = builder.Configuration.GetConnectionString("Database");
|
||||||
|
if (databaseConnectionString is null)
|
||||||
|
throw new Exception("no database connection string found");
|
||||||
|
|
||||||
|
builder.Services.UseBlogModule(databaseConnectionString);
|
||||||
|
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
|
builder
|
||||||
|
.Services.AddControllers()
|
||||||
|
.AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
|
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
|
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.Run();
|
23
Femto.Api/Properties/launchSettings.json
Normal file
23
Femto.Api/Properties/launchSettings.json
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5181",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7269;http://localhost:5181",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
Femto.Api/appsettings.json
Normal file
9
Femto.Api/appsettings.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
7
Femto.Common/Attributes/EventTypeAttribute.cs
Normal file
7
Femto.Common/Attributes/EventTypeAttribute.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Femto.Common.Attributes;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||||
|
public class EventTypeAttribute(string name) : Attribute
|
||||||
|
{
|
||||||
|
public string Name { get; } = name;
|
||||||
|
}
|
3
Femto.Common/Domain/DomainException.cs
Normal file
3
Femto.Common/Domain/DomainException.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
|
public class DomainException(string message) : Exception(message);
|
21
Femto.Common/Domain/Entity.cs
Normal file
21
Femto.Common/Domain/Entity.cs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
|
public abstract class Entity
|
||||||
|
{
|
||||||
|
private readonly ICollection<IDomainEvent> _domainEvents = [];
|
||||||
|
|
||||||
|
protected void AddDomainEvent(IDomainEvent evt)
|
||||||
|
{
|
||||||
|
this._domainEvents.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IList<IDomainEvent> DomainEvents => this._domainEvents.ToList();
|
||||||
|
|
||||||
|
public void ClearDomainEvents() => this._domainEvents.Clear();
|
||||||
|
|
||||||
|
protected void CheckRule(IRule rule)
|
||||||
|
{
|
||||||
|
if (!rule.Check())
|
||||||
|
throw new RuleBrokenException(rule.Message);
|
||||||
|
}
|
||||||
|
}
|
13
Femto.Common/Domain/IDomainEvent.cs
Normal file
13
Femto.Common/Domain/IDomainEvent.cs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
|
public interface IDomainEvent : INotification
|
||||||
|
{
|
||||||
|
public Guid EventId { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract record DomainEvent : IDomainEvent
|
||||||
|
{
|
||||||
|
public Guid EventId { get; } = Guid.NewGuid();
|
||||||
|
}
|
8
Femto.Common/Domain/IRule.cs
Normal file
8
Femto.Common/Domain/IRule.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
|
public interface IRule
|
||||||
|
{
|
||||||
|
bool Check();
|
||||||
|
|
||||||
|
string Message { get; }
|
||||||
|
}
|
3
Femto.Common/Domain/RuleBrokenException.cs
Normal file
3
Femto.Common/Domain/RuleBrokenException.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
|
public class RuleBrokenException(string message) : DomainException(message);
|
16
Femto.Common/Femto.Common.csproj
Normal file
16
Femto.Common/Femto.Common.csproj
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Infrastructure\" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
8
Femto.Common/Integration/IIntegrationEvent.cs
Normal file
8
Femto.Common/Integration/IIntegrationEvent.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Common.Integration;
|
||||||
|
|
||||||
|
public interface IIntegrationEvent : INotification
|
||||||
|
{
|
||||||
|
public Guid EventId { get; }
|
||||||
|
}
|
19
Femto.Database/Femto.Database.csproj
Normal file
19
Femto.Database/Femto.Database.csproj
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||||
|
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Migrations\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
44
Femto.Database/Migrations/20250425121459_Init.sql
Normal file
44
Femto.Database/Migrations/20250425121459_Init.sql
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
-- 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,
|
||||||
|
created_on timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
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,
|
||||||
|
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 CURRENT_TIMESTAMP 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
|
||||||
|
|
||||||
|
)
|
122
Femto.Database/Migrator.cs
Normal file
122
Femto.Database/Migrator.cs
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace Femto.Database;
|
||||||
|
|
||||||
|
internal class Migrator(string migrationDirectory, NpgsqlDataSource dataSource) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private record MigrationScript(string Id, string Script);
|
||||||
|
|
||||||
|
public async Task Migrate()
|
||||||
|
{
|
||||||
|
var migrations = await LoadMigrationScripts();
|
||||||
|
|
||||||
|
await this.EnsureMigrationsTableExists();
|
||||||
|
var executedMigrations = await GetExecutedMigrations();
|
||||||
|
|
||||||
|
EnsureConsistentMigrationHistory(migrations, executedMigrations);
|
||||||
|
|
||||||
|
if (executedMigrations.Count == migrations.Count)
|
||||||
|
{
|
||||||
|
Console.WriteLine("up to date");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
await DoMigrations(migrations.Skip(executedMigrations.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IList<MigrationScript>> LoadMigrationScripts()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(migrationDirectory))
|
||||||
|
throw new DirectoryNotFoundException($"Migration directory not found: {migrationDirectory}");
|
||||||
|
|
||||||
|
var scriptFiles = Directory.EnumerateFiles(migrationDirectory, "*.sql")
|
||||||
|
.OrderBy(Path.GetFileName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!scriptFiles.Any())
|
||||||
|
throw new Exception("No migration scripts found in the specified directory.");
|
||||||
|
|
||||||
|
var migrationScripts = new List<MigrationScript>();
|
||||||
|
|
||||||
|
foreach (var file in scriptFiles)
|
||||||
|
{
|
||||||
|
var scriptContent = await File.ReadAllTextAsync(file);
|
||||||
|
var scriptId = Path.GetFileName(file);
|
||||||
|
|
||||||
|
migrationScripts.Add(new MigrationScript(scriptId, scriptContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrationScripts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureMigrationsTableExists()
|
||||||
|
{
|
||||||
|
await using var createSchemaCommand = dataSource.CreateCommand(
|
||||||
|
"""CREATE SCHEMA IF NOT EXISTS __migrations;"""
|
||||||
|
);
|
||||||
|
|
||||||
|
await createSchemaCommand.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
await using var createTableCommand = dataSource.CreateCommand(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS __migrations.__migration_history ( migration VARCHAR(127) PRIMARY KEY );
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
await createTableCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IList<string>> GetExecutedMigrations()
|
||||||
|
{
|
||||||
|
await using var command = dataSource.CreateCommand(
|
||||||
|
"""SELECT migration FROM __migrations.__migration_history"""
|
||||||
|
);
|
||||||
|
var reader = await command.ExecuteReaderAsync();
|
||||||
|
var migrations = new List<string>();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
migrations.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
return migrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureConsistentMigrationHistory(
|
||||||
|
IList<MigrationScript> migrationScripts,
|
||||||
|
IList<string> executedMigrationIds
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (executedMigrationIds.Count > migrationScripts.Count)
|
||||||
|
throw new Exception("inconsistent migration history");
|
||||||
|
|
||||||
|
for (var i = 0; i < executedMigrationIds.Count; i++)
|
||||||
|
{
|
||||||
|
var migration = migrationScripts[i];
|
||||||
|
var executedMigrationId = executedMigrationIds[i];
|
||||||
|
|
||||||
|
if (migration.Id != executedMigrationId)
|
||||||
|
throw new Exception($"unexpected migration in history {executedMigrationId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DoMigrations(IEnumerable<MigrationScript> migrationScripts)
|
||||||
|
{
|
||||||
|
foreach (var migration in migrationScripts)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"applying migration {migration.Id}");
|
||||||
|
await using var command = dataSource.CreateCommand(migration.Script);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
await using var addToHistoryCommand = dataSource.CreateCommand(
|
||||||
|
"""INSERT INTO __migrations.__migration_history (migration) VALUES (@migration)"""
|
||||||
|
);
|
||||||
|
addToHistoryCommand.Parameters.AddWithValue("@migration", migration.Id);
|
||||||
|
await addToHistoryCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return dataSource.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
154
Femto.Database/Program.cs
Normal file
154
Femto.Database/Program.cs
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
using System.CommandLine;
|
||||||
|
using Femto.Database;
|
||||||
|
using Femto.Database.Seed;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
var nameArg = new Argument<string>("name", "the name of the migration");
|
||||||
|
var migrationsDirectoryOption = new Option<string>(
|
||||||
|
["--migrations-directory"],
|
||||||
|
() => "./Migrations",
|
||||||
|
"the directory where the migrations are stored"
|
||||||
|
);
|
||||||
|
|
||||||
|
var newCommand = new Command("new", "creates a new migrations")
|
||||||
|
{
|
||||||
|
nameArg,
|
||||||
|
migrationsDirectoryOption,
|
||||||
|
};
|
||||||
|
newCommand.SetHandler(MakeNewMigration, nameArg, migrationsDirectoryOption);
|
||||||
|
|
||||||
|
var connectionStringArg = new Argument<string>(
|
||||||
|
"--connection-string",
|
||||||
|
"the connection string to the database"
|
||||||
|
);
|
||||||
|
|
||||||
|
var upCommand = new Command("up", "update the database to the most current migration")
|
||||||
|
{
|
||||||
|
migrationsDirectoryOption,
|
||||||
|
connectionStringArg,
|
||||||
|
};
|
||||||
|
upCommand.SetHandler(MigrateUp, migrationsDirectoryOption, connectionStringArg);
|
||||||
|
|
||||||
|
var seedCommand = new Command("seed", "seed the database with test data") { connectionStringArg };
|
||||||
|
seedCommand.SetHandler(Seed, connectionStringArg);
|
||||||
|
|
||||||
|
// Add these near the top with other command definitions
|
||||||
|
var yesOption = new Option<bool>(
|
||||||
|
["-y", "--yes"],
|
||||||
|
"Skip confirmation prompt"
|
||||||
|
);
|
||||||
|
|
||||||
|
var resetCommand = new Command("reset", "drops the existing database, runs migrations, and seeds test data")
|
||||||
|
{
|
||||||
|
connectionStringArg,
|
||||||
|
migrationsDirectoryOption,
|
||||||
|
yesOption
|
||||||
|
};
|
||||||
|
resetCommand.SetHandler(CreateTestDatabase, connectionStringArg, migrationsDirectoryOption, yesOption);
|
||||||
|
|
||||||
|
|
||||||
|
var rootCommand = new RootCommand("migrator") { newCommand, upCommand, seedCommand, resetCommand };
|
||||||
|
|
||||||
|
return await rootCommand.InvokeAsync(args);
|
||||||
|
|
||||||
|
static async Task MakeNewMigration(string name, string migrationsDirectory)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(migrationsDirectory);
|
||||||
|
|
||||||
|
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||||||
|
var fileName = $"{timestamp}_{name}.sql";
|
||||||
|
var filePath = Path.Combine(migrationsDirectory, fileName);
|
||||||
|
|
||||||
|
// Write an initial comment in the file
|
||||||
|
File.WriteAllText(filePath, $"-- Migration: {name}\n-- Created at: {DateTime.UtcNow}");
|
||||||
|
|
||||||
|
// Notify the user
|
||||||
|
Console.WriteLine($"Migration created successfully: {filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task MigrateUp(string migrationsDirectory, string connectionString)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(migrationsDirectory))
|
||||||
|
{
|
||||||
|
Console.WriteLine("Migrations directory does not exist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
|
await using var migrator = new Migrator(migrationsDirectory, dataSource);
|
||||||
|
await migrator.Migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task Seed(string connectionString)
|
||||||
|
{
|
||||||
|
var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
|
await TestDataSeeder.Seed(dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task CreateTestDatabase(string connectionString, string migrationsDirectory, bool skipConfirmation)
|
||||||
|
{
|
||||||
|
var builder = new NpgsqlConnectionStringBuilder(connectionString);
|
||||||
|
if (!skipConfirmation)
|
||||||
|
{
|
||||||
|
builder.Database = "postgres";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var dataSource = NpgsqlDataSource.Create(builder.ConnectionString);
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
|
||||||
|
cmd.CommandText = $"SELECT 1 FROM pg_database WHERE datname = '{builder.Database}'";
|
||||||
|
var exists = await cmd.ExecuteScalarAsync();
|
||||||
|
if (exists is true)
|
||||||
|
{
|
||||||
|
Console.WriteLine("WARNING: This will drop the existing database and recreate it.");
|
||||||
|
Console.Write("Are you sure you want to continue? (y/N): ");
|
||||||
|
var response = Console.ReadLine()?.ToLower();
|
||||||
|
if (response != "y")
|
||||||
|
{
|
||||||
|
Console.WriteLine("Operation cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error checking database existence: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var databaseName = builder.Database;
|
||||||
|
builder.Database = "postgres"; // Connect to default database to drop the target
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Connect to postgres database to drop/create the target database
|
||||||
|
await using var dataSource = NpgsqlDataSource.Create(builder.ConnectionString);
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
|
||||||
|
// Drop database if it exists
|
||||||
|
cmd.CommandText = $"DROP DATABASE IF EXISTS {databaseName} WITH (FORCE)";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
// Create fresh database
|
||||||
|
cmd.CommandText = $"CREATE DATABASE {databaseName}";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
// Now run migrations and seed with the original connection string
|
||||||
|
Console.WriteLine("Running migrations...");
|
||||||
|
await MigrateUp(migrationsDirectory, connectionString);
|
||||||
|
|
||||||
|
Console.WriteLine("Seeding database...");
|
||||||
|
await Seed(connectionString);
|
||||||
|
|
||||||
|
Console.WriteLine("Database reset completed successfully.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error resetting database: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
41
Femto.Database/Seed/TestDataSeeder.cs
Normal file
41
Femto.Database/Seed/TestDataSeeder.cs
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace Femto.Database.Seed;
|
||||||
|
|
||||||
|
public static class TestDataSeeder
|
||||||
|
{
|
||||||
|
public static async Task Seed(NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
await using var addToHistoryCommand = dataSource.CreateCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO blog.author
|
||||||
|
(id, username)
|
||||||
|
VALUES
|
||||||
|
('0196960c-6296-7532-ba66-8fabb38c6ae0', 'johnbotris')
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO blog.post
|
||||||
|
(id, author_id, content)
|
||||||
|
VALUES
|
||||||
|
('019691a0-48ed-7eba-b8d3-608e25e07d4b', '0196960c-6296-7532-ba66-8fabb38c6ae0', 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'),
|
||||||
|
('019691a0-4ace-7bb5-a8f3-e3362920eba0', '0196960c-6296-7532-ba66-8fabb38c6ae0', 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'),
|
||||||
|
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', '0196960c-6296-7532-ba66-8fabb38c6ae0','Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'),
|
||||||
|
('019691a0-4dd3-7e89-909e-94a6fd19a05e', '0196960c-6296-7532-ba66-8fabb38c6ae0','Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.')
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO blog.post_media
|
||||||
|
(id, post_id, url, ordering)
|
||||||
|
VALUES
|
||||||
|
('019691a2-c1b0-705e-8865-b5053bed9671', '019691a0-48ed-7eba-b8d3-608e25e07d4b', 'https://wallpaperaccess.com/full/1401569.jpg', 0),
|
||||||
|
('019691b5-bbfa-7481-ad74-25a6fea8db60', '019691a0-48ed-7eba-b8d3-608e25e07d4b', 'https://i.redd.it/4g8k43py9pi81.png', 1),
|
||||||
|
('019691b5-d813-7f46-87a5-8ee4e987622c', '019691a0-4ace-7bb5-a8f3-e3362920eba0', 'https://wallpapercave.com/wp/wp5305675.jpg', 0),
|
||||||
|
('019691b5-f345-7d86-9eba-2ca6780d8358', '019691a0-4c3e-726f-b8f6-bcbaabe789ae', 'https://wallpapers.com/images/hd/big-chungus-kpb89wgv5ov3znql.jpg', 0),
|
||||||
|
('019691b6-07cb-7353-8c33-68456188f462', '019691a0-4c3e-726f-b8f6-bcbaabe789ae', 'https://wallpapers.com/images/hd/big-chungus-2bxloyitgw7q1hfg.jpg', 1),
|
||||||
|
('019691b6-2608-7088-8110-f0f6e35fa633', '019691a0-4dd3-7e89-909e-94a6fd19a05e', 'https://www.pinclipart.com/picdir/big/535-5356059_big-transparent-chungus-png-background-big-chungus-clipart.png', 0)
|
||||||
|
;
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
await addToHistoryCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
98
Femto.Docs/Design/MediaUpload/PostMediaClaimFlow.md
Normal file
98
Femto.Docs/Design/MediaUpload/PostMediaClaimFlow.md
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
~~**Design Specification: Post Creation with Media Claiming in a Modular Monolith (DDD + MediatR)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
This document describes the design of a feature that allows users to upload media files and later attach them to a Post within a modular monolith application. The system is built using Domain-Driven Design (DDD) principles and uses MediatR for in-process communication between modules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Modules Involved
|
||||||
|
|
||||||
|
### a. Microblogging Module
|
||||||
|
- Responsible for Post aggregate lifecycle.
|
||||||
|
- Attaches media to posts based on Media IDs.
|
||||||
|
|
||||||
|
### b. MediaManagement Module
|
||||||
|
- Handles file uploads and MediaUpload entity lifecycle.
|
||||||
|
- Validates and claims media for Posts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Key Entities
|
||||||
|
|
||||||
|
### Post (Aggregate Root)
|
||||||
|
- Properties: Id, AuthorId, Content, List<MediaItem>
|
||||||
|
- Methods: AttachMedia(MediaItem), Create(), etc.
|
||||||
|
|
||||||
|
### MediaItem (Owned Entity)
|
||||||
|
- Properties: MediaId, Uri, Type, AltText, Order
|
||||||
|
|
||||||
|
### MediaUpload (Entity in MediaManagement)
|
||||||
|
- Properties: Id, UploaderId, Uri, MimeType, Status (Unassigned, Attached), PostId (nullable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Use Case Flow
|
||||||
|
|
||||||
|
### Step 1: Upload Media
|
||||||
|
**Command**: `UploadMediaCommand`
|
||||||
|
**Handler**: `UploadMediaHandler` (MediaManagement)
|
||||||
|
|
||||||
|
- Validates file and stores it.
|
||||||
|
- Creates MediaUpload record with `Status = Unassigned`.
|
||||||
|
- Returns `MediaId` to client.
|
||||||
|
|
||||||
|
### Step 2: Create Post
|
||||||
|
**Command**: `CreatePostCommand`
|
||||||
|
**Handler**: `CreatePostHandler` (Microblogging)
|
||||||
|
|
||||||
|
- Creates new Post aggregate.
|
||||||
|
- For each referenced MediaId:
|
||||||
|
- Sends `ClaimMediaRequest` command via MediatR.
|
||||||
|
|
||||||
|
### Step 3: Claim Media
|
||||||
|
**Command**: `ClaimMediaRequest`
|
||||||
|
**Handler**: `ClaimMediaHandler` (MediaManagement)
|
||||||
|
|
||||||
|
- Validates ownership and existence.
|
||||||
|
- Updates `Status = Attached`, sets `PostId`.
|
||||||
|
- Publishes `MediaClaimedEvent`.
|
||||||
|
|
||||||
|
### Step 4: Attach Media to Post
|
||||||
|
**Event**: `MediaClaimedEvent`
|
||||||
|
**Handler**: `MediaClaimedHandler` (Microblogging)
|
||||||
|
|
||||||
|
- Loads corresponding Post.
|
||||||
|
- Calls `post.AttachMedia()`.
|
||||||
|
- Saves updated Post.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Event & Command Definitions
|
||||||
|
|
||||||
|
### UploadMediaCommand
|
||||||
|
- FileStream, FileName, MimeType, UploaderId
|
||||||
|
|
||||||
|
### CreatePostCommand
|
||||||
|
- AuthorId, Content, List<MediaId>
|
||||||
|
|
||||||
|
### ClaimMediaRequest
|
||||||
|
- MediaId, AuthorId, PostId
|
||||||
|
|
||||||
|
### MediaClaimedEvent
|
||||||
|
- MediaId, PostId, Uri, Type, AltText
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rules and Invariants
|
||||||
|
- Only the uploader can claim media.
|
||||||
|
- Media can only be claimed once.
|
||||||
|
- Posts only accept claimed media.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UML Diagrams
|
||||||
|
|
||||||
|
UML Diagrams for the described system are provided below.~~
|
||||||
|
|
BIN
Femto.Docs/Design/MediaUpload/PostMediaClaimFlowDiagram.png
Normal file
BIN
Femto.Docs/Design/MediaUpload/PostMediaClaimFlowDiagram.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 MiB |
10
Femto.Docs/Femto.Docs.csproj
Normal file
10
Femto.Docs/Femto.Docs.csproj
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
3
Femto.Docs/Program.cs
Normal file
3
Femto.Docs/Program.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
// See https://aka.ms/new-console-template for more information
|
||||||
|
|
||||||
|
Console.WriteLine("Hello, World!");
|
5
Femto.Modules.Blog.Contracts/Dto/GetAuthorPostsDto.cs
Normal file
5
Femto.Modules.Blog.Contracts/Dto/GetAuthorPostsDto.cs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
namespace Femto.Modules.Blog.Contracts.Dto;
|
||||||
|
|
||||||
|
public record GetAuthorPostsDto(Guid PostId, string Text, IList<GetAuthorPostsMediaDto> Media);
|
||||||
|
|
||||||
|
public record GetAuthorPostsMediaDto(Uri Url);
|
|
@ -0,0 +1,8 @@
|
||||||
|
using Femto.Common.Attributes;
|
||||||
|
using Femto.Common.Integration;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Contracts.Events;
|
||||||
|
|
||||||
|
[EventType("post.created")]
|
||||||
|
public record PostCreatedIntegrationEvent(Guid EventId, Guid PostId, IEnumerable<Guid> MediaIds)
|
||||||
|
: IIntegrationEvent;
|
|
@ -0,0 +1,20 @@
|
||||||
|
<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>
|
34
Femto.Modules.Blog.Contracts/Module.cs
Normal file
34
Femto.Modules.Blog.Contracts/Module.cs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Reflection;
|
||||||
|
using Femto.Common.Attributes;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Contracts;
|
||||||
|
|
||||||
|
public static class Module
|
||||||
|
{
|
||||||
|
public static IDictionary<string, Type> GetIntegrationEventTypes()
|
||||||
|
{
|
||||||
|
var mapping = new Dictionary<string, Type>();
|
||||||
|
|
||||||
|
var types = typeof(Module).Assembly.GetTypes();
|
||||||
|
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
if (!typeof(INotification).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var attribute = type.GetCustomAttribute<EventTypeAttribute>();
|
||||||
|
if (attribute == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var eventName = attribute.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(eventName))
|
||||||
|
{
|
||||||
|
mapping.TryAdd(eventName, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
}
|
5
Femto.Modules.Blog.Data/Class1.cs
Normal file
5
Femto.Modules.Blog.Data/Class1.cs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
namespace Femto.Modules.Blog.Data;
|
||||||
|
|
||||||
|
public class Class1
|
||||||
|
{
|
||||||
|
}
|
9
Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj
Normal file
9
Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
24
Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj
Normal file
24
Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
|
||||||
|
<ProjectReference Include="..\Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Microsoft.EntityFrameworkCore">
|
||||||
|
<HintPath>..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\9.0.4\lib\net8.0\Microsoft.EntityFrameworkCore.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
18
Femto.Modules.Blog/Data/BlogContext.cs
Normal file
18
Femto.Modules.Blog/Data/BlogContext.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
using Femto.Modules.Blog.Domain.Posts;
|
||||||
|
using Femto.Modules.Blog.Infrastructure.Integration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Data;
|
||||||
|
|
||||||
|
internal class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public virtual DbSet<Post> Posts { get; set; }
|
||||||
|
public virtual DbSet<OutboxEntry> Outbox { get; set; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(builder);
|
||||||
|
builder.HasDefaultSchema("blog");
|
||||||
|
builder.ApplyConfigurationsFromAssembly(typeof(BlogContext).Assembly);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Femto.Modules.Blog.Infrastructure.Integration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Data.Configurations;
|
||||||
|
|
||||||
|
internal class OutboxEntryConfiguration : IEntityTypeConfiguration<OutboxEntry>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OutboxEntry> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("outbox");
|
||||||
|
|
||||||
|
builder.Property(x => x.Payload)
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
}
|
||||||
|
}
|
14
Femto.Modules.Blog/Data/Configurations/PostConfiguration.cs
Normal file
14
Femto.Modules.Blog/Data/Configurations/PostConfiguration.cs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
using Femto.Modules.Blog.Domain.Posts;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Data.Configurations;
|
||||||
|
|
||||||
|
internal class PostConfiguration : IEntityTypeConfiguration<Post>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Post> table)
|
||||||
|
{
|
||||||
|
table.ToTable("post");
|
||||||
|
table.OwnsMany(post => post.Media).WithOwner();
|
||||||
|
}
|
||||||
|
}
|
7
Femto.Modules.Blog/Domain/Authors/Author.cs
Normal file
7
Femto.Modules.Blog/Domain/Authors/Author.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Femto.Modules.Blog.Domain.Authors;
|
||||||
|
|
||||||
|
internal class Author
|
||||||
|
{
|
||||||
|
public string Id { get; private set; } = null!;
|
||||||
|
public string Name { get; private set; } = null!;
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
|
||||||
|
|
||||||
|
public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable<Uri> Media)
|
||||||
|
: IRequest<Guid>;
|
|
@ -0,0 +1,21 @@
|
||||||
|
using Femto.Modules.Blog.Data;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
|
||||||
|
|
||||||
|
internal class CreatePostCommandHandler(BlogContext context)
|
||||||
|
: IRequestHandler<CreatePostCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(CreatePostCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = new Post(
|
||||||
|
request.AuthorId,
|
||||||
|
request.Content,
|
||||||
|
request.Media.Select((url, idx) => new PostMedia(Guid.CreateVersion7(), url, idx)).ToList()
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.AddAsync(post, cancellationToken);
|
||||||
|
|
||||||
|
return post.Id;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
using Femto.Modules.Blog.Contracts.Dto;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetAuthorPosts;
|
||||||
|
|
||||||
|
public record GetAuthorPostsQuery(Guid AuthorId, Guid? Cursor, int? Count) : IRequest<IList<GetAuthorPostsDto>>;
|
|
@ -0,0 +1,61 @@
|
||||||
|
using Dapper;
|
||||||
|
using Femto.Modules.Blog.Contracts.Dto;
|
||||||
|
using Femto.Modules.Blog.Infrastructure.DbConnection;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetAuthorPosts;
|
||||||
|
|
||||||
|
public class GetAuthorPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
|
: IRequestHandler<GetAuthorPostsQuery, IList<GetAuthorPostsDto>>
|
||||||
|
{
|
||||||
|
public async Task<IList<GetAuthorPostsDto>> Handle(
|
||||||
|
GetAuthorPostsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
using var conn = connectionFactory.GetConnection();
|
||||||
|
|
||||||
|
var sql = $$"""
|
||||||
|
with post_page as (
|
||||||
|
select * from blog.post
|
||||||
|
where blog.post.author_id = @authorId
|
||||||
|
and (@cursor is null or blog.post.id < @cursor)
|
||||||
|
order by blog.post.id desc
|
||||||
|
limit @count
|
||||||
|
)
|
||||||
|
select
|
||||||
|
p.id as PostId,
|
||||||
|
p.content as Content,
|
||||||
|
pm.url as MediaUrl
|
||||||
|
from post_page p
|
||||||
|
left join blog.post_media pm on pm.post_id = p.id
|
||||||
|
order by p.id desc
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = await conn.QueryAsync<QueryResult>(
|
||||||
|
sql,
|
||||||
|
new { authorId = query.AuthorId, cursor = query.Cursor, count = query.Count }
|
||||||
|
);
|
||||||
|
|
||||||
|
return result
|
||||||
|
.GroupBy(row => row.PostId)
|
||||||
|
.Select(group => new GetAuthorPostsDto(
|
||||||
|
group.Key,
|
||||||
|
group.First().Content,
|
||||||
|
group
|
||||||
|
.Select(row => row.MediaUrl)
|
||||||
|
.OfType<string>()
|
||||||
|
.Select(url => new GetAuthorPostsMediaDto(new Uri(url)))
|
||||||
|
.ToList()
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class QueryResult
|
||||||
|
{
|
||||||
|
public Guid PostId { get; set; }
|
||||||
|
public string Content { get; set; }
|
||||||
|
public string? MediaUrl { get; set; }
|
||||||
|
}
|
||||||
|
}
|
5
Femto.Modules.Blog/Domain/Posts/Events/PostCreated.cs
Normal file
5
Femto.Modules.Blog/Domain/Posts/Events/PostCreated.cs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Domain.Posts.Events;
|
||||||
|
|
||||||
|
internal record PostCreated(Post Post) : DomainEvent;
|
|
@ -0,0 +1,19 @@
|
||||||
|
using Femto.Modules.Blog.Contracts.Events;
|
||||||
|
using Femto.Modules.Blog.Domain.Posts.Events;
|
||||||
|
using Femto.Modules.Blog.Infrastructure.Integration;
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
25
Femto.Modules.Blog/Domain/Posts/Post.cs
Normal file
25
Femto.Modules.Blog/Domain/Posts/Post.cs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Blog.Domain.Posts.Events;
|
||||||
|
using Femto.Modules.Blog.Domain.Posts.Rules;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Domain.Posts;
|
||||||
|
|
||||||
|
internal class Post : Entity
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
public Guid AuthorId { get; private set; }
|
||||||
|
public string Content { get; private set; } = null!;
|
||||||
|
public IList<PostMedia> Media { get; private set; }
|
||||||
|
|
||||||
|
private Post() { }
|
||||||
|
|
||||||
|
public Post(Guid authorId,string content, IList<PostMedia> media)
|
||||||
|
{
|
||||||
|
this.Id = Guid.CreateVersion7();
|
||||||
|
this.AuthorId = authorId;
|
||||||
|
this.Content = content;
|
||||||
|
this.Media = media;
|
||||||
|
|
||||||
|
this.AddDomainEvent(new PostCreated(this));
|
||||||
|
}
|
||||||
|
}
|
17
Femto.Modules.Blog/Domain/Posts/PostMedia.cs
Normal file
17
Femto.Modules.Blog/Domain/Posts/PostMedia.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
namespace Femto.Modules.Blog.Domain.Posts;
|
||||||
|
|
||||||
|
internal class PostMedia
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
public Uri Url { get; private set; }
|
||||||
|
public int Ordering { get; private set; }
|
||||||
|
|
||||||
|
private PostMedia() {}
|
||||||
|
|
||||||
|
public PostMedia(Guid id, Uri url, int ordering)
|
||||||
|
{
|
||||||
|
this.Id = id;
|
||||||
|
this.Url = url;
|
||||||
|
this.Ordering = ordering;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Domain.Posts.Rules;
|
||||||
|
|
||||||
|
internal class PostMustHaveSomeMediaRule(int mediaCount) : IRule
|
||||||
|
{
|
||||||
|
public bool Check() => true || mediaCount > 0;
|
||||||
|
public string Message => "Post must contain some media";
|
||||||
|
}
|
47
Femto.Modules.Blog/Femto.Modules.Blog.csproj
Normal file
47
Femto.Modules.Blog/Femto.Modules.Blog.csproj
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>Femto.Modules.Blog</AssemblyName>
|
||||||
|
<RootNamespace>Femto.Modules.Blog</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="MediatR">
|
||||||
|
<HintPath>..\..\..\..\.nuget\packages\mediatr\12.5.0\lib\net6.0\MediatR.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Microsoft.Data.SqlClient">
|
||||||
|
<HintPath>..\..\..\..\.nuget\packages\microsoft.data.sqlclient\6.0.1\ref\net9.0\Microsoft.Data.SqlClient.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Npgsql">
|
||||||
|
<HintPath>..\..\..\..\.nuget\packages\npgsql\9.0.3\lib\net8.0\Npgsql.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Npgsql.EntityFrameworkCore.PostgreSQL">
|
||||||
|
<HintPath>..\..\..\..\.nuget\packages\npgsql.entityframeworkcore.postgresql\9.0.4\lib\net8.0\Npgsql.EntityFrameworkCore.PostgreSQL.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Domain\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
|
||||||
|
<ProjectReference Include="..\Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
|
@ -0,0 +1,12 @@
|
||||||
|
using Femto.Modules.Blog.Contracts.Events;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Handlers;
|
||||||
|
|
||||||
|
public class PostCreatedIntegrationEventHandler : INotificationHandler<PostCreatedIntegrationEvent>
|
||||||
|
{
|
||||||
|
public async Task Handle(PostCreatedIntegrationEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
using System.Data;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Infrastructure.DbConnection;
|
||||||
|
|
||||||
|
public class DbConnectionFactory(string connectionString) : IDbConnectionFactory
|
||||||
|
{
|
||||||
|
public IDbConnection GetConnection()
|
||||||
|
{
|
||||||
|
return new NpgsqlConnection(connectionString);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
using System.Data;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Infrastructure.DbConnection;
|
||||||
|
|
||||||
|
public interface IDbConnectionFactory
|
||||||
|
{
|
||||||
|
IDbConnection GetConnection();
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Femto.Modules.Blog.Data;
|
||||||
|
using Femto.Modules.Blog.Infrastructure.Integration;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog;
|
||||||
|
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
internal class MailmanJob(
|
||||||
|
Outbox outbox,
|
||||||
|
BlogContext context,
|
||||||
|
ILogger<MailmanJob> logger,
|
||||||
|
IMediator mediator
|
||||||
|
) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext executionContext)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var messages = await outbox.GetPendingMessages(executionContext.CancellationToken);
|
||||||
|
|
||||||
|
logger.LogTrace("loaded {Count} outbox messages to process", messages.Count);
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var notificationType = OutboxMessageTypeRegistry.GetType(message.EventType);
|
||||||
|
|
||||||
|
if (notificationType is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"unmapped event type {Type}. skipping.",
|
||||||
|
message.EventType
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notification =
|
||||||
|
JsonSerializer.Deserialize(message.Payload, notificationType)
|
||||||
|
as INotification;
|
||||||
|
|
||||||
|
if (notification is null)
|
||||||
|
throw new Exception("notification is null");
|
||||||
|
|
||||||
|
logger.LogTrace(
|
||||||
|
"publishing outbox message {EventType}. Id: {Id}, AggregateId: {AggregateId}",
|
||||||
|
message.EventType,
|
||||||
|
message.Id,
|
||||||
|
message.AggregateId
|
||||||
|
);
|
||||||
|
|
||||||
|
await mediator.Publish(notification, executionContext.CancellationToken);
|
||||||
|
|
||||||
|
message.Succeed();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
e,
|
||||||
|
"Error processing event {EventId} for aggregate {AggregateId}",
|
||||||
|
message.Id,
|
||||||
|
message.AggregateId
|
||||||
|
);
|
||||||
|
|
||||||
|
message.Fail(e.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(executionContext.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(e, "Error while processing outbox");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
namespace Femto.Modules.Blog.Infrastructure.Integration;
|
||||||
|
|
||||||
|
internal class OutboxEntry
|
||||||
|
{
|
||||||
|
private const int MaxRetries = 5;
|
||||||
|
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
|
public string EventType { get; private set; } = null!;
|
||||||
|
public Guid AggregateId { get; private set; }
|
||||||
|
|
||||||
|
public string Payload { get; private set; } = null!;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
||||||
|
public DateTime? ProcessedAt { get; private set; }
|
||||||
|
public DateTime? NextRetryAt { get; private set; }
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
this.Id = eventId;
|
||||||
|
this.EventType = eventType;
|
||||||
|
this.AggregateId = aggregateId;
|
||||||
|
this.Payload = payload;
|
||||||
|
this.CreatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Succeed()
|
||||||
|
{
|
||||||
|
this.ProcessedAt = DateTime.UtcNow;
|
||||||
|
this.Status = OutboxEntryStatus.Completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Fail(string error)
|
||||||
|
{
|
||||||
|
if (this.RetryCount >= MaxRetries)
|
||||||
|
{
|
||||||
|
this.Status = OutboxEntryStatus.Failed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.LastError = error;
|
||||||
|
this.NextRetryAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, this.RetryCount));
|
||||||
|
this.RetryCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OutboxEntryStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Completed,
|
||||||
|
Failed
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Reflection;
|
||||||
|
using Femto.Common.Attributes;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Infrastructure.Integration;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Blog.Data;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Infrastructure.PipelineBehaviours;
|
||||||
|
|
||||||
|
internal class SaveChangesPipelineBehaviour<TRequest, TResponse>(
|
||||||
|
BlogContext context,
|
||||||
|
IPublisher publisher,
|
||||||
|
ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger
|
||||||
|
) : IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : notnull
|
||||||
|
{
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var response = await next(cancellationToken);
|
||||||
|
|
||||||
|
if (context.ChangeTracker.HasChanges())
|
||||||
|
{
|
||||||
|
|
||||||
|
await EmitDomainEvents(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogDebug("saving changes");
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
91
Femto.Modules.Blog/Module.cs
Normal file
91
Femto.Modules.Blog/Module.cs
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
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.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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
8
Femto.Modules.Files/Domain/Files/File.cs
Normal file
8
Femto.Modules.Files/Domain/Files/File.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Femto.Modules.Files.Domain.Files;
|
||||||
|
|
||||||
|
public class File
|
||||||
|
{
|
||||||
|
Guid Id { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
}
|
9
Femto.Modules.Files/Femto.Modules.Files.csproj
Normal file
9
Femto.Modules.Files/Femto.Modules.Files.csproj
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
16
Femto.Modules.Media/Data/MediaContext.cs
Normal file
16
Femto.Modules.Media/Data/MediaContext.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
using Femto.Modules.Media.Infrastructure.Integration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Data;
|
||||||
|
|
||||||
|
internal class MediaContext(DbContextOptions<MediaContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public virtual DbSet<OutboxEntry> Outbox { get; set; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(builder);
|
||||||
|
builder.HasDefaultSchema("blog");
|
||||||
|
builder.ApplyConfigurationsFromAssembly(typeof(MediaContext).Assembly);
|
||||||
|
}
|
||||||
|
}
|
33
Femto.Modules.Media/Femto.Modules.Media.csproj
Normal file
33
Femto.Modules.Media/Femto.Modules.Media.csproj
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Data\Configurations\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="MediatR">
|
||||||
|
<HintPath>..\..\..\..\.nuget\packages\mediatr\12.5.0\lib\net6.0\MediatR.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Microsoft.Extensions.Hosting.Abstractions">
|
||||||
|
<HintPath>..\..\..\..\.nuget\packages\microsoft.aspnetcore.app.ref\9.0.4\ref\net9.0\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
78
Femto.Modules.Media/Infrastructure/Integration/Mailman.cs
Normal file
78
Femto.Modules.Media/Infrastructure/Integration/Mailman.cs
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Femto.Modules.Media.Data;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Infrastructure.Integration;
|
||||||
|
|
||||||
|
internal class Mailman(Outbox outbox, MediaContext context, ILogger<Mailman> logger, IMediator mediator)
|
||||||
|
: BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var timeToWait = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await this.DeliverMail(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(e, "Error while processing outbox");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(timeToWait, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeliverMail(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var messages = await outbox.GetPendingMessages(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var notificationType = OutboxMessageTypeRegistry.GetType(message.EventType);
|
||||||
|
if (notificationType is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("unmapped event type {Type}. skipping.", message.EventType);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notification =
|
||||||
|
JsonSerializer.Deserialize(message.Payload, notificationType) as INotification;
|
||||||
|
|
||||||
|
if (notification is null)
|
||||||
|
throw new Exception("notification is null");
|
||||||
|
|
||||||
|
await mediator.Publish(notification, cancellationToken);
|
||||||
|
|
||||||
|
message.Succeed();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
e,
|
||||||
|
"Error processing event {EventId} for aggregate {AggregateId}",
|
||||||
|
message.Id,
|
||||||
|
message.AggregateId
|
||||||
|
);
|
||||||
|
|
||||||
|
message.Fail(e.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
Femto.Modules.Media/Infrastructure/Integration/Outbox.cs
Normal file
34
Femto.Modules.Media/Infrastructure/Integration/Outbox.cs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Femto.Common.Integration;
|
||||||
|
using Femto.Modules.Media.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Infrastructure.Integration;
|
||||||
|
|
||||||
|
internal class Outbox(MediaContext context)
|
||||||
|
{
|
||||||
|
public async Task AddMessage<TMessage>(Guid aggregateId, TMessage message, CancellationToken cancellationToken)
|
||||||
|
where TMessage : IIntegrationEvent
|
||||||
|
{
|
||||||
|
await context.Outbox.AddAsync(
|
||||||
|
new(
|
||||||
|
message.EventId,
|
||||||
|
aggregateId,
|
||||||
|
typeof(TMessage).Name,
|
||||||
|
JsonSerializer.Serialize(message)
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
namespace Femto.Modules.Media.Infrastructure.Integration;
|
||||||
|
|
||||||
|
internal class OutboxEntry
|
||||||
|
{
|
||||||
|
private const int MaxRetries = 5;
|
||||||
|
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
|
public string EventType { get; private set; } = null!;
|
||||||
|
public Guid AggregateId { get; private set; }
|
||||||
|
|
||||||
|
public string Payload { get; private set; } = null!;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
||||||
|
public DateTime? ProcessedAt { get; private set; }
|
||||||
|
public DateTime? NextRetryAt { get; private set; }
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
this.Id = eventId;
|
||||||
|
this.EventType = eventType;
|
||||||
|
this.AggregateId = aggregateId;
|
||||||
|
this.Payload = payload;
|
||||||
|
this.CreatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Succeed()
|
||||||
|
{
|
||||||
|
this.ProcessedAt = DateTime.UtcNow;
|
||||||
|
this.Status = OutboxEntryStatus.Completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Fail(string error)
|
||||||
|
{
|
||||||
|
if (this.RetryCount >= MaxRetries)
|
||||||
|
{
|
||||||
|
this.Status = OutboxEntryStatus.Failed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.LastError = error;
|
||||||
|
this.NextRetryAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, this.RetryCount));
|
||||||
|
this.RetryCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OutboxEntryStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Completed,
|
||||||
|
Failed
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Reflection;
|
||||||
|
using Femto.Common.Attributes;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Infrastructure.Integration;
|
||||||
|
|
||||||
|
internal static class OutboxMessageTypeRegistry
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<string, Type> Mapping = new();
|
||||||
|
|
||||||
|
public static void RegisterOutboxMessageTypesInAssembly(Assembly assembly)
|
||||||
|
{
|
||||||
|
var types = assembly.GetTypes();
|
||||||
|
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
if (!typeof(INotification).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var attribute = type.GetCustomAttribute<EventTypeAttribute>();
|
||||||
|
if (attribute == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var eventName = attribute.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(eventName))
|
||||||
|
{
|
||||||
|
Mapping.TryAdd(eventName, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Type? GetType(string eventName) => Mapping.GetValueOrDefault(eventName);
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Media.Data;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Infrastructure.PipelineBehaviours;
|
||||||
|
|
||||||
|
internal class DomainEventsPipelineBehaviour<TRequest, TResponse>(
|
||||||
|
MediaContext context,
|
||||||
|
IPublisher publisher) : IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : notnull
|
||||||
|
{
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await next(cancellationToken);
|
||||||
|
|
||||||
|
var domainEvents = context.ChangeTracker
|
||||||
|
.Entries<Entity>()
|
||||||
|
.SelectMany(e =>
|
||||||
|
{
|
||||||
|
var events = e.Entity.DomainEvents;
|
||||||
|
e.Entity.ClearDomainEvents();
|
||||||
|
return events;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var domainEvent in domainEvents)
|
||||||
|
{
|
||||||
|
await publisher.Publish(domainEvent, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
using Femto.Modules.Media.Data;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Infrastructure.PipelineBehaviours;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// automatically call unit of work after all requuests
|
||||||
|
/// </summary>
|
||||||
|
internal class SaveChangesPipelineBehaviour<TRequest, TResponse>(MediaContext context)
|
||||||
|
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
|
||||||
|
{
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var response = await next(cancellationToken);
|
||||||
|
if (context.ChangeTracker.HasChanges())
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
66
Femto.Modules.Media/MediaModule.cs
Normal file
66
Femto.Modules.Media/MediaModule.cs
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
using Femto.Modules.Media.Data;
|
||||||
|
using Femto.Modules.Media.Infrastructure.Integration;
|
||||||
|
using Femto.Modules.Media.Infrastructure.PipelineBehaviours;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media;
|
||||||
|
|
||||||
|
public static class MediaModule
|
||||||
|
{
|
||||||
|
public static void UseBlogModule(this IServiceCollection services, string connectionString)
|
||||||
|
{
|
||||||
|
OutboxMessageTypeRegistry.RegisterOutboxMessageTypesInAssembly(typeof(MediaModule).Assembly);
|
||||||
|
|
||||||
|
services.AddDbContext<MediaContext>(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(MediaModule).Assembly);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.SetupMediatrPipeline();
|
||||||
|
|
||||||
|
services.AddTransient<Outbox, Outbox>();
|
||||||
|
services.AddHostedService<Mailman>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetupMediatrPipeline(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddTransient(
|
||||||
|
typeof(IPipelineBehavior<,>),
|
||||||
|
typeof(DomainEventsPipelineBehaviour<,>)
|
||||||
|
);
|
||||||
|
|
||||||
|
services.AddTransient(
|
||||||
|
typeof(IPipelineBehavior<,>),
|
||||||
|
typeof(SaveChangesPipelineBehaviour<,>)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
48
FemtoBackend.sln
Normal file
48
FemtoBackend.sln
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Api", "Femto.Api\Femto.Api.csproj", "{925D568D-6B82-463B-8E6B-44E392E05970}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Blog", "Femto.Modules.Blog\Femto.Modules.Blog.csproj", "{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Database", "Femto.Database\Femto.Database.csproj", "{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Docs", "Femto.Docs\Femto.Docs.csproj", "{B5444421-873F-4E3F-AE5B-0CB86005E23A}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Common", "Femto.Common\Femto.Common.csproj", "{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}"
|
||||||
|
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
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{925D568D-6B82-463B-8E6B-44E392E05970}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{925D568D-6B82-463B-8E6B-44E392E05970}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{925D568D-6B82-463B-8E6B-44E392E05970}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{925D568D-6B82-463B-8E6B-44E392E05970}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B5444421-873F-4E3F-AE5B-0CB86005E23A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B5444421-873F-4E3F-AE5B-0CB86005E23A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B5444421-873F-4E3F-AE5B-0CB86005E23A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B5444421-873F-4E3F-AE5B-0CB86005E23A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Debug|Any CPU.ActiveCfg = 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.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
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
6
compose.yaml
Normal file
6
compose.yaml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: backend
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
Loading…
Add table
Add a link
Reference in a new issue