Files
clinicpro/.claude/prompt/blog-admin-edit-and-dark-mode.md
T
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

26 KiB
Raw Blame History

رفع ویرایش بلاگ در پنل ادمین + سازگاری 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

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

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 (کپی عین کد):

  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 (ادیتور، بدون هیچ استایل دارک):

                <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 فعلی — هر کلید ارسال‌شده را می‌نویسد):

    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 ──):

    #[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 در فرانت دست‌نخورده بماند.

نحوه تست:

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 — منبع داده، حالت خطا، حالت ثبت

  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، قبل از رندر فرم:

  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 نمی‌کند. جایگزین:

  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 فقط از متغیرهای خودش رنگ می‌گیرد، پس نگاشت آن‌ها به توکن‌های پروژه تنها راه درست است (بدون هاردکد هگز):

/* 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 کلاس inputcp-input (با resize-none حفظ شود).
  • همهٔ <label>های بدون کلاس در BlogFormPage.tsx و BlogSeoFields.tsxclassName="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

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ها):

    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.

نحوه تست:

# یک شنوندهٔ ساده به‌جای سایت
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 همیشه ۴۰۴ می‌گیرد (که بی‌خطر لاگ می‌شود).