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