From 3fe614edc7623e5e5cee8ef3d03804a3feaef4ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Sun, 2 Jun 2024 21:27:41 +1000 Subject: [PATCH] Allow user to delete a product (#5) --- src/Admin/NCafe.Admin.Api/Program.cs | 6 ++ .../Projections/ProductProjectionService.cs | 6 ++ .../Commands/DeleteProductTests.cs | 49 +++++++++++++++ .../Commands/DeleteProduct.cs | 25 ++++++++ .../NCafe.Admin.Domain/Entities/Product.cs | 11 ++++ .../Events/ProductDeleted.cs | 11 ++++ .../Exceptions/ProductNotFound.cs | 5 ++ .../NCafe.Admin.Domain/Queries/GetProducts.cs | 1 + .../NCafe.Admin.Domain/ReadModels/Product.cs | 1 + src/Cashier/NCafe.Cashier.Api/Program.cs | 2 +- .../Projections/ProductDeleted.cs | 11 ++++ .../Projections/ProductProjectionService.cs | 6 +- .../Queries/GetProducts.cs | 1 + .../ReadModels/Product.cs | 1 + .../EventStore/EventStoreProjectionService.cs | 5 +- src/UI/NCafe.Web/Pages/Admin/Index.razor | 60 +++++++++++++++---- 16 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 src/Admin/NCafe.Admin.Domain.Tests/Commands/DeleteProductTests.cs create mode 100644 src/Admin/NCafe.Admin.Domain/Commands/DeleteProduct.cs create mode 100644 src/Admin/NCafe.Admin.Domain/Events/ProductDeleted.cs create mode 100644 src/Admin/NCafe.Admin.Domain/Exceptions/ProductNotFound.cs create mode 100644 src/Cashier/NCafe.Cashier.Api/Projections/ProductDeleted.cs diff --git a/src/Admin/NCafe.Admin.Api/Program.cs b/src/Admin/NCafe.Admin.Api/Program.cs index ad850ca..72a62ec 100644 --- a/src/Admin/NCafe.Admin.Api/Program.cs +++ b/src/Admin/NCafe.Admin.Api/Program.cs @@ -59,6 +59,12 @@ }) .WithName("CreateProduct"); +app.MapDelete("/products/{id}", async (IMediator mediator, Guid id) => +{ + await mediator.Send(new DeleteProduct(id)); + return Results.NoContent(); +}).WithName("DeleteProduct"); + app.MapGet("/healthz", () => "OK"); app.Run(); diff --git a/src/Admin/NCafe.Admin.Api/Projections/ProductProjectionService.cs b/src/Admin/NCafe.Admin.Api/Projections/ProductProjectionService.cs index d6ebed7..f9c5b99 100644 --- a/src/Admin/NCafe.Admin.Api/Projections/ProductProjectionService.cs +++ b/src/Admin/NCafe.Admin.Api/Projections/ProductProjectionService.cs @@ -18,6 +18,12 @@ public ProductProjectionService(IProjectionService projectionService) Name = @event.Name, Price = @event.Price }); + + projectionService.OnUpdate( + product => product.Id, + (@event, product) => product.IsDeleted = true); + + } protected async override Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/src/Admin/NCafe.Admin.Domain.Tests/Commands/DeleteProductTests.cs b/src/Admin/NCafe.Admin.Domain.Tests/Commands/DeleteProductTests.cs new file mode 100644 index 0000000..c9be4c9 --- /dev/null +++ b/src/Admin/NCafe.Admin.Domain.Tests/Commands/DeleteProductTests.cs @@ -0,0 +1,49 @@ +using NCafe.Admin.Domain.Commands; +using NCafe.Admin.Domain.Entities; +using NCafe.Admin.Domain.Exceptions; +using NCafe.Core.Repositories; +using System.Threading; + +namespace NCafe.Admin.Domain.Tests.Commands; + +public class DeleteProductTests +{ + private readonly DeleteProductHandler _sut; + private readonly IRepository _repository; + + public DeleteProductTests() + { + _repository = A.Fake(); + _sut = new DeleteProductHandler(_repository); + } + + [Fact] + public async Task GivenNonExistingProduct_ShouldThrowException() + { + // Arrange + var id = Guid.NewGuid(); + A.CallTo(() => _repository.GetById(id)).Returns((Product)null); + + // Act + var exception = await Record.ExceptionAsync(() => _sut.Handle(new DeleteProduct(id), CancellationToken.None)); + + // Assert + exception.ShouldBeOfType(); + } + + [Fact] + public async Task GivenExistingProduct_ShouldDeleteProduct() + { + // Arrange + var id = Guid.NewGuid(); + var product = new Product(id, "Latte", 3); + A.CallTo(() => _repository.GetById(id)).Returns(product); + + // Act + await _sut.Handle(new DeleteProduct(id), CancellationToken.None); + + // Assert + A.CallTo(() => _repository.Save(A.That.Matches(p => p.Id == id && p.IsDeleted == true))) + .MustHaveHappenedOnceExactly(); + } +} diff --git a/src/Admin/NCafe.Admin.Domain/Commands/DeleteProduct.cs b/src/Admin/NCafe.Admin.Domain/Commands/DeleteProduct.cs new file mode 100644 index 0000000..085a236 --- /dev/null +++ b/src/Admin/NCafe.Admin.Domain/Commands/DeleteProduct.cs @@ -0,0 +1,25 @@ +using MediatR; +using NCafe.Admin.Domain.Entities; +using NCafe.Admin.Domain.Exceptions; +using NCafe.Core.Repositories; + +namespace NCafe.Admin.Domain.Commands; + +public record DeleteProduct(Guid Id) : IRequest; + +internal sealed class DeleteProductHandler(IRepository repository) : IRequestHandler +{ + private readonly IRepository _repository = repository; + + public async Task Handle(DeleteProduct command, CancellationToken cancellationToken) + { + var product = await _repository.GetById(command.Id); + if (product is null || product.IsDeleted) + { + throw new ProductNotFound(command.Id); + } + + product.Delete(); + await _repository.Save(product); + } +} diff --git a/src/Admin/NCafe.Admin.Domain/Entities/Product.cs b/src/Admin/NCafe.Admin.Domain/Entities/Product.cs index ee0a6bf..21f0f3b 100644 --- a/src/Admin/NCafe.Admin.Domain/Entities/Product.cs +++ b/src/Admin/NCafe.Admin.Domain/Entities/Product.cs @@ -32,6 +32,7 @@ public Product(Guid id, string name, decimal price) public string Name { get; private set; } public decimal Price { get; private set; } + public bool IsDeleted { get; private set; } private void Apply(ProductCreated @event) { @@ -39,4 +40,14 @@ private void Apply(ProductCreated @event) Name = @event.Name; Price = @event.Price; } + + public void Delete() + { + RaiseEvent(new ProductDeleted(Id)); + } + + private void Apply(ProductDeleted _) + { + IsDeleted = true; + } } diff --git a/src/Admin/NCafe.Admin.Domain/Events/ProductDeleted.cs b/src/Admin/NCafe.Admin.Domain/Events/ProductDeleted.cs new file mode 100644 index 0000000..058c195 --- /dev/null +++ b/src/Admin/NCafe.Admin.Domain/Events/ProductDeleted.cs @@ -0,0 +1,11 @@ +using NCafe.Core.Domain; + +namespace NCafe.Admin.Domain.Events; + +public sealed record ProductDeleted : Event +{ + public ProductDeleted(Guid id) + { + Id = id; + } +} diff --git a/src/Admin/NCafe.Admin.Domain/Exceptions/ProductNotFound.cs b/src/Admin/NCafe.Admin.Domain/Exceptions/ProductNotFound.cs new file mode 100644 index 0000000..f07e5f3 --- /dev/null +++ b/src/Admin/NCafe.Admin.Domain/Exceptions/ProductNotFound.cs @@ -0,0 +1,5 @@ +using NCafe.Core.Exceptions; + +namespace NCafe.Admin.Domain.Exceptions; + +public class ProductNotFound(Guid id) : DomainException($"Product with id '{id}' was not found."); diff --git a/src/Admin/NCafe.Admin.Domain/Queries/GetProducts.cs b/src/Admin/NCafe.Admin.Domain/Queries/GetProducts.cs index c722423..660f73e 100644 --- a/src/Admin/NCafe.Admin.Domain/Queries/GetProducts.cs +++ b/src/Admin/NCafe.Admin.Domain/Queries/GetProducts.cs @@ -13,6 +13,7 @@ internal sealed class GetProductsHandler(IReadModelRepository productRe public Task Handle(GetProducts query, CancellationToken cancellation) { var products = _productRepository.GetAll() + .Where(p => !p.IsDeleted) .ToArray(); return Task.FromResult(products); } diff --git a/src/Admin/NCafe.Admin.Domain/ReadModels/Product.cs b/src/Admin/NCafe.Admin.Domain/ReadModels/Product.cs index 95e2512..7cda581 100644 --- a/src/Admin/NCafe.Admin.Domain/ReadModels/Product.cs +++ b/src/Admin/NCafe.Admin.Domain/ReadModels/Product.cs @@ -6,4 +6,5 @@ public sealed class Product : ReadModel { public string Name { get; set; } public decimal Price { get; set; } + public bool IsDeleted { get; set; } } diff --git a/src/Cashier/NCafe.Cashier.Api/Program.cs b/src/Cashier/NCafe.Cashier.Api/Program.cs index 13028a5..3cf7aae 100644 --- a/src/Cashier/NCafe.Cashier.Api/Program.cs +++ b/src/Cashier/NCafe.Cashier.Api/Program.cs @@ -88,4 +88,4 @@ app.Run(); -public partial class Program { } +public partial class Program; diff --git a/src/Cashier/NCafe.Cashier.Api/Projections/ProductDeleted.cs b/src/Cashier/NCafe.Cashier.Api/Projections/ProductDeleted.cs new file mode 100644 index 0000000..c78ba86 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Api/Projections/ProductDeleted.cs @@ -0,0 +1,11 @@ +using NCafe.Core.Domain; + +namespace NCafe.Cashier.Api.Projections; + +public sealed record ProductDeleted : Event +{ + public ProductDeleted(Guid id) + { + Id = id; + } +} diff --git a/src/Cashier/NCafe.Cashier.Api/Projections/ProductProjectionService.cs b/src/Cashier/NCafe.Cashier.Api/Projections/ProductProjectionService.cs index bd13e2d..2fce75c 100644 --- a/src/Cashier/NCafe.Cashier.Api/Projections/ProductProjectionService.cs +++ b/src/Cashier/NCafe.Cashier.Api/Projections/ProductProjectionService.cs @@ -15,8 +15,12 @@ public ProductProjectionService(IProjectionService projectionService) { Id = @event.Id, Name = @event.Name, - Price = @event.Price + Price = @event.Price, }); + + projectionService.OnUpdate( + product => product.Id, + (@event, product) => product.IsDeleted = true); } protected async override Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/src/Cashier/NCafe.Cashier.Domain/Queries/GetProducts.cs b/src/Cashier/NCafe.Cashier.Domain/Queries/GetProducts.cs index 3d6bcf9..f88a4a1 100644 --- a/src/Cashier/NCafe.Cashier.Domain/Queries/GetProducts.cs +++ b/src/Cashier/NCafe.Cashier.Domain/Queries/GetProducts.cs @@ -14,6 +14,7 @@ internal sealed class GetProductsHandler(IReadModelRepository productRe public Task Handle(GetProducts request, CancellationToken cancellationToken) { var products = _productRepository.GetAll() + .Where(p => !p.IsDeleted) .ToArray(); return Task.FromResult(products); } diff --git a/src/Cashier/NCafe.Cashier.Domain/ReadModels/Product.cs b/src/Cashier/NCafe.Cashier.Domain/ReadModels/Product.cs index 12d80a5..a61ed91 100644 --- a/src/Cashier/NCafe.Cashier.Domain/ReadModels/Product.cs +++ b/src/Cashier/NCafe.Cashier.Domain/ReadModels/Product.cs @@ -6,4 +6,5 @@ public sealed class Product : ReadModel { public string Name { get; set; } public decimal Price { get; set; } + public bool IsDeleted { get; set; } } diff --git a/src/Common/NCafe.Infrastructure/EventStore/EventStoreProjectionService.cs b/src/Common/NCafe.Infrastructure/EventStore/EventStoreProjectionService.cs index 4be0148..53be975 100644 --- a/src/Common/NCafe.Infrastructure/EventStore/EventStoreProjectionService.cs +++ b/src/Common/NCafe.Infrastructure/EventStore/EventStoreProjectionService.cs @@ -1,4 +1,4 @@ -using EventStore.Client; +using EventStore.Client; using Microsoft.Extensions.Logging; using NCafe.Core.Domain; using NCafe.Core.Projections; @@ -51,7 +51,8 @@ private Task EventAppeared(StreamSubscription subscription, ResolvedEvent @event private void SubscriptionDropped(StreamSubscription subscription, SubscriptionDroppedReason reason, Exception exception) { - _logger.LogError("Subscription Dropped."); + _logger.LogError("Subscription Dropped for '{EventStoreStream}' with reason '{SubscriptionDroppedReason}'", subscription.SubscriptionId, reason); + _logger.LogError(exception, "{Exception}", exception.Message); } public void OnCreate(Func handler) where TEvent : Event diff --git a/src/UI/NCafe.Web/Pages/Admin/Index.razor b/src/UI/NCafe.Web/Pages/Admin/Index.razor index 050c5f0..9e1cd52 100644 --- a/src/UI/NCafe.Web/Pages/Admin/Index.razor +++ b/src/UI/NCafe.Web/Pages/Admin/Index.razor @@ -1,8 +1,10 @@ -@page "/admin" +@page "/admin" @using NCafe.Web.Models @inject HttpClient Http @inject IConfiguration Configuration @inject NavigationManager NavigationManager +@inject ModalService _modalService +@inject IMessageService _message Admin - NCafe @@ -22,37 +24,73 @@ to help mitigate this in a later moment.

-@if (products == null) +@if (_products == null) { + return; } -else if (!products.Any()) + +@if (!_products.Any()) { + return; } -else -{ + + - + + + + +
-} + @code { - private Product[] products; + private Product[] _products; protected override async Task OnInitializedAsync() { var url = $"{Configuration["AdminBaseAddress"]}/products"; - products = await Http.GetFromJsonAsync(url); + _products = await Http.GetFromJsonAsync(url); } void CreateProduct() { NavigationManager.NavigateTo("admin/create-product"); } + + + private async Task ShowDeleteConfirm(Guid id) + { + var productName = _products.Single(p => p.Id == id).Name; + var confirmed = await _modalService.ConfirmAsync(new ConfirmOptions + { + Title = "Delete item", + Content = $"Are you sure you want to delete {productName}?", + OkType = "danger", + Icon = @ + }); + + if (confirmed) + { + await DeleteItem(id); + } + } + + private async Task DeleteItem(Guid id) + { + var url = $"{Configuration["AdminBaseAddress"]}/products/{id}"; + var response = await Http.DeleteAsync(url); + response.EnsureSuccessStatusCode(); + + var product = _products.Single(p => p.Id == id); + await _message.Info($"{product.Name} has been deleted."); + _products = _products.Where(p => p.Id != id).ToArray(); + } }