feat: Implement file management and authorization features
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m9s
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:
@@ -0,0 +1,17 @@
|
||||
namespace CMSMicroservice.Application.Common.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس بررسی مجوز کاربر بر اساس نقشهای JWT
|
||||
/// </summary>
|
||||
public interface IPermissionService
|
||||
{
|
||||
/// <summary>
|
||||
/// دریافت نقشهای کاربر فعلی از JWT Claims
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetUserRolesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// بررسی اینکه آیا کاربر فعلی مجوز مشخصی دارد
|
||||
/// </summary>
|
||||
Task<bool> HasPermissionAsync(string permission, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
namespace CMSMicroservice.Application.Common.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// ثوابت نام مجوزها — دستهبندی شده بر اساس حوزه
|
||||
/// </summary>
|
||||
public static class PermissionNames
|
||||
{
|
||||
// Dashboard
|
||||
public const string DashboardView = "dashboard.view";
|
||||
|
||||
// Orders
|
||||
public const string OrdersView = "orders.view";
|
||||
public const string OrdersCreate = "orders.create";
|
||||
public const string OrdersUpdate = "orders.update";
|
||||
public const string OrdersDelete = "orders.delete";
|
||||
public const string OrdersCancel = "orders.cancel";
|
||||
public const string OrdersApprove = "orders.approve";
|
||||
|
||||
// Products
|
||||
public const string ProductsView = "products.view";
|
||||
public const string ProductsCreate = "products.create";
|
||||
public const string ProductsUpdate = "products.update";
|
||||
public const string ProductsDelete = "products.delete";
|
||||
|
||||
// Users
|
||||
public const string UsersView = "users.view";
|
||||
public const string UsersUpdate = "users.update";
|
||||
public const string UsersDelete = "users.delete";
|
||||
|
||||
// Commission
|
||||
public const string CommissionView = "commission.view";
|
||||
public const string CommissionApproveWithdrawal = "commission.approve_withdrawal";
|
||||
|
||||
// Public Messages
|
||||
public const string PublicMessagesView = "publicmessages.view";
|
||||
public const string PublicMessagesCreate = "publicmessages.create";
|
||||
public const string PublicMessagesUpdate = "publicmessages.update";
|
||||
public const string PublicMessagesPublish = "publicmessages.publish";
|
||||
|
||||
// Manual Payments
|
||||
public const string ManualPaymentsView = "manualpayments.view";
|
||||
public const string ManualPaymentsCreate = "manualpayments.create";
|
||||
public const string ManualPaymentsApprove = "manualpayments.approve";
|
||||
|
||||
// Settings
|
||||
public const string SettingsView = "settings.view";
|
||||
public const string SettingsUpdate = "settings.update";
|
||||
public const string SettingsDelete = "settings.delete";
|
||||
public const string SettingsManageConfiguration = "settings.manage_configuration";
|
||||
public const string SettingsManageVat = "settings.manage_vat";
|
||||
|
||||
// Reports
|
||||
public const string ReportsView = "reports.view";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// نام نقشها
|
||||
/// </summary>
|
||||
public static class RoleNames
|
||||
{
|
||||
public const string SuperAdmin = "Administrator";
|
||||
public const string Admin = "Admin";
|
||||
public const string Inspector = "Inspector";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// تنظیمات نقش→مجوز — ماتریس دسترسی
|
||||
/// </summary>
|
||||
public static class RolePermissionConfig
|
||||
{
|
||||
private static readonly Dictionary<string, HashSet<string>> RolePermissions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[RoleNames.SuperAdmin] = new(StringComparer.OrdinalIgnoreCase) { "*" }, // Full access
|
||||
|
||||
[RoleNames.Admin] = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
PermissionNames.DashboardView,
|
||||
PermissionNames.OrdersView,
|
||||
PermissionNames.OrdersCreate,
|
||||
PermissionNames.OrdersUpdate,
|
||||
PermissionNames.OrdersCancel,
|
||||
PermissionNames.ProductsView,
|
||||
PermissionNames.ProductsCreate,
|
||||
PermissionNames.ProductsUpdate,
|
||||
PermissionNames.ProductsDelete,
|
||||
PermissionNames.UsersView,
|
||||
PermissionNames.UsersUpdate,
|
||||
PermissionNames.CommissionView,
|
||||
PermissionNames.CommissionApproveWithdrawal,
|
||||
PermissionNames.PublicMessagesView,
|
||||
PermissionNames.PublicMessagesCreate,
|
||||
PermissionNames.PublicMessagesUpdate,
|
||||
PermissionNames.PublicMessagesPublish,
|
||||
PermissionNames.ManualPaymentsView,
|
||||
PermissionNames.ManualPaymentsCreate,
|
||||
PermissionNames.ReportsView
|
||||
},
|
||||
|
||||
[RoleNames.Inspector] = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
PermissionNames.DashboardView,
|
||||
PermissionNames.OrdersView,
|
||||
PermissionNames.UsersView,
|
||||
PermissionNames.CommissionView,
|
||||
PermissionNames.PublicMessagesView,
|
||||
PermissionNames.ReportsView
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// بررسی اینکه آیا نقش مشخصی مجوز خاصی دارد
|
||||
/// </summary>
|
||||
public static bool HasPermission(string role, string permission)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(role) || string.IsNullOrWhiteSpace(permission))
|
||||
return false;
|
||||
|
||||
if (!RolePermissions.TryGetValue(role, out var permissions))
|
||||
return false;
|
||||
|
||||
// Wildcard: SuperAdmin has full access
|
||||
if (permissions.Contains("*"))
|
||||
return true;
|
||||
|
||||
return permissions.Contains(permission);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace CMSMicroservice.Application.Common.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute برای مشخص کردن مجوز لازم برای دسترسی به یک متد gRPC
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
||||
public sealed class RequiresPermissionAttribute : Attribute
|
||||
{
|
||||
public RequiresPermissionAttribute(string permission)
|
||||
{
|
||||
Permission = permission ?? throw new ArgumentNullException(nameof(permission));
|
||||
}
|
||||
|
||||
public string Permission { get; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Service for uploading files to FMS (File Management Service)
|
||||
/// </summary>
|
||||
public interface IFileManagementService
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads a file to FMS and returns the stored file path
|
||||
/// </summary>
|
||||
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
|
||||
/// <param name="fileBytes">Raw file bytes</param>
|
||||
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
|
||||
/// <param name="fileName">Original file name</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The stored file path returned by FMS, or null if upload failed</returns>
|
||||
Task<string?> UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads an image to FMS with optimization (resize + compress)
|
||||
/// Returns both main image path and thumbnail path
|
||||
/// </summary>
|
||||
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
|
||||
/// <param name="fileBytes">Raw image bytes</param>
|
||||
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
|
||||
/// <param name="fileName">Original file name</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Tuple of (mainImagePath, thumbnailPath), either can be null if upload failed</returns>
|
||||
Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync(
|
||||
string directory, byte[] fileBytes, string mime, string? fileName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file from FMS by its ID
|
||||
/// </summary>
|
||||
Task<bool> DeleteFileAsync(long fileId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
+1
-1
@@ -57,7 +57,7 @@ public class CreateNewOtpTokenCommandHandler : IRequestHandler<CreateNewOtpToken
|
||||
|
||||
};
|
||||
await _context.OtpTokens.AddAsync(entity, cancellationToken);
|
||||
entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity));
|
||||
entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity, code));
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return new CreateNewOtpTokenResponseDto()
|
||||
{
|
||||
|
||||
+18
-4
@@ -1,3 +1,4 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Events;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -6,16 +7,29 @@ namespace CMSMicroservice.Application.OtpTokenCQ.EventHandlers;
|
||||
public class CreateNewOtpTokenEventHandler : INotificationHandler<CreateNewOtpTokenEvent>
|
||||
{
|
||||
private readonly ILogger<CreateNewOtpTokenEventHandler> _logger;
|
||||
private readonly IKavenegarService _kavenegarService;
|
||||
|
||||
public CreateNewOtpTokenEventHandler(ILogger<CreateNewOtpTokenEventHandler> logger)
|
||||
public CreateNewOtpTokenEventHandler(
|
||||
ILogger<CreateNewOtpTokenEventHandler> logger,
|
||||
IKavenegarService kavenegarService)
|
||||
{
|
||||
_logger = logger;
|
||||
_kavenegarService = kavenegarService;
|
||||
}
|
||||
|
||||
public Task Handle(CreateNewOtpTokenEvent notification, CancellationToken cancellationToken)
|
||||
public async Task Handle(CreateNewOtpTokenEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Domain Event: {DomainEvent}", notification.GetType().Name);
|
||||
_logger.LogInformation("Domain Event: {DomainEvent} for mobile {Mobile}",
|
||||
notification.GetType().Name, notification.Item.Mobile);
|
||||
|
||||
return Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
await _kavenegarService.VerifyLookupAsync(notification.Item.Mobile, notification.PlainCode);
|
||||
_logger.LogInformation("OTP SMS sent successfully to {Mobile}", notification.Item.Mobile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send OTP SMS to {Mobile}", notification.Item.Mobile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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; }
|
||||
}
|
||||
+86
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
+10
@@ -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;
|
||||
}
|
||||
+26
@@ -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; }
|
||||
}
|
||||
+112
@@ -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 };
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
};
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts;
|
||||
|
||||
public class CreateNewProductsResponseDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace CMSMicroservice.Application.ProductsCQ.Commands.DeleteProducts;
|
||||
|
||||
public record DeleteProductsCommand : IRequest<Unit>
|
||||
{
|
||||
public long Id { get; init; }
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace CMSMicroservice.Application.ProductsCQ.Commands.RemoveProductImage;
|
||||
|
||||
public record RemoveProductImageCommand : IRequest<Unit>
|
||||
{
|
||||
public long ProductGalleryId { get; init; }
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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; }
|
||||
}
|
||||
+127
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace CMSMicroservice.Application.ProductsCQ.Queries.GetProductGallery;
|
||||
|
||||
public class GetProductGalleryQuery : IRequest<GetProductGalleryResponseDto>
|
||||
{
|
||||
public long ProductId { get; set; }
|
||||
}
|
||||
+34
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+22
-6
@@ -1,5 +1,6 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
|
||||
namespace CMSMicroservice.Application.UserCQ.Commands.AcceptContract;
|
||||
@@ -7,25 +8,38 @@ public class AcceptContractCommandHandler : IRequestHandler<AcceptContractComman
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IHashService _hashService;
|
||||
private readonly IGenerateJwtToken _generateJwt;
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public AcceptContractCommandHandler(IApplicationDbContext context, ICurrentUserService currentUserService)
|
||||
public AcceptContractCommandHandler(IApplicationDbContext context, ICurrentUserService currentUserService,
|
||||
IHashService hashService, IGenerateJwtToken generateJwt, IConfiguration cfg)
|
||||
{
|
||||
_context = context;
|
||||
_currentUserService = currentUserService;
|
||||
_hashService = hashService;
|
||||
_generateJwt = generateJwt;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
public async Task<AcceptContractResponseDto> Handle(AcceptContractCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Verify OTP first
|
||||
var otpToken = await _context.OtpTokens
|
||||
.Where(x => x.Mobile == _currentUserService.Username && x.Purpose == "signContract" && !x.IsUsed && x.Code == request.Code)
|
||||
.OrderByDescending(x => x.Id) // Use Id instead of CreatedAt for now
|
||||
.Where(x => x.Mobile == _currentUserService.Username && x.Purpose == "signContract" && !x.IsUsed)
|
||||
.OrderByDescending(x => x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (otpToken == null || !otpToken.IsValid(request.Code))
|
||||
var secret = _cfg["Otp:Secret"] ?? throw new InvalidOperationException("Otp:Secret not set");
|
||||
if (otpToken == null || !otpToken.IsValid() || !_hashService.VerifyHmacSha256Hex(request.Code, otpToken.CodeHash, secret))
|
||||
return new AcceptContractResponseDto { IsSuccess = false, Message = "کد تایید نامعتبر است" };
|
||||
|
||||
var user = await _context.Users
|
||||
.Include(u => u.UserContracts)
|
||||
.ThenInclude(uc => uc.Contract)
|
||||
.Include(u => u.UserRoles)
|
||||
.ThenInclude(ur => ur.Role)
|
||||
.Include(u => u.ClubMembership)
|
||||
.Where(x => x.Mobile == _currentUserService.Username)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
@@ -48,12 +62,14 @@ public class AcceptContractCommandHandler : IRequestHandler<AcceptContractComman
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// TODO: Implement JWT token generation
|
||||
// Generate JWT token with updated contract status
|
||||
var token = await _generateJwt.GenerateJwtToken(user);
|
||||
|
||||
return new AcceptContractResponseDto
|
||||
{
|
||||
IsSuccess = true,
|
||||
Message = "قرارداد با موفقیت تایید شد",
|
||||
Token = "TODO_IMPLEMENT_JWT_GENERATION"
|
||||
Token = token
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -1,26 +1,41 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace CMSMicroservice.Application.UserCQ.Commands.VerifyOtpToken;
|
||||
public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenCommand, VerifyOtpTokenResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IGenerateJwtToken _generateJwt;
|
||||
private readonly IHashService _hashService;
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public VerifyOtpTokenCommandHandler(IApplicationDbContext context, IGenerateJwtToken generateJwt)
|
||||
public VerifyOtpTokenCommandHandler(IApplicationDbContext context, IGenerateJwtToken generateJwt,
|
||||
IHashService hashService, IConfiguration cfg)
|
||||
{
|
||||
_context = context;
|
||||
_generateJwt = generateJwt;
|
||||
_hashService = hashService;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
public async Task<VerifyOtpTokenResponseDto> Handle(VerifyOtpTokenCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var otpToken = await _context.OtpTokens
|
||||
.Where(x => x.Mobile == request.Mobile && x.Purpose == request.Purpose && !x.IsUsed)
|
||||
.OrderByDescending(x => x.Id) // Use Id instead of CreatedAt for now
|
||||
.OrderByDescending(x => x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (otpToken == null || !otpToken.IsValid(request.Code))
|
||||
if (otpToken == null)
|
||||
return new VerifyOtpTokenResponseDto { Success = false, Message = "کد تایید نامعتبر است" };
|
||||
|
||||
// Check expiry and usage
|
||||
if (otpToken.IsUsed || DateTime.Now > otpToken.ExpiresAt)
|
||||
return new VerifyOtpTokenResponseDto { Success = false, Message = "کد تایید منقضی شده است" };
|
||||
|
||||
// Verify using the same HMAC-SHA256 method used during creation
|
||||
var secret = _cfg["Otp:Secret"] ?? throw new InvalidOperationException("Otp:Secret not set");
|
||||
if (!_hashService.VerifyHmacSha256Hex(request.Code, otpToken.CodeHash, secret))
|
||||
return new VerifyOtpTokenResponseDto { Success = false, Message = "کد تایید نامعتبر است" };
|
||||
|
||||
var user = await _context.Users
|
||||
|
||||
Reference in New Issue
Block a user