feat: add social media links field to Doctor entity and update API documentation
This commit is contained in:
@@ -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`).
|
||||
@@ -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
@@ -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:00–13: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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user