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:
hamed
2026-07-23 22:05:23 +03:30
parent 14730e43ce
commit 62b2f28c4f
14 changed files with 786 additions and 0 deletions
+48
View File
@@ -40,6 +40,54 @@ class BlogRepository extends ServiceEntityRepository
->getQuery()->getSingleScalarResult();
}
/**
* A representative's own posts, newest first, optionally filtered by status.
* @return Blog[]
*/
public function findByRepresentation(\App\Representation\Entity\Representation $rep, int $page = 1, int $limit = 20, ?string $status = null): array
{
$qb = $this->createQueryBuilder('b')
->leftJoin('b.city', 'c')->addSelect('c')
->where('b.representation = :rep')
->setParameter('rep', $rep)
->orderBy('b.createdAt', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
if ($status !== null && $status !== '') {
$qb->andWhere('b.status = :status')->setParameter('status', $status);
}
return $qb->getQuery()->getResult();
}
public function countByRepresentation(\App\Representation\Entity\Representation $rep, ?string $status = null): int
{
$qb = $this->createQueryBuilder('b')
->select('COUNT(b.id)')
->where('b.representation = :rep')
->setParameter('rep', $rep);
if ($status !== null && $status !== '') {
$qb->andWhere('b.status = :status')->setParameter('status', $status);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* Draft posts due for automatic publish: scheduled_at reached, and either not
* in the review workflow or already approved (the review gate wins).
* @return Blog[]
*/
public function findDueForPublish(int $now): array
{
return $this->createQueryBuilder('b')
->where('b.status = :draft')
->andWhere('b.scheduledAt IS NOT NULL AND b.scheduledAt <= :now')
->andWhere('b.reviewStatus IS NULL OR b.reviewStatus = :approved')
->setParameter('draft', Blog::STATUS_DRAFT)
->setParameter('now', $now)
->setParameter('approved', Blog::REVIEW_APPROVED)
->getQuery()->getResult();
}
/** @return Blog[] published, newest first */
public function findPublished(int $page = 1, int $limit = 20, ?string $tag = null, ?int $cityId = null): array
{