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
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod';
import type { Blog } from '../types';
/**
* Shared blog form schema, defaults and payload builder — reused by the admin
* BlogFormPage and the representative RepresentationBlogFormPage so both accept
* the same core + SEO + scheduling fields without duplication.
*/
export const blogFormSchema = z.object({
title: z.string().min(3, 'عنوان الزامی است'),
summary: z.string().optional(),
body: z.string().min(10, 'محتوا الزامی است'),
tags: z.string().optional(),
status: z.enum(['draft', 'published']),
image_url: z.string().optional(),
city_id: z.number().nullable().optional(),
// SEO
meta_title: z.string().optional(),
meta_description: z.string().optional(),
primary_keyword: z.string().optional(),
secondary_keywords: z.string().optional(), // comma-separated in the form
internal_links: z.string().optional(), // comma-separated URLs
external_links: z.string().optional(),
reading_time: z.number().nullable().optional(),
faq: z.array(z.object({ q: z.string(), a: z.string() })).optional(),
// scheduling (unix seconds, null = manual publish)
scheduled_at: z.number().nullable().optional(),
});
export type BlogFormData = z.infer<typeof blogFormSchema>;
const splitCsv = (s?: string): string[] =>
s ? s.split(',').map((x) => x.trim()).filter(Boolean) : [];
export function blogDefaults(blog?: Blog): Partial<BlogFormData> {
if (!blog) return { status: 'draft', faq: [] };
return {
title: blog.title,
summary: blog.summary ?? '',
body: blog.body,
tags: blog.tags?.join(', ') ?? '',
status: blog.status,
image_url: blog.image_url ?? '',
city_id: blog.city ? Number(blog.city.id) : null,
meta_title: blog.meta_title ?? '',
meta_description: blog.meta_description ?? '',
primary_keyword: blog.primary_keyword ?? '',
secondary_keywords: (blog.secondary_keywords ?? []).join(', '),
internal_links: (blog.internal_links ?? []).map((l) => (typeof l === 'string' ? l : l.url)).join(', '),
external_links: (blog.external_links ?? []).map((l) => (typeof l === 'string' ? l : l.url)).join(', '),
reading_time: blog.reading_time ?? null,
faq: blog.faq ?? [],
scheduled_at: blog.scheduled_at ?? null,
};
}
/** Build the API payload (shared shape for admin + representative endpoints). */
export function buildBlogPayload(d: BlogFormData): Record<string, unknown> {
return {
title: d.title,
body: d.body,
summary: d.summary,
status: d.status,
image_url: d.image_url ?? '',
tags: splitCsv(d.tags),
city_id: d.city_id ?? null,
meta_title: d.meta_title || null,
meta_description: d.meta_description || null,
primary_keyword: d.primary_keyword || null,
secondary_keywords: splitCsv(d.secondary_keywords),
internal_links: splitCsv(d.internal_links),
external_links: splitCsv(d.external_links),
reading_time: d.reading_time ?? null,
faq: (d.faq ?? []).filter((f) => f.q.trim() && f.a.trim()),
scheduled_at: d.scheduled_at ?? null,
};
}
+15
View File
@@ -446,6 +446,21 @@ export interface Blog {
review_note?: string | null;
/** کلید یکتای موضوع پایپ‌لاین (specialty, angle) */
topic_slug?: string | null;
// SEO
meta_title?: string | null;
meta_description?: string | null;
primary_keyword?: string | null;
secondary_keywords?: string[];
faq?: { q: string; a: string }[];
internal_links?: (string | { url: string; anchor?: string })[];
external_links?: (string | { url: string; anchor?: string })[];
reading_time?: number | null;
canonical_url?: string | null;
og_image?: string | null;
/** زمان انتشار خودکار (unix)، null = دستی */
scheduled_at?: number | null;
/** نمایندهٔ مالک، null = پست ادمین/سراسری */
representation?: { uuid: string; name: string; domain?: string | null } | null;
created_at: string;
updated_at?: string;
}
+43
View File
@@ -130,6 +130,18 @@ Create a new blog post.
| `sources` | array | ❌ | E-E-A-T source list. Each item `{ "url": "...", "title": "..." }`. Used by the content pipeline; shown on the published post. |
| `review_status` | string\|null | ❌ | Medical-review gate. Omit/`null` = manual admin post (no review). The content pipeline sends `"pending_review"` so the post enters the doctor review queue (`GET /api/v1/admin/blog/review-queue`). |
| `topic_slug` | string | ❌ | Stable pipeline topic key `(specialty, angle)`, **unique**. Makes creation **idempotent**: posting the same `topic_slug` again returns the existing post with HTTP **200** (not a duplicate `201`). |
| `meta_title` | string | ❌ | SEO `<title>` override (falls back to `title`). |
| `meta_description` | string | ❌ | SEO meta description. |
| `primary_keyword` | string | ❌ | Primary target keyword. |
| `secondary_keywords` | string[] | ❌ | Secondary keywords (empty entries dropped). |
| `faq` | array | ❌ | `[{ "q": "...", "a": "..." }]` — rendered as FAQPage JSON-LD on the site. Malformed entries dropped. |
| `internal_links` / `external_links` | array | ❌ | Link suggestions, e.g. `["https://..."]` or `[{ "url", "anchor" }]`. |
| `reading_time` | integer | ❌ | Estimated minutes. |
| `canonical_url` | string | ❌ | Canonical override. |
| `og_image` | string | ❌ | Open Graph image URL. |
| `scheduled_at` | integer | ❌ | Unix timestamp for **automatic publish**. Omit for manual publishing. A scheduled post publishes only once `review_status = approved` (or `null`) — the review gate wins. |
> The same SEO/scheduling fields are accepted on `PATCH /api/v1/blog/{uuid}` (omitted keys untouched). All are returned in the blog `toArray`.
### Response `201`
```json
@@ -205,6 +217,37 @@ Updated blog object.
---
## Representative blog (scoped to one representative)
A representative (`ROLE_REPRESENTATION`) manages their own posts for their own domain. They see and edit **only their own** posts, and may target **only cities within their coverage** (`representation.cities`). Admin CRUD (`/api/v1/blog*`) is unaffected — an admin still sees everything.
| Route | Method | Path | Permission |
|-------|--------|------|------------|
| list own | GET | `/api/v1/representation/blogs` | `ROLE_REPRESENTATION` (`page`,`limit`,`status`) |
| create | POST | `/api/v1/representation/blog` | `ROLE_REPRESENTATION` |
| update own | PATCH | `/api/v1/representation/blog/{uuid}` | `ROLE_REPRESENTATION` |
| delete own | DELETE | `/api/v1/representation/blog/{uuid}` | `ROLE_REPRESENTATION` |
- On create, `author` = the representative's user and `representation` = their record (set server-side).
- `city_id` **must** be one of the representative's coverage cities, else `422`. A post that isn't owned by the caller returns `404` (never leaked).
- The request body accepts the same core + SEO + scheduling fields as the admin create.
- The blog `toArray`/`toListArray` includes `representation` (`{ uuid, name, domain }` or `null`).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_006` | 403 | Not a representative / no representative profile |
| `ERR_NOT_FOUND_001` | 404 | Post not found or not owned by the caller |
| `ERR_VALIDATION_002` | 422 | Missing title/body, unknown city, or city outside coverage |
---
## Scheduled publishing
`scheduled_at` (unix) drives automatic publishing. A scheduler task (`PublishScheduledBlogsMessage`, every 1 minute via `symfony/scheduler`, consumed by `messenger:consume scheduler_default`) publishes every `draft` whose `scheduled_at <= now` **and** whose `review_status` is `null` or `approved`. A `pending_review` post is never auto-published — the medical-review gate wins over the schedule.
---
## Medical-review gate
The content pipeline (`clinicpro-crawler/content/`) generates Persian health articles as **drafts** (`status=draft`, `review_status=pending_review`). A doctor/admin then approves or rejects each one before it goes public. The reviewer's identity is stored and shown on the post — the E-E-A-T signal for YMYL content. `review_status=null` posts (created manually by an admin) are outside this gate.
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260723174500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add SEO fields, scheduling (scheduled_at) and representative ownership (representation_id) to blogs';
}
public function up(Schema $schema): void
{
// Scoped to the blogs table only. Unrelated project-wide schema drift that
// doctrine:diff also emitted is deliberately left out of this feature migration.
// Nullable columns + representation FK column.
$this->addSql('ALTER TABLE blogs ADD meta_title VARCHAR(255) DEFAULT NULL, ADD meta_description VARCHAR(500) DEFAULT NULL, ADD primary_keyword VARCHAR(255) DEFAULT NULL, ADD reading_time INT DEFAULT NULL, ADD canonical_url VARCHAR(500) DEFAULT NULL, ADD og_image VARCHAR(500) DEFAULT NULL, ADD scheduled_at INT DEFAULT NULL, ADD representation_id INT DEFAULT NULL');
// JSON NOT NULL columns can't be added directly to a table with rows (the
// empty backfill fails json_valid). Add nullable, backfill '[]', then enforce.
$this->addSql('ALTER TABLE blogs ADD secondary_keywords JSON DEFAULT NULL, ADD faq JSON DEFAULT NULL, ADD internal_links JSON DEFAULT NULL, ADD external_links JSON DEFAULT NULL');
$this->addSql("UPDATE blogs SET secondary_keywords = '[]', faq = '[]', internal_links = '[]', external_links = '[]'");
$this->addSql('ALTER TABLE blogs MODIFY secondary_keywords JSON NOT NULL, MODIFY faq JSON NOT NULL, MODIFY internal_links JSON NOT NULL, MODIFY external_links JSON NOT NULL');
$this->addSql('ALTER TABLE blogs ADD CONSTRAINT FK_F41BCA7046CE82F4 FOREIGN KEY (representation_id) REFERENCES representations (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_F41BCA7046CE82F4 ON blogs (representation_id)');
$this->addSql('CREATE INDEX idx_blogs_scheduled_at ON blogs (scheduled_at)');
$this->addSql('CREATE INDEX idx_blogs_representation ON blogs (representation_id, created_at)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE blogs DROP FOREIGN KEY FK_F41BCA7046CE82F4');
$this->addSql('DROP INDEX IDX_F41BCA7046CE82F4 ON blogs');
$this->addSql('DROP INDEX idx_blogs_scheduled_at ON blogs');
$this->addSql('DROP INDEX idx_blogs_representation ON blogs');
$this->addSql('ALTER TABLE blogs DROP meta_title, DROP meta_description, DROP primary_keyword, DROP secondary_keywords, DROP faq, DROP internal_links, DROP external_links, DROP reading_time, DROP canonical_url, DROP og_image, DROP scheduled_at, DROP representation_id');
}
}
+3
View File
@@ -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' => 'مقاله حذف شد']);
}
}
+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,
];
@@ -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();
}
}
+48
View File
@@ -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
{
+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);
}
}
+4
View File
@@ -3,6 +3,7 @@
namespace App;
use App\Appointment\Message\ExpireAppointmentsMessage;
use App\Blog\Message\PublishScheduledBlogsMessage;
use App\Shared\Logging\Message\PruneLogsMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
@@ -28,6 +29,9 @@ class Schedule implements ScheduleProviderInterface
)
->add(
RecurringMessage::every('1 day', new PruneLogsMessage())
)
->add(
RecurringMessage::every('1 minute', new PublishScheduledBlogsMessage())
);
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Tests\Blog;
use App\Blog\Entity\Blog;
use App\Blog\Service\ScheduledBlogPublisher;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Representation\Entity\Representation;
use App\Tests\ApiTestCase;
/**
* v2 backend: SEO fields, representative-scoped ownership, and scheduled publish.
*/
class BlogV2FieldsTest extends ApiTestCase
{
private function makeCity(string $name): City
{
$province = new Province($name);
$this->em->persist($province);
$city = new City($name, $province);
$this->em->persist($city);
return $city;
}
private function makeRepresentation(array $cities): Representation
{
$user = $this->createUser(['ROLE_REPRESENTATION']);
$rep = new Representation($user, 'نمایندهٔ ' . bin2hex(random_bytes(3)));
$rep->setCities($cities);
$this->em->persist($rep);
$this->em->flush();
return $rep;
}
// ── SEO fields (admin path) ───────────────────────────────────────────────
public function testAdminCreateStoresSeoFields(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$res = $this->authJson('POST', '/api/v1/blog', $admin, [
'title' => 'مقالهٔ سئو ' . bin2hex(random_bytes(3)),
'body' => 'متن آزمایشی مقاله برای تست',
'meta_title' => 'عنوان متا',
'meta_description' => 'توضیح متا برای موتور جستجو',
'primary_keyword' => 'سکته قلبی',
'secondary_keywords' => ['علائم', 'پیشگیری', ''],
'faq' => [['q' => 'سؤال؟', 'a' => 'جواب'], ['q' => '', 'a' => 'بی‌سؤال']],
'internal_links' => ['https://nobat724.com/x'],
'reading_time' => 6,
]);
$this->assertSame(201, $this->responseCode());
$b = $res['data']['data'];
$this->assertSame('توضیح متا برای موتور جستجو', $b['meta_description']);
$this->assertSame('سکته قلبی', $b['primary_keyword']);
$this->assertSame(['علائم', 'پیشگیری'], $b['secondary_keywords'], 'empty keyword dropped');
$this->assertCount(1, $b['faq'], 'malformed FAQ entry dropped');
$this->assertSame(6, $b['reading_time']);
}
// ── Representative scope ──────────────────────────────────────────────────
public function testRepresentativeCreatesPostInOwnCity(): void
{
$yasuj = $this->makeCity('یاسوج');
$rep = $this->makeRepresentation([$yasuj]);
$res = $this->authJson('POST', '/api/v1/representation/blog', $rep->getUser(), [
'title' => 'مقالهٔ نماینده ' . bin2hex(random_bytes(3)),
'body' => 'متن آزمایشی مقاله برای تست',
'city_id' => $yasuj->getId(),
]);
$this->assertSame(201, $this->responseCode());
$this->assertSame($rep->getUuid(), $res['data']['data']['representation']['uuid']);
$this->assertSame('یاسوج', $res['data']['data']['city']['name']);
}
public function testRepresentativeCannotUseCityOutsideCoverage(): void
{
$yasuj = $this->makeCity('یاسوج');
$tabriz = $this->makeCity('تبریز');
$rep = $this->makeRepresentation([$yasuj]); // covers Yasuj only
$this->authJson('POST', '/api/v1/representation/blog', $rep->getUser(), [
'title' => 'مقاله',
'body' => 'متن آزمایشی مقاله برای تست',
'city_id' => $tabriz->getId(),
]);
$this->assertSame(422, $this->responseCode(), 'city outside coverage must be rejected');
}
public function testRepresentativeListShowsOnlyOwnPosts(): void
{
$yasuj = $this->makeCity('یاسوج');
$repA = $this->makeRepresentation([$yasuj]);
$repB = $this->makeRepresentation([$yasuj]);
$tag = bin2hex(random_bytes(4));
$this->authJson('POST', '/api/v1/representation/blog', $repA->getUser(), [
'title' => "مالA-$tag", 'body' => 'متن آزمایشی مقاله برای تست', 'city_id' => $yasuj->getId(),
]);
$this->authJson('POST', '/api/v1/representation/blog', $repB->getUser(), [
'title' => "مالB-$tag", 'body' => 'متن آزمایشی مقاله برای تست', 'city_id' => $yasuj->getId(),
]);
$list = $this->authJson('GET', '/api/v1/representation/blogs?limit=50', $repA->getUser());
$titles = array_column($list['data'], 'title');
$this->assertContains("مالA-$tag", $titles);
$this->assertNotContains("مالB-$tag", $titles, "another rep's post leaked");
}
public function testRepresentativeCannotEditOthersPost(): void
{
$yasuj = $this->makeCity('یاسوج');
$repA = $this->makeRepresentation([$yasuj]);
$repB = $this->makeRepresentation([$yasuj]);
$created = $this->authJson('POST', '/api/v1/representation/blog', $repB->getUser(), [
'title' => 'مقالهٔ B', 'body' => 'متن آزمایشی مقاله برای تست', 'city_id' => $yasuj->getId(),
]);
$uuid = $created['data']['data']['uuid'];
$this->authJson('PATCH', "/api/v1/representation/blog/$uuid", $repA->getUser(), ['title' => 'هک']);
$this->assertSame(404, $this->responseCode(), "must not see another rep's post");
}
public function testNonRepresentativeIsForbidden(): void
{
$user = $this->createUser(['ROLE_USER']);
$this->authJson('GET', '/api/v1/representation/blogs', $user);
$this->assertSame(403, $this->responseCode());
}
// ── Scheduled publish ─────────────────────────────────────────────────────
private function makeScheduled(?int $scheduledAt, ?string $reviewStatus): Blog
{
$blog = new Blog($this->createUser(['ROLE_ADMIN']), 'زمان‌بندی ' . bin2hex(random_bytes(3)), 'متن آزمایشی مقاله برای تست');
$blog->setStatus(Blog::STATUS_DRAFT)->setScheduledAt($scheduledAt)->setReviewStatus($reviewStatus);
$this->em->persist($blog);
$this->em->flush();
return $blog;
}
public function testScheduledPublisherPublishesDuePosts(): void
{
$publisher = static::getContainer()->get(ScheduledBlogPublisher::class);
$past = time() - 60;
$duePlain = $this->makeScheduled($past, null); // manual, due -> publish
$dueApproved = $this->makeScheduled($past, Blog::REVIEW_APPROVED); // approved, due -> publish
$duePending = $this->makeScheduled($past, Blog::REVIEW_PENDING); // due but unreviewed -> hold
$future = $this->makeScheduled(time() + 3600, null); // not due -> hold
$publisher->publishDue();
$this->em->clear();
$reload = fn(Blog $b) => $this->em->getRepository(Blog::class)->find($b->getId());
$this->assertSame(Blog::STATUS_PUBLISHED, $reload($duePlain)->getStatus());
$this->assertSame(Blog::STATUS_PUBLISHED, $reload($dueApproved)->getStatus());
$this->assertSame(Blog::STATUS_DRAFT, $reload($duePending)->getStatus(), 'review gate wins over schedule');
$this->assertSame(Blog::STATUS_DRAFT, $reload($future)->getStatus());
}
}