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