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();
+ }
}