feat(blog): add admin endpoint for blog details and cache invalidation

- Implemented `adminDetail()` method in `BlogController` to retrieve blog posts of any status for admin editing.
- Introduced `BlogCacheInvalidator` service to handle cache invalidation after blog create/update/delete actions.
- Updated existing methods in `BlogController` and `RepresentationBlogController` to call cache invalidation on blog modifications.
- Enhanced `BlogFormPage` and `RepresentationBlogFormPage` to utilize the new admin endpoint for fetching blog data.
- Added tests for `BlogCacheInvalidator` to ensure proper functionality and error handling.
- Updated documentation to reflect new API endpoint and cache invalidation behavior.
This commit is contained in:
hamed
2026-07-27 18:54:35 +03:30
parent e4edaea9b8
commit 15abcb5c8a
12 changed files with 972 additions and 47 deletions
@@ -0,0 +1,405 @@
# رفع ویرایش بلاگ در پنل ادمین + سازگاری Dark Mode + Webhook سینک فرانت
## پروژه
`clinicpro` (بک‌اند Symfony + پنل ادمین React)
پرامپت همتا (cross-repo): `nobat724_front/.claude/prompt/blog-frontend-sync-and-default-cover.md`**اول این پرامپت اجرا شود**، چون اندپوینت جدید و webhook در همین‌جا تعریف می‌شوند.
## زمینه
صفحهٔ `/admin/blogs/{uuid}/edit` برای مقالهٔ `56fd9a20-9594-4aa1-a651-346fa86720bd` کاملاً خالی بالا می‌آید (اسکرین‌شات کاربر: همهٔ فیلدها placeholder، ادیتور خالی، بدون هیچ پیام خطا). این مقاله در دیتابیس لوکال وجود دارد:
```
uuid: 56fd9a20-9594-4aa1-a651-346fa86720bd
title: rtertert
status: draft ← کلید ماجرا
review_status: NULL
image_url: NULL
city_id: NULL
```
همچنین ادیتور CKEditor در دارک‌مود سفید می‌ماند (اسکرین‌شات: نوار ابزار و بدنهٔ ادیتور روشن، بقیهٔ صفحه تیره).
## مشکل / هدف
### مشکل ۱ — فرم ویرایش خالی است (باگ اصلی)
`BlogFormPage` دادهٔ ویرایش را از **اندپوینت عمومی** می‌گیرد:
`clinicpro/assets/admin/pages/BlogFormPage.tsx:48-53`
```tsx
const { data, isLoading } = useQuery({
queryKey: ['blog', uuid],
queryFn: () => api.get<ApiResponse<{ data: Blog }>>(`/api/v1/blog/${uuid}`),
enabled: isEdit,
});
const blog = data?.data?.data;
```
و اندپوینت عمومی هر پستِ غیرمنتشر را ۴۰۴ می‌کند:
`clinicpro/src/Blog/Controller/BlogController.php:147-152`
```php
public function detail(string $slug, Request $request): JsonResponse
{
$blog = $this->blogRepo->findBySlug($slug) ?? $this->blogRepo->findByUuid($slug);
if ($blog === null || $blog->getStatus() !== Blog::STATUS_PUBLISHED) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
```
نتیجه: **هر پیش‌نویس (و هر پست آرشیو) اصلاً قابل ویرایش نیست.** چون `isError` در کامپوننت مصرف نمی‌شود، کاربر فقط یک فرم خالی می‌بیند؛ اگر روی «بروزرسانی» بزند، `PATCH` با فیلدهای خالی ارسال می‌شود و **محتوای واقعی مقاله را پاک می‌کند** (چون `update()` با `array_key_exists` هر کلید ارسال‌شده را می‌نویسد). این خطرِ از دست رفتن داده است، نه فقط یک باگ نمایشی.
خروجی `ddev exec php bin/console debug:router | grep blog` تأیید می‌کند هیچ route ادمینی برای «جزئیات یک بلاگ» وجود ندارد:
```
app_blog_blog_list GET /api/v1/blogs
app_blog_blog_detail GET /api/v1/blog/{slug} ← فقط published
app_blog_blog_adminlist GET /api/v1/admin/blogs
app_blog_blog_create POST /api/v1/blog
app_blog_blog_update PATCH /api/v1/blog/{uuid}
...
```
**دو راه‌حل بررسی شد:**
| راه‌حل | مزیت | ریسک | تصمیم |
|---|---|---|---|
| A) شل کردن شرط در `detail()` عمومی: اگر `isGranted('ROLE_ADMIN')` بود، پیش‌نویس هم برگردان | بدون اندپوینت جدید | اندپوینت عمومی cache-able است (سایت با `next: { revalidate: 3600 }` می‌خواندش) و پاسخش وابسته به نقش می‌شود → نشت پیش‌نویس به کش CDN/ISR؛ نقض اصل «یک اندپوینت یک قرارداد» | ❌ رد |
| B) اندپوینت ادمین جدا: `GET /api/v1/admin/blog/{uuid}` با `#[IsGranted('ROLE_ADMIN')]`، بدون فیلتر status | قرارداد عمومی دست‌نخورده می‌ماند؛ آینهٔ دقیقِ `GET /api/v1/admin/blogs` که همین الآن هم برای لیست وجود دارد | یک route اضافه | ✅ انتخاب |
قاعدهٔ پروژه («اول بگرد، بعد توسعه بده، در آخر بساز») رعایت شده: اندپوینت موجودی که پیش‌نویس بدهد وجود ندارد و توسعهٔ اندپوینت عمومی ریسک نشت دارد.
### مشکل ۲ — Dark mode در ادیتور و فرم بلاگ
- در `assets/admin/styles.css` **هیچ** قاعده‌ای برای CKEditor نیست (`grep '\-\-ck-\|ck-content\|ck-editor'` روی کل `assets/` صفر نتیجه دارد). CKEditor 5 تمام رنگ‌هایش را از متغیرهای `--ck-color-*` می‌گیرد که پیش‌فرضشان روشن است → در `[data-theme="dark"]` نوار ابزار و بدنهٔ ادیتور سفید می‌ماند.
- کلاس `ck-rtl` در `BlogFormPage.tsx:159` و `RepresentationBlogFormPage.tsx:105` استفاده شده ولی **در هیچ CSS ای تعریف نشده** — یک کلاس مرده است.
- ناهماهنگی کلاس: در همان فرم بعضی فیلدها `cp-input` دارند و بعضی `input` (`BlogFormPage.tsx:150`, `BlogSeoFields.tsx:27`)، و `<label>`ها بدون کلاس‌اند در حالی که `.cp-label` (`styles.css:235`) و `.field-label` (`styles.css:580`) وجود دارد؛ `<label>` لخت فقط داخل `.form-row`/`.field-block` استایل می‌گیرد که اینجا نیست.
### مشکل ۳ — تغییرات ادمین با تأخیر روی سایت دیده می‌شود
`nobat724_front/app/blog/[slug]/page.js:21-32` با `next: { revalidate: 3600, tags: ['blog-<slug>'] }` می‌خواند و هیچ‌کس آن tag را باطل نمی‌کند → تا یک ساعت محتوای قدیمی. بک‌اند باید بعد از هر نوشتن (create/update/delete/review) webhook باطل‌سازی را صدا بزند. مسیر webhook در پرامپت فرانت ساخته می‌شود؛ اینجا فقط **فراخواننده** ساخته می‌شود.
## معیار پذیرش
-**موفق:**
- `GET /api/v1/admin/blog/56fd9a20-9594-4aa1-a651-346fa86720bd` با توکن ادمین → `200` و `data.data.title === 'rtertert'` و `data.data.status === 'draft'`.
- باز کردن `/admin/blogs/56fd9a20-9594-4aa1-a651-346fa86720bd/edit` → عنوان، محتوا، وضعیت، شهر، تگ‌ها و فیلدهای SEO پر شده‌اند؛ زدن «بروزرسانی» → توست موفق و مقادیر در DB عوض می‌شوند.
- در `[data-theme="dark"]` نوار ابزار و بدنهٔ CKEditor پس‌زمینهٔ `var(--surface)` و متن `var(--text)` دارند؛ contrast متن روی پس‌زمینه ≥ 4.5:1.
- بعد از `PATCH /api/v1/blog/{uuid}` یک درخواست `POST` به webhook فرانت با tag `blog-{slug}` و `blog-list` ارسال می‌شود (در لاگ dev قابل مشاهده).
-**خطا:**
- `GET /api/v1/admin/blog/{uuid}` بدون توکن → `401`؛ با توکن غیرادمین (مثلاً پزشک) → `403` با envelope `{success:false, errors:[{code,message}]}`.
- `GET /api/v1/admin/blog/00000000-0000-0000-0000-000000000000` با توکن ادمین → `404` با `ERR_NOT_FOUND_001`.
- وقتی query صفحهٔ ویرایش شکست بخورد، فرم خالیِ قابل‌ثبت نمایش داده **نشود**؛ به‌جایش پیام خطای فارسی + دکمهٔ بازگشت به `/admin/blogs` نمایش داده شود (تا PATCHِ پاک‌کننده رخ ندهد).
- اگر webhook فرانت در دسترس نباشد (timeout/۵xx)، ذخیرهٔ بلاگ **نباید** شکست بخورد؛ فقط warning لاگ شود.
- ⚠️ **مرزی:**
- مقاله‌ای که `status='archived'` است هم در فرم ویرایش کامل بارگذاری شود (اندپوینت ادمین هیچ فیلتر status ندارد).
- مقاله‌ای که `city_id = NULL` است → سلکت شهر روی «سراسری (همه شهرها)» بنشیند و پس از ذخیره همچنان `NULL` بماند (نه عدد صفر).
- مقاله‌ای با `faq = []` و `secondary_keywords = []` → فرم بدون خطا و بدون افزودن ردیف خالی بارگذاری شود.
- وقتی `REVALIDATE_WEBHOOK_URL` تنظیم نشده باشد (محیط dev/تست)، هیچ درخواست HTTP ای ارسال نشود و هیچ خطایی رخ ندهد.
## فایل‌های مرتبط
| فایل | نقش |
|---|---|
| `clinicpro/src/Blog/Controller/BlogController.php` | افزودن `adminDetail()`؛ صدا زدن سرویس باطل‌سازی در `create/update/delete/review` |
| `clinicpro/src/Blog/Service/BlogCacheInvalidator.php` | **جدید** — فراخوانی webhook فرانت (HttpClient تزریق‌شده) |
| `clinicpro/config/services.yaml` | bind کردن `$revalidateWebhookUrl` / `$revalidateWebhookSecret` |
| `clinicpro/assets/admin/pages/BlogFormPage.tsx` | سوییچ به اندپوینت ادمین، هندل `isError`، دکمهٔ submit با `isPending` |
| `clinicpro/assets/admin/components/BlogSeoFields.tsx` | یکسان‌سازی کلاس‌های input/label |
| `clinicpro/assets/admin/styles.css` | استایل CKEditor (روشن + دارک) و `.ck-rtl` |
| `clinicpro/docs/api/blog.md` | مستند اندپوینت جدید |
## وضعیت فعلی
`clinicpro/assets/admin/pages/BlogFormPage.tsx:48-66` (کپی عین کد):
```tsx
const { data, isLoading } = useQuery({
queryKey: ['blog', uuid],
queryFn: () => api.get<ApiResponse<{ data: Blog }>>(`/api/v1/blog/${uuid}`),
enabled: isEdit,
});
const blog = data?.data?.data;
const citiesQuery = useQuery({
queryKey: ['cities-select'],
queryFn: () => api.get<PaginatedResponse<City>>('/api/v1/admin/cities?limit=200'),
staleTime: 5 * 60_000,
});
const cityOptions = (citiesQuery.data?.data ?? []).map((c) => ({ value: c.id, label: c.name }));
const { register, handleSubmit, control, watch, setValue, formState: { errors, isSubmitting } } = useForm<BlogFormData>({
resolver: zodResolver(blogFormSchema),
defaultValues: blogDefaults(),
values: blog ? (blogDefaults(blog) as BlogFormData) : undefined,
});
```
`clinicpro/assets/admin/pages/BlogFormPage.tsx:154-174` (ادیتور، بدون هیچ استایل دارک):
```tsx
<label>محتوا *</label>
<Controller
control={control}
name="body"
render={({ field }) => (
<div dir="rtl" className="ck-rtl">
<CKEditor
editor={ClassicEditor as never}
data={field.value ?? ''}
onChange={(_evt: unknown, editor: { getData: () => string }) => field.onChange(editor.getData())}
config={{
licenseKey: 'GPL',
language: 'fa',
toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'blockQuote', 'insertTable', '|', 'undo', 'redo'],
}}
/>
</div>
)}
/>
```
`clinicpro/src/Blog/Controller/BlogController.php:376-397` (update فعلی — هر کلید ارسال‌شده را می‌نویسد):
```php
public function update(string $uuid, Request $request): JsonResponse
{
$blog = $this->blogRepo->findByUuid($uuid);
if ($blog === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('title', $data)) $blog->setTitle($data['title']);
if (array_key_exists('body', $data)) $blog->setBody($data['body']);
...
$this->blogRepo->save($blog);
return $this->success(['data' => $blog->toArray()]);
}
```
## وظایف
### ۱. اندپوینت ادمینِ جزئیات بلاگ
در `src/Blog/Controller/BlogController.php`، کنار `adminList()` (بخش `── Admin CRUD ──`):
```php
#[OA\Get(
path: '/api/v1/admin/blog/{uuid}',
summary: 'Get one blog post of ANY status for the admin edit form',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid')),
],
responses: [
new OA\Response(response: 200, description: 'Blog detail (draft/published/archived)'),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(response: 404, description: 'Blog post not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/blog/{uuid}', methods: ['GET'])]
public function adminDetail(string $uuid): JsonResponse
{
// برخلاف detail() عمومی، اینجا هیچ فیلتر status/city ای نیست: فرم ویرایش
// باید پیش‌نویس و آرشیو را هم کامل بارگذاری کند.
$blog = $this->blogRepo->findByUuid($uuid);
if ($blog === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
return $this->success(['data' => $blog->toArray()]);
}
```
شکل پاسخ **عمداً** همان double-nested `{data:{data:{...}}}` اندپوینت عمومی است تا `data?.data?.data` در فرانت دست‌نخورده بماند.
**نحوه تست:**
```bash
TOKEN=$(ddev exec php -r '...' ) # یا لاگین با 09390039833/09390039833 و برداشتن توکن
ddev exec curl -s -H "Authorization: Bearer $TOKEN" \
https://clinic-pro.ddev.site/api/v1/admin/blog/56fd9a20-9594-4aa1-a651-346fa86720bd | jq '.data.data | {title,status,city}'
# انتظار: {"title":"rtertert","status":"draft","city":null}
ddev exec curl -s -o /dev/null -w '%{http_code}\n' \
https://clinic-pro.ddev.site/api/v1/admin/blog/56fd9a20-9594-4aa1-a651-346fa86720bd # → 401
```
### ۲. اصلاح `BlogFormPage` — منبع داده، حالت خطا، حالت ثبت
```tsx
const { data, isLoading, isError, error } = useQuery({
queryKey: ['blog', uuid],
// اندپوینت ادمین: پیش‌نویس/آرشیو را هم برمی‌گرداند. اندپوینت عمومی فقط published
// است و برای پیش‌نویس ۴۰۴ می‌داد → فرم خالی و PATCHِ پاک‌کننده.
queryFn: () => api.get<ApiResponse<{ data: Blog }>>(`/api/v1/admin/blog/${uuid}`),
enabled: isEdit,
retry: false,
});
const blog = data?.data?.data;
```
و بعد از بلوک `isLoading`، قبل از رندر فرم:
```tsx
if (isEdit && (isError || !blog)) {
return (
<div className="cp-card p-6 text-center space-y-4">
<p className="text-[var(--danger)]">{(error as Error)?.message ?? 'مقاله یافت نشد'}</p>
<button className="cp-btn-secondary" onClick={() => navigate('/admin/blogs')}>بازگشت به فهرست مقالات</button>
</div>
);
}
```
همچنین دکمهٔ ثبت: `isSubmitting` هرگز `true` نمی‌شود چون `onSubmit` mutation را await نمی‌کند. جایگزین:
```tsx
const saving = createMutation.isPending || updateMutation.isPending;
...
<button type="submit" disabled={saving} className="cp-btn-primary justify-center py-2.5" style={{ minWidth: 160 }}>
{saving ? 'در حال ذخیره...' : isEdit ? 'بروزرسانی' : 'ذخیره'}
</button>
```
همین سه اصلاح را در `RepresentationBlogFormPage.tsx` هم بررسی کن؛ آن صفحه از `/api/v1/representation/blog/{uuid}` می‌خواند که فیلتر status ندارد، پس فقط مورد `isError` و `isPending` آنجا لازم است — اندپوینتش را عوض نکن.
**نحوه تست:** `yarn dev` و باز کردن `/admin/blogs/56fd9a20-9594-4aa1-a651-346fa86720bd/edit` با کاربر `09390039833 / 09390039833` → فرم پر است؛ عنوان را عوض کن و «بروزرسانی» بزن → توست موفق و `select title from blogs where uuid='56fd9a20…'` مقدار جدید را نشان دهد. سپس `/admin/blogs/00000000-0000-0000-0000-000000000000/edit` → کارت خطا، نه فرم خالی. تست‌های موجود `BlogFormPage.test.tsx` باید به‌روز و سبز شوند (`yarn test`).
### ۳. Dark mode برای CKEditor و یکسان‌سازی فرم
در انتهای `assets/admin/styles.css` (بعد از بقیهٔ کامپوننت‌ها) — CKEditor فقط از متغیرهای خودش رنگ می‌گیرد، پس نگاشت آن‌ها به توکن‌های پروژه تنها راه درست است (بدون هاردکد هگز):
```css
/* CKEditor 5 — نگاشت متغیرهای خود ادیتور به توکن‌های دیزاین‌سیستم.
بدون این نگاشت ادیتور در دارک‌مود سفید می‌ماند. */
.ck-rtl { direction: rtl; }
.ck-rtl .ck.ck-editor__editable { min-height: 320px; }
.ck.ck-editor {
--ck-color-base-background: var(--surface);
--ck-color-base-foreground: var(--surface-2);
--ck-color-base-text: var(--text);
--ck-color-base-border: var(--border);
--ck-color-toolbar-background: var(--surface-2);
--ck-color-toolbar-border: var(--border);
--ck-color-button-default-hover-background: var(--surface-3);
--ck-color-button-on-background: var(--primary-soft);
--ck-color-button-on-color: var(--primary);
--ck-color-dropdown-panel-background: var(--surface);
--ck-color-dropdown-panel-border: var(--border);
--ck-color-list-background: var(--surface);
--ck-color-list-button-hover-background: var(--surface-3);
--ck-color-panel-background: var(--surface);
--ck-color-panel-border: var(--border);
--ck-color-input-background: var(--surface);
--ck-color-input-border: var(--border);
--ck-color-input-text: var(--text);
--ck-border-radius: var(--r-sm);
}
.ck.ck-editor__editable_inline { background: var(--surface); color: var(--text); }
.ck.ck-editor__editable_inline.ck-focused { border-color: var(--primary); box-shadow: 0 0 0 4px var(--ring); }
.ck.ck-content blockquote { border-inline-start: 3px solid var(--border-2); color: var(--text-2); }
.ck.ck-content a { color: var(--primary); }
.ck.ck-content table td, .ck.ck-content table th { border-color: var(--border); }
[data-theme="dark"] .ck.ck-editor { --ck-color-text: var(--text); --ck-color-shadow-drop: rgb(0 0 0 / .4); }
[data-theme="dark"] .ck.ck-icon, [data-theme="dark"] .ck.ck-button { color: var(--text-2); }
[data-theme="dark"] .ck.ck-icon :is(path, polygon, rect, circle) { fill: currentColor; }
```
و یکسان‌سازی کلاس‌ها (بدون تغییر ساختار DOM):
- در `BlogFormPage.tsx:150` و `BlogSeoFields.tsx:27` کلاس `input``cp-input` (با `resize-none` حفظ شود).
- همهٔ `<label>`های بدون کلاس در `BlogFormPage.tsx` و `BlogSeoFields.tsx``className="cp-label"` (توکن موجود در `styles.css:235`).
⚠️ حدس نزن — قبل از افزودن، مقادیر واقعی توکن‌ها (`--ring`, `--primary-soft`, `--surface-3`) را در بلوک `:root` و `[data-theme="dark"]` همان فایل چک کن و اگر اسمی فرق داشت، اسم واقعی را بگذار.
**نحوه تست:** `yarn dev` → صفحهٔ ویرایش را در هر دو تم باز کن (سوییچ تم در تاپ‌بار). در دارک: نوار ابزار ادیتور تیره، متن تایپ‌شده روشن، دراپ‌داون `Paragraph` و پنل جدول تیره. سپس `/admin/blogs` و مودال حذف را هم در دارک ببین. اسکرین‌شات هر دو حالت را ضمیمهٔ گزارش کن.
### ۴. باطل‌سازی کش سایت عمومی بعد از هر نوشتن (cross-repo)
سرویس جدید `src/Blog/Service/BlogCacheInvalidator.php` — الگوی **Observer/side-effect service** با تزریق `HttpClientInterface`؛ دلیل انتخاب: اثر جانبیِ شبکه‌ای نباید داخل Controller یا Entity باشد و باید بی‌صدا شکست بخورد بدون آنکه تراکنش ذخیره را خراب کند.
```php
<?php
namespace App\Blog\Service;
use App\Blog\Entity\Blog;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* پس از هر نوشتن روی بلاگ، کش ISR سایت عمومی (nobat724_front) را باطل می‌کند.
* شکست این فراخوانی هرگز نباید ذخیرهٔ مقاله را شکست بدهد.
*/
class BlogCacheInvalidator
{
public function __construct(
private readonly HttpClientInterface $http,
private readonly LoggerInterface $logger,
private readonly string $webhookUrl,
private readonly string $webhookSecret,
) {}
public function invalidate(Blog $blog): void
{
if ($this->webhookUrl === '' || $this->webhookSecret === '') {
return; // محیط dev/تست بدون سایت عمومی
}
try {
$this->http->request('POST', $this->webhookUrl, [
'json' => ['tags' => ['blog-' . $blog->getSlug(), 'blog-' . $blog->getUuid(), 'blog-list']],
'headers' => ['X-Revalidate-Secret' => $this->webhookSecret],
'timeout' => 3,
])->getStatusCode();
} catch (\Throwable $e) {
$this->logger->warning('blog cache invalidation failed', ['uuid' => $blog->getUuid(), 'error' => $e->getMessage()]);
}
}
}
```
پارامترها در `config/services.yaml` (کنار بقیهٔ bindها):
```yaml
App\Blog\Service\BlogCacheInvalidator:
arguments:
$webhookUrl: '%env(default::REVALIDATE_WEBHOOK_URL)%'
$webhookSecret: '%env(default::REVALIDATE_WEBHOOK_SECRET)%'
```
و `.env` / `.env.local`:
```
REVALIDATE_WEBHOOK_URL=
REVALIDATE_WEBHOOK_SECRET=
```
سپس در `BlogController` سرویس را تزریق کن و **بعد از هر `$this->blogRepo->save($blog)`** در `create()`، `update()`، `review()` و بعد از `remove()` در `delete()` صدا بزن.
قرارداد webhook که سایت عمومی باید پیاده کند (در پرامپت همتا):
`POST {REVALIDATE_WEBHOOK_URL}` با هدر `X-Revalidate-Secret` و بدنهٔ `{"tags": ["blog-<slug>", "blog-<uuid>", "blog-list"]}``200 {"revalidated": true}`؛ سکرت غلط → `401`.
**نحوه تست:**
```bash
# یک شنوندهٔ ساده به‌جای سایت
php -S 127.0.0.1:9099 -t /tmp & # یا: nc -l 9099
# REVALIDATE_WEBHOOK_URL=http://host.docker.internal:9099/api/revalidate در .env.local
ddev exec curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"تست کش"}' https://clinic-pro.ddev.site/api/v1/blog/56fd9a20-9594-4aa1-a651-346fa86720bd
# انتظار: درخواست POST با هدر X-Revalidate-Secret روی پورت 9099 دیده شود
# و با URL خالی: هیچ درخواستی ارسال نشود و PATCH همچنان 200 بدهد
```
### ۵. مستندات
`docs/api/blog.md` را در همین جلسه به‌روز کن: اندپوینت `GET /api/v1/admin/blog/{uuid}` با permission، پارامتر، **JSON واقعیِ اجرای واقعی** (نه دست‌ساز)، و کدهای ۴۰۱/۴۰۳/۴۰۴؛ به‌علاوه یک بند دربارهٔ webhook باطل‌سازی و متغیرهای محیطی‌اش. اگر سند فعلی با رفتار موجود `detail()` عمومی فرق دارد، اول همان را اصلاح کن.
## نکات مهم
- **این باگ داده‌خور است:** تا وقتی وظیفهٔ ۲ اجرا نشده، هر بار که کاربر روی فرم خالیِ یک پیش‌نویس «بروزرسانی» زده باشد، `title/body/tags/...` آن مقاله در DB خالی شده است. قبل از شروع، از جدول `blogs` بکاپ بگیر (`ddev export-db`) و در گزارش پایانی بگو آیا رکوردی با `body` خالی وجود دارد یا نه.
- شکل پاسخ اندپوینت جدید عمداً double-nested است (`$this->success(['data' => ...])`) تا هم‌قرارداد با `detail()` بماند؛ این با هشدار «double nesting» در CLAUDE.md آگاهانه در تضاد است — هدف، دست‌نخورده ماندن مصرف‌کنندهٔ فعلی است.
- الگوی به‌کاررفته در وظیفهٔ ۴: **side-effect service با DI** (نه `new` داخل کنترلر، نه منطق شبکه در Entity) — مطابق §۵ راهنما.
- بلاگ در بک‌اند **دسته‌بندی ندارد**؛ فقط `tags` (ستون json). درخواست کاربر دربارهٔ «دسته‌بندی‌ها» با همین `tags` پوشش داده می‌شود. اگر واقعاً Entity دسته‌بندی لازم است، تسک جدا و migration جدا می‌خواهد — در این پرامپت نیست.
- Entity تغییر نمی‌کند → migration لازم نیست.
- بعد از تغییر، `npx tsc --noEmit`، `yarn test`، `ddev exec php vendor/bin/phpstan analyse` و `ddev exec php bin/phpunit` باید سبز باشند.
- کلاینت‌های متأثر از قرارداد: هیچ‌کدام از اندپوینت‌های موجود تغییر شکل نمی‌دهند (فقط افزوده می‌شود)، ولی `nobat724_front` باید route جدید `/api/revalidate` را پیاده کند وگرنه webhook همیشه ۴۰۴ می‌گیرد (که بی‌خطر لاگ می‌شود).
+10 -10
View File
@@ -17,46 +17,46 @@ export default function BlogSeoFields({ control, register }: Props) {
<h3 className="section-title">سئو و زمانبندی</h3>
<div>
<label>عنوان متا (Meta Title)</label>
<label className="cp-label">عنوان متا (Meta Title)</label>
<input {...register('meta_title')} className="cp-input h-11" placeholder="عنوان برای موتور جستجو" />
</div>
<div>
<label>توضیح متا (Meta Description)</label>
<textarea {...register('meta_description')} rows={2} className="input resize-none"
<label className="cp-label">توضیح متا (Meta Description)</label>
<textarea {...register('meta_description')} rows={2} className="cp-textarea resize-none"
placeholder="حداکثر ۱۵۵ کاراکتر" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label>کلیدواژهٔ اصلی</label>
<label className="cp-label">کلیدواژهٔ اصلی</label>
<input {...register('primary_keyword')} className="cp-input h-11" />
</div>
<div>
<label>کلیدواژههای فرعی (با ویرگول)</label>
<label className="cp-label">کلیدواژههای فرعی (با ویرگول)</label>
<input {...register('secondary_keywords')} className="cp-input h-11" placeholder="کلمه۱، کلمه۲" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label>لینکهای داخلی (URL با ویرگول)</label>
<label className="cp-label">لینکهای داخلی (URL با ویرگول)</label>
<input {...register('internal_links')} dir="ltr" className="cp-input h-11" placeholder="/a, /b" />
</div>
<div>
<label>لینکهای خارجی (URL با ویرگول)</label>
<label className="cp-label">لینکهای خارجی (URL با ویرگول)</label>
<input {...register('external_links')} dir="ltr" className="cp-input h-11" placeholder="https://..." />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label>زمان مطالعه (دقیقه)</label>
<label className="cp-label">زمان مطالعه (دقیقه)</label>
<input type="number" {...register('reading_time', { setValueAs: (v) => (v === '' || v == null ? null : Number(v)) })}
className="cp-input h-11" min={1} />
</div>
<div>
<label>زمانبندی انتشار</label>
<label className="cp-label">زمانبندی انتشار</label>
<Controller
control={control}
name="scheduled_at"
@@ -79,7 +79,7 @@ export default function BlogSeoFields({ control, register }: Props) {
{fields.map((f, i) => (
<div key={f.id} className="card card-pad" style={{ padding: 12 }}>
<input {...register(`faq.${i}.q` as const)} className="cp-input h-10 mb-2" placeholder="سوال" />
<textarea {...register(`faq.${i}.a` as const)} rows={2} className="input resize-none" placeholder="پاسخ" />
<textarea {...register(`faq.${i}.a` as const)} rows={2} className="cp-textarea resize-none" placeholder="پاسخ" />
<button type="button" className="mini-btn danger mt-2" onClick={() => remove(i)}>حذف</button>
</div>
))}
+81
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Routes, Route } from 'react-router-dom';
import { renderWithProviders } from '@/test/utils';
vi.mock('@ckeditor/ckeditor5-react', () => ({ CKEditor: () => null }));
@@ -45,3 +46,83 @@ describe('BlogFormPage — اعتبارسنجی zod (حالت ساخت)', () =>
expect(post).not.toHaveBeenCalled();
});
});
const BLOG_UUID = '56fd9a20-9594-4aa1-a651-346fa86720bd';
function renderEditPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/blogs/:uuid/edit" element={<BlogFormPage />} />
</Routes>,
{ route: `/admin/blogs/${BLOG_UUID}/edit` }
);
}
describe('BlogFormPage — حالت ویرایش', () => {
it('پیش‌نویس را از اندپوینت ادمین می‌گیرد و فرم را پر می‌کند', async () => {
get.mockImplementation((url: string) =>
url.startsWith(`/api/v1/admin/blog/${BLOG_UUID}`)
? Promise.resolve({
data: {
data: {
uuid: BLOG_UUID,
title: 'عنوان پیش‌نویس',
body: '<p>محتوای تست</p>',
summary: 'خلاصهٔ تست',
status: 'draft',
tags: ['الف', 'ب'],
},
},
})
: Promise.resolve({ data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } })
);
renderEditPage();
expect(await screen.findByDisplayValue('عنوان پیش‌نویس')).toBeInTheDocument();
expect(screen.getByDisplayValue('خلاصهٔ تست')).toBeInTheDocument();
expect(screen.getByDisplayValue('الف, ب')).toBeInTheDocument();
// اندپوینت عمومی (که پیش‌نویس را ۴۰۴ می‌کرد) نباید صدا زده شود
expect(get).not.toHaveBeenCalledWith(`/api/v1/blog/${BLOG_UUID}`);
});
it('خطای بارگذاری → کارت خطا به‌جای فرم خالیِ قابل‌ثبت', async () => {
get.mockImplementation((url: string) =>
url.startsWith(`/api/v1/admin/blog/${BLOG_UUID}`)
? Promise.reject(new Error('مقاله یافت نشد'))
: Promise.resolve({ data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } })
);
renderEditPage();
expect(await screen.findByText('مقاله یافت نشد')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'بروزرسانی' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'بازگشت به فهرست مقالات' })).toBeInTheDocument();
});
it('مقالهٔ سراسری با faq و کلیدواژهٔ خالی بدون خطا بارگذاری می‌شود', async () => {
get.mockImplementation((url: string) =>
url.startsWith(`/api/v1/admin/blog/${BLOG_UUID}`)
? Promise.resolve({
data: {
data: {
uuid: BLOG_UUID,
title: 'مقالهٔ سراسری',
body: '<p>x</p>',
status: 'draft',
tags: [],
faq: [],
secondary_keywords: [],
city: null,
},
},
})
: Promise.resolve({ data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } })
);
renderEditPage();
expect(await screen.findByDisplayValue('مقالهٔ سراسری')).toBeInTheDocument();
expect(screen.getByText('سوالی افزوده نشده است.')).toBeInTheDocument();
});
});
+30 -13
View File
@@ -45,10 +45,13 @@ export default function BlogFormPage() {
const [uploading, setUploading] = useState(false);
const [aiOpen, setAiOpen] = useState(false);
const { data, isLoading } = useQuery({
const { data, isLoading, isError, error } = useQuery({
queryKey: ['blog', uuid],
queryFn: () => api.get<ApiResponse<{ data: Blog }>>(`/api/v1/blog/${uuid}`),
// اندپوینت ادمین پیش‌نویس و آرشیو را هم برمی‌گرداند؛ اندپوینت عمومی فقط
// published است و برای پیش‌نویس ۴۰۴ می‌داد → فرم خالی بدون هیچ پیام خطا.
queryFn: () => api.get<ApiResponse<{ data: Blog }>>(`/api/v1/admin/blog/${uuid}`),
enabled: isEdit,
retry: false,
});
const blog = data?.data?.data;
@@ -59,7 +62,7 @@ export default function BlogFormPage() {
});
const cityOptions = (citiesQuery.data?.data ?? []).map((c) => ({ value: c.id, label: c.name }));
const { register, handleSubmit, control, watch, setValue, formState: { errors, isSubmitting } } = useForm<BlogFormData>({
const { register, handleSubmit, control, watch, setValue, formState: { errors } } = useForm<BlogFormData>({
resolver: zodResolver(blogFormSchema),
defaultValues: blogDefaults(),
values: blog ? (blogDefaults(blog) as BlogFormData) : undefined,
@@ -88,6 +91,8 @@ export default function BlogFormPage() {
onError: (err: Error) => toast.error(err.message),
});
const saving = createMutation.isPending || updateMutation.isPending;
const onSubmit = (d: BlogFormData) => {
if (isEdit) updateMutation.mutate(d);
else createMutation.mutate(d);
@@ -119,6 +124,18 @@ export default function BlogFormPage() {
);
}
// فرم خالیِ قابل‌ثبت نمایش داده نشود: کاربر باید بفهمد مقاله بارگذاری نشده.
if (isEdit && (isError || !blog)) {
return (
<div className="cp-card p-6 text-center space-y-4">
<p className="text-[var(--danger)]">{(error as Error | null)?.message ?? 'مقاله یافت نشد'}</p>
<button type="button" className="cp-btn-secondary" onClick={() => navigate('/admin/blogs')}>
بازگشت به فهرست مقالات
</button>
</div>
);
}
return (
<div>
<PageHeader
@@ -140,18 +157,18 @@ export default function BlogFormPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-5">
<div>
<label>عنوان مقاله *</label>
<label className="cp-label">عنوان مقاله *</label>
<input {...register('title')} placeholder="عنوان جذاب بنویسید..." className="cp-input h-11" />
{errors.title && <p className="text-[var(--danger)] text-xs mt-1">{errors.title.message}</p>}
</div>
<div>
<label>خلاصه</label>
<textarea {...register('summary')} rows={2} placeholder="خلاصه کوتاه مقاله..." className="input resize-none" />
<label className="cp-label">خلاصه</label>
<textarea {...register('summary')} rows={2} placeholder="خلاصه کوتاه مقاله..." className="cp-textarea resize-none" />
</div>
<div>
<label>محتوا *</label>
<label className="cp-label">محتوا *</label>
<Controller
control={control}
name="body"
@@ -176,7 +193,7 @@ export default function BlogFormPage() {
<div className="space-y-5">
<div>
<label>تصویر شاخص</label>
<label className="cp-label">تصویر شاخص</label>
{imageUrl ? (
<div className="relative">
<img src={imageUrl} alt="cover" style={{ width: '100%', height: 160, objectFit: 'cover', borderRadius: 8 }} />
@@ -193,7 +210,7 @@ export default function BlogFormPage() {
</div>
<div>
<label>وضعیت انتشار</label>
<label className="cp-label">وضعیت انتشار</label>
<Controller
control={control}
name="status"
@@ -208,7 +225,7 @@ export default function BlogFormPage() {
</div>
<div>
<label>شهر</label>
<label className="cp-label">شهر</label>
<Controller
control={control}
name="city_id"
@@ -227,7 +244,7 @@ export default function BlogFormPage() {
</div>
<div>
<label>تگها (با ویرگول جدا کنید)</label>
<label className="cp-label">تگها (با ویرگول جدا کنید)</label>
<input {...register('tags')} dir="ltr" placeholder="tag1, tag2, tag3" className="cp-input h-11" />
</div>
</div>
@@ -239,8 +256,8 @@ export default function BlogFormPage() {
</div>
<div className="flex gap-3">
<button type="submit" disabled={isSubmitting} className="cp-btn-primary justify-center py-2.5" style={{ minWidth: 160 }}>
{isSubmitting ? 'در حال ذخیره...' : isEdit ? 'بروزرسانی' : 'ذخیره'}
<button type="submit" disabled={saving} className="cp-btn-primary justify-center py-2.5" style={{ minWidth: 160 }}>
{saving ? 'در حال ذخیره...' : isEdit ? 'بروزرسانی' : 'ذخیره'}
</button>
<button type="button" onClick={() => navigate('/admin/blogs')} className="cp-btn-secondary justify-center py-2.5" style={{ minWidth: 120 }}>
لغو
@@ -29,14 +29,15 @@ export default function RepresentationBlogFormPage() {
});
const cityOptions = (meQuery.data?.data?.cities ?? []).map((c) => ({ value: c.id, label: c.name }));
const { data, isLoading } = useQuery({
const { data, isLoading, isError, error } = useQuery({
queryKey: ['rep-blog', uuid],
queryFn: () => api.get<ApiResponse<{ data: Blog }>>(`/api/v1/representation/blog/${uuid}`),
enabled: isEdit,
retry: false,
});
const blog = data?.data?.data;
const { register, handleSubmit, control, formState: { errors, isSubmitting } } = useForm<BlogFormData>({
const { register, handleSubmit, control, formState: { errors } } = useForm<BlogFormData>({
resolver: zodResolver(blogFormSchema),
defaultValues: blogDefaults(),
values: blog ? (blogDefaults(blog) as BlogFormData) : undefined,
@@ -63,6 +64,8 @@ export default function RepresentationBlogFormPage() {
onError: (err: Error) => toast.error(err.message),
});
const saving = createMutation.isPending || updateMutation.isPending;
const onSubmit = (d: BlogFormData) => {
if (!d.city_id) {
toast.error('انتخاب شهر الزامی است');
@@ -76,6 +79,17 @@ export default function RepresentationBlogFormPage() {
return <div className="cp-card p-6 space-y-3">{Array.from({ length: 5 }).map((_, i) => <div key={i} className="h-10 rounded-lg skeleton" />)}</div>;
}
if (isEdit && (isError || !blog)) {
return (
<div className="cp-card p-6 text-center space-y-4">
<p className="text-[var(--danger)]">{(error as Error | null)?.message ?? 'مقاله یافت نشد'}</p>
<button type="button" className="cp-btn-secondary" onClick={() => navigate('/admin/representation-blogs')}>
بازگشت به فهرست مقالات
</button>
</div>
);
}
return (
<div>
<PageHeader
@@ -88,16 +102,16 @@ export default function RepresentationBlogFormPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-5">
<div>
<label>عنوان مقاله *</label>
<label className="cp-label">عنوان مقاله *</label>
<input {...register('title')} className="cp-input h-11" placeholder="عنوان جذاب بنویسید..." />
{errors.title && <p className="text-[var(--danger)] text-xs mt-1">{errors.title.message}</p>}
</div>
<div>
<label>خلاصه</label>
<textarea {...register('summary')} rows={2} className="input resize-none" />
<label className="cp-label">خلاصه</label>
<textarea {...register('summary')} rows={2} className="cp-textarea resize-none" />
</div>
<div>
<label>محتوا *</label>
<label className="cp-label">محتوا *</label>
<Controller
control={control}
name="body"
@@ -118,7 +132,7 @@ export default function RepresentationBlogFormPage() {
<div className="space-y-5">
<div>
<label>شهر *</label>
<label className="cp-label">شهر *</label>
<Controller
control={control}
name="city_id"
@@ -135,7 +149,7 @@ export default function RepresentationBlogFormPage() {
<p className="text-[12px] text-[var(--text-3)] mt-1">فقط شهرهای حوزهٔ نمایندگی شما.</p>
</div>
<div>
<label>وضعیت انتشار</label>
<label className="cp-label">وضعیت انتشار</label>
<Controller
control={control}
name="status"
@@ -149,11 +163,11 @@ export default function RepresentationBlogFormPage() {
/>
</div>
<div>
<label>آدرس تصویر شاخص (URL)</label>
<label className="cp-label">آدرس تصویر شاخص (URL)</label>
<input {...register('image_url')} dir="ltr" className="cp-input h-11" placeholder="https://..." />
</div>
<div>
<label>تگها (با ویرگول)</label>
<label className="cp-label">تگها (با ویرگول)</label>
<input {...register('tags')} dir="ltr" className="cp-input h-11" placeholder="tag1, tag2" />
</div>
</div>
@@ -164,8 +178,8 @@ export default function RepresentationBlogFormPage() {
</div>
<div className="flex gap-3">
<button type="submit" disabled={isSubmitting} className="cp-btn-primary justify-center py-2.5" style={{ minWidth: 160 }}>
{isSubmitting ? 'در حال ذخیره...' : isEdit ? 'بروزرسانی' : 'ذخیره'}
<button type="submit" disabled={saving} className="cp-btn-primary justify-center py-2.5" style={{ minWidth: 160 }}>
{saving ? 'در حال ذخیره...' : isEdit ? 'بروزرسانی' : 'ذخیره'}
</button>
<button type="button" onClick={() => navigate('/admin/representation-blogs')} className="cp-btn-secondary justify-center py-2.5" style={{ minWidth: 120 }}>لغو</button>
</div>
+63
View File
@@ -966,3 +966,66 @@ html, body { max-width: 100%; overflow-x: hidden; }
می‌شود تا ساعت دوتایی نشود — مثل مبدأ tauri که فقط یک ClockField دارد. */
.time-input-plain::-webkit-calendar-picker-indicator { display: none; }
.time-input-plain { appearance: none; -webkit-appearance: none; }
/* ── CKEditor 5 ────────────────────────────────────────────────────────────
ادیتور همهٔ رنگ‌هایش را از متغیرهای --ck-color-* خودش می‌گیرد و پیش‌فرض آن‌ها
روشن است؛ بدون این نگاشت، ادیتور در [data-theme="dark"] سفید می‌ماند. */
.ck-rtl { direction: rtl; }
.ck-rtl .ck.ck-editor__editable { min-height: 320px; }
/* .ck-body-wrapper هم لازم است: بالنِ لینک و جدول به <body> منتقل می‌شوند و
بیرون از .ck-editor قرار می‌گیرند، پس متغیرها به آن‌ها ارث نمی‌رسد. */
.ck.ck-editor,
.ck-body-wrapper {
--ck-color-base-background: var(--surface);
--ck-color-base-foreground: var(--surface-2);
--ck-color-base-text: var(--text);
--ck-color-base-border: var(--border);
--ck-color-base-active: var(--primary);
--ck-color-base-active-focus: var(--primary-600);
--ck-color-text: var(--text);
--ck-color-toolbar-background: var(--surface-2);
--ck-color-toolbar-border: var(--border);
--ck-color-button-default-background: transparent;
--ck-color-button-default-hover-background: var(--surface-3);
--ck-color-button-default-active-background: var(--surface-3);
--ck-color-button-on-background: var(--primary-soft);
--ck-color-button-on-hover-background: var(--primary-soft2);
--ck-color-button-on-color: var(--primary);
--ck-color-dropdown-panel-background: var(--surface);
--ck-color-dropdown-panel-border: var(--border);
--ck-color-list-background: var(--surface);
--ck-color-list-button-hover-background: var(--surface-3);
--ck-color-list-button-on-background: var(--primary-soft);
--ck-color-list-button-on-text: var(--primary);
--ck-color-panel-background: var(--surface);
--ck-color-panel-border: var(--border);
--ck-color-input-background: var(--surface);
--ck-color-input-border: var(--border);
--ck-color-input-text: var(--text);
--ck-color-input-disabled-background: var(--surface-2);
--ck-color-labeled-field-label-background: var(--surface);
--ck-color-split-button-hover-background: var(--surface-3);
--ck-color-split-button-hover-border: var(--border-2);
--ck-color-tooltip-background: var(--surface-3);
--ck-color-tooltip-text: var(--text);
--ck-color-editable-blur-selection: var(--surface-3);
--ck-border-radius: var(--r-sm);
}
.ck.ck-editor__editable_inline { background: var(--surface); color: var(--text); }
.ck.ck-editor__editable_inline.ck-focused {
border-color: var(--primary);
box-shadow: 0 0 0 4px var(--ring);
}
.ck.ck-content blockquote { border-inline-start: 3px solid var(--border-2); color: var(--text-2); }
.ck.ck-content a { color: var(--primary); }
.ck.ck-content :is(h2, h3, h4, h5, strong) { color: var(--text); }
.ck.ck-content :is(table td, table th) { border-color: var(--border); }
.ck.ck-content table th { background: var(--surface-2); }
/* آیکون‌های SVG ادیتور fill ثابت دارند؛ بدون currentColor در دارک‌مود دیده نمی‌شوند. */
[data-theme="dark"] .ck.ck-button,
[data-theme="dark"] .ck.ck-button .ck-button__label { color: var(--text-2); }
[data-theme="dark"] .ck.ck-icon,
[data-theme="dark"] .ck.ck-icon :is(path, polygon, rect, circle, ellipse) { fill: currentColor; }
[data-theme="dark"] .ck.ck-button.ck-on .ck-icon :is(path, polygon, rect, circle, ellipse) { fill: var(--primary); }
+5
View File
@@ -121,6 +121,11 @@ services:
arguments:
$projectDir: '%kernel.project_dir%'
App\Blog\Service\BlogCacheInvalidator:
arguments:
$webhookUrl: '%env(REVALIDATE_WEBHOOK_URL)%'
$webhookSecret: '%env(REVALIDATE_WEBHOOK_SECRET)%'
App\Insurance\Controller\InsuranceController:
arguments:
$projectDir: '%kernel.project_dir%'
+151 -12
View File
@@ -100,21 +100,113 @@ Get a single blog post by slug.
> بدون این پارامتر، پستی که از لیستِ یک دامنه فیلتر شده بود همچنان با URL مستقیم روی همان دامنه ۲۰۰ می‌گرفت و یک محتوا روی چند دامنه تکرار می‌شد. سایت عمومی (`nobat724_front`) روی دامنه‌های شهری همیشه `city_id` را می‌فرستد.
### Response `200`
> ⚠️ پاسخ **double-nested** است (`data.data`) — کنترلر `$this->success(['data' => $blog->toArray()])` برمی‌گرداند. کلاینت باید `data?.data` را بخواند.
شکل کامل، عیناً خروجی `Blog::toArray()`:
```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
"data": {
"uuid": "...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": "خلاصه...",
"body": "<p>محتوای کامل...</p>",
"image_url": "/uploads/blogs/blog_xxx_cover.png",
"tags": ["دیابت", "تغذیه"],
"sources": [{ "url": "https://...", "title": "..." }],
"status": "published",
"review_status": null,
"reviewer": null,
"reviewed_at": null,
"review_note": null,
"topic_slug": null,
"meta_title": null,
"meta_description": null,
"primary_keyword": null,
"secondary_keywords": [],
"faq": [{ "q": "...", "a": "..." }],
"internal_links": [],
"external_links": [],
"reading_time": null,
"canonical_url": null,
"og_image": null,
"scheduled_at": null,
"representation": null,
"author": { "uuid": "...", "name": "احمدی" },
"city": null,
"created_at": 1717000000,
"updated_at": 1717100000
}
}
}
```
> نکته: `tags` آرایهٔ **رشته** است (نه آبجکت `{id,name}`)، تصویر در `image_url` (نه `image`) و نام نویسنده در `author.name` (نه `real_name`). نسخهٔ قبلی این سند شکل دیگری نشان می‌داد که با کد هم‌خوان نبود.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Blog not found, not published, or owned by another city (when `city_id` is sent) |
---
## GET `/api/v1/admin/blog/{uuid}`
Get **one** blog post of any status for the admin edit form.
**Permission:** `ROLE_ADMIN`
اندپوینت عمومی `GET /api/v1/blog/{slug}` هر پستِ غیر `published` را ۴۰۴ می‌کند، پس فرم ویرایش پنل ادمین نمی‌توانست پیش‌نویس و آرشیو را بارگذاری کند (فرم خالی بالا می‌آمد). این اندپوینت هیچ فیلتر `status`/`city` ندارد. اندپوینت عمومی عمداً دست‌نخورده ماند تا پاسخِ cache-شدهٔ آن وابسته به نقشِ فراخوان نشود.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string | UUID مقاله. الگوی اجباری `[0-9a-fA-F-]{36}` — بدون آن این route مسیر `/api/v1/admin/blog/review-queue` را می‌دزدید. **فقط UUID؛ slug پذیرفته نمی‌شود.** |
### Response `200` (خروجی واقعی curl روی پیش‌نویس)
```json
{
"success": true,
"data": {
"data": {
"uuid": "56fd9a20-9594-4aa1-a651-346fa86720bd",
"title": "rtertert",
"slug": "نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20",
"summary": "ertet",
"body": "<p>etetetet</p>",
"image_url": null,
"tags": [],
"sources": [],
"status": "draft",
"review_status": null,
"reviewer": null,
"reviewed_at": null,
"review_note": null,
"topic_slug": null,
"meta_title": null,
"meta_description": null,
"primary_keyword": null,
"secondary_keywords": [],
"faq": [
{ "q": "etrtert", "a": "retet" },
{ "q": "eertret", "a": "ertet" }
],
"internal_links": [],
"external_links": [],
"reading_time": null,
"canonical_url": null,
"og_image": null,
"scheduled_at": null,
"representation": null,
"author": { "uuid": "7464eba2-6754-4cea-bcde-83e47050e829", "name": "1402/12/17 12" },
"city": null,
"created_at": 1784827054,
"updated_at": 1785157798
}
}
}
```
@@ -122,7 +214,9 @@ Get a single blog post by slug.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Blog not found, not published, or owned by another city (when `city_id` is sent) |
| `ERR_AUTH_001` | 401 | بدون توکن |
| `ERR_FORBIDDEN_001` | 403 | توکن معتبر ولی بدون `ROLE_ADMIN` |
| `ERR_NOT_FOUND_001` | 404 | مقاله با این UUID وجود ندارد |
---
@@ -433,3 +527,48 @@ Upload blog post header image.
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_FILE_001` | 422 | Invalid file type |
---
## باطل‌سازی کش سایت عمومی (webhook خروجی)
سایت عمومی (`nobat724_front`) پاسخ `GET /api/v1/blog/{slug}` را با `next: { revalidate: 3600, tags: [...] }` کش می‌کند. بدون باطل‌سازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمی‌شد.
`App\Blog\Service\BlogCacheInvalidator` بعد از **هر نوشتن** یک درخواست خروجی می‌فرستد:
| نقطهٔ فراخوانی | فایل |
|---|---|
| `POST /api/v1/blog` (create) | `BlogController::create` |
| `PATCH /api/v1/blog/{uuid}` | `BlogController::update` |
| `POST /api/v1/admin/blog/{uuid}/review` | `BlogController::review` |
| `DELETE /api/v1/blog/{uuid}` | `BlogController::delete` (**قبل** از `remove`) |
| `POST/PATCH/DELETE /api/v1/representation/blog…` | `RepresentationBlogController` |
### قرارداد درخواست (خروجی — مصرف‌کننده `nobat724_front`)
```
POST {REVALIDATE_WEBHOOK_URL}
X-Revalidate-Secret: {REVALIDATE_WEBHOOK_SECRET}
Content-Type: application/json
{"tags":["blog-<slug>","blog-<uuid>","blog-list"]}
```
بدنهٔ واقعی یک `PATCH` (خروجی شنوندهٔ تست):
```json
{"tags":["blog-نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20","blog-56fd9a20-9594-4aa1-a651-346fa86720bd","blog-list"]}
```
پاسخ مورد انتظار سایت: `200 {"revalidated": true}`؛ سکرت غلط `401`؛ بدنهٔ بدون `tags``400`.
### متغیرهای محیطی
| متغیر | پیش‌فرض | معنا |
|---|---|---|
| `REVALIDATE_WEBHOOK_URL` | خالی | آدرس webhook سایت. **خالی = هیچ درخواستی ارسال نمی‌شود** |
| `REVALIDATE_WEBHOOK_SECRET` | خالی | باید با `REVALIDATE_SECRET` سمت سایت یکی باشد. خالی = ارسال نمی‌شود |
### رفتار خطا — fail-open
timeout سه ثانیه است و هر خطای شبکه فقط با سطح `warning` لاگ می‌شود؛ **ذخیره/حذف مقاله هرگز به خاطر شکست webhook شکست نمی‌خورد.** تست‌شده: با URL غیرقابل‌دسترس، `PATCH` همچنان `200` برمی‌گرداند.
+54
View File
@@ -24,6 +24,7 @@ class BlogController extends BaseController
private readonly CityRepository $cityRepo,
private readonly FileValidatorService $fileValidator,
private readonly \App\Blog\Service\BlogWriter $blogWriter,
private readonly \App\Blog\Service\BlogCacheInvalidator $cacheInvalidator,
private readonly string $projectDir,
) {}
@@ -203,6 +204,52 @@ class BlogController extends BaseController
}
#[OA\Get(
path: '/api/v1/admin/blog/{uuid}',
summary: 'Get one blog post of ANY status for the admin edit form',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid')),
],
responses: [
new OA\Response(
response: 200,
description: 'Blog detail of any status (draft/published/archived)',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'data', type: 'object'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(response: 404, description: 'Blog post not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
// requirement اجباری است: این route قبل از /api/v1/admin/blog/review-queue ثبت
// می‌شود و بدون الگوی uuid، آن مسیر را با uuid="review-queue" می‌دزدید.
#[Route('/api/v1/admin/blog/{uuid}', methods: ['GET'], requirements: ['uuid' => '[0-9a-fA-F-]{36}'])]
public function adminDetail(string $uuid): JsonResponse
{
// برخلاف detail() عمومی اینجا هیچ فیلتر status/city ای نیست: فرم ویرایش باید
// پیش‌نویس و آرشیو را هم کامل بارگذاری کند. شکل پاسخ عمداً همان double-nested
// اندپوینت عمومی است تا مصرف‌کنندهٔ فعلی (data.data.data) دست‌نخورده بماند.
$blog = $this->blogRepo->findByUuid($uuid);
if ($blog === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
return $this->success(['data' => $blog->toArray()]);
}
#[OA\Post(
path: '/api/v1/blog',
summary: 'Create a new blog post (ROLE_ADMIN, or ROLE_IMPORTER as a pending-review draft)',
@@ -320,6 +367,7 @@ class BlogController extends BaseController
}
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()], 201);
}
@@ -392,6 +440,7 @@ class BlogController extends BaseController
$this->blogWriter->applySeoFields($blog, $data);
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()]);
}
@@ -489,6 +538,7 @@ class BlogController extends BaseController
}
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()]);
}
@@ -536,7 +586,11 @@ class BlogController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
// باطل‌سازی قبل از حذف: بعد از remove، Doctrine آبجکت را detach می‌کند و
// اتکا به مقادیر باقی‌مانده در حافظه شکننده است.
$this->cacheInvalidator->invalidate($blog);
$this->blogRepo->remove($blog);
return $this->success(['message' => 'مقاله با موفقیت حذف شد']);
}
@@ -5,6 +5,7 @@ namespace App\Blog\Controller;
use App\Auth\Entity\User;
use App\Blog\Entity\Blog;
use App\Blog\Repository\BlogRepository;
use App\Blog\Service\BlogCacheInvalidator;
use App\Blog\Service\BlogWriter;
use App\Location\Entity\City;
use App\Location\Repository\CityRepository;
@@ -33,6 +34,7 @@ class RepresentationBlogController extends BaseController
private readonly RepresentationRepository $repRepo,
private readonly CityRepository $cityRepo,
private readonly BlogWriter $writer,
private readonly BlogCacheInvalidator $cacheInvalidator,
) {}
private function currentRepresentation(User $user): Representation
@@ -124,6 +126,7 @@ class RepresentationBlogController extends BaseController
$blog->setSlug($blog->getSlug() . '-' . substr(uniqid(), -4));
}
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()], 201);
}
@@ -149,6 +152,7 @@ class RepresentationBlogController extends BaseController
}
$this->writer->applySeoFields($blog, $data);
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()]);
}
@@ -159,7 +163,9 @@ class RepresentationBlogController extends BaseController
{
$rep = $this->currentRepresentation($user);
$blog = $this->ownedBlogOr404($uuid, $rep);
$this->cacheInvalidator->invalidate($blog);
$this->blogRepo->remove($blog);
return $this->success(['message' => 'مقاله حذف شد']);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Blog\Service;
use App\Blog\Entity\Blog;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* پس از هر نوشتن روی بلاگ، کش ISR سایت عمومی (nobat724_front) را باطل می‌کند.
*
* سایت پاسخ `GET /api/v1/blog/{slug}` را با `revalidate: 3600` و tag ذخیره
* می‌کند؛ بدون این فراخوانی، تغییر پنل ادمین تا یک ساعت روی سایت دیده نمی‌شود.
*
* قرارداد: `POST {webhookUrl}` با هدر `X-Revalidate-Secret` و بدنهٔ
* `{"tags": ["blog-<slug>", "blog-<uuid>", "blog-list"]}`.
*
* fail-open: شکست شبکه فقط لاگ می‌شود و هرگز ذخیرهٔ مقاله را نمی‌شکند.
*/
class BlogCacheInvalidator
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly string $webhookUrl, // خالی = سایت عمومی پیکربندی نشده
private readonly string $webhookSecret,
) {}
public function invalidate(Blog $blog): void
{
if ($this->webhookUrl === '' || $this->webhookSecret === '') {
return;
}
// مقادیر قبل از هر تغییر بعدی برداشته می‌شوند تا حذف مقاله هم قابل باطل‌سازی باشد.
$tags = array_values(array_filter([
$blog->getSlug() !== '' ? 'blog-' . $blog->getSlug() : null,
'blog-' . $blog->getUuid(),
'blog-list',
]));
try {
$this->httpClient->request('POST', $this->webhookUrl, [
'json' => ['tags' => $tags],
'headers' => ['X-Revalidate-Secret' => $this->webhookSecret],
'timeout' => 3,
])->getStatusCode();
} catch (\Throwable $e) {
$this->logger->warning('blog cache invalidation failed', [
'uuid' => $blog->getUuid(),
'error' => $e->getMessage(),
]);
}
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Tests\Blog;
use App\Auth\Entity\User;
use App\Blog\Entity\Blog;
use App\Blog\Service\BlogCacheInvalidator;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* باطل‌سازی کش ISR سایت عمومی پس از هر نوشتن روی بلاگ.
* fail-open است: شکست شبکه نباید ذخیرهٔ مقاله را بشکند.
*/
class BlogCacheInvalidatorTest extends TestCase
{
private function makeBlog(): Blog
{
return new Blog(new User('09120000000'), 'عنوان آزمایشی', 'متن آزمایشی مقاله');
}
/** @param list<array{0:string,1:string,2:array}> $calls */
private function recordingClient(array &$calls, int $status = 200): HttpClientInterface
{
return new MockHttpClient(function (string $method, string $url, array $options) use (&$calls, $status) {
$calls[] = [$method, $url, $options];
return new MockResponse('{"revalidated":true}', ['http_code' => $status]);
});
}
public function testSendsTagsForSlugUuidAndList(): void
{
$calls = [];
$blog = $this->makeBlog();
(new BlogCacheInvalidator(
$this->recordingClient($calls),
new NullLogger(),
'https://nobat724.com/api/revalidate',
's3cret',
))->invalidate($blog);
self::assertCount(1, $calls);
[$method, $url, $options] = $calls[0];
self::assertSame('POST', $method);
self::assertSame('https://nobat724.com/api/revalidate', $url);
self::assertContains('X-Revalidate-Secret: s3cret', $options['headers']);
$body = json_decode($options['body'], true);
self::assertSame(
['blog-' . $blog->getSlug(), 'blog-' . $blog->getUuid(), 'blog-list'],
$body['tags']
);
}
public function testNetworkFailureIsSwallowed(): void
{
$client = new MockHttpClient(function (): MockResponse {
throw new class ('boom') extends \RuntimeException implements TransportExceptionInterface {};
});
$invalidator = new BlogCacheInvalidator($client, new NullLogger(), 'https://nobat724.com/api/revalidate', 's3cret');
$invalidator->invalidate($this->makeBlog());
self::assertTrue(true, 'شکست شبکه نباید exception بدهد');
}
public function testNoRequestWhenWebhookIsNotConfigured(): void
{
$calls = [];
// URL خالی — محیط dev/تست بدون سایت عمومی
(new BlogCacheInvalidator($this->recordingClient($calls), new NullLogger(), '', 's3cret'))
->invalidate($this->makeBlog());
// سکرت خالی — پیکربندی ناقص نباید درخواست بدون احراز بفرستد
(new BlogCacheInvalidator($this->recordingClient($calls), new NullLogger(), 'https://nobat724.com/api/revalidate', ''))
->invalidate($this->makeBlog());
self::assertSame([], $calls);
}
}