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

Commit

Permalink
feat(product): add product use cases
Browse files Browse the repository at this point in the history
  • Loading branch information
foxminchan committed May 14, 2024
1 parent 83e72a4 commit c15c6c2
Show file tree
Hide file tree
Showing 37 changed files with 597 additions and 16 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ docker-compose --env-file .env up -d
**You can access the following services:**

1. `https://localhost:6000` for identity server
2. `https://localhost:5000` for all the REST API document
1. `https://localhost:9000` for all the REST API document
2. `https://localhost:5001` for identity server
3. `https://localhost:4000` for user facing website
4. `https://localhost:3000` for admin facing website
5. `https://localhost:1888` for observability dashboard
Expand Down
2 changes: 2 additions & 0 deletions src/RookieShop.ApiService/Endpoints/Categories/Create.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using RookieShop.ApiService.Filters;
Expand All @@ -22,6 +23,7 @@ public sealed class Create(ISender sender) : IEndpoint<Created<CreateCategoryRes
.WithTags(nameof(Categories))
.WithName("Create Category")
.MapToApiVersion(new(1, 0))
.RequireAuthorization(JwtBearerDefaults.AuthenticationScheme)
.RequirePerUserRateLimit();

public async Task<Created<CreateCategoryResponse>> HandleAsync(CreateCategoryRequest request,
Expand Down
2 changes: 2 additions & 0 deletions src/RookieShop.ApiService/Endpoints/Categories/Delete.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.HttpResults;
using RookieShop.Application.Categories.Commands.Delete;
using RookieShop.Domain.Entities.CategoryAggregator.Primitives;
Expand All @@ -15,6 +16,7 @@ public sealed class Delete(ISender sender) : IEndpoint<NoContent, DeleteCategory
.WithTags(nameof(Categories))
.WithName("Delete Category")
.MapToApiVersion(new(1, 0))
.RequireAuthorization(JwtBearerDefaults.AuthenticationScheme)
.RequirePerUserRateLimit();

public async Task<NoContent> HandleAsync(DeleteCategoryRequest request,
Expand Down
2 changes: 2 additions & 0 deletions src/RookieShop.ApiService/Endpoints/Categories/Update.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.HttpResults;
using RookieShop.ApiService.ViewModels.Categories;
using RookieShop.Application.Categories.Commands.Update;
Expand All @@ -15,6 +16,7 @@ public sealed class Update(ISender sender) : IEndpoint<Ok<CategoryVm>, UpdateCat
.WithTags(nameof(Categories))
.WithName("Update Category")
.MapToApiVersion(new(1, 0))
.RequireAuthorization(JwtBearerDefaults.AuthenticationScheme)
.RequirePerUserRateLimit();

public async Task<Ok<CategoryVm>> HandleAsync(UpdateCategoryRequest request,
Expand Down
4 changes: 2 additions & 2 deletions src/RookieShop.ApiService/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5001"
"applicationUrl": "http://localhost:9001"
},
"https": {
"commandName": "Project",
Expand All @@ -21,7 +21,7 @@
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:5000"
"applicationUrl": "https://localhost:9000"
},
"Container (.NET SDK)": {
"commandName": "SdkContainer",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed class ListCategoriesHandler(IReadRepository<Category> repository)

var totalPages = (int)Math.Ceiling(totalRecords / (double)request.PageSize);

var pagedInfo = new PagedInfo(request.PageIndex, request.PageSize, totalPages, totalRecords);
PagedInfo pagedInfo = new(request.PageIndex, request.PageSize, totalPages, totalRecords);

return new(pagedInfo, categories.ToCategoryDto());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ public async Task<Result> Handle(DeleteCustomerCommand request, CancellationToke

Guard.Against.NotFound(request.Id, customer);

await repository.DeleteAsync(customer, cancellationToken);
customer.Delete();

await repository.UpdateAsync(customer, cancellationToken);

return Result.Success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed class ListCustomersHandler(IReadRepository<Customer> repository)

var totalPages = (int)Math.Ceiling(totalRecords / (double)request.PageSize);

var pagedInfo = new PagedInfo(request.PageIndex, request.PageSize, totalPages, totalRecords);
PagedInfo pagedInfo = new(request.PageIndex, request.PageSize, totalPages, totalRecords);

return new(pagedInfo, customers.ToCustomerDto());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Ardalis.Result;
using Microsoft.AspNetCore.Http;
using RookieShop.Domain.Entities.CategoryAggregator.Primitives;
using RookieShop.Domain.Entities.ProductAggregator.Primitives;
using RookieShop.Domain.SharedKernel;

namespace RookieShop.Application.Products.Commands.Create;

public sealed record CreateProductCommand(
string Name,
string? Description,
int Quantity,
decimal Price,
decimal PriceSale,
IFormFileCollection? ProductImages,
CategoryId CategoryId = default) : ICommand<Result<ProductId>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Text.Json;
using Ardalis.Result;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using RookieShop.Domain.Entities.ProductAggregator;
using RookieShop.Domain.Entities.ProductAggregator.Primitives;
using RookieShop.Domain.Entities.ProductAggregator.ValueObjects;
using RookieShop.Domain.SharedKernel;
using RookieShop.Infrastructure.GenAi.OpenAi;
using RookieShop.Infrastructure.Storage.Azurite;

namespace RookieShop.Application.Products.Commands.Create;

public sealed class CreateProductHandler(
IRepository<Product> repository,
IOpenAiService aiService,
IAzuriteService azuriteService,
ILogger<CreateProductHandler> logger) : ICommandHandler<CreateProductCommand, Result<ProductId>>
{
public async Task<Result<ProductId>> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var productImages = await UploadProductImagesAsync(request.ProductImages, request.Name, cancellationToken);

var product = Product.Factory.Create(
request.Name,
request.Description,
request.Quantity,
request.Price,
request.PriceSale,
productImages,
request.CategoryId);

product.Embedding =
await aiService.GetEmbeddingAsync($"{product.Name} {product.Description}", cancellationToken);

logger.LogInformation("[{Command}] - Creating product {@Product}", nameof(CreateProductCommand),
JsonSerializer.Serialize(product));

var result = await repository.AddAsync(product, cancellationToken);

return result.Id;
}

private async Task<List<ProductImage>?> UploadProductImagesAsync(
IReadOnlyCollection<IFormFile>? imageFiles,
string productName,
CancellationToken cancellationToken)
{
if (imageFiles is null || imageFiles.Count == 0)
return null;

var productImages = new List<ProductImage>();

foreach (var imageFile in imageFiles)
{
var imageUrl = await azuriteService.UploadFileAsync(imageFile, cancellationToken);

productImages.Add(
productImages.Count == 0
? ProductImage.Create(imageUrl, productName, true)
: ProductImage.Create(imageUrl, productName)
);
}

return productImages;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using FluentValidation;
using RookieShop.Domain.Constants;

namespace RookieShop.Application.Products.Commands.Create;

public sealed class CreateProductValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(DataLength.Medium);

RuleFor(x => x.Description)
.MaximumLength(DataLength.Max);

RuleFor(x => x.Quantity)
.GreaterThanOrEqualTo(0);

RuleFor(x => x.Price)
.GreaterThanOrEqualTo(0);

RuleFor(x => x.PriceSale)
.GreaterThanOrEqualTo(0)
.LessThanOrEqualTo(x => x.Price);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using Ardalis.Result;
using RookieShop.Domain.Entities.ProductAggregator.Primitives;
using RookieShop.Domain.SharedKernel;

namespace RookieShop.Application.Products.Commands.Delete;

public sealed record DeleteProductCommand(ProductId Id) : ICommand<Result>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Ardalis.GuardClauses;
using Ardalis.Result;
using RookieShop.Domain.Entities.ProductAggregator;
using RookieShop.Domain.SharedKernel;

namespace RookieShop.Application.Products.Commands.Delete;

public sealed class DeleteProductHandler(IRepository<Product> repository)
: ICommandHandler<DeleteProductCommand, Result>
{
public async Task<Result> Handle(DeleteProductCommand request, CancellationToken cancellationToken)
{
var product = await repository.GetByIdAsync(request.Id, cancellationToken);

Guard.Against.NotFound(request.Id, product);

product.Delete();

await repository.UpdateAsync(product, cancellationToken);

return Result.Success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentValidation;

namespace RookieShop.Application.Products.Commands.Delete;

public sealed class DeleteProductValidator : AbstractValidator<DeleteProductCommand>
{
public DeleteProductValidator() => RuleFor(x => x.Id).NotEmpty();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Ardalis.Result;
using Microsoft.AspNetCore.Http;
using RookieShop.Application.Products.DTOs;
using RookieShop.Domain.Entities.CategoryAggregator.Primitives;
using RookieShop.Domain.Entities.ProductAggregator.Primitives;
using RookieShop.Domain.SharedKernel;

namespace RookieShop.Application.Products.Commands.Update;

public sealed record UpdateProductCommand(
ProductId Id,
string Name,
string? Description,
int Quantity,
decimal Price,
decimal PriceSale,
IFormFileCollection? Images,
IEnumerable<ProductImageId>? DeleteImageIds,
CategoryId CategoryId = default) : ICommand<Result<ProductDto>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using Ardalis.GuardClauses;
using Ardalis.Result;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using RookieShop.Application.Products.DTOs;
using RookieShop.Domain.Entities.ProductAggregator;
using RookieShop.Domain.Entities.ProductAggregator.Primitives;
using RookieShop.Domain.Entities.ProductAggregator.ValueObjects;
using RookieShop.Domain.SharedKernel;
using RookieShop.Infrastructure.GenAi.OpenAi;
using RookieShop.Infrastructure.Storage.Azurite;

namespace RookieShop.Application.Products.Commands.Update;

public sealed class UpdateProductHandler(
IRepository<Product> repository,
IOpenAiService aiService,
IAzuriteService azuriteService,
ILogger<UpdateProductHandler> logger) : ICommandHandler<UpdateProductCommand, Result<ProductDto>>
{
public async Task<Result<ProductDto>> Handle(UpdateProductCommand request, CancellationToken cancellationToken)
{
var product = await repository.GetByIdAsync(request.Id, cancellationToken);

Guard.Against.NotFound(request.Id, product);

product.Update(
request.Name,
request.Description,
request.Quantity,
request.Price,
request.PriceSale,
request.CategoryId);

product.Embedding =
await aiService.GetEmbeddingAsync($"{product.Name} {product.Description}", cancellationToken);

if (request.DeleteImageIds?.Any() == true && product.ProductImages?.Count != 0)
await DeleteProductImagesAsync(product, request.DeleteImageIds, azuriteService, cancellationToken);

var productImages = await AddProductImagesAsync(request.Images, product.Name, cancellationToken);

product.AddImages(productImages);

logger.LogInformation("Updating product: {ProductId}, {ProductName}", product.Id, product.Name);

await repository.UpdateAsync(product, cancellationToken);

return product.ToProductDto();
}

private async Task<List<ProductImage>?> AddProductImagesAsync(
IReadOnlyCollection<IFormFile>? imageFiles,
string productName,
CancellationToken cancellationToken)
{
if (imageFiles is null || imageFiles.Count == 0)
return null;

var productImages = new List<ProductImage>();

foreach (var imageFile in imageFiles)
{
var imageUrl = await azuriteService.UploadFileAsync(imageFile, cancellationToken);

productImages.Add(
productImages.Count == 0
? ProductImage.Create(imageUrl, productName, true)
: ProductImage.Create(imageUrl, productName)
);
}

return productImages;
}

private static async Task DeleteProductImagesAsync(
Product product,
IEnumerable<ProductImageId> imageIdsToDelete,
IAzuriteService azuriteService,
CancellationToken cancellationToken)
{
foreach (var imageId in imageIdsToDelete)
{
var productImage = product.ProductImages?.FirstOrDefault(x => x.Id == imageId);

if (productImage?.Name is not null)
await azuriteService.DeleteFileAsync(productImage.Name, cancellationToken);

product.DeleteImage(imageId);
}
}
}
Loading

0 comments on commit c15c6c2

Please sign in to comment.