Files
hamedandClaude Opus 4.8 e78b0c4b4c chore(prompt): split SEO audit follow-up into backend task prompts
The nobat724_front SEO audit produced three backend-side tasks that the
frontend work is blocked on or that it only masks:

- doctors-list-city-and-limit: implemented in the previous commit
- blog-city-field: blog has no city column, so the city-scoped blog work
  on the public site is written but inert
- cleanup-polluted-doctor-clinic-records: records named after a phone
  number or "test" reach public results; the site currently hides them
  with a noindex filter, which masks rather than fixes the data

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 08:09:00 +03:30

6.9 KiB

افزودن شهر به بلاگ (پیش‌نیاز بلاگ شهر-محور)

پروژه

clinicpro (backend)

cross-repo: این قرارداد را سایت عمومی مصرف می‌کند. پرامپت همتا (بعد از این اجرا شود): nobat724_front/.claude/prompt/blog-city-scoping-activate.md

زمینه

سایت عمومی ۳۵ دامنهٔ شهری دارد و هر شهر نمایندهٔ محتوایی خودش را دارد، اما بلاگ هیچ ارتباطی با شهر ندارد: نه در URL، نه در متادیتا، نه در مدل داده. نتیجه اینکه محتوای بلاگ روی همهٔ ۳۵ دامنه یکسان است و هیچ اعتبار محلی نمی‌سازد.

سمت فرانت، منطق شهر-محور از قبل نوشته و مستقر شده ولی غیرفعال است چون داده‌اش وجود ندارد:

  • nobat724_front/app/blog/[slug]/page.js — canonical پست را با extractEntityCityId(blog) حساب می‌کند؛ چون city_id نیست، همیشه fallback به self می‌خورد.
  • برچسب بصری «مخصوص شهر: X» در components/blog/head/index.js فقط وقتی cityName بیاید رندر می‌شود.
  • spatialCoverage در JSON-LD مقاله فقط با وجود شهر اضافه می‌شود.
  • app/sitemap.js هر پست را فقط در sitemap دامنهٔ canonical خودش می‌گذارد.

یعنی به‌محض اینکه API فیلد شهر بدهد، همهٔ این‌ها خودکار فعال می‌شوند.

وضعیت فعلی داده: GET /api/v1/blogs صفر رکورد برمی‌گرداند (totalRecords: 0). پس این تغییر روی داده‌ای اعمال می‌شود که هنوز تولید نشده — فرصت خوبی برای اضافه‌کردن فیلد قبل از پرشدن جدول.

فایل‌های مرتبط

فایل نقش
clinicpro/src/Blog/Entity/Blog.php Entity — فیلد جدید اینجا
clinicpro/src/Blog/Controller/BlogController.php endpointهای عمومی و ادمین
clinicpro/src/Blog/Repository/BlogRepository.php فیلتر لیست
clinicpro/docs/api/blog.md مستندات
clinicpro/assets/admin/pages/ (صفحهٔ بلاگ) انتخاب شهر در فرم ادمین

وضعیت فعلی

src/Blog/Entity/Blog.php — هیچ ارجاعی به شهر ندارد:

#[ORM\Entity(repositoryClass: BlogRepository::class)]
#[ORM\Table(name: 'blogs')]
#[ORM\Index(columns: ['status', 'created_at'], name: 'idx_blogs_status')]
class Blog
{
    #[ORM\Column(type: 'string', length: 255)]
    private string $title;

    #[ORM\Column(type: 'string', length: 255, unique: true)]
    private string $slug;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(name: 'author_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
    private User $author;

    #[ORM\Column(type: 'json')]
    private array $tags = [];

    #[ORM\Column(type: 'string', length: 20)]
    private string $status = self::STATUS_DRAFT;
    // ...
}

وظایف

۱. فیلد شهر روی Entity

رابطهٔ ManyToOne اختیاری به Entity شهر (همان Entity که Address/Clinic استفاده می‌کنند — از آن‌ها الگو بگیر):

#[ORM\ManyToOne(targetEntity: City::class)]
#[ORM\JoinColumn(name: 'city_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?City $city = null;
  • nullable: true الزامی است و معنای صریح دارد: پست بدون شهر = «پست سراسری» که روی دامنهٔ اصلی canonical می‌شود. این حالت باید برای همیشه پشتیبانی شود، نه یک وضعیت موقت.
  • onDelete: 'SET NULL' تا حذف شهر پست را نکشد.
  • ایندکس روی city_id اضافه شود (فیلتر لیست per-domain روی همین ستون است).

۲. Migration

ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction

رکوردهای موجود city_id = NULL می‌گیرند ⇒ همه «سراسری» می‌شوند. رفتار فعلی سایت را نمی‌شکند.

۳. شهر در پاسخ API

در سریال‌سازی پست (هم لیست هم جزئیات) شهر با همان شکلی که سایت عمومی از کلینیک انتظار دارد برگردد:

'city' => $this->city ? [
    'id'   => (string) $this->city->getId(),
    'name' => $this->city->getName(),
] : null,

nobat724_front/lib/domainHelpers.jsextractEntityCityId() هر سه شکل city_id مسطح، city: {id} و city: [{id}] را می‌پذیرد؛ پس هر کدام از این‌ها کار می‌کند — ولی { id, name } را انتخاب کن چون name برای برچسب بصری «مخصوص شهر: X» لازم است.

۴. فیلتر شهر در لیست عمومی

GET /api/v1/blogs پارامتر city_id بپذیرد با این معنا:

پست‌های آن شهر + پست‌های سراسری (city_id IS NULL)

این دقیقاً همان چیزی است که لیست بلاگ per-domain لازم دارد (app/blogs/page.js روی هر دامنهٔ شهری). بدون این پارامتر، رفتار فعلی (همهٔ پست‌ها) حفظ شود.

WHERE b.status = 'published' AND (b.city_id = :cityId OR b.city_id IS NULL)

۵. انتخاب شهر در پنل ادمین

در فرم ایجاد/ویرایش بلاگ، انتخابگر شهر با گزینهٔ خالی «سراسری». از SearchableSelect استفاده کن (قانون پروژه: هرگز <select> بومی).

۶. مستندات

clinicpro/docs/api/blog.md: فیلد city در پاسخ لیست و جزئیات، پارامتر city_id، و معنای null = سراسری.

نکات مهم

  • معنای null را در مستندات صریح بنویس — سایت عمومی بر پایهٔ همین تصمیم می‌گیرد پست را روی دامنهٔ اصلی canonical کند یا روی دامنهٔ شهر.
  • پستی که شهر دارد نباید روی دامنه‌های دیگر از لیست حذف شود؛ فقط canonical و sitemap آن به دامنهٔ شهر می‌رود. تصمیم «چه چیزی در کدام لیست دیده شود» با پارامتر city_id است و اختیاری.
  • منبع شهر در سایت عمومی data/city.json است (۳۵ رکورد با domain) و id آن با id شهر در دیتابیس یکی است (مثلاً یاسوج = ۱۲۳). اگر این تطابق برقرار نیست، قبل از هر کاری این را گزارش کن — کل نگاشت شهر→دامنه به آن وابسته است.