diff --git a/Backend/src/Applications/Core/Core.Api/Core.Api.csproj b/Backend/src/Applications/Core/Core.Api/Core.Api.csproj index d29da61..9be5721 100644 --- a/Backend/src/Applications/Core/Core.Api/Core.Api.csproj +++ b/Backend/src/Applications/Core/Core.Api/Core.Api.csproj @@ -19,6 +19,7 @@ + diff --git a/Backend/src/Applications/Core/Core.Api/Program.cs b/Backend/src/Applications/Core/Core.Api/Program.cs index ef3f688..5ed4822 100644 --- a/Backend/src/Applications/Core/Core.Api/Program.cs +++ b/Backend/src/Applications/Core/Core.Api/Program.cs @@ -2,6 +2,8 @@ using BuildingBlocks.Domain; using Carter; using Core.Api.Extensions; +using Core.Api.Sdk; +using Core.Api.Sdk.Interfaces; using HealthChecks.UI.Client; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -19,6 +21,8 @@ builder.Services.AddHealthChecks() .AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException()); +builder.Services.AddHttpClient(); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -41,3 +45,5 @@ }); app.Run(); + +public abstract partial class Program; // This partial class is needed for the integration tests diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/FileIdConverter.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/FileIdConverter.cs new file mode 100644 index 0000000..73b7f24 --- /dev/null +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/FileIdConverter.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Domain.ValueObjects.Ids; + +namespace BuildingBlocks.Api.Converters; + +public class FileIdConverter : IRegister +{ + public void Register(TypeAdapterConfig config) => + config.NewConfig().ConstructUsing(src => FileAssetId.Of(src.Value)); +} diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/NodeIdConverter.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/NodeIdConverter.cs index 05da85b..2bf272f 100644 --- a/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/NodeIdConverter.cs +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/NodeIdConverter.cs @@ -1,7 +1,9 @@ -namespace BuildingBlocks.Api.Converters; - -public class NodeIdConverter : IRegister -{ - public void Register(TypeAdapterConfig config) => - config.NewConfig().ConstructUsing(src => NodeId.Of(src.Value)); -} +using BuildingBlocks.Domain.ValueObjects.Ids; + +namespace BuildingBlocks.Api.Converters; + +public class NodeIdConverter : IRegister +{ + public void Register(TypeAdapterConfig config) => + config.NewConfig().ConstructUsing(src => NodeId.Of(src.Value)); +} diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/NoteIdConverter.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/NoteIdConverter.cs new file mode 100644 index 0000000..8ae4c50 --- /dev/null +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/NoteIdConverter.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Domain.ValueObjects.Ids; + +namespace BuildingBlocks.Api.Converters; + +public class NoteIdConverter : IRegister +{ + public void Register(TypeAdapterConfig config) => + config.NewConfig().ConstructUsing(src => NoteId.Of(src.Value)); +} diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/ReminderIdConverter.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/ReminderIdConverter.cs new file mode 100644 index 0000000..0b1bb4b --- /dev/null +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/ReminderIdConverter.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Domain.ValueObjects.Ids; + +namespace BuildingBlocks.Api.Converters; + +public class ReminderIdConverter : IRegister +{ + public void Register(TypeAdapterConfig config) => + config.NewConfig().ConstructUsing(src => ReminderId.Of(src.Value)); +} diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/TimelineIdConverter.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/TimelineIdConverter.cs new file mode 100644 index 0000000..15fa426 --- /dev/null +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Api/Converters/TimelineIdConverter.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Domain.ValueObjects.Ids; + +namespace BuildingBlocks.Api.Converters; + +public class TimelineIdConverter : IRegister +{ + public void Register(TypeAdapterConfig config) => + config.NewConfig().ConstructUsing(src => TimelineId.Of(src.Value)); +} diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Api/GlobalUsing.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Api/GlobalUsing.cs index 6b8c44a..766f651 100644 --- a/Backend/src/BuildingBlocks/BuildingBlocks.Api/GlobalUsing.cs +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Api/GlobalUsing.cs @@ -1,2 +1 @@ -global using Mapster; -global using BuildingBlocks.Domain.ValueObjects.Ids; +global using Mapster; diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/FileAssetId.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/FileAssetId.cs index 0411b72..c09da68 100644 --- a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/FileAssetId.cs +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/FileAssetId.cs @@ -1,13 +1,10 @@ -using System.Text.Json.Serialization; +namespace BuildingBlocks.Domain.ValueObjects.Ids; -namespace BuildingBlocks.Domain.ValueObjects.Ids; - -[JsonConverter(typeof(FileAssetIdJsonConverter))] -public record FileAssetId : StronglyTypedId +public class FileAssetId : StronglyTypedId { private FileAssetId(Guid value) : base(value) { } public static FileAssetId Of(Guid value) => new(value); - private class FileAssetIdJsonConverter : StronglyTypedIdJsonConverter; + public override string ToString() => Value.ToString(); } diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/NodeId.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/NodeId.cs index d026a7c..e81ef16 100644 --- a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/NodeId.cs +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/NodeId.cs @@ -1,13 +1,55 @@ -using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; namespace BuildingBlocks.Domain.ValueObjects.Ids; [JsonConverter(typeof(NodeIdJsonConverter))] -public record NodeId : StronglyTypedId +public class NodeId : StronglyTypedId { private NodeId(Guid value) : base(value) { } public static NodeId Of(Guid value) => new(value); - private class NodeIdJsonConverter : StronglyTypedIdJsonConverter; + public override string ToString() => Value.ToString(); +} + +public class NodeIdJsonConverter : JsonConverter +{ + public override NodeId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (reader.TokenType) + { + case JsonTokenType.String: + { + var guidString = reader.GetString(); + if (!Guid.TryParse(guidString, out var guid)) + throw new JsonException($"Invalid GUID format for NodeId: {guidString}"); + + return NodeId.Of(guid); + } + case JsonTokenType.StartObject: + { + using var jsonDoc = JsonDocument.ParseValue(ref reader); + + if (!jsonDoc.RootElement.TryGetProperty("id", out JsonElement idElement)) + throw new JsonException("Expected property 'id' not found."); + + var guidString = idElement.GetString(); + + if (!Guid.TryParse(guidString, out var guid)) + throw new JsonException($"Invalid GUID format for NodeId: {guidString}"); + + return NodeId.Of(guid); + } + default: + throw new JsonException( + $"Unexpected token parsing NodeId. Expected String or StartObject, got {reader.TokenType}."); + } + } + + public override void Write(Utf8JsonWriter writer, NodeId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } } diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/NoteId.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/NoteId.cs index 63436b0..b5c3dcc 100644 --- a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/NoteId.cs +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/NoteId.cs @@ -1,13 +1,10 @@ -using System.Text.Json.Serialization; +namespace BuildingBlocks.Domain.ValueObjects.Ids; -namespace BuildingBlocks.Domain.ValueObjects.Ids; - -[JsonConverter(typeof(NoteIdJsonConverter))] -public record NoteId : StronglyTypedId +public class NoteId : StronglyTypedId { private NoteId(Guid value) : base(value) { } public static NoteId Of(Guid value) => new(value); - - private class NoteIdJsonConverter : StronglyTypedIdJsonConverter; + + public override string ToString() => Value.ToString(); } diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/ReminderId.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/ReminderId.cs index 9e4195d..2080e6a 100644 --- a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/ReminderId.cs +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/ReminderId.cs @@ -1,13 +1,10 @@ -using System.Text.Json.Serialization; +namespace BuildingBlocks.Domain.ValueObjects.Ids; -namespace BuildingBlocks.Domain.ValueObjects.Ids; - -[JsonConverter(typeof(ReminderIdJsonConverter))] -public record ReminderId : StronglyTypedId +public class ReminderId : StronglyTypedId { private ReminderId(Guid value) : base(value) { } public static ReminderId Of(Guid value) => new(value); - private class ReminderIdJsonConverter : StronglyTypedIdJsonConverter; + public override string ToString() => Value.ToString(); } diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/TimelineId.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/TimelineId.cs index fee385e..cdad40d 100644 --- a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/TimelineId.cs +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/Ids/TimelineId.cs @@ -1,13 +1,10 @@ -using System.Text.Json.Serialization; +namespace BuildingBlocks.Domain.ValueObjects.Ids; -namespace BuildingBlocks.Domain.ValueObjects.Ids; - -[JsonConverter(typeof(TimelineIdJsonConverter))] -public record TimelineId : StronglyTypedId +public class TimelineId : StronglyTypedId { private TimelineId(Guid value) : base(value) { } public static TimelineId Of(Guid value) => new(value); - private class TimelineIdJsonConverter : StronglyTypedIdJsonConverter; + public override string ToString() => Value.ToString(); } diff --git a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/StronglyTypedId.cs b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/StronglyTypedId.cs index 41cec1b..b1e0750 100644 --- a/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/StronglyTypedId.cs +++ b/Backend/src/BuildingBlocks/BuildingBlocks.Domain/ValueObjects/StronglyTypedId.cs @@ -1,9 +1,6 @@ -using System.Text.Json.Serialization; -using System.Text.Json; +namespace BuildingBlocks.Domain.ValueObjects; -namespace BuildingBlocks.Domain.ValueObjects; - -public abstract record StronglyTypedId +public abstract class StronglyTypedId { protected StronglyTypedId(Guid value) { @@ -14,27 +11,6 @@ protected StronglyTypedId(Guid value) } public Guid Value { get; } - - public override string ToString() => Value.ToString(); -} - -public class StronglyTypedIdJsonConverter : JsonConverter where T : StronglyTypedId -{ - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - - if (Guid.TryParse(value, out var guid)) - { - var constructor = typeof(T).GetConstructor(new[] { typeof(Guid) }); - - if (constructor != null) - return (T)constructor.Invoke(new object[] { guid }); - } - - throw new JsonException($"Invalid GUID format for {typeof(T).Name}: {value}"); - } - - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => - writer.WriteStringValue(value.Value.ToString()); + + public abstract override string ToString(); } diff --git a/Backend/src/Modules/Files/Files.Api/Endpoints/CreateFileAsset.cs b/Backend/src/Modules/Files/Files.Api/Endpoints/Files/CreateFileAsset.cs similarity index 88% rename from Backend/src/Modules/Files/Files.Api/Endpoints/CreateFileAsset.cs rename to Backend/src/Modules/Files/Files.Api/Endpoints/Files/CreateFileAsset.cs index b310ac4..13d8847 100644 --- a/Backend/src/Modules/Files/Files.Api/Endpoints/CreateFileAsset.cs +++ b/Backend/src/Modules/Files/Files.Api/Endpoints/Files/CreateFileAsset.cs @@ -1,9 +1,9 @@ using BuildingBlocks.Domain.ValueObjects.Ids; -using Files.Application.Files.Commands.CreateFileAsset; +using Files.Application.Entities.Files.Commands.CreateFileAsset; // ReSharper disable ClassNeverInstantiated.Global -namespace Files.Api.Endpoints; +namespace Files.Api.Endpoints.Files; public class CreateFileAsset : ICarterModule { diff --git a/Backend/src/Modules/Files/Files.Api/Endpoints/Files/GetFileAssetById.cs b/Backend/src/Modules/Files/Files.Api/Endpoints/Files/GetFileAssetById.cs new file mode 100644 index 0000000..842e7ba --- /dev/null +++ b/Backend/src/Modules/Files/Files.Api/Endpoints/Files/GetFileAssetById.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Files.Application.Entities.Files.Queries.GetFileAssetById; + +namespace Files.Api.Endpoints.Files; + +// ReSharper disable once UnusedType.Global +public class GetFileAssetById : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("/Files/{fileId}", async (string fileId, ISender sender) => + { + var result = await sender.Send(new GetFileAssetByIdQuery(fileId)); + var response = result.Adapt(); + + return Results.Ok(response); + }) + .WithName("GetFileAssetById") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get File Asset by Id") + .WithDescription("Get File Asset by Id"); + } +} + +// ReSharper disable once ClassNeverInstantiated.Global +// ReSharper disable once NotAccessedPositionalProperty.Global +public record GetFileAssetByIdResponse([property: JsonPropertyName("file")] FileAssetDto FileAssetDto); diff --git a/Backend/src/Modules/Files/Files.Api/Endpoints/Files/ListFileAssets.cs b/Backend/src/Modules/Files/Files.Api/Endpoints/Files/ListFileAssets.cs new file mode 100644 index 0000000..15891b6 --- /dev/null +++ b/Backend/src/Modules/Files/Files.Api/Endpoints/Files/ListFileAssets.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Application.Pagination; +using Files.Application.Entities.Files.Queries.ListFileAssets; + +namespace Files.Api.Endpoints.Files; + +public class ListFileAssets : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("/Files", async ([AsParameters] PaginationRequest query, ISender sender) => + { + var result = await sender.Send(new ListFileAssetsQuery(query)); + var response = result.Adapt(); + + return Results.Ok(response); + }) + .WithName("ListFileAssets") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("List File Assets") + .WithDescription("List File Assets"); + } +} + +// ReSharper disable once ClassNeverInstantiated.Global +// ReSharper disable once NotAccessedPositionalProperty.Global +public record ListFileAssetsResponse(PaginatedResult FileAssets); diff --git a/Backend/src/Modules/Files/Files.Api/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Files/Files.Api/Extensions/ServiceCollectionExtensions.cs index bee1739..d2dd07a 100644 --- a/Backend/src/Modules/Files/Files.Api/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Files/Files.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,7 +1,6 @@ +using BuildingBlocks.Api.Converters; using Files.Application.Extensions; using Files.Infrastructure; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -21,8 +20,7 @@ public static IServiceCollection AddFilesModule private static IServiceCollection AddApiServices(this IServiceCollection services) { - // services.AddExceptionHandler(); - // services.AddHealthChecks()... + TypeAdapterConfig.GlobalSettings.Scan(typeof(FileIdConverter).Assembly); return services; } @@ -31,9 +29,6 @@ public static IEndpointRouteBuilder UseFilesModule(this IEndpointRouteBuilder en { endpoints.MapGet("/Files/Test", () => "Files.Api Test -> Ok!"); - // app.UseExceptionHandler(_ => { }); - // app.UseHealthChecks... - return endpoints; } } diff --git a/Backend/src/Modules/Files/Files.Api/GlobalUsing.cs b/Backend/src/Modules/Files/Files.Api/GlobalUsing.cs index b4edfed..312b27e 100644 --- a/Backend/src/Modules/Files/Files.Api/GlobalUsing.cs +++ b/Backend/src/Modules/Files/Files.Api/GlobalUsing.cs @@ -4,4 +4,4 @@ global using Microsoft.AspNetCore.Builder; global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Routing; -global using Files.Application.Dtos; +global using Files.Application.Entities.Files.Dtos; diff --git a/Backend/src/Modules/Files/Files.Application/Files/Commands/CreateFileAsset/CreateFileAssetCommand.cs b/Backend/src/Modules/Files/Files.Application/Entities/Files/Commands/CreateFileAsset/CreateFileAssetCommand.cs similarity index 80% rename from Backend/src/Modules/Files/Files.Application/Files/Commands/CreateFileAsset/CreateFileAssetCommand.cs rename to Backend/src/Modules/Files/Files.Application/Entities/Files/Commands/CreateFileAsset/CreateFileAssetCommand.cs index 9484d3e..8e21157 100644 --- a/Backend/src/Modules/Files/Files.Application/Files/Commands/CreateFileAsset/CreateFileAssetCommand.cs +++ b/Backend/src/Modules/Files/Files.Application/Entities/Files/Commands/CreateFileAsset/CreateFileAssetCommand.cs @@ -1,11 +1,11 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using Files.Application.Dtos; -using FluentValidation; +using Files.Application.Entities.Files.Dtos; -namespace Files.Application.Files.Commands.CreateFileAsset; +namespace Files.Application.Entities.Files.Commands.CreateFileAsset; +// ReSharper disable once ClassNeverInstantiated.Global public record CreateFileAssetCommand(FileAssetDto FileAsset) : ICommand; +// ReSharper disable once NotAccessedPositionalProperty.Global public record CreateFileAssetResult(FileAssetId Id); public class CreateFileCommandValidator : AbstractValidator diff --git a/Backend/src/Modules/Files/Files.Application/Files/Commands/CreateFileAsset/CreateFileAssetHandler.cs b/Backend/src/Modules/Files/Files.Application/Entities/Files/Commands/CreateFileAsset/CreateFileAssetHandler.cs similarity index 62% rename from Backend/src/Modules/Files/Files.Application/Files/Commands/CreateFileAsset/CreateFileAssetHandler.cs rename to Backend/src/Modules/Files/Files.Application/Entities/Files/Commands/CreateFileAsset/CreateFileAssetHandler.cs index 5b5be60..1ea420d 100644 --- a/Backend/src/Modules/Files/Files.Application/Files/Commands/CreateFileAsset/CreateFileAssetHandler.cs +++ b/Backend/src/Modules/Files/Files.Application/Entities/Files/Commands/CreateFileAsset/CreateFileAssetHandler.cs @@ -1,13 +1,24 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using Files.Application.Data; -namespace Files.Application.Files.Commands.CreateFileAsset; +namespace Files.Application.Entities.Files.Commands.CreateFileAsset; -public class CreateFileAssetHandler(IFilesDbContext dbContext) : +internal class CreateFileAssetHandler(IFilesDbContext dbContext) : ICommandHandler { public async Task Handle(CreateFileAssetCommand command, CancellationToken cancellationToken) { - var fileAsset = FileAsset.Create( + var fileAsset = command.ToFileAsset(); + + dbContext.FileAssets.Add(fileAsset); + await dbContext.SaveChangesAsync(cancellationToken); + + return new CreateFileAssetResult(fileAsset.Id); + } +} + +internal static class CreateFileAssetCommandExtensions +{ + public static FileAsset ToFileAsset(this CreateFileAssetCommand command) + { + return FileAsset.Create( FileAssetId.Of(Guid.NewGuid()), command.FileAsset.Name, command.FileAsset.Size, @@ -16,10 +27,5 @@ public async Task Handle(CreateFileAssetCommand command, command.FileAsset.Description, command.FileAsset.SharedWith ); - - dbContext.FileAssets.Add(fileAsset); - await dbContext.SaveChangesAsync(cancellationToken); - - return new CreateFileAssetResult(fileAsset.Id); } } diff --git a/Backend/src/Modules/Files/Files.Application/Dtos/FileAssetDto.cs b/Backend/src/Modules/Files/Files.Application/Entities/Files/Dtos/FileAssetDto.cs similarity index 72% rename from Backend/src/Modules/Files/Files.Application/Dtos/FileAssetDto.cs rename to Backend/src/Modules/Files/Files.Application/Entities/Files/Dtos/FileAssetDto.cs index 0bbfc2e..4335af9 100644 --- a/Backend/src/Modules/Files/Files.Application/Dtos/FileAssetDto.cs +++ b/Backend/src/Modules/Files/Files.Application/Entities/Files/Dtos/FileAssetDto.cs @@ -1,4 +1,4 @@ -namespace Files.Application.Dtos; +namespace Files.Application.Entities.Files.Dtos; public record FileAssetDto( string Id, diff --git a/Backend/src/Modules/Files/Files.Application/Entities/Files/Exceptions/FileAssetNotFoundException.cs b/Backend/src/Modules/Files/Files.Application/Entities/Files/Exceptions/FileAssetNotFoundException.cs new file mode 100644 index 0000000..993a2db --- /dev/null +++ b/Backend/src/Modules/Files/Files.Application/Entities/Files/Exceptions/FileAssetNotFoundException.cs @@ -0,0 +1,3 @@ +namespace Files.Application.Entities.Files.Exceptions; + +public class FileAssetNotFoundException(string id) : NotFoundException("FileAsset", id); diff --git a/Backend/src/Modules/Files/Files.Application/Entities/Files/Extensions/FileAssetExtensions.cs b/Backend/src/Modules/Files/Files.Application/Entities/Files/Extensions/FileAssetExtensions.cs new file mode 100644 index 0000000..b5dbb5c --- /dev/null +++ b/Backend/src/Modules/Files/Files.Application/Entities/Files/Extensions/FileAssetExtensions.cs @@ -0,0 +1,23 @@ +using Files.Application.Entities.Files.Dtos; + +namespace Files.Application.Entities.Files.Extensions; + +public static class FileAssetExtensions +{ + public static FileAssetDto ToFileAssetDto(this FileAsset fileAsset) + { + return new FileAssetDto( + fileAsset.Id.ToString(), + fileAsset.Name, + fileAsset.Size, + fileAsset.Type, + fileAsset.Owner, + fileAsset.Description, + fileAsset.SharedWith.ToList()); + } + + public static IEnumerable ToFileAssetDtoList(this IEnumerable fileAssets) + { + return fileAssets.Select(ToFileAssetDto); + } +} diff --git a/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/GetFileAssetById/GetFileAssetByIdHandler.cs b/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/GetFileAssetById/GetFileAssetByIdHandler.cs new file mode 100644 index 0000000..52a584b --- /dev/null +++ b/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/GetFileAssetById/GetFileAssetByIdHandler.cs @@ -0,0 +1,19 @@ +using Files.Application.Entities.Files.Exceptions; +using Files.Application.Entities.Files.Extensions; + +namespace Files.Application.Entities.Files.Queries.GetFileAssetById; + +internal class GetFileAssetByIdHandler(IFilesDbContext dbContext) : IQueryHandler +{ + public async Task Handle(GetFileAssetByIdQuery query, CancellationToken cancellationToken) + { + var fileAsset = await dbContext.FileAssets + .AsNoTracking() + .SingleOrDefaultAsync(f => f.Id == FileAssetId.Of(Guid.Parse(query.Id)), cancellationToken); + + if (fileAsset is null) + throw new FileAssetNotFoundException(query.Id); + + return new GetFileAssetByIdResult(fileAsset.ToFileAssetDto()); + } +} diff --git a/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/GetFileAssetById/GetFileAssetByIdQuery.cs b/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/GetFileAssetById/GetFileAssetByIdQuery.cs new file mode 100644 index 0000000..53bcf99 --- /dev/null +++ b/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/GetFileAssetById/GetFileAssetByIdQuery.cs @@ -0,0 +1,10 @@ +// ReSharper disable ClassNeverInstantiated.Global + +using Files.Application.Entities.Files.Dtos; + +namespace Files.Application.Entities.Files.Queries.GetFileAssetById; + +public record GetFileAssetByIdQuery(string Id) : IQuery; + +// ReSharper disable once NotAccessedPositionalProperty.Global +public record GetFileAssetByIdResult(FileAssetDto FileAssetDto); diff --git a/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/ListFileAssets/ListFileAssetsHandler.cs b/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/ListFileAssets/ListFileAssetsHandler.cs new file mode 100644 index 0000000..3932505 --- /dev/null +++ b/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/ListFileAssets/ListFileAssetsHandler.cs @@ -0,0 +1,30 @@ +using BuildingBlocks.Application.Pagination; +using Files.Application.Entities.Files.Dtos; +using Files.Application.Entities.Files.Extensions; + +namespace Files.Application.Entities.Files.Queries.ListFileAssets; + +public class ListFileAssetsHandler(IFilesDbContext dbContext) : IQueryHandler +{ + public async Task Handle(ListFileAssetsQuery query, CancellationToken cancellationToken) + { + var pageIndex = query.PaginationRequest.PageIndex; + var pageSize = query.PaginationRequest.PageSize; + + var totalCount = await dbContext.FileAssets.LongCountAsync(cancellationToken); + + var nodes = await dbContext.FileAssets + .AsNoTracking() + .OrderBy(n => n.CreatedAt) + .Skip(pageSize * pageIndex) + .Take(pageSize) + .ToListAsync(cancellationToken: cancellationToken); + + return new ListFileAssetsResult( + new PaginatedResult( + pageIndex, + pageSize, + totalCount, + nodes.ToFileAssetDtoList())); + } +} diff --git a/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/ListFileAssets/ListFileAssetsQuery.cs b/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/ListFileAssets/ListFileAssetsQuery.cs new file mode 100644 index 0000000..229c70a --- /dev/null +++ b/Backend/src/Modules/Files/Files.Application/Entities/Files/Queries/ListFileAssets/ListFileAssetsQuery.cs @@ -0,0 +1,10 @@ +using BuildingBlocks.Application.Pagination; +using Files.Application.Entities.Files.Dtos; + +// ReSharper disable ClassNeverInstantiated.Global + +namespace Files.Application.Entities.Files.Queries.ListFileAssets; + +public record ListFileAssetsQuery(PaginationRequest PaginationRequest) : IQuery; + +public record ListFileAssetsResult(PaginatedResult FileAssets); diff --git a/Backend/src/Modules/Files/Files.Application/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Files/Files.Application/Extensions/ServiceCollectionExtensions.cs index 2a88ba5..986ee30 100644 --- a/Backend/src/Modules/Files/Files.Application/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Files/Files.Application/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; -using System.Reflection; +using System.Reflection; +using BuildingBlocks.Application.Behaviors; +using Microsoft.Extensions.DependencyInjection; namespace Files.Application.Extensions; @@ -10,10 +11,12 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddMediatR(config => { config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); - // config.AddOpenBehavior(typeof(ValidationBehavior<,>)); - // config.AddOpenBehavior(typeof(LoggingBehavior<,>)); + config.AddOpenBehavior(typeof(ValidationBehavior<,>)); + config.AddOpenBehavior(typeof(LoggingBehavior<,>)); }); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + return services; } } diff --git a/Backend/src/Modules/Files/Files.Application/GlobalUsing.cs b/Backend/src/Modules/Files/Files.Application/GlobalUsing.cs index d56def5..27f0eb5 100644 --- a/Backend/src/Modules/Files/Files.Application/GlobalUsing.cs +++ b/Backend/src/Modules/Files/Files.Application/GlobalUsing.cs @@ -1,3 +1,7 @@ -global using Microsoft.EntityFrameworkCore; +global using FluentValidation; +global using Microsoft.EntityFrameworkCore; global using BuildingBlocks.Application.Cqrs; +global using BuildingBlocks.Application.Exceptions; +global using BuildingBlocks.Domain.ValueObjects.Ids; +global using Files.Application.Data; global using Files.Domain.Models; diff --git a/Backend/src/Modules/Files/Files.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs b/Backend/src/Modules/Files/Files.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..32017f3 --- /dev/null +++ b/Backend/src/Modules/Files/Files.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Files.Infrastructure.Data.Interceptors; + +public class AuditableEntityInterceptor : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + UpdateEntities(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + UpdateEntities(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void UpdateEntities(DbContext? context) + { + if (context == null) return; + + context.ChangeTracker.DetectChanges(); + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedBy = "username"; + entry.Entity.CreatedAt = DateTime.UtcNow; + } + + var isAdded = entry.State == EntityState.Added; + var isModified = entry.State == EntityState.Modified; + + if (!isAdded && !isModified && !entry.HasChangedOwnedEntities()) + continue; + + entry.Entity.LastModifiedBy = "username"; + entry.Entity.LastModifiedAt = DateTime.UtcNow; + } + } +} + +public static class Extensions +{ + public static bool HasChangedOwnedEntities(this EntityEntry entry) + { + return entry.References.Any(r => + r.TargetEntry != null && + r.TargetEntry.Metadata.IsOwned() && + r.TargetEntry.State is EntityState.Added or EntityState.Modified); + } +} diff --git a/Backend/src/Modules/Files/Files.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs b/Backend/src/Modules/Files/Files.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs new file mode 100644 index 0000000..4f527e6 --- /dev/null +++ b/Backend/src/Modules/Files/Files.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs @@ -0,0 +1,41 @@ +using MediatR; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Files.Infrastructure.Data.Interceptors; + +public class DispatchDomainEventsInterceptor(IMediator mediator) : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult(); + return base.SavingChanges(eventData, result); + } + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + await DispatchDomainEvents(eventData.Context); + return await base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private async Task DispatchDomainEvents(DbContext? context) + { + if (context == null) return; + + var aggregates = context.ChangeTracker + .Entries() + .Where(a => a.Entity.DomainEvents.Any()) + .Select(a => a.Entity); + + var aggregatesList = aggregates.ToList(); + + var domainEvents = aggregatesList + .SelectMany(a => a.DomainEvents) + .ToList(); + + aggregatesList.ForEach(a => a.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + await mediator.Publish(domainEvent); + } +} diff --git a/Backend/src/Modules/Files/Files.Infrastructure/DependencyInjection.cs b/Backend/src/Modules/Files/Files.Infrastructure/DependencyInjection.cs index 73ab177..10103ce 100644 --- a/Backend/src/Modules/Files/Files.Infrastructure/DependencyInjection.cs +++ b/Backend/src/Modules/Files/Files.Infrastructure/DependencyInjection.cs @@ -1,7 +1,8 @@ -using Files.Application.Data; -using Files.Infrastructure.Data; -using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; +using Files.Application.Data; +using Files.Infrastructure.Data; +using Files.Infrastructure.Data.Interceptors; namespace Files.Infrastructure; @@ -12,6 +13,9 @@ public static IServiceCollection AddInfrastructureServices { var connectionString = configuration.GetConnectionString("DefaultConnection"); + services.AddScoped(); + services.AddScoped(); + // Add File-specific DbContext services.AddDbContext((serviceProvider, options) => { diff --git a/Backend/src/Modules/Files/Files.Infrastructure/GlobalUsing.cs b/Backend/src/Modules/Files/Files.Infrastructure/GlobalUsing.cs index 6c3adfe..7545782 100644 --- a/Backend/src/Modules/Files/Files.Infrastructure/GlobalUsing.cs +++ b/Backend/src/Modules/Files/Files.Infrastructure/GlobalUsing.cs @@ -1,4 +1,5 @@ global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.DependencyInjection; +global using BuildingBlocks.Domain.Abstractions; global using BuildingBlocks.Domain.ValueObjects.Ids; global using Files.Domain.Models; diff --git a/Backend/src/Modules/Nodes/Nodes.Api/Endpoints/Nodes/CreateNode.cs b/Backend/src/Modules/Nodes/Nodes.Api/Endpoints/Nodes/CreateNode.cs index 5c27bdc..8a664d4 100644 --- a/Backend/src/Modules/Nodes/Nodes.Api/Endpoints/Nodes/CreateNode.cs +++ b/Backend/src/Modules/Nodes/Nodes.Api/Endpoints/Nodes/CreateNode.cs @@ -2,6 +2,8 @@ using Nodes.Application.Entities.Nodes.Commands.CreateNode; // ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable NotAccessedPositionalProperty.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global namespace Nodes.Api.Endpoints.Nodes; @@ -10,22 +12,28 @@ public class CreateNode : ICarterModule public void AddRoutes(IEndpointRouteBuilder app) { app.MapPost("/Nodes", async (CreateNodeRequest request, ISender sender) => - { - var command = request.Adapt(); - var result = await sender.Send(command); - var response = result.Adapt(); + { + var command = request.Adapt(); + var result = await sender.Send(command); + var response = result.Adapt(); - return Results.Created($"/Nodes/{response.Id}", response); - }) - .WithName("CreateNode") - .Produces(StatusCodes.Status201Created) - .ProducesProblem(StatusCodes.Status400BadRequest) - .WithSummary("Create Node") - .WithDescription("Creates a new node"); + return Results.Created($"/Nodes/{response.Id}", response); + }) + .WithName("CreateNode") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Create Node") + .WithDescription("Creates a new node"); } } -// ReSharper disable once NotAccessedPositionalProperty.Global -public record CreateNodeRequest(NodeDto Node); +public class CreateNodeRequest +{ + public CreateNodeRequest() { } + + public CreateNodeRequest(NodeDto node) => Node = node; + + public NodeDto Node { get; set; } +} public record CreateNodeResponse(NodeId Id); diff --git a/Backend/src/Modules/Nodes/Nodes.Api/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Nodes/Nodes.Api/Extensions/ServiceCollectionExtensions.cs index 6562ec0..a40a195 100644 --- a/Backend/src/Modules/Nodes/Nodes.Api/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Nodes/Nodes.Api/Extensions/ServiceCollectionExtensions.cs @@ -28,7 +28,7 @@ private static IServiceCollection AddApiServices(this IServiceCollection service public static IEndpointRouteBuilder UseNodesModule(this IEndpointRouteBuilder endpoints) { endpoints.MapGet("/Nodes/Test", () => "Nodes.Api Test -> Ok!"); - + return endpoints; } } diff --git a/Backend/src/Modules/Nodes/Nodes.Api/Nodes.Api.csproj b/Backend/src/Modules/Nodes/Nodes.Api/Nodes.Api.csproj index b8f2e2d..7e9b647 100644 --- a/Backend/src/Modules/Nodes/Nodes.Api/Nodes.Api.csproj +++ b/Backend/src/Modules/Nodes/Nodes.Api/Nodes.Api.csproj @@ -1,23 +1,19 @@ - - - - net9.0 - Library - - - - - - - - - - - - - - - - - - + + + + net9.0 + Library + + + + + + + + + + + + + + diff --git a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Commands/CreateNode/CreateNodeCommand.cs b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Commands/CreateNode/CreateNodeCommand.cs index 0fb812d..b0cfe33 100644 --- a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Commands/CreateNode/CreateNodeCommand.cs +++ b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Commands/CreateNode/CreateNodeCommand.cs @@ -15,11 +15,37 @@ public CreateNodeCommandValidator() RuleFor(x => x.Node.Title) .NotEmpty().WithMessage("Title is required.") .MaximumLength(100).WithMessage("Title must not exceed 100 characters."); - + RuleFor(x => x.Node.Description) .NotEmpty().WithMessage("Description is required.") .MaximumLength(500).WithMessage("Description must not exceed 500 characters."); - // ToDo: Add remaining Node command validators + RuleFor(x => x.Node.Timestamp) + .LessThanOrEqualTo(DateTime.Now).WithMessage("Timestamp cannot be in the future."); + + RuleFor(x => x.Node.Importance) + .InclusiveBetween(1, 10).WithMessage("Importance must be between 1 and 10."); + + RuleFor(x => x.Node.Phase) + .NotEmpty().WithMessage("Phase is required."); + + RuleFor(x => x.Node) + .NotNull().WithMessage("Node cannot be null.") + .DependentRules(() => + { + RuleFor(x => x.Node.Categories) + .Must(categories => categories != null && categories.Count > 0) + .WithMessage("At least one category must be provided."); + + RuleFor(x => x.Node.Tags) + .Must(tags => tags != null && tags.Count > 0) + .WithMessage("At least one tag must be provided."); + }); + + RuleForEach(x => x.Node.Categories) + .MaximumLength(50).WithMessage("Category must not exceed 50 characters."); + + RuleForEach(x => x.Node.Tags) + .MaximumLength(50).WithMessage("Tag must not exceed 50 characters."); } } diff --git a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Dtos/NodeDto.cs b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Dtos/NodeDto.cs index c7a1e4f..6b3f4f1 100644 --- a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Dtos/NodeDto.cs +++ b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Dtos/NodeDto.cs @@ -1,11 +1,30 @@ +using System.Text.Json.Serialization; + namespace Nodes.Application.Entities.Nodes.Dtos; -public record NodeDto( - string Id, - string Title, - string Description, - DateTime Timestamp, - int Importance, - string Phase, - List Categories, - List Tags); +public class NodeDto( + string? id, + string title, + string description, + DateTime timestamp, + int importance, + string phase, + List categories, + List tags) +{ + [JsonPropertyName("id")] public string? Id { get; } = id; + + [JsonPropertyName("title")] public string Title { get; } = title; + + [JsonPropertyName("description")] public string Description { get; } = description; + + [JsonPropertyName("timestamp")] public DateTime Timestamp { get; } = timestamp; + + [JsonPropertyName("importance")] public int Importance { get; } = importance; + + [JsonPropertyName("phase")] public string Phase { get; } = phase; + + [JsonPropertyName("categories")] public List Categories { get; } = categories; + + [JsonPropertyName("tags")] public List Tags { get; } = tags; +} diff --git a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/GetNodeById/GetNodeByIdHandler.cs b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/GetNodeById/GetNodeByIdHandler.cs index 2a3c6bd..279532a 100644 --- a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/GetNodeById/GetNodeByIdHandler.cs +++ b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/GetNodeById/GetNodeByIdHandler.cs @@ -5,14 +5,16 @@ namespace Nodes.Application.Entities.Nodes.Queries.GetNodeById; internal class GetNodeByIdHandler(INodesDbContext dbContext) : IQueryHandler { - public async Task Handle(GetNodeByIdQuery request, CancellationToken cancellationToken) + public async Task Handle(GetNodeByIdQuery query, CancellationToken cancellationToken) { + var nodeId = query.Id.ToString(); + var node = await dbContext.Nodes .AsNoTracking() - .SingleOrDefaultAsync(n => n.Id == NodeId.Of(Guid.Parse(request.Id)), cancellationToken); + .SingleOrDefaultAsync(n => n.Id == NodeId.Of(Guid.Parse(nodeId)), cancellationToken); if (node is null) - throw new NodeNotFoundException(request.Id); + throw new NodeNotFoundException(nodeId); return new GetNodeByIdResult(node.ToNodeDto()); } diff --git a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/GetNodeById/GetNodeByIdQuery.cs b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/GetNodeById/GetNodeByIdQuery.cs index 9d7ffb1..5a3b6e6 100644 --- a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/GetNodeById/GetNodeByIdQuery.cs +++ b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/GetNodeById/GetNodeByIdQuery.cs @@ -4,7 +4,20 @@ namespace Nodes.Application.Entities.Nodes.Queries.GetNodeById; -public record GetNodeByIdQuery(string Id) : IQuery; +public record GetNodeByIdQuery(NodeId Id) : IQuery +{ + public GetNodeByIdQuery(string Id) : this(NodeId.Of(Guid.Parse(Id))) { } +} // ReSharper disable once NotAccessedPositionalProperty.Global public record GetNodeByIdResult(NodeDto NodeDto); + +public class GetNodeByIdQueryValidator : AbstractValidator +{ + public GetNodeByIdQueryValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("Id is required.") + .Must(value => Guid.TryParse(value.ToString(), out _)).WithMessage("Id is not valid."); + } +} diff --git a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/ListNodes/ListNodesHandler.cs b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/ListNodes/ListNodesHandler.cs index 37c0b79..7c00e7d 100644 --- a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/ListNodes/ListNodesHandler.cs +++ b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/ListNodes/ListNodesHandler.cs @@ -4,7 +4,7 @@ namespace Nodes.Application.Entities.Nodes.Queries.ListNodes; -public class ListNodesHandler(INodesDbContext dbContext) : IQueryHandler +internal class ListNodesHandler(INodesDbContext dbContext) : IQueryHandler { public async Task Handle(ListNodesQuery query, CancellationToken cancellationToken) { @@ -12,14 +12,14 @@ public async Task Handle(ListNodesQuery query, CancellationToke var pageSize = query.PaginationRequest.PageSize; var totalCount = await dbContext.Nodes.LongCountAsync(cancellationToken); - + var nodes = await dbContext.Nodes .AsNoTracking() .OrderBy(n => n.Timestamp) .Skip(pageSize * pageIndex) .Take(pageSize) .ToListAsync(cancellationToken: cancellationToken); - + return new ListNodesResult( new PaginatedResult( pageIndex, diff --git a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/ListNodes/ListNodesQuery.cs b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/ListNodes/ListNodesQuery.cs index 0e5a6cb..b185cde 100644 --- a/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/ListNodes/ListNodesQuery.cs +++ b/Backend/src/Modules/Nodes/Nodes.Application/Entities/Nodes/Queries/ListNodes/ListNodesQuery.cs @@ -2,6 +2,7 @@ using Nodes.Application.Entities.Nodes.Dtos; // ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable NotAccessedPositionalProperty.Global namespace Nodes.Application.Entities.Nodes.Queries.ListNodes; diff --git a/Backend/src/Modules/Nodes/Nodes.Application/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Nodes/Nodes.Application/Extensions/ServiceCollectionExtensions.cs index 28cafb1..8d29e5d 100644 --- a/Backend/src/Modules/Nodes/Nodes.Application/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Nodes/Nodes.Application/Extensions/ServiceCollectionExtensions.cs @@ -14,6 +14,8 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection config.AddOpenBehavior(typeof(ValidationBehavior<,>)); config.AddOpenBehavior(typeof(LoggingBehavior<,>)); }); + + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); diff --git a/Backend/src/Modules/Nodes/Nodes.Domain/Events/NodeCreatedEvent.cs b/Backend/src/Modules/Nodes/Nodes.Domain/Events/NodeCreatedEvent.cs index 51b1020..a5aca3f 100644 --- a/Backend/src/Modules/Nodes/Nodes.Domain/Events/NodeCreatedEvent.cs +++ b/Backend/src/Modules/Nodes/Nodes.Domain/Events/NodeCreatedEvent.cs @@ -2,4 +2,4 @@ namespace Nodes.Domain.Events; -public record NodeCreatedEvent(Node Node) : IDomainEvent { } +public record NodeCreatedEvent(Node Node) : IDomainEvent; diff --git a/Backend/src/Modules/Nodes/Nodes.Domain/Events/NodeUpdatedEvent.cs b/Backend/src/Modules/Nodes/Nodes.Domain/Events/NodeUpdatedEvent.cs index 3b75f8a..a631e81 100644 --- a/Backend/src/Modules/Nodes/Nodes.Domain/Events/NodeUpdatedEvent.cs +++ b/Backend/src/Modules/Nodes/Nodes.Domain/Events/NodeUpdatedEvent.cs @@ -2,4 +2,4 @@ namespace Nodes.Domain.Events; -public record NodeUpdatedEvent(Node Node) : IDomainEvent { } +public record NodeUpdatedEvent(Node Node) : IDomainEvent; diff --git a/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Configurations/NodeConfiguration.cs b/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Configurations/NodeConfiguration.cs index 56efb78..69c8194 100644 --- a/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Configurations/NodeConfiguration.cs +++ b/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Configurations/NodeConfiguration.cs @@ -1,16 +1,28 @@ -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Nodes.Infrastructure.Data.Configurations; - -public class NodeConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(n => n.Id); - builder.Property(n => n.Id).HasConversion( - nodeId => nodeId.Value, - dbId => NodeId.Of(dbId)); - - // ToDo: Add remaining Node configuration commands - } -} +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Nodes.Infrastructure.Data.Configurations; + +public class NodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(n => n.Id); + builder.Property(n => n.Id).HasConversion( + nodeId => nodeId.Value, + dbId => NodeId.Of(dbId)); + + builder.Property(n => n.Title) + .IsRequired() + .HasMaxLength(100); + + builder.Property(n => n.Description) + .IsRequired() + .HasMaxLength(500); + + builder.Property(n => n.Importance) + .IsRequired(); + + builder.Property(n => n.Phase) + .IsRequired(); + } +} diff --git a/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/20241228182710_ApplyNewNodeConfigurations.Designer.cs b/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/20241228182710_ApplyNewNodeConfigurations.Designer.cs new file mode 100644 index 0000000..b127a32 --- /dev/null +++ b/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/20241228182710_ApplyNewNodeConfigurations.Designer.cs @@ -0,0 +1,73 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nodes.Infrastructure.Data; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nodes.Infrastructure.Data.Migrations +{ + [DbContext(typeof(NodesDbContext))] + [Migration("20241228182710_ApplyNewNodeConfigurations")] + partial class ApplyNewNodeConfigurations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Nodes") + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nodes.Domain.Models.Node", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Importance") + .HasColumnType("integer"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedBy") + .HasColumnType("text"); + + b.Property("Phase") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", "Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/20241228182710_ApplyNewNodeConfigurations.cs b/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/20241228182710_ApplyNewNodeConfigurations.cs new file mode 100644 index 0000000..8dc6259 --- /dev/null +++ b/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/20241228182710_ApplyNewNodeConfigurations.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nodes.Infrastructure.Data.Migrations +{ + /// + public partial class ApplyNewNodeConfigurations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Title", + schema: "Nodes", + table: "Nodes", + type: "character varying(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Description", + schema: "Nodes", + table: "Nodes", + type: "character varying(500)", + maxLength: 500, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Title", + schema: "Nodes", + table: "Nodes", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "Description", + schema: "Nodes", + table: "Nodes", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(500)", + oldMaxLength: 500); + } + } +} diff --git a/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/NodesDbContextModelSnapshot.cs b/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/NodesDbContextModelSnapshot.cs index bfcc659..cd8b2de 100644 --- a/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/NodesDbContextModelSnapshot.cs +++ b/Backend/src/Modules/Nodes/Nodes.Infrastructure/Data/Migrations/NodesDbContextModelSnapshot.cs @@ -1,68 +1,70 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Nodes.Infrastructure.Data; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Nodes.Infrastructure.Data.Migrations -{ - [DbContext(typeof(NodesDbContext))] - partial class NodesDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("Nodes") - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Nodes.Domain.Models.Node", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Importance") - .HasColumnType("integer"); - - b.Property("LastModifiedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastModifiedBy") - .HasColumnType("text"); - - b.Property("Phase") - .IsRequired() - .HasColumnType("text"); - - b.Property("Timestamp") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Nodes", "Nodes"); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nodes.Infrastructure.Data; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nodes.Infrastructure.Data.Migrations +{ + [DbContext(typeof(NodesDbContext))] + partial class NodesDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Nodes") + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nodes.Domain.Models.Node", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Importance") + .HasColumnType("integer"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedBy") + .HasColumnType("text"); + + b.Property("Phase") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", "Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/Modules/Notes/Notes.Api/Endpoints/CreateNote.cs b/Backend/src/Modules/Notes/Notes.Api/Endpoints/Notes/CreateNote.cs similarity index 76% rename from Backend/src/Modules/Notes/Notes.Api/Endpoints/CreateNote.cs rename to Backend/src/Modules/Notes/Notes.Api/Endpoints/Notes/CreateNote.cs index 39ecde2..616b055 100644 --- a/Backend/src/Modules/Notes/Notes.Api/Endpoints/CreateNote.cs +++ b/Backend/src/Modules/Notes/Notes.Api/Endpoints/Notes/CreateNote.cs @@ -1,14 +1,7 @@ using BuildingBlocks.Domain.ValueObjects.Ids; -using Carter; -using Mapster; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Notes.Application.Dtos; -using Notes.Application.Notes.Commands.CreateNote; +using Notes.Application.Entities.Notes.Commands.CreateNote; -namespace Notes.Api.Endpoints; +namespace Notes.Api.Endpoints.Notes; public class CreateNote : ICarterModule { diff --git a/Backend/src/Modules/Notes/Notes.Api/Endpoints/Notes/GetNoteById.cs b/Backend/src/Modules/Notes/Notes.Api/Endpoints/Notes/GetNoteById.cs new file mode 100644 index 0000000..01e4158 --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Api/Endpoints/Notes/GetNoteById.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using Notes.Application.Entities.Notes.Queries.GetNoteById; + +namespace Notes.Api.Endpoints.Notes; + +public class GetNoteById : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("/Notes/{noteId}", async (string noteId, ISender sender) => + { + var result = await sender.Send(new GetNoteByIdQuery(noteId)); + var response = result.Adapt(); + + return Results.Ok(response); + }) + .WithName("GetNoteById") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get Note by Id") + .WithDescription("Get Note by Id"); + } +} + +public record GetNoteByIdResponse([property: JsonPropertyName("note")] NoteDto NoteDto); diff --git a/Backend/src/Modules/Notes/Notes.Api/Endpoints/Notes/ListNotes.cs b/Backend/src/Modules/Notes/Notes.Api/Endpoints/Notes/ListNotes.cs new file mode 100644 index 0000000..c449e1f --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Api/Endpoints/Notes/ListNotes.cs @@ -0,0 +1,25 @@ +using BuildingBlocks.Application.Pagination; +using Notes.Application.Entities.Notes.Queries.ListNotes; + +namespace Notes.Api.Endpoints.Notes; + +public class ListNotes : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("/Notes", async ([AsParameters] PaginationRequest query, ISender sender) => + { + var result = await sender.Send(new ListNotesQuery(query)); + var response = result.Adapt(); + + return Results.Ok(response); + }) + .WithName("ListNotes") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("List Notes") + .WithDescription("List Notes"); + } +} + +public record ListNotesResponse(PaginatedResult Notes); diff --git a/Backend/src/Modules/Notes/Notes.Api/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Notes/Notes.Api/Extensions/ServiceCollectionExtensions.cs index 6d7c2f7..ac2cb93 100644 --- a/Backend/src/Modules/Notes/Notes.Api/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Notes/Notes.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; +using BuildingBlocks.Api.Converters; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Notes.Application.Extensions; @@ -15,19 +14,21 @@ public static IServiceCollection AddNotesModule services.AddApiServices(); services.AddApplicationServices(); services.AddInfrastructureServices(configuration); - + return services; } private static IServiceCollection AddApiServices(this IServiceCollection services) { + TypeAdapterConfig.GlobalSettings.Scan(typeof(NoteIdConverter).Assembly); + return services; } public static IEndpointRouteBuilder UseNotesModule(this IEndpointRouteBuilder endpoints) { endpoints.MapGet("/Notes/Test", () => "Notes.Api Test -> Ok!"); - + return endpoints; } } diff --git a/Backend/src/Modules/Notes/Notes.Api/GlobalUsing.cs b/Backend/src/Modules/Notes/Notes.Api/GlobalUsing.cs new file mode 100644 index 0000000..589a722 --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Api/GlobalUsing.cs @@ -0,0 +1,7 @@ +global using Carter; +global using Mapster; +global using MediatR; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Routing; +global using Notes.Application.Entities.Notes.Dtos; diff --git a/Backend/src/Modules/Notes/Notes.Application/Notes/Commands/CreateNote/CreateNoteCommand.cs b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Commands/CreateNote/CreateNoteCommand.cs similarity index 76% rename from Backend/src/Modules/Notes/Notes.Application/Notes/Commands/CreateNote/CreateNoteCommand.cs rename to Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Commands/CreateNote/CreateNoteCommand.cs index e450809..af6f105 100644 --- a/Backend/src/Modules/Notes/Notes.Application/Notes/Commands/CreateNote/CreateNoteCommand.cs +++ b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Commands/CreateNote/CreateNoteCommand.cs @@ -1,8 +1,6 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using FluentValidation; -using Notes.Application.Dtos; +using Notes.Application.Entities.Notes.Dtos; -namespace Notes.Application.Notes.Commands.CreateNote; +namespace Notes.Application.Entities.Notes.Commands.CreateNote; public record CreateNoteCommand(NoteDto Note) : ICommand; diff --git a/Backend/src/Modules/Notes/Notes.Application/Notes/Commands/CreateNote/CreateNoteHandler.cs b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Commands/CreateNote/CreateNoteHandler.cs similarity index 61% rename from Backend/src/Modules/Notes/Notes.Application/Notes/Commands/CreateNote/CreateNoteHandler.cs rename to Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Commands/CreateNote/CreateNoteHandler.cs index da59aff..8b78728 100644 --- a/Backend/src/Modules/Notes/Notes.Application/Notes/Commands/CreateNote/CreateNoteHandler.cs +++ b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Commands/CreateNote/CreateNoteHandler.cs @@ -1,24 +1,29 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using Notes.Application.Data; +namespace Notes.Application.Entities.Notes.Commands.CreateNote; -namespace Notes.Application.Notes.Commands.CreateNote; - -public class CreateNoteHandler(INotesDbContext dbContext) +internal class CreateNoteHandler(INotesDbContext dbContext) : ICommandHandler { public async Task Handle(CreateNoteCommand command, CancellationToken cancellationToken) { - var note = Note.Create( + var note = command.ToNote(); + + dbContext.Notes.Add(note); + await dbContext.SaveChangesAsync(cancellationToken); + + return new CreateNoteResult(note.Id); + } +} + +internal static class CreateNoteCommandExtensions +{ + public static Note ToNote(this CreateNoteCommand command) + { + return Note.Create( NoteId.Of(Guid.NewGuid()), command.Note.Title, command.Note.Content, command.Note.Timestamp, command.Note.Importance ); - - dbContext.Notes.Add(note); - await dbContext.SaveChangesAsync(cancellationToken); - - return new CreateNoteResult(note.Id); } } diff --git a/Backend/src/Modules/Notes/Notes.Application/Dtos/NoteDto.cs b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Dtos/NoteDto.cs similarity index 61% rename from Backend/src/Modules/Notes/Notes.Application/Dtos/NoteDto.cs rename to Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Dtos/NoteDto.cs index fa429df..db9f2b7 100644 --- a/Backend/src/Modules/Notes/Notes.Application/Dtos/NoteDto.cs +++ b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Dtos/NoteDto.cs @@ -1,6 +1,7 @@ -namespace Notes.Application.Dtos; +namespace Notes.Application.Entities.Notes.Dtos; public record NoteDto( + string Id, string Title, string Content, DateTime Timestamp, diff --git a/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Exceptions/NoteNotFoundException.cs b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Exceptions/NoteNotFoundException.cs new file mode 100644 index 0000000..2479788 --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Exceptions/NoteNotFoundException.cs @@ -0,0 +1,3 @@ +namespace Notes.Application.Entities.Notes.Exceptions; + +public class NoteNotFoundException(string id) : NotFoundException("Note", id); diff --git a/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Extensions/NoteExtensions.cs b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Extensions/NoteExtensions.cs new file mode 100644 index 0000000..af5468b --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Extensions/NoteExtensions.cs @@ -0,0 +1,21 @@ +using Notes.Application.Entities.Notes.Dtos; + +namespace Notes.Application.Entities.Notes.Extensions; + +public static class NoteExtensions +{ + public static NoteDto ToNoteDto(this Note note) + { + return new NoteDto( + note.Id.ToString(), + note.Title, + note.Content, + note.Timestamp, + note.Importance); + } + + public static IEnumerable ToNodeDtoList(this IEnumerable notes) + { + return notes.Select(ToNoteDto); + } +} diff --git a/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/GetNoteById/GetNoteByIdHandler.cs b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/GetNoteById/GetNoteByIdHandler.cs new file mode 100644 index 0000000..a98d648 --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/GetNoteById/GetNoteByIdHandler.cs @@ -0,0 +1,19 @@ +using Notes.Application.Entities.Notes.Exceptions; +using Notes.Application.Entities.Notes.Extensions; + +namespace Notes.Application.Entities.Notes.Queries.GetNoteById; + +internal class GetNoteByIdHandler(INotesDbContext dbContext) : IQueryHandler +{ + public async Task Handle(GetNoteByIdQuery query, CancellationToken cancellationToken) + { + var note = await dbContext.Notes + .AsNoTracking() + .SingleOrDefaultAsync(n => n.Id == NoteId.Of(Guid.Parse(query.Id)), cancellationToken); + + if (note is null) + throw new NoteNotFoundException(query.Id); + + return new GetNoteByIdResult(note.ToNoteDto()); + } +} diff --git a/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/GetNoteById/GetNoteByIdQuery.cs b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/GetNoteById/GetNoteByIdQuery.cs new file mode 100644 index 0000000..7d7c8be --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/GetNoteById/GetNoteByIdQuery.cs @@ -0,0 +1,7 @@ +using Notes.Application.Entities.Notes.Dtos; + +namespace Notes.Application.Entities.Notes.Queries.GetNoteById; + +public record GetNoteByIdQuery(string Id) : IQuery; + +public record GetNoteByIdResult(NoteDto NoteDto); diff --git a/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/ListNotes/ListNotesHandler.cs b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/ListNotes/ListNotesHandler.cs new file mode 100644 index 0000000..2a6c80d --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/ListNotes/ListNotesHandler.cs @@ -0,0 +1,30 @@ +using BuildingBlocks.Application.Pagination; +using Notes.Application.Entities.Notes.Dtos; +using Notes.Application.Entities.Notes.Extensions; + +namespace Notes.Application.Entities.Notes.Queries.ListNotes; + +internal class ListNotesHandler(INotesDbContext dbContext) : IQueryHandler +{ + public async Task Handle(ListNotesQuery query, CancellationToken cancellationToken) + { + var pageIndex = query.PaginationRequest.PageIndex; + var pageSize = query.PaginationRequest.PageSize; + + var totalCount = await dbContext.Notes.LongCountAsync(cancellationToken); + + var notes = await dbContext.Notes + .AsNoTracking() + .OrderBy(n => n.Timestamp) + .Skip(pageSize * pageIndex) + .Take(pageSize) + .ToListAsync(cancellationToken: cancellationToken); + + return new ListNotesResult( + new PaginatedResult( + pageIndex, + pageSize, + totalCount, + notes.ToNodeDtoList())); + } +} diff --git a/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/ListNotes/ListNotesQuery.cs b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/ListNotes/ListNotesQuery.cs new file mode 100644 index 0000000..457f3be --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Application/Entities/Notes/Queries/ListNotes/ListNotesQuery.cs @@ -0,0 +1,8 @@ +using BuildingBlocks.Application.Pagination; +using Notes.Application.Entities.Notes.Dtos; + +namespace Notes.Application.Entities.Notes.Queries.ListNotes; + +public record ListNotesQuery(PaginationRequest PaginationRequest) : IQuery; + +public record ListNotesResult(PaginatedResult Notes); diff --git a/Backend/src/Modules/Notes/Notes.Application/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Notes/Notes.Application/Extensions/ServiceCollectionExtensions.cs index 5584c17..dcdef9d 100644 --- a/Backend/src/Modules/Notes/Notes.Application/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Notes/Notes.Application/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using System.Reflection; +using BuildingBlocks.Application.Behaviors; using Microsoft.Extensions.DependencyInjection; namespace Notes.Application.Extensions; @@ -10,8 +11,12 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddMediatR(config => { config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + config.AddOpenBehavior(typeof(ValidationBehavior<,>)); + config.AddOpenBehavior(typeof(LoggingBehavior<,>)); }); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + return services; } } diff --git a/Backend/src/Modules/Notes/Notes.Application/GlobalUsing.cs b/Backend/src/Modules/Notes/Notes.Application/GlobalUsing.cs index ae1e9e3..840dd78 100644 --- a/Backend/src/Modules/Notes/Notes.Application/GlobalUsing.cs +++ b/Backend/src/Modules/Notes/Notes.Application/GlobalUsing.cs @@ -1,3 +1,7 @@ +global using FluentValidation; global using Microsoft.EntityFrameworkCore; global using BuildingBlocks.Application.Cqrs; +global using BuildingBlocks.Application.Exceptions; +global using BuildingBlocks.Domain.ValueObjects.Ids; +global using Notes.Application.Data; global using Notes.Domain.Models; diff --git a/Backend/src/Modules/Notes/Notes.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs b/Backend/src/Modules/Notes/Notes.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..0b8bc8a --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Notes.Infrastructure.Data.Interceptors; + +public class AuditableEntityInterceptor : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + UpdateEntities(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + UpdateEntities(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void UpdateEntities(DbContext? context) + { + if (context == null) return; + + context.ChangeTracker.DetectChanges(); + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedBy = "username"; + entry.Entity.CreatedAt = DateTime.UtcNow; + } + + var isAdded = entry.State == EntityState.Added; + var isModified = entry.State == EntityState.Modified; + + if (!isAdded && !isModified && !entry.HasChangedOwnedEntities()) + continue; + + entry.Entity.LastModifiedBy = "username"; + entry.Entity.LastModifiedAt = DateTime.UtcNow; + } + } +} + +public static class Extensions +{ + public static bool HasChangedOwnedEntities(this EntityEntry entry) + { + return entry.References.Any(r => + r.TargetEntry != null && + r.TargetEntry.Metadata.IsOwned() && + r.TargetEntry.State is EntityState.Added or EntityState.Modified); + } +} diff --git a/Backend/src/Modules/Notes/Notes.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs b/Backend/src/Modules/Notes/Notes.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs new file mode 100644 index 0000000..ee05e04 --- /dev/null +++ b/Backend/src/Modules/Notes/Notes.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs @@ -0,0 +1,41 @@ +using MediatR; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Notes.Infrastructure.Data.Interceptors; + +public class DispatchDomainEventsInterceptor(IMediator mediator) : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult(); + return base.SavingChanges(eventData, result); + } + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + await DispatchDomainEvents(eventData.Context); + return await base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private async Task DispatchDomainEvents(DbContext? context) + { + if (context == null) return; + + var aggregates = context.ChangeTracker + .Entries() + .Where(a => a.Entity.DomainEvents.Any()) + .Select(a => a.Entity); + + var aggregatesList = aggregates.ToList(); + + var domainEvents = aggregatesList + .SelectMany(a => a.DomainEvents) + .ToList(); + + aggregatesList.ForEach(a => a.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + await mediator.Publish(domainEvent); + } +} diff --git a/Backend/src/Modules/Notes/Notes.Infrastructure/DependencyInjection.cs b/Backend/src/Modules/Notes/Notes.Infrastructure/DependencyInjection.cs index d6ca5e6..ef22f24 100644 --- a/Backend/src/Modules/Notes/Notes.Infrastructure/DependencyInjection.cs +++ b/Backend/src/Modules/Notes/Notes.Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Notes.Application.Data; using Notes.Infrastructure.Data; +using Notes.Infrastructure.Data.Interceptors; namespace Notes.Infrastructure; @@ -12,6 +13,9 @@ public static IServiceCollection AddInfrastructureServices { var connectionString = configuration.GetConnectionString("DefaultConnection"); + services.AddScoped(); + services.AddScoped(); + // Add Note-specific DbContext services.AddDbContext((serviceProvider, options) => { diff --git a/Backend/src/Modules/Notes/Notes.Infrastructure/GlobalUsing.cs b/Backend/src/Modules/Notes/Notes.Infrastructure/GlobalUsing.cs index 9f0a97b..a3fbde8 100644 --- a/Backend/src/Modules/Notes/Notes.Infrastructure/GlobalUsing.cs +++ b/Backend/src/Modules/Notes/Notes.Infrastructure/GlobalUsing.cs @@ -1,4 +1,5 @@ global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.DependencyInjection; +global using BuildingBlocks.Domain.Abstractions; global using BuildingBlocks.Domain.ValueObjects.Ids; global using Notes.Domain.Models; diff --git a/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/CreateReminder.cs b/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/Reminders/CreateReminder.cs similarity index 74% rename from Backend/src/Modules/Reminders/Reminders.Api/Endpoints/CreateReminder.cs rename to Backend/src/Modules/Reminders/Reminders.Api/Endpoints/Reminders/CreateReminder.cs index fd7f7d3..c9ffb7b 100644 --- a/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/CreateReminder.cs +++ b/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/Reminders/CreateReminder.cs @@ -1,14 +1,9 @@ using BuildingBlocks.Domain.ValueObjects.Ids; -using Carter; -using MediatR; -using Mapster; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Reminders.Application.Dtos; -using Reminders.Application.Reminders.Commands.CreateReminder; +using Reminders.Application.Entities.Reminders.Commands.CreateReminder; -namespace Reminders.Api.Endpoints; +// ReSharper disable ClassNeverInstantiated.Global + +namespace Reminders.Api.Endpoints.Reminders; public class CreateReminder : ICarterModule { @@ -30,6 +25,7 @@ public void AddRoutes(IEndpointRouteBuilder app) } } +// ReSharper disable once NotAccessedPositionalProperty.Global public record CreateReminderRequest(ReminderDto Reminder); public record CreateReminderResponse(ReminderId Id); diff --git a/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/Reminders/GetReminderById.cs b/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/Reminders/GetReminderById.cs new file mode 100644 index 0000000..5fbb74f --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/Reminders/GetReminderById.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Reminders.Application.Entities.Reminders.Queries.GetReminderById; + +namespace Reminders.Api.Endpoints.Reminders; + +// ReSharper disable once UnusedType.Global +public class GetReminderById : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("/Reminders/{reminderId}", async (string reminderId, ISender sender) => + { + var result = await sender.Send(new GetReminderByIdQuery(reminderId)); + var response = result.Adapt(); + + return Results.Ok(response); + }) + .WithName("GetReminderById") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get Reminder by Id") + .WithDescription("Get Reminder by Id"); + } +} + +// ReSharper disable once ClassNeverInstantiated.Global +// ReSharper disable once NotAccessedPositionalProperty.Global +public record GetReminderByIdResponse([property: JsonPropertyName("reminder")] ReminderDto ReminderDto); diff --git a/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/Reminders/ListReminders.cs b/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/Reminders/ListReminders.cs new file mode 100644 index 0000000..42c2d21 --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Api/Endpoints/Reminders/ListReminders.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Application.Pagination; +using Reminders.Application.Entities.Reminders.Queries.ListReminders; + +namespace Reminders.Api.Endpoints.Reminders; + +public class ListReminders : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("/Reminders", async ([AsParameters] PaginationRequest query, ISender sender) => + { + var result = await sender.Send(new ListRemindersQuery(query)); + var response = result.Adapt(); + + return Results.Ok(response); + }) + .WithName("ListReminders") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("List Reminders") + .WithDescription("List Reminders"); + } +} + +// ReSharper disable once ClassNeverInstantiated.Global +// ReSharper disable once NotAccessedPositionalProperty.Global +public record ListRemindersResponse(PaginatedResult Reminders); diff --git a/Backend/src/Modules/Reminders/Reminders.Api/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Reminders/Reminders.Api/Extensions/ServiceCollectionExtensions.cs index 8636f73..8526d23 100644 --- a/Backend/src/Modules/Reminders/Reminders.Api/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Reminders/Reminders.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; +using BuildingBlocks.Api.Converters; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Reminders.Application.Extensions; @@ -15,13 +14,13 @@ public static IServiceCollection AddRemindersModule services.AddApiServices(); services.AddApplicationServices(); services.AddInfrastructureServices(configuration); + return services; } private static IServiceCollection AddApiServices(this IServiceCollection services) { - // services.AddExceptionHandler(); - // services.AddHealthChecks()... + TypeAdapterConfig.GlobalSettings.Scan(typeof(ReminderIdConverter).Assembly); return services; } @@ -30,9 +29,6 @@ public static IEndpointRouteBuilder UseRemindersModule(this IEndpointRouteBuilde { endpoints.MapGet("/Reminders/Test", () => "Reminders.Api Test -> Ok!"); - // app.UseExceptionHandler(_ => { }); - // app.UseHealthChecks... - return endpoints; } } diff --git a/Backend/src/Modules/Reminders/Reminders.Api/GlobalUsing.cs b/Backend/src/Modules/Reminders/Reminders.Api/GlobalUsing.cs new file mode 100644 index 0000000..6570b14 --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Api/GlobalUsing.cs @@ -0,0 +1,7 @@ +global using Carter; +global using Mapster; +global using MediatR; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Routing; +global using Reminders.Application.Entities.Reminders.Dtos; diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Reminders/Commands/CreateReminder/CreateReminderCommand.cs b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Commands/CreateReminder/CreateReminderCommand.cs similarity index 81% rename from Backend/src/Modules/Reminders/Reminders.Application/Reminders/Commands/CreateReminder/CreateReminderCommand.cs rename to Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Commands/CreateReminder/CreateReminderCommand.cs index 6066eff..306a68f 100644 --- a/Backend/src/Modules/Reminders/Reminders.Application/Reminders/Commands/CreateReminder/CreateReminderCommand.cs +++ b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Commands/CreateReminder/CreateReminderCommand.cs @@ -1,11 +1,11 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using FluentValidation; -using Reminders.Application.Dtos; +using Reminders.Application.Entities.Reminders.Dtos; -namespace Reminders.Application.Reminders.Commands.CreateReminder; +namespace Reminders.Application.Entities.Reminders.Commands.CreateReminder; +// ReSharper disable once ClassNeverInstantiated.Global public record CreateReminderCommand(ReminderDto Reminder) : ICommand; +// ReSharper disable once NotAccessedPositionalProperty.Global public record CreateReminderResult(ReminderId Id); public class CreateReminderCommandValidator : AbstractValidator diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Reminders/Commands/CreateReminder/CreateReminderHandler.cs b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Commands/CreateReminder/CreateReminderHandler.cs similarity index 56% rename from Backend/src/Modules/Reminders/Reminders.Application/Reminders/Commands/CreateReminder/CreateReminderHandler.cs rename to Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Commands/CreateReminder/CreateReminderHandler.cs index f4bf8db..96006f8 100644 --- a/Backend/src/Modules/Reminders/Reminders.Application/Reminders/Commands/CreateReminder/CreateReminderHandler.cs +++ b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Commands/CreateReminder/CreateReminderHandler.cs @@ -1,14 +1,23 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using Reminders.Application.Data; +namespace Reminders.Application.Entities.Reminders.Commands.CreateReminder; -namespace Reminders.Application.Reminders.Commands.CreateReminder; - -public class CreateReminderHandler(IRemindersDbContext dbContext) : - ICommandHandler +internal class CreateReminderHandler(IRemindersDbContext dbContext) : ICommandHandler { public async Task Handle(CreateReminderCommand command, CancellationToken cancellationToken) { - var reminder = Reminder.Create( + var reminder = command.ToReminder(); + + dbContext.Reminders.Add(reminder); + await dbContext.SaveChangesAsync(cancellationToken); + + return new CreateReminderResult(reminder.Id); + } +} + +internal static class CreateReminderCommandExtensions +{ + public static Reminder ToReminder(this CreateReminderCommand command) + { + return Reminder.Create( ReminderId.Of(Guid.NewGuid()), command.Reminder.Title, command.Reminder.Description, @@ -17,10 +26,5 @@ public async Task Handle(CreateReminderCommand command, Ca command.Reminder.NotificationTime, command.Reminder.Status ); - - dbContext.Reminders.Add(reminder); - await dbContext.SaveChangesAsync(cancellationToken); - - return new CreateReminderResult(reminder.Id); } } diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Dtos/ReminderDto.cs b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Dtos/ReminderDto.cs similarity index 72% rename from Backend/src/Modules/Reminders/Reminders.Application/Dtos/ReminderDto.cs rename to Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Dtos/ReminderDto.cs index 7290aea..94091c7 100644 --- a/Backend/src/Modules/Reminders/Reminders.Application/Dtos/ReminderDto.cs +++ b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Dtos/ReminderDto.cs @@ -1,4 +1,4 @@ -namespace Reminders.Application.Dtos; +namespace Reminders.Application.Entities.Reminders.Dtos; public record ReminderDto( string Id, diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Exceptions/ReminderNotFoundException.cs b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Exceptions/ReminderNotFoundException.cs new file mode 100644 index 0000000..debceda --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Exceptions/ReminderNotFoundException.cs @@ -0,0 +1,3 @@ +namespace Reminders.Application.Entities.Reminders.Exceptions; + +public class ReminderNotFoundException(string id) : NotFoundException("Reminder", id); diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Extensions/ReminderExtensions.cs b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Extensions/ReminderExtensions.cs new file mode 100644 index 0000000..69ccb3d --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Extensions/ReminderExtensions.cs @@ -0,0 +1,23 @@ +using Reminders.Application.Entities.Reminders.Dtos; + +namespace Reminders.Application.Entities.Reminders.Extensions; + +public static class ReminderExtensions +{ + public static ReminderDto ToReminderDto(this Reminder reminder) + { + return new ReminderDto( + reminder.Id.ToString(), + reminder.Title, + reminder.Description, + reminder.DueDateTime, + reminder.Priority, + reminder.NotificationTime, + reminder.Status); + } + + public static IEnumerable ToReminderDtoList(this IEnumerable reminders) + { + return reminders.Select(ToReminderDto); + } +} diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/GetReminderById/GetReminderByIdHandler.cs b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/GetReminderById/GetReminderByIdHandler.cs new file mode 100644 index 0000000..4089172 --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/GetReminderById/GetReminderByIdHandler.cs @@ -0,0 +1,19 @@ +using Reminders.Application.Entities.Reminders.Exceptions; +using Reminders.Application.Entities.Reminders.Extensions; + +namespace Reminders.Application.Entities.Reminders.Queries.GetReminderById; + +internal class GetReminderByIdHandler(IRemindersDbContext dbContext) : IQueryHandler +{ + public async Task Handle(GetReminderByIdQuery query, CancellationToken cancellationToken) + { + var reminder = await dbContext.Reminders + .AsNoTracking() + .SingleOrDefaultAsync(r => r.Id == ReminderId.Of(Guid.Parse(query.Id)), cancellationToken); + + if (reminder is null) + throw new ReminderNotFoundException(query.Id); + + return new GetReminderByIdResult(reminder.ToReminderDto()); + } +} diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/GetReminderById/GetReminderByIdQuery.cs b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/GetReminderById/GetReminderByIdQuery.cs new file mode 100644 index 0000000..8be8cc8 --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/GetReminderById/GetReminderByIdQuery.cs @@ -0,0 +1,10 @@ +// ReSharper disable ClassNeverInstantiated.Global + +using Reminders.Application.Entities.Reminders.Dtos; + +namespace Reminders.Application.Entities.Reminders.Queries.GetReminderById; + +public record GetReminderByIdQuery(string Id) : IQuery; + +// ReSharper disable once NotAccessedPositionalProperty.Global +public record GetReminderByIdResult(ReminderDto ReminderDto); diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/ListReminders/ListRemindersHandler.cs b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/ListReminders/ListRemindersHandler.cs new file mode 100644 index 0000000..38e7502 --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/ListReminders/ListRemindersHandler.cs @@ -0,0 +1,30 @@ +using BuildingBlocks.Application.Pagination; +using Reminders.Application.Entities.Reminders.Dtos; +using Reminders.Application.Entities.Reminders.Extensions; + +namespace Reminders.Application.Entities.Reminders.Queries.ListReminders; + +public class ListRemindersHandler(IRemindersDbContext dbContext) : IQueryHandler +{ + public async Task Handle(ListRemindersQuery query, CancellationToken cancellationToken) + { + var pageIndex = query.PaginationRequest.PageIndex; + var pageSize = query.PaginationRequest.PageSize; + + var totalCount = await dbContext.Reminders.LongCountAsync(cancellationToken); + + var nodes = await dbContext.Reminders + .AsNoTracking() + .OrderBy(r => r.DueDateTime) + .Skip(pageSize * pageIndex) + .Take(pageSize) + .ToListAsync(cancellationToken: cancellationToken); + + return new ListRemindersResult( + new PaginatedResult( + pageIndex, + pageSize, + totalCount, + nodes.ToReminderDtoList())); + } +} diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/ListReminders/ListRemindersQuery.cs b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/ListReminders/ListRemindersQuery.cs new file mode 100644 index 0000000..23f53cb --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Application/Entities/Reminders/Queries/ListReminders/ListRemindersQuery.cs @@ -0,0 +1,10 @@ +using BuildingBlocks.Application.Pagination; +using Reminders.Application.Entities.Reminders.Dtos; + +// ReSharper disable ClassNeverInstantiated.Global + +namespace Reminders.Application.Entities.Reminders.Queries.ListReminders; + +public record ListRemindersQuery(PaginationRequest PaginationRequest) : IQuery; + +public record ListRemindersResult(PaginatedResult Reminders); diff --git a/Backend/src/Modules/Reminders/Reminders.Application/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Reminders/Reminders.Application/Extensions/ServiceCollectionExtensions.cs index 8dceacf..d0c9f15 100644 --- a/Backend/src/Modules/Reminders/Reminders.Application/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Reminders/Reminders.Application/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using System.Reflection; +using BuildingBlocks.Application.Behaviors; using Microsoft.Extensions.DependencyInjection; namespace Reminders.Application.Extensions; @@ -10,10 +11,12 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddMediatR(config => { config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); - // config.AddOpenBehavior(typeof(ValidationBehavior<,>)); - // config.AddOpenBehavior(typeof(LoggingBehavior<,>)); + config.AddOpenBehavior(typeof(ValidationBehavior<,>)); + config.AddOpenBehavior(typeof(LoggingBehavior<,>)); }); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + return services; } } diff --git a/Backend/src/Modules/Reminders/Reminders.Application/GlobalUsing.cs b/Backend/src/Modules/Reminders/Reminders.Application/GlobalUsing.cs index 206bd02..b2224e5 100644 --- a/Backend/src/Modules/Reminders/Reminders.Application/GlobalUsing.cs +++ b/Backend/src/Modules/Reminders/Reminders.Application/GlobalUsing.cs @@ -1,3 +1,7 @@ -global using Microsoft.EntityFrameworkCore; +global using FluentValidation; +global using Microsoft.EntityFrameworkCore; global using BuildingBlocks.Application.Cqrs; +global using BuildingBlocks.Application.Exceptions; +global using BuildingBlocks.Domain.ValueObjects.Ids; +global using Reminders.Application.Data; global using Reminders.Domain.Models; diff --git a/Backend/src/Modules/Reminders/Reminders.Domain/GlobalUsing.cs b/Backend/src/Modules/Reminders/Reminders.Domain/GlobalUsing.cs index 461143c..ec98520 100644 --- a/Backend/src/Modules/Reminders/Reminders.Domain/GlobalUsing.cs +++ b/Backend/src/Modules/Reminders/Reminders.Domain/GlobalUsing.cs @@ -1 +1,2 @@ global using BuildingBlocks.Domain.Abstractions; +global using BuildingBlocks.Domain.ValueObjects.Ids; diff --git a/Backend/src/Modules/Reminders/Reminders.Domain/Models/Reminder.cs b/Backend/src/Modules/Reminders/Reminders.Domain/Models/Reminder.cs index fad7f4c..fdff140 100644 --- a/Backend/src/Modules/Reminders/Reminders.Domain/Models/Reminder.cs +++ b/Backend/src/Modules/Reminders/Reminders.Domain/Models/Reminder.cs @@ -1,5 +1,4 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using Reminders.Domain.Events; +using Reminders.Domain.Events; namespace Reminders.Domain.Models; diff --git a/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Extensions/InitialData.cs b/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Extensions/InitialData.cs index a74759c..ea67929 100644 --- a/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Extensions/InitialData.cs +++ b/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Extensions/InitialData.cs @@ -7,10 +7,20 @@ internal static class InitialData { Reminder.Create( ReminderId.Of(Guid.Parse("2e1c4902-7841-484c-b997-0a8cd3955e72")), - "Meeting Room 1 - Daily", "Important meeting with Timo", DateTime.UtcNow.AddHours(2), 3, DateTime.UtcNow.AddHours(1), "Pending"), + "Meeting Room 1 - Daily", + "Important meeting with Timo", + DateTime.UtcNow.AddHours(2), + 3, + DateTime.UtcNow.AddHours(1), + "Pending"), Reminder.Create( ReminderId.Of(Guid.Parse("74f40a78-bda2-4177-bffd-86e6648c4318")), - "Weekly Team Sync", "Basic team sync with the team", DateTime.UtcNow.AddHours(2), 1, DateTime.UtcNow.AddHours(1), "Canceled"), + "Weekly Team Sync", + "Basic team sync with the team", + DateTime.UtcNow.AddHours(2), + 1, + DateTime.UtcNow.AddHours(1), + "Canceled"), }; } diff --git a/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs b/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..685d87a --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs @@ -0,0 +1,57 @@ +using BuildingBlocks.Domain.Abstractions; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Reminders.Infrastructure.Data.Interceptors; + +public class AuditableEntityInterceptor : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + UpdateEntities(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + UpdateEntities(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void UpdateEntities(DbContext? context) + { + if (context == null) return; + + context.ChangeTracker.DetectChanges(); + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedBy = "username"; + entry.Entity.CreatedAt = DateTime.UtcNow; + } + + var isAdded = entry.State == EntityState.Added; + var isModified = entry.State == EntityState.Modified; + + if (!isAdded && !isModified && !entry.HasChangedOwnedEntities()) + continue; + + entry.Entity.LastModifiedBy = "username"; + entry.Entity.LastModifiedAt = DateTime.UtcNow; + } + } +} + +public static class Extensions +{ + public static bool HasChangedOwnedEntities(this EntityEntry entry) + { + return entry.References.Any(r => + r.TargetEntry != null && + r.TargetEntry.Metadata.IsOwned() && + r.TargetEntry.State is EntityState.Added or EntityState.Modified); + } +} diff --git a/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs b/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs new file mode 100644 index 0000000..7bf2ce7 --- /dev/null +++ b/Backend/src/Modules/Reminders/Reminders.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs @@ -0,0 +1,42 @@ +using BuildingBlocks.Domain.Abstractions; +using MediatR; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Reminders.Infrastructure.Data.Interceptors; + +public class DispatchDomainEventsInterceptor(IMediator mediator) : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult(); + return base.SavingChanges(eventData, result); + } + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + await DispatchDomainEvents(eventData.Context); + return await base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private async Task DispatchDomainEvents(DbContext? context) + { + if (context == null) return; + + var aggregates = context.ChangeTracker + .Entries() + .Where(a => a.Entity.DomainEvents.Any()) + .Select(a => a.Entity); + + var aggregatesList = aggregates.ToList(); + + var domainEvents = aggregatesList + .SelectMany(a => a.DomainEvents) + .ToList(); + + aggregatesList.ForEach(a => a.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + await mediator.Publish(domainEvent); + } +} diff --git a/Backend/src/Modules/Reminders/Reminders.Infrastructure/DependencyInjection.cs b/Backend/src/Modules/Reminders/Reminders.Infrastructure/DependencyInjection.cs index e13ec8a..74dd8c0 100644 --- a/Backend/src/Modules/Reminders/Reminders.Infrastructure/DependencyInjection.cs +++ b/Backend/src/Modules/Reminders/Reminders.Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Reminders.Application.Data; using Reminders.Infrastructure.Data; +using Reminders.Infrastructure.Data.Interceptors; namespace Reminders.Infrastructure; @@ -12,6 +13,9 @@ public static IServiceCollection AddInfrastructureServices { var connectionString = configuration.GetConnectionString("DefaultConnection"); + services.AddScoped(); + services.AddScoped(); + // Add Reminder-specific DbContext services.AddDbContext((serviceProvider, options) => { diff --git a/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/CreateTimeline.cs b/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/CreateTimeline.cs deleted file mode 100644 index b5b01ad..0000000 --- a/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/CreateTimeline.cs +++ /dev/null @@ -1,35 +0,0 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using Carter; -using Mapster; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Timelines.Application.Dtos; -using Timelines.Application.Timelines.Commands.CreateTimeline; - -namespace Timelines.Api.Endpoints; - -public class CreateTimeline : ICarterModule -{ - public void AddRoutes(IEndpointRouteBuilder app) - { - app.MapPost("/Timelines", async (CreateTimelineRequest request, ISender sender) => - { - var command = request.Adapt(); - var result = await sender.Send(command); - var response = result.Adapt(); - - return Results.Created($"/Timelines/{response.Id}", response); - }) - .WithName("CreateTimeline") - .Produces(StatusCodes.Status201Created) - .ProducesProblem(StatusCodes.Status400BadRequest) - .WithSummary("Create Timeline") - .WithDescription("Creates a new timeline"); - } -} - -public record CreateTimelineRequest(TimelineDto Timeline); - -public record CreateTimelineResponse(TimelineId Id); diff --git a/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/Timelines/CreateTimeline.cs b/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/Timelines/CreateTimeline.cs new file mode 100644 index 0000000..a4fe6f0 --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/Timelines/CreateTimeline.cs @@ -0,0 +1,31 @@ +using BuildingBlocks.Domain.ValueObjects.Ids; +using Timelines.Application.Entities.Timelines.Commands.CreateTimeline; + +// ReSharper disable ClassNeverInstantiated.Global + +namespace Timelines.Api.Endpoints.Timelines; + +public class CreateTimeline : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapPost("/Timelines", async (CreateTimelineRequest request, ISender sender) => + { + var command = request.Adapt(); + var result = await sender.Send(command); + var response = result.Adapt(); + + return Results.Created($"/Timelines/{response.Id}", response); + }) + .WithName("CreateTimeline") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("Create Timeline") + .WithDescription("Creates a new timeline"); + } +} + +// ReSharper disable once NotAccessedPositionalProperty.Global +public record CreateTimelineRequest(TimelineDto Timeline); + +public record CreateTimelineResponse(TimelineId Id); diff --git a/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/Timelines/GetTimelineById.cs b/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/Timelines/GetTimelineById.cs new file mode 100644 index 0000000..0eb6e90 --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/Timelines/GetTimelineById.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Timelines.Application.Entities.Timelines.Queries.GetTimelineById; + +namespace Timelines.Api.Endpoints.Timelines; + +// ReSharper disable once UnusedType.Global +public class GetTimelineById : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("/Timelines/{timelineId}", async (string timelineId, ISender sender) => + { + var result = await sender.Send(new GetTimelineByIdQuery(timelineId)); + var response = result.Adapt(); + + return Results.Ok(response); + }) + .WithName("GetTimelineById") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get Timeline by Id") + .WithDescription("Get Timeline by Id"); + } +} + +// ReSharper disable once ClassNeverInstantiated.Global +// ReSharper disable once NotAccessedPositionalProperty.Global +public record GetTimelineByIdResponse([property: JsonPropertyName("timeline")] TimelineDto TimelineDto); diff --git a/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/Timelines/ListTimelines.cs b/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/Timelines/ListTimelines.cs new file mode 100644 index 0000000..435b91a --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Api/Endpoints/Timelines/ListTimelines.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Application.Pagination; +using Timelines.Application.Entities.Timelines.Queries.ListTimelines; + +namespace Timelines.Api.Endpoints.Timelines; + +public class ListTimelines : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("/Timelines", async ([AsParameters] PaginationRequest query, ISender sender) => + { + var result = await sender.Send(new ListTimelinesQuery(query)); + var response = result.Adapt(); + + return Results.Ok(response); + }) + .WithName("ListTimelines") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithSummary("List Timelines") + .WithDescription("List Timelines"); + } +} + +// ReSharper disable once ClassNeverInstantiated.Global +// ReSharper disable once NotAccessedPositionalProperty.Global +public record ListTimelinesResponse(PaginatedResult Timelines); diff --git a/Backend/src/Modules/Timelines/Timelines.Api/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Timelines/Timelines.Api/Extensions/ServiceCollectionExtensions.cs index a374491..7158a4d 100644 --- a/Backend/src/Modules/Timelines/Timelines.Api/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Timelines/Timelines.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; +using BuildingBlocks.Api.Converters; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Timelines.Application.Extensions; @@ -21,8 +20,7 @@ public static IServiceCollection AddTimelinesModule private static IServiceCollection AddApiServices(this IServiceCollection services) { - // services.AddExceptionHandler(); - // services.AddHealthChecks()... + TypeAdapterConfig.GlobalSettings.Scan(typeof(TimelineIdConverter).Assembly); return services; } @@ -31,9 +29,6 @@ public static IEndpointRouteBuilder UseTimelinesModule(this IEndpointRouteBuilde { endpoints.MapGet("/Timelines/Test", () => "Timelines.Api Test -> Ok!"); - // app.UseExceptionHandler(_ => { }); - // app.UseHealthChecks... - return endpoints; } } diff --git a/Backend/src/Modules/Timelines/Timelines.Api/GlobalUsing.cs b/Backend/src/Modules/Timelines/Timelines.Api/GlobalUsing.cs new file mode 100644 index 0000000..e283c2a --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Api/GlobalUsing.cs @@ -0,0 +1,7 @@ +global using Carter; +global using Mapster; +global using MediatR; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Routing; +global using Timelines.Application.Entities.Timelines.Dtos; diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Dtos/TimelineDto.cs b/Backend/src/Modules/Timelines/Timelines.Application/Dtos/TimelineDto.cs deleted file mode 100644 index 2df7956..0000000 --- a/Backend/src/Modules/Timelines/Timelines.Application/Dtos/TimelineDto.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Timelines.Application.Dtos; - -public record TimelineDto( - string Title); diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Timelines/Commands/CreateTimeline/CreateTimelineCommand.cs b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Commands/CreateTimeline/CreateTimelineCommand.cs similarity index 61% rename from Backend/src/Modules/Timelines/Timelines.Application/Timelines/Commands/CreateTimeline/CreateTimelineCommand.cs rename to Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Commands/CreateTimeline/CreateTimelineCommand.cs index fa7fb49..7444435 100644 --- a/Backend/src/Modules/Timelines/Timelines.Application/Timelines/Commands/CreateTimeline/CreateTimelineCommand.cs +++ b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Commands/CreateTimeline/CreateTimelineCommand.cs @@ -1,11 +1,11 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using FluentValidation; -using Timelines.Application.Dtos; +using Timelines.Application.Entities.Timelines.Dtos; -namespace Timelines.Application.Timelines.Commands.CreateTimeline; +namespace Timelines.Application.Entities.Timelines.Commands.CreateTimeline; +// ReSharper disable once ClassNeverInstantiated.Global public record CreateTimelineCommand(TimelineDto Timeline) : ICommand; +// ReSharper disable once NotAccessedPositionalProperty.Global public record CreateTimelineResult(TimelineId Id); public class CreateTimelineCommandValidator : AbstractValidator diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Commands/CreateTimeline/CreateTimelineHandler.cs b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Commands/CreateTimeline/CreateTimelineHandler.cs new file mode 100644 index 0000000..8bd6476 --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Commands/CreateTimeline/CreateTimelineHandler.cs @@ -0,0 +1,25 @@ +namespace Timelines.Application.Entities.Timelines.Commands.CreateTimeline; + +internal class CreateTimelineHandler(ITimelinesDbContext dbContext) : ICommandHandler +{ + public async Task Handle(CreateTimelineCommand command, CancellationToken cancellationToken) + { + var timeline = command.ToTimeline(); + + dbContext.Timelines.Add(timeline); + await dbContext.SaveChangesAsync(cancellationToken); + + return new CreateTimelineResult(timeline.Id); + } +} + +internal static class CreateTimelineCommandExtensions +{ + public static Timeline ToTimeline(this CreateTimelineCommand command) + { + return Timeline.Create( + TimelineId.Of(Guid.NewGuid()), + command.Timeline.Title + ); + } +} diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Dtos/TimelineDto.cs b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Dtos/TimelineDto.cs new file mode 100644 index 0000000..232e7fe --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Dtos/TimelineDto.cs @@ -0,0 +1,5 @@ +namespace Timelines.Application.Entities.Timelines.Dtos; + +public record TimelineDto( + string Id, + string Title); diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Exceptions/TimelineNotFoundException.cs b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Exceptions/TimelineNotFoundException.cs new file mode 100644 index 0000000..f937cf1 --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Exceptions/TimelineNotFoundException.cs @@ -0,0 +1,3 @@ +namespace Timelines.Application.Entities.Timelines.Exceptions; + +public class TimelineNotFoundException(string id) : NotFoundException("Timeline", id); diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Extensions/TimelineExtensions.cs b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Extensions/TimelineExtensions.cs new file mode 100644 index 0000000..a136b18 --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Extensions/TimelineExtensions.cs @@ -0,0 +1,18 @@ +using Timelines.Application.Entities.Timelines.Dtos; + +namespace Timelines.Application.Entities.Timelines.Extensions; + +public static class TimelineExtensions +{ + public static TimelineDto ToTimelineDto(this Timeline timeline) + { + return new TimelineDto( + timeline.Id.ToString(), + timeline.Title); + } + + public static IEnumerable ToTimelineDtoList(this IEnumerable timelines) + { + return timelines.Select(ToTimelineDto); + } +} diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/GetTimelineById/GetTimelineByIdHandler.cs b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/GetTimelineById/GetTimelineByIdHandler.cs new file mode 100644 index 0000000..f31d0b6 --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/GetTimelineById/GetTimelineByIdHandler.cs @@ -0,0 +1,19 @@ +using Timelines.Application.Entities.Timelines.Exceptions; +using Timelines.Application.Entities.Timelines.Extensions; + +namespace Timelines.Application.Entities.Timelines.Queries.GetTimelineById; + +internal class GetTimelineByIdHandler(ITimelinesDbContext dbContext) : IQueryHandler +{ + public async Task Handle(GetTimelineByIdQuery query, CancellationToken cancellationToken) + { + var timeline = await dbContext.Timelines + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == TimelineId.Of(Guid.Parse(query.Id)), cancellationToken); + + if (timeline is null) + throw new TimelineNotFoundException(query.Id); + + return new GetTimelineByIdResult(timeline.ToTimelineDto()); + } +} diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/GetTimelineById/GetTimelineByIdQuery.cs b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/GetTimelineById/GetTimelineByIdQuery.cs new file mode 100644 index 0000000..9e07fb5 --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/GetTimelineById/GetTimelineByIdQuery.cs @@ -0,0 +1,10 @@ +// ReSharper disable ClassNeverInstantiated.Global + +using Timelines.Application.Entities.Timelines.Dtos; + +namespace Timelines.Application.Entities.Timelines.Queries.GetTimelineById; + +public record GetTimelineByIdQuery(string Id) : IQuery; + +// ReSharper disable once NotAccessedPositionalProperty.Global +public record GetTimelineByIdResult(TimelineDto TimelineDto); diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/ListTimelines/ListTimelinesHandler.cs b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/ListTimelines/ListTimelinesHandler.cs new file mode 100644 index 0000000..71d258b --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/ListTimelines/ListTimelinesHandler.cs @@ -0,0 +1,30 @@ +using BuildingBlocks.Application.Pagination; +using Timelines.Application.Entities.Timelines.Dtos; +using Timelines.Application.Entities.Timelines.Extensions; + +namespace Timelines.Application.Entities.Timelines.Queries.ListTimelines; + +public class ListTimelinesHandler(ITimelinesDbContext dbContext) : IQueryHandler +{ + public async Task Handle(ListTimelinesQuery query, CancellationToken cancellationToken) + { + var pageIndex = query.PaginationRequest.PageIndex; + var pageSize = query.PaginationRequest.PageSize; + + var totalCount = await dbContext.Timelines.LongCountAsync(cancellationToken); + + var nodes = await dbContext.Timelines + .AsNoTracking() + .OrderBy(n => n.CreatedAt) + .Skip(pageSize * pageIndex) + .Take(pageSize) + .ToListAsync(cancellationToken: cancellationToken); + + return new ListTimelinesResult( + new PaginatedResult( + pageIndex, + pageSize, + totalCount, + nodes.ToTimelineDtoList())); + } +} diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/ListTimelines/ListTimelinesQuery.cs b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/ListTimelines/ListTimelinesQuery.cs new file mode 100644 index 0000000..3e5d6d6 --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Application/Entities/Timelines/Queries/ListTimelines/ListTimelinesQuery.cs @@ -0,0 +1,10 @@ +using BuildingBlocks.Application.Pagination; +using Timelines.Application.Entities.Timelines.Dtos; + +// ReSharper disable ClassNeverInstantiated.Global + +namespace Timelines.Application.Entities.Timelines.Queries.ListTimelines; + +public record ListTimelinesQuery(PaginationRequest PaginationRequest) : IQuery; + +public record ListTimelinesResult(PaginatedResult Timelines); diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Extensions/ServiceCollectionExtensions.cs b/Backend/src/Modules/Timelines/Timelines.Application/Extensions/ServiceCollectionExtensions.cs index 2145846..55cfef7 100644 --- a/Backend/src/Modules/Timelines/Timelines.Application/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/src/Modules/Timelines/Timelines.Application/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using System.Reflection; +using BuildingBlocks.Application.Behaviors; using Microsoft.Extensions.DependencyInjection; namespace Timelines.Application.Extensions; @@ -10,10 +11,12 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddMediatR(config => { config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); - // config.AddOpenBehavior(typeof(ValidationBehavior<,>)); - // config.AddOpenBehavior(typeof(LoggingBehavior<,>)); + config.AddOpenBehavior(typeof(ValidationBehavior<,>)); + config.AddOpenBehavior(typeof(LoggingBehavior<,>)); }); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + return services; } } diff --git a/Backend/src/Modules/Timelines/Timelines.Application/GlobalUsing.cs b/Backend/src/Modules/Timelines/Timelines.Application/GlobalUsing.cs index dcccfea..8e2b3c9 100644 --- a/Backend/src/Modules/Timelines/Timelines.Application/GlobalUsing.cs +++ b/Backend/src/Modules/Timelines/Timelines.Application/GlobalUsing.cs @@ -1,3 +1,7 @@ -global using Microsoft.EntityFrameworkCore; +global using FluentValidation; +global using Microsoft.EntityFrameworkCore; global using BuildingBlocks.Application.Cqrs; +global using BuildingBlocks.Application.Exceptions; +global using BuildingBlocks.Domain.ValueObjects.Ids; +global using Timelines.Application.Data; global using Timelines.Domain.Models; diff --git a/Backend/src/Modules/Timelines/Timelines.Application/Timelines/Commands/CreateTimeline/CreateTimelineHandler.cs b/Backend/src/Modules/Timelines/Timelines.Application/Timelines/Commands/CreateTimeline/CreateTimelineHandler.cs index d663073..dd9963b 100644 --- a/Backend/src/Modules/Timelines/Timelines.Application/Timelines/Commands/CreateTimeline/CreateTimelineHandler.cs +++ b/Backend/src/Modules/Timelines/Timelines.Application/Timelines/Commands/CreateTimeline/CreateTimelineHandler.cs @@ -1,9 +1,8 @@ -using BuildingBlocks.Domain.ValueObjects.Ids; -using Timelines.Application.Data; +using Timelines.Application.Entities.Timelines.Commands.CreateTimeline; namespace Timelines.Application.Timelines.Commands.CreateTimeline; -public class CreateTimelineHandler(ITimelinesDbContext dbContext) : +internal class CreateTimelineHandler(ITimelinesDbContext dbContext) : ICommandHandler { public async Task Handle(CreateTimelineCommand command, CancellationToken cancellationToken) diff --git a/Backend/src/Modules/Timelines/Timelines.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs b/Backend/src/Modules/Timelines/Timelines.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..53f069f --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Timelines.Infrastructure.Data.Interceptors; + +public class AuditableEntityInterceptor : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + UpdateEntities(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + UpdateEntities(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void UpdateEntities(DbContext? context) + { + if (context == null) return; + + context.ChangeTracker.DetectChanges(); + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedBy = "username"; + entry.Entity.CreatedAt = DateTime.UtcNow; + } + + var isAdded = entry.State == EntityState.Added; + var isModified = entry.State == EntityState.Modified; + + if (!isAdded && !isModified && !entry.HasChangedOwnedEntities()) + continue; + + entry.Entity.LastModifiedBy = "username"; + entry.Entity.LastModifiedAt = DateTime.UtcNow; + } + } +} + +public static class Extensions +{ + public static bool HasChangedOwnedEntities(this EntityEntry entry) + { + return entry.References.Any(r => + r.TargetEntry != null && + r.TargetEntry.Metadata.IsOwned() && + r.TargetEntry.State is EntityState.Added or EntityState.Modified); + } +} diff --git a/Backend/src/Modules/Timelines/Timelines.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs b/Backend/src/Modules/Timelines/Timelines.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs new file mode 100644 index 0000000..7fdde6c --- /dev/null +++ b/Backend/src/Modules/Timelines/Timelines.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs @@ -0,0 +1,41 @@ +using MediatR; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Timelines.Infrastructure.Data.Interceptors; + +public class DispatchDomainEventsInterceptor(IMediator mediator) : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult(); + return base.SavingChanges(eventData, result); + } + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + await DispatchDomainEvents(eventData.Context); + return await base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private async Task DispatchDomainEvents(DbContext? context) + { + if (context == null) return; + + var aggregates = context.ChangeTracker + .Entries() + .Where(a => a.Entity.DomainEvents.Any()) + .Select(a => a.Entity); + + var aggregatesList = aggregates.ToList(); + + var domainEvents = aggregatesList + .SelectMany(a => a.DomainEvents) + .ToList(); + + aggregatesList.ForEach(a => a.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + await mediator.Publish(domainEvent); + } +} diff --git a/Backend/src/Modules/Timelines/Timelines.Infrastructure/DependencyInjection.cs b/Backend/src/Modules/Timelines/Timelines.Infrastructure/DependencyInjection.cs index e610d89..4e0e291 100644 --- a/Backend/src/Modules/Timelines/Timelines.Infrastructure/DependencyInjection.cs +++ b/Backend/src/Modules/Timelines/Timelines.Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Timelines.Application.Data; using Timelines.Infrastructure.Data; +using Timelines.Infrastructure.Data.Interceptors; namespace Timelines.Infrastructure; @@ -12,6 +13,9 @@ public static IServiceCollection AddInfrastructureServices { var connectionString = configuration.GetConnectionString("DefaultConnection"); + services.AddScoped(); + services.AddScoped(); + // Add Timeline-specific DbContext services.AddDbContext((serviceProvider, options) => { diff --git a/Backend/src/Modules/Timelines/Timelines.Infrastructure/GlobalUsing.cs b/Backend/src/Modules/Timelines/Timelines.Infrastructure/GlobalUsing.cs index 5d8b13e..3ca0c1c 100644 --- a/Backend/src/Modules/Timelines/Timelines.Infrastructure/GlobalUsing.cs +++ b/Backend/src/Modules/Timelines/Timelines.Infrastructure/GlobalUsing.cs @@ -1,4 +1,5 @@ global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.DependencyInjection; +global using BuildingBlocks.Domain.Abstractions; global using BuildingBlocks.Domain.ValueObjects.Ids; global using Timelines.Domain.Models; diff --git a/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Commands/CreateNodeRequest.cs b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Commands/CreateNodeRequest.cs new file mode 100644 index 0000000..31983b4 --- /dev/null +++ b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Commands/CreateNodeRequest.cs @@ -0,0 +1,11 @@ +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable UnusedMember.Global + +using Core.Api.Sdk.Contracts.Nodes.Dtos; + +namespace Core.Api.Sdk.Contracts.Nodes.Commands; + +public class CreateNodeRequest +{ + public required NodeDto Node { get; init; } +} diff --git a/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Commands/CreateNodeResponse.cs b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Commands/CreateNodeResponse.cs new file mode 100644 index 0000000..826af22 --- /dev/null +++ b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Commands/CreateNodeResponse.cs @@ -0,0 +1,11 @@ +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable ClassNeverInstantiated.Global + +using Core.Api.Sdk.Contracts.Nodes.ValueObjects; + +namespace Core.Api.Sdk.Contracts.Nodes.Commands; + +public class CreateNodeResponse +{ + public required NodeId Id { get; init; } +} diff --git a/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Dtos/NodeDto.cs b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Dtos/NodeDto.cs new file mode 100644 index 0000000..91accaa --- /dev/null +++ b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Dtos/NodeDto.cs @@ -0,0 +1,16 @@ +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Core.Api.Sdk.Contracts.Nodes.Dtos; + +public class NodeDto +{ + public string? Id { get; set; } + public required string Title { get; init; } + public required string Description { get; init; } + public required DateTime Timestamp { get; init; } + public required int Importance { get; init; } + public required string Phase { get; init; } + public required List Categories { get; set; } + public required List Tags { get; set; } +} diff --git a/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Queries/GetNodeByIdResponse.cs b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Queries/GetNodeByIdResponse.cs new file mode 100644 index 0000000..cf5adf2 --- /dev/null +++ b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/Queries/GetNodeByIdResponse.cs @@ -0,0 +1,11 @@ +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable ClassNeverInstantiated.Global + +using Core.Api.Sdk.Contracts.Nodes.Dtos; + +namespace Core.Api.Sdk.Contracts.Nodes.Queries; + +public class GetNodeByIdResponse +{ + public required NodeDto NodeDto { get; init; } +} diff --git a/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/ValueObjects/NodeId.cs b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/ValueObjects/NodeId.cs new file mode 100644 index 0000000..c64e5cc --- /dev/null +++ b/Backend/src/Sdk/Core.Api.Sdk/Contracts/Nodes/ValueObjects/NodeId.cs @@ -0,0 +1,14 @@ +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable UnusedMember.Global + +namespace Core.Api.Sdk.Contracts.Nodes.ValueObjects; + +public class NodeId +{ + public NodeId() { } + + public NodeId(Guid value) => Value = value; + + public Guid Value { get; init; } +} diff --git a/Backend/src/Sdk/Core.Api.Sdk/Core.Api.Sdk.csproj b/Backend/src/Sdk/Core.Api.Sdk/Core.Api.Sdk.csproj new file mode 100644 index 0000000..5fc1e4f --- /dev/null +++ b/Backend/src/Sdk/Core.Api.Sdk/Core.Api.Sdk.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/Backend/src/Sdk/Core.Api.Sdk/CoreApiClient.cs b/Backend/src/Sdk/Core.Api.Sdk/CoreApiClient.cs new file mode 100644 index 0000000..e1b1613 --- /dev/null +++ b/Backend/src/Sdk/Core.Api.Sdk/CoreApiClient.cs @@ -0,0 +1,50 @@ +using System.Net.Http.Json; +using Core.Api.Sdk.Interfaces; +using Mapster; +using ApiCreateNodeRequest = Nodes.Api.Endpoints.Nodes.CreateNodeRequest; +using ApiCreateNodeResponse = Nodes.Api.Endpoints.Nodes.CreateNodeResponse; +using ApiGetNodeByIdResponse = Nodes.Api.Endpoints.Nodes.GetNodeByIdResponse; +using SdkNodeId = Core.Api.Sdk.Contracts.Nodes.ValueObjects.NodeId; +using SdkCreateNodeRequest = Core.Api.Sdk.Contracts.Nodes.Commands.CreateNodeRequest; +using SdkCreateNodeResponse = Core.Api.Sdk.Contracts.Nodes.Commands.CreateNodeResponse; +using SdkGetNodeByIdResponse = Core.Api.Sdk.Contracts.Nodes.Queries.GetNodeByIdResponse; + +namespace Core.Api.Sdk; + +public class CoreApiClient(HttpClient httpClient) : ICoreApiClient +{ + public async Task<(SdkCreateNodeResponse? Response, HttpResponseMessage RawResponse)> CreateNodeAsync( + SdkCreateNodeRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + var apiRequest = request.Adapt(); + var response = await httpClient.PostAsJsonAsync("/nodes", apiRequest); + + if (!response.IsSuccessStatusCode) + return (null, response); + + var apiResponse = await response.Content.ReadFromJsonAsync(); + var sdkCreateNodeResponse = apiResponse.Adapt(); + + return (sdkCreateNodeResponse, response); + } + + public async Task<(SdkGetNodeByIdResponse? Response, HttpResponseMessage RawResponse)> GetNodeByIdAsync( + SdkNodeId nodeId) + { + if (nodeId == null) + throw new ArgumentNullException(nameof(nodeId)); + + var response = await httpClient.GetAsync($"/nodes/{nodeId.Value}"); + + if (!response.IsSuccessStatusCode) + return (null, response); + + var apiNode = await response.Content.ReadFromJsonAsync(); + var sdkNode = apiNode.Adapt(); + + return (sdkNode, response); + } +} diff --git a/Backend/src/Sdk/Core.Api.Sdk/Interfaces/ICoreApiClient.cs b/Backend/src/Sdk/Core.Api.Sdk/Interfaces/ICoreApiClient.cs new file mode 100644 index 0000000..cc30042 --- /dev/null +++ b/Backend/src/Sdk/Core.Api.Sdk/Interfaces/ICoreApiClient.cs @@ -0,0 +1,12 @@ +using SdkNodeId = Core.Api.Sdk.Contracts.Nodes.ValueObjects.NodeId; +using SdkCreateNodeRequest = Core.Api.Sdk.Contracts.Nodes.Commands.CreateNodeRequest; +using SdkCreateNodeResponse = Core.Api.Sdk.Contracts.Nodes.Commands.CreateNodeResponse; +using SdkGetNodeByIdResponse = Core.Api.Sdk.Contracts.Nodes.Queries.GetNodeByIdResponse; + +namespace Core.Api.Sdk.Interfaces; + +public interface ICoreApiClient +{ + Task<(SdkCreateNodeResponse? Response, HttpResponseMessage RawResponse)> CreateNodeAsync(SdkCreateNodeRequest request); + Task<(SdkGetNodeByIdResponse? Response, HttpResponseMessage RawResponse)> GetNodeByIdAsync(SdkNodeId nodeId); +} diff --git a/Backend/src/Tests/Nodes/Nodes.Integration/Features/Nodes/POST.feature b/Backend/src/Tests/Nodes/Nodes.Integration/Features/Nodes/POST.feature new file mode 100644 index 0000000..21fe729 --- /dev/null +++ b/Backend/src/Tests/Nodes/Nodes.Integration/Features/Nodes/POST.feature @@ -0,0 +1,7 @@ +@Integration +Feature: POST Nodes + + Scenario: Create Node + When a POST request is sent to the /Nodes endpoint with a valid payload + Then the response status code is 201 (Created) + And the Node is created diff --git a/Backend/src/Tests/Nodes/Nodes.Integration/Features/Nodes/POST.feature.cs b/Backend/src/Tests/Nodes/Nodes.Integration/Features/Nodes/POST.feature.cs new file mode 100644 index 0000000..33511a3 --- /dev/null +++ b/Backend/src/Tests/Nodes/Nodes.Integration/Features/Nodes/POST.feature.cs @@ -0,0 +1,110 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by SpecFlow (https://www.specflow.org/). +// SpecFlow Version:3.9.0.0 +// SpecFlow Generator Version:3.9.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +namespace Nodes.Integration.Features.Nodes +{ + using TechTalk.SpecFlow; + using System; + using System.Linq; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.9.0.0")] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [NUnit.Framework.TestFixtureAttribute()] + [NUnit.Framework.DescriptionAttribute("POST Nodes")] + [NUnit.Framework.CategoryAttribute("Integration")] + public partial class POSTNodesFeature + { + + private TechTalk.SpecFlow.ITestRunner testRunner; + + private static string[] featureTags = new string[] { + "Integration"}; + +#line 1 "POST.feature" +#line hidden + + [NUnit.Framework.OneTimeSetUpAttribute()] + public virtual void FeatureSetup() + { + testRunner = TechTalk.SpecFlow.TestRunnerManager.GetTestRunner(); + TechTalk.SpecFlow.FeatureInfo featureInfo = new TechTalk.SpecFlow.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "Features/Nodes", "POST Nodes", null, ProgrammingLanguage.CSharp, featureTags); + testRunner.OnFeatureStart(featureInfo); + } + + [NUnit.Framework.OneTimeTearDownAttribute()] + public virtual void FeatureTearDown() + { + testRunner.OnFeatureEnd(); + testRunner = null; + } + + [NUnit.Framework.SetUpAttribute()] + public void TestInitialize() + { + } + + [NUnit.Framework.TearDownAttribute()] + public void TestTearDown() + { + testRunner.OnScenarioEnd(); + } + + public void ScenarioInitialize(TechTalk.SpecFlow.ScenarioInfo scenarioInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(NUnit.Framework.TestContext.CurrentContext); + } + + public void ScenarioStart() + { + testRunner.OnScenarioStart(); + } + + public void ScenarioCleanup() + { + testRunner.CollectScenarioErrors(); + } + + [NUnit.Framework.TestAttribute()] + [NUnit.Framework.DescriptionAttribute("Create Node")] + public void CreateNode() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Create Node", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 4 + this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((TagHelper.ContainsIgnoreTag(tagsOfScenario) || TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + this.ScenarioStart(); +#line 5 + testRunner.When("a POST request is sent to the /Nodes endpoint with a valid payload", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); +#line hidden +#line 6 + testRunner.Then("the response status code is 201 (Created)", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); +#line hidden +#line 7 + testRunner.And("the Node is created", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); +#line hidden + } + this.ScenarioCleanup(); + } + } +} +#pragma warning restore +#endregion diff --git a/Backend/src/Tests/Nodes/Nodes.Integration/GlobalUsing.cs b/Backend/src/Tests/Nodes/Nodes.Integration/GlobalUsing.cs new file mode 100644 index 0000000..f8846b2 --- /dev/null +++ b/Backend/src/Tests/Nodes/Nodes.Integration/GlobalUsing.cs @@ -0,0 +1,4 @@ +global using FluentAssertions; +global using System.Globalization; +global using TechTalk.SpecFlow; +global using Core.Api.Sdk.Interfaces; diff --git a/Backend/src/Tests/Nodes/Nodes.Integration/Nodes.Integration.csproj b/Backend/src/Tests/Nodes/Nodes.Integration/Nodes.Integration.csproj new file mode 100644 index 0000000..71e46b6 --- /dev/null +++ b/Backend/src/Tests/Nodes/Nodes.Integration/Nodes.Integration.csproj @@ -0,0 +1,53 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Backend/src/Tests/Nodes/Nodes.Integration/SpecFlowDependencies.cs b/Backend/src/Tests/Nodes/Nodes.Integration/SpecFlowDependencies.cs new file mode 100644 index 0000000..dec0b4a --- /dev/null +++ b/Backend/src/Tests/Nodes/Nodes.Integration/SpecFlowDependencies.cs @@ -0,0 +1,19 @@ +using BoDi; +using Core.Api.Sdk; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Nodes.Integration; + +[Binding] +public class SpecFlowDependencies(IObjectContainer container) +{ + [BeforeScenario] + public void RegisterClient() + { + var factory = new WebApplicationFactory(); + var httpClient = factory.CreateClient(); + var client = new CoreApiClient(httpClient); + + container.RegisterInstanceAs(client); + } +} diff --git a/Backend/src/Tests/Nodes/Nodes.Integration/StepDefinitions/NodesApiStepDefinitions.cs b/Backend/src/Tests/Nodes/Nodes.Integration/StepDefinitions/NodesApiStepDefinitions.cs new file mode 100644 index 0000000..548ff33 --- /dev/null +++ b/Backend/src/Tests/Nodes/Nodes.Integration/StepDefinitions/NodesApiStepDefinitions.cs @@ -0,0 +1,80 @@ +using SdkNodeId = Core.Api.Sdk.Contracts.Nodes.ValueObjects.NodeId; +using SdkNodeDto = Core.Api.Sdk.Contracts.Nodes.Dtos.NodeDto; +using SdkCreateNodeRequest = Core.Api.Sdk.Contracts.Nodes.Commands.CreateNodeRequest; + +// ReSharper disable ConvertToPrimaryConstructor +// ReSharper disable InconsistentNaming +// ReSharper disable NullableWarningSuppressionIsUsed +// ReSharper disable Reqnroll.MethodNameMismatchPattern + +namespace Nodes.Integration.StepDefinitions; + +[Binding] +public class NodesApiStepDefinitions +{ + private readonly ICoreApiClient _apiApiClient; + private HttpResponseMessage? _response; + + private SdkNodeDto? _nodeDto; + private SdkNodeId? _persistedNodeId; + + public NodesApiStepDefinitions(ICoreApiClient apiClient) + { + _apiApiClient = apiClient; + } + + #region When + + [When("a POST request is sent to the /Nodes endpoint with a valid payload")] + public async Task WhenAPostRequestIsSentToTheNodesEndpoint() + { + var (response, rawResponse) = + await _apiApiClient.CreateNodeAsync(new SdkCreateNodeRequest + { + Node = new SdkNodeDto + { + Id = null, + Title = "Test Node", + Description = "Test Node Description.", + Timestamp = DateTime.Parse("2024-01-15T09:00:00.000000Z", null, DateTimeStyles.RoundtripKind), + Importance = 1, + Phase = "Testing", + Categories = ["Category1", "Category2"], + Tags = ["Tag1", "Tag2", "Tag3"] + } + }); + + (_response = rawResponse).EnsureSuccessStatusCode(); + rawResponse.EnsureSuccessStatusCode(); + + (_persistedNodeId = response!.Id).Should().NotBeNull(); + } + + #endregion + + #region Then + + [Then(@"the response status code is (\d{3}) \((.+)\)")] + public void ThenTheResponseStatusCodeIs(int expectedStatusCode, string description) + { + if (_response != null) + ((int)_response.StatusCode).Should().Be(expectedStatusCode); + } + + [Then("the Node is created")] + public async Task ThenTheNodeIsCreated() + { + var (response, rawResponse) = await _apiApiClient.GetNodeByIdAsync(_persistedNodeId!); + + rawResponse.EnsureSuccessStatusCode(); + var nodeDto = response!.NodeDto; + + nodeDto?.Title.Should().Be("Test Node"); + nodeDto?.Description.Should().Be("Test Node Description."); + nodeDto?.Timestamp.Should().Be(DateTime.Parse("2024-01-15T09:00:00.000000Z", null, DateTimeStyles.RoundtripKind)); + nodeDto?.Importance.Should().Be(1); + nodeDto?.Phase.Should().Be("Testing"); + } + + #endregion +} diff --git a/Backend/src/Timelines.sln b/Backend/src/Timelines.sln index 06e632c..6470998 100644 --- a/Backend/src/Timelines.sln +++ b/Backend/src/Timelines.sln @@ -76,6 +76,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks.Application" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks.Api", "BuildingBlocks\BuildingBlocks.Api\BuildingBlocks.Api.csproj", "{117FE66A-6DBA-41B8-9FE4-D933D86E029E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sdk", "Sdk", "{A4DAC3A7-D970-4866-AF91-228AFA853760}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Api.Sdk", "Sdk\Core.Api.Sdk\Core.Api.Sdk.csproj", "{8F5C7F4B-B84F-4685-9A9B-509713A8278A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{FC2E36AE-2129-4B8B-890F-0090F296AA3F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Nodes", "Nodes", "{BD1E0E89-A837-44A4-8C43-B3CDDCD5F519}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nodes.Integration", "Tests\Nodes\Nodes.Integration\Nodes.Integration.csproj", "{3E7D0B42-4579-4960-A05D-56FCE720A62D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -182,6 +192,14 @@ Global {117FE66A-6DBA-41B8-9FE4-D933D86E029E}.Debug|Any CPU.Build.0 = Debug|Any CPU {117FE66A-6DBA-41B8-9FE4-D933D86E029E}.Release|Any CPU.ActiveCfg = Release|Any CPU {117FE66A-6DBA-41B8-9FE4-D933D86E029E}.Release|Any CPU.Build.0 = Release|Any CPU + {8F5C7F4B-B84F-4685-9A9B-509713A8278A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F5C7F4B-B84F-4685-9A9B-509713A8278A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F5C7F4B-B84F-4685-9A9B-509713A8278A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F5C7F4B-B84F-4685-9A9B-509713A8278A}.Release|Any CPU.Build.0 = Release|Any CPU + {3E7D0B42-4579-4960-A05D-56FCE720A62D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E7D0B42-4579-4960-A05D-56FCE720A62D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E7D0B42-4579-4960-A05D-56FCE720A62D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E7D0B42-4579-4960-A05D-56FCE720A62D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -217,5 +235,8 @@ Global {BC94BED1-DEA3-4B89-8710-A8EC7357650C} = {0224C1F2-CC52-4048-87F2-B9D12499AAC9} {B3708965-8661-4DD2-ADBC-E2E34DF86A81} = {0224C1F2-CC52-4048-87F2-B9D12499AAC9} {117FE66A-6DBA-41B8-9FE4-D933D86E029E} = {0224C1F2-CC52-4048-87F2-B9D12499AAC9} + {8F5C7F4B-B84F-4685-9A9B-509713A8278A} = {A4DAC3A7-D970-4866-AF91-228AFA853760} + {BD1E0E89-A837-44A4-8C43-B3CDDCD5F519} = {FC2E36AE-2129-4B8B-890F-0090F296AA3F} + {3E7D0B42-4579-4960-A05D-56FCE720A62D} = {BD1E0E89-A837-44A4-8C43-B3CDDCD5F519} EndGlobalSection EndGlobal