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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user