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:
@@ -23,6 +23,7 @@ class BlogController extends BaseController
|
||||
private readonly BlogRepository $blogRepo,
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly \App\Blog\Service\BlogWriter $blogWriter,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
@@ -243,6 +244,7 @@ class BlogController extends BaseController
|
||||
if (!empty($data['review_status'])) $blog->setReviewStatus($data['review_status']);
|
||||
// نبودِ city_id یعنی سراسری — پس همیشه اعمال میشود، نه فقط وقتی مقدار دارد.
|
||||
$blog->setCity($this->resolveCity($data['city_id'] ?? null));
|
||||
$this->blogWriter->applySeoFields($blog, $data);
|
||||
|
||||
// Ensure slug uniqueness
|
||||
if ($this->blogRepo->findBySlug($blog->getSlug()) !== null) {
|
||||
@@ -319,6 +321,7 @@ class BlogController extends BaseController
|
||||
if (array_key_exists('image_url', $data)) $blog->setImageUrl($data['image_url'] ?: null);
|
||||
// PATCH: فقط وقتی صریحاً فرستاده شد تغییر کند. ارسال null یعنی «سراسریاش کن».
|
||||
if (array_key_exists('city_id', $data)) $blog->setCity($this->resolveCity($data['city_id']));
|
||||
$this->blogWriter->applySeoFields($blog, $data);
|
||||
|
||||
$this->blogRepo->save($blog);
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Blog\Entity\Blog;
|
||||
use App\Blog\Repository\BlogRepository;
|
||||
use App\Blog\Service\BlogWriter;
|
||||
use App\Location\Entity\City;
|
||||
use App\Location\Repository\CityRepository;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Blog management scoped to a single representative. A representative sees and
|
||||
* edits only their own posts, and may only target cities within their coverage
|
||||
* (representation.cities). Admin CRUD lives in BlogController.
|
||||
*/
|
||||
#[OA\Tag(name: 'Blog')]
|
||||
#[IsGranted('ROLE_REPRESENTATION')]
|
||||
class RepresentationBlogController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BlogRepository $blogRepo,
|
||||
private readonly RepresentationRepository $repRepo,
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly BlogWriter $writer,
|
||||
) {}
|
||||
|
||||
private function currentRepresentation(User $user): Representation
|
||||
{
|
||||
$rep = $this->repRepo->findByUser($user);
|
||||
if ($rep === null) {
|
||||
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_AUTH_006, 'پروفایل نماینده یافت نشد', 403);
|
||||
}
|
||||
return $rep;
|
||||
}
|
||||
|
||||
/** Resolve a city_id, enforcing it belongs to the representative's coverage. */
|
||||
private function resolveOwnedCity(mixed $cityId, Representation $rep): ?City
|
||||
{
|
||||
if ($cityId === null || $cityId === '' || (int) $cityId === 0) {
|
||||
return null; // nationwide is not allowed here, but null is handled by the caller
|
||||
}
|
||||
$city = $this->cityRepo->find((int) $cityId);
|
||||
if ($city === null) {
|
||||
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'شهر یافت نشد', 422);
|
||||
}
|
||||
if (!in_array((int) $city->getId(), $rep->cityIds(), true)) {
|
||||
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'این شهر جزو حوزهٔ نمایندگی شما نیست', 422);
|
||||
}
|
||||
return $city;
|
||||
}
|
||||
|
||||
private function ownedBlogOr404(string $uuid, Representation $rep): Blog
|
||||
{
|
||||
$blog = $this->blogRepo->findByUuid($uuid);
|
||||
if ($blog === null || $blog->getRepresentation()?->getId() !== $rep->getId()) {
|
||||
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
|
||||
}
|
||||
return $blog;
|
||||
}
|
||||
|
||||
#[OA\Get(path: '/api/v1/representation/blogs', summary: "List the representative's own blog posts", security: [['bearerAuth' => []]])]
|
||||
#[Route('/api/v1/representation/blogs', methods: ['GET'])]
|
||||
public function list(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->currentRepresentation($user);
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||
$status = $request->query->get('status') ?: null;
|
||||
|
||||
$items = array_map(
|
||||
fn(Blog $b) => $b->toListArray(),
|
||||
$this->blogRepo->findByRepresentation($rep, $page, $limit, $status)
|
||||
);
|
||||
$total = $this->blogRepo->countByRepresentation($rep, $status);
|
||||
|
||||
return $this->paginated($items, $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[OA\Post(path: '/api/v1/representation/blog', summary: 'Create a post owned by the representative', security: [['bearerAuth' => []]])]
|
||||
#[Route('/api/v1/representation/blog', methods: ['POST'])]
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->currentRepresentation($user);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
$title = trim((string) ($data['title'] ?? ''));
|
||||
$body = trim((string) ($data['body'] ?? ''));
|
||||
if ($title === '' || $body === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body الزامی است', 422);
|
||||
}
|
||||
|
||||
$blog = new Blog($user, $title, $body);
|
||||
$blog->setRepresentation($rep);
|
||||
$blog->setCity($this->resolveOwnedCity($data['city_id'] ?? null, $rep));
|
||||
if (!empty($data['summary'])) $blog->setSummary($data['summary']);
|
||||
if (!empty($data['tags'])) $blog->setTags((array) $data['tags']);
|
||||
if (!empty($data['image_url'])) $blog->setImageUrl($data['image_url']);
|
||||
if (!empty($data['status']) && in_array($data['status'], [Blog::STATUS_DRAFT, Blog::STATUS_PUBLISHED], true)) {
|
||||
$blog->setStatus($data['status']);
|
||||
}
|
||||
$this->writer->applySeoFields($blog, $data);
|
||||
|
||||
if ($this->blogRepo->findBySlug($blog->getSlug()) !== null) {
|
||||
$blog->setSlug($blog->getSlug() . '-' . substr(uniqid(), -4));
|
||||
}
|
||||
$this->blogRepo->save($blog);
|
||||
|
||||
return $this->success(['data' => $blog->toArray()], 201);
|
||||
}
|
||||
|
||||
#[OA\Patch(path: '/api/v1/representation/blog/{uuid}', summary: "Update the representative's own post", security: [['bearerAuth' => []]])]
|
||||
#[Route('/api/v1/representation/blog/{uuid}', methods: ['PATCH'])]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->currentRepresentation($user);
|
||||
$blog = $this->ownedBlogOr404($uuid, $rep);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (array_key_exists('title', $data)) $blog->setTitle((string) $data['title']);
|
||||
if (array_key_exists('body', $data)) $blog->setBody((string) $data['body']);
|
||||
if (array_key_exists('summary', $data)) $blog->setSummary($data['summary']);
|
||||
if (array_key_exists('tags', $data)) $blog->setTags((array) $data['tags']);
|
||||
if (array_key_exists('image_url', $data)) $blog->setImageUrl($data['image_url'] ?: null);
|
||||
if (array_key_exists('status', $data) && in_array($data['status'], [Blog::STATUS_DRAFT, Blog::STATUS_PUBLISHED, Blog::STATUS_ARCHIVED], true)) {
|
||||
$blog->setStatus($data['status']);
|
||||
}
|
||||
if (array_key_exists('city_id', $data)) {
|
||||
$blog->setCity($this->resolveOwnedCity($data['city_id'], $rep));
|
||||
}
|
||||
$this->writer->applySeoFields($blog, $data);
|
||||
$this->blogRepo->save($blog);
|
||||
|
||||
return $this->success(['data' => $blog->toArray()]);
|
||||
}
|
||||
|
||||
#[OA\Delete(path: '/api/v1/representation/blog/{uuid}', summary: "Delete the representative's own post", security: [['bearerAuth' => []]])]
|
||||
#[Route('/api/v1/representation/blog/{uuid}', methods: ['DELETE'])]
|
||||
public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->currentRepresentation($user);
|
||||
$blog = $this->ownedBlogOr404($uuid, $rep);
|
||||
$this->blogRepo->remove($blog);
|
||||
return $this->success(['message' => 'مقاله حذف شد']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Message;
|
||||
|
||||
/**
|
||||
* Marker message dispatched every minute by the scheduler. Its handler publishes
|
||||
* any draft blog whose scheduled_at has arrived (and which passed the review gate).
|
||||
*/
|
||||
final class PublishScheduledBlogsMessage
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\MessageHandler;
|
||||
|
||||
use App\Blog\Message\PublishScheduledBlogsMessage;
|
||||
use App\Blog\Service\ScheduledBlogPublisher;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final class PublishScheduledBlogsHandler
|
||||
{
|
||||
public function __construct(private readonly ScheduledBlogPublisher $publisher) {}
|
||||
|
||||
public function __invoke(PublishScheduledBlogsMessage $message): void
|
||||
{
|
||||
$this->publisher->publishDue();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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