621 lines
28 KiB
PHP
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]);
|
|
}
|
|
}
|