From 14730e43cead298b99b31a963e9e28f646ca18d2 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 23 Jul 2026 21:01:58 +0330 Subject: [PATCH] feat(blog): implement medical review gate for blog posts - Added new fields to the Blog entity: sources, review_status, reviewer, reviewed_at, review_note, and topic_slug. - Created API endpoints for reviewing blog posts: GET /api/v1/admin/blog/review-queue and POST /api/v1/admin/blog/{uuid}/review. - Updated BlogController to handle review logic, including approval and rejection of posts. - Introduced BlogReviewPage component for admin interface to manage blog reviews. - Added migration to update the database schema for new fields. - Implemented tests for review queue functionality and review decision handling. --- assets/admin/App.tsx | 2 + assets/admin/components/layout/Sidebar.tsx | 5 + assets/admin/pages/BlogReviewPage.tsx | 214 +++++++++++++++++++++ assets/admin/types/index.ts | 9 + docs/api/blog.md | 102 ++++++++++ migrations/Version20260723171254.php | 40 ++++ src/Blog/Controller/BlogController.php | 107 +++++++++++ src/Blog/Entity/Blog.php | 82 ++++++++ src/Blog/Repository/BlogRepository.php | 27 +++ tests/Blog/BlogReviewGateTest.php | 158 +++++++++++++++ 10 files changed, 746 insertions(+) create mode 100644 assets/admin/pages/BlogReviewPage.tsx create mode 100644 migrations/Version20260723171254.php create mode 100644 tests/Blog/BlogReviewGateTest.php diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index c77870e2..584b86aa 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -31,6 +31,7 @@ import LogsPage from './pages/LogsPage'; import CategoriesPage from './pages/CategoriesPage'; import BlogsPage from './pages/BlogsPage'; import BlogFormPage from './pages/BlogFormPage'; +import BlogReviewPage from './pages/BlogReviewPage'; import SecretariesPage from './pages/SecretariesPage'; import ClinicDoctorsPage from './pages/ClinicDoctorsPage'; import SettingsPage from './pages/SettingsPage'; @@ -204,6 +205,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index fe98409e..9d4da1f7 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -191,6 +191,11 @@ function buildSections( icon: DocumentTextIcon, label: "بلاگ", }, + { + to: "/admin/blog-review", + icon: DocumentTextIcon, + label: "بازبینی بلاگ", + }, { to: "/admin/sms", icon: DevicePhoneMobileIcon, diff --git a/assets/admin/pages/BlogReviewPage.tsx b/assets/admin/pages/BlogReviewPage.tsx new file mode 100644 index 00000000..ecf255fa --- /dev/null +++ b/assets/admin/pages/BlogReviewPage.tsx @@ -0,0 +1,214 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse, PaginatedResponse } from '../lib/api'; +import type { Blog } from '../types'; +import { formatDate } from '../lib/utils'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { Column } from '../components/ui/DataTable'; +import Pagination from '../components/ui/Pagination'; +import Modal from '../components/ui/Modal'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; + +const limit = 15; + +/** وضعیت بازبینی → رنگ نشان. مقاله‌های صف همه pending_review هستند. */ +function reviewBadgeColor(status?: string | null): string { + switch (status) { + case 'approved': return 'green'; + case 'rejected': return 'red'; + case 'pending_review': return 'amber'; + default: return 'gray'; + } +} + +function reviewBadgeLabel(status?: string | null): string { + switch (status) { + case 'approved': return 'تأییدشده'; + case 'rejected': return 'ردشده'; + case 'pending_review': return 'در انتظار بازبینی'; + default: return status ?? '—'; + } +} + +export default function BlogReviewPage() { + const qc = useQueryClient(); + const [page, setPage] = useState(1); + const [preview, setPreview] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectNote, setRejectNote] = useState(''); + + const { data, isLoading } = useQuery({ + queryKey: ['blog-review-queue', page], + queryFn: () => { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + return api.get>(`/api/v1/admin/blog/review-queue?${params}`); + }, + }); + + const items = data?.data ?? []; + const total = data?.meta?.totalRecords ?? 0; + + const approveMutation = useMutation({ + mutationFn: (blog: Blog) => + api.post>(`/api/v1/admin/blog/${blog.uuid}/review`, { + decision: 'approved', + publish: true, + }), + onSuccess: () => { + toast.success('مقاله تأیید و منتشر شد'); + setPreview(null); + qc.invalidateQueries({ queryKey: ['blog-review-queue'] }); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const rejectMutation = useMutation({ + mutationFn: (vars: { blog: Blog; note: string }) => + api.post>(`/api/v1/admin/blog/${vars.blog.uuid}/review`, { + decision: 'rejected', + note: vars.note, + }), + onSuccess: () => { + toast.success('مقاله رد شد'); + setRejectTarget(null); + setRejectNote(''); + setPreview(null); + qc.invalidateQueries({ queryKey: ['blog-review-queue'] }); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const columns: Column[] = [ + { key: 'title', header: 'عنوان', render: (b) => {b.title} }, + { + key: 'topic_slug', + header: 'موضوع', + render: (b) => {b.topic_slug ?? '—'}, + }, + { + key: 'review_status', + header: 'وضعیت', + render: (b) => ( + + + {reviewBadgeLabel(b.review_status)} + + ), + }, + { key: 'created_at', header: 'تاریخ', render: (b) => formatDate(b.created_at) }, + ]; + + return ( +
+ + +
+ + columns={columns} + data={items} + loading={isLoading} + emptyMessage="مقاله‌ای در انتظار بازبینی نیست" + actions={(b) => ( + + )} + /> + +
+ + {/* پیش‌نمایش کامل + تصمیم */} + setPreview(null)} + footer={ + preview ? ( +
+ + +
+ ) : undefined + } + > + {preview && ( +
+ {preview.summary &&

{preview.summary}

} +
+ {preview.sources && preview.sources.length > 0 && ( +
+

منابع

+ +
+ )} +
+ )} + + + {/* رد با درج دلیل الزامی */} + { + if (!rejectTarget) return; + if (!rejectNote.trim()) { + toast.error('درج دلیل الزامی است'); + return; + } + rejectMutation.mutate({ blog: rejectTarget, note: rejectNote.trim() }); + }} + onCancel={() => { + setRejectTarget(null); + setRejectNote(''); + }} + > +