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