feat: add social media links field to Doctor entity and update API documentation

This commit is contained in:
hamed
2026-06-21 12:35:22 +03:30
parent 943784b64a
commit 3f1b2de971
6 changed files with 236 additions and 1 deletions
+105
View File
@@ -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`).
+45
View File
@@ -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<typeof editSchema>;
@@ -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
</div>
</div>
{/* ── Section: شبکه‌های اجتماعی ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader icon={<DocumentTextIcon style={{ width: 16, height: 16 }} />} title="شبکه‌های اجتماعی" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<EditField label="اینستاگرام">
<input type="text" dir="ltr" className="cp-input" placeholder="https://instagram.com/..." {...register('social_instagram')} />
</EditField>
<EditField label="تلگرام">
<input type="text" dir="ltr" className="cp-input" placeholder="https://t.me/..." {...register('social_telegram')} />
</EditField>
<EditField label="آپارات">
<input type="text" dir="ltr" className="cp-input" placeholder="https://aparat.com/..." {...register('social_aparat')} />
</EditField>
<EditField label="یوتیوب">
<input type="text" dir="ltr" className="cp-input" placeholder="https://youtube.com/..." {...register('social_youtube')} />
</EditField>
<EditField label="لینکدین">
<input type="text" dir="ltr" className="cp-input" placeholder="https://linkedin.com/in/..." {...register('social_linkedin')} />
</EditField>
</div>
</div>
{/* ── Section: تخصص‌ها ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader
+24 -1
View File
@@ -86,6 +86,13 @@ Get doctor detail with clinics.
"degree": "specialist",
"detail": "...",
"img": [],
"social_media": {
"instagram": "https://instagram.com/dr.example",
"telegram": "https://t.me/dr_example",
"aparat": null,
"youtube": null,
"linkedin": null
},
"satisfaction": "60",
"point": "3.5",
"free_turn": "دوشنبه 09:0013:00",
@@ -216,7 +223,23 @@ Update doctor profile.
| `uuid` | string (UUID) | Doctor UUID |
### Request Body (`application/json`)
Same fields as POST all optional.
Same fields as POST (all optional), plus:
| Field | Type | Description |
|-------|------|-------------|
| `social_media` | object | Keys: `instagram`, `telegram`, `aparat`, `youtube`, `linkedin`. Each value must be a full valid URL or `null`. Any value that fails `FILTER_VALIDATE_URL` is silently stored as `null`. |
```json
{
"social_media": {
"instagram": "https://instagram.com/dr.example",
"telegram": "https://t.me/dr_example",
"aparat": null,
"youtube": null,
"linkedin": null
}
}
```
### Response `200`
Updated doctor object (same structure as GET single).
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260621084558 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
@@ -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();
+6
View File
@@ -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()),