- Introduced a new nullable city_id column in the blogs table to allow scoping of blog posts to specific cities. - Updated Blog entity to include a ManyToOne relationship with the City entity. - Enhanced BlogController to handle city_id in the request, allowing filtering of posts by city. - Modified BlogRepository to support querying published posts based on city_id. - Added tests to ensure correct behavior for city-scoped and nationwide posts, including creation and updating of posts with city associations.
259 lines
7.4 KiB
Markdown
259 lines
7.4 KiB
Markdown
# Blog API
|
|
|
|
> **Prefix:** `/api/v1/blog`, `/api/v1/blogs`
|
|
|
|
---
|
|
|
|
## GET `/api/v1/blogs`
|
|
|
|
List published blog posts.
|
|
|
|
**Permission:** `PUBLIC`
|
|
|
|
### Query Parameters
|
|
| Param | Type | Required | Default | Description |
|
|
|-------|------|----------|---------|-------------|
|
|
| `page` | integer | ❌ | 1 | Page number |
|
|
| `limit` | integer | ❌ | 20 | Items per page |
|
|
| `tag` | string | ❌ | — | Filter by exact tag **name** (e.g. `?tag=دیابت`). Blog tags are stored as a JSON array of names; only blogs whose `tags` array contains this exact name are returned. |
|
|
| `city_id` | integer | ❌ | — | Scope to one city. Returns that city's posts **plus every nationwide post** (`city_id IS NULL`). Omit it to return all published posts regardless of city. |
|
|
|
|
### Response `200`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": [
|
|
{
|
|
"uuid": "...",
|
|
"title": "آشنایی با بیماری دیابت",
|
|
"slug": "ashnayi-ba-bimari-diabat",
|
|
"summary": "خلاصه مطلب...",
|
|
"image": "https://...",
|
|
"author": { "uuid": "...", "real_name": "دکتر احمدی" },
|
|
"tags": [{ "id": 1, "name": "دیابت" }],
|
|
"status": "published",
|
|
"city": { "id": "123", "name": "یاسوج" },
|
|
"created_at": 1717000000
|
|
}
|
|
],
|
|
"meta": {
|
|
"totalRecords": 25,
|
|
"totalPages": 2,
|
|
"currentPage": 1,
|
|
"limit": 20
|
|
}
|
|
}
|
|
```
|
|
|
|
### فیلد `city` — شهر پست
|
|
|
|
`city` در پاسخ لیست و جزئیات وجود دارد و دو حالت دارد:
|
|
|
|
| مقدار | معنا |
|
|
|-------|------|
|
|
| `{ "id": "123", "name": "یاسوج" }` | پست **شهری** — به آن شهر تعلق دارد |
|
|
| `null` | پست **سراسری** — حالت دائمی و معتبر، نه «تنظیمنشده» |
|
|
|
|
- `city_id` روی `blogs` **nullable** است و `NULL` معنای دائمی «سراسری» دارد. رکوردهای قبل از این تغییر همگی سراسری شدند.
|
|
- حذف شهر پست را حذف نمیکند (`ON DELETE SET NULL`) — پست سراسری میشود.
|
|
- پست سراسری روی **همهٔ** دامنههای شهری در لیست دیده میشود؛ فقط canonical آن روی دامنهٔ اصلی مینشیند. به همین دلیل `city_id=X` هم پستهای شهر X و هم پستهای سراسری را برمیگرداند — نه فقط شهر X.
|
|
|
|
> 🔗 مصرفکننده: سایت عمومی چند-دامنهای (`nobat724_front`) با همین فیلد تصمیم میگیرد پست را روی دامنهٔ شهر canonical کند یا روی دامنهٔ اصلی، و در کدام sitemap بگذارد. تغییر معنای `null` قرارداد آن را میشکند.
|
|
|
|
---
|
|
|
|
## GET `/api/v1/blog/{slug}`
|
|
|
|
Get a single blog post by slug.
|
|
|
|
**Permission:** `PUBLIC`
|
|
|
|
### Path Parameters
|
|
| Param | Type | Description |
|
|
|-------|------|-------------|
|
|
| `slug` | string | URL slug (e.g. `ashnayi-ba-bimari-diabat`) |
|
|
|
|
### Response `200`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"uuid": "...",
|
|
"title": "آشنایی با بیماری دیابت",
|
|
"slug": "ashnayi-ba-bimari-diabat",
|
|
"summary": "خلاصه...",
|
|
"body": "<p>محتوای کامل...</p>",
|
|
"image": "https://...",
|
|
"author": { "uuid": "...", "real_name": "دکتر احمدی" },
|
|
"tags": [{ "id": 1, "name": "دیابت" }],
|
|
"status": "published",
|
|
"created_at": 1717000000,
|
|
"updated_at": 1717100000
|
|
}
|
|
}
|
|
```
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_NOT_FOUND_001` | 404 | Blog not found or not published |
|
|
|
|
---
|
|
|
|
## POST `/api/v1/blog`
|
|
|
|
Create a new blog post.
|
|
|
|
**Permission:** `ROLE_ADMIN`
|
|
|
|
### Request Body (`application/json`)
|
|
```json
|
|
{
|
|
"title": "آشنایی با بیماری دیابت",
|
|
"body": "<p>محتوای کامل مقاله...</p>",
|
|
"summary": "خلاصه کوتاه از مقاله",
|
|
"tags": [1, 2],
|
|
"status": "draft",
|
|
"image_url": "/uploads/blogs/blog_abc.jpg"
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `title` | string | ✅ | Post title (slug auto-generated) |
|
|
| `body` | string | ✅ | Full HTML body (from the admin CKEditor) |
|
|
| `summary` | string | ❌ | Short excerpt |
|
|
| `tags` | integer[] | ❌ | Array of tag IDs |
|
|
| `status` | string | ❌ | `"draft"` (default) or `"published"` |
|
|
| `image_url` | string | ❌ | Cover image path returned by the upload endpoint |
|
|
| `city_id` | integer\|null | ❌ | City this post belongs to. **Omitting it, or sending `null`/`0`, creates a nationwide post.** An unknown city id is rejected with `422`. |
|
|
|
|
### Response `201`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"uuid": "blog-uuid-...",
|
|
"title": "آشنایی با بیماری دیابت",
|
|
"slug": "ashnayi-ba-bimari-diabat",
|
|
"summary": "...",
|
|
"body": "...",
|
|
"image": null,
|
|
"tags": [],
|
|
"status": "draft",
|
|
"created_at": 1717000000
|
|
}
|
|
}
|
|
```
|
|
|
|
**Blog Status Values:**
|
|
| Value | Description |
|
|
|-------|-------------|
|
|
| `draft` | Not visible to public |
|
|
| `published` | Visible in public listing |
|
|
| `archived` | Hidden from listing |
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not admin |
|
|
| `ERR_VALIDATION_002` | 422 | Missing required fields |
|
|
|
|
---
|
|
|
|
## PATCH `/api/v1/blog/{uuid}`
|
|
|
|
Update a blog post.
|
|
|
|
**Permission:** `ROLE_ADMIN`
|
|
|
|
### Path Parameters
|
|
| Param | Type | Description |
|
|
|-------|------|-------------|
|
|
| `uuid` | string (UUID) | Blog UUID |
|
|
|
|
### Request Body (`application/json`)
|
|
```json
|
|
{
|
|
"title": "عنوان جدید",
|
|
"body": "<p>محتوای جدید</p>",
|
|
"summary": "خلاصه جدید",
|
|
"tags": [1, 3],
|
|
"status": "published",
|
|
"image_url": "/uploads/blogs/blog_abc.jpg"
|
|
}
|
|
```
|
|
|
|
All fields optional. Send `image_url: ""` to clear the cover image.
|
|
|
|
`city_id` follows PATCH semantics: **omit it and the post's city is left untouched**; send `null` (or `0`) to turn the post into a nationwide one; send a city id to move it to that city.
|
|
|
|
### Response `200`
|
|
Updated blog object.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not admin |
|
|
| `ERR_NOT_FOUND_001` | 404 | Blog not found |
|
|
| `ERR_VALIDATION_002` | 422 | Unknown `city_id` |
|
|
|
|
---
|
|
|
|
## DELETE `/api/v1/blog/{uuid}`
|
|
|
|
Delete a blog post.
|
|
|
|
**Permission:** `ROLE_ADMIN`
|
|
|
|
### Response `200`
|
|
```json
|
|
{ "success": true, "data": { "message": "مقاله حذف شد" } }
|
|
```
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not admin |
|
|
| `ERR_NOT_FOUND_001` | 404 | Blog not found |
|
|
|
|
---
|
|
|
|
## POST `/file/upload/clinic_pro/blog/field_image`
|
|
|
|
Upload blog post header image.
|
|
|
|
**Permission:** `ROLE_ADMIN`
|
|
|
|
### Request
|
|
`Content-Type: multipart/form-data`
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `file` | binary | ✅ | Image (max 5MB) |
|
|
| `blog_uuid` | string (UUID) | ❌ | Auto-associate with blog post |
|
|
|
|
### Response `200`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"url": "https://clinic-pro.ddev.site/uploads/blog/post_abc.jpg",
|
|
"uuid": "...",
|
|
"filename": "post_abc.jpg",
|
|
"filemime": "image/jpeg",
|
|
"filesize": 204800
|
|
}
|
|
}
|
|
```
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not admin |
|
|
| `ERR_FILE_001` | 422 | Invalid file type |
|