From 3f1b2de97143608e299570578fd6b5e24a9d5b91 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 21 Jun 2026 12:35:22 +0330 Subject: [PATCH] feat: add social media links field to Doctor entity and update API documentation --- .claude/prompt/doctor-social-media-field.md | 105 ++++++++++++++++++++ assets/admin/pages/DoctorDetailPage.tsx | 45 +++++++++ docs/api/doctor.md | 25 ++++- migrations/Version20260621084558.php | 31 ++++++ src/Doctor/Controller/DoctorController.php | 25 +++++ src/Doctor/Entity/Doctor.php | 6 ++ 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 .claude/prompt/doctor-social-media-field.md create mode 100644 migrations/Version20260621084558.php diff --git a/.claude/prompt/doctor-social-media-field.md b/.claude/prompt/doctor-social-media-field.md new file mode 100644 index 00000000..c0e23a28 --- /dev/null +++ b/.claude/prompt/doctor-social-media-field.md @@ -0,0 +1,105 @@ +# افزودن فیلد شبکه‌های اجتماعی به Doctor + +## پروژه + +`clinicpro` (Backend) — این تغییر پیش‌نیاز پرامپت همتا در frontend است: `nobat724_front/.claude/prompt/doctor-seo-schema-knowledge-panel.md`. آن پرامپت برای Knowledge Panel گوگل به `sameAs` در JSON-LD نیاز دارد که باید از همین فیلد جدید پر شود. + +## زمینه + +برای فعال‌سازی Knowledge Panel گوگل برای صفحه‌ی هر پزشک (هدف نهایی در `nobat724_front`)، schema.org نیاز به آرایه‌ی `sameAs` دارد که لینک پروفایل‌های شبکه‌های اجتماعی پزشک (اینستاگرام، تلگرام، آپارات، یوتیوب، لینکدین) را به گوگل معرفی می‌کند. بررسی `src/Doctor/Entity/Doctor.php` و `docs/api/doctor.md` نشان داد این داده هیچ‌جا در API موجود نیست — نه در پاسخ `GET /api/v1/doctor/{uuid}` و نه در فرم ادمین (`assets/admin/pages/DoctorFormPage.tsx`). + +نکته‌ی مهم: برخلاف انتظار اولیه، `DoctorAddress` (`src/Doctor/Entity/DoctorAddress.php`) از قبل `latitude`/`longitude` دارد و در `toArray()` به‌صورت `map: { latitude, longitude }` برمی‌گرداند — یعنی GeoCoordinates برای LocalBusiness schema نیازی به تغییر backend ندارد. فقط شبکه‌های اجتماعی پزشک باقی می‌ماند. + +## مشکل / هدف + +به `Doctor` entity یک فیلد JSON برای ذخیره‌ی لینک شبکه‌های اجتماعی (اینستاگرام، تلگرام، آپارات، یوتیوب، لینکدین، توییتر/X) اضافه شود، در پاسخ API برگردانده شود، و در فرم ادمین برای ویرایش در دسترس باشد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Doctor/Entity/Doctor.php` | باید فیلد `socialMedia` (JSON nullable) اضافه شود | +| `src/Doctor/Controller/DoctorController.php` | پاسخ `GET /api/v1/doctor/{uuid}` باید `social_media` را برگرداند؛ متد update باید آن را بپذیرد | +| `migrations/` | migration جدید برای ستون `social_media` روی جدول `doctors` | +| `assets/admin/pages/DoctorFormPage.tsx` | فیلدهای ورودی لینک‌های اجتماعی به فرم اضافه شود | +| `assets/admin/types/index.ts` | type پزشک باید `socialMedia` را شامل شود | +| `docs/api/doctor.md` | باید فیلد جدید مستند شود | + +## وضعیت فعلی + +`src/Doctor/Entity/Doctor.php` فیلدهای پایه دارد (نام، تخصص، آدرس‌ها) اما هیچ فیلد JSON برای داده‌ی نیمه‌ساخت‌یافته ندارد. الگوی مشابه را می‌توان از `DoctorAddress::toArray()` که `map` را به‌صورت nested object برمی‌گرداند الگو گرفت: + +```php +// src/Doctor/Entity/DoctorAddress.php — الگوی موجود برای nested object در toArray() +'map' => [ + 'latitude' => $this->latitude !== null ? (string) $this->latitude : null, + 'longitude' => $this->longitude !== null ? (string) $this->longitude : null, +], +``` + +## وظایف + +### ۱. افزودن فیلد `socialMedia` به `Doctor` entity + +```php +// src/Doctor/Entity/Doctor.php +#[ORM\Column(name: 'social_media', type: 'json', nullable: true)] +private ?array $socialMedia = null; + +public function getSocialMedia(): ?array { return $this->socialMedia; } +public function setSocialMedia(?array $v): self { $this->socialMedia = $v; $this->touch(); return $this; } +``` + +ساختار JSON ذخیره‌شده: + +```json +{ + "instagram": "https://instagram.com/dr.example", + "telegram": "https://t.me/dr_example", + "aparat": "https://aparat.com/dr.example", + "youtube": "https://youtube.com/@dr.example", + "linkedin": "https://linkedin.com/in/dr-example" +} +``` + +هر کلید nullable است — پزشک ممکن است فقط بعضی شبکه‌ها را داشته باشد. کلیدهای خالی/null باید در پاسخ API هم `null` بمانند (نه حذف شوند) تا frontend به‌سادگی چک کند. + +### ۲. Migration + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +### ۳. بازگرداندن فیلد در پاسخ API + +در `src/Doctor/Controller/DoctorController.php`، هرجا `toArray()`-مانند برای پزشک ساخته می‌شود (GET تکی، GET لیست)، کلید `social_media` اضافه شود: + +```php +'social_media' => $doctor->getSocialMedia(), +``` + +و در متد update، فیلد جدید از request body خوانده و validate شود (فقط باید URL معتبر یا null باشد برای هر کلید — از `InputValidator` یا یک Assert ساده استفاده کن، الگوی موجود validation در همان کنترلر را دنبال کن). + +### ۴. فرم ادمین + +در `assets/admin/pages/DoctorFormPage.tsx`، یک بخش جدید «شبکه‌های اجتماعی» با ۵ فیلد متنی (اینستاگرام، تلگرام، آپارات، یوتیوب، لینکدین) اضافه شود. الگوی `Field()` موجود در همین فایل (خط ۵۲) را برای ساخت input استفاده کن. مقدار اولیه از `doctor.socialMedia` پر شود، در submit به همان شکل JSON ارسال شود. + +در `assets/admin/types/index.ts`، interface پزشک باید شامل شود: + +```ts +socialMedia?: { + instagram?: string | null; + telegram?: string | null; + aparat?: string | null; + youtube?: string | null; + linkedin?: string | null; +} | null; +``` + +## نکات مهم + +- این فیلد باید **nullable** کامل باشد — پزشکانی که شبکه اجتماعی ندارند نباید خطا بگیرند. +- مقادیر باید URL کامل (با `https://`) ذخیره شوند، نه فقط username — frontend مستقیماً این مقدار را در `sameAs` آرایه‌ی JSON-LD قرار می‌دهد بدون پردازش اضافی. +- بعد از این تغییر، `docs/api/doctor.md` را طبق قانون پروژه (به‌روزرسانی مستندات هم‌زمان با تغییر API) ویرایش کن — هم در نمونه‌ی JSON پاسخ `GET /api/v1/doctor/{uuid}` و هم در بخش فیلدهای قابل ویرایش. +- migration را قبل از merge باید روی دیتابیس dev واقعی تست کنی (`ddev exec php bin/console doctrine:migrations:migrate --no-interaction`). diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 39cf0b2f..9781899e 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -47,6 +47,10 @@ interface DoctorDetail { img: { url: string; fid: number }[]; expertise: { id: string; uuid: string; name: string }[]; satisfaction: string; point: string; + social_media: { + instagram: string | null; telegram: string | null; aparat: string | null; + youtube: string | null; linkedin: string | null; + } | null; address: AddressData[]; state: { id: string; name: string }[]; city: { id: string; name: string }[]; @@ -2116,6 +2120,8 @@ function EditSpecialtyPicker({ selected, onChange, specialties }: { // ── Edit schema ──────────────────────────────────────────────────────────── +const urlOrEmpty = z.string().url('آدرس نامعتبر است').optional().or(z.literal('')); + const editSchema = z.object({ name: z.string().min(2, 'نام حداقل ۲ کاراکتر'), gender: z.enum(['man', 'woman']).optional().or(z.literal('')), @@ -2125,6 +2131,11 @@ const editSchema = z.object({ info: z.string().max(2000).optional().or(z.literal('')), specialties: z.array(z.number()).optional(), services: z.array(z.number()).optional(), + social_instagram: urlOrEmpty, + social_telegram: urlOrEmpty, + social_aparat: urlOrEmpty, + social_youtube: urlOrEmpty, + social_linkedin: urlOrEmpty, }); type EditForm = z.infer; @@ -2310,6 +2321,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil info: doctor.detail ?? '', specialties: doctor.specialties.map(s => Number(s.id)), services: doctor.expertise.map(s => Number(s.id)), + social_instagram: doctor.social_media?.instagram ?? '', + social_telegram: doctor.social_media?.telegram ?? '', + social_aparat: doctor.social_media?.aparat ?? '', + social_youtube: doctor.social_media?.youtube ?? '', + social_linkedin: doctor.social_media?.linkedin ?? '', }); setEditGender((doctor.gender as any) ?? ''); } @@ -2337,6 +2353,13 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil info: body.info || undefined, specialties: body.specialties ?? [], doctor_services: body.services ?? [], + social_media: { + instagram: body.social_instagram || null, + telegram: body.social_telegram || null, + aparat: body.social_aparat || null, + youtube: body.social_youtube || null, + linkedin: body.social_linkedin || null, + }, }), onSuccess: () => { toast.success('اطلاعات پزشک بروزرسانی شد'); @@ -2785,6 +2808,28 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil + {/* ── Section: شبکه‌های اجتماعی ── */} +
+ } title="شبکه‌های اجتماعی" /> +
+ + + + + + + + + + + + + + + +
+
+ {/* ── Section: تخصص‌ها ── */}
addSql('ALTER TABLE doctors ADD social_media JSON DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE doctors DROP social_media'); + } +} diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index f5cd4222..e9c8960d 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -278,6 +278,18 @@ class DoctorController extends BaseController items: new OA\Items(type: 'integer'), nullable: true, ), + new OA\Property( + property: 'social_media', + type: 'object', + nullable: true, + properties: [ + new OA\Property(property: 'instagram', type: 'string', nullable: true), + new OA\Property(property: 'telegram', type: 'string', nullable: true), + new OA\Property(property: 'aparat', type: 'string', nullable: true), + new OA\Property(property: 'youtube', type: 'string', nullable: true), + new OA\Property(property: 'linkedin', type: 'string', nullable: true), + ], + ), ] ) ), @@ -725,6 +737,19 @@ class DoctorController extends BaseController $doctor->setImages($data['images']); } + // Social media links (each key nullable, must be a valid URL when present) + if (array_key_exists('social_media', $data) && is_array($data['social_media'])) { + $allowedKeys = ['instagram', 'telegram', 'aparat', 'youtube', 'linkedin']; + $socialMedia = []; + foreach ($allowedKeys as $key) { + $value = $data['social_media'][$key] ?? null; + $socialMedia[$key] = (is_string($value) && filter_var($value, FILTER_VALIDATE_URL)) + ? $value + : null; + } + $doctor->setSocialMedia($socialMedia); + } + // Specialties if (array_key_exists('specialties', $data) && is_array($data['specialties'])) { $doctor->getSpecialties()->clear(); diff --git a/src/Doctor/Entity/Doctor.php b/src/Doctor/Entity/Doctor.php index 152f54d7..163dd718 100644 --- a/src/Doctor/Entity/Doctor.php +++ b/src/Doctor/Entity/Doctor.php @@ -58,6 +58,9 @@ class Doctor #[ORM\Column(type: 'json', nullable: true)] private ?array $images = null; + #[ORM\Column(name: 'social_media', type: 'json', nullable: true)] + private ?array $socialMedia = null; + #[ORM\Column(name: 'doctor_rate', type: 'float')] private float $doctorRate = 3.5; @@ -139,6 +142,7 @@ class Doctor public function getDegree(): ?string { return $this->degree; } public function getInfo(): ?string { return $this->info; } public function getImages(): ?array { return $this->images; } + public function getSocialMedia(): ?array { return $this->socialMedia; } public function getDoctorRate(): float { return $this->doctorRate; } public function getDoctorRatePercentage(): float { return $this->doctorRatePercentage; } public function isActiveDoctorAppointment(): bool { return $this->activeDoctorAppointment; } @@ -160,6 +164,7 @@ class Doctor public function setDegree(?string $v): self { $this->degree = $v; $this->touch(); return $this; } public function setInfo(?string $v): self { $this->info = $v; $this->touch(); return $this; } public function setImages(?array $v): self { $this->images = $v; $this->touch(); return $this; } + public function setSocialMedia(?array $v): self { $this->socialMedia = $v; $this->touch(); return $this; } public function setDoctorRate(float $v): self { $this->doctorRate = $v; $this->touch(); return $this; } public function setDoctorRatePercentage(float $v): self { $this->doctorRatePercentage = $v; $this->touch(); return $this; } public function setActiveDoctorAppointment(bool $v): self { $this->activeDoctorAppointment = $v; $this->touch(); return $this; } @@ -288,6 +293,7 @@ class Doctor ], $this->specialties->toArray()), 'active' => $this->activeDoctorAppointment && $sf['has_schedule'], 'img' => $this->images ?? [], + 'social_media' => $this->socialMedia, 'expertise' => array_map(fn(DoctorService $ds) => [ 'uuid' => $ds->getUuid(), 'id' => (string) $ds->getId(), 'name' => $ds->getName(), ], $this->services->toArray()),