feat: integrate PYMS payment gateway, add blog/sitepage/image services, local file manager
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m44s

Payment Gateway:
- Add PYMSPaymentService: IPaymentGatewayService via gRPC to PYMS microservice
- Add ZarinPalPaymentService: direct ZarinPal integration (backup)
- Register 'pyms' payment provider in DI ConfigureServices
- Add PYMS proto files (pyms_transaction.proto, pyms_public_messages.proto)
- Fix VerifyDiscountWalletCharge: pass 'OK' as status instead of Authority
- Update appsettings: PaymentProvider=pyms, sandbox mode, merchant ID

Blog System:
- Add BlogCategory, BlogPost, BlogPostImage entities and CQRS
- Add proto files and gRPC services for blog management
- Add Mapster profiles for blog responses

Content Management:
- Add SitePage entity and CQRS for static pages
- Add proto and gRPC service for site pages

Image/File Management:
- Add LocalFileManager with disk storage + base64 serving + FMS fallback
- Add ImagePathResolverInterceptor for gRPC responses
- Add ImageResolverService for explicit image resolution
- Add UploadsController for public file serving with FMS fallback
- Add PaymentCallbackController for discount order payment callbacks

Database:
- Add blog and content entity migrations
- Remove ImagePath MaxLength constraints
- Remove old FileManagementService (replaced by LocalFileManager)
This commit is contained in:
masoodafar-web
2026-02-15 23:01:16 +03:30
parent 5a4e4a960d
commit 2502cbbda2
177 changed files with 16632 additions and 487 deletions
@@ -0,0 +1,13 @@
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
public class CreateBlogCategoryCommand : IRequest<long>
{
public string Title { get; set; } = default!;
public string Slug { get; set; } = default!;
public string? Description { get; set; }
public string? IconName { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,33 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
public class CreateBlogCategoryCommandHandler : IRequestHandler<CreateBlogCategoryCommand, long>
{
private readonly IApplicationDbContext _context;
public CreateBlogCategoryCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<long> Handle(CreateBlogCategoryCommand request, CancellationToken cancellationToken)
{
var entity = new BlogCategory
{
Title = request.Title,
Slug = request.Slug.ToLower(),
Description = request.Description,
IconName = request.IconName,
SortOrder = request.SortOrder,
IsActive = request.IsActive
};
_context.BlogCategories.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}
@@ -0,0 +1,24 @@
using FluentValidation;
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
public class CreateBlogCategoryCommandValidator : AbstractValidator<CreateBlogCategoryCommand>
{
public CreateBlogCategoryCommandValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("عنوان دسته‌بندی الزامی است")
.MaximumLength(200).WithMessage("عنوان دسته‌بندی حداکثر ۲۰۰ کاراکتر");
RuleFor(x => x.Slug)
.NotEmpty().WithMessage("اسلاگ الزامی است")
.MaximumLength(200).WithMessage("اسلاگ حداکثر ۲۰۰ کاراکتر")
.Matches(@"^[a-z0-9\-]+$").WithMessage("اسلاگ فقط شامل حروف کوچک، اعداد و خط تیره");
RuleFor(x => x.Description)
.MaximumLength(1000).WithMessage("توضیحات حداکثر ۱۰۰۰ کاراکتر");
RuleFor(x => x.IconName)
.MaximumLength(100).WithMessage("نام آیکون حداکثر ۱۰۰ کاراکتر");
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.DeleteBlogCategory;
public class DeleteBlogCategoryCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -0,0 +1,29 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.DeleteBlogCategory;
public class DeleteBlogCategoryCommandHandler : IRequestHandler<DeleteBlogCategoryCommand, Unit>
{
private readonly IApplicationDbContext _context;
public DeleteBlogCategoryCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(DeleteBlogCategoryCommand request, CancellationToken cancellationToken)
{
var entity = await _context.BlogCategories.FindAsync(new object[] { request.Id }, cancellationToken);
if (entity == null || entity.IsDeleted)
throw new NotFoundException(nameof(BlogCategory), request.Id);
entity.IsDeleted = true;
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,14 @@
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
public class UpdateBlogCategoryCommand : IRequest<Unit>
{
public long Id { get; set; }
public string Title { get; set; } = default!;
public string Slug { get; set; } = default!;
public string? Description { get; set; }
public string? IconName { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
@@ -0,0 +1,34 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
public class UpdateBlogCategoryCommandHandler : IRequestHandler<UpdateBlogCategoryCommand, Unit>
{
private readonly IApplicationDbContext _context;
public UpdateBlogCategoryCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(UpdateBlogCategoryCommand request, CancellationToken cancellationToken)
{
var entity = await _context.BlogCategories.FindAsync(new object[] { request.Id }, cancellationToken);
if (entity == null || entity.IsDeleted)
throw new NotFoundException(nameof(BlogCategory), request.Id);
entity.Title = request.Title;
entity.Slug = request.Slug.ToLower();
entity.Description = request.Description;
entity.IconName = request.IconName;
entity.SortOrder = request.SortOrder;
entity.IsActive = request.IsActive;
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,27 @@
using FluentValidation;
namespace CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
public class UpdateBlogCategoryCommandValidator : AbstractValidator<UpdateBlogCategoryCommand>
{
public UpdateBlogCategoryCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه دسته‌بندی نامعتبر است");
RuleFor(x => x.Title)
.NotEmpty().WithMessage("عنوان دسته‌بندی الزامی است")
.MaximumLength(200).WithMessage("عنوان دسته‌بندی حداکثر ۲۰۰ کاراکتر");
RuleFor(x => x.Slug)
.NotEmpty().WithMessage("اسلاگ الزامی است")
.MaximumLength(200).WithMessage("اسلاگ حداکثر ۲۰۰ کاراکتر")
.Matches(@"^[a-z0-9\-]+$").WithMessage("اسلاگ فقط شامل حروف کوچک، اعداد و خط تیره");
RuleFor(x => x.Description)
.MaximumLength(1000).WithMessage("توضیحات حداکثر ۱۰۰۰ کاراکتر");
RuleFor(x => x.IconName)
.MaximumLength(100).WithMessage("نام آیکون حداکثر ۱۰۰ کاراکتر");
}
}
@@ -0,0 +1,45 @@
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetActiveBlogCategories;
public class GetActiveBlogCategoriesQuery : IRequest<List<BlogCategoryDto>>
{
}
public class GetActiveBlogCategoriesQueryHandler : IRequestHandler<GetActiveBlogCategoriesQuery, List<BlogCategoryDto>>
{
private readonly IApplicationDbContext _context;
public GetActiveBlogCategoriesQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<BlogCategoryDto>> Handle(GetActiveBlogCategoriesQuery request, CancellationToken cancellationToken)
{
var categories = await _context.BlogCategories
.Include(x => x.BlogPostCategories)
.Where(x => !x.IsDeleted && x.IsActive)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Title)
.Select(x => new BlogCategoryDto
{
Id = x.Id,
Title = x.Title,
Slug = x.Slug,
Description = x.Description,
IconName = x.IconName,
SortOrder = x.SortOrder,
IsActive = x.IsActive,
PostCount = x.BlogPostCategories.Count,
Created = x.Created,
LastModified = x.LastModified
})
.ToListAsync(cancellationToken);
return categories;
}
}
@@ -0,0 +1,11 @@
using CMSMicroservice.Application.Common.Models;
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
public class GetAllBlogCategoriesQuery : IRequest<GetAllBlogCategoriesResponseDto>
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 20;
public string? SearchTerm { get; set; }
}
@@ -0,0 +1,64 @@
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
public class GetAllBlogCategoriesQueryHandler : IRequestHandler<GetAllBlogCategoriesQuery, GetAllBlogCategoriesResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllBlogCategoriesQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllBlogCategoriesResponseDto> Handle(GetAllBlogCategoriesQuery request, CancellationToken cancellationToken)
{
var query = _context.BlogCategories
.Include(x => x.BlogPostCategories)
.Where(x => !x.IsDeleted);
if (!string.IsNullOrEmpty(request.SearchTerm))
{
var term = request.SearchTerm.ToLower();
query = query.Where(x => x.Title.ToLower().Contains(term) || x.Slug.ToLower().Contains(term));
}
var totalCount = await query.CountAsync(cancellationToken);
var categories = await query
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Title)
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Select(x => new BlogCategoryDto
{
Id = x.Id,
Title = x.Title,
Slug = x.Slug,
Description = x.Description,
IconName = x.IconName,
SortOrder = x.SortOrder,
IsActive = x.IsActive,
PostCount = x.BlogPostCategories.Count,
Created = x.Created,
LastModified = x.LastModified
})
.ToListAsync(cancellationToken);
var metaData = new MetaData
{
TotalCount = totalCount,
PageSize = request.PageSize,
CurrentPage = request.PageNumber,
TotalPage = (int)Math.Ceiling(totalCount / (double)request.PageSize),
HasNext = request.PageNumber < (int)Math.Ceiling(totalCount / (double)request.PageSize),
HasPrevious = request.PageNumber > 1
};
return new GetAllBlogCategoriesResponseDto { MetaData = metaData, Models = categories };
}
}
@@ -0,0 +1,10 @@
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
using CMSMicroservice.Application.Common.Models;
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
public class GetAllBlogCategoriesResponseDto
{
public MetaData MetaData { get; set; } = default!;
public List<BlogCategoryDto> Models { get; set; } = new();
}
@@ -0,0 +1,15 @@
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
public class BlogCategoryDto
{
public long Id { get; set; }
public string Title { get; set; } = default!;
public string Slug { get; set; } = default!;
public string? Description { get; set; }
public string? IconName { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public int PostCount { get; set; }
public DateTime Created { get; set; }
public DateTime? LastModified { get; set; }
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
public class GetBlogCategoryQuery : IRequest<BlogCategoryDto>
{
public long Id { get; set; }
}
@@ -0,0 +1,41 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
public class GetBlogCategoryQueryHandler : IRequestHandler<GetBlogCategoryQuery, BlogCategoryDto>
{
private readonly IApplicationDbContext _context;
public GetBlogCategoryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<BlogCategoryDto> Handle(GetBlogCategoryQuery request, CancellationToken cancellationToken)
{
var entity = await _context.BlogCategories
.Include(x => x.BlogPostCategories)
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
if (entity == null)
throw new NotFoundException(nameof(BlogCategory), request.Id);
return new BlogCategoryDto
{
Id = entity.Id,
Title = entity.Title,
Slug = entity.Slug,
Description = entity.Description,
IconName = entity.IconName,
SortOrder = entity.SortOrder,
IsActive = entity.IsActive,
PostCount = entity.BlogPostCategories.Count,
Created = entity.Created,
LastModified = entity.LastModified
};
}
}
@@ -0,0 +1,14 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.ArchiveBlogPost;
public class ArchiveBlogPostCommand : IRequest<ArchiveBlogPostResult>
{
public long Id { get; set; }
}
public class ArchiveBlogPostResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
}
@@ -0,0 +1,32 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.ArchiveBlogPost;
public class ArchiveBlogPostCommandHandler : IRequestHandler<ArchiveBlogPostCommand, ArchiveBlogPostResult>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<ArchiveBlogPostCommandHandler> _logger;
public ArchiveBlogPostCommandHandler(IApplicationDbContext context, ILogger<ArchiveBlogPostCommandHandler> logger)
{
_context = context;
_logger = logger;
}
public async Task<ArchiveBlogPostResult> Handle(ArchiveBlogPostCommand request, CancellationToken cancellationToken)
{
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
post.Status = BlogPostStatus.Archived;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Blog post archived. Id: {Id}, Title: {Title}", post.Id, post.Title);
return new ArchiveBlogPostResult { Success = true, Message = "مقاله آرشیو شد" };
}
}
@@ -0,0 +1,25 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
/// <summary>
/// دستور ایجاد مقاله جدید
/// </summary>
public class CreateBlogPostCommand : IRequest<long>
{
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string? Summary { get; set; }
public string HtmlContent { get; set; } = string.Empty;
public string? FeaturedImagePath { get; set; }
public string? FeaturedImageThumbnailPath { get; set; }
public List<long> CategoryIds { get; set; } = new();
public List<long> TagIds { get; set; } = new();
public bool IsFeatured { get; set; }
public int SortOrder { get; set; }
// Image upload properties
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
@@ -0,0 +1,98 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
public class CreateBlogPostCommandHandler : IRequestHandler<CreateBlogPostCommand, long>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
private readonly IFileManager _fileManager;
private readonly ILogger<CreateBlogPostCommandHandler> _logger;
public CreateBlogPostCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser,
IFileManager fileManager,
ILogger<CreateBlogPostCommandHandler> logger)
{
_context = context;
_currentUser = currentUser;
_fileManager = fileManager;
_logger = logger;
}
public async Task<long> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken)
{
var currentUserId = _currentUser.UserId;
if (string.IsNullOrEmpty(currentUserId) || !long.TryParse(currentUserId, out var authorUserId))
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
var post = new BlogPost
{
Title = request.Title.Trim(),
Slug = string.IsNullOrWhiteSpace(request.Slug)
? $"post-{Guid.NewGuid():N}".Substring(0, 20)
: request.Slug.Trim().ToLower(),
Summary = request.Summary?.Trim(),
HtmlContent = request.HtmlContent,
FeaturedImagePath = request.FeaturedImagePath,
FeaturedImageThumbnailPath = request.FeaturedImageThumbnailPath,
Status = BlogPostStatus.Draft,
AuthorUserId = authorUserId,
IsFeatured = request.IsFeatured,
SortOrder = request.SortOrder,
ViewCount = 0
};
// آپلود تصویر شاخص (اگر فایل ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/BlogPosts",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
post.FeaturedImagePath = result.Main.Path;
post.FeaturedImageThumbnailPath = result.Thumbnail.Path;
}
_context.BlogPosts.Add(post);
await _context.SaveChangesAsync(cancellationToken);
// افزودن دسته‌بندی‌ها
foreach (var categoryId in request.CategoryIds)
{
_context.BlogPostCategories.Add(new BlogPostCategory
{
BlogPostId = post.Id,
BlogCategoryId = categoryId
});
}
// افزودن تگ‌ها
foreach (var tagId in request.TagIds)
{
_context.BlogPostTags.Add(new BlogPostTag
{
BlogPostId = post.Id,
TagId = tagId
});
}
if (request.CategoryIds.Any() || request.TagIds.Any())
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Blog post created. Id: {Id}, Title: {Title}, Author: {Author}",
post.Id, post.Title, authorUserId);
return post.Id;
}
}
@@ -0,0 +1,25 @@
using FluentValidation;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
public class CreateBlogPostCommandValidator : AbstractValidator<CreateBlogPostCommand>
{
public CreateBlogPostCommandValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("عنوان مقاله الزامی است")
.MaximumLength(200).WithMessage("عنوان نمی‌تواند بیشتر از 200 کاراکتر باشد");
RuleFor(x => x.Slug)
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
.MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد")
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
RuleFor(x => x.Summary)
.MaximumLength(500).WithMessage("خلاصه نمی‌تواند بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.Summary));
RuleFor(x => x.HtmlContent)
.NotEmpty().WithMessage("محتوای مقاله الزامی است");
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.DeleteBlogPost;
public class DeleteBlogPostCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -0,0 +1,31 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.DeleteBlogPost;
public class DeleteBlogPostCommandHandler : IRequestHandler<DeleteBlogPostCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<DeleteBlogPostCommandHandler> _logger;
public DeleteBlogPostCommandHandler(IApplicationDbContext context, ILogger<DeleteBlogPostCommandHandler> logger)
{
_context = context;
_logger = logger;
}
public async Task<Unit> Handle(DeleteBlogPostCommand request, CancellationToken cancellationToken)
{
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
post.IsDeleted = true;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Blog post soft-deleted. Id: {Id}", post.Id);
return Unit.Value;
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.IncrementViewCount;
public class IncrementViewCountCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -0,0 +1,27 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.IncrementViewCount;
public class IncrementViewCountCommandHandler : IRequestHandler<IncrementViewCountCommand, Unit>
{
private readonly IApplicationDbContext _context;
public IncrementViewCountCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(IncrementViewCountCommand request, CancellationToken cancellationToken)
{
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
if (post != null)
{
post.ViewCount++;
await _context.SaveChangesAsync(cancellationToken);
}
return Unit.Value;
}
}
@@ -0,0 +1,15 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.PublishBlogPost;
public class PublishBlogPostCommand : IRequest<PublishBlogPostResult>
{
public long Id { get; set; }
}
public class PublishBlogPostResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public DateTime? PublishedAt { get; set; }
}
@@ -0,0 +1,41 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.PublishBlogPost;
public class PublishBlogPostCommandHandler : IRequestHandler<PublishBlogPostCommand, PublishBlogPostResult>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<PublishBlogPostCommandHandler> _logger;
public PublishBlogPostCommandHandler(IApplicationDbContext context, ILogger<PublishBlogPostCommandHandler> logger)
{
_context = context;
_logger = logger;
}
public async Task<PublishBlogPostResult> Handle(PublishBlogPostCommand request, CancellationToken cancellationToken)
{
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
if (post.Status == BlogPostStatus.Published)
return new PublishBlogPostResult { Success = false, Message = "مقاله قبلاً منتشر شده است" };
post.Status = BlogPostStatus.Published;
post.PublishedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Blog post published. Id: {Id}, Title: {Title}", post.Id, post.Title);
return new PublishBlogPostResult
{
Success = true,
Message = "مقاله با موفقیت منتشر شد",
PublishedAt = post.PublishedAt
};
}
}
@@ -0,0 +1,23 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
public class UpdateBlogPostCommand : IRequest<Unit>
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string? Summary { get; set; }
public string HtmlContent { get; set; } = string.Empty;
public string? FeaturedImagePath { get; set; }
public string? FeaturedImageThumbnailPath { get; set; }
public List<long> CategoryIds { get; set; } = new();
public List<long> TagIds { get; set; } = new();
public bool IsFeatured { get; set; }
public int SortOrder { get; set; }
// Image upload properties
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
@@ -0,0 +1,88 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
public class UpdateBlogPostCommandHandler : IRequestHandler<UpdateBlogPostCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
private readonly ILogger<UpdateBlogPostCommandHandler> _logger;
public UpdateBlogPostCommandHandler(
IApplicationDbContext context,
IFileManager fileManager,
ILogger<UpdateBlogPostCommandHandler> logger)
{
_context = context;
_fileManager = fileManager;
_logger = logger;
}
public async Task<Unit> Handle(UpdateBlogPostCommand request, CancellationToken cancellationToken)
{
var post = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
post.Title = request.Title.Trim();
// حفظ slug قبلی اگر مقدار جدید خالی باشد
if (!string.IsNullOrWhiteSpace(request.Slug))
post.Slug = request.Slug.Trim().ToLower();
post.Summary = request.Summary?.Trim();
post.HtmlContent = request.HtmlContent;
post.FeaturedImagePath = request.FeaturedImagePath;
post.FeaturedImageThumbnailPath = request.FeaturedImageThumbnailPath;
post.IsFeatured = request.IsFeatured;
post.SortOrder = request.SortOrder;
// آپلود تصویر شاخص (اگر فایل جدید ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/BlogPosts",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
post.FeaturedImagePath = result.Main.Path;
post.FeaturedImageThumbnailPath = result.Thumbnail.Path;
}
// بروزرسانی دسته‌بندی‌ها
var existingCategories = await _context.BlogPostCategories
.Where(x => x.BlogPostId == post.Id).ToListAsync(cancellationToken);
_context.BlogPostCategories.RemoveRange(existingCategories);
foreach (var categoryId in request.CategoryIds)
{
_context.BlogPostCategories.Add(new BlogPostCategory
{
BlogPostId = post.Id,
BlogCategoryId = categoryId
});
}
// بروزرسانی تگ‌ها
var existingTags = await _context.BlogPostTags
.Where(x => x.BlogPostId == post.Id).ToListAsync(cancellationToken);
_context.BlogPostTags.RemoveRange(existingTags);
foreach (var tagId in request.TagIds)
{
_context.BlogPostTags.Add(new BlogPostTag
{
BlogPostId = post.Id,
TagId = tagId
});
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Blog post updated. Id: {Id}, Title: {Title}", post.Id, post.Title);
return Unit.Value;
}
}
@@ -0,0 +1,23 @@
using FluentValidation;
namespace CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
public class UpdateBlogPostCommandValidator : AbstractValidator<UpdateBlogPostCommand>
{
public UpdateBlogPostCommandValidator()
{
RuleFor(x => x.Id).GreaterThan(0).WithMessage("شناسه مقاله نامعتبر است");
RuleFor(x => x.Title)
.NotEmpty().WithMessage("عنوان مقاله الزامی است")
.MaximumLength(200).WithMessage("عنوان نمی‌تواند بیشتر از 200 کاراکتر باشد");
RuleFor(x => x.Slug)
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
.MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد")
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
RuleFor(x => x.HtmlContent)
.NotEmpty().WithMessage("محتوای مقاله الزامی است");
}
}
@@ -0,0 +1,16 @@
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
public class GetAllBlogPostsQuery : IRequest<GetAllBlogPostsResponseDto>
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
public string? SortBy { get; set; }
public string? SearchTerm { get; set; }
public BlogPostStatus? Status { get; set; }
public long? CategoryId { get; set; }
public bool? IsFeatured { get; set; }
}
@@ -0,0 +1,85 @@
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
public class GetAllBlogPostsQueryHandler : IRequestHandler<GetAllBlogPostsQuery, GetAllBlogPostsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllBlogPostsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllBlogPostsResponseDto> Handle(GetAllBlogPostsQuery request, CancellationToken cancellationToken)
{
var query = _context.BlogPosts
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
.Where(x => !x.IsDeleted);
// فیلترها
if (request.Status.HasValue)
query = query.Where(x => x.Status == request.Status.Value);
if (request.CategoryId.HasValue)
query = query.Where(x => x.BlogPostCategories.Any(c => c.BlogCategoryId == request.CategoryId.Value));
if (request.IsFeatured.HasValue)
query = query.Where(x => x.IsFeatured == request.IsFeatured.Value);
if (!string.IsNullOrEmpty(request.SearchTerm))
{
var term = request.SearchTerm.ToLower();
query = query.Where(x => x.Title.ToLower().Contains(term) || (x.Summary != null && x.Summary.ToLower().Contains(term)));
}
var totalCount = await query.CountAsync(cancellationToken);
// مرتب‌سازی
query = request.SortBy?.ToLower() switch
{
"title" => query.OrderBy(x => x.Title),
"viewcount" => query.OrderByDescending(x => x.ViewCount),
"publishedat" => query.OrderByDescending(x => x.PublishedAt),
_ => query.OrderByDescending(x => x.Created)
};
var posts = await query
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Select(x => new BlogPostListItemDto
{
Id = x.Id,
Title = x.Title,
Slug = x.Slug,
Summary = x.Summary,
FeaturedImageThumbnailPath = x.FeaturedImageThumbnailPath,
Status = (int)x.Status,
StatusName = GetBlogPostQueryHandler.GetStatusName(x.Status),
PublishedAt = x.PublishedAt,
ViewCount = x.ViewCount,
IsFeatured = x.IsFeatured,
Created = x.Created,
Categories = x.BlogPostCategories.Select(c => new BlogPostCategoryDto
{
Id = c.BlogCategory.Id,
Title = c.BlogCategory.Title,
Slug = c.BlogCategory.Slug
}).ToList()
})
.ToListAsync(cancellationToken);
var metaData = new MetaData
{
TotalCount = totalCount,
PageSize = request.PageSize,
CurrentPage = request.PageNumber,
TotalPage = (int)Math.Ceiling(totalCount / (double)request.PageSize),
HasNext = request.PageNumber < (int)Math.Ceiling(totalCount / (double)request.PageSize),
HasPrevious = request.PageNumber > 1
};
return new GetAllBlogPostsResponseDto { MetaData = metaData, Models = posts };
}
}
@@ -0,0 +1,26 @@
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
public class GetAllBlogPostsResponseDto
{
public MetaData MetaData { get; set; } = new();
public List<BlogPostListItemDto> Models { get; set; } = new();
}
public class BlogPostListItemDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string? Summary { get; set; }
public string? FeaturedImageThumbnailPath { get; set; }
public int Status { get; set; }
public string StatusName { get; set; } = string.Empty;
public DateTime? PublishedAt { get; set; }
public int ViewCount { get; set; }
public bool IsFeatured { get; set; }
public DateTime Created { get; set; }
public List<BlogPostCategoryDto> Categories { get; set; } = new();
}
@@ -0,0 +1,39 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
public class BlogPostDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string? Summary { get; set; }
public string HtmlContent { get; set; } = string.Empty;
public string? FeaturedImagePath { get; set; }
public string? FeaturedImageThumbnailPath { get; set; }
public BlogPostStatus Status { get; set; }
public string StatusName { get; set; } = string.Empty;
public DateTime? PublishedAt { get; set; }
public int ViewCount { get; set; }
public long AuthorUserId { get; set; }
public bool IsFeatured { get; set; }
public int SortOrder { get; set; }
public DateTime Created { get; set; }
public DateTime? LastModified { get; set; }
public List<BlogPostCategoryDto> Categories { get; set; } = new();
public List<BlogPostTagDto> Tags { get; set; } = new();
}
public class BlogPostCategoryDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
}
public class BlogPostTagDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
public class GetBlogPostQuery : IRequest<BlogPostDto>
{
public long Id { get; set; }
}
@@ -0,0 +1,66 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
public class GetBlogPostQueryHandler : IRequestHandler<GetBlogPostQuery, BlogPostDto>
{
private readonly IApplicationDbContext _context;
public GetBlogPostQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<BlogPostDto> Handle(GetBlogPostQuery request, CancellationToken cancellationToken)
{
var post = await _context.BlogPosts
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
.Include(x => x.BlogPostTags).ThenInclude(x => x.Tag)
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
return new BlogPostDto
{
Id = post.Id,
Title = post.Title,
Slug = post.Slug,
Summary = post.Summary,
HtmlContent = post.HtmlContent,
FeaturedImagePath = post.FeaturedImagePath,
FeaturedImageThumbnailPath = post.FeaturedImageThumbnailPath,
Status = post.Status,
StatusName = GetStatusName(post.Status),
PublishedAt = post.PublishedAt,
ViewCount = post.ViewCount,
AuthorUserId = post.AuthorUserId,
IsFeatured = post.IsFeatured,
SortOrder = post.SortOrder,
Created = post.Created,
LastModified = post.LastModified,
Categories = post.BlogPostCategories.Select(c => new BlogPostCategoryDto
{
Id = c.BlogCategory.Id,
Title = c.BlogCategory.Title,
Slug = c.BlogCategory.Slug
}).ToList(),
Tags = post.BlogPostTags.Select(t => new BlogPostTagDto
{
Id = t.Tag.Id,
Title = t.Tag.Title,
Name = t.Tag.Name
}).ToList()
};
}
public static string GetStatusName(BlogPostStatus status) => status switch
{
BlogPostStatus.Draft => "پیش‌نویس",
BlogPostStatus.Published => "منتشرشده",
BlogPostStatus.Scheduled => "زمانبندی‌شده",
BlogPostStatus.Archived => "آرشیو",
_ => "نامشخص"
};
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPostBySlug;
public class GetBlogPostBySlugQuery : IRequest<GetBlogPost.BlogPostDto>
{
public string Slug { get; set; } = string.Empty;
}
@@ -0,0 +1,58 @@
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPostBySlug;
public class GetBlogPostBySlugQueryHandler : IRequestHandler<GetBlogPostBySlugQuery, BlogPostDto>
{
private readonly IApplicationDbContext _context;
public GetBlogPostBySlugQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<BlogPostDto> Handle(GetBlogPostBySlugQuery request, CancellationToken cancellationToken)
{
var post = await _context.BlogPosts
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
.Include(x => x.BlogPostTags).ThenInclude(x => x.Tag)
.FirstOrDefaultAsync(x => x.Slug == request.Slug && !x.IsDeleted, cancellationToken)
?? throw new KeyNotFoundException($"مقاله با نشانی '{request.Slug}' یافت نشد");
return new BlogPostDto
{
Id = post.Id,
Title = post.Title,
Slug = post.Slug,
Summary = post.Summary,
HtmlContent = post.HtmlContent,
FeaturedImagePath = post.FeaturedImagePath,
FeaturedImageThumbnailPath = post.FeaturedImageThumbnailPath,
Status = post.Status,
StatusName = GetBlogPostQueryHandler.GetStatusName(post.Status),
PublishedAt = post.PublishedAt,
ViewCount = post.ViewCount,
AuthorUserId = post.AuthorUserId,
IsFeatured = post.IsFeatured,
SortOrder = post.SortOrder,
Created = post.Created,
LastModified = post.LastModified,
Categories = post.BlogPostCategories.Select(c => new BlogPostCategoryDto
{
Id = c.BlogCategory.Id,
Title = c.BlogCategory.Title,
Slug = c.BlogCategory.Slug
}).ToList(),
Tags = post.BlogPostTags.Select(t => new BlogPostTagDto
{
Id = t.Tag.Id,
Title = t.Tag.Title,
Name = t.Tag.Name
}).ToList()
};
}
}
@@ -0,0 +1,55 @@
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetFeaturedBlogPosts;
public class GetFeaturedBlogPostsQuery : IRequest<List<GetAllBlogPosts.BlogPostListItemDto>>
{
public int Count { get; set; } = 5;
}
public class GetFeaturedBlogPostsQueryHandler : IRequestHandler<GetFeaturedBlogPostsQuery, List<GetAllBlogPosts.BlogPostListItemDto>>
{
private readonly IApplicationDbContext _context;
public GetFeaturedBlogPostsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<GetAllBlogPosts.BlogPostListItemDto>> Handle(GetFeaturedBlogPostsQuery request, CancellationToken cancellationToken)
{
var posts = await _context.BlogPosts
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
.Where(x => !x.IsDeleted && x.Status == BlogPostStatus.Published && x.IsFeatured)
.OrderBy(x => x.SortOrder)
.ThenByDescending(x => x.PublishedAt)
.Take(request.Count)
.Select(x => new GetAllBlogPosts.BlogPostListItemDto
{
Id = x.Id,
Title = x.Title,
Slug = x.Slug,
Summary = x.Summary,
FeaturedImageThumbnailPath = x.FeaturedImageThumbnailPath,
Status = (int)x.Status,
StatusName = GetBlogPostQueryHandler.GetStatusName(x.Status),
PublishedAt = x.PublishedAt,
ViewCount = x.ViewCount,
IsFeatured = x.IsFeatured,
Created = x.Created,
Categories = x.BlogPostCategories.Select(c => new BlogPostCategoryDto
{
Id = c.BlogCategory.Id,
Title = c.BlogCategory.Title,
Slug = c.BlogCategory.Slug
}).ToList()
})
.ToListAsync(cancellationToken);
return posts;
}
}
@@ -0,0 +1,81 @@
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogPostCQ.Queries.GetPublishedBlogPosts;
public class GetPublishedBlogPostsQuery : IRequest<GetAllBlogPosts.GetAllBlogPostsResponseDto>
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
public string? SearchTerm { get; set; }
public long? CategoryId { get; set; }
}
public class GetPublishedBlogPostsQueryHandler : IRequestHandler<GetPublishedBlogPostsQuery, GetAllBlogPosts.GetAllBlogPostsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetPublishedBlogPostsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllBlogPosts.GetAllBlogPostsResponseDto> Handle(GetPublishedBlogPostsQuery request, CancellationToken cancellationToken)
{
var query = _context.BlogPosts
.Include(x => x.BlogPostCategories).ThenInclude(x => x.BlogCategory)
.Where(x => !x.IsDeleted && x.Status == BlogPostStatus.Published);
if (request.CategoryId.HasValue)
query = query.Where(x => x.BlogPostCategories.Any(c => c.BlogCategoryId == request.CategoryId.Value));
if (!string.IsNullOrEmpty(request.SearchTerm))
{
var term = request.SearchTerm.ToLower();
query = query.Where(x => x.Title.ToLower().Contains(term) || (x.Summary != null && x.Summary.ToLower().Contains(term)));
}
var totalCount = await query.CountAsync(cancellationToken);
var posts = await query
.OrderByDescending(x => x.PublishedAt)
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Select(x => new GetAllBlogPosts.BlogPostListItemDto
{
Id = x.Id,
Title = x.Title,
Slug = x.Slug,
Summary = x.Summary,
FeaturedImageThumbnailPath = x.FeaturedImageThumbnailPath,
Status = (int)x.Status,
StatusName = GetBlogPostQueryHandler.GetStatusName(x.Status),
PublishedAt = x.PublishedAt,
ViewCount = x.ViewCount,
IsFeatured = x.IsFeatured,
Created = x.Created,
Categories = x.BlogPostCategories.Select(c => new BlogPostCategoryDto
{
Id = c.BlogCategory.Id,
Title = c.BlogCategory.Title,
Slug = c.BlogCategory.Slug
}).ToList()
})
.ToListAsync(cancellationToken);
var metaData = new MetaData
{
TotalCount = totalCount,
PageSize = request.PageSize,
CurrentPage = request.PageNumber,
TotalPage = (int)Math.Ceiling(totalCount / (double)request.PageSize),
HasNext = request.PageNumber < (int)Math.Ceiling(totalCount / (double)request.PageSize),
HasPrevious = request.PageNumber > 1
};
return new GetAllBlogPosts.GetAllBlogPostsResponseDto { MetaData = metaData, Models = posts };
}
}
@@ -0,0 +1,13 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
public class AddBlogPostImageCommand : IRequest<long>
{
public long BlogPostId { get; set; }
public string ImagePath { get; set; } = default!;
public string ThumbnailPath { get; set; } = default!;
public string? AltText { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,39 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
public class AddBlogPostImageCommandHandler : IRequestHandler<AddBlogPostImageCommand, long>
{
private readonly IApplicationDbContext _context;
public AddBlogPostImageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<long> Handle(AddBlogPostImageCommand request, CancellationToken cancellationToken)
{
var blogPost = await _context.BlogPosts.FirstOrDefaultAsync(x => x.Id == request.BlogPostId && !x.IsDeleted, cancellationToken);
if (blogPost == null)
throw new NotFoundException(nameof(BlogPost), request.BlogPostId);
var entity = new BlogPostImage
{
BlogPostId = request.BlogPostId,
ImagePath = request.ImagePath,
ThumbnailPath = request.ThumbnailPath,
AltText = request.AltText,
Caption = request.Caption,
SortOrder = request.SortOrder
};
_context.BlogPostImages.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}
@@ -0,0 +1,21 @@
using FluentValidation;
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
public class AddBlogPostImageCommandValidator : AbstractValidator<AddBlogPostImageCommand>
{
public AddBlogPostImageCommandValidator()
{
RuleFor(x => x.BlogPostId)
.GreaterThan(0).WithMessage("شناسه پست نامعتبر است");
RuleFor(x => x.ImagePath)
.NotEmpty().WithMessage("مسیر تصویر الزامی است");
RuleFor(x => x.AltText)
.MaximumLength(500).WithMessage("متن جایگزین حداکثر ۵۰۰ کاراکتر");
RuleFor(x => x.Caption)
.MaximumLength(1000).WithMessage("عنوان تصویر حداکثر ۱۰۰۰ کاراکتر");
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.DeleteBlogPostImage;
public class DeleteBlogPostImageCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -0,0 +1,29 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
using MediatR;
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.DeleteBlogPostImage;
public class DeleteBlogPostImageCommandHandler : IRequestHandler<DeleteBlogPostImageCommand, Unit>
{
private readonly IApplicationDbContext _context;
public DeleteBlogPostImageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(DeleteBlogPostImageCommand request, CancellationToken cancellationToken)
{
var entity = await _context.BlogPostImages.FindAsync(new object[] { request.Id }, cancellationToken);
if (entity == null || entity.IsDeleted)
throw new NotFoundException(nameof(BlogPostImage), request.Id);
entity.IsDeleted = true;
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,14 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.ReorderBlogPostImages;
public class ReorderBlogPostImagesCommand : IRequest<Unit>
{
public List<ImageSortItem> Items { get; set; } = new();
}
public class ImageSortItem
{
public long Id { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,34 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogPostImageCQ.Commands.ReorderBlogPostImages;
public class ReorderBlogPostImagesCommandHandler : IRequestHandler<ReorderBlogPostImagesCommand, Unit>
{
private readonly IApplicationDbContext _context;
public ReorderBlogPostImagesCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(ReorderBlogPostImagesCommand request, CancellationToken cancellationToken)
{
var ids = request.Items.Select(x => x.Id).ToList();
var images = await _context.BlogPostImages
.Where(x => ids.Contains(x.Id) && !x.IsDeleted)
.ToListAsync(cancellationToken);
foreach (var item in request.Items)
{
var image = images.FirstOrDefault(x => x.Id == item.Id);
if (image != null)
image.SortOrder = item.SortOrder;
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,19 @@
using MediatR;
namespace CMSMicroservice.Application.BlogPostImageCQ.Queries.GetBlogPostImages;
public class GetBlogPostImagesQuery : IRequest<List<BlogPostImageDto>>
{
public long BlogPostId { get; set; }
}
public class BlogPostImageDto
{
public long Id { get; set; }
public long BlogPostId { get; set; }
public string ImagePath { get; set; } = default!;
public string? ThumbnailPath { get; set; }
public string? AltText { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,35 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.BlogPostImageCQ.Queries.GetBlogPostImages;
public class GetBlogPostImagesQueryHandler : IRequestHandler<GetBlogPostImagesQuery, List<BlogPostImageDto>>
{
private readonly IApplicationDbContext _context;
public GetBlogPostImagesQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<BlogPostImageDto>> Handle(GetBlogPostImagesQuery request, CancellationToken cancellationToken)
{
var images = await _context.BlogPostImages
.Where(x => x.BlogPostId == request.BlogPostId && !x.IsDeleted)
.OrderBy(x => x.SortOrder)
.Select(x => new BlogPostImageDto
{
Id = x.Id,
BlogPostId = x.BlogPostId,
ImagePath = x.ImagePath,
ThumbnailPath = x.ThumbnailPath,
AltText = x.AltText,
Caption = x.Caption,
SortOrder = x.SortOrder
})
.ToListAsync(cancellationToken);
return images;
}
}
@@ -31,7 +31,18 @@ public class GetAllCategoryByFilterQueryHandler : IRequestHandler<GetAllCategory
{
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query.PaginatedListAsync(paginationState: request.PaginationState)
.ProjectToType<GetAllCategoryByFilterResponseModel>().ToListAsync(cancellationToken)
.Select(x => new GetAllCategoryByFilterResponseModel
{
Id = x.Id,
Name = x.Name,
Title = x.Title,
Description = x.Description,
ImagePath = x.ImagePath,
ParentId = x.ParentId,
IsActive = x.IsActive,
SortOrder = x.SortOrder,
ProductCount = x.ProductCategories.Count
}).ToListAsync(cancellationToken)
};
}
}
@@ -24,4 +24,6 @@ public class GetAllCategoryByFilterResponseDto
public bool IsActive { get; set; }
//ترتیب نمایش
public int SortOrder { get; set; }
//تعداد محصولات
public int ProductCount { get; set; }
}
@@ -53,20 +53,28 @@ public class AcceptClubMembershipContractCommandHandler
AcceptClubMembershipContractCommand request,
CancellationToken cancellationToken)
{
// خواندن UserId از JWT (امن‌تر از دریافت از کلاینت)
if (!long.TryParse(_currentUser.UserId, out var userId))
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "کاربر احراز هویت نشده است"
};
_logger.LogInformation(
"Processing club membership contract for UserId: {UserId}",
request.UserId
userId
);
// 1. دریافت کاربر
var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User not found: {UserId}", request.UserId);
throw new NotFoundException(nameof(User), request.UserId);
_logger.LogWarning("User not found: {UserId}", userId);
throw new NotFoundException(nameof(User), userId);
}
// 2. بررسی خرید پکیج
@@ -4,10 +4,6 @@ public class AcceptClubMembershipContractCommandValidator : AbstractValidator<Ac
{
public AcceptClubMembershipContractCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر الزامی است");
RuleFor(x => x.OtpCode)
.NotEmpty()
.WithMessage("کد تایید الزامی است")
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
{
/// <summary>
/// از هفته (فیلتر اختیاری)
/// از هفته — WeekDefinitionId (فیلتر اختیاری)
/// </summary>
public int? FromWeekOrder { get; init; }
public long? FromWeekDefinitionId { get; init; }
/// <summary>
/// تا هفته (فیلتر اختیاری)
/// تا هفته — WeekDefinitionId (فیلتر اختیاری)
/// </summary>
public int? ToWeekOrder { get; init; }
public long? ToWeekDefinitionId { get; init; }
/// <summary>
/// فقط Pool های محاسبه شده
@@ -20,14 +20,14 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
.AsNoTracking();
// Apply filters
if (request.FromWeekOrder!=null)
if (request.FromWeekDefinitionId != null)
{
query = query.Where(x => x.WeekDefinition.WeekOrder>=request.FromWeekOrder );
query = query.Where(x => x.WeekDefinitionId >= request.FromWeekDefinitionId);
}
if (request.ToWeekOrder!=null)
if (request.ToWeekDefinitionId != null)
{
query = query.Where(x =>x.WeekDefinition.WeekOrder<= request.ToWeekOrder);
query = query.Where(x => x.WeekDefinitionId <= request.ToWeekDefinitionId);
}
if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value)
@@ -18,7 +18,10 @@ public class LoggingBehaviour<TRequest> : IRequestPreProcessor<TRequest> where T
{
var requestName = typeof(TRequest).Name;
var userId = _currentUserService.UserId ?? string.Empty;
_logger.LogInformation("Request: {Name} {@UserId} {@Request}",
requestName, userId, request);
var safeLog = request?.ToString() ?? "";
if (safeLog.Length > 2000)
safeLog = safeLog[..2000] + "... [TRUNCATED]";
_logger.LogInformation("Request: {Name} {UserId} {Request}",
requestName, userId, safeLog);
}
}
@@ -1,16 +1,20 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using MediatR;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.Common.Behaviours;
public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
public partial class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly Stopwatch _timer;
private readonly ILogger<TRequest> _logger;
private readonly ICurrentUserService _currentUserService;
[GeneratedRegex(@"ImageFile(?:Bytes|Mime|FileName)|ImageFile|File", RegexOptions.None)]
private static partial Regex BinaryPropertyPattern();
public PerformanceBehaviour(ILogger<TRequest> logger, ICurrentUserService currentUserService)
{
_timer = new Stopwatch();
@@ -33,11 +37,20 @@ public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequ
{
var requestName = typeof(TRequest).Name;
var userId = _currentUserService.UserId ?? string.Empty;
var safeLog = SanitizeForLog(request);
_logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@Request}",
requestName, elapsedMilliseconds, userId, request);
_logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {UserId} {Request}",
requestName, elapsedMilliseconds, userId, safeLog);
}
return response;
}
private static string SanitizeForLog(TRequest request)
{
var text = request?.ToString() ?? "";
if (text.Length > 2000)
return text[..2000] + "... [TRUNCATED]";
return text;
}
}
@@ -22,8 +22,11 @@ public class UnhandledExceptionBehaviour<TRequest, TResponse> : IPipelineBehavio
catch (Exception ex)
{
var requestName = typeof(TRequest).Name;
var safeLog = request?.ToString() ?? "";
if (safeLog.Length > 2000)
safeLog = safeLog[..2000] + "... [TRUNCATED]";
_logger.LogError(ex, "Request: Unhandled Exception for Request {Name} {@Request}", requestName, request);
_logger.LogError(ex, "Request: Unhandled Exception for Request {Name} {Request}", requestName, safeLog);
throw;
}
@@ -0,0 +1,67 @@
namespace CMSMicroservice.Application.Common.FileManager;
/// <summary>
/// سرویس مدیریت فایل — ذخیره روی دیسک، مسیر نسبی در دیتابیس
/// موقع واکشی: خواندن از دیسک و تبدیل به base64 data-URI
/// </summary>
public interface IFileManager
{
/// <summary>
/// آپلود یک فایل خام به دیسک
/// </summary>
/// <returns>نتیجه آپلود شامل مسیر نسبی فایل</returns>
/// <exception cref="FileUploadException">در صورت خطای آپلود</exception>
Task<UploadedFile> UploadAsync(
string directory,
byte[] fileBytes,
string mime,
string? fileName = null,
CancellationToken ct = default);
/// <summary>
/// آپلود تصویر با بهینه‌سازی خودکار + ساخت بندانگشتی
/// </summary>
/// <returns>نتیجه آپلود شامل تصویر اصلی و بندانگشتی (مسیرهای نسبی)</returns>
/// <exception cref="FileUploadException">در صورت خطای آپلود</exception>
Task<UploadedImage> UploadImageAsync(
string directory,
byte[] fileBytes,
string mime,
string? fileName = null,
CancellationToken ct = default);
/// <summary>
/// حذف فایل از دیسک
/// </summary>
Task DeleteAsync(long fileId, CancellationToken ct = default);
/// <summary>
/// خواندن فایل از دیسک و تبدیل به base64 data-URI
/// اگر مسیر از قبل data: باشد، همان را برمی‌گرداند
/// اگر فایل وجود نداشته باشد، رشته خالی برمی‌گرداند
/// </summary>
string ResolveImageUrl(string? path);
}
/// <summary>
/// نتیجه آپلود فایل
/// </summary>
/// <param name="Id">شناسه فایل در FMS</param>
/// <param name="Path">مسیر فایل ذخیره‌شده (مثلاً /Images/Products/abc.jpg)</param>
public sealed record UploadedFile(long Id, string Path);
/// <summary>
/// نتیجه آپلود تصویر — شامل تصویر اصلی و بندانگشتی
/// </summary>
/// <param name="Main">تصویر اصلی بهینه‌شده</param>
/// <param name="Thumbnail">تصویر بندانگشتی</param>
public sealed record UploadedImage(UploadedFile Main, UploadedFile Thumbnail);
/// <summary>
/// خطای آپلود فایل
/// </summary>
public class FileUploadException : Exception
{
public FileUploadException(string message) : base(message) { }
public FileUploadException(string message, Exception inner) : base(message, inner) { }
}
@@ -1,3 +1,5 @@
using CMSMicroservice.Domain.Entities.Blog;
using CMSMicroservice.Domain.Entities.Content;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop;
@@ -66,6 +68,17 @@ public interface IApplicationDbContext
DbSet<InventoryItem> InventoryItems { get; }
DbSet<StockMovement> StockMovements { get; }
// ============= Blog =============
DbSet<BlogPost> BlogPosts { get; }
DbSet<BlogCategory> BlogCategories { get; }
DbSet<BlogPostCategory> BlogPostCategories { get; }
DbSet<BlogPostTag> BlogPostTags { get; }
DbSet<BlogPostImage> BlogPostImages { get; }
// ============= Content Management =============
DbSet<SitePage> SitePages { get; }
DbSet<SitePageSection> SitePageSections { get; }
/// <summary>
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
/// </summary>
@@ -1,37 +0,0 @@
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);
}
@@ -27,6 +27,24 @@ public interface IPaymentGatewayService
string verificationToken,
CancellationToken cancellationToken = default);
/// <summary>
/// تأیید پرداخت با مبلغ — برای درگاه‌هایی مثل زرین‌پال که مبلغ را در Verify نیاز دارند
/// </summary>
/// <param name="refId">شماره مرجع تراکنش (Authority در زرین‌پال)</param>
/// <param name="verificationToken">توکن تأیید از درگاه (Status در زرین‌پال)</param>
/// <param name="amountInToman">مبلغ تراکنش به تومان</param>
/// <param name="cancellationToken"></param>
/// <returns>وضعیت نهایی تراکنش</returns>
Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken = default)
{
// پیش‌فرض: درگاه‌هایی که Amount نمی‌خواهند، از overload بدون amount استفاده کنند
return VerifyPaymentAsync(refId, verificationToken, cancellationToken);
}
/// <summary>
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
/// </summary>
@@ -9,4 +9,9 @@ public class AddDiscountProductImageCommand : IRequest<long>
public string ThumbnailPath { get; set; } = string.Empty;
public string? Title { get; set; }
public string? AltText { get; set; }
// Image file upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.DiscountShop;
using MediatR;
@@ -8,10 +9,12 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.AddDiscountProduct
public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscountProductImageCommand, long>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public AddDiscountProductImageCommandHandler(IApplicationDbContext context)
public AddDiscountProductImageCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<long> Handle(AddDiscountProductImageCommand request, CancellationToken cancellationToken)
@@ -23,6 +26,23 @@ public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscount
if (!productExists)
throw new InvalidOperationException($"DiscountProduct with Id {request.DiscountProductId} not found.");
var imagePath = request.ImagePath;
var thumbnailPath = request.ThumbnailPath;
// آپلود تصویر (اگر فایل ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/DiscountProducts/Gallery",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
imagePath = result.Main.Path;
thumbnailPath = result.Thumbnail.Path;
}
// Get the max sort order for this product
var maxSortOrder = await _context.DiscountProductImages
.Where(i => i.DiscountProductId == request.DiscountProductId)
@@ -31,8 +51,8 @@ public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscount
var image = new DiscountProductImage
{
DiscountProductId = request.DiscountProductId,
ImagePath = request.ImagePath,
ThumbnailPath = request.ThumbnailPath,
ImagePath = imagePath,
ThumbnailPath = thumbnailPath,
Title = request.Title,
AltText = request.AltText,
SortOrder = maxSortOrder + 1,
@@ -11,5 +11,15 @@ public class CreateDiscountProductCommand : IRequest<long>
public int MaxDiscountPercent { get; set; }
public string ImagePath { get; set; }
public string ThumbnailPath { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public List<long> CategoryIds { get; set; } = new();
// Image file upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
public byte[]? ThumbnailFileBytes { get; set; }
public string? ThumbnailFileMime { get; set; }
public string? ThumbnailFileName { get; set; }
}
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Enums;
@@ -10,13 +11,16 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
{
private readonly IApplicationDbContext _context;
private readonly IInventoryService _inventoryService;
private readonly IFileManager _fileManager;
public CreateDiscountProductCommandHandler(
IApplicationDbContext context,
IInventoryService inventoryService)
IInventoryService inventoryService,
IFileManager fileManager)
{
_context = context;
_inventoryService = inventoryService;
_fileManager = fileManager;
}
public async Task<long> Handle(CreateDiscountProductCommand request, CancellationToken cancellationToken)
@@ -28,15 +32,42 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
FullInformation = request.FullInformation,
Price = request.Price,
MaxDiscountPercent = request.MaxDiscountPercent,
ImagePath = request.ImagePath,
ThumbnailPath = request.ThumbnailPath,
RemainingCount = 0, // موجودی اولیه صفر - باید از طریق Inventory اضافه شود
ImagePath = request.ImagePath ?? string.Empty,
ThumbnailPath = request.ThumbnailPath ?? string.Empty,
RemainingCount = 0,
Rate = 0,
SaleCount = 0,
ViewCount = 0,
IsActive = true
IsActive = request.IsActive
};
// آپلود تصویر اصلی (اگر فایل ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/DiscountProducts",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
product.ImagePath = result.Main.Path;
product.ThumbnailPath = result.Thumbnail.Path;
}
// آپلود بندانگشتی جداگانه (اختیاری)
if (request.ThumbnailFileBytes is { Length: > 0 })
{
var thumbResult = await _fileManager.UploadAsync(
"Images/DiscountProducts/Thumbnails",
request.ThumbnailFileBytes,
request.ThumbnailFileMime ?? "image/jpeg",
request.ThumbnailFileName,
cancellationToken);
product.ThumbnailPath = thumbResult.Path;
}
_context.DiscountProducts.Add(product);
await _context.SaveChangesAsync(cancellationToken);
@@ -18,4 +18,9 @@ public class PlaceOrderResponseDto
public long TotalAmount { get; set; }
public long DiscountBalanceUsed { get; set; }
public long GatewayAmountRequired { get; set; }
/// <summary>
/// URL درگاه پرداخت — اگر null باشد یعنی نیاز به پرداخت آنلاین نیست
/// </summary>
public string? PaymentUrl { get; set; }
}
@@ -5,6 +5,8 @@ using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder;
@@ -12,13 +14,22 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
{
private readonly IApplicationDbContext _context;
private readonly IInventoryService _inventoryService;
private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration;
private readonly ILogger<PlaceOrderCommandHandler> _logger;
public PlaceOrderCommandHandler(
IApplicationDbContext context,
IInventoryService inventoryService)
IInventoryService inventoryService,
IPaymentGatewayService paymentGateway,
IConfiguration configuration,
ILogger<PlaceOrderCommandHandler> logger)
{
_context = context;
_inventoryService = inventoryService;
_paymentGateway = paymentGateway;
_configuration = configuration;
_logger = logger;
}
public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
@@ -172,15 +183,102 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
await _context.SaveChangesAsync(cancellationToken);
// اگر مبلغ درگاه > ۰ باشد، باید به درگاه پرداخت متصل شویم
string? paymentUrl = null;
if (finalGatewayAmount > 0)
{
try
{
// آدرس callback — زرین‌پال بعد از پرداخت کاربر را به اینجا هدایت می‌کند
var cmsBaseUrl = _configuration["CmsBaseUrl"] ?? "https://localhost:32846";
var callbackUrl = $"{cmsBaseUrl}/api/payment/discount-order/callback?orderId={order.Id}";
// درخواست به درگاه
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
{
Amount = finalGatewayAmount,
UserId = request.UserId,
Description = $"فروشگاه تخفیفی - سفارش #{order.Id}",
CallbackUrl = callbackUrl
}, cancellationToken);
if (paymentResult.IsSuccess && !string.IsNullOrEmpty(paymentResult.GatewayUrl))
{
// ذخیره Authority/RefId در تراکنش برای verify بعدی
transaction.RefId = paymentResult.RefId;
await _context.SaveChangesAsync(cancellationToken);
paymentUrl = paymentResult.GatewayUrl;
_logger.LogInformation(
"Payment gateway initiated for DiscountOrder #{OrderId}: RefId={RefId}, Url={Url}",
order.Id, paymentResult.RefId, paymentResult.GatewayUrl);
}
else
{
_logger.LogError(
"Payment gateway initiation failed for DiscountOrder #{OrderId}: {Error}",
order.Id, paymentResult.ErrorMessage);
return new PlaceOrderResponseDto
{
Success = false,
Message = $"خطا در اتصال به درگاه پرداخت: {paymentResult.ErrorMessage}",
OrderId = order.Id
};
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Payment gateway exception for DiscountOrder #{OrderId}", order.Id);
return new PlaceOrderResponseDto
{
Success = false,
Message = $"خطا در اتصال به درگاه پرداخت: {ex.Message}",
OrderId = order.Id
};
}
}
else
{
// اگر کل مبلغ از کیف تخفیفی پرداخت شد — مستقیماً تکمیل شود
transaction.PaymentStatus = PaymentStatus.Success;
transaction.PaymentDate = DateTime.Now;
order.PaymentStatus = PaymentStatus.Success;
order.PaymentDate = DateTime.Now;
order.DeliveryStatus = DeliveryStatus.InTransit;
var walletForDeduct = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken);
if (walletForDeduct != null)
walletForDeduct.DiscountBalance -= actualDiscountBalanceUsed;
foreach (var cartItem in cartItems)
{
await _inventoryService.ConfirmSaleAsync(
cartItem.ProductId, ProductType.DiscountProduct,
cartItem.Count, order.Id, cancellationToken);
cartItem.Product.SaleCount += cartItem.Count;
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"DiscountOrder #{OrderId} fully paid via discount balance ({Amount} T)",
order.Id, actualDiscountBalanceUsed);
}
return new PlaceOrderResponseDto
{
Success = true,
Message = "سفارش ایجاد شد. لطفا پرداخت را تکمیل کنید",
Message = finalGatewayAmount > 0
? "سفارش ایجاد شد. در حال انتقال به درگاه پرداخت..."
: "سفارش با موفقیت ثبت و پرداخت شد",
OrderId = order.Id,
TransactionId = transaction.Id,
TotalAmount = totalAmount,
DiscountBalanceUsed = actualDiscountBalanceUsed,
GatewayAmountRequired = finalGatewayAmount
GatewayAmountRequired = finalGatewayAmount,
PaymentUrl = paymentUrl
};
}
}
@@ -12,7 +12,16 @@ public class UpdateDiscountProductCommand : IRequest<Unit>
public int MaxDiscountPercent { get; set; }
public string ImagePath { get; set; }
public string ThumbnailPath { get; set; }
public int SortOrder { get; set; }
public int RemainingCount { get; set; }
public bool IsActive { get; set; }
public List<long> CategoryIds { get; set; } = new();
// Image file upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
public byte[]? ThumbnailFileBytes { get; set; }
public string? ThumbnailFileMime { get; set; }
public string? ThumbnailFileName { get; set; }
}
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.DiscountShop;
using MediatR;
@@ -8,10 +9,12 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateDiscountProd
public class UpdateDiscountProductCommandHandler : IRequestHandler<UpdateDiscountProductCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public UpdateDiscountProductCommandHandler(IApplicationDbContext context)
public UpdateDiscountProductCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<Unit> Handle(UpdateDiscountProductCommand request, CancellationToken cancellationToken)
@@ -27,11 +30,44 @@ public class UpdateDiscountProductCommandHandler : IRequestHandler<UpdateDiscoun
product.FullInformation = request.FullInformation;
product.Price = request.Price;
product.MaxDiscountPercent = request.MaxDiscountPercent;
product.ImagePath = request.ImagePath;
product.ThumbnailPath = request.ThumbnailPath;
product.RemainingCount = request.RemainingCount;
product.IsActive = request.IsActive;
// آپلود تصویر اصلی (اگر فایل جدید ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/DiscountProducts",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
product.ImagePath = result.Main.Path;
product.ThumbnailPath = result.Thumbnail.Path;
}
else if (!string.IsNullOrEmpty(request.ImagePath))
{
product.ImagePath = request.ImagePath;
}
// آپلود بندانگشتی جداگانه (اختیاری)
if (request.ThumbnailFileBytes is { Length: > 0 })
{
var thumbResult = await _fileManager.UploadAsync(
"Images/DiscountProducts/Thumbnails",
request.ThumbnailFileBytes,
request.ThumbnailFileMime ?? "image/jpeg",
request.ThumbnailFileName,
cancellationToken);
product.ThumbnailPath = thumbResult.Path;
}
else if (!string.IsNullOrEmpty(request.ThumbnailPath))
{
product.ThumbnailPath = request.ThumbnailPath;
}
// Update categories
var existingCategories = await _context.DiscountProductCategories
.Where(pc => pc.ProductId == request.ProductId)
@@ -43,4 +43,6 @@ public class OrderItemDto
public int DiscountPercentUsed { get; set; }
public long DiscountAmount { get; set; }
public long FinalPrice { get; set; }
public string ImagePath { get; set; }
public string ThumbnailPath { get; set; }
}
@@ -53,7 +53,9 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
UnitPrice = od.UnitPrice,
DiscountPercentUsed = od.DiscountPercentUsed,
DiscountAmount = od.DiscountAmount,
FinalPrice = od.FinalPrice
FinalPrice = od.FinalPrice,
ImagePath = od.Product.ImagePath ?? "",
ThumbnailPath = od.Product.ThumbnailPath ?? ""
}).ToList()
};
}
@@ -5,5 +5,8 @@ public record CreateNewOtpTokenCommand : IRequest<CreateNewOtpTokenResponseDto>
public string Mobile { get; init; }
//مقصود
public string Purpose { get; init; }
/// <summary>
/// شناسه GUID قرارداد (فقط برای purpose=signcontract/signclubcontract)
/// </summary>
public string? SignGuid { get; init; }
}
@@ -57,7 +57,7 @@ public class CreateNewOtpTokenCommandHandler : IRequestHandler<CreateNewOtpToken
};
await _context.OtpTokens.AddAsync(entity, cancellationToken);
entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity, code));
entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity, code, request.SignGuid));
await _context.SaveChangesAsync(cancellationToken);
return new CreateNewOtpTokenResponseDto()
{
@@ -24,7 +24,20 @@ public class CreateNewOtpTokenEventHandler : INotificationHandler<CreateNewOtpTo
try
{
await _kavenegarService.VerifyLookupAsync(notification.Item.Mobile, notification.PlainCode);
var purpose = notification.Item.Purpose?.ToLowerInvariant();
// برای امضای قرارداد، پیامک ساده با GUID ارسال شود
if ((purpose == "signcontract" || purpose == "signclubcontract")
&& !string.IsNullOrEmpty(notification.SignGuid))
{
var message = $"کد تایید امضای قرارداد: {notification.PlainCode}\nشناسه قرارداد: {notification.SignGuid}";
await _kavenegarService.SendAsync(notification.Item.Mobile, message);
}
else
{
await _kavenegarService.VerifyLookupAsync(notification.Item.Mobile, notification.PlainCode);
}
_logger.LogInformation("OTP SMS sent successfully to {Mobile}", notification.Item.Mobile);
}
catch (Exception ex)
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using Microsoft.Extensions.Logging;
@@ -7,64 +8,52 @@ namespace CMSMicroservice.Application.ProductsCQ.Commands.AddProductImage;
public class AddProductImageCommandHandler : IRequestHandler<AddProductImageCommand, AddProductImageResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IFileManagementService _fileManagementService;
private readonly IFileManager _fileManager;
private readonly ILogger<AddProductImageCommandHandler> _logger;
public AddProductImageCommandHandler(
IApplicationDbContext context,
IFileManagementService fileManagementService,
IFileManager fileManager,
ILogger<AddProductImageCommandHandler> logger)
{
_context = context;
_fileManagementService = fileManagementService;
_fileManager = fileManager;
_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;
// بدون فایل تصویر، کاری انجام نمی‌شود
if (request.ImageFileBytes is not { Length: > 0 })
throw new FileUploadException("فایل تصویر ارسال نشده است");
// 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);
// آپلود تصویر به FMS (اگر خطا بخوره، exception پرتاب می‌شه و entity ذخیره نمیشه)
var uploaded = await _fileManager.UploadImageAsync(
"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
ImagePath = uploaded.Main.Path,
ImageThumbnailPath = uploaded.Thumbnail.Path
};
await _context.ProductImages.AddAsync(productImage, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// Create ProductGallery join entity
// اتصال تصویر به محصول
var productGallery = new ProductGallery
{
ProductId = request.ProductId,
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using Microsoft.Extensions.Logging;
@@ -7,16 +8,16 @@ namespace CMSMicroservice.Application.ProductsCQ.Commands.CreateNewProducts;
public class CreateNewProductsCommandHandler : IRequestHandler<CreateNewProductsCommand, CreateNewProductsResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IFileManagementService _fileManagementService;
private readonly IFileManager _fileManager;
private readonly ILogger<CreateNewProductsCommandHandler> _logger;
public CreateNewProductsCommandHandler(
IApplicationDbContext context,
IFileManagementService fileManagementService,
IFileManager fileManager,
ILogger<CreateNewProductsCommandHandler> logger)
{
_context = context;
_fileManagementService = fileManagementService;
_fileManager = fileManager;
_logger = logger;
}
@@ -32,56 +33,38 @@ public class CreateNewProductsCommandHandler : IRequestHandler<CreateNewProducts
Price = request.Price,
Discount = request.Discount,
Rate = request.Rate,
ImagePath = request.ImagePath ?? string.Empty,
ThumbnailPath = request.ThumbnailPath ?? string.Empty,
ImagePath = string.Empty,
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);
var result = await _fileManager.UploadImageAsync(
"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");
}
entity.ImagePath = result.Main.Path;
entity.ThumbnailPath = result.Thumbnail.Path;
}
// 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");
}
// آپلود بندانگشتی جداگانه (اختیاری — جایگزین بندانگشتی خودکار)
if (request.ThumbnailFileBytes is { Length: > 0 })
{
var thumbResult = await _fileManager.UploadAsync(
"Images/Products/Thumbnails",
request.ThumbnailFileBytes,
request.ThumbnailFileMime ?? "image/jpeg",
request.ThumbnailFileName,
cancellationToken);
entity.ThumbnailPath = thumbResult.Path;
}
await _context.Products.AddAsync(entity, cancellationToken);
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using Microsoft.Extensions.Logging;
@@ -7,16 +8,16 @@ namespace CMSMicroservice.Application.ProductsCQ.Commands.UpdateProducts;
public class UpdateProductsCommandHandler : IRequestHandler<UpdateProductsCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly IFileManagementService _fileManagementService;
private readonly IFileManager _fileManager;
private readonly ILogger<UpdateProductsCommandHandler> _logger;
public UpdateProductsCommandHandler(
IApplicationDbContext context,
IFileManagementService fileManagementService,
IFileManager fileManager,
ILogger<UpdateProductsCommandHandler> logger)
{
_context = context;
_fileManagementService = fileManagementService;
_fileManager = fileManager;
_logger = logger;
}
@@ -38,57 +39,31 @@ public class UpdateProductsCommandHandler : IRequestHandler<UpdateProductsComman
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);
var result = await _fileManager.UploadImageAsync(
"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;
entity.ImagePath = result.Main.Path;
entity.ThumbnailPath = result.Thumbnail.Path;
}
// 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);
var thumbResult = await _fileManager.UploadAsync(
"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);
}
entity.ThumbnailPath = thumbResult.Path;
}
_context.Products.Update(entity);
@@ -21,5 +21,6 @@ public class GetCustomerProductsByFilterQuery : IRequest<GetCustomerProductsByFi
public int? SaleCount { get; set; }
public int? ViewCount { get; set; }
public int? RemainingCount { get; set; }
public bool? IsActive { get; set; }
public List<long>? CategoryIds { get; set; }
}
@@ -59,6 +59,9 @@ public class GetCustomerProductsByFilterQueryHandler : IRequestHandler<GetCustom
if (request.CategoryIds != null && request.CategoryIds.Any())
query = query.Where(x => x.ProductCategories.Any(pc => request.CategoryIds.Contains(pc.CategoryId)));
if (request.IsActive.HasValue)
query = query.Where(x => x.IsDeleted != request.IsActive.Value);
// Apply sorting
if (!string.IsNullOrEmpty(request.SortBy))
query = query.ApplyOrder(request.SortBy);
@@ -99,6 +102,7 @@ public class GetCustomerProductsByFilterQueryHandler : IRequestHandler<GetCustom
SaleCount = p.SaleCount,
ViewCount = p.ViewCount,
RemainingCount = p.RemainingCount,
IsActive = !p.IsDeleted,
Categories = p.ProductCategories?.Select(pc => new ProductCategoryPathModel
{
CategoryId = pc.CategoryId,
@@ -23,6 +23,7 @@ public class CustomerProductModel
public int SaleCount { get; set; }
public int ViewCount { get; set; }
public int RemainingCount { get; set; }
public bool IsActive { get; set; }
public List<ProductCategoryPathModel> Categories { get; set; }
}
@@ -0,0 +1,18 @@
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.CreateSitePage;
public class CreateSitePageCommand : IRequest<long>
{
public string PageKey { get; set; } = default!;
public string Title { get; set; } = default!;
public string? MetaDescription { get; set; }
public string? HeroTitle { get; set; }
public string? HeroSubtitle { get; set; }
public bool IsActive { get; set; } = true;
// Image upload properties
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
@@ -0,0 +1,49 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.CreateSitePage;
public class CreateSitePageCommandHandler : IRequestHandler<CreateSitePageCommand, long>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public CreateSitePageCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<long> Handle(CreateSitePageCommand request, CancellationToken cancellationToken)
{
var entity = new SitePage
{
PageKey = request.PageKey,
Title = request.Title,
MetaDescription = request.MetaDescription,
HeroTitle = request.HeroTitle,
HeroSubtitle = request.HeroSubtitle,
IsActive = request.IsActive
};
// آپلود تصویر هیرو (اگر فایل ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/SitePages",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
entity.HeroImagePath = result.Main.Path;
}
_context.SitePages.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}
@@ -0,0 +1,21 @@
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.CreateSitePageSection;
public class CreateSitePageSectionCommand : IRequest<long>
{
public long SitePageId { get; set; }
public string SectionKey { get; set; } = default!;
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? HtmlContent { get; set; }
public string? IconName { get; set; }
public string? ImagePath { get; set; }
public bool IsActive { get; set; } = true;
public string? ExtraData { get; set; }
// Image upload properties
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
@@ -0,0 +1,68 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageCQ.Commands.CreateSitePageSection;
public class CreateSitePageSectionCommandHandler : IRequestHandler<CreateSitePageSectionCommand, long>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public CreateSitePageSectionCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<long> Handle(CreateSitePageSectionCommand request, CancellationToken cancellationToken)
{
var page = await _context.SitePages.FirstOrDefaultAsync(x => x.Id == request.SitePageId && !x.IsDeleted, cancellationToken);
if (page == null)
throw new NotFoundException(nameof(SitePage), request.SitePageId);
var maxSortOrder = await _context.SitePageSections
.Where(x => x.SitePageId == request.SitePageId && !x.IsDeleted)
.MaxAsync(x => (int?)x.SortOrder, cancellationToken) ?? 0;
var sectionKey = string.IsNullOrWhiteSpace(request.SectionKey)
? $"section-{Guid.NewGuid():N}"[..20]
: request.SectionKey.Trim().ToLower();
var entity = new SitePageSection
{
SitePageId = request.SitePageId,
SectionKey = sectionKey,
Title = request.Title,
Subtitle = request.Subtitle,
HtmlContent = request.HtmlContent,
IconName = request.IconName,
ImagePath = request.ImagePath,
SortOrder = maxSortOrder + 1,
IsActive = request.IsActive,
ExtraData = request.ExtraData
};
// آپلود تصویر بخش (اگر فایل ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/SitePageSections",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
entity.ImagePath = result.Main.Path;
entity.ImageThumbnailPath = result.Thumbnail.Path;
}
_context.SitePageSections.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}
@@ -0,0 +1,26 @@
using FluentValidation;
namespace CMSMicroservice.Application.SitePageCQ.Commands.CreateSitePageSection;
public class CreateSitePageSectionCommandValidator : AbstractValidator<CreateSitePageSectionCommand>
{
public CreateSitePageSectionCommandValidator()
{
RuleFor(x => x.SitePageId)
.GreaterThan(0).WithMessage("شناسه صفحه نامعتبر است");
RuleFor(x => x.SectionKey)
.MaximumLength(100).WithMessage("کلید بخش حداکثر ۱۰۰ کاراکتر")
.Matches(@"^[a-z0-9\-_]*$").WithMessage("کلید بخش فقط شامل حروف کوچک، اعداد، خط تیره و زیرخط")
.When(x => !string.IsNullOrWhiteSpace(x.SectionKey));
RuleFor(x => x.Title)
.MaximumLength(300).WithMessage("عنوان بخش حداکثر ۳۰۰ کاراکتر");
RuleFor(x => x.Subtitle)
.MaximumLength(500).WithMessage("زیرعنوان بخش حداکثر ۵۰۰ کاراکتر");
RuleFor(x => x.IconName)
.MaximumLength(100).WithMessage("نام آیکون حداکثر ۱۰۰ کاراکتر");
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.DeleteSitePage;
public class DeleteSitePageCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -0,0 +1,35 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.DeleteSitePage;
public class DeleteSitePageCommandHandler : IRequestHandler<DeleteSitePageCommand, Unit>
{
private readonly IApplicationDbContext _context;
public DeleteSitePageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(DeleteSitePageCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SitePages.FindAsync(new object[] { request.Id }, cancellationToken);
if (entity == null || entity.IsDeleted)
throw new NotFoundException(nameof(SitePage), request.Id);
entity.IsDeleted = true;
// حذف نرم بخش‌های وابسته
foreach (var section in entity.Sections.Where(s => !s.IsDeleted))
{
section.IsDeleted = true;
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.DeleteSitePageSection;
public class DeleteSitePageSectionCommand : IRequest<Unit>
{
public long Id { get; set; }
}
@@ -0,0 +1,29 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.DeleteSitePageSection;
public class DeleteSitePageSectionCommandHandler : IRequestHandler<DeleteSitePageSectionCommand, Unit>
{
private readonly IApplicationDbContext _context;
public DeleteSitePageSectionCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(DeleteSitePageSectionCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SitePageSections.FindAsync(new object[] { request.Id }, cancellationToken);
if (entity == null || entity.IsDeleted)
throw new NotFoundException(nameof(SitePageSection), request.Id);
entity.IsDeleted = true;
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,14 @@
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.ReorderSitePageSections;
public class ReorderSitePageSectionsCommand : IRequest<Unit>
{
public List<SectionSortItem> Items { get; set; } = new();
}
public class SectionSortItem
{
public long Id { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,34 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageCQ.Commands.ReorderSitePageSections;
public class ReorderSitePageSectionsCommandHandler : IRequestHandler<ReorderSitePageSectionsCommand, Unit>
{
private readonly IApplicationDbContext _context;
public ReorderSitePageSectionsCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(ReorderSitePageSectionsCommand request, CancellationToken cancellationToken)
{
var ids = request.Items.Select(x => x.Id).ToList();
var sections = await _context.SitePageSections
.Where(x => ids.Contains(x.Id) && !x.IsDeleted)
.ToListAsync(cancellationToken);
foreach (var item in request.Items)
{
var section = sections.FirstOrDefault(x => x.Id == item.Id);
if (section != null)
section.SortOrder = item.SortOrder;
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,19 @@
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.UpdateSitePage;
public class UpdateSitePageCommand : IRequest<Unit>
{
public long Id { get; set; }
public string Title { get; set; } = default!;
public string? MetaDescription { get; set; }
public string? HeroTitle { get; set; }
public string? HeroSubtitle { get; set; }
public string? HeroImagePath { get; set; }
public bool IsActive { get; set; }
// Image upload properties
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
@@ -0,0 +1,50 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.UpdateSitePage;
public class UpdateSitePageCommandHandler : IRequestHandler<UpdateSitePageCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public UpdateSitePageCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<Unit> Handle(UpdateSitePageCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SitePages.FindAsync(new object[] { request.Id }, cancellationToken);
if (entity == null || entity.IsDeleted)
throw new NotFoundException(nameof(SitePage), request.Id);
entity.Title = request.Title;
entity.MetaDescription = request.MetaDescription;
entity.HeroTitle = request.HeroTitle;
entity.HeroSubtitle = request.HeroSubtitle;
entity.HeroImagePath = request.HeroImagePath;
entity.IsActive = request.IsActive;
// آپلود تصویر هیرو (اگر فایل ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/SitePages",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
entity.HeroImagePath = result.Main.Path;
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,25 @@
using FluentValidation;
namespace CMSMicroservice.Application.SitePageCQ.Commands.UpdateSitePage;
public class UpdateSitePageCommandValidator : AbstractValidator<UpdateSitePageCommand>
{
public UpdateSitePageCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه صفحه نامعتبر است");
RuleFor(x => x.Title)
.NotEmpty().WithMessage("عنوان صفحه الزامی است")
.MaximumLength(200).WithMessage("عنوان صفحه حداکثر ۲۰۰ کاراکتر");
RuleFor(x => x.MetaDescription)
.MaximumLength(500).WithMessage("توضیحات متا حداکثر ۵۰۰ کاراکتر");
RuleFor(x => x.HeroTitle)
.MaximumLength(300).WithMessage("عنوان هیرو حداکثر ۳۰۰ کاراکتر");
RuleFor(x => x.HeroSubtitle)
.MaximumLength(500).WithMessage("زیرعنوان هیرو حداکثر ۵۰۰ کاراکتر");
}
}
@@ -0,0 +1,22 @@
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.UpdateSitePageSection;
public class UpdateSitePageSectionCommand : IRequest<Unit>
{
public long Id { get; set; }
public string SectionKey { get; set; } = default!;
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? HtmlContent { get; set; }
public string? IconName { get; set; }
public string? ImagePath { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public string? ExtraData { get; set; }
// Image upload properties
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
@@ -0,0 +1,54 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Commands.UpdateSitePageSection;
public class UpdateSitePageSectionCommandHandler : IRequestHandler<UpdateSitePageSectionCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public UpdateSitePageSectionCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<Unit> Handle(UpdateSitePageSectionCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SitePageSections.FindAsync(new object[] { request.Id }, cancellationToken);
if (entity == null || entity.IsDeleted)
throw new NotFoundException(nameof(SitePageSection), request.Id);
entity.SectionKey = request.SectionKey;
entity.Title = request.Title;
entity.Subtitle = request.Subtitle;
entity.HtmlContent = request.HtmlContent;
entity.IconName = request.IconName;
entity.ImagePath = request.ImagePath;
entity.SortOrder = request.SortOrder;
entity.IsActive = request.IsActive;
entity.ExtraData = request.ExtraData;
// آپلود تصویر بخش (اگر فایل جدید ارسال شده باشد)
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/SitePageSections",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
entity.ImagePath = result.Main.Path;
entity.ImageThumbnailPath = result.Thumbnail.Path;
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,26 @@
using FluentValidation;
namespace CMSMicroservice.Application.SitePageCQ.Commands.UpdateSitePageSection;
public class UpdateSitePageSectionCommandValidator : AbstractValidator<UpdateSitePageSectionCommand>
{
public UpdateSitePageSectionCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه بخش نامعتبر است");
RuleFor(x => x.SectionKey)
.NotEmpty().WithMessage("کلید بخش الزامی است")
.MaximumLength(100).WithMessage("کلید بخش حداکثر ۱۰۰ کاراکتر")
.Matches(@"^[a-z0-9\-_]+$").WithMessage("کلید بخش فقط شامل حروف کوچک، اعداد، خط تیره و زیرخط");
RuleFor(x => x.Title)
.MaximumLength(300).WithMessage("عنوان بخش حداکثر ۳۰۰ کاراکتر");
RuleFor(x => x.Subtitle)
.MaximumLength(500).WithMessage("زیرعنوان بخش حداکثر ۵۰۰ کاراکتر");
RuleFor(x => x.IconName)
.MaximumLength(100).WithMessage("نام آیکون حداکثر ۱۰۰ کاراکتر");
}
}
@@ -0,0 +1,51 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageCQ.Queries.GetAllSitePages;
public class GetAllSitePagesQuery : IRequest<List<SitePageListItemDto>>
{
}
public class SitePageListItemDto
{
public long Id { get; set; }
public string PageKey { get; set; } = default!;
public string Title { get; set; } = default!;
public bool IsActive { get; set; }
public int SectionCount { get; set; }
public DateTime Created { get; set; }
public DateTime? LastModified { get; set; }
}
public class GetAllSitePagesQueryHandler : IRequestHandler<GetAllSitePagesQuery, List<SitePageListItemDto>>
{
private readonly IApplicationDbContext _context;
public GetAllSitePagesQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<SitePageListItemDto>> Handle(GetAllSitePagesQuery request, CancellationToken cancellationToken)
{
var pages = await _context.SitePages
.Include(x => x.Sections)
.Where(x => !x.IsDeleted)
.OrderBy(x => x.PageKey)
.Select(x => new SitePageListItemDto
{
Id = x.Id,
PageKey = x.PageKey,
Title = x.Title,
IsActive = x.IsActive,
SectionCount = x.Sections.Count(s => !s.IsDeleted),
Created = x.Created,
LastModified = x.LastModified
})
.ToListAsync(cancellationToken);
return pages;
}
}
@@ -0,0 +1,8 @@
using MediatR;
namespace CMSMicroservice.Application.SitePageCQ.Queries.GetSitePage;
public class GetSitePageQuery : IRequest<SitePageDto>
{
public long Id { get; set; }
}
@@ -0,0 +1,59 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageCQ.Queries.GetSitePage;
public class GetSitePageQueryHandler : IRequestHandler<GetSitePageQuery, SitePageDto>
{
private readonly IApplicationDbContext _context;
public GetSitePageQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<SitePageDto> Handle(GetSitePageQuery request, CancellationToken cancellationToken)
{
var entity = await _context.SitePages
.Include(x => x.Sections.Where(s => !s.IsDeleted).OrderBy(s => s.SortOrder))
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
if (entity == null)
throw new NotFoundException(nameof(SitePage), request.Id);
return MapToDto(entity);
}
internal static SitePageDto MapToDto(SitePage entity)
{
return new SitePageDto
{
Id = entity.Id,
PageKey = entity.PageKey,
Title = entity.Title,
MetaDescription = entity.MetaDescription,
HeroTitle = entity.HeroTitle,
HeroSubtitle = entity.HeroSubtitle,
HeroImagePath = entity.HeroImagePath,
IsActive = entity.IsActive,
Created = entity.Created,
LastModified = entity.LastModified,
Sections = entity.Sections.Select(s => new SitePageSectionDto
{
Id = s.Id,
SectionKey = s.SectionKey,
Title = s.Title,
Subtitle = s.Subtitle,
HtmlContent = s.HtmlContent,
IconName = s.IconName,
ImagePath = s.ImagePath,
SortOrder = s.SortOrder,
IsActive = s.IsActive,
ExtraData = s.ExtraData
}).ToList()
};
}
}

Some files were not shown because too many files have changed in this diff Show More