commit ab2e20f7e1a2505566dafcd95cf923324a669662 Author: john Date: Sat May 3 15:38:57 2025 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d0bdca --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Femto.Api/Controllers/Authors/AuthorsController.cs b/Femto.Api/Controllers/Authors/AuthorsController.cs new file mode 100644 index 0000000..498829b --- /dev/null +++ b/Femto.Api/Controllers/Authors/AuthorsController.cs @@ -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> 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))) + ); + } +} diff --git a/Femto.Api/Controllers/Authors/Dto/GetAuthorPostsResponse.cs b/Femto.Api/Controllers/Authors/Dto/GetAuthorPostsResponse.cs new file mode 100644 index 0000000..6485d45 --- /dev/null +++ b/Femto.Api/Controllers/Authors/Dto/GetAuthorPostsResponse.cs @@ -0,0 +1,9 @@ +using JetBrains.Annotations; + +namespace Femto.Api.Controllers.Authors.Dto; + +[PublicAPI] +public record GetAuthorPostsResponse(IEnumerable Posts); + +[PublicAPI] +public record AuthorPostDto(Guid PostId, string Content, IEnumerable Media); diff --git a/Femto.Api/Controllers/Authors/GetAuthorPostsSearchParams.cs b/Femto.Api/Controllers/Authors/GetAuthorPostsSearchParams.cs new file mode 100644 index 0000000..cac1d91 --- /dev/null +++ b/Femto.Api/Controllers/Authors/GetAuthorPostsSearchParams.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Authors; + +public record GetAuthorPostsSearchParams(Guid? Cursor, int? Count); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/CreatePostRequest.cs b/Femto.Api/Controllers/Posts/Dto/CreatePostRequest.cs new file mode 100644 index 0000000..28c7df9 --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/CreatePostRequest.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record CreatePostRequest(Guid AuthorId, string Content, IEnumerable Media); diff --git a/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs b/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs new file mode 100644 index 0000000..a03dd93 --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record CreatePostResponse(Guid PostId); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs new file mode 100644 index 0000000..757afd0 --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs @@ -0,0 +1,6 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record GetPostResponse +{ + +} \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs new file mode 100644 index 0000000..88087b3 --- /dev/null +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -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> Post( + [FromBody] CreatePostRequest req, + CancellationToken cancellationToken + ) + { + var guid = await mediator.Send( + new CreatePostCommand(req.AuthorId, req.Content, req.Media), + cancellationToken + ); + + return new CreatePostResponse(guid); + } +} diff --git a/Femto.Api/Femto.Api.csproj b/Femto.Api/Femto.Api.csproj new file mode 100644 index 0000000..d112436 --- /dev/null +++ b/Femto.Api/Femto.Api.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + Linux + 13 + + + + + + + + + + + + + + + + ..\..\..\..\.nuget\packages\microsoft.data.sqlclient\6.0.1\ref\net9.0\Microsoft.Data.SqlClient.dll + + + + diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs new file mode 100644 index 0000000..d22eea5 --- /dev/null +++ b/Femto.Api/Program.cs @@ -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(); diff --git a/Femto.Api/Properties/launchSettings.json b/Femto.Api/Properties/launchSettings.json new file mode 100644 index 0000000..ba9387f --- /dev/null +++ b/Femto.Api/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/Femto.Api/appsettings.json b/Femto.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/Femto.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Femto.Common/Attributes/EventTypeAttribute.cs b/Femto.Common/Attributes/EventTypeAttribute.cs new file mode 100644 index 0000000..63a19cd --- /dev/null +++ b/Femto.Common/Attributes/EventTypeAttribute.cs @@ -0,0 +1,7 @@ +namespace Femto.Common.Attributes; + +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public class EventTypeAttribute(string name) : Attribute +{ + public string Name { get; } = name; +} \ No newline at end of file diff --git a/Femto.Common/Domain/DomainException.cs b/Femto.Common/Domain/DomainException.cs new file mode 100644 index 0000000..b8f9665 --- /dev/null +++ b/Femto.Common/Domain/DomainException.cs @@ -0,0 +1,3 @@ +namespace Femto.Common.Domain; + +public class DomainException(string message) : Exception(message); \ No newline at end of file diff --git a/Femto.Common/Domain/Entity.cs b/Femto.Common/Domain/Entity.cs new file mode 100644 index 0000000..24ce8a0 --- /dev/null +++ b/Femto.Common/Domain/Entity.cs @@ -0,0 +1,21 @@ +namespace Femto.Common.Domain; + +public abstract class Entity +{ + private readonly ICollection _domainEvents = []; + + protected void AddDomainEvent(IDomainEvent evt) + { + this._domainEvents.Add(evt); + } + + public IList DomainEvents => this._domainEvents.ToList(); + + public void ClearDomainEvents() => this._domainEvents.Clear(); + + protected void CheckRule(IRule rule) + { + if (!rule.Check()) + throw new RuleBrokenException(rule.Message); + } +} diff --git a/Femto.Common/Domain/IDomainEvent.cs b/Femto.Common/Domain/IDomainEvent.cs new file mode 100644 index 0000000..b8670aa --- /dev/null +++ b/Femto.Common/Domain/IDomainEvent.cs @@ -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(); +} \ No newline at end of file diff --git a/Femto.Common/Domain/IRule.cs b/Femto.Common/Domain/IRule.cs new file mode 100644 index 0000000..74e8ff0 --- /dev/null +++ b/Femto.Common/Domain/IRule.cs @@ -0,0 +1,8 @@ +namespace Femto.Common.Domain; + +public interface IRule +{ + bool Check(); + + string Message { get; } +} \ No newline at end of file diff --git a/Femto.Common/Domain/RuleBrokenException.cs b/Femto.Common/Domain/RuleBrokenException.cs new file mode 100644 index 0000000..9648642 --- /dev/null +++ b/Femto.Common/Domain/RuleBrokenException.cs @@ -0,0 +1,3 @@ +namespace Femto.Common.Domain; + +public class RuleBrokenException(string message) : DomainException(message); \ No newline at end of file diff --git a/Femto.Common/Femto.Common.csproj b/Femto.Common/Femto.Common.csproj new file mode 100644 index 0000000..b123554 --- /dev/null +++ b/Femto.Common/Femto.Common.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + + + + + + + + + + diff --git a/Femto.Common/Integration/IIntegrationEvent.cs b/Femto.Common/Integration/IIntegrationEvent.cs new file mode 100644 index 0000000..8872940 --- /dev/null +++ b/Femto.Common/Integration/IIntegrationEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace Femto.Common.Integration; + +public interface IIntegrationEvent : INotification +{ + public Guid EventId { get; } +} \ No newline at end of file diff --git a/Femto.Database/Femto.Database.csproj b/Femto.Database/Femto.Database.csproj new file mode 100644 index 0000000..257e1a1 --- /dev/null +++ b/Femto.Database/Femto.Database.csproj @@ -0,0 +1,19 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/Femto.Database/Migrations/20250425121459_Init.sql b/Femto.Database/Migrations/20250425121459_Init.sql new file mode 100644 index 0000000..a66b6f1 --- /dev/null +++ b/Femto.Database/Migrations/20250425121459_Init.sql @@ -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 + +) \ No newline at end of file diff --git a/Femto.Database/Migrator.cs b/Femto.Database/Migrator.cs new file mode 100644 index 0000000..4096bd0 --- /dev/null +++ b/Femto.Database/Migrator.cs @@ -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> 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(); + + 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> GetExecutedMigrations() + { + await using var command = dataSource.CreateCommand( + """SELECT migration FROM __migrations.__migration_history""" + ); + var reader = await command.ExecuteReaderAsync(); + var migrations = new List(); + while (await reader.ReadAsync()) + { + migrations.Add(reader.GetString(0)); + } + return migrations; + } + + private void EnsureConsistentMigrationHistory( + IList migrationScripts, + IList 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 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(); + } +} diff --git a/Femto.Database/Program.cs b/Femto.Database/Program.cs new file mode 100644 index 0000000..3af67fb --- /dev/null +++ b/Femto.Database/Program.cs @@ -0,0 +1,154 @@ +using System.CommandLine; +using Femto.Database; +using Femto.Database.Seed; +using Npgsql; + +var nameArg = new Argument("name", "the name of the migration"); +var migrationsDirectoryOption = new Option( + ["--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( + "--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( + ["-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; + } +} diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs new file mode 100644 index 0000000..649a1d5 --- /dev/null +++ b/Femto.Database/Seed/TestDataSeeder.cs @@ -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(); + } +} \ No newline at end of file diff --git a/Femto.Docs/Design/MediaUpload/PostMediaClaimFlow.md b/Femto.Docs/Design/MediaUpload/PostMediaClaimFlow.md new file mode 100644 index 0000000..1620a40 --- /dev/null +++ b/Femto.Docs/Design/MediaUpload/PostMediaClaimFlow.md @@ -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 +- 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 + +### 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.~~ + diff --git a/Femto.Docs/Design/MediaUpload/PostMediaClaimFlowDiagram.png b/Femto.Docs/Design/MediaUpload/PostMediaClaimFlowDiagram.png new file mode 100644 index 0000000..bb85b05 Binary files /dev/null and b/Femto.Docs/Design/MediaUpload/PostMediaClaimFlowDiagram.png differ diff --git a/Femto.Docs/Femto.Docs.csproj b/Femto.Docs/Femto.Docs.csproj new file mode 100644 index 0000000..85b4959 --- /dev/null +++ b/Femto.Docs/Femto.Docs.csproj @@ -0,0 +1,10 @@ + + + + Exe + net9.0 + enable + enable + + + diff --git a/Femto.Docs/Program.cs b/Femto.Docs/Program.cs new file mode 100644 index 0000000..e5dff12 --- /dev/null +++ b/Femto.Docs/Program.cs @@ -0,0 +1,3 @@ +// See https://aka.ms/new-console-template for more information + +Console.WriteLine("Hello, World!"); \ No newline at end of file diff --git a/Femto.Modules.Blog.Contracts/Dto/GetAuthorPostsDto.cs b/Femto.Modules.Blog.Contracts/Dto/GetAuthorPostsDto.cs new file mode 100644 index 0000000..649b4be --- /dev/null +++ b/Femto.Modules.Blog.Contracts/Dto/GetAuthorPostsDto.cs @@ -0,0 +1,5 @@ +namespace Femto.Modules.Blog.Contracts.Dto; + +public record GetAuthorPostsDto(Guid PostId, string Text, IList Media); + +public record GetAuthorPostsMediaDto(Uri Url); \ No newline at end of file diff --git a/Femto.Modules.Blog.Contracts/Events/PostCreatedIntegrationEvent.cs b/Femto.Modules.Blog.Contracts/Events/PostCreatedIntegrationEvent.cs new file mode 100644 index 0000000..0c4bf4f --- /dev/null +++ b/Femto.Modules.Blog.Contracts/Events/PostCreatedIntegrationEvent.cs @@ -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 MediaIds) + : IIntegrationEvent; diff --git a/Femto.Modules.Blog.Contracts/Femto.Modules.Blog.Contracts.csproj b/Femto.Modules.Blog.Contracts/Femto.Modules.Blog.Contracts.csproj new file mode 100644 index 0000000..828552d --- /dev/null +++ b/Femto.Modules.Blog.Contracts/Femto.Modules.Blog.Contracts.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + diff --git a/Femto.Modules.Blog.Contracts/Module.cs b/Femto.Modules.Blog.Contracts/Module.cs new file mode 100644 index 0000000..10fba4f --- /dev/null +++ b/Femto.Modules.Blog.Contracts/Module.cs @@ -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 GetIntegrationEventTypes() + { + var mapping = new Dictionary(); + + 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(); + if (attribute == null) + continue; + + var eventName = attribute.Name; + if (!string.IsNullOrWhiteSpace(eventName)) + { + mapping.TryAdd(eventName, type); + } + } + + return mapping; + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog.Data/Class1.cs b/Femto.Modules.Blog.Data/Class1.cs new file mode 100644 index 0000000..3be8b2a --- /dev/null +++ b/Femto.Modules.Blog.Data/Class1.cs @@ -0,0 +1,5 @@ +namespace Femto.Modules.Blog.Data; + +public class Class1 +{ +} \ No newline at end of file diff --git a/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj b/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj new file mode 100644 index 0000000..17b910f --- /dev/null +++ b/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj b/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj new file mode 100644 index 0000000..6ae6742 --- /dev/null +++ b/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + ..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\9.0.4\lib\net8.0\Microsoft.EntityFrameworkCore.dll + + + + diff --git a/Femto.Modules.Blog/Data/BlogContext.cs b/Femto.Modules.Blog/Data/BlogContext.cs new file mode 100644 index 0000000..e4a662b --- /dev/null +++ b/Femto.Modules.Blog/Data/BlogContext.cs @@ -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 options) : DbContext(options) +{ + public virtual DbSet Posts { get; set; } + public virtual DbSet Outbox { get; set; } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.HasDefaultSchema("blog"); + builder.ApplyConfigurationsFromAssembly(typeof(BlogContext).Assembly); + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Data/Configurations/OutboxEntryConfiguration.cs b/Femto.Modules.Blog/Data/Configurations/OutboxEntryConfiguration.cs new file mode 100644 index 0000000..b7dbd93 --- /dev/null +++ b/Femto.Modules.Blog/Data/Configurations/OutboxEntryConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("outbox"); + + builder.Property(x => x.Payload) + .HasColumnType("jsonb"); + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Data/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Data/Configurations/PostConfiguration.cs new file mode 100644 index 0000000..089a111 --- /dev/null +++ b/Femto.Modules.Blog/Data/Configurations/PostConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder table) + { + table.ToTable("post"); + table.OwnsMany(post => post.Media).WithOwner(); + } +} diff --git a/Femto.Modules.Blog/Domain/Authors/Author.cs b/Femto.Modules.Blog/Domain/Authors/Author.cs new file mode 100644 index 0000000..7f796ce --- /dev/null +++ b/Femto.Modules.Blog/Domain/Authors/Author.cs @@ -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!; +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Domain/Posts/Commands/CreatePost/CreatePostCommand.cs b/Femto.Modules.Blog/Domain/Posts/Commands/CreatePost/CreatePostCommand.cs new file mode 100644 index 0000000..d99bf88 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/Commands/CreatePost/CreatePostCommand.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace Femto.Modules.Blog.Domain.Posts.Commands.CreatePost; + +public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable Media) + : IRequest; \ No newline at end of file diff --git a/Femto.Modules.Blog/Domain/Posts/Commands/CreatePost/CreatePostCommandHandler.cs b/Femto.Modules.Blog/Domain/Posts/Commands/CreatePost/CreatePostCommandHandler.cs new file mode 100644 index 0000000..3158a43 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/Commands/CreatePost/CreatePostCommandHandler.cs @@ -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 +{ + public async Task 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; + } +} diff --git a/Femto.Modules.Blog/Domain/Posts/Commands/GetAuthorPosts/GetAuthorPostsQuery.cs b/Femto.Modules.Blog/Domain/Posts/Commands/GetAuthorPosts/GetAuthorPostsQuery.cs new file mode 100644 index 0000000..1334bb8 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/Commands/GetAuthorPosts/GetAuthorPostsQuery.cs @@ -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>; \ No newline at end of file diff --git a/Femto.Modules.Blog/Domain/Posts/Commands/GetAuthorPosts/GetAuthorPostsQueryHandler.cs b/Femto.Modules.Blog/Domain/Posts/Commands/GetAuthorPosts/GetAuthorPostsQueryHandler.cs new file mode 100644 index 0000000..47ff33f --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/Commands/GetAuthorPosts/GetAuthorPostsQueryHandler.cs @@ -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> +{ + public async Task> 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( + 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() + .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; } + } +} diff --git a/Femto.Modules.Blog/Domain/Posts/Events/PostCreated.cs b/Femto.Modules.Blog/Domain/Posts/Events/PostCreated.cs new file mode 100644 index 0000000..c4c4596 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/Events/PostCreated.cs @@ -0,0 +1,5 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Blog.Domain.Posts.Events; + +internal record PostCreated(Post Post) : DomainEvent; \ No newline at end of file diff --git a/Femto.Modules.Blog/Domain/Posts/Handlers/PostCreatedHandler.cs b/Femto.Modules.Blog/Domain/Posts/Handlers/PostCreatedHandler.cs new file mode 100644 index 0000000..d07abc3 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/Handlers/PostCreatedHandler.cs @@ -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 +{ + 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 + ); + } +} diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs new file mode 100644 index 0000000..49a6aaf --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/Post.cs @@ -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 Media { get; private set; } + + private Post() { } + + public Post(Guid authorId,string content, IList media) + { + this.Id = Guid.CreateVersion7(); + this.AuthorId = authorId; + this.Content = content; + this.Media = media; + + this.AddDomainEvent(new PostCreated(this)); + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Domain/Posts/PostMedia.cs b/Femto.Modules.Blog/Domain/Posts/PostMedia.cs new file mode 100644 index 0000000..927dfff --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/PostMedia.cs @@ -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; + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Domain/Posts/Rules/PostMustHaveSomeMediaRule.cs b/Femto.Modules.Blog/Domain/Posts/Rules/PostMustHaveSomeMediaRule.cs new file mode 100644 index 0000000..37f6d9d --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/Rules/PostMustHaveSomeMediaRule.cs @@ -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"; +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Femto.Modules.Blog.csproj b/Femto.Modules.Blog/Femto.Modules.Blog.csproj new file mode 100644 index 0000000..94383f1 --- /dev/null +++ b/Femto.Modules.Blog/Femto.Modules.Blog.csproj @@ -0,0 +1,47 @@ + + + + net9.0 + enable + enable + Femto.Modules.Blog + Femto.Modules.Blog + + + + + + + + + + + + + + + + + ..\..\..\..\.nuget\packages\mediatr\12.5.0\lib\net6.0\MediatR.dll + + + ..\..\..\..\.nuget\packages\microsoft.data.sqlclient\6.0.1\ref\net9.0\Microsoft.Data.SqlClient.dll + + + ..\..\..\..\.nuget\packages\npgsql\9.0.3\lib\net8.0\Npgsql.dll + + + ..\..\..\..\.nuget\packages\npgsql.entityframeworkcore.postgresql\9.0.4\lib\net8.0\Npgsql.EntityFrameworkCore.PostgreSQL.dll + + + + + + + + + + + + + diff --git a/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs b/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs new file mode 100644 index 0000000..13f2ae1 --- /dev/null +++ b/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs @@ -0,0 +1,12 @@ +using Femto.Modules.Blog.Contracts.Events; +using MediatR; + +namespace Femto.Modules.Blog.Handlers; + +public class PostCreatedIntegrationEventHandler : INotificationHandler +{ + public async Task Handle(PostCreatedIntegrationEvent notification, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Infrastructure/DbConnection/DbConnectionFactory.cs b/Femto.Modules.Blog/Infrastructure/DbConnection/DbConnectionFactory.cs new file mode 100644 index 0000000..ee32f96 --- /dev/null +++ b/Femto.Modules.Blog/Infrastructure/DbConnection/DbConnectionFactory.cs @@ -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); + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Infrastructure/DbConnection/IDbConnectionFactory.cs b/Femto.Modules.Blog/Infrastructure/DbConnection/IDbConnectionFactory.cs new file mode 100644 index 0000000..0c05f73 --- /dev/null +++ b/Femto.Modules.Blog/Infrastructure/DbConnection/IDbConnectionFactory.cs @@ -0,0 +1,9 @@ +using System.Data; +using Microsoft.Data.SqlClient; + +namespace Femto.Modules.Blog.Infrastructure.DbConnection; + +public interface IDbConnectionFactory +{ + IDbConnection GetConnection(); +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Infrastructure/Integration/Outbox/MailmanJob.cs b/Femto.Modules.Blog/Infrastructure/Integration/Outbox/MailmanJob.cs new file mode 100644 index 0000000..a860dcf --- /dev/null +++ b/Femto.Modules.Blog/Infrastructure/Integration/Outbox/MailmanJob.cs @@ -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 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; + } + } +} diff --git a/Femto.Modules.Blog/Infrastructure/Integration/Outbox/Outbox.cs b/Femto.Modules.Blog/Infrastructure/Integration/Outbox/Outbox.cs new file mode 100644 index 0000000..e084e5b --- /dev/null +++ b/Femto.Modules.Blog/Infrastructure/Integration/Outbox/Outbox.cs @@ -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(Guid aggregateId, TMessage message, CancellationToken cancellationToken) + where TMessage : IIntegrationEvent + { + var eventType = typeof(TMessage).GetCustomAttribute(); + 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> 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); + } +} diff --git a/Femto.Modules.Blog/Infrastructure/Integration/Outbox/OutboxEntry.cs b/Femto.Modules.Blog/Infrastructure/Integration/Outbox/OutboxEntry.cs new file mode 100644 index 0000000..05d65cd --- /dev/null +++ b/Femto.Modules.Blog/Infrastructure/Integration/Outbox/OutboxEntry.cs @@ -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 +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Infrastructure/Integration/Outbox/OutboxMessageTypeRegistry.cs b/Femto.Modules.Blog/Infrastructure/Integration/Outbox/OutboxMessageTypeRegistry.cs new file mode 100644 index 0000000..d345e93 --- /dev/null +++ b/Femto.Modules.Blog/Infrastructure/Integration/Outbox/OutboxMessageTypeRegistry.cs @@ -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 Mapping = new(); + + public static void RegisterOutboxMessages(IImmutableDictionary mapping) + { + foreach (var (key, value) in mapping) + { + Mapping.TryAdd(key, value); + } + } + + public static Type? GetType(string eventName) => Mapping.GetValueOrDefault(eventName); + +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Infrastructure/PipelineBehaviours/SaveChangesPipelineBehaviour.cs b/Femto.Modules.Blog/Infrastructure/PipelineBehaviours/SaveChangesPipelineBehaviour.cs new file mode 100644 index 0000000..82547c6 --- /dev/null +++ b/Femto.Modules.Blog/Infrastructure/PipelineBehaviours/SaveChangesPipelineBehaviour.cs @@ -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( + BlogContext context, + IPublisher publisher, + ILogger> logger +) : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate 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() + .SelectMany(e => + { + var events = e.Entity.DomainEvents; + e.Entity.ClearDomainEvents(); + return events; + }) + .ToList(); + + logger.LogTrace("loaded {Count} domain events", domainEvents.Count); + + foreach (var domainEvent in domainEvents) + { + logger.LogTrace( + "publishing {Type} domain event {Id}", + domainEvent.GetType().Name, + domainEvent.EventId + ); + await publisher.Publish(domainEvent, cancellationToken); + } + } +} diff --git a/Femto.Modules.Blog/Module.cs b/Femto.Modules.Blog/Module.cs new file mode 100644 index 0000000..5715270 --- /dev/null +++ b/Femto.Modules.Blog/Module.cs @@ -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(builder => + { + builder.UseNpgsql( + connectionString, + o => + { + o.MapEnum("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(); + services.AddTransient(_ => 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(jobKey) + .AddTrigger(trigger => + trigger + .ForJob(jobKey) + .WithSimpleSchedule(schedule => + schedule.WithIntervalInSeconds(1).RepeatForever() + ) + ); + } +} diff --git a/Femto.Modules.Files/Domain/Files/File.cs b/Femto.Modules.Files/Domain/Files/File.cs new file mode 100644 index 0000000..9600ceb --- /dev/null +++ b/Femto.Modules.Files/Domain/Files/File.cs @@ -0,0 +1,8 @@ +namespace Femto.Modules.Files.Domain.Files; + +public class File +{ + Guid Id { get; set; } + + +} \ No newline at end of file diff --git a/Femto.Modules.Files/Femto.Modules.Files.csproj b/Femto.Modules.Files/Femto.Modules.Files.csproj new file mode 100644 index 0000000..17b910f --- /dev/null +++ b/Femto.Modules.Files/Femto.Modules.Files.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/Femto.Modules.Media/Data/MediaContext.cs b/Femto.Modules.Media/Data/MediaContext.cs new file mode 100644 index 0000000..9289499 --- /dev/null +++ b/Femto.Modules.Media/Data/MediaContext.cs @@ -0,0 +1,16 @@ +using Femto.Modules.Media.Infrastructure.Integration; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Media.Data; + +internal class MediaContext(DbContextOptions options) : DbContext(options) +{ + public virtual DbSet Outbox { get; set; } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.HasDefaultSchema("blog"); + builder.ApplyConfigurationsFromAssembly(typeof(MediaContext).Assembly); + } +} \ No newline at end of file diff --git a/Femto.Modules.Media/Femto.Modules.Media.csproj b/Femto.Modules.Media/Femto.Modules.Media.csproj new file mode 100644 index 0000000..91c6358 --- /dev/null +++ b/Femto.Modules.Media/Femto.Modules.Media.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + + + + + + + + + ..\..\..\..\.nuget\packages\mediatr\12.5.0\lib\net6.0\MediatR.dll + + + ..\..\..\..\.nuget\packages\microsoft.aspnetcore.app.ref\9.0.4\ref\net9.0\Microsoft.Extensions.Hosting.Abstractions.dll + + + + + + + + + + + + + + + diff --git a/Femto.Modules.Media/Infrastructure/Integration/Mailman.cs b/Femto.Modules.Media/Infrastructure/Integration/Mailman.cs new file mode 100644 index 0000000..854954b --- /dev/null +++ b/Femto.Modules.Media/Infrastructure/Integration/Mailman.cs @@ -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 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); + } + } +} diff --git a/Femto.Modules.Media/Infrastructure/Integration/Outbox.cs b/Femto.Modules.Media/Infrastructure/Integration/Outbox.cs new file mode 100644 index 0000000..00e182d --- /dev/null +++ b/Femto.Modules.Media/Infrastructure/Integration/Outbox.cs @@ -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(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> 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); + } +} diff --git a/Femto.Modules.Media/Infrastructure/Integration/OutboxEntry.cs b/Femto.Modules.Media/Infrastructure/Integration/OutboxEntry.cs new file mode 100644 index 0000000..e568726 --- /dev/null +++ b/Femto.Modules.Media/Infrastructure/Integration/OutboxEntry.cs @@ -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 +} \ No newline at end of file diff --git a/Femto.Modules.Media/Infrastructure/Integration/OutboxMessageTypeRegistry.cs b/Femto.Modules.Media/Infrastructure/Integration/OutboxMessageTypeRegistry.cs new file mode 100644 index 0000000..56ef505 --- /dev/null +++ b/Femto.Modules.Media/Infrastructure/Integration/OutboxMessageTypeRegistry.cs @@ -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 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(); + 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); + +} \ No newline at end of file diff --git a/Femto.Modules.Media/Infrastructure/PipelineBehaviours/DomainEventsPipelineHandler.cs b/Femto.Modules.Media/Infrastructure/PipelineBehaviours/DomainEventsPipelineHandler.cs new file mode 100644 index 0000000..c8ddc7d --- /dev/null +++ b/Femto.Modules.Media/Infrastructure/PipelineBehaviours/DomainEventsPipelineHandler.cs @@ -0,0 +1,36 @@ +using Femto.Common.Domain; +using Femto.Modules.Media.Data; +using MediatR; + +namespace Femto.Modules.Media.Infrastructure.PipelineBehaviours; + +internal class DomainEventsPipelineBehaviour( + MediaContext context, + IPublisher publisher) : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var response = await next(cancellationToken); + + var domainEvents = context.ChangeTracker + .Entries() + .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; + } +} \ No newline at end of file diff --git a/Femto.Modules.Media/Infrastructure/PipelineBehaviours/SaveChangesPipelineBehaviour.cs b/Femto.Modules.Media/Infrastructure/PipelineBehaviours/SaveChangesPipelineBehaviour.cs new file mode 100644 index 0000000..4aa3e44 --- /dev/null +++ b/Femto.Modules.Media/Infrastructure/PipelineBehaviours/SaveChangesPipelineBehaviour.cs @@ -0,0 +1,23 @@ +using Femto.Modules.Media.Data; +using MediatR; + +namespace Femto.Modules.Media.Infrastructure.PipelineBehaviours; + +/// +/// automatically call unit of work after all requuests +/// +internal class SaveChangesPipelineBehaviour(MediaContext context) + : IPipelineBehavior where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + var response = await next(cancellationToken); + if (context.ChangeTracker.HasChanges()) + await context.SaveChangesAsync(cancellationToken); + return response; + } +} diff --git a/Femto.Modules.Media/MediaModule.cs b/Femto.Modules.Media/MediaModule.cs new file mode 100644 index 0000000..c8dc76d --- /dev/null +++ b/Femto.Modules.Media/MediaModule.cs @@ -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(builder => + { + builder.UseNpgsql( + connectionString, + o => + { + o.MapEnum("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(); + services.AddHostedService(); + } + + private static void SetupMediatrPipeline(this IServiceCollection services) + { + services.AddTransient( + typeof(IPipelineBehavior<,>), + typeof(DomainEventsPipelineBehaviour<,>) + ); + + services.AddTransient( + typeof(IPipelineBehavior<,>), + typeof(SaveChangesPipelineBehaviour<,>) + ); + } +} diff --git a/FemtoBackend.sln b/FemtoBackend.sln new file mode 100644 index 0000000..04b476a --- /dev/null +++ b/FemtoBackend.sln @@ -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 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..6869752 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,6 @@ +services: + backend: + image: backend + build: + context: . + dockerfile: backend/Dockerfile