feat(content+admin): home-events CRUD + comments moderation
Build backend images / build content-svc (push) Failing after 45s
Build backend images / build file-svc (push) Failing after 1m3s
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 0s

- content-svc: home-events gains Create/Update/Delete + includeInactive list
  (POST/PUT/DELETE /v1/home-events, admin-gated; dates coerced to UTC)
- admin /admin/home-events: full CRUD for homepage hero event banners
- admin /admin/comments: list + approve/unapprove + delete (moderation)
- AdminResource: optional listQuery to fetch inactive rows for admin views

Fills the remaining legacy-admin gaps (home events, comments).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 22:24:56 +03:30
parent 3acd366fda
commit 0b538e1b1e
10 changed files with 173 additions and 15 deletions
+3 -1
View File
@@ -329,7 +329,9 @@
"crm": "CRM", "crm": "CRM",
"ranking": "Ranking", "ranking": "Ranking",
"stats": "Dashboard", "stats": "Dashboard",
"music": "Music" "music": "Music",
"homeEvents": "Home Events",
"comments": "Comments"
}, },
"appAdminNodesPage": { "appAdminNodesPage": {
"title": "Render Nodes", "title": "Render Nodes",
+3 -1
View File
@@ -329,7 +329,9 @@
"crm": "مدیریت مشتریان", "crm": "مدیریت مشتریان",
"ranking": "رتبه‌بندی", "ranking": "رتبه‌بندی",
"stats": "داشبورد", "stats": "داشبورد",
"music": "موسیقی" "music": "موسیقی",
"homeEvents": "رویدادهای صفحه اصلی",
"comments": "نظرات"
}, },
"appAdminNodesPage": { "appAdminNodesPage": {
"title": "نودهای رندر", "title": "نودهای رندر",
@@ -172,16 +172,55 @@ public class CmsService(ContentDbContext db)
// ── Home Page Events ────────────────────────────────────────────────────── // ── Home Page Events ──────────────────────────────────────────────────────
public async Task<List<HomePageEventResponse>> GetHomePageEventsAsync(Guid? tenantId) public async Task<List<HomePageEventResponse>> GetHomePageEventsAsync(Guid? tenantId, bool includeInactive = false)
{ {
return await db.HomePageEvents var q = db.HomePageEvents.Where(x => x.TenantId == null || x.TenantId == tenantId);
.Where(x => x.IsActive && (x.TenantId == null || x.TenantId == tenantId)) if (!includeInactive) q = q.Where(x => x.IsActive);
.OrderBy(x => x.Sort) return await q.OrderBy(x => x.Sort).Select(e => MapHomeEvent(e)).ToListAsync();
.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, private static HomePageEventResponse MapHomeEvent(HomePageEvent e) => new(
e.TextColor, e.Image, e.IsActive, e.Sort, e.StartsAt, e.EndsAt e.Id, e.Title, e.Subtitle, e.Description, e.Badge, e.BadgeClass,
)).ToListAsync(); 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);
} }
// ── Website Settings ────────────────────────────────────────────────────── // ── Website Settings ──────────────────────────────────────────────────────
@@ -103,8 +103,26 @@ public class SlidesController(CmsService svc) : ControllerBase
public class HomePageEventsController(CmsService svc) : ControllerBase public class HomePageEventsController(CmsService svc) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> Get([FromQuery] Guid? tenantId = null) => public async Task<IActionResult> Get([FromQuery] Guid? tenantId = null, [FromQuery] bool includeInactive = false) =>
Ok(await svc.GetHomePageEventsAsync(tenantId)); Ok(await svc.GetHomePageEventsAsync(tenantId, includeInactive));
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> Create([FromBody] UpsertHomeEventRequest req) =>
Ok(await svc.CreateHomeEventAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpsertHomeEventRequest req) =>
Ok(await svc.UpdateHomeEventAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteHomeEventAsync(id);
return NoContent();
}
} }
[ApiController] [ApiController]
@@ -95,6 +95,25 @@ public record CreateMusicTrackRequest(
int Sort int Sort
); );
public record UpsertHomeEventRequest(
string? Title,
string? Subtitle,
string? Description,
string? Badge,
string? BadgeClass,
string? ButtonText,
string? ButtonUrl,
string? ButtonClass,
string? Color,
string? BackgroundColor,
string? TextColor,
string? Image,
bool IsActive = true,
int Sort = 0,
DateTime? StartsAt = null,
DateTime? EndsAt = null
);
public record UpdateMusicTrackRequest( public record UpdateMusicTrackRequest(
string Name, string Name,
string? Caption, string? Caption,
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { commentsConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={commentsConfig} />;
}
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { homeEventsConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={homeEventsConfig} />;
}
+2
View File
@@ -25,6 +25,8 @@ export default async function AdminLayout({
{ href: "/admin/music", label: t("music") }, { href: "/admin/music", label: t("music") },
{ href: "/admin/blogs", label: t("blogs") }, { href: "/admin/blogs", label: t("blogs") },
{ href: "/admin/slides", label: t("slides") }, { href: "/admin/slides", label: t("slides") },
{ href: "/admin/home-events", label: t("homeEvents") },
{ href: "/admin/comments", label: t("comments") },
{ href: "/admin/files", label: t("media") }, { href: "/admin/files", label: t("media") },
{ href: "/admin/ai", label: t("aiContent") }, { href: "/admin/ai", label: t("aiContent") },
{ href: "/admin/messaging", label: t("messaging") }, { href: "/admin/messaging", label: t("messaging") },
+3 -2
View File
@@ -26,6 +26,7 @@ export interface ResourceConfig {
basePath: string; // e.g. "categories" basePath: string; // e.g. "categories"
idKey?: string; // default "id" idKey?: string; // default "id"
listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array
listQuery?: string; // extra query string appended to the list fetch, e.g. "includeInactive=true"
columns: ColumnDef[]; columns: ColumnDef[];
fields?: FieldDef[]; fields?: FieldDef[];
canCreate?: boolean; canCreate?: boolean;
@@ -55,7 +56,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const res = await fetch(url(), { cache: "no-store" }); const res = await fetch(url(config.listQuery ? `?${config.listQuery}` : ""), { cache: "no-store" });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? "Failed to load"); if (!res.ok) throw new Error(data?.error ?? "Failed to load");
const list = config.listKey ? data?.[config.listKey] : data; const list = config.listKey ? data?.[config.listKey] : data;
@@ -66,7 +67,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
setLoading(false); setLoading(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.basePath, config.listKey]); }, [config.basePath, config.listKey, config.listQuery]);
useEffect(() => { useEffect(() => {
reload(); reload();
+59
View File
@@ -176,6 +176,65 @@ export const slidesConfig: ResourceConfig = {
], ],
}; };
export const homeEventsConfig: ResourceConfig = {
title: "Home Events",
description: "Promotional event banners on the homepage hero.",
basePath: "home-events",
listQuery: "includeInactive=true",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "title", label: "Title" },
{ key: "badge", label: "Badge" },
{ key: "sort", label: "Sort" },
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
],
fields: [
{ key: "title", label: "Title", required: true },
{ key: "subtitle", label: "Subtitle" },
{ key: "description", label: "Description", type: "textarea" },
{ key: "badge", label: "Badge text" },
{ key: "image", label: "Image", type: "image" },
{ key: "button_text", label: "Button text" },
{ key: "button_url", label: "Button URL" },
{ key: "color", label: "Text color (hex)" },
{ key: "background_color", label: "Background color (hex)" },
{ key: "sort", label: "Sort", type: "number" },
{ key: "is_active", label: "Active", type: "checkbox" },
],
};
export const commentsConfig: ResourceConfig = {
title: "Comments",
description: "Moderate user comments on blogs and templates.",
basePath: "comments",
listKey: "data",
canCreate: false,
canEdit: false,
canDelete: true,
columns: [
{ key: "author_name", label: "Author" },
{ key: "content", label: "Comment" },
{ key: "is_approved", label: "Approved", render: (r) => badge(!!r.is_approved, "approved", "pending") },
],
rowActions: (row, reload) => {
const id = String(row.id);
const approve = async (val: boolean) => {
await fetch(`/api/admin/resource/comments/${id}/approve?approve=${val}`, { method: "PATCH" });
reload?.();
};
return (
<button
className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]"
onClick={() => approve(!row.is_approved)}
>
{row.is_approved ? "لغو تأیید" : "تأیید"}
</button>
);
},
};
export const usersConfig: ResourceConfig = { export const usersConfig: ResourceConfig = {
title: "Users", title: "Users",
description: "Accounts in this tenant. Ban or unban below.", description: "Accounts in this tenant. Ban or unban below.",