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
+104
View File
@@ -4,6 +4,7 @@ namespace App\Blog\Entity;
use App\Auth\Entity\User;
use App\Location\Entity\City;
use App\Representation\Entity\Representation;
use Doctrine\ORM\Mapping as ORM;
use App\Blog\Repository\BlogRepository;
use Symfony\Component\Uid\Uuid;
@@ -13,6 +14,8 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Index(columns: ['status', 'created_at'], name: 'idx_blogs_status')]
#[ORM\Index(columns: ['city_id'], name: 'idx_blogs_city')]
#[ORM\Index(columns: ['review_status', 'created_at'], name: 'idx_blogs_review_status')]
#[ORM\Index(columns: ['scheduled_at'], name: 'idx_blogs_scheduled_at')]
#[ORM\Index(columns: ['representation_id', 'created_at'], name: 'idx_blogs_representation')]
class Blog
{
public const STATUS_DRAFT = 'draft';
@@ -95,6 +98,52 @@ class Blog
#[ORM\Column(name: 'topic_slug', type: 'string', length: 255, nullable: true, unique: true)]
private ?string $topicSlug = null;
// ── SEO fields (all nullable; a plain post may leave them empty) ──────────
#[ORM\Column(name: 'meta_title', type: 'string', length: 255, nullable: true)]
private ?string $metaTitle = null;
#[ORM\Column(name: 'meta_description', type: 'string', length: 500, nullable: true)]
private ?string $metaDescription = null;
#[ORM\Column(name: 'primary_keyword', type: 'string', length: 255, nullable: true)]
private ?string $primaryKeyword = null;
#[ORM\Column(name: 'secondary_keywords', type: 'json')]
private array $secondaryKeywords = [];
/** [{ "q": "...", "a": "..." }] — rendered as FAQPage JSON-LD on the site. */
#[ORM\Column(type: 'json')]
private array $faq = [];
/** [{ "url": "...", "anchor": "..." }] internal link suggestions. */
#[ORM\Column(name: 'internal_links', type: 'json')]
private array $internalLinks = [];
#[ORM\Column(name: 'external_links', type: 'json')]
private array $externalLinks = [];
#[ORM\Column(name: 'reading_time', type: 'integer', nullable: true)]
private ?int $readingTime = null;
#[ORM\Column(name: 'canonical_url', type: 'string', length: 500, nullable: true)]
private ?string $canonicalUrl = null;
#[ORM\Column(name: 'og_image', type: 'string', length: 500, nullable: true)]
private ?string $ogImage = null;
// ── Scheduling ───────────────────────────────────────────────────────────
// Unix timestamp for automatic publish. NULL = publish manually. A scheduled
// AI post publishes only once review_status = approved (the review gate wins).
#[ORM\Column(name: 'scheduled_at', type: 'integer', nullable: true)]
private ?int $scheduledAt = null;
// ── Representative ownership ──────────────────────────────────────────────
// NULL = an admin/nationwide post. Set when a representative authors a post
// for their own domain; city must be one of the representative's coverage cities.
#[ORM\ManyToOne(targetEntity: Representation::class)]
#[ORM\JoinColumn(name: 'representation_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Representation $representation = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -129,6 +178,18 @@ class Blog
public function getReviewedAt(): ?int { return $this->reviewedAt; }
public function getReviewNote(): ?string { return $this->reviewNote; }
public function getTopicSlug(): ?string { return $this->topicSlug; }
public function getMetaTitle(): ?string { return $this->metaTitle; }
public function getMetaDescription(): ?string { return $this->metaDescription; }
public function getPrimaryKeyword(): ?string { return $this->primaryKeyword; }
public function getSecondaryKeywords(): array { return $this->secondaryKeywords; }
public function getFaq(): array { return $this->faq; }
public function getInternalLinks(): array { return $this->internalLinks; }
public function getExternalLinks(): array { return $this->externalLinks; }
public function getReadingTime(): ?int { return $this->readingTime; }
public function getCanonicalUrl(): ?string { return $this->canonicalUrl; }
public function getOgImage(): ?string { return $this->ogImage; }
public function getScheduledAt(): ?int { return $this->scheduledAt; }
public function getRepresentation(): ?Representation { return $this->representation; }
public function getCity(): ?City { return $this->city; }
public function setTitle(string $v): self { $this->title = $v; $this->touch(); return $this; }
@@ -145,6 +206,18 @@ class Blog
public function setReviewedAt(?int $v): self { $this->reviewedAt = $v; $this->touch(); return $this; }
public function setReviewNote(?string $v): self { $this->reviewNote = $v; $this->touch(); return $this; }
public function setTopicSlug(?string $v): self { $this->topicSlug = $v; $this->touch(); return $this; }
public function setMetaTitle(?string $v): self { $this->metaTitle = $v; $this->touch(); return $this; }
public function setMetaDescription(?string $v): self { $this->metaDescription = $v; $this->touch(); return $this; }
public function setPrimaryKeyword(?string $v): self { $this->primaryKeyword = $v; $this->touch(); return $this; }
public function setSecondaryKeywords(array $v): self { $this->secondaryKeywords = array_values($v); $this->touch(); return $this; }
public function setFaq(array $v): self { $this->faq = array_values($v); $this->touch(); return $this; }
public function setInternalLinks(array $v): self { $this->internalLinks = array_values($v); $this->touch(); return $this; }
public function setExternalLinks(array $v): self { $this->externalLinks = array_values($v); $this->touch(); return $this; }
public function setReadingTime(?int $v): self { $this->readingTime = $v; $this->touch(); return $this; }
public function setCanonicalUrl(?string $v): self { $this->canonicalUrl = $v; $this->touch(); return $this; }
public function setOgImage(?string $v): self { $this->ogImage = $v; $this->touch(); return $this; }
public function setScheduledAt(?int $v): self { $this->scheduledAt = $v; $this->touch(); return $this; }
public function setRepresentation(?Representation $v): self { $this->representation = $v; $this->touch(); return $this; }
/** null = پست سراسری (روی همهٔ دامنه‌ها، canonical روی دامنهٔ اصلی) */
public function setCity(?City $v): self { $this->city = $v; $this->touch(); return $this; }
@@ -186,6 +259,18 @@ class Blog
'reviewed_at'=> $this->reviewedAt,
'review_note'=> $this->reviewNote,
'topic_slug' => $this->topicSlug,
'meta_title' => $this->metaTitle,
'meta_description' => $this->metaDescription,
'primary_keyword' => $this->primaryKeyword,
'secondary_keywords' => $this->secondaryKeywords,
'faq' => $this->faq,
'internal_links' => $this->internalLinks,
'external_links' => $this->externalLinks,
'reading_time' => $this->readingTime,
'canonical_url' => $this->canonicalUrl,
'og_image' => $this->ogImage,
'scheduled_at' => $this->scheduledAt,
'representation' => $this->representationToArray(),
'author' => $this->authorToArray(),
'city' => $this->cityToArray(),
'created_at' => $this->createdAt,
@@ -193,6 +278,23 @@ class Blog
];
}
/** The owning representative, or null for admin/nationwide posts. */
private function representationToArray(): ?array
{
if ($this->representation === null) {
return null;
}
try {
return [
'uuid' => $this->representation->getUuid(),
'name' => $this->representation->getFullName(),
'domain' => $this->representation->getDomain(),
];
} catch (\Doctrine\ORM\EntityNotFoundException) {
return null;
}
}
/** The reviewing doctor, or null. Same defensive lazy-proxy handling as author. */
private function reviewerToArray(): ?array
{
@@ -252,6 +354,8 @@ class Blog
'review_status' => $this->reviewStatus,
'reviewer' => $this->reviewerToArray(),
'topic_slug' => $this->topicSlug,
'scheduled_at' => $this->scheduledAt,
'representation' => $this->representationToArray(),
'city' => $this->cityToArray(),
'created_at' => $this->createdAt,
];