# رفع ویرایش بلاگ در پنل ادمین + سازگاری 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 همیشه ۴۰۴ می‌گیرد (که بی‌خطر لاگ می‌شود).
