Skip to content
This repository has been archived by the owner on Jul 12, 2024. It is now read-only.

Commit

Permalink
feat: add quantity control
Browse files Browse the repository at this point in the history
  • Loading branch information
foxminchan committed Jun 6, 2024
1 parent 65b61dc commit 64b0eb7
Show file tree
Hide file tree
Showing 16 changed files with 169 additions and 8 deletions.
5 changes: 5 additions & 0 deletions src/RookieShop.ApiService/Endpoints/Baskets/Update.Request.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using RookieShop.Domain.Entities.ProductAggregator.Primitives;

namespace RookieShop.ApiService.Endpoints.Baskets;

public sealed record UpdateBasketRequest(Guid AccountId, ProductId ProductId, int Quantity);
38 changes: 38 additions & 0 deletions src/RookieShop.ApiService/Endpoints/Baskets/Update.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Ardalis.Result;
using MediatR;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using RookieShop.ApiService.Filters;
using RookieShop.ApiService.ViewModels.Baskets;
using RookieShop.Application.Baskets.Commands.Update;
using RookieShop.Domain.Constants;
using RookieShop.Infrastructure.Endpoints.Abstractions;
using RookieShop.Infrastructure.RateLimiter;

namespace RookieShop.ApiService.Endpoints.Baskets;

public sealed class Update(ISender sender) : IEndpoint<Ok<BasketVm>, UpdateBasketRequest>
{
public void MapEndpoint(IEndpointRouteBuilder app) =>
app.MapPatch("/baskets",
async ([FromHeader(Name = HeaderName.IdempotencyKey)] string key, UpdateBasketRequest basket) =>
await HandleAsync(basket))
.AddEndpointFilter<IdempotencyFilter>()
.Produces<Ok<BasketVm>>()
.Produces<NotFound<string>>(StatusCodes.Status404NotFound)
.Produces<BadRequest<IEnumerable<ValidationError>>>(StatusCodes.Status400BadRequest)
.WithTags(nameof(Baskets))
.WithName("Update Basket Quantity")
.MapToApiVersion(new(1, 0))
.RequirePerUserRateLimit();

public async Task<Ok<BasketVm>> HandleAsync(UpdateBasketRequest request,
CancellationToken cancellationToken = default)
{
UpdateBasketCommand command = new(request.AccountId, request.ProductId, request.Quantity);

var result = await sender.Send(command, cancellationToken);

return TypedResults.Ok(result.Value.ToBasketVm());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Ardalis.Result;
using RookieShop.Application.Baskets.DTOs;
using RookieShop.Domain.Entities.ProductAggregator.Primitives;
using RookieShop.Domain.SharedKernel;

namespace RookieShop.Application.Baskets.Commands.Update;

public sealed record UpdateBasketCommand(Guid AccountId, ProductId ProductId, int Quantity)
: ICommand<Result<BasketDto>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Text.Json;
using Ardalis.GuardClauses;
using Ardalis.Result;
using Microsoft.Extensions.Logging;
using RookieShop.Application.Baskets.DTOs;
using RookieShop.Domain.Entities.BasketAggregator;
using RookieShop.Domain.SharedKernel;
using RookieShop.Infrastructure.Cache.Redis;

namespace RookieShop.Application.Baskets.Commands.Update;

public sealed class UpdateBasketHandler(IRedisService redisService, ILogger<UpdateBasketHandler> logger)
: ICommandHandler<UpdateBasketCommand, Result<BasketDto>>
{
public async Task<Result<BasketDto>> Handle(UpdateBasketCommand request, CancellationToken cancellationToken)
{
var basket = await redisService.HashGetAsync<Basket>(nameof(Basket), request.AccountId.ToString());

Guard.Against.NotFound(request.AccountId, basket);

var basketDetail = basket.BasketDetails.First(x => x.Id == request.ProductId);

basketDetail.Quantity = request.Quantity;

logger.LogInformation("[{Command}] - Updating basket for account {AccountId} with {@Basket}",
nameof(UpdateBasketCommand), request.AccountId, JsonSerializer.Serialize(basket));

var result = await redisService.HashSetAsync(nameof(Basket), request.AccountId.ToString(), basket);

return result.ToBasketDto();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using FluentValidation;

namespace RookieShop.Application.Baskets.Commands.Update;

public sealed class UpdateBasketValidator : AbstractValidator<UpdateBasketCommand>
{
public UpdateBasketValidator()
{
RuleFor(x => x.AccountId)
.NotEmpty();

RuleFor(x => x.ProductId)
.NotEmpty();

RuleFor(x => x.Quantity)
.GreaterThan(0);
}
}
15 changes: 15 additions & 0 deletions ui/storefront/Areas/Basket/Controllers/BasketController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,19 @@ await basketService.DeleteItemAsync(new()

return RedirectToAction("Index");
}

[HttpPost]
public async Task<IActionResult> UpdateBasket(UpdateBasketQuantityRequest request)
{
if (!ModelState.IsValid)
return RedirectToAction("Index");

if (HttpContext.Items["Customer"] is not CustomerViewModel customer) return Unauthorized();

request.AccountId = customer.AccountId;

await basketService.UpdateBasketAsync(request, Guid.NewGuid());

return RedirectToAction("Index");
}
}
2 changes: 2 additions & 0 deletions ui/storefront/Areas/Basket/Models/BasketRequest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Refit;

namespace RookieShop.Storefront.Areas.Basket.Models;
Expand All @@ -9,6 +10,7 @@ public sealed class BasketRequest

[AliasAs("id")]
[Required(ErrorMessage = "Product Id is required")]
[JsonRequired]
public Guid ProductId { get; set; }

[AliasAs("quantity")]
Expand Down
21 changes: 21 additions & 0 deletions ui/storefront/Areas/Basket/Models/UpdateBasketQuantityRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Refit;

namespace RookieShop.Storefront.Areas.Basket.Models;

public sealed class UpdateBasketQuantityRequest
{
[AliasAs("accountId")]
public Guid? AccountId { get; set; }

[AliasAs("productId")]
[Required(ErrorMessage = "Product Id is required")]
[JsonRequired]
public Guid ProductId { get; set; }

[AliasAs("quantity")]
[Required(ErrorMessage = "Quantity is required")]
[Range(1, int.MaxValue, ErrorMessage = "Quantity must be greater than 0")]
public int Quantity { get; set; }
}
5 changes: 5 additions & 0 deletions ui/storefront/Areas/Basket/Services/IBasketService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,9 @@ public interface IBasketService

[Delete("/baskets/items")]
Task DeleteItemAsync([Query] DeleteItemRequest deleteItemRequest);

[Patch("/baskets")]
Task<BasketViewModel> UpdateBasketAsync(
UpdateBasketQuantityRequest request,
[Header(HeaderName.IdempotencyKey)] Guid requestId);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@using System.Globalization
@using System.Net.Mime
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model RookieShop.Storefront.Areas.Basket.Models.CartDetailRender

Expand All @@ -20,9 +21,21 @@
<div class="flex items-center h-full">
<input type="number"
asp-for="Quantity"
class="border-y border-gray-200 outline-none text-gray-900 font-semibold text-lg w-full max-w-[73px] min-w-[60px] placeholder:text-gray-900 py-[15px] text-center bg-transparent"
class="border border-gray-200 outline-none text-gray-900 font-semibold text-lg w-full max-w-[73px] min-w-[60px] placeholder:text-gray-900 py-[15px] text-center bg-transparent"
value="@Model.Quantity"
placeholder="1"/>
placeholder="1"
hx-target="#basket"
hx-swap="outerHTML"
hx-post
hx-area="Basket"
hx-controller="Basket"
hx-action="UpdateBasket"
hx-headers-Content-Type="@MediaTypeNames.Application.Json"
hx-trigger="change from:body debounce:300ms"
hx-include="[name='ProductId']"
hx-vals='{"AccountId": null, "ProductId": @Model.Product.Id}'
_="on change wait 300ms then trigger this"/>
<input type="hidden" name="ProductId" value="@Model.Product.Id" />
</div>
</div>
<div class="flex items-center max-[500px]:justify-center md:justify-end max-md:mt-3 h-full">
Expand Down
3 changes: 3 additions & 0 deletions ui/storefront/Areas/Order/Models/OrderFromRequest.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using RookieShop.Storefront.Areas.Basket.Models;

namespace RookieShop.Storefront.Areas.Order.Models;

public sealed class OrderFromRequest
{
[Required(ErrorMessage = "Please select payment method")]
[JsonRequired]
public PaymentMethod PaymentMethod { get; set; }

[MaxLength(100, ErrorMessage = "Street must be less than 100 characters")]
Expand All @@ -21,5 +23,6 @@ public sealed class OrderFromRequest
public string? Province { get; set; }

[Required(ErrorMessage = "Please login to place order")]
[JsonRequired]
public Guid AccountId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Refit;

namespace RookieShop.Storefront.Areas.Product.Models.Feedbacks;
Expand All @@ -16,6 +17,7 @@ public sealed class FeedbackRequest

[AliasAs("productId")]
[Required(ErrorMessage = "Product Id is required")]
[JsonRequired]
public Guid ProductId { get; set; }

[AliasAs("customerId")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public sealed class AddToCartViewComponent(IHttpContextAccessor httpContextAcces
{
public async Task<IViewComponentResult> InvokeAsync(Guid productId, decimal price)
{
var userId = httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
var userId = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);

var basketRequest = new BasketRequest
{
Expand Down
2 changes: 2 additions & 0 deletions ui/storefront/Areas/User/Models/CustomerRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ public class CustomerRequest
[AliasAs("gender")]
[JsonPropertyName("gender")]
[Required(ErrorMessage = "Gender is required")]
[JsonRequired]
public Gender Gender { get; set; }

[AliasAs("accountId")]
[JsonPropertyName("accountId")]
[JsonRequired]
[Required(ErrorMessage = "Account Id is required")]
public Guid AccountId { get; set; }
}
1 change: 1 addition & 0 deletions ui/storefront/Areas/User/Models/CustomerViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ public sealed class CustomerViewModel : CustomerRequest
{
[JsonPropertyName("id")]
[Required(ErrorMessage = "Id is required")]
[JsonRequired]
public Guid Id { get; set; }
}
5 changes: 0 additions & 5 deletions ui/storefront/wwwroot/css/output.css
Original file line number Diff line number Diff line change
Expand Up @@ -1251,11 +1251,6 @@ video {
border-width: 0px;
}

.border-y {
border-top-width: 1px;
border-bottom-width: 1px;
}

.border-b {
border-bottom-width: 1px;
}
Expand Down

0 comments on commit 64b0eb7

Please sign in to comment.