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>
132 lines
6.9 KiB
Markdown
132 lines
6.9 KiB
Markdown
<div dir="rtl" markdown="1">
|
|
|
|
# افزودن شهر به بلاگ (پیشنیاز بلاگ شهر-محور)
|
|
|
|
## پروژه
|
|
|
|
`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` — هیچ ارجاعی به شهر ندارد:
|
|
|
|
```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` استفاده میکنند — از آنها الگو بگیر):
|
|
|
|
```php
|
|
#[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
|
|
|
|
```bash
|
|
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
|
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
|
```
|
|
|
|
رکوردهای موجود `city_id = NULL` میگیرند ⇒ همه «سراسری» میشوند. رفتار فعلی سایت را نمیشکند.
|
|
|
|
### ۳. شهر در پاسخ API
|
|
|
|
در سریالسازی پست (هم لیست هم جزئیات) شهر با **همان شکلی که سایت عمومی از کلینیک انتظار دارد** برگردد:
|
|
|
|
```php
|
|
'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` روی هر دامنهٔ شهری). بدون این پارامتر، رفتار فعلی (همهٔ پستها) حفظ شود.
|
|
|
|
```sql
|
|
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` شهر در دیتابیس یکی است (مثلاً یاسوج = ۱۲۳). اگر این تطابق برقرار نیست، **قبل از هر کاری این را گزارش کن** — کل نگاشت شهر→دامنه به آن وابسته است.
|
|
|
|
</div>
|