diff --git a/.claude/prompt/blog-city-field.md b/.claude/prompt/blog-city-field.md
new file mode 100644
index 00000000..ac71d7bc
--- /dev/null
+++ b/.claude/prompt/blog-city-field.md
@@ -0,0 +1,131 @@
+
+
+# افزودن شهر به بلاگ (پیشنیاز بلاگ شهر-محور)
+
+## پروژه
+
+`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` استفاده کن (قانون پروژه: هرگز `
diff --git a/.claude/prompt/cleanup-polluted-doctor-clinic-records.md b/.claude/prompt/cleanup-polluted-doctor-clinic-records.md
new file mode 100644
index 00000000..a352b1c0
--- /dev/null
+++ b/.claude/prompt/cleanup-polluted-doctor-clinic-records.md
@@ -0,0 +1,86 @@
+
+
+# پاکسازی رکوردهای آلودهٔ پزشک و کلینیک
+
+## پروژه
+
+`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 عمومی رندر میشدند و `` صفحهای مثل «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 اشتباه نگیرد.
+
+
diff --git a/.claude/prompt/doctors-list-city-and-limit.md b/.claude/prompt/doctors-list-city-and-limit.md
new file mode 100644
index 00000000..ee68d245
--- /dev/null
+++ b/.claude/prompt/doctors-list-city-and-limit.md
@@ -0,0 +1,108 @@
+
+
+# افزودن شهر به لیست پزشکان + رفع سقف خاموش 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 لازم نیست.
+
+