feat(blog): add SEO fields, scheduling, and representative ownership to blog posts
- Introduced new SEO fields (meta_title, meta_description, primary_keyword, secondary_keywords, faq, internal_links, external_links, reading_time, canonical_url, og_image) to the Blog entity. - Added scheduling capability with a scheduled_at field to manage automatic publishing of blog posts. - Implemented representative ownership through a foreign key representation_id in the Blog entity, allowing representatives to manage their own posts. - Updated BlogController and RepresentationBlogController to handle new fields and ensure proper data handling for SEO and scheduling. - Created BlogWriter service to encapsulate the logic for applying SEO and scheduling fields to blog entities. - Added PublishScheduledBlogsMessage and its handler to manage the publishing of scheduled blogs. - Implemented ScheduledBlogPublisher service to publish drafts whose scheduled_at has arrived, respecting review status. - Created migration to update the database schema with new fields and constraints. - Added tests to ensure the correct functionality of new features, including SEO fields, representative scope, and scheduled publishing.
This commit is contained in:
@@ -23,6 +23,7 @@ class BlogController extends BaseController
|
||||
private readonly BlogRepository $blogRepo,
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly \App\Blog\Service\BlogWriter $blogWriter,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
@@ -243,6 +244,7 @@ class BlogController extends BaseController
|
||||
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) {
|
||||
@@ -319,6 +321,7 @@ class BlogController extends BaseController
|
||||
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);
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Blog\Entity\Blog;
|
||||
use App\Blog\Repository\BlogRepository;
|
||||
use App\Blog\Service\BlogWriter;
|
||||
use App\Location\Entity\City;
|
||||
use App\Location\Repository\CityRepository;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Blog management scoped to a single representative. A representative sees and
|
||||
* edits only their own posts, and may only target cities within their coverage
|
||||
* (representation.cities). Admin CRUD lives in BlogController.
|
||||
*/
|
||||
#[OA\Tag(name: 'Blog')]
|
||||
#[IsGranted('ROLE_REPRESENTATION')]
|
||||
class RepresentationBlogController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BlogRepository $blogRepo,
|
||||
private readonly RepresentationRepository $repRepo,
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly BlogWriter $writer,
|
||||
) {}
|
||||
|
||||
private function currentRepresentation(User $user): Representation
|
||||
{
|
||||
$rep = $this->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\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'] ?? ''));
|
||||
$body = 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);
|
||||
|
||||
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((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);
|
||||
|
||||
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->blogRepo->remove($blog);
|
||||
return $this->success(['message' => 'مقاله حذف شد']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user