Files
soroush.asadi 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
feat(admin): affiliate/personal discounts, user-videos, internal routes, authz
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>
2026-06-02 22:42:01 +03:30

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
);
}