diff --git a/src/CMSMicroservice.Application/BlogPostCQ/Commands/CreateBlogPost/CreateBlogPostCommandHandler.cs b/src/CMSMicroservice.Application/BlogPostCQ/Commands/CreateBlogPost/CreateBlogPostCommandHandler.cs index 9bb72e6..bd17cb7 100644 --- a/src/CMSMicroservice.Application/BlogPostCQ/Commands/CreateBlogPost/CreateBlogPostCommandHandler.cs +++ b/src/CMSMicroservice.Application/BlogPostCQ/Commands/CreateBlogPost/CreateBlogPostCommandHandler.cs @@ -1,3 +1,4 @@ +using CMSMicroservice.Application.Common; using CMSMicroservice.Application.Common.FileManager; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Domain.Entities.Blog; @@ -32,12 +33,13 @@ public class CreateBlogPostCommandHandler : IRequestHandler x.Slug) - .NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است") .MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد") - .Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"); + .Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد") + .When(x => !string.IsNullOrWhiteSpace(x.Slug)); RuleFor(x => x.Summary) .MaximumLength(500).WithMessage("خلاصه نمی‌تواند بیشتر از 500 کاراکتر باشد") diff --git a/src/CMSMicroservice.Application/BlogPostCQ/Commands/UpdateBlogPost/UpdateBlogPostCommandHandler.cs b/src/CMSMicroservice.Application/BlogPostCQ/Commands/UpdateBlogPost/UpdateBlogPostCommandHandler.cs index 8b9c74e..efefd9e 100644 --- a/src/CMSMicroservice.Application/BlogPostCQ/Commands/UpdateBlogPost/UpdateBlogPostCommandHandler.cs +++ b/src/CMSMicroservice.Application/BlogPostCQ/Commands/UpdateBlogPost/UpdateBlogPostCommandHandler.cs @@ -1,3 +1,4 @@ +using CMSMicroservice.Application.Common; using CMSMicroservice.Application.Common.FileManager; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Domain.Entities.Blog; @@ -29,9 +30,21 @@ public class UpdateBlogPostCommandHandler : IRequestHandler x.Slug) - .NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است") .MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد") - .Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"); + .Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد") + .When(x => !string.IsNullOrWhiteSpace(x.Slug)); RuleFor(x => x.HtmlContent) .NotEmpty().WithMessage("محتوای مقاله الزامی است"); diff --git a/src/CMSMicroservice.Application/Common/BlogSlugHelper.cs b/src/CMSMicroservice.Application/Common/BlogSlugHelper.cs new file mode 100644 index 0000000..896803e --- /dev/null +++ b/src/CMSMicroservice.Application/Common/BlogSlugHelper.cs @@ -0,0 +1,85 @@ +using CMSMicroservice.Application.Common.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace CMSMicroservice.Application.Common; + +public static class BlogSlugHelper +{ + private const int MaxSlugLength = 200; + + public static string? NormalizeOptional(string? slug) + { + if (string.IsNullOrWhiteSpace(slug)) + return null; + + return slug.Trim().ToLowerInvariant(); + } + + /// + /// Builds a URL slug from title (ASCII letters/digits). Persian-only titles yield empty string. + /// + public static string GenerateFromTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + return string.Empty; + + var sb = new System.Text.StringBuilder(); + var lastWasHyphen = false; + + foreach (var c in title.Trim().ToLowerInvariant()) + { + if (c is >= 'a' and <= 'z' or >= '0' and <= '9') + { + sb.Append(c); + lastWasHyphen = false; + } + else if (c is ' ' or '_' or '-') + { + if (sb.Length > 0 && !lastWasHyphen) + { + sb.Append('-'); + lastWasHyphen = true; + } + } + } + + var result = sb.ToString().Trim('-'); + return result.Length > MaxSlugLength ? result[..MaxSlugLength].TrimEnd('-') : result; + } + + public static string CreateFallbackSlug() => + $"post-{Guid.NewGuid():N}"[..Math.Min(20, MaxSlugLength)]; + + public static string ResolveForCreate(string? requestedSlug, string title) + { + var normalized = NormalizeOptional(requestedSlug); + if (!string.IsNullOrEmpty(normalized)) + return normalized; + + var fromTitle = GenerateFromTitle(title); + return !string.IsNullOrEmpty(fromTitle) ? fromTitle : CreateFallbackSlug(); + } + + public static async Task EnsureUniquePostSlugAsync( + IApplicationDbContext context, + string slug, + long? excludePostId = null, + CancellationToken cancellationToken = default) + { + var candidate = slug; + var suffix = 2; + + while (await context.BlogPosts.AnyAsync( + p => p.Slug == candidate && !p.IsDeleted && (excludePostId == null || p.Id != excludePostId.Value), + cancellationToken)) + { + var suffixStr = $"-{suffix}"; + var maxBase = MaxSlugLength - suffixStr.Length; + var basePart = slug.Length > maxBase ? slug[..maxBase].TrimEnd('-') : slug; + candidate = basePart + suffixStr; + suffix++; + } + + return candidate; + } +}