diff --git a/src/DotNetBoilerplate.Api/Books/BrowseBooksEndpoint.cs b/src/DotNetBoilerplate.Api/Books/BrowseBooksEndpoint.cs index 9b3d1dc..1e24c29 100644 --- a/src/DotNetBoilerplate.Api/Books/BrowseBooksEndpoint.cs +++ b/src/DotNetBoilerplate.Api/Books/BrowseBooksEndpoint.cs @@ -14,7 +14,7 @@ public static void Map(IEndpointRouteBuilder app) .WithSummary("Browse books with optional BookStoreId parameter"); } - private static async Task>, Ok, NotFound>> Handle( + private static async Task>, NotFound>> Handle( [FromQuery] Guid? bookStoreId, [FromServices] IQueryDispatcher queryDispatcher, CancellationToken ct diff --git a/src/DotNetBoilerplate.Api/Catalogs/AddBookToCatalogEndpoint.cs b/src/DotNetBoilerplate.Api/Catalogs/AddBookToCatalogEndpoint.cs new file mode 100644 index 0000000..3cb278b --- /dev/null +++ b/src/DotNetBoilerplate.Api/Catalogs/AddBookToCatalogEndpoint.cs @@ -0,0 +1,37 @@ +using DotNetBoilerplate.Application.Catalogs.AddBook; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetBoilerplate.Api.Catalogs; + +public class AddBookToCatalogEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapPost("{catalogId:guid}/books", Handle) + .RequireAuthorization() + .WithSummary("Add book to catalog"); + } + + private static async Task> Handle( + [FromRoute] Guid catalogId, + [FromBody] Request request, + [FromServices] ICommandDispatcher commandDispatcher, + CancellationToken ct + ) + { + var command = new AddBookToCatalogCommand(catalogId, request.BookId); + var result = await commandDispatcher.DispatchAsync(command, ct); + + return TypedResults.Ok(new Response(result)); + } + internal sealed record Response( + Guid Id + ); + + private class Request + { + public Guid BookId { get; init; } + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Api/Catalogs/BrowseBooksInCatalogEndpoint.cs b/src/DotNetBoilerplate.Api/Catalogs/BrowseBooksInCatalogEndpoint.cs new file mode 100644 index 0000000..257cffe --- /dev/null +++ b/src/DotNetBoilerplate.Api/Catalogs/BrowseBooksInCatalogEndpoint.cs @@ -0,0 +1,30 @@ +using DotNetBoilerplate.Application.Catalogs.BrowseBooks; +using DotNetBoilerplate.Application.Books.DTO; +using DotNetBoilerplate.Shared.Abstractions.Queries; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetBoilerplate.Api.Catalogs; + +public class BrowseBooksInCatalogEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapGet("{catalogId:guid}/books", Handle) + .WithSummary("Browse books with CatalogId parameter"); + } + + private static async Task>, NotFound>> Handle( + [FromRoute] Guid catalogId, + [FromServices] IQueryDispatcher queryDispatcher, + CancellationToken ct + ) + { + var query = new BrowseBooksInCatalogQuery(catalogId); + var result = await queryDispatcher.QueryAsync(query, ct); + + return result is null + ? TypedResults.NotFound() + : TypedResults.Ok(result); + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Api/Catalogs/BrowseCatalogsEndpoint.cs b/src/DotNetBoilerplate.Api/Catalogs/BrowseCatalogsEndpoint.cs new file mode 100644 index 0000000..386705c --- /dev/null +++ b/src/DotNetBoilerplate.Api/Catalogs/BrowseCatalogsEndpoint.cs @@ -0,0 +1,30 @@ +using DotNetBoilerplate.Application.Catalogs.Browse; +using DotNetBoilerplate.Application.Catalogs.DTO; +using DotNetBoilerplate.Shared.Abstractions.Queries; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetBoilerplate.Api.Catalogs; + +public class BrowseCatalogsEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapGet("", Handle) + .WithSummary("Browse catalogs with optional BookStoreId parameter"); + } + + private static async Task>, NotFound>> Handle( + [FromQuery] Guid? bookStoreId, + [FromServices] IQueryDispatcher queryDispatcher, + CancellationToken ct + ) + { + var query = new BrowseCatalogsQuery(bookStoreId); + var result = await queryDispatcher.QueryAsync(query, ct); + + return result is null + ? TypedResults.NotFound() + : TypedResults.Ok(result); + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Api/Catalogs/CatalogsEndpoints.cs b/src/DotNetBoilerplate.Api/Catalogs/CatalogsEndpoints.cs new file mode 100644 index 0000000..c616316 --- /dev/null +++ b/src/DotNetBoilerplate.Api/Catalogs/CatalogsEndpoints.cs @@ -0,0 +1,26 @@ +using DotNetBoilerplate.Api.Catalogs; + +namespace DotNetBoilerplate.Api.Catalogs; + +internal static class CatalogsEndpoints +{ + public const string BasePath = "catalogs"; + public const string Tags = "Catalogs"; + + public static void MapCatalogsEndpoints(this WebApplication app) + { + var group = app.MapGroup(BasePath) + .WithTags(Tags); + + group + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint(); + } +} + diff --git a/src/DotNetBoilerplate.Api/Catalogs/CreateCatalogEndpoint.cs b/src/DotNetBoilerplate.Api/Catalogs/CreateCatalogEndpoint.cs new file mode 100644 index 0000000..ba8e342 --- /dev/null +++ b/src/DotNetBoilerplate.Api/Catalogs/CreateCatalogEndpoint.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using DotNetBoilerplate.Application.Catalogs.Create; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetBoilerplate.Api.Catalogs; + +internal sealed class CreateCatalogEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapPost("", Handle) + .RequireAuthorization() + .WithSummary("Create catalog"); + } + private static async Task> Handle( + [FromBody] Request request, + [FromServices] ICommandDispatcher commandDispatcher, + CancellationToken ct + ) + { + var command = new CreateCatalogCommand( + request.Name, + request.Genre, + request.Description + ); + + var result = await commandDispatcher.DispatchAsync(command, ct); + + return TypedResults.Ok(new Response(result)); + } + + internal sealed record Response( + Guid Id + ); + + private sealed class Request + { + [Required] public string Name { get; init; } + + [Required] public string Genre { get; init; } + + [Required] public string Description { get; init; } + } +} diff --git a/src/DotNetBoilerplate.Api/Catalogs/DeleteCatalogEndpoint.cs b/src/DotNetBoilerplate.Api/Catalogs/DeleteCatalogEndpoint.cs new file mode 100644 index 0000000..a488cbc --- /dev/null +++ b/src/DotNetBoilerplate.Api/Catalogs/DeleteCatalogEndpoint.cs @@ -0,0 +1,29 @@ +using DotNetBoilerplate.Application.Catalogs.Delete; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetBoilerplate.Api.Catalogs; + +internal sealed class DeleteCatalogEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapDelete("{id:guid}", Handle) + .RequireAuthorization() + .WithSummary("Delete catalog by Id"); + } + + private static async Task Handle( + Guid id, + [FromServices] ICommandDispatcher commandDispatcher, + CancellationToken ct + ) + { + var command = new DeleteCatalogCommand(id); + + await commandDispatcher.DispatchAsync(command, ct); + + return TypedResults.NoContent(); + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Api/Catalogs/GetCatalogByIdEndpoint.cs b/src/DotNetBoilerplate.Api/Catalogs/GetCatalogByIdEndpoint.cs new file mode 100644 index 0000000..0f6fcbc --- /dev/null +++ b/src/DotNetBoilerplate.Api/Catalogs/GetCatalogByIdEndpoint.cs @@ -0,0 +1,31 @@ +using DotNetBoilerplate.Application.Catalogs.Get; +using DotNetBoilerplate.Application.Catalogs.DTO; +using DotNetBoilerplate.Shared.Abstractions.Queries; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetBoilerplate.Api.Catalogs; + +public class GetCatalogByIdEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapGet("{id:guid}", Handle) + .WithSummary("Browse catalog by Id"); + } + + private static async Task, NotFound>> Handle( + [FromRoute] Guid id, + [FromServices] IQueryDispatcher queryDispatcher, + CancellationToken ct + ) + { + var query = new GetCatalogByIdQuery(id); + + var result = await queryDispatcher.QueryAsync(query, ct); + + if (result is null) return TypedResults.NotFound(); + + return TypedResults.Ok(result); + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Api/Catalogs/RemoveBookFromCatalogEndpoint.cs b/src/DotNetBoilerplate.Api/Catalogs/RemoveBookFromCatalogEndpoint.cs new file mode 100644 index 0000000..5295385 --- /dev/null +++ b/src/DotNetBoilerplate.Api/Catalogs/RemoveBookFromCatalogEndpoint.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using DotNetBoilerplate.Application.Catalogs.Delete; +using DotNetBoilerplate.Application.Catalogs.RemoveBook; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetBoilerplate.Api.Catalogs; + +internal sealed class RemoveBookFromCatalogEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapDelete("{catalogId:guid}/books/{bookId:guid}", Handle) + .RequireAuthorization() + .WithSummary("Remove book from catalog by Id"); + } + + private static async Task Handle( + Guid catalogId, Guid bookId, + [FromServices] ICommandDispatcher commandDispatcher, + CancellationToken ct + ) + { + var command = new RemoveBookFromCatalogCommand(catalogId, bookId); + + await commandDispatcher.DispatchAsync(command, ct); + + return TypedResults.NoContent(); + } + private sealed class Request + { + [Required] public Guid BookId { get; init; } + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Api/Catalogs/UpdateCatalogEndpoint.cs b/src/DotNetBoilerplate.Api/Catalogs/UpdateCatalogEndpoint.cs new file mode 100644 index 0000000..8cef77f --- /dev/null +++ b/src/DotNetBoilerplate.Api/Catalogs/UpdateCatalogEndpoint.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using DotNetBoilerplate.Application.Catalogs.Update; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace DotNetBoilerplate.Api.Catalogs; + +public class UpdateCatalogEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapPut("{id:guid}", Handle) + .RequireAuthorization() + .WithSummary("Update a catalog"); + } + + private static async Task> Handle( + [FromRoute] Guid id, + [FromBody] Request request, + [FromServices] ICommandDispatcher commandDispatcher, + CancellationToken ct + ) + { + var command = new UpdateCatalogCommand( + id, + request.Name, + request.Genre, + request.Description + ); + + var result = await commandDispatcher.DispatchAsync(command, ct); + + return TypedResults.Ok(new Response(result)); + } + + internal sealed record Response( + Guid Id + ); + + private sealed class Request + { + // ReSharper disable once UnusedAutoPropertyAccessor.Local + [Required] public string Name { get; init; } + + // ReSharper disable once UnusedAutoPropertyAccessor.Local + [Required] public string Genre { get; init; } + + // ReSharper disable once UnusedAutoPropertyAccessor.Local + [Required] public string Description { get; init; } + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Api/Program.cs b/src/DotNetBoilerplate.Api/Program.cs index 3085d98..4c06737 100644 --- a/src/DotNetBoilerplate.Api/Program.cs +++ b/src/DotNetBoilerplate.Api/Program.cs @@ -3,6 +3,7 @@ using DotNetBoilerplate.Api.Books; using DotNetBoilerplate.Api.Reviews; using DotNetBoilerplate.Api.Users; +using DotNetBoilerplate.Api.Catalogs; using DotNetBoilerplate.Application; using DotNetBoilerplate.Core; using DotNetBoilerplate.Infrastructure; @@ -24,6 +25,7 @@ app.MapBookStoresEndpoints(); app.MapBookEndpoints(); app.MapReviewEndpoints(); +app.MapCatalogsEndpoints(); app.UseInfrastructure(); diff --git a/src/DotNetBoilerplate.Application/Catalogs/AddBook/AddBookToCatalogCommand.cs b/src/DotNetBoilerplate.Application/Catalogs/AddBook/AddBookToCatalogCommand.cs new file mode 100644 index 0000000..191b495 --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/AddBook/AddBookToCatalogCommand.cs @@ -0,0 +1,5 @@ +using DotNetBoilerplate.Shared.Abstractions.Commands; + +namespace DotNetBoilerplate.Application.Catalogs.AddBook; + +public sealed record AddBookToCatalogCommand(Guid CatalogId, Guid BookId) : ICommand; \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/AddBook/AddBookToCatalogHandler.cs b/src/DotNetBoilerplate.Application/Catalogs/AddBook/AddBookToCatalogHandler.cs new file mode 100644 index 0000000..6c1c774 --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/AddBook/AddBookToCatalogHandler.cs @@ -0,0 +1,26 @@ +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Core.Books; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using DotNetBoilerplate.Application.Exceptions; + +namespace DotNetBoilerplate.Application.Catalogs.AddBook; + +internal sealed class AddBookToCatalogHandler( + ICatalogRepository catalogRepository, + IBookRepository bookRepository +) : ICommandHandler +{ + public async Task HandleAsync(AddBookToCatalogCommand command) + { + var catalog = await catalogRepository.GetByIdAsync(command.CatalogId); + if (catalog is null) + throw new CatalogNotFoundException(); + var book = await bookRepository.GetByIdAsync(command.BookId); + if (book is null) + throw new BookNotFoundException(); + + await catalogRepository.AddBookToCatalogAsync(book, catalog); + + return catalog.Id; + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/Browse/BrowseCatalogsHandler.cs b/src/DotNetBoilerplate.Application/Catalogs/Browse/BrowseCatalogsHandler.cs new file mode 100644 index 0000000..646dbbd --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Browse/BrowseCatalogsHandler.cs @@ -0,0 +1,22 @@ +using DotNetBoilerplate.Application.Catalogs.DTO; +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Shared.Abstractions.Queries; + +namespace DotNetBoilerplate.Application.Catalogs.Browse; + +internal sealed class BrowseCatalogsHandler( + ICatalogRepository catalogRepository +) : IQueryHandler> +{ + public async Task> HandleAsync(BrowseCatalogsQuery query) + { + IEnumerable catalogs; + if (query.BookStoreId.HasValue) + { + catalogs = await catalogRepository.GetAllInBookStore(query.BookStoreId.Value); + return catalogs.Select(x => new CatalogDto(x.Id, x.Name, x.Genre, x.Description)); + } + catalogs = await catalogRepository.GetAll(); + return catalogs.Select(x => new CatalogDto(x.Id, x.Name, x.Genre, x.Description)); + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/Browse/BrowseCatalogsQuery.cs b/src/DotNetBoilerplate.Application/Catalogs/Browse/BrowseCatalogsQuery.cs new file mode 100644 index 0000000..a5f38b0 --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Browse/BrowseCatalogsQuery.cs @@ -0,0 +1,6 @@ +using DotNetBoilerplate.Application.Catalogs.DTO; +using DotNetBoilerplate.Shared.Abstractions.Queries; + +namespace DotNetBoilerplate.Application.Catalogs.Browse; + +public sealed record BrowseCatalogsQuery(Guid? BookStoreId = null) : IQuery>; diff --git a/src/DotNetBoilerplate.Application/Catalogs/BrowseBooks/BrowseBooksInCatalogHandler.cs b/src/DotNetBoilerplate.Application/Catalogs/BrowseBooks/BrowseBooksInCatalogHandler.cs new file mode 100644 index 0000000..62de677 --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/BrowseBooks/BrowseBooksInCatalogHandler.cs @@ -0,0 +1,17 @@ +using DotNetBoilerplate.Application.Books.DTO; +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Shared.Abstractions.Queries; + +namespace DotNetBoilerplate.Application.Catalogs.BrowseBooks; + +internal sealed class BrowseBooksInCatalogHandler( + ICatalogRepository catalogRepository +) : IQueryHandler> +{ + public async Task> HandleAsync(BrowseBooksInCatalogQuery query) + { + var books = await catalogRepository.GetBooksInCatalogAsync(query.CatalogId); + + return books.Select(x => new BookDto(x.Id, x.Title, x.Writer, x.Genre, x.Year, x.Description)); + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/BrowseBooks/BrowseBooksInCatalogQuery.cs b/src/DotNetBoilerplate.Application/Catalogs/BrowseBooks/BrowseBooksInCatalogQuery.cs new file mode 100644 index 0000000..9c20eba --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/BrowseBooks/BrowseBooksInCatalogQuery.cs @@ -0,0 +1,6 @@ +using DotNetBoilerplate.Application.Books.DTO; +using DotNetBoilerplate.Shared.Abstractions.Queries; + +namespace DotNetBoilerplate.Application.Catalogs.BrowseBooks; + +public sealed record BrowseBooksInCatalogQuery(Guid CatalogId) : IQuery>; \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/Create/CreateCatalogCommand.cs b/src/DotNetBoilerplate.Application/Catalogs/Create/CreateCatalogCommand.cs new file mode 100644 index 0000000..63b10ac --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Create/CreateCatalogCommand.cs @@ -0,0 +1,5 @@ +using DotNetBoilerplate.Shared.Abstractions.Commands; + +namespace DotNetBoilerplate.Application.Catalogs.Create; + +public sealed record CreateCatalogCommand(string Name, string Genre, string Description) : ICommand; \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/Create/CreateCatalogHandler.cs b/src/DotNetBoilerplate.Application/Catalogs/Create/CreateCatalogHandler.cs new file mode 100644 index 0000000..1e4fc6f --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Create/CreateCatalogHandler.cs @@ -0,0 +1,36 @@ +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Core.BookStores; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using DotNetBoilerplate.Application.Exceptions; +using DotNetBoilerplate.Shared.Abstractions.Contexts; +using DotNetBoilerplate.Shared.Abstractions.Time; + +namespace DotNetBoilerplate.Application.Catalogs.Create; + +internal sealed class CreateCatalogHandler( + IClock clock, + IContext context, + ICatalogRepository catalogRepository, + IBookStoreRepository bookStoreRepository +) : ICommandHandler +{ + public async Task HandleAsync(CreateCatalogCommand command) + { + var bookStore = await bookStoreRepository.GetByOwnerIdAsync(context.Identity.Id); + if (bookStore is null) + throw new BookStoreNotFoundException(); + + bool userCanNotCreateCatalog = await catalogRepository.UserCanNotAddCatalogAsync(bookStore.Id); + var catalog = Catalog.Create( + command.Name, + command.Genre, + command.Description, + bookStore.Id, + context.Identity.Id, + clock.Now(), + userCanNotCreateCatalog + ); + await catalogRepository.AddAsync(catalog); + return catalog.Id; + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/DTO/CatalogDto.cs b/src/DotNetBoilerplate.Application/Catalogs/DTO/CatalogDto.cs new file mode 100644 index 0000000..c96a53b --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/DTO/CatalogDto.cs @@ -0,0 +1,8 @@ +namespace DotNetBoilerplate.Application.Catalogs.DTO; + +public sealed record CatalogDto( + Guid Id, + string Name, + string Genre, + string Description +); \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/Delete/DeleteCatalogCommand.cs b/src/DotNetBoilerplate.Application/Catalogs/Delete/DeleteCatalogCommand.cs new file mode 100644 index 0000000..3e6d1d4 --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Delete/DeleteCatalogCommand.cs @@ -0,0 +1,5 @@ +using DotNetBoilerplate.Shared.Abstractions.Commands; + +namespace DotNetBoilerplate.Application.Catalogs.Delete; + +public sealed record DeleteCatalogCommand(Guid Id) : ICommand; diff --git a/src/DotNetBoilerplate.Application/Catalogs/Delete/DeleteCatalogHandler.cs b/src/DotNetBoilerplate.Application/Catalogs/Delete/DeleteCatalogHandler.cs new file mode 100644 index 0000000..9e21bff --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Delete/DeleteCatalogHandler.cs @@ -0,0 +1,24 @@ +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using DotNetBoilerplate.Shared.Abstractions.Contexts; +using DotNetBoilerplate.Application.Exceptions; + +namespace DotNetBoilerplate.Application.Catalogs.Delete; + +internal sealed class DeleteBookHandler( + IContext context, + ICatalogRepository catalogRepository + ) : ICommandHandler +{ + public async Task HandleAsync(DeleteCatalogCommand command) + { + var catalog = await catalogRepository.GetByIdAsync(command.Id); + + if (catalog is null) + throw new CatalogNotFoundException(); + if (catalog.CreatedBy != context.Identity.Id) + throw new UserCanNotDeleteBookException(); + + await catalogRepository.DeleteAsync(catalog); + } +} diff --git a/src/DotNetBoilerplate.Application/Catalogs/Get/GetCatalogByIdHandler.cs b/src/DotNetBoilerplate.Application/Catalogs/Get/GetCatalogByIdHandler.cs new file mode 100644 index 0000000..5a3f96c --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Get/GetCatalogByIdHandler.cs @@ -0,0 +1,24 @@ +using DotNetBoilerplate.Application.Catalogs.DTO; +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Shared.Abstractions.Queries; +using DotNetBoilerplate.Application.Exceptions; +namespace DotNetBoilerplate.Application.Catalogs.Get; + +internal sealed class GetBookStoreByIdHandler( + ICatalogRepository catalogRepository +) : IQueryHandler +{ + public async Task HandleAsync(GetCatalogByIdQuery query) + { + var catalog = await catalogRepository.GetByIdAsync(query.Id); + if (catalog is null) + return null; + + return new CatalogDto( + catalog.Id, + catalog.Name, + catalog.Genre, + catalog.Description + ); + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/Get/GetCatalogByIdQuery.cs b/src/DotNetBoilerplate.Application/Catalogs/Get/GetCatalogByIdQuery.cs new file mode 100644 index 0000000..cc7714e --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Get/GetCatalogByIdQuery.cs @@ -0,0 +1,6 @@ +using DotNetBoilerplate.Application.Catalogs.DTO; +using DotNetBoilerplate.Shared.Abstractions.Queries; + +namespace DotNetBoilerplate.Application.Catalogs.Get; + +public sealed record GetCatalogByIdQuery(Guid Id) : IQuery; \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/RemoveBook/RemoveBookFromCatalogCommand.cs b/src/DotNetBoilerplate.Application/Catalogs/RemoveBook/RemoveBookFromCatalogCommand.cs new file mode 100644 index 0000000..eb13e3a --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/RemoveBook/RemoveBookFromCatalogCommand.cs @@ -0,0 +1,5 @@ +using DotNetBoilerplate.Shared.Abstractions.Commands; + +namespace DotNetBoilerplate.Application.Catalogs.RemoveBook; + +public sealed record RemoveBookFromCatalogCommand(Guid CatalogId, Guid BookId) : ICommand; diff --git a/src/DotNetBoilerplate.Application/Catalogs/RemoveBook/RemoveBookFromCatalogHandler.cs b/src/DotNetBoilerplate.Application/Catalogs/RemoveBook/RemoveBookFromCatalogHandler.cs new file mode 100644 index 0000000..da0134f --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/RemoveBook/RemoveBookFromCatalogHandler.cs @@ -0,0 +1,30 @@ +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using DotNetBoilerplate.Shared.Abstractions.Contexts; +using DotNetBoilerplate.Application.Exceptions; +using DotNetBoilerplate.Core.Books; + +namespace DotNetBoilerplate.Application.Catalogs.RemoveBook; + +internal sealed class RemoveBookFromCatalogHandler( + IContext context, + ICatalogRepository catalogRepository, + IBookRepository bookRepository + ) : ICommandHandler +{ + public async Task HandleAsync(RemoveBookFromCatalogCommand command) + { + var catalog = await catalogRepository.GetByIdAsync(command.CatalogId); + if (catalog is null) + throw new CatalogNotFoundException(); + + var book = await bookRepository.GetByIdAsync(command.BookId); + if (book is null) + throw new BookNotFoundException(); + + if (catalog.CreatedBy != context.Identity.Id) + throw new UserCanNotRemoveBookFromCatalogException(); + + await catalogRepository.RemoveBookFromCatalogAsync(book, catalog); + } +} diff --git a/src/DotNetBoilerplate.Application/Catalogs/Update/UpdateCatalogCommand.cs b/src/DotNetBoilerplate.Application/Catalogs/Update/UpdateCatalogCommand.cs new file mode 100644 index 0000000..8b9be3e --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Update/UpdateCatalogCommand.cs @@ -0,0 +1,5 @@ +using DotNetBoilerplate.Shared.Abstractions.Commands; + +namespace DotNetBoilerplate.Application.Catalogs.Update; + +public sealed record UpdateCatalogCommand(Guid Id, string Name, string Genre, string Description) : ICommand; \ No newline at end of file diff --git a/src/DotNetBoilerplate.Application/Catalogs/Update/UpdateCatalogHandler.cs b/src/DotNetBoilerplate.Application/Catalogs/Update/UpdateCatalogHandler.cs new file mode 100644 index 0000000..2f274fb --- /dev/null +++ b/src/DotNetBoilerplate.Application/Catalogs/Update/UpdateCatalogHandler.cs @@ -0,0 +1,35 @@ +using DotNetBoilerplate.Application.Exceptions; +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Shared.Abstractions.Commands; +using DotNetBoilerplate.Shared.Abstractions.Contexts; +using DotNetBoilerplate.Shared.Abstractions.Time; + +namespace DotNetBoilerplate.Application.Catalogs.Update; + +internal sealed class UpdateCatalogHandler( + IClock clock, + IContext context, + ICatalogRepository catalogRepository +) : ICommandHandler +{ + public async Task HandleAsync(UpdateCatalogCommand command) + { + var catalog = await catalogRepository.GetByIdAsync(command.Id); + if (catalog is null) + throw new BookNotFoundException(); + + bool userCanNotUpdateCatalog = await catalogRepository.UserCanNotUpdateCatalogAsync(catalog.UpdatedAt, clock.Now()); + + catalog.Update( + command.Name, + command.Genre, + command.Description, + context.Identity.Id, + clock.Now(), + userCanNotUpdateCatalog + ); + + await catalogRepository.UpdateAsync(catalog); + return catalog.Id; + } +} diff --git a/src/DotNetBoilerplate.Application/Exceptions/CatalogNotFoundException.cs b/src/DotNetBoilerplate.Application/Exceptions/CatalogNotFoundException.cs new file mode 100644 index 0000000..9b9251c --- /dev/null +++ b/src/DotNetBoilerplate.Application/Exceptions/CatalogNotFoundException.cs @@ -0,0 +1,6 @@ +using DotNetBoilerplate.Shared.Abstractions.Exceptions; + +namespace DotNetBoilerplate.Application.Exceptions; + +internal sealed class CatalogNotFoundException() + : CustomException("Catalog not found"); diff --git a/src/DotNetBoilerplate.Application/Exceptions/UserCanNotDeleteCatalogException.cs b/src/DotNetBoilerplate.Application/Exceptions/UserCanNotDeleteCatalogException.cs new file mode 100644 index 0000000..d2e575f --- /dev/null +++ b/src/DotNetBoilerplate.Application/Exceptions/UserCanNotDeleteCatalogException.cs @@ -0,0 +1,6 @@ +using DotNetBoilerplate.Shared.Abstractions.Exceptions; + +namespace DotNetBoilerplate.Application.Exceptions; + +internal sealed class UserCanNotDeleteCatalogException() + : CustomException("User does not have permission to delete this catalog."); diff --git a/src/DotNetBoilerplate.Application/Exceptions/UserCanNotRemoveBookFromCatalogException.cs b/src/DotNetBoilerplate.Application/Exceptions/UserCanNotRemoveBookFromCatalogException.cs new file mode 100644 index 0000000..489188f --- /dev/null +++ b/src/DotNetBoilerplate.Application/Exceptions/UserCanNotRemoveBookFromCatalogException.cs @@ -0,0 +1,6 @@ +using DotNetBoilerplate.Shared.Abstractions.Exceptions; + +namespace DotNetBoilerplate.Application.Exceptions; + +internal sealed class UserCanNotRemoveBookFromCatalogException() + : CustomException("User can not remove book from that catalog."); \ No newline at end of file diff --git a/src/DotNetBoilerplate.Core/Catalogs/Catalog.cs b/src/DotNetBoilerplate.Core/Catalogs/Catalog.cs new file mode 100644 index 0000000..15c18fa --- /dev/null +++ b/src/DotNetBoilerplate.Core/Catalogs/Catalog.cs @@ -0,0 +1,65 @@ +using DotNetBoilerplate.Core.Books; +using DotNetBoilerplate.Core.Catalogs.Exceptions; +using DotNetBoilerplate.Core.Users; + +namespace DotNetBoilerplate.Core.Catalogs; + +public sealed class Catalog +{ + private Catalog() + { } + public Guid Id { get; private set; } + public string Name { get; private set; } + public string Genre { get; private set; } + public string Description { get; private set; } + public List Books { get; set; } + public Guid BookStoreId { get; private set; } + public UserId CreatedBy { get; private set; } + public DateTime UpdatedAt { get; private set; } + + public static Catalog Create( + string name, + string genre, + string description, + Guid bookStoreId, + Guid createdBy, + DateTime updatedAt, + bool userCanNotCreateCatalog + ) + { + if (userCanNotCreateCatalog) + throw new UserCanNotCreateCatalogException(); + + return new Catalog + { + Id = Guid.NewGuid(), + Name = name, + Genre = genre, + Description = description, + Books = new List(), + BookStoreId = bookStoreId, + CreatedBy = createdBy, + UpdatedAt = updatedAt + }; + } + public void Update( + string name, + string genre, + string description, + Guid updater, + DateTime updatedAt, + bool userCanNotUpdateCatalog + + ) + { + if (updater == Guid.Empty) + throw new UserCanNotUpdateCatalogException(); + if (userCanNotUpdateCatalog) + throw new UserCanNotUpdateCatalogMoreThanOnceIn3DaysException(); + + Name = name; + Genre = genre; + Description = description; + UpdatedAt = updatedAt; + } +} diff --git a/src/DotNetBoilerplate.Core/Catalogs/Exceptions/BookAlreadyAddedToCatalogException.cs b/src/DotNetBoilerplate.Core/Catalogs/Exceptions/BookAlreadyAddedToCatalogException.cs new file mode 100644 index 0000000..9b32bbb --- /dev/null +++ b/src/DotNetBoilerplate.Core/Catalogs/Exceptions/BookAlreadyAddedToCatalogException.cs @@ -0,0 +1,8 @@ +using DotNetBoilerplate.Shared.Abstractions.Exceptions; + +namespace DotNetBoilerplate.Core.Catalogs.Exceptions; + +internal sealed class BookAlreadyAddedToCatalogException() + : CustomException("Book is already added to a catalog.") +{ +}; diff --git a/src/DotNetBoilerplate.Core/Catalogs/Exceptions/UserCanNotCreateCatalogException.cs b/src/DotNetBoilerplate.Core/Catalogs/Exceptions/UserCanNotCreateCatalogException.cs new file mode 100644 index 0000000..54c2cb1 --- /dev/null +++ b/src/DotNetBoilerplate.Core/Catalogs/Exceptions/UserCanNotCreateCatalogException.cs @@ -0,0 +1,8 @@ +using DotNetBoilerplate.Shared.Abstractions.Exceptions; + +namespace DotNetBoilerplate.Core.Catalogs.Exceptions; + +internal sealed class UserCanNotCreateCatalogException() + : CustomException("User can not create catalog.") +{ +}; diff --git a/src/DotNetBoilerplate.Core/Catalogs/Exceptions/UserCanNotUpdateCatalogException.cs b/src/DotNetBoilerplate.Core/Catalogs/Exceptions/UserCanNotUpdateCatalogException.cs new file mode 100644 index 0000000..b6853c8 --- /dev/null +++ b/src/DotNetBoilerplate.Core/Catalogs/Exceptions/UserCanNotUpdateCatalogException.cs @@ -0,0 +1,8 @@ +using DotNetBoilerplate.Shared.Abstractions.Exceptions; + +namespace DotNetBoilerplate.Core.Catalogs.Exceptions; + +internal sealed class UserCanNotUpdateCatalogException() + : CustomException("User can not update catalog.") +{ +}; diff --git a/src/DotNetBoilerplate.Core/Catalogs/Exceptions/UserCanNotUpdateCatalogMoreThanOnceIn3DaysException.cs b/src/DotNetBoilerplate.Core/Catalogs/Exceptions/UserCanNotUpdateCatalogMoreThanOnceIn3DaysException.cs new file mode 100644 index 0000000..fb7f977 --- /dev/null +++ b/src/DotNetBoilerplate.Core/Catalogs/Exceptions/UserCanNotUpdateCatalogMoreThanOnceIn3DaysException.cs @@ -0,0 +1,8 @@ +using DotNetBoilerplate.Shared.Abstractions.Exceptions; + +namespace DotNetBoilerplate.Core.Catalogs.Exceptions; + +internal sealed class UserCanNotUpdateCatalogMoreThanOnceIn3DaysException() + : CustomException("User can not update catalog more than once in 3 days.") +{ +}; diff --git a/src/DotNetBoilerplate.Core/Catalogs/ICatalogRepository.cs b/src/DotNetBoilerplate.Core/Catalogs/ICatalogRepository.cs new file mode 100644 index 0000000..6fee6f0 --- /dev/null +++ b/src/DotNetBoilerplate.Core/Catalogs/ICatalogRepository.cs @@ -0,0 +1,28 @@ +using DotNetBoilerplate.Core.Books; + +namespace DotNetBoilerplate.Core.Catalogs; + +public interface ICatalogRepository +{ + Task AddAsync(Catalog catalog); + + Task UserCanNotAddCatalogAsync(Guid bookStoreId); + + Task UpdateAsync(Catalog catalog); + + Task UserCanNotUpdateCatalogAsync(DateTime lastUpdated, DateTime now); + + Task> GetAll(); + + Task GetByIdAsync(Guid id); + + Task> GetAllInBookStore(Guid bookStoreId); + + Task DeleteAsync(Catalog catalog); + + Task AddBookToCatalogAsync(Book book, Catalog catalog); + + Task> GetBooksInCatalogAsync(Guid catalogId); + + Task RemoveBookFromCatalogAsync(Book book, Catalog catalog); +} diff --git a/src/DotNetBoilerplate.Infrastructure/DAL/Configurations/Write/CatalogWriteConfiguration.cs b/src/DotNetBoilerplate.Infrastructure/DAL/Configurations/Write/CatalogWriteConfiguration.cs new file mode 100644 index 0000000..4230e9e --- /dev/null +++ b/src/DotNetBoilerplate.Infrastructure/DAL/Configurations/Write/CatalogWriteConfiguration.cs @@ -0,0 +1,58 @@ +using DotNetBoilerplate.Core.Books; +using DotNetBoilerplate.Core.BookStores; +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Core.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DotNetBoilerplate.Infrastructure.DAL.Configurations.Write; + +internal sealed class CatalogWriteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name) + .IsRequired() + .HasMaxLength(30); + + builder.Property(x => x.Genre) + .IsRequired() + .HasMaxLength(20); + + builder.Property(x => x.Description) + .HasMaxLength(1000); + + // builder.Property(x => x.Books) + // .HasConversion() + // .IsRequired(); + + builder.Property(x => x.BookStoreId) + .IsRequired(); + + builder.Property(x => x.CreatedBy) + .HasConversion(x => x.Value, x => new UserId(x)) + .IsRequired(); + + builder.Property(x => x.UpdatedAt) + .IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(x => x.BookStoreId) + .IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(x => x.CreatedBy) + .IsRequired(); + + builder + .HasMany() + .WithMany() + .UsingEntity(j => j.ToTable("CatalogBooks")); + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Infrastructure/DAL/Contexts/DotNetBoilerplateWriteDbContext.cs b/src/DotNetBoilerplate.Infrastructure/DAL/Contexts/DotNetBoilerplateWriteDbContext.cs index 529972d..164670e 100644 --- a/src/DotNetBoilerplate.Infrastructure/DAL/Contexts/DotNetBoilerplateWriteDbContext.cs +++ b/src/DotNetBoilerplate.Infrastructure/DAL/Contexts/DotNetBoilerplateWriteDbContext.cs @@ -2,6 +2,7 @@ using DotNetBoilerplate.Core.BookStores; using DotNetBoilerplate.Core.Users; using DotNetBoilerplate.Core.Reviews; +using DotNetBoilerplate.Core.Catalogs; using DotNetBoilerplate.Infrastructure.DAL.Configurations.Write; using DotNetBoilerplate.Shared.Abstractions.Outbox; using Microsoft.EntityFrameworkCore; @@ -20,6 +21,9 @@ internal sealed class DotNetBoilerplateWriteDbContext(DbContextOptions Reviews { get; set; } + public DbSet Catalogs { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema("dotNetBoilerplate"); @@ -29,5 +33,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new BookStoreWriteConfiguration()); modelBuilder.ApplyConfiguration(new BookWriteConfiguration()); modelBuilder.ApplyConfiguration(new ReviewsWriteConfiguration()); + modelBuilder.ApplyConfiguration(new CatalogWriteConfiguration()); } } \ No newline at end of file diff --git a/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240731074754_Initial.Designer.cs b/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240813124313_Initial.Designer.cs similarity index 70% rename from src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240731074754_Initial.Designer.cs rename to src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240813124313_Initial.Designer.cs index 85e0f52..7d97a75 100644 --- a/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240731074754_Initial.Designer.cs +++ b/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240813124313_Initial.Designer.cs @@ -12,7 +12,7 @@ namespace DotNetBoilerplate.Infrastructure.DAL.Migrations { [DbContext(typeof(DotNetBoilerplateWriteDbContext))] - [Migration("20240731074754_Initial")] + [Migration("20240813124313_Initial")] partial class Initial { /// @@ -26,6 +26,21 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("BookCatalog", b => + { + b.Property("BookId") + .HasColumnType("uuid"); + + b.Property("CatalogId") + .HasColumnType("uuid"); + + b.HasKey("BookId", "CatalogId"); + + b.HasIndex("CatalogId"); + + b.ToTable("CatalogBooks", "dotNetBoilerplate"); + }); + modelBuilder.Entity("DotNetBoilerplate.Core.BookStores.BookStore", b => { b.Property("Id") @@ -64,6 +79,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("BookStoreId") .HasColumnType("uuid"); + b.Property("CatalogId") + .HasColumnType("uuid"); + b.Property("CreatedBy") .HasColumnType("uuid"); @@ -93,11 +111,51 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("BookStoreId"); + b.HasIndex("CatalogId"); + b.HasIndex("CreatedBy"); b.ToTable("Books", "dotNetBoilerplate"); }); + modelBuilder.Entity("DotNetBoilerplate.Core.Catalogs.Catalog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BookStoreId") + .HasColumnType("uuid"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("BookStoreId"); + + b.HasIndex("CreatedBy"); + + b.ToTable("Catalogs", "dotNetBoilerplate"); + }); + modelBuilder.Entity("DotNetBoilerplate.Core.Reviews.Review", b => { b.Property("Id") @@ -196,6 +254,21 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("OutboxMessages", "dotNetBoilerplate"); }); + modelBuilder.Entity("BookCatalog", b => + { + b.HasOne("DotNetBoilerplate.Core.Books.Book", null) + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DotNetBoilerplate.Core.Catalogs.Catalog", null) + .WithMany() + .HasForeignKey("CatalogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("DotNetBoilerplate.Core.BookStores.BookStore", b => { b.HasOne("DotNetBoilerplate.Core.Users.User", null) @@ -204,6 +277,25 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); modelBuilder.Entity("DotNetBoilerplate.Core.Books.Book", b => + { + b.HasOne("DotNetBoilerplate.Core.BookStores.BookStore", null) + .WithMany() + .HasForeignKey("BookStoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DotNetBoilerplate.Core.Catalogs.Catalog", null) + .WithMany("Books") + .HasForeignKey("CatalogId"); + + b.HasOne("DotNetBoilerplate.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DotNetBoilerplate.Core.Catalogs.Catalog", b => { b.HasOne("DotNetBoilerplate.Core.BookStores.BookStore", null) .WithMany() @@ -232,6 +324,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("DotNetBoilerplate.Core.Catalogs.Catalog", b => + { + b.Navigation("Books"); + }); #pragma warning restore 612, 618 } } diff --git a/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240731074754_Initial.cs b/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240813124313_Initial.cs similarity index 66% rename from src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240731074754_Initial.cs rename to src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240813124313_Initial.cs index af5bb1d..ae66f98 100644 --- a/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240731074754_Initial.cs +++ b/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/20240813124313_Initial.cs @@ -70,6 +70,38 @@ protected override void Up(MigrationBuilder migrationBuilder) principalColumn: "Id"); }); + migrationBuilder.CreateTable( + name: "Catalogs", + schema: "dotNetBoilerplate", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Genre = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + BookStoreId = table.Column(type: "uuid", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + UpdatedAt = table.Column(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Catalogs", x => x.Id); + table.ForeignKey( + name: "FK_Catalogs_BookStores_BookStoreId", + column: x => x.BookStoreId, + principalSchema: "dotNetBoilerplate", + principalTable: "BookStores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Catalogs_Users_CreatedBy", + column: x => x.CreatedBy, + principalSchema: "dotNetBoilerplate", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "Books", schema: "dotNetBoilerplate", @@ -82,7 +114,8 @@ protected override void Up(MigrationBuilder migrationBuilder) Year = table.Column(type: "integer", nullable: false), Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), BookStoreId = table.Column(type: "uuid", nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false) + CreatedBy = table.Column(type: "uuid", nullable: false), + CatalogId = table.Column(type: "uuid", nullable: true) }, constraints: table => { @@ -94,6 +127,12 @@ protected override void Up(MigrationBuilder migrationBuilder) principalTable: "BookStores", principalColumn: "Id", onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Books_Catalogs_CatalogId", + column: x => x.CatalogId, + principalSchema: "dotNetBoilerplate", + principalTable: "Catalogs", + principalColumn: "Id"); table.ForeignKey( name: "FK_Books_Users_CreatedBy", column: x => x.CreatedBy, @@ -103,6 +142,33 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "CatalogBooks", + schema: "dotNetBoilerplate", + columns: table => new + { + BookId = table.Column(type: "uuid", nullable: false), + CatalogId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CatalogBooks", x => new { x.BookId, x.CatalogId }); + table.ForeignKey( + name: "FK_CatalogBooks_Books_BookId", + column: x => x.BookId, + principalSchema: "dotNetBoilerplate", + principalTable: "Books", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CatalogBooks_Catalogs_CatalogId", + column: x => x.CatalogId, + principalSchema: "dotNetBoilerplate", + principalTable: "Catalogs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "Reviews", schema: "dotNetBoilerplate", @@ -140,6 +206,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Books", column: "BookStoreId"); + migrationBuilder.CreateIndex( + name: "IX_Books_CatalogId", + schema: "dotNetBoilerplate", + table: "Books", + column: "CatalogId"); + migrationBuilder.CreateIndex( name: "IX_Books_CreatedBy", schema: "dotNetBoilerplate", @@ -153,6 +225,24 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "OwnerId", unique: true); + migrationBuilder.CreateIndex( + name: "IX_CatalogBooks_CatalogId", + schema: "dotNetBoilerplate", + table: "CatalogBooks", + column: "CatalogId"); + + migrationBuilder.CreateIndex( + name: "IX_Catalogs_BookStoreId", + schema: "dotNetBoilerplate", + table: "Catalogs", + column: "BookStoreId"); + + migrationBuilder.CreateIndex( + name: "IX_Catalogs_CreatedBy", + schema: "dotNetBoilerplate", + table: "Catalogs", + column: "CreatedBy"); + migrationBuilder.CreateIndex( name: "IX_Reviews_BookId_CreatedBy", schema: "dotNetBoilerplate", @@ -184,6 +274,10 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "CatalogBooks", + schema: "dotNetBoilerplate"); + migrationBuilder.DropTable( name: "OutboxMessages", schema: "dotNetBoilerplate"); @@ -196,6 +290,10 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "Books", schema: "dotNetBoilerplate"); + migrationBuilder.DropTable( + name: "Catalogs", + schema: "dotNetBoilerplate"); + migrationBuilder.DropTable( name: "BookStores", schema: "dotNetBoilerplate"); diff --git a/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/DotNetBoilerplateWriteDbContextModelSnapshot.cs b/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/DotNetBoilerplateWriteDbContextModelSnapshot.cs index 3066c83..089ab94 100644 --- a/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/DotNetBoilerplateWriteDbContextModelSnapshot.cs +++ b/src/DotNetBoilerplate.Infrastructure/DAL/Migrations/DotNetBoilerplateWriteDbContextModelSnapshot.cs @@ -23,6 +23,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("BookCatalog", b => + { + b.Property("BookId") + .HasColumnType("uuid"); + + b.Property("CatalogId") + .HasColumnType("uuid"); + + b.HasKey("BookId", "CatalogId"); + + b.HasIndex("CatalogId"); + + b.ToTable("CatalogBooks", "dotNetBoilerplate"); + }); + modelBuilder.Entity("DotNetBoilerplate.Core.BookStores.BookStore", b => { b.Property("Id") @@ -61,6 +76,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("BookStoreId") .HasColumnType("uuid"); + b.Property("CatalogId") + .HasColumnType("uuid"); + b.Property("CreatedBy") .HasColumnType("uuid"); @@ -90,11 +108,51 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("BookStoreId"); + b.HasIndex("CatalogId"); + b.HasIndex("CreatedBy"); b.ToTable("Books", "dotNetBoilerplate"); }); + modelBuilder.Entity("DotNetBoilerplate.Core.Catalogs.Catalog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BookStoreId") + .HasColumnType("uuid"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("BookStoreId"); + + b.HasIndex("CreatedBy"); + + b.ToTable("Catalogs", "dotNetBoilerplate"); + }); + modelBuilder.Entity("DotNetBoilerplate.Core.Reviews.Review", b => { b.Property("Id") @@ -193,6 +251,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OutboxMessages", "dotNetBoilerplate"); }); + modelBuilder.Entity("BookCatalog", b => + { + b.HasOne("DotNetBoilerplate.Core.Books.Book", null) + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DotNetBoilerplate.Core.Catalogs.Catalog", null) + .WithMany() + .HasForeignKey("CatalogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("DotNetBoilerplate.Core.BookStores.BookStore", b => { b.HasOne("DotNetBoilerplate.Core.Users.User", null) @@ -201,6 +274,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); modelBuilder.Entity("DotNetBoilerplate.Core.Books.Book", b => + { + b.HasOne("DotNetBoilerplate.Core.BookStores.BookStore", null) + .WithMany() + .HasForeignKey("BookStoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DotNetBoilerplate.Core.Catalogs.Catalog", null) + .WithMany("Books") + .HasForeignKey("CatalogId"); + + b.HasOne("DotNetBoilerplate.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DotNetBoilerplate.Core.Catalogs.Catalog", b => { b.HasOne("DotNetBoilerplate.Core.BookStores.BookStore", null) .WithMany() @@ -229,6 +321,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("DotNetBoilerplate.Core.Catalogs.Catalog", b => + { + b.Navigation("Books"); + }); #pragma warning restore 612, 618 } } diff --git a/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/Extensions.cs b/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/Extensions.cs index 5b87612..576a21e 100644 --- a/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/Extensions.cs +++ b/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/Extensions.cs @@ -3,6 +3,7 @@ using DotNetBoilerplate.Core.Users; using DotNetBoilerplate.Core.Reviews; using Microsoft.Extensions.DependencyInjection; +using DotNetBoilerplate.Core.Catalogs; namespace DotNetBoilerplate.Infrastructure.DAL.Repositories; @@ -14,6 +15,8 @@ public static IServiceCollection AddRepositories(this IServiceCollection service services.AddScoped(); services.AddScoped(); services.AddScoped(); + // services.AddSingleton(); + services.AddScoped(); return services; } diff --git a/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/InMemoryCatalogRepository.cs b/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/InMemoryCatalogRepository.cs new file mode 100644 index 0000000..a0ff52b --- /dev/null +++ b/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/InMemoryCatalogRepository.cs @@ -0,0 +1,66 @@ +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Core.Books; + +namespace DotNetBoilerplate.Infrastructure.DAL.Repositories; + +internal sealed class InMemoryCatalogRepository : ICatalogRepository +{ + private readonly List _catalogs = []; + + public Task AddAsync(Catalog catalog) + { + _catalogs.Add(catalog); + return Task.CompletedTask; + } + public Task UserCanNotAddCatalogAsync(Guid bookStoreId) + { + var count = _catalogs.Count(x => x.BookStoreId == bookStoreId); + return Task.FromResult(count >= 5); + } + public Task UpdateAsync(Catalog catalog) + { + var existingCatalogIndex = _catalogs.FindIndex(x => x.Id == catalog.Id); + _catalogs[existingCatalogIndex] = catalog; + + return Task.CompletedTask; + } + public Task UserCanNotUpdateCatalogAsync(DateTime lastUpdated, DateTime now) + { + return Task.FromResult((now - lastUpdated).TotalSeconds < 3); + } + public Task> GetAll() + { + return Task.FromResult(_catalogs.AsEnumerable()); + } + public Task GetByIdAsync(Guid id) + { + return Task.FromResult(_catalogs.FirstOrDefault(x => x.Id == id)); + } + public Task> GetAllInBookStore(Guid bookStoreId) + { + return Task.FromResult(_catalogs.FindAll(x => x.BookStoreId == bookStoreId) + .AsEnumerable()); + } + public Task DeleteAsync(Catalog catalog) + { + _catalogs.Remove(catalog); + return Task.CompletedTask; + } + + + public Task AddBookToCatalogAsync(Book book, Catalog catalog) + { + _catalogs.FirstOrDefault(x => x.Id == catalog.Id).Books.Add(book); + return Task.CompletedTask; + } + public Task> GetBooksInCatalogAsync(Guid catalogId) + { + var catalog = _catalogs.FirstOrDefault(x => x.Id == catalogId); + return Task.FromResult(catalog.Books.AsEnumerable()); + } + public Task RemoveBookFromCatalogAsync(Book book, Catalog catalog) + { + _catalogs.FirstOrDefault(x => x.Id == catalog.Id).Books.Remove(book); + return Task.CompletedTask; + } +} diff --git a/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/PostgresCatalogRepository.cs b/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/PostgresCatalogRepository.cs new file mode 100644 index 0000000..f9b3200 --- /dev/null +++ b/src/DotNetBoilerplate.Infrastructure/DAL/Repositories/PostgresCatalogRepository.cs @@ -0,0 +1,72 @@ + +using DotNetBoilerplate.Core.Books; +using DotNetBoilerplate.Core.Catalogs; +using DotNetBoilerplate.Infrastructure.DAL.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace DotNetBoilerplate.Infrastructure.DAL.Repositories; + +internal sealed class PostgresCatalogRepository(DotNetBoilerplateWriteDbContext dbContext) : ICatalogRepository +{ + public async Task AddAsync(Catalog catalog) + { + await dbContext.Catalogs.AddAsync(catalog); + } + public async Task UserCanNotAddCatalogAsync(Guid bookStoreId) + { + return await dbContext.Catalogs.CountAsync(x => x.BookStoreId == bookStoreId) >= 5; + } + public async Task UpdateAsync(Catalog catalog) + { + dbContext.Catalogs.Update(catalog); + await dbContext.SaveChangesAsync(); + } + public Task UserCanNotUpdateCatalogAsync(DateTime lastUpdated, DateTime now) + { + return Task.FromResult((now - lastUpdated).TotalSeconds < 3); + } + public async Task> GetAll() + { + return await dbContext.Catalogs.ToListAsync(); + } + public async Task GetByIdAsync(Guid id) + { + return await dbContext.Catalogs.FirstOrDefaultAsync(x => x.Id == id); + } + public async Task> GetAllInBookStore(Guid bookStoreId) + { + return await dbContext.Catalogs.Where(x => x.BookStoreId == bookStoreId).ToListAsync(); + } + public async Task DeleteAsync(Catalog catalog) + { + dbContext.Catalogs.Remove(catalog); + await dbContext.SaveChangesAsync(); + } + + + public async Task AddBookToCatalogAsync(Book book, Catalog catalog) + { + var existingCatalog = await dbContext.Catalogs.Include(c => c.Books).FirstOrDefaultAsync(x => x.Id == catalog.Id); + // if (existingCatalog.Books == null) + // { + // existingCatalog.Books = new List(); + // } + existingCatalog.Books.Add(book); + await dbContext.SaveChangesAsync(); + } + public async Task> GetBooksInCatalogAsync(Guid catalogId) + { + var catalog = await dbContext.Catalogs + .Include(c => c.Books) + .FirstOrDefaultAsync(x => x.Id == catalogId); + return catalog.Books.ToList(); + } + public async Task RemoveBookFromCatalogAsync(Book book, Catalog catalog) + { + var existingCatalog = await dbContext.Catalogs + .Include(c => c.Books) + .FirstOrDefaultAsync(x => x.Id == catalog.Id); + existingCatalog.Books.Remove(book); + await dbContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/src/DotNetBoilerplate.Infrastructure/Emails/Extensions.cs b/src/DotNetBoilerplate.Infrastructure/Emails/Extensions.cs index a15eb6c..64f2c0b 100644 --- a/src/DotNetBoilerplate.Infrastructure/Emails/Extensions.cs +++ b/src/DotNetBoilerplate.Infrastructure/Emails/Extensions.cs @@ -21,11 +21,6 @@ public static IServiceCollection AddEmails(this IServiceCollection services, ICo services.AddHttpClient() .AddTransientHttpErrorPolicy(policyBuilder => policyBuilder.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))) .AddTransientHttpErrorPolicy(policyBuilder => policyBuilder.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30))) - // .ConfigureHttpClient((sp, client) => - // { - // var options = sp.GetRequiredService>().Value; - // // Configure the HttpClient if needed - // }) .AddTypedClient((httpClient, sp) => { var options = sp.GetRequiredService>().Value;