repRepo->findByUser($user); if ($rep === null) { throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_AUTH_006, 'پروفایل نماینده یافت نشد', 403); } return $rep; } /** Resolve a city_id, enforcing it belongs to the representative's coverage. */ private function resolveOwnedCity(mixed $cityId, Representation $rep): ?City { if ($cityId === null || $cityId === '' || (int) $cityId === 0) { return null; // nationwide is not allowed here, but null is handled by the caller } $city = $this->cityRepo->find((int) $cityId); if ($city === null) { throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'شهر یافت نشد', 422); } if (!in_array((int) $city->getId(), $rep->cityIds(), true)) { throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'این شهر جزو حوزهٔ نمایندگی شما نیست', 422); } return $city; } private function ownedBlogOr404(string $uuid, Representation $rep): Blog { $blog = $this->blogRepo->findByUuid($uuid); if ($blog === null || $blog->getRepresentation()?->getId() !== $rep->getId()) { throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404); } return $blog; } #[OA\Get(path: '/api/v1/representation/blogs', summary: "List the representative's own blog posts", security: [['bearerAuth' => []]])] #[Route('/api/v1/representation/blogs', methods: ['GET'])] public function list(Request $request, #[CurrentUser] User $user): JsonResponse { $rep = $this->currentRepresentation($user); $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; $items = array_map( fn(Blog $b) => $b->toListArray(), $this->blogRepo->findByRepresentation($rep, $page, $limit, $status) ); $total = $this->blogRepo->countByRepresentation($rep, $status); return $this->paginated($items, $total, $page, $limit); } #[OA\Get(path: '/api/v1/representation/blog/{uuid}', summary: "Get one of the representative's own posts", security: [['bearerAuth' => []]])] #[Route('/api/v1/representation/blog/{uuid}', methods: ['GET'])] public function detail(string $uuid, #[CurrentUser] User $user): JsonResponse { $rep = $this->currentRepresentation($user); $blog = $this->ownedBlogOr404($uuid, $rep); return $this->success(['data' => $blog->toArray()]); } #[OA\Post(path: '/api/v1/representation/blog', summary: 'Create a post owned by the representative', security: [['bearerAuth' => []]])] #[Route('/api/v1/representation/blog', methods: ['POST'])] public function create(Request $request, #[CurrentUser] User $user): JsonResponse { $rep = $this->currentRepresentation($user); $data = json_decode($request->getContent(), true) ?? []; $title = trim((string) ($data['title'] ?? '')); // پاک‌سازی پیش از سنجشِ خالی‌بودن — بدنه‌ای که چیزی جز markup ناامن ندارد // باید ۴۲۲ بگیرد نه اینکه خالی ذخیره شود. $body = $this->bodySanitizer->clean(trim((string) ($data['body'] ?? ''))); if ($title === '' || $body === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body الزامی است', 422); } $blog = new Blog($user, $title, $body); $blog->setRepresentation($rep); $blog->setCity($this->resolveOwnedCity($data['city_id'] ?? null, $rep)); if (!empty($data['summary'])) $blog->setSummary($data['summary']); if (!empty($data['tags'])) $blog->setTags((array) $data['tags']); if (!empty($data['image_url'])) $blog->setImageUrl($data['image_url']); if (!empty($data['status']) && in_array($data['status'], [Blog::STATUS_DRAFT, Blog::STATUS_PUBLISHED], true)) { $blog->setStatus($data['status']); } $this->writer->applySeoFields($blog, $data); if ($this->blogRepo->findBySlug($blog->getSlug()) !== null) { $blog->setSlug($blog->getSlug() . '-' . substr(uniqid(), -4)); } $this->blogRepo->save($blog); $this->cacheInvalidator->invalidate($blog); return $this->success(['data' => $blog->toArray()], 201); } #[OA\Patch(path: '/api/v1/representation/blog/{uuid}', summary: "Update the representative's own post", security: [['bearerAuth' => []]])] #[Route('/api/v1/representation/blog/{uuid}', methods: ['PATCH'])] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $rep = $this->currentRepresentation($user); $blog = $this->ownedBlogOr404($uuid, $rep); $data = json_decode($request->getContent(), true) ?? []; if (array_key_exists('title', $data)) $blog->setTitle((string) $data['title']); if (array_key_exists('body', $data)) $blog->setBody($this->bodySanitizer->clean((string) $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('image_url', $data)) $blog->setImageUrl($data['image_url'] ?: null); if (array_key_exists('status', $data) && in_array($data['status'], [Blog::STATUS_DRAFT, Blog::STATUS_PUBLISHED, Blog::STATUS_ARCHIVED], true)) { $blog->setStatus($data['status']); } if (array_key_exists('city_id', $data)) { $blog->setCity($this->resolveOwnedCity($data['city_id'], $rep)); } $this->writer->applySeoFields($blog, $data); $this->blogRepo->save($blog); $this->cacheInvalidator->invalidate($blog); return $this->success(['data' => $blog->toArray()]); } #[OA\Delete(path: '/api/v1/representation/blog/{uuid}', summary: "Delete the representative's own post", security: [['bearerAuth' => []]])] #[Route('/api/v1/representation/blog/{uuid}', methods: ['DELETE'])] public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse { $rep = $this->currentRepresentation($user); $blog = $this->ownedBlogOr404($uuid, $rep); $this->cacheInvalidator->invalidate($blog); $this->blogRepo->remove($blog); return $this->success(['message' => 'مقاله حذف شد']); } }