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
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Blog\Service;
use App\Blog\Entity\Blog;
/**
* Maps the SEO + scheduling fields of a blog write request onto the entity.
* Shared by the admin BlogController and the representative RepresentationBlogController
* so both accept the same field set without duplicating the mapping.
*
* Core fields (title/body/summary/tags/status/image/city) and ownership stay in
* the controllers — this service is only the optional SEO/scheduling surface.
*/
class BlogWriter
{
/** Apply any SEO / scheduling fields present in $data. Missing keys are left untouched. */
public function applySeoFields(Blog $blog, array $data): void
{
if (array_key_exists('meta_title', $data)) $blog->setMetaTitle($this->str($data['meta_title']));
if (array_key_exists('meta_description', $data)) $blog->setMetaDescription($this->str($data['meta_description']));
if (array_key_exists('primary_keyword', $data)) $blog->setPrimaryKeyword($this->str($data['primary_keyword']));
if (array_key_exists('secondary_keywords', $data)) $blog->setSecondaryKeywords($this->arr($data['secondary_keywords']));
if (array_key_exists('faq', $data)) $blog->setFaq($this->faq($data['faq']));
if (array_key_exists('internal_links', $data)) $blog->setInternalLinks($this->arr($data['internal_links']));
if (array_key_exists('external_links', $data)) $blog->setExternalLinks($this->arr($data['external_links']));
if (array_key_exists('reading_time', $data)) $blog->setReadingTime($this->int($data['reading_time']));
if (array_key_exists('canonical_url', $data)) $blog->setCanonicalUrl($this->str($data['canonical_url']));
if (array_key_exists('og_image', $data)) $blog->setOgImage($this->str($data['og_image']));
if (array_key_exists('scheduled_at', $data)) $blog->setScheduledAt($this->int($data['scheduled_at']));
}
private function str(mixed $v): ?string
{
$v = is_string($v) ? trim($v) : $v;
return ($v === null || $v === '') ? null : (string) $v;
}
private function int(mixed $v): ?int
{
return ($v === null || $v === '') ? null : (int) $v;
}
/** @return list<string> non-empty string values */
private function arr(mixed $v): array
{
if (!is_array($v)) {
return [];
}
return array_values(array_filter(array_map(
fn($x) => is_string($x) ? trim($x) : $x,
$v
), fn($x) => $x !== null && $x !== ''));
}
/** @return list<array{q:string,a:string}> — drops malformed entries. */
private function faq(mixed $v): array
{
if (!is_array($v)) {
return [];
}
$out = [];
foreach ($v as $item) {
if (is_array($item) && !empty($item['q']) && !empty($item['a'])) {
$out[] = ['q' => trim((string) $item['q']), 'a' => trim((string) $item['a'])];
}
}
return $out;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Blog\Service;
use App\Blog\Entity\Blog;
use App\Blog\Repository\BlogRepository;
/**
* Publishes drafts whose scheduled_at has arrived. A post in the review workflow
* is published only once review_status = approved — the medical-review gate wins
* over the schedule, so AI-generated content is never auto-published unreviewed.
*/
class ScheduledBlogPublisher
{
public function __construct(private readonly BlogRepository $blogRepo) {}
/** @return int number of posts published */
public function publishDue(?int $now = null): int
{
$now = $now ?? time();
$due = $this->blogRepo->findDueForPublish($now);
foreach ($due as $blog) {
$blog->setStatus(Blog::STATUS_PUBLISHED);
$this->blogRepo->save($blog);
}
return count($due);
}
}