feat: Implement file management and authorization features
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m9s

- Add RequiresPermissionAttribute for gRPC method access control.
- Create IFileManagementService interface for file upload and management.
- Implement AddProductImageCommand and handler for adding product images.
- Implement CreateNewProductsCommand and handler for creating new products with image uploads.
- Implement DeleteProductsCommand and handler for deleting products and their associations.
- Implement RemoveProductImageCommand and handler for removing product images from galleries.
- Implement UpdateProductsCommand and handler for updating product details and images.
- Create GetProductGalleryQuery and handler for retrieving product galleries.
- Implement PermissionService for role-based access control using JWT claims.
- Implement FileManagementService for handling file uploads and image optimization.
- Define gRPC service and messages for file management in fms.proto.
- Add FluentValidation for request validation in various commands.
- Create PermissionInterceptor for enforcing permissions on gRPC methods.
This commit is contained in:
masoodafar-web
2026-02-10 22:04:54 +03:30
parent f64b6be7da
commit b42d9e141d
65 changed files with 3082 additions and 2384 deletions
@@ -0,0 +1,10 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.AddProductImage;
public record AddProductImageCommand : IRequest<AddProductImageResponseDto>
{
public long ProductId { get; init; }
public string Title { get; init; } = string.Empty;
public byte[]? ImageFileBytes { get; init; }
public string? ImageFileMime { get; init; }
public string? ImageFileName { get; init; }
}
@@ -0,0 +1,86 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.ProductsCQ.Commands.AddProductImage;
public class AddProductImageCommandHandler : IRequestHandler<AddProductImageCommand, AddProductImageResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IFileManagementService _fileManagementService;
private readonly ILogger<AddProductImageCommandHandler> _logger;
public AddProductImageCommandHandler(
IApplicationDbContext context,
IFileManagementService fileManagementService,
ILogger<AddProductImageCommandHandler> logger)
{
_context = context;
_fileManagementService = fileManagementService;
_logger = logger;
}
public async Task<AddProductImageResponseDto> Handle(AddProductImageCommand request,
CancellationToken cancellationToken)
{
// Verify product exists
var productExists = await _context.Products
.AnyAsync(p => p.Id == request.ProductId, cancellationToken);
if (!productExists)
throw new NotFoundException(nameof(Product), request.ProductId);
string imagePath = string.Empty;
string thumbnailPath = string.Empty;
// Upload image to FMS
if (request.ImageFileBytes is { Length: > 0 })
{
try
{
var (mainPath, thumbPath) = await _fileManagementService.UploadImageWithThumbnailAsync(
"Images/Products/Gallery",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
imagePath = mainPath ?? string.Empty;
thumbnailPath = thumbPath ?? string.Empty;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to upload gallery image to FMS for product {ProductId}", request.ProductId);
}
}
// Create ProductImage entity
var productImage = new ProductImage
{
Title = request.Title,
ImagePath = imagePath,
ImageThumbnailPath = thumbnailPath
};
await _context.ProductImages.AddAsync(productImage, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// Create ProductGallery join entity
var productGallery = new ProductGallery
{
ProductId = request.ProductId,
ProductImageId = productImage.Id
};
await _context.ProductGalleries.AddAsync(productGallery, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return new AddProductImageResponseDto
{
ProductGalleryId = productGallery.Id,
ProductImageId = productImage.Id,
Title = productImage.Title,
ImagePath = productImage.ImagePath,
ImageThumbnailPath = productImage.ImageThumbnailPath
};
}
}
@@ -0,0 +1,10 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.AddProductImage;
public class AddProductImageResponseDto
{
public long ProductGalleryId { get; set; }
public long ProductImageId { get; set; }
public string Title { get; set; } = string.Empty;
public string ImagePath { get; set; } = string.Empty;
public string ImageThumbnailPath { get; set; } = string.Empty;
}
@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts;
public record CreateNewProductsCommand : IRequest<CreateNewProductsResponseDto>
{
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string ShortInfomation { get; init; } = string.Empty;
public string FullInformation { get; init; } = string.Empty;
public long Price { get; init; }
public int Discount { get; init; }
public int Rate { get; init; }
public string? ImagePath { get; init; }
public string? ThumbnailPath { get; init; }
public int SaleCount { get; init; }
public int ViewCount { get; init; }
public int RemainingCount { get; init; }
public List<long> CategoryIds { get; init; } = new();
// File upload fields (raw bytes from client)
public byte[]? ImageFileBytes { get; init; }
public string? ImageFileMime { get; init; }
public string? ImageFileName { get; init; }
public byte[]? ThumbnailFileBytes { get; init; }
public string? ThumbnailFileMime { get; init; }
public string? ThumbnailFileName { get; init; }
}
@@ -0,0 +1,112 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts;
public class CreateNewProductsCommandHandler : IRequestHandler<CreateNewProductsCommand, CreateNewProductsResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IFileManagementService _fileManagementService;
private readonly ILogger<CreateNewProductsCommandHandler> _logger;
public CreateNewProductsCommandHandler(
IApplicationDbContext context,
IFileManagementService fileManagementService,
ILogger<CreateNewProductsCommandHandler> logger)
{
_context = context;
_fileManagementService = fileManagementService;
_logger = logger;
}
public async Task<CreateNewProductsResponseDto> Handle(CreateNewProductsCommand request,
CancellationToken cancellationToken)
{
var entity = new Product
{
Title = request.Title,
Description = request.Description,
ShortInfomation = request.ShortInfomation,
FullInformation = request.FullInformation,
Price = request.Price,
Discount = request.Discount,
Rate = request.Rate,
ImagePath = request.ImagePath ?? string.Empty,
ThumbnailPath = request.ThumbnailPath ?? string.Empty,
SaleCount = request.SaleCount,
ViewCount = request.ViewCount,
RemainingCount = request.RemainingCount
};
// Handle image upload to FMS if file bytes provided
if (request.ImageFileBytes is { Length: > 0 })
{
try
{
var (mainPath, thumbPath) = await _fileManagementService.UploadImageWithThumbnailAsync(
"Images/Products",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
if (!string.IsNullOrWhiteSpace(mainPath))
entity.ImagePath = mainPath;
if (!string.IsNullOrWhiteSpace(thumbPath))
entity.ThumbnailPath = thumbPath;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to upload product image to FMS");
}
}
// Handle separate thumbnail upload if provided (and not already set from main image)
if (request.ThumbnailFileBytes is { Length: > 0 } && string.IsNullOrWhiteSpace(entity.ThumbnailPath))
{
try
{
var thumbPath = await _fileManagementService.UploadFileAsync(
"Images/Products/Thumbnails",
request.ThumbnailFileBytes,
request.ThumbnailFileMime ?? "image/jpeg",
request.ThumbnailFileName,
cancellationToken);
if (!string.IsNullOrWhiteSpace(thumbPath))
entity.ThumbnailPath = thumbPath;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to upload product thumbnail to FMS");
}
}
await _context.Products.AddAsync(entity, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// Handle category assignments
if (request.CategoryIds is { Count: > 0 })
{
foreach (var categoryId in request.CategoryIds)
{
var categoryExists = await _context.Categories
.AnyAsync(c => c.Id == categoryId, cancellationToken);
if (categoryExists)
{
await _context.ProductCategories.AddAsync(new ProductCategory
{
ProductId = entity.Id,
CategoryId = categoryId
}, cancellationToken);
}
}
await _context.SaveChangesAsync(cancellationToken);
}
return new CreateNewProductsResponseDto { Id = entity.Id };
}
}
@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts;
public class CreateNewProductsCommandValidator : AbstractValidator<CreateNewProductsCommand>
{
public CreateNewProductsCommandValidator()
{
RuleFor(model => model.Title)
.NotEmpty().WithMessage("عنوان محصول الزامی است");
RuleFor(model => model.Price)
.GreaterThanOrEqualTo(0).WithMessage("قیمت نمی‌تواند منفی باشد");
RuleFor(model => model.Discount)
.InclusiveBetween(0, 100).WithMessage("تخفیف باید بین 0 تا 100 باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<CreateNewProductsCommand>.CreateWithOptions(
(CreateNewProductsCommand)model, x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts;
public class CreateNewProductsResponseDto
{
public long Id { get; set; }
}
@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.DeleteProducts;
public record DeleteProductsCommand : IRequest<Unit>
{
public long Id { get; init; }
}
@@ -0,0 +1,38 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
namespace CMSMicroservice.Application.ProductsCQ.Commands.DeleteProducts;
public class DeleteProductsCommandHandler : IRequestHandler<DeleteProductsCommand, Unit>
{
private readonly IApplicationDbContext _context;
public DeleteProductsCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(DeleteProductsCommand request, CancellationToken cancellationToken)
{
var entity = await _context.Products
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken)
?? throw new NotFoundException(nameof(Product), request.Id);
// Remove category associations
var productCategories = await _context.ProductCategories
.Where(pc => pc.ProductId == entity.Id)
.ToListAsync(cancellationToken);
_context.ProductCategories.RemoveRange(productCategories);
// Remove gallery associations
var productGalleries = await _context.ProductGalleries
.Where(pg => pg.ProductId == entity.Id)
.ToListAsync(cancellationToken);
_context.ProductGalleries.RemoveRange(productGalleries);
_context.Products.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.RemoveProductImage;
public record RemoveProductImageCommand : IRequest<Unit>
{
public long ProductGalleryId { get; init; }
}
@@ -0,0 +1,40 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
namespace CMSMicroservice.Application.ProductsCQ.Commands.RemoveProductImage;
public class RemoveProductImageCommandHandler : IRequestHandler<RemoveProductImageCommand, Unit>
{
private readonly IApplicationDbContext _context;
public RemoveProductImageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(RemoveProductImageCommand request, CancellationToken cancellationToken)
{
var gallery = await _context.ProductGalleries
.Include(pg => pg.ProductImage)
.FirstOrDefaultAsync(pg => pg.Id == request.ProductGalleryId, cancellationToken)
?? throw new NotFoundException(nameof(ProductGallery), request.ProductGalleryId);
// Remove gallery entry
_context.ProductGalleries.Remove(gallery);
// Remove the product image if it exists and is not referenced by other galleries
if (gallery.ProductImage != null)
{
var otherReferences = await _context.ProductGalleries
.AnyAsync(pg => pg.ProductImageId == gallery.ProductImageId && pg.Id != gallery.Id, cancellationToken);
if (!otherReferences)
{
_context.ProductImages.Remove(gallery.ProductImage);
}
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,27 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts;
public record UpdateProductsCommand : IRequest<Unit>
{
public long Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string ShortInfomation { get; init; } = string.Empty;
public string FullInformation { get; init; } = string.Empty;
public long Price { get; init; }
public int Discount { get; init; }
public int Rate { get; init; }
public string? ImagePath { get; init; }
public string? ThumbnailPath { get; init; }
public int SaleCount { get; init; }
public int ViewCount { get; init; }
public int RemainingCount { get; init; }
public List<long> CategoryIds { get; init; } = new();
// File upload fields (raw bytes from client)
public byte[]? ImageFileBytes { get; init; }
public string? ImageFileMime { get; init; }
public string? ImageFileName { get; init; }
public byte[]? ThumbnailFileBytes { get; init; }
public string? ThumbnailFileMime { get; init; }
public string? ThumbnailFileName { get; init; }
}
@@ -0,0 +1,127 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts;
public class UpdateProductsCommandHandler : IRequestHandler<UpdateProductsCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly IFileManagementService _fileManagementService;
private readonly ILogger<UpdateProductsCommandHandler> _logger;
public UpdateProductsCommandHandler(
IApplicationDbContext context,
IFileManagementService fileManagementService,
ILogger<UpdateProductsCommandHandler> logger)
{
_context = context;
_fileManagementService = fileManagementService;
_logger = logger;
}
public async Task<Unit> Handle(UpdateProductsCommand request, CancellationToken cancellationToken)
{
var entity = await _context.Products
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken)
?? throw new NotFoundException(nameof(Product), request.Id);
// Update basic properties
entity.Title = request.Title;
entity.Description = request.Description;
entity.ShortInfomation = request.ShortInfomation;
entity.FullInformation = request.FullInformation;
entity.Price = request.Price;
entity.Discount = request.Discount;
entity.Rate = request.Rate;
entity.SaleCount = request.SaleCount;
entity.ViewCount = request.ViewCount;
entity.RemainingCount = request.RemainingCount;
// Handle image upload to FMS if new file bytes provided
if (request.ImageFileBytes is { Length: > 0 })
{
try
{
var (mainPath, thumbPath) = await _fileManagementService.UploadImageWithThumbnailAsync(
"Images/Products",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
if (!string.IsNullOrWhiteSpace(mainPath))
entity.ImagePath = mainPath;
if (!string.IsNullOrWhiteSpace(thumbPath))
entity.ThumbnailPath = thumbPath;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to upload updated product image to FMS for product {ProductId}", request.Id);
}
}
else
{
// If no new file uploaded, keep existing paths or update from request
if (!string.IsNullOrWhiteSpace(request.ImagePath))
entity.ImagePath = request.ImagePath;
if (!string.IsNullOrWhiteSpace(request.ThumbnailPath))
entity.ThumbnailPath = request.ThumbnailPath;
}
// Handle separate thumbnail upload if provided
if (request.ThumbnailFileBytes is { Length: > 0 })
{
try
{
var thumbPath = await _fileManagementService.UploadFileAsync(
"Images/Products/Thumbnails",
request.ThumbnailFileBytes,
request.ThumbnailFileMime ?? "image/jpeg",
request.ThumbnailFileName,
cancellationToken);
if (!string.IsNullOrWhiteSpace(thumbPath))
entity.ThumbnailPath = thumbPath;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to upload updated product thumbnail to FMS for product {ProductId}", request.Id);
}
}
_context.Products.Update(entity);
await _context.SaveChangesAsync(cancellationToken);
// Update category assignments
if (request.CategoryIds != null)
{
// Remove existing categories
var existingCategories = await _context.ProductCategories
.Where(pc => pc.ProductId == entity.Id)
.ToListAsync(cancellationToken);
_context.ProductCategories.RemoveRange(existingCategories);
// Add new categories
foreach (var categoryId in request.CategoryIds)
{
var categoryExists = await _context.Categories
.AnyAsync(c => c.Id == categoryId, cancellationToken);
if (categoryExists)
{
await _context.ProductCategories.AddAsync(new ProductCategory
{
ProductId = entity.Id,
CategoryId = categoryId
}, cancellationToken);
}
}
await _context.SaveChangesAsync(cancellationToken);
}
return Unit.Value;
}
}
@@ -0,0 +1,29 @@
namespace CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts;
public class UpdateProductsCommandValidator : AbstractValidator<UpdateProductsCommand>
{
public UpdateProductsCommandValidator()
{
RuleFor(model => model.Id)
.NotNull().WithMessage("شناسه محصول الزامی است");
RuleFor(model => model.Title)
.NotEmpty().WithMessage("عنوان محصول الزامی است");
RuleFor(model => model.Price)
.GreaterThanOrEqualTo(0).WithMessage("قیمت نمی‌تواند منفی باشد");
RuleFor(model => model.Discount)
.InclusiveBetween(0, 100).WithMessage("تخفیف باید بین 0 تا 100 باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<UpdateProductsCommand>.CreateWithOptions(
(UpdateProductsCommand)model, x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -24,7 +24,7 @@ public class GetCustomerProductsQueryHandler : IRequestHandler<GetCustomerProduc
.FirstOrDefaultAsync(cancellationToken);
if (product == null)
throw new NotFoundException(nameof(Products), request.Id);
throw new NotFoundException(nameof(Product), request.Id);
var response = new GetCustomerProductsResponseDto
{
@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery;
public class GetProductGalleryQuery : IRequest<GetProductGalleryResponseDto>
{
public long ProductId { get; set; }
}
@@ -0,0 +1,34 @@
using CMSMicroservice.Application.Common.Interfaces;
namespace CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery;
public class GetProductGalleryQueryHandler : IRequestHandler<GetProductGalleryQuery, GetProductGalleryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetProductGalleryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetProductGalleryResponseDto> Handle(GetProductGalleryQuery request, CancellationToken cancellationToken)
{
var galleries = await _context.ProductGalleries
.AsNoTracking()
.Where(pg => pg.ProductId == request.ProductId)
.Include(pg => pg.ProductImage)
.ToListAsync(cancellationToken);
return new GetProductGalleryResponseDto
{
Items = galleries.Select(pg => new ProductGalleryItemDto
{
ProductGalleryId = pg.Id,
ProductImageId = pg.ProductImageId,
Title = pg.ProductImage?.Title ?? string.Empty,
ImagePath = pg.ProductImage?.ImagePath ?? string.Empty,
ImageThumbnailPath = pg.ProductImage?.ImageThumbnailPath ?? string.Empty
}).ToList()
};
}
}
@@ -0,0 +1,15 @@
namespace CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery;
public class GetProductGalleryResponseDto
{
public List<ProductGalleryItemDto> Items { get; set; } = new();
}
public class ProductGalleryItemDto
{
public long ProductGalleryId { get; set; }
public long ProductImageId { get; set; }
public string Title { get; set; } = string.Empty;
public string ImagePath { get; set; } = string.Empty;
public string ImageThumbnailPath { get; set; } = string.Empty;
}