Files
clinicpro/src/Blog/Controller/BlogController.php
T

621 lines
28 KiB
PHP

<?php
namespace App\Blog\Controller;
use App\Auth\Entity\User;
use App\Blog\Entity\Blog;
use App\Blog\Repository\BlogRepository;
use App\Location\Repository\CityRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Blog')]
class BlogController extends BaseController
{
public function __construct(
private readonly BlogRepository $blogRepo,
private readonly CityRepository $cityRepo,
private readonly FileValidatorService $fileValidator,
private readonly \App\Blog\Service\BlogWriter $blogWriter,
private readonly string $projectDir,
) {}
/**
* city_id ورودی ادمین را به Entity تبدیل می‌کند.
* مقدار خالی/صفر/null یعنی «سراسری» و عمداً به null نگاشت می‌شود.
*
* @throws \App\Shared\Exception\AppException وقتی شناسهٔ شهر نامعتبر باشد
*/
private function resolveCity(mixed $cityId): ?\App\Location\Entity\City
{
if ($cityId === null || $cityId === '' || (int) $cityId === 0) {
return null;
}
$city = $this->cityRepo->find((int) $cityId);
if ($city === null) {
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'شهر یافت نشد', 422);
}
return $city;
}
// ── Public list/detail ────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/blogs',
summary: 'List published blog posts (paginated)',
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20, maximum: 50)),
new OA\Parameter(
name: 'city_id',
in: 'query',
required: false,
description: 'Scope to one city: returns that city\'s posts plus nationwide posts (city_id IS NULL). Omit to return every published post.',
schema: new OA\Schema(type: 'integer')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of published blog posts',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(
property: 'meta',
properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
],
type: 'object'
),
]
)
),
]
)]
#[Route('/api/v1/blogs', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$tag = $request->query->get('tag') ?: null;
$cityId = $request->query->get('city_id') !== null
? max(1, (int) $request->query->get('city_id'))
: null;
$blogs = array_map(
fn(Blog $b) => $b->toListArray(),
$this->blogRepo->findPublished($page, $limit, $tag, $cityId)
);
$total = $this->blogRepo->countPublished($tag, $cityId);
return $this->paginated($blogs, $total, $page, $limit);
}
#[OA\Get(
path: '/api/v1/blog/{slug}',
summary: 'Get a published blog post by slug or UUID',
parameters: [
new OA\Parameter(
name: 'slug',
in: 'path',
required: true,
description: 'Blog slug or UUID',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'city_id',
in: 'query',
required: false,
description: 'Domain scope of the caller. A post that belongs to another city is 404 here, exactly as it is absent from GET /api/v1/blogs?city_id=…. Nationwide posts (city IS NULL) are always returned. Omit on the main domain to read any post.',
schema: new OA\Schema(type: 'integer')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Blog post detail',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'data', type: 'object'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 404, description: 'Blog post not found, not published, or owned by another city'),
]
)]
#[Route('/api/v1/blog/{slug}', methods: ['GET'])]
public function detail(string $slug, Request $request): JsonResponse
{
$blog = $this->blogRepo->findBySlug($slug) ?? $this->blogRepo->findByUuid($slug);
if ($blog === null || $blog->getStatus() !== Blog::STATUS_PUBLISHED) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
// یک پستِ شهریافته فقط روی دامنهٔ خودش وجود دارد. بدون این شرط، پستی که از
// لیستِ دامنه حذف شده بود همچنان با URL مستقیم روی هر دامنه‌ای ۲۰۰ می‌گرفت و
// همان محتوا روی چند دامنه تکرار می‌شد. پست سراسری (city IS NULL) استثناست.
$cityId = $request->query->get('city_id');
if ($cityId !== null && $cityId !== '' && !$this->isVisibleOnCity($blog, (int) $cityId)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
return $this->success(['data' => $blog->toArray()]);
}
/** آینهٔ BlogRepository::applyCityFilter — پست همان شهر یا پست سراسری. */
private function isVisibleOnCity(Blog $blog, int $cityId): bool
{
$owner = $blog->getCity();
return $owner === null || $owner->getId() === $cityId;
}
// ── Admin CRUD ────────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/blogs',
summary: 'List blog posts of ALL statuses for the admin panel (draft/published/archived)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20, maximum: 50)),
new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['draft', 'published', 'archived'])),
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
],
responses: [new OA\Response(response: 200, description: 'Paginated blog list (all statuses)')]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/blogs', methods: ['GET'])]
public function adminList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$status = $request->query->get('status') ?: null;
$search = $request->query->get('search') ?: null;
$items = array_map(
fn(Blog $b) => $b->toListArray(),
$this->blogRepo->findForAdmin($page, $limit, $status, $search)
);
$total = $this->blogRepo->countForAdmin($status, $search);
return $this->paginated($items, $total, $page, $limit);
}
#[OA\Post(
path: '/api/v1/blog',
summary: 'Create a new blog post (admin only)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['title', 'body'],
properties: [
new OA\Property(property: 'title', type: 'string'),
new OA\Property(property: 'body', type: 'string'),
new OA\Property(property: 'summary', type: 'string', nullable: true),
new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true),
new OA\Property(property: 'city_id', type: 'integer', nullable: true, description: 'City this post belongs to. Omit or null for a nationwide post.'),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Blog post created',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'data', type: 'object'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(
response: 422,
description: 'Validation error',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/blog', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
if ((isset($data['title']) && !is_string($data['title'])) || (isset($data['body']) && !is_string($data['body']))) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body باید رشته باشند', 422);
}
$title = trim((string) ($data['title'] ?? ''));
$body = trim((string) ($data['body'] ?? ''));
if (empty($title) || empty($body)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body الزامی است', 422);
}
// Idempotency for the content pipeline: a topic_slug is a stable (specialty,
// angle) key. A re-run must not create a duplicate — return the existing post.
$topicSlug = isset($data['topic_slug']) ? trim((string) $data['topic_slug']) : '';
if ($topicSlug !== '') {
$existing = $this->blogRepo->findByTopicSlug($topicSlug);
if ($existing !== null) {
return $this->success(['data' => $existing->toArray()], 200);
}
}
$blog = new Blog($user, $title, $body);
if (!empty($data['summary'])) $blog->setSummary($data['summary']);
if (!empty($data['tags'])) $blog->setTags((array)$data['tags']);
if (!empty($data['sources'])) $blog->setSources((array)$data['sources']);
if (!empty($data['status'])) $blog->setStatus($data['status']);
if (!empty($data['image_url'])) $blog->setImageUrl($data['image_url']);
if ($topicSlug !== '') $blog->setTopicSlug($topicSlug);
// review_status: null means "manual admin post". The pipeline sends
// "pending_review" so the post enters the doctor review queue.
if (!empty($data['review_status'])) $blog->setReviewStatus($data['review_status']);
// نبودِ city_id یعنی سراسری — پس همیشه اعمال می‌شود، نه فقط وقتی مقدار دارد.
$blog->setCity($this->resolveCity($data['city_id'] ?? null));
$this->blogWriter->applySeoFields($blog, $data);
// Ensure slug uniqueness
if ($this->blogRepo->findBySlug($blog->getSlug()) !== null) {
$blog->setSlug($blog->getSlug() . '-' . substr(uniqid(), -4));
}
$this->blogRepo->save($blog);
return $this->success(['data' => $blog->toArray()], 201);
}
#[OA\Patch(
path: '/api/v1/blog/{uuid}',
summary: 'Update a blog post (admin only)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'title', type: 'string', nullable: true),
new OA\Property(property: 'body', type: 'string', nullable: true),
new OA\Property(property: 'summary', type: 'string', nullable: true),
new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true),
new OA\Property(property: 'city_id', type: 'integer', nullable: true, description: 'City this post belongs to. Omit or null for a nationwide post.'),
]
)
),
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string', format: 'uuid')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Blog post updated',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'data', type: 'object'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(response: 404, description: 'Blog post not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/blog/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request): JsonResponse
{
$blog = $this->blogRepo->findByUuid($uuid);
if ($blog === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('title', $data)) $blog->setTitle($data['title']);
if (array_key_exists('body', $data)) $blog->setBody($data['body']);
if (array_key_exists('summary', $data)) $blog->setSummary($data['summary']);
if (array_key_exists('tags', $data)) $blog->setTags((array)$data['tags']);
if (array_key_exists('status', $data)) $blog->setStatus($data['status']);
if (array_key_exists('image_url', $data)) $blog->setImageUrl($data['image_url'] ?: null);
// PATCH: فقط وقتی صریحاً فرستاده شد تغییر کند. ارسال null یعنی «سراسری‌اش کن».
if (array_key_exists('city_id', $data)) $blog->setCity($this->resolveCity($data['city_id']));
$this->blogWriter->applySeoFields($blog, $data);
$this->blogRepo->save($blog);
return $this->success(['data' => $blog->toArray()]);
}
// ── Medical-review gate ───────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/blog/review-queue',
summary: 'List blog drafts awaiting doctor review (admin only)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20, maximum: 50)),
],
responses: [
new OA\Response(response: 200, description: 'Paginated review queue'),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/blog/review-queue', methods: ['GET'])]
public function reviewQueue(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$items = array_map(
fn(Blog $b) => $b->toListArray(),
$this->blogRepo->findByReviewStatus(Blog::REVIEW_PENDING, $page, $limit)
);
$total = $this->blogRepo->countByReviewStatus(Blog::REVIEW_PENDING);
return $this->paginated($items, $total, $page, $limit);
}
#[OA\Post(
path: '/api/v1/admin/blog/{uuid}/review',
summary: 'Approve or reject a blog draft (admin only). Approving may publish it.',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['decision'],
properties: [
new OA\Property(property: 'decision', type: 'string', enum: ['approved', 'rejected']),
new OA\Property(property: 'note', type: 'string', nullable: true, description: 'Reviewer note (required message on rejection).'),
new OA\Property(property: 'publish', type: 'boolean', nullable: true, description: 'On approval, publish immediately (default true).'),
]
)
),
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid')),
],
responses: [
new OA\Response(response: 200, description: 'Review decision recorded'),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(response: 404, description: 'Blog post not found'),
new OA\Response(response: 422, description: 'Invalid decision'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/blog/{uuid}/review', methods: ['POST'])]
public function review(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$blog = $this->blogRepo->findByUuid($uuid);
if ($blog === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$decision = $data['decision'] ?? null;
$note = isset($data['note']) ? trim((string) $data['note']) : null;
if (!in_array($decision, [Blog::REVIEW_APPROVED, Blog::REVIEW_REJECTED], true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'decision باید approved یا rejected باشد', 422);
}
if ($decision === Blog::REVIEW_REJECTED && ($note === null || $note === '')) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برای رد مقاله، درج دلیل الزامی است', 422);
}
$blog->applyReview($decision, $user, $note);
// Approving publishes by default; rejecting keeps the post as a draft.
if ($decision === Blog::REVIEW_APPROVED && ($data['publish'] ?? true)) {
$blog->setStatus(Blog::STATUS_PUBLISHED);
} elseif ($decision === Blog::REVIEW_REJECTED) {
$blog->setStatus(Blog::STATUS_DRAFT);
}
$this->blogRepo->save($blog);
return $this->success(['data' => $blog->toArray()]);
}
#[OA\Delete(
path: '/api/v1/blog/{uuid}',
summary: 'Delete a blog post (admin only)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string', format: 'uuid')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Blog post deleted',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(response: 404, description: 'Blog post not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/blog/{uuid}', methods: ['DELETE'])]
public function delete(string $uuid): JsonResponse
{
$blog = $this->blogRepo->findByUuid($uuid);
if ($blog === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
$this->blogRepo->remove($blog);
return $this->success(['message' => 'مقاله با موفقیت حذف شد']);
}
// ── Image upload ──────────────────────────────────────────────────────────
#[OA\Post(
path: '/file/upload/clinic_pro/blog/field_image',
summary: 'Upload a blog post image (admin only)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'multipart/form-data',
schema: new OA\Schema(
required: ['file'],
properties: [
new OA\Property(property: 'file', type: 'string', format: 'binary'),
new OA\Property(property: 'blog_uuid', type: 'string', format: 'uuid', nullable: true),
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'Image uploaded',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'image_url', type: 'string'),
new OA\Property(property: 'filename', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(
response: 422,
description: 'No file provided or invalid file',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/file/upload/clinic_pro/blog/field_image', methods: ['POST'])]
public function uploadImage(Request $request): JsonResponse
{
$file = $request->files->get('file');
$blogUuid = $request->request->get('blog_uuid', '');
if ($file === null) {
return $this->error(ErrorCodes::ERR_FILE_001, 'فایل ارسال نشده است', 422);
}
try {
$safeFilename = $this->fileValidator->validateUploadedFile($file);
} catch (\App\Shared\Exception\AppException $e) {
return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus());
}
$uploadDir = $this->projectDir . '/public/uploads/blogs/';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
$filename = uniqid('blog_') . '_' . $safeFilename;
$file->move($uploadDir, $filename);
$imageUrl = '/uploads/blogs/' . $filename;
$imagePath = $uploadDir . $filename;
if (!empty($blogUuid)) {
$blog = $this->blogRepo->findByUuid($blogUuid);
if ($blog !== null) {
$blog->setImageUrl($imageUrl)->setImagePath($imagePath);
$this->blogRepo->save($blog);
}
}
return $this->success(['image_url' => $imageUrl, 'filename' => $filename]);
}
}