Files
clinicpro/.claude/prompt/blog-admin-edit-and-dark-mode.md
hamed 15abcb5c8a 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.
2026-07-27 18:54:35 +03:30

406 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# رفع ویرایش بلاگ در پنل ادمین + سازگاری 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 همیشه ۴۰۴ می‌گیرد (که بی‌خطر لاگ می‌شود).