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]); } }