diff --git a/assets/admin/lib/blogForm.ts b/assets/admin/lib/blogForm.ts new file mode 100644 index 00000000..bdb14fc0 --- /dev/null +++ b/assets/admin/lib/blogForm.ts @@ -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; + +const splitCsv = (s?: string): string[] => + s ? s.split(',').map((x) => x.trim()).filter(Boolean) : []; + +export function blogDefaults(blog?: Blog): Partial { + 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 { + 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, + }; +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index ac805c4a..27983bfe 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -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; } diff --git a/docs/api/blog.md b/docs/api/blog.md index 4b31cbd6..e875dd57 100644 --- a/docs/api/blog.md +++ b/docs/api/blog.md @@ -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 `` 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. diff --git a/migrations/Version20260723174500.php b/migrations/Version20260723174500.php new file mode 100644 index 00000000..36a5c93e --- /dev/null +++ b/migrations/Version20260723174500.php @@ -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'); + } +} diff --git a/src/Blog/Controller/BlogController.php b/src/Blog/Controller/BlogController.php index d3d6f94b..a3810e3f 100644 --- a/src/Blog/Controller/BlogController.php +++ b/src/Blog/Controller/BlogController.php @@ -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); diff --git a/src/Blog/Controller/RepresentationBlogController.php b/src/Blog/Controller/RepresentationBlogController.php new file mode 100644 index 00000000..ab581dd3 --- /dev/null +++ b/src/Blog/Controller/RepresentationBlogController.php @@ -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' => 'مقاله حذف شد']); + } +} diff --git a/src/Blog/Entity/Blog.php b/src/Blog/Entity/Blog.php index b93b458f..c56d346b 100644 --- a/src/Blog/Entity/Blog.php +++ b/src/Blog/Entity/Blog.php @@ -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, ]; diff --git a/src/Blog/Message/PublishScheduledBlogsMessage.php b/src/Blog/Message/PublishScheduledBlogsMessage.php new file mode 100644 index 00000000..7e09817f --- /dev/null +++ b/src/Blog/Message/PublishScheduledBlogsMessage.php @@ -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 +{ +} diff --git a/src/Blog/MessageHandler/PublishScheduledBlogsHandler.php b/src/Blog/MessageHandler/PublishScheduledBlogsHandler.php new file mode 100644 index 00000000..05e2326e --- /dev/null +++ b/src/Blog/MessageHandler/PublishScheduledBlogsHandler.php @@ -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(); + } +} diff --git a/src/Blog/Repository/BlogRepository.php b/src/Blog/Repository/BlogRepository.php index 2ecd9e4b..84660954 100644 --- a/src/Blog/Repository/BlogRepository.php +++ b/src/Blog/Repository/BlogRepository.php @@ -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 { diff --git a/src/Blog/Service/BlogWriter.php b/src/Blog/Service/BlogWriter.php new file mode 100644 index 00000000..84c3c1fb --- /dev/null +++ b/src/Blog/Service/BlogWriter.php @@ -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; + } +} diff --git a/src/Blog/Service/ScheduledBlogPublisher.php b/src/Blog/Service/ScheduledBlogPublisher.php new file mode 100644 index 00000000..e69ea97d --- /dev/null +++ b/src/Blog/Service/ScheduledBlogPublisher.php @@ -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); + } +} diff --git a/src/Schedule.php b/src/Schedule.php index c90c46de..e2dab6eb 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -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()) ); } } diff --git a/tests/Blog/BlogV2FieldsTest.php b/tests/Blog/BlogV2FieldsTest.php new file mode 100644 index 00000000..c08b143c --- /dev/null +++ b/tests/Blog/BlogV2FieldsTest.php @@ -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()); + } +}