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