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>
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.js → extractEntityCityId() هر سه شکل 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شهر در دیتابیس یکی است (مثلاً یاسوج = ۱۲۳). اگر این تطابق برقرار نیست، قبل از هر کاری این را گزارش کن — کل نگاشت شهر→دامنه به آن وابسته است.