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>
This commit is contained in:
hamed
2026-07-19 08:09:00 +03:30
co-authored by Claude Opus 4.8
parent 3363dfbf22
commit e78b0c4b4c
3 changed files with 325 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
<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>
@@ -0,0 +1,86 @@
<div dir="rtl" markdown="1">
# پاک‌سازی رکوردهای آلودهٔ پزشک و کلینیک
## پروژه
`clinicpro` (لایهٔ داده + اعتبارسنجی)
## زمینه
ممیزی SEO سایت عمومی رکوردهایی را در نتایج **عمومی** پیدا کرد که نام واقعی ندارند:
- پزشک با `name: "09390039833"` (شماره‌تلفن به‌جای نام) و `owner_status: "claimed"`
- پزشک با `name: "test"` و `owner_status: "claimed"`
- کلینیک `9c163d69-0051-4745-a423-c830135b1c01` با `title: "09398631203"`، `is_active: false`، بدون `caption`/`logo`/`services`
این رکوردها در HTML عمومی رندر می‌شدند و `<title>` صفحه‌ای مثل «09398631203 | یاسوج نوبت» می‌ساختند.
**وضعیت فعلی:** سایت عمومی موقتاً محافظت شده — `nobat724_front/lib/entityQuality.js` رکوردهایی با نام شبیه شماره‌تلفن یا `test` را `noindex` می‌کند و از sitemap بیرون می‌گذارد. ولی این فقط ماسک است: داده هنوز آلوده است، در API عمومی برمی‌گردد و در لیست‌ها به کاربر نمایش داده می‌شود.
سنجش روی دادهٔ واقعی: از ۵۰ رکورد اول لیست پزشکان، ۲ رکورد آلوده بودند.
## هدف
۱. رکوردهای آلودهٔ موجود پاک/غیرفعال شوند.
۲. جلوی تولید رکورد آلودهٔ جدید گرفته شود (اعتبارسنجی).
## وظایف
### ۱. گزارش دامنهٔ آلودگی (اول اندازه‌گیری، بعد حذف)
اسکریپت یا کوئری که بشمارد و **فهرست کند** (بدون تغییر داده):
- پزشکانی که `name` فقط رقم است یا با الگوی موبایل ایران می‌خواند (`^0?9\d{9}$` پس از حذف فاصله و خط تیره)
- پزشکان با نام در مجموعهٔ `test`, `تست`, `-`, `—` (بدون حساسیت به بزرگی/کوچکی)
- کلینیک‌ها با همان دو الگو روی `title`/`name`
- از هر گروه: تعداد کل، چندتا `owner_status = claimed`، چندتا نوبت/رابطهٔ واقعی دارند
الگوها را از `nobat724_front/lib/entityQuality.js` بردار تا معیار دو طرف یکی بماند:
```js
const PHONE_LIKE = /^0?9\d{9}$/;
const PLACEHOLDER_NAMES = new Set(["test", "تست", "-", "—"]);
```
**خروجی این مرحله را قبل از هر حذفی گزارش کن.** اگر رکوردی نوبت واقعی یا پرداخت دارد، حذف نیست — تصمیمش با تیم است.
### ۲. پاک‌سازی
بر اساس گزارش مرحلهٔ ۱، برای هر گروه تصمیم صریح بگیر و مستند کن:
| وضعیت رکورد | اقدام |
|---|---|
| بدون هیچ رابطهٔ واقعی (نوبت/پرداخت/کاربر) | حذف |
| دارای رابطهٔ واقعی ولی نام آلوده | نام اصلاح شود یا از انتشار عمومی خارج شود؛ حذف نشود |
| `owner_status: claimed` با نام آلوده | claim نامعتبر است — بازبینی دستی لازم دارد |
اسکریپت پاک‌سازی باید **dry-run پیش‌فرض** داشته باشد و فقط با فلگ صریح بنویسد.
### ۳. اعتبارسنجی برای جلوگیری از تکرار
در مسیر ایجاد/ویرایش پزشک و کلینیک (هم API عمومی، هم پنل ادمین، هم import):
- نام برابر الگوی شماره‌تلفن → خطای اعتبارسنجی فارسی
- نام در فهرست placeholderها → خطای اعتبارسنجی فارسی
- حداقل طول معنادار برای نام
پیام خطا فارسی باشد (قانون پروژه). به `clinicpro/src/Doctor/Controller/DoctorImportController.php` هم اعمال شود — import مسیر محتمل ورود دادهٔ آلوده است.
### ۴. بازبینی معیار `is_active` کلینیک
کلینیک `9c163d69` با `is_active: false` هنوز از API عمومی برمی‌گردد. تصمیم بگیر و مستند کن:
- `is_active: false` یعنی «موقتاً غیرفعال» → در API عمومی بماند ولی سایت `noindex` کند (رفتار فعلی سمت فرانت)
- یا یعنی «حذف‌شده» → از پاسخ‌های عمومی حذف شود
هر کدام را انتخاب کردی، در `docs/api/clinic.md` بنویس.
## نکات مهم
- **این تغییرات لایهٔ داده‌اند و برگشت‌ناپذیر.** قبل از حذف، بکاپ بگیر.
- بعد از پاک‌سازی، sitemap سایت عمومی خودکار بزرگ‌تر می‌شود (فیلتر `isThinDoctor` رکورد کمتری کنار می‌گذارد) — نیازی به تغییر فرانت نیست.
- اگر بعد از پاک‌سازی داده تمیز شد، فیلتر نام در `nobat724_front/lib/entityQuality.js` را **حذف نکن**؛ به‌عنوان لایهٔ دفاعی بماند.
- تست: `clinicpro/TEST_USERS.md` — کاربر تست `09390039833` است. مطمئن شو اسکریپت پاک‌سازی حساب‌های تست توسعه را با دادهٔ آلودهٔ production اشتباه نگیرد.
</div>
@@ -0,0 +1,108 @@
<div dir="rtl" markdown="1">
# افزودن شهر به لیست پزشکان + رفع سقف خاموش limit
## پروژه
`clinicpro` (backend)
**cross-repo:** خروجی این endpoint را `nobat724_front/app/sitemap.js` مصرف می‌کند.
پرامپت همتا (بعد از این اجرا شود): `nobat724_front/.claude/prompt/sitemap-simplify-with-city.md`
## زمینه
در ممیزی SEO سایت عمومی دو محدودیت این endpoint باعث دو باگ در sitemap شد:
۱. **پاسخ لیست پزشکان فیلد شهر ندارد.** سایت عمومی چند-دامنه‌ای است (۳۵ دامنهٔ شهری) و باید بداند هر پزشک به کدام دامنه تعلق دارد. چون شهر در پاسخ نیست، sitemap دامنهٔ اصلی مجبور است **۳۵ بار جداگانه** لیست را با `city_id` بگیرد و از کل کم کند تا بفهمد کدام پزشک شهر اختصاصی ندارد. تولید sitemap ریشه ~۱۳ ثانیه طول می‌کشد.
۲. **پارامتر `limit` بی‌صدا به ۵۰ سقف می‌خورد.** کلاینت `limit=500` می‌فرستد، پاسخ ۵۰ رکورد است و هیچ نشانه‌ای از سقف‌خوردن در پاسخ نیست. این باعث شد sitemap ماه‌ها روی ۵۰ پزشک بریده بماند (حلقهٔ صفحه‌بندی وقتی `items.length < limit` بود متوقف می‌شد). سمت فرانت با تکیه بر `meta.totalPages` رفع شد، ولی رفتار خاموشِ API همچنان تله است.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `clinicpro/src/Doctor/Entity/Doctor.php` | `toListArray()` — شکل پاسخ لیست |
| `clinicpro/src/Doctor/Repository/DoctorRepository.php` | سقف `limit` در خطوط ۴۵ و ۱۴۲ |
| `clinicpro/src/Doctor/Controller/DoctorController.php` | route `/api/v1/doctors` خط ۲۵۲ |
| `clinicpro/docs/api/doctor.md` | مستندات — الزاماً به‌روز شود |
## وضعیت فعلی
`src/Doctor/Entity/Doctor.php:540` — بدون شهر:
```php
public function toListArray(array $schedules = []): array
{
$sf = $this->computeScheduleFields($schedules);
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'gender' => $this->gender,
'degree' => $this->degree,
'img' => $this->images ?? [],
'specialties' => array_map(fn(Specialty $s) => [...], $this->specialties->toArray()),
'satisfaction' => $this->hasPublicRating() ? (string) $this->doctorRatePercentage : null,
'point' => $this->hasPublicRating() ? (string) $this->doctorRate : null,
'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'],
'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
'owner_status' => $this->ownerStatus,
];
}
```
`src/Doctor/Repository/DoctorRepository.php:45` و `:142` — سقف خاموش:
```php
$limit = min(50, max(1, (int) ($filters['limit'] ?? 10)));
```
> نکته: در پاسخ **جزئیات** پزشک (`toDetailArray`) شهر داخل `address[].city` هست، ولی `city` و `state` سطح‌بالا آرایهٔ خالی برمی‌گردند. سایت عمومی برای همین از `address[].city.id` استخراج می‌کند (`nobat724_front/lib/domainHelpers.js` → `extractEntityCityId`). این پرامپت آن رفتار را تغییر نمی‌دهد.
## وظایف
### ۱. افزودن شهر به `toListArray()`
شهرِ پزشک از آدرس‌هایش می‌آید. شهر **اولین آدرس** (یا آدرس اصلی، اگر مفهوم آدرس اصلی وجود دارد) به‌عنوان شهر پزشک برگردد — چون سایت عمومی هم برای canonical دقیقاً همین قاعده («یک شهر اصلی برای پزشک چند-شهری») را اعمال می‌کند.
```php
'city' => $primaryAddress?->getCity() ? [
'id' => (string) $primaryAddress->getCity()->getId(),
'name' => $primaryAddress->getCity()->getName(),
] : null,
'state' => $primaryAddress?->getProvince() ? [
'id' => (string) $primaryAddress->getProvince()->getId(),
'name' => $primaryAddress->getProvince()->getName(),
] : null,
```
- شکل `{ id, name }` باشد تا با `city` در پاسخ لیست کلینیک‌ها یکسان باشد (آنجا آرایه‌ای از همین شکل است).
- `id` رشته باشد — هم‌راستا با بقیهٔ فیلدهای این متد.
- پزشک بدون آدرس → `null` (نه آرایهٔ خالی، تا با «شهر ندارد» تفکیک‌پذیر بماند).
- **N+1 نساز:** آدرس/شهر/استان در همان کوئری `findWithFilters` با `JOIN`/`addSelect` بارگذاری شود، نه lazy per-doctor.
### ۲. شفاف‌کردن سقف `limit`
سقف ۵۰ حفظ شود (محافظت از دیتابیس)، ولی دیگر خاموش نباشد:
- مقدار مؤثر `limit` در `meta` برگردد (اگر الان برنمی‌گردد) تا کلاینت بفهمد درخواستش کوتاه شده.
- در `docs/api/doctor.md` صریح نوشته شود: «`limit` حداکثر ۵۰؛ مقادیر بزرگ‌تر بی‌صدا به ۵۰ کاهش می‌یابند».
اگر تصمیم گرفتی سقف را برای مصرف‌کنندهٔ sitemap بالاتر ببری، آن را به‌صورت یک حد جداگانه و مستند انجام بده — نه با حذف `min()`.
### ۳. به‌روزرسانی مستندات
`clinicpro/docs/api/doctor.md` برای `GET /api/v1/doctors`:
- فیلدهای جدید `city` و `state` با مثال واقعی JSON
- رفتار و سقف `limit`
## نکات مهم
- `toListArray()` را مصرف‌کنندگان دیگری هم دارند (پنل ادمین، اپ Tauri). **فیلد اضافه می‌کنیم، فیلد موجود را تغییر نام یا حذف نمی‌کنیم** — تغییر افزایشی و backward-compatible باشد.
- بعد از تغییر، پاسخ واقعی را با یک پزشک دارای آدرس تست کن:
`curl -s "https://clinic-pro.ir/api/v1/doctors?page=1&limit=5" | jq '.data.data[0] | {name, city, state}'`
- پزشک `beaca548-816f-4613-937d-360db01bd7c8` شهرش یاسوج (`city_id: 123`) است — نمونهٔ خوبی برای تأیید.
- Entity تغییر نمی‌کند (فقط متد سریال‌سازی) ⇒ migration لازم نیست.
</div>