3091911260
Build backend images / build content-svc (push) Failing after 1s
Build backend images / build file-svc (push) Failing after 1s
Build backend images / build gateway (push) Failing after 0s
Build backend images / build identity-svc (push) Failing after 0s
Build backend images / build notification-svc (push) Failing after 1s
Build backend images / build render-svc (push) Failing after 1s
Build backend images / build studio-svc (push) Failing after 1s
Closes the remaining legacy-admin gaps:
- Users «مدیریت» modal: create personal discount or affiliate code (owner_user_id +
owner_profit_percentage on existing /v1/discounts), and view the user's saved
projects ("videos") via new admin GET /v1/saved-projects/by-user/{id} (studio)
- Internal routes admin (/admin/routes): CRUD on content.internal_routes
(RoutesController + CmsService + gateway /v1/routes/*)
- Security: lock identity UsersController Search + Ban to [Authorize(Roles="Admin")]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
354 lines
16 KiB
C#
354 lines
16 KiB
C#
using FlatRender.ContentSvc.Domain.Entities;
|
|
using FlatRender.ContentSvc.Domain.Enums;
|
|
using FlatRender.ContentSvc.Infrastructure.Data;
|
|
using FlatRender.ContentSvc.Models.Requests;
|
|
using FlatRender.ContentSvc.Models.Responses;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace FlatRender.ContentSvc.Application.Services;
|
|
|
|
public class CmsService(ContentDbContext db)
|
|
{
|
|
// ── Blogs ─────────────────────────────────────────────────────────────────
|
|
|
|
public async Task<PagedResponse<BlogSummaryResponse>> GetBlogsAsync(BlogListRequest req)
|
|
{
|
|
var kind = Enum.TryParse<BlogKind>(req.Kind, true, out var k) ? k : BlogKind.Blog;
|
|
var q = db.Blogs.Where(x => x.Kind == kind);
|
|
|
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
|
q = q.Where(x => EF.Functions.ILike(x.Title, $"%{req.Search}%"));
|
|
|
|
if (req.IsPublished.HasValue)
|
|
q = q.Where(x => x.IsPublished == req.IsPublished);
|
|
|
|
var total = await q.LongCountAsync();
|
|
var items = await q.OrderByDescending(x => x.PublishDate ?? x.CreatedAt)
|
|
.Skip((req.Page - 1) * req.PageSize).Take(req.PageSize).ToListAsync();
|
|
|
|
return new PagedResponse<BlogSummaryResponse>(
|
|
items.Select(MapBlogSummary),
|
|
new PaginationMeta(req.Page, req.PageSize, total, (int)Math.Ceiling((double)total / req.PageSize))
|
|
);
|
|
}
|
|
|
|
public async Task<BlogDetailResponse> GetBlogBySlugAsync(string slug)
|
|
{
|
|
var blog = await db.Blogs.FirstOrDefaultAsync(x => x.Slug == slug)
|
|
?? throw new KeyNotFoundException($"Blog '{slug}' not found");
|
|
await db.Blogs.Where(x => x.Id == blog.Id)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ViewCount, x => x.ViewCount + 1));
|
|
return MapBlogDetail(blog);
|
|
}
|
|
|
|
public async Task<BlogDetailResponse> CreateBlogAsync(BlogListRequest _, CreateBlogRequest req, Guid? authorId)
|
|
{
|
|
var kind = Enum.TryParse<BlogKind>(req.Kind, true, out var k) ? k : BlogKind.Blog;
|
|
var blog = new Blog
|
|
{
|
|
TenantId = null, Kind = kind, Slug = req.Slug, Title = req.Title,
|
|
ShortDescription = req.ShortDescription, Content = req.Content,
|
|
MetaTitle = req.MetaTitle, MetaDescription = req.MetaDescription, MetaKeywords = req.MetaKeywords,
|
|
IncludeInSiteMap = req.IncludeInSiteMap, Image = req.Image, Cover = req.Cover,
|
|
AuthorUserId = authorId, AuthorDisplayName = req.AuthorDisplayName,
|
|
IsPublished = req.IsPublished, PublishDate = req.PublishDate
|
|
};
|
|
db.Blogs.Add(blog);
|
|
await db.SaveChangesAsync();
|
|
return MapBlogDetail(blog);
|
|
}
|
|
|
|
public async Task<BlogDetailResponse> UpdateBlogAsync(Guid id, UpdateBlogRequest req)
|
|
{
|
|
var blog = await db.Blogs.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Blog {id} not found");
|
|
blog.Slug = req.Slug; blog.Title = req.Title; blog.ShortDescription = req.ShortDescription;
|
|
blog.Content = req.Content; blog.MetaTitle = req.MetaTitle; blog.MetaDescription = req.MetaDescription;
|
|
blog.MetaKeywords = req.MetaKeywords; blog.IncludeInSiteMap = req.IncludeInSiteMap;
|
|
blog.Image = req.Image; blog.Cover = req.Cover; blog.AuthorDisplayName = req.AuthorDisplayName;
|
|
blog.IsPublished = req.IsPublished; blog.PublishDate = req.PublishDate;
|
|
blog.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return MapBlogDetail(blog);
|
|
}
|
|
|
|
public async Task DeleteBlogAsync(Guid id)
|
|
{
|
|
var blog = await db.Blogs.FindAsync(id) ?? throw new KeyNotFoundException($"Blog {id} not found");
|
|
blog.DeletedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
// ── Comments ──────────────────────────────────────────────────────────────
|
|
|
|
public async Task<PagedResponse<CommentResponse>> GetCommentsAsync(
|
|
int page, int pageSize, Guid? blogId, Guid? containerId, bool? isApproved)
|
|
{
|
|
var q = db.Comments.AsQueryable();
|
|
if (blogId.HasValue) q = q.Where(x => x.BlogId == blogId);
|
|
if (containerId.HasValue) q = q.Where(x => x.ContainerId == containerId);
|
|
if (isApproved.HasValue) q = q.Where(x => x.IsApproved == isApproved);
|
|
|
|
var total = await q.LongCountAsync();
|
|
var items = await q.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
|
|
|
return new PagedResponse<CommentResponse>(
|
|
items.Select(MapComment),
|
|
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
|
|
);
|
|
}
|
|
|
|
public async Task<CommentResponse> CreateCommentAsync(CreateCommentRequest req, Guid userId)
|
|
{
|
|
if (req.BlogId == null && req.ContainerId == null)
|
|
throw new ArgumentException("Either BlogId or ContainerId must be provided");
|
|
if (req.BlogId != null && req.ContainerId != null)
|
|
throw new ArgumentException("Only one of BlogId or ContainerId may be provided");
|
|
|
|
var comment = new Comment
|
|
{
|
|
UserId = userId, BlogId = req.BlogId, ContainerId = req.ContainerId,
|
|
ParentCommentId = req.ParentCommentId, Content = req.Content, Rate = req.Rate
|
|
};
|
|
db.Comments.Add(comment);
|
|
await db.SaveChangesAsync();
|
|
return MapComment(comment);
|
|
}
|
|
|
|
public async Task ApproveCommentAsync(Guid id, bool approve)
|
|
{
|
|
var comment = await db.Comments.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Comment {id} not found");
|
|
comment.IsApproved = approve;
|
|
comment.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task DeleteCommentAsync(Guid id)
|
|
{
|
|
var comment = await db.Comments.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Comment {id} not found");
|
|
comment.DeletedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
// ── Slides ────────────────────────────────────────────────────────────────
|
|
|
|
public async Task<List<SlideResponse>> GetSlidesAsync(Guid? tenantId)
|
|
{
|
|
var q = db.NewSlides.Where(x => x.IsActive &&
|
|
(x.TenantId == null || x.TenantId == tenantId) &&
|
|
(x.ExpireDate == null || x.ExpireDate > DateTime.UtcNow));
|
|
|
|
return await q.OrderBy(x => x.Sort).Select(s => new SlideResponse(
|
|
s.Id, s.Keyword, s.Title, s.Image, s.Parameter, s.SlideType.ToString(),
|
|
s.ExpireDate, s.Sort, s.IsActive
|
|
)).ToListAsync();
|
|
}
|
|
|
|
public async Task<SlideResponse> CreateSlideAsync(CreateSlideRequest req)
|
|
{
|
|
if (!Enum.TryParse<SlideType>(req.SlideType, true, out var type))
|
|
throw new ArgumentException($"Invalid SlideType: {req.SlideType}");
|
|
|
|
var slide = new NewSlide
|
|
{
|
|
Keyword = req.Keyword, Title = req.Title, Image = req.Image,
|
|
Parameter = req.Parameter, SlideType = type, ExpireDate = req.ExpireDate,
|
|
Sort = req.Sort, IsActive = req.IsActive
|
|
};
|
|
db.NewSlides.Add(slide);
|
|
await db.SaveChangesAsync();
|
|
return new SlideResponse(slide.Id, slide.Keyword, slide.Title, slide.Image, slide.Parameter,
|
|
slide.SlideType.ToString(), slide.ExpireDate, slide.Sort, slide.IsActive);
|
|
}
|
|
|
|
public async Task DeleteSlideAsync(Guid id)
|
|
{
|
|
var slide = await db.NewSlides.FindAsync(id) ?? throw new KeyNotFoundException($"Slide {id} not found");
|
|
db.NewSlides.Remove(slide);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
// ── Home Page Events ──────────────────────────────────────────────────────
|
|
|
|
public async Task<List<HomePageEventResponse>> GetHomePageEventsAsync(Guid? tenantId, bool includeInactive = false)
|
|
{
|
|
var q = db.HomePageEvents.Where(x => x.TenantId == null || x.TenantId == tenantId);
|
|
if (!includeInactive) q = q.Where(x => x.IsActive);
|
|
return await q.OrderBy(x => x.Sort).Select(e => MapHomeEvent(e)).ToListAsync();
|
|
}
|
|
|
|
private static HomePageEventResponse MapHomeEvent(HomePageEvent e) => new(
|
|
e.Id, e.Title, e.Subtitle, e.Description, e.Badge, e.BadgeClass,
|
|
e.ButtonText, e.ButtonUrl, e.ButtonClass, e.Color, e.BackgroundColor,
|
|
e.TextColor, e.Image, e.IsActive, e.Sort, e.StartsAt, e.EndsAt);
|
|
|
|
private static DateTime? Utc(DateTime? d) => d.HasValue ? DateTime.SpecifyKind(d.Value, DateTimeKind.Utc) : null;
|
|
|
|
public async Task<HomePageEventResponse> CreateHomeEventAsync(UpsertHomeEventRequest req)
|
|
{
|
|
var e = new HomePageEvent();
|
|
ApplyHomeEvent(e, req);
|
|
db.HomePageEvents.Add(e);
|
|
await db.SaveChangesAsync();
|
|
return MapHomeEvent(e);
|
|
}
|
|
|
|
public async Task<HomePageEventResponse> UpdateHomeEventAsync(Guid id, UpsertHomeEventRequest req)
|
|
{
|
|
var e = await db.HomePageEvents.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Home event {id} not found");
|
|
ApplyHomeEvent(e, req);
|
|
e.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return MapHomeEvent(e);
|
|
}
|
|
|
|
public async Task DeleteHomeEventAsync(Guid id)
|
|
{
|
|
var e = await db.HomePageEvents.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Home event {id} not found");
|
|
db.HomePageEvents.Remove(e);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static void ApplyHomeEvent(HomePageEvent e, UpsertHomeEventRequest r)
|
|
{
|
|
e.Title = r.Title; e.Subtitle = r.Subtitle; e.Description = r.Description;
|
|
e.Badge = r.Badge; e.BadgeClass = r.BadgeClass;
|
|
e.ButtonText = r.ButtonText; e.ButtonUrl = r.ButtonUrl; e.ButtonClass = r.ButtonClass;
|
|
e.Color = r.Color; e.BackgroundColor = r.BackgroundColor; e.TextColor = r.TextColor;
|
|
e.Image = r.Image; e.IsActive = r.IsActive; e.Sort = r.Sort;
|
|
e.StartsAt = Utc(r.StartsAt); e.EndsAt = Utc(r.EndsAt);
|
|
}
|
|
|
|
// ── Internal routes ─────────────────────────────────────────────────────────
|
|
|
|
public async Task<List<InternalRouteResponse>> GetRoutesAsync(Guid? tenantId)
|
|
{
|
|
return await db.InternalRoutes
|
|
.Where(x => x.TenantId == null || x.TenantId == tenantId)
|
|
.OrderBy(x => x.Priority)
|
|
.Select(r => new InternalRouteResponse(r.Id, r.Name, r.Image, r.Slug, r.Priority, r.LastDate))
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<InternalRouteResponse> CreateRouteAsync(UpsertRouteRequest req)
|
|
{
|
|
var r = new InternalRoute { Name = req.Name, Image = req.Image, Slug = req.Slug, Priority = req.Priority, LastDate = Utc(req.LastDate) };
|
|
db.InternalRoutes.Add(r);
|
|
await db.SaveChangesAsync();
|
|
return new InternalRouteResponse(r.Id, r.Name, r.Image, r.Slug, r.Priority, r.LastDate);
|
|
}
|
|
|
|
public async Task<InternalRouteResponse> UpdateRouteAsync(Guid id, UpsertRouteRequest req)
|
|
{
|
|
var r = await db.InternalRoutes.FindAsync(id) ?? throw new KeyNotFoundException($"Route {id} not found");
|
|
r.Name = req.Name; r.Image = req.Image; r.Slug = req.Slug; r.Priority = req.Priority;
|
|
r.LastDate = Utc(req.LastDate); r.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return new InternalRouteResponse(r.Id, r.Name, r.Image, r.Slug, r.Priority, r.LastDate);
|
|
}
|
|
|
|
public async Task DeleteRouteAsync(Guid id)
|
|
{
|
|
var r = await db.InternalRoutes.FindAsync(id) ?? throw new KeyNotFoundException($"Route {id} not found");
|
|
db.InternalRoutes.Remove(r);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
// ── Website Settings ──────────────────────────────────────────────────────
|
|
|
|
public async Task<List<WebsiteSettingResponse>> GetSettingsAsync(Guid? tenantId, bool includeSecret = false)
|
|
{
|
|
var q = db.WebsiteSettings.Where(x => x.TenantId == tenantId);
|
|
if (!includeSecret) q = q.Where(x => !x.IsSecret);
|
|
return await q.Select(s => new WebsiteSettingResponse(s.Id, s.Key, s.Value, s.Description, s.IsSecret)).ToListAsync();
|
|
}
|
|
|
|
public async Task<WebsiteSettingResponse> UpsertSettingAsync(Guid? tenantId, UpsertWebsiteSettingRequest req)
|
|
{
|
|
var setting = await db.WebsiteSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Key == req.Key);
|
|
if (setting == null)
|
|
{
|
|
setting = new WebsiteSetting { TenantId = tenantId, Key = req.Key };
|
|
db.WebsiteSettings.Add(setting);
|
|
}
|
|
setting.Value = req.Value;
|
|
setting.Description = req.Description;
|
|
setting.IsSecret = req.IsSecret;
|
|
setting.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return new WebsiteSettingResponse(setting.Id, setting.Key, setting.Value, setting.Description, setting.IsSecret);
|
|
}
|
|
|
|
// ── Favorites ─────────────────────────────────────────────────────────────
|
|
|
|
public async Task<List<FavoriteFolderResponse>> GetFavoriteFoldersAsync(Guid userId)
|
|
{
|
|
return await db.FavoriteFolders
|
|
.Where(x => x.UserId == userId)
|
|
.Select(f => new FavoriteFolderResponse(
|
|
f.Id, f.Name, f.Description,
|
|
db.FavoriteContainers.Count(fc => fc.FolderId == f.Id),
|
|
f.CreatedAt
|
|
)).ToListAsync();
|
|
}
|
|
|
|
public async Task<FavoriteFolderResponse> CreateFavoriteFolderAsync(Guid userId, Guid tenantId, CreateFavoriteFolderRequest req)
|
|
{
|
|
var folder = new FavoriteFolder { UserId = userId, TenantId = tenantId, Name = req.Name, Description = req.Description };
|
|
db.FavoriteFolders.Add(folder);
|
|
await db.SaveChangesAsync();
|
|
return new FavoriteFolderResponse(folder.Id, folder.Name, folder.Description, 0, folder.CreatedAt);
|
|
}
|
|
|
|
public async Task DeleteFavoriteFolderAsync(Guid userId, Guid id)
|
|
{
|
|
var folder = await db.FavoriteFolders.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId)
|
|
?? throw new KeyNotFoundException($"Folder {id} not found");
|
|
db.FavoriteFolders.Remove(folder);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task AddFavoriteContainerAsync(Guid userId, Guid tenantId, AddFavoriteContainerRequest req)
|
|
{
|
|
var exists = await db.FavoriteContainers.AnyAsync(x => x.UserId == userId && x.ContainerId == req.ContainerId);
|
|
if (exists) return;
|
|
|
|
db.FavoriteContainers.Add(new FavoriteContainer
|
|
{
|
|
UserId = userId, TenantId = tenantId, ContainerId = req.ContainerId,
|
|
FolderId = req.FolderId, Note = req.Note
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task RemoveFavoriteContainerAsync(Guid userId, Guid containerId)
|
|
{
|
|
var fav = await db.FavoriteContainers.FirstOrDefaultAsync(x => x.UserId == userId && x.ContainerId == containerId)
|
|
?? throw new KeyNotFoundException($"Favorite not found");
|
|
db.FavoriteContainers.Remove(fav);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
// ── Mappers ───────────────────────────────────────────────────────────────
|
|
|
|
private static BlogSummaryResponse MapBlogSummary(Blog b) => new(
|
|
b.Id, b.Slug, b.Title, b.ShortDescription, b.Image, b.Cover,
|
|
b.AuthorDisplayName, b.IsPublished, b.PublishDate, b.ViewCount, b.CreatedAt
|
|
);
|
|
|
|
private static BlogDetailResponse MapBlogDetail(Blog b) => new(
|
|
b.Id, b.Slug, b.Title, b.ShortDescription, b.Content, b.MetaTitle, b.MetaDescription,
|
|
b.MetaKeywords, b.IncludeInSiteMap, b.Image, b.Cover, b.AuthorUserId, b.AuthorDisplayName,
|
|
b.IsPublished, b.PublishDate, b.ViewCount, b.CreatedAt, b.UpdatedAt
|
|
);
|
|
|
|
private static CommentResponse MapComment(Comment c) => new(
|
|
c.Id, c.UserId, c.BlogId, c.ContainerId, c.ParentCommentId,
|
|
c.Content, c.Rate, c.IsApproved, c.IsPinned, c.CreatedAt
|
|
);
|
|
}
|