feat: V2 microservices stack — backend services, gateway, JWT auth

Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 23:29:31 +03:30
parent 53ea78a00d
commit 90ac0b81d1
7636 changed files with 3707504 additions and 240 deletions
@@ -0,0 +1,279 @@
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)
{
return await db.HomePageEvents
.Where(x => x.IsActive && (x.TenantId == null || x.TenantId == tenantId))
.OrderBy(x => x.Sort)
.Select(e => new HomePageEventResponse(
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
)).ToListAsync();
}
// ── 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
);
}
@@ -0,0 +1,228 @@
using FlatRender.ContentSvc.Domain.Entities;
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 TaxonomyService(ContentDbContext db)
{
public async Task<List<CategoryResponse>> GetCategoryTreeAsync()
{
var all = await db.Categories
.Where(x => x.ParentId == null)
.Include(x => x.Children)
.OrderBy(x => x.Sort)
.ToListAsync();
return all.Select(MapCategory).ToList();
}
public async Task<CategoryResponse> CreateCategoryAsync(CreateCategoryRequest req)
{
var cat = new Category
{
ParentId = req.ParentId,
Name = req.Name,
Slug = req.Slug,
Description = req.Description,
ImageUrl = req.ImageUrl,
Icon = req.Icon,
MetaTitle = req.MetaTitle,
MetaDescription = req.MetaDescription,
MetaKeywords = req.MetaKeywords,
BotFollow = req.BotFollow,
Sort = req.Sort,
IsActive = req.IsActive
};
db.Categories.Add(cat);
await db.SaveChangesAsync();
return MapCategory(cat);
}
public async Task<CategoryResponse> UpdateCategoryAsync(Guid id, UpdateCategoryRequest req)
{
var cat = await db.Categories.FindAsync(id)
?? throw new KeyNotFoundException($"Category {id} not found");
cat.ParentId = req.ParentId;
cat.Name = req.Name;
cat.Slug = req.Slug;
cat.Description = req.Description;
cat.ImageUrl = req.ImageUrl;
cat.Icon = req.Icon;
cat.MetaTitle = req.MetaTitle;
cat.MetaDescription = req.MetaDescription;
cat.MetaKeywords = req.MetaKeywords;
cat.BotFollow = req.BotFollow;
cat.Sort = req.Sort;
cat.IsActive = req.IsActive;
cat.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapCategory(cat);
}
public async Task DeleteCategoryAsync(Guid id)
{
var cat = await db.Categories.FindAsync(id)
?? throw new KeyNotFoundException($"Category {id} not found");
cat.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<PagedResponse<TagResponse>> GetTagsAsync(int page, int pageSize, string? search)
{
var q = db.Tags.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
var total = await q.LongCountAsync();
var items = await q.OrderBy(x => x.Name).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
return new PagedResponse<TagResponse>(
items.Select(t => new TagResponse(t.Id, t.Name, t.LatinName, t.Slug, t.AppliesToMode, t.IsActive)),
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
);
}
public async Task<TagResponse> CreateTagAsync(CreateTagRequest req)
{
var tag = new Tag
{
Name = req.Name,
LatinName = req.LatinName,
Slug = req.Slug,
AppliesToMode = req.AppliesToMode,
IsActive = req.IsActive
};
db.Tags.Add(tag);
await db.SaveChangesAsync();
return new TagResponse(tag.Id, tag.Name, tag.LatinName, tag.Slug, tag.AppliesToMode, tag.IsActive);
}
public async Task<TagResponse> UpdateTagAsync(Guid id, UpdateTagRequest req)
{
var tag = await db.Tags.FindAsync(id)
?? throw new KeyNotFoundException($"Tag {id} not found");
tag.Name = req.Name;
tag.LatinName = req.LatinName;
tag.Slug = req.Slug;
tag.AppliesToMode = req.AppliesToMode;
tag.IsActive = req.IsActive;
tag.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new TagResponse(tag.Id, tag.Name, tag.LatinName, tag.Slug, tag.AppliesToMode, tag.IsActive);
}
public async Task DeleteTagAsync(Guid id)
{
var tag = await db.Tags.FindAsync(id)
?? throw new KeyNotFoundException($"Tag {id} not found");
tag.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<PagedResponse<FontResponse>> GetFontsAsync(int page, int pageSize, string? search, string? direction)
{
var q = db.Fonts.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
if (!string.IsNullOrWhiteSpace(direction))
q = q.Where(x => x.Direction == direction);
var total = await q.LongCountAsync();
var items = await q.OrderBy(x => x.Sort).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
return new PagedResponse<FontResponse>(
items.Select(MapFont),
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
);
}
public async Task<FontResponse> CreateFontAsync(CreateFontRequest req)
{
var font = new Font
{
Name = req.Name, OriginalName = req.OriginalName, SystemName = req.SystemName,
Family = req.Family, Weight = req.Weight, Style = req.Style, Direction = req.Direction,
FileUrl = req.FileUrl, SampleImageUrl = req.SampleImageUrl, IsPremium = req.IsPremium,
IsActive = req.IsActive, InstalledOnNodes = req.InstalledOnNodes, Sort = req.Sort
};
db.Fonts.Add(font);
await db.SaveChangesAsync();
return MapFont(font);
}
public async Task<FontResponse> UpdateFontAsync(Guid id, UpdateFontRequest req)
{
var font = await db.Fonts.FindAsync(id)
?? throw new KeyNotFoundException($"Font {id} not found");
font.Name = req.Name; font.OriginalName = req.OriginalName; font.SystemName = req.SystemName;
font.Family = req.Family; font.Weight = req.Weight; font.Style = req.Style; font.Direction = req.Direction;
font.FileUrl = req.FileUrl; font.SampleImageUrl = req.SampleImageUrl; font.IsPremium = req.IsPremium;
font.IsActive = req.IsActive; font.InstalledOnNodes = req.InstalledOnNodes; font.Sort = req.Sort;
font.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapFont(font);
}
public async Task DeleteFontAsync(Guid id)
{
var font = await db.Fonts.FindAsync(id)
?? throw new KeyNotFoundException($"Font {id} not found");
font.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<PagedResponse<MusicTrackResponse>> GetMusicTracksAsync(int page, int pageSize, string? search)
{
var q = db.MusicTracks.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
var total = await q.LongCountAsync();
var items = await q.OrderBy(x => x.Sort).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
return new PagedResponse<MusicTrackResponse>(
items.Select(MapMusic),
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
);
}
public async Task<MusicTrackResponse> CreateMusicTrackAsync(CreateMusicTrackRequest req)
{
var track = new MusicTrack
{
Name = req.Name, Caption = req.Caption, Keywords = req.Keywords, Url = req.Url,
WaveformData = req.WaveformData, DurationSec = req.DurationSec, Bpm = req.Bpm,
Genre = req.Genre, Mood = req.Mood, IsPremium = req.IsPremium, IsActive = req.IsActive, Sort = req.Sort
};
db.MusicTracks.Add(track);
await db.SaveChangesAsync();
return MapMusic(track);
}
public async Task DeleteMusicTrackAsync(Guid id)
{
var track = await db.MusicTracks.FindAsync(id)
?? throw new KeyNotFoundException($"MusicTrack {id} not found");
track.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
private static CategoryResponse MapCategory(Category c) => new(
c.Id, c.ParentId, c.Name, c.Slug, c.Description, c.ImageUrl, c.Icon,
c.IsActive, c.Sort, c.Children.Select(MapCategory).ToList()
);
private static FontResponse MapFont(Font f) => new(
f.Id, f.Name, f.OriginalName, f.SystemName, f.Family, f.Weight, f.Style,
f.Direction, f.FileUrl, f.SampleImageUrl, f.IsPremium, f.IsActive, f.InstalledOnNodes
);
private static MusicTrackResponse MapMusic(MusicTrack m) => new(
m.Id, m.Name, m.Caption, m.Url, m.WaveformData, m.DurationSec, m.Bpm, m.Genre, m.Mood, m.IsPremium
);
}
@@ -0,0 +1,294 @@
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 TemplateService(ContentDbContext db)
{
public async Task<PagedResponse<ContainerSummaryResponse>> GetContainersAsync(ContainerListRequest req)
{
var q = db.ProjectContainers
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{req.Search}%") ||
EF.Functions.ILike(x.Slug, $"%{req.Search}%"));
if (req.CategoryId.HasValue)
q = q.Where(x => x.ContainerCategories.Any(cc => cc.CategoryId == req.CategoryId));
if (!string.IsNullOrWhiteSpace(req.TagSlug))
q = q.Where(x => x.ContainerTags.Any(ct => ct.Tag.Slug == req.TagSlug));
if (req.IsPublished.HasValue)
q = q.Where(x => x.IsPublished == req.IsPublished);
if (req.IsPremium.HasValue)
q = q.Where(x => x.IsPremium == req.IsPremium);
if (!string.IsNullOrWhiteSpace(req.Mode))
{
if (Enum.TryParse<ChooseMode>(req.Mode, true, out var mode))
q = q.Where(x => x.PrimaryMode == mode);
}
q = req.Sort switch
{
"sort_asc" => q.OrderBy(x => x.Sort),
"name_asc" => q.OrderBy(x => x.Name),
"view_count_desc" => q.OrderByDescending(x => x.ViewCount),
_ => q.OrderByDescending(x => x.SortDate)
};
var total = await q.LongCountAsync();
var items = await q.Skip((req.Page - 1) * req.PageSize).Take(req.PageSize).ToListAsync();
return new PagedResponse<ContainerSummaryResponse>(
items.Select(MapContainerSummary),
new PaginationMeta(req.Page, req.PageSize, total, (int)Math.Ceiling((double)total / req.PageSize))
);
}
public async Task<ContainerDetailResponse> GetContainerBySlugAsync(string slug)
{
var container = await db.ProjectContainers
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
.Include(x => x.Projects.Where(p => p.DeletedAt == null))
.FirstOrDefaultAsync(x => x.Slug == slug)
?? throw new KeyNotFoundException($"Container '{slug}' not found");
return MapContainerDetail(container);
}
public async Task<ContainerDetailResponse> GetContainerByIdAsync(Guid id)
{
var container = await db.ProjectContainers
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
.Include(x => x.Projects.Where(p => p.DeletedAt == null))
.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Container {id} not found");
return MapContainerDetail(container);
}
public async Task<ContainerDetailResponse> CreateContainerAsync(CreateContainerRequest req)
{
if (!Enum.TryParse<ChooseMode>(req.PrimaryMode, true, out var mode))
throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}");
var container = new ProjectContainer
{
Slug = req.Slug, Name = req.Name, Description = req.Description, Keywords = req.Keywords,
NewsText = req.NewsText, Image = req.Image, Demo = req.Demo, FullDemo = req.FullDemo,
MiniDemo = req.MiniDemo, DemoScriptTag = req.DemoScriptTag, IsPublished = req.IsPublished,
IsPremium = req.IsPremium, IsMockup = req.IsMockup, PrimaryMode = mode, Sort = req.Sort,
SortDate = DateTime.UtcNow
};
db.ProjectContainers.Add(container);
await db.SaveChangesAsync();
await SyncContainerCategoriesAsync(container.Id, req.CategoryIds);
await SyncContainerTagsAsync(container.Id, req.TagIds);
return await GetContainerByIdAsync(container.Id);
}
public async Task<ContainerDetailResponse> UpdateContainerAsync(Guid id, UpdateContainerRequest req)
{
var container = await db.ProjectContainers.FindAsync(id)
?? throw new KeyNotFoundException($"Container {id} not found");
if (!Enum.TryParse<ChooseMode>(req.PrimaryMode, true, out var mode))
throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}");
container.Slug = req.Slug; container.Name = req.Name; container.Description = req.Description;
container.Keywords = req.Keywords; container.NewsText = req.NewsText; container.Image = req.Image;
container.Demo = req.Demo; container.FullDemo = req.FullDemo; container.MiniDemo = req.MiniDemo;
container.DemoScriptTag = req.DemoScriptTag; container.IsPublished = req.IsPublished;
container.IsPremium = req.IsPremium; container.IsMockup = req.IsMockup;
container.PrimaryMode = mode; container.Sort = req.Sort; container.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await SyncContainerCategoriesAsync(id, req.CategoryIds);
await SyncContainerTagsAsync(id, req.TagIds);
return await GetContainerByIdAsync(id);
}
public async Task DeleteContainerAsync(Guid id)
{
var container = await db.ProjectContainers.FindAsync(id)
?? throw new KeyNotFoundException($"Container {id} not found");
container.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<ProjectDetailResponse> GetProjectDetailAsync(Guid id)
{
var project = await db.Projects
.Include(x => x.Scenes.Where(s => s.DeletedAt == null)).ThenInclude(x => x.RepeaterItems)
.Include(x => x.Scenes).ThenInclude(x => x.ContentElements)
.Include(x => x.Scenes).ThenInclude(x => x.ColorElements)
.Include(x => x.Scenes).ThenInclude(x => x.ColorPresets).ThenInclude(x => x.Items)
.Include(x => x.Scenes).ThenInclude(x => x.Characters).ThenInclude(x => x.Controllers).ThenInclude(x => x.Options)
.Include(x => x.SharedColors)
.Include(x => x.SharedLayers)
.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Project {id} not found");
return MapProjectDetail(project);
}
public async Task<ProjectDetailResponse> CreateProjectAsync(CreateProjectRequest req)
{
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
var project = new Project
{
ContainerId = req.ContainerId, ProjectServerId = req.ProjectServerId, Name = req.Name,
Description = req.Description, Image = req.Image, FullDemo = req.FullDemo,
DemoScriptTag = req.DemoScriptTag, DownloadLink = req.DownloadLink, Folder = req.Folder,
OriginalWidth = req.OriginalWidth, OriginalHeight = req.OriginalHeight, Aspect = req.Aspect,
ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec,
MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode,
Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp,
IsPublished = req.IsPublished, Sort = req.Sort
};
db.Projects.Add(project);
await db.SaveChangesAsync();
return await GetProjectDetailAsync(project.Id);
}
public async Task<ProjectDetailResponse> UpdateProjectAsync(Guid id, UpdateProjectRequest req)
{
var project = await db.Projects.FindAsync(id)
?? throw new KeyNotFoundException($"Project {id} not found");
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
project.Name = req.Name; project.Description = req.Description; project.Image = req.Image;
project.FullDemo = req.FullDemo; project.DemoScriptTag = req.DemoScriptTag;
project.DownloadLink = req.DownloadLink; project.Folder = req.Folder;
project.OriginalWidth = req.OriginalWidth; project.OriginalHeight = req.OriginalHeight;
project.Aspect = req.Aspect; project.ProjectDurationSec = req.ProjectDurationSec;
project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec;
project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution;
project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp;
project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg;
project.SharedColorPresetsSvg = req.SharedColorPresetsSvg;
project.IsPublished = req.IsPublished; project.Sort = req.Sort;
project.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return await GetProjectDetailAsync(project.Id);
}
public async Task DeleteProjectAsync(Guid id)
{
var project = await db.Projects.FindAsync(id)
?? throw new KeyNotFoundException($"Project {id} not found");
project.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task IncrementContainerViewAsync(Guid id)
{
await db.ProjectContainers
.Where(x => x.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ViewCount, x => x.ViewCount + 1));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task SyncContainerCategoriesAsync(Guid containerId, List<Guid> categoryIds)
{
var existing = await db.ContainerCategories.Where(x => x.ContainerId == containerId).ToListAsync();
db.ContainerCategories.RemoveRange(existing);
for (int i = 0; i < categoryIds.Count; i++)
db.ContainerCategories.Add(new ContainerCategory { ContainerId = containerId, CategoryId = categoryIds[i], Sort = i });
await db.SaveChangesAsync();
}
private async Task SyncContainerTagsAsync(Guid containerId, List<Guid> tagIds)
{
var existing = await db.ContainerTags.Where(x => x.ContainerId == containerId).ToListAsync();
db.ContainerTags.RemoveRange(existing);
foreach (var tagId in tagIds)
db.ContainerTags.Add(new ContainerTag { ContainerId = containerId, TagId = tagId });
await db.SaveChangesAsync();
}
private static ContainerSummaryResponse MapContainerSummary(ProjectContainer c) => new(
c.Id, c.Slug, c.Name, c.Description, c.Image, c.Demo, c.MiniDemo,
c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(),
c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate,
c.ContainerCategories.Select(cc => cc.Category.Slug).ToList(),
c.ContainerTags.Select(ct => ct.Tag.Name).ToList()
);
private static ContainerDetailResponse MapContainerDetail(ProjectContainer c) => new(
c.Id, c.Slug, c.Name, c.Description, c.Keywords, c.NewsText,
c.Image, c.Demo, c.FullDemo, c.MiniDemo, c.DemoScriptTag,
c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(),
c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate,
c.Projects.Select(MapProject).ToList(),
c.ContainerCategories.Select(cc => MapCategoryFlat(cc.Category)).ToList(),
c.ContainerTags.Select(ct => new TagResponse(ct.Tag.Id, ct.Tag.Name, ct.Tag.LatinName, ct.Tag.Slug, ct.Tag.AppliesToMode, ct.Tag.IsActive)).ToList()
);
private static CategoryResponse MapCategoryFlat(Category c) => new(
c.Id, c.ParentId, c.Name, c.Slug, c.Description, c.ImageUrl, c.Icon,
c.IsActive, c.Sort, []
);
private static ProjectResponse MapProject(Project p) => new(
p.Id, p.ContainerId, p.Name, p.Image, p.FullDemo,
p.OriginalWidth, p.OriginalHeight, p.Aspect,
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(),
p.IsPublished, p.Sort
);
private static ProjectDetailResponse MapProjectDetail(Project p) => new(
p.Id, p.ContainerId, p.Name, p.Description, p.Image, p.FullDemo, p.DemoScriptTag, p.DownloadLink,
p.OriginalWidth, p.OriginalHeight, p.Aspect,
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp,
p.SharedLayerImage, p.IsPublished, p.Sort,
p.Scenes.Select(MapScene).ToList(),
p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(),
p.SharedLayers.Select(MapSharedLayer).ToList()
);
private static SceneResponse MapScene(Scene s) => new(
s.Id, s.ProjectId, s.Key, s.Title, s.LocalizedTitle, s.SceneType.ToString(),
s.Image, s.Demo, s.SnapshotUrl, s.GenerateKf,
s.DefaultDurationSec, s.MinDurationSec, s.MaxDurationSec, s.OverlapAtEndSec,
s.CanHandleDuration, s.ManualColorSelection, s.Sort, s.IsActive
);
private static SharedLayerResponse MapSharedLayer(SharedLayer sl) => new(
sl.Id, sl.Key, sl.Title, sl.LocalizedTitle, sl.Hint, sl.Type.ToString(), sl.DefaultValue,
sl.FontId, sl.FontFace, sl.FontSize, sl.IsFontChangeable, sl.IsFontSizeChangeable,
sl.Justify.ToString(), sl.CanJustify, sl.PositionInContainer, sl.IsTextBox, sl.MaxSize,
sl.VideoSupport, sl.MinDurationSec, sl.MaxDurationSec, sl.Width, sl.Height,
sl.MappedList, sl.AiInputType.ToString(), sl.IsHidden, sl.IsFocused,
sl.VirtualCount, sl.Sort
);
}