feat: add social media fields to Clinic entity and update API documentation
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# افزودن فیلد شبکههای اجتماعی به Clinic
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend) — پیشنیاز پرامپت همتا در frontend: `nobat724_front/.claude/prompt/clinic-seo-schema-knowledge-panel.md`. آن پرامپت برای Knowledge Panel گوگل به `sameAs` در JSON-LD نیاز دارد که از همین فیلد جدید پر میشود.
|
||||
|
||||
این تغییر دقیقاً همان الگویی است که قبلاً برای `Doctor` entity پیادهسازی شده (`clinicpro/.claude/prompt/doctor-social-media-field.md` — قبلاً اجرا و merge شده). همان ساختار را برای `Clinic` تکرار کن.
|
||||
|
||||
## زمینه
|
||||
|
||||
بررسی `src/Clinic/Entity/Clinic.php` و `docs/api/clinic.md` نشان داد کلینیک هیچ فیلد شبکهاجتماعی ندارد. کلینیک از قبل `latitude`/`longitude`/`address`/`telephone`/`working_days`/`is247` مستقیم روی خودش دارد (برخلاف Doctor که اینها در یک entity جدا — `DoctorAddress` — هستند)، پس برای کلینیک نیازی به fetch جدا نیست؛ فقط `socialMedia` کم است.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
به `Clinic` entity یک فیلد JSON برای لینک شبکههای اجتماعی (اینستاگرام، تلگرام، آپارات، یوتیوب، لینکدین) اضافه شود، در پاسخ API برگردانده شود، و قابل ویرایش باشد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Clinic/Entity/Clinic.php` | فیلد `socialMedia` (JSON nullable) — دقیقاً کنار `imagesClinic` (خط ۷۱) |
|
||||
| `src/Clinic/Controller/ClinicController.php` | متد `update()` (خط ۲۰۵) باید فیلد را بپذیرد و validate کند؛ `toDetailArray()`/`toListArray()` در entity باید آن را برگردانند |
|
||||
| `migrations/` | migration جدید برای ستون `social_media` روی جدول `clinics` |
|
||||
| `docs/api/clinic.md` | مستندسازی فیلد جدید |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`src/Clinic/Entity/Clinic.php` الگوی دقیق برای فیلد JSON nullable از قبل دارد:
|
||||
|
||||
```php
|
||||
// خط ۷۱ — الگوی موجود
|
||||
private ?array $imagesClinic = null;
|
||||
|
||||
// خط ۱۴۴
|
||||
public function getImagesClinic(): ?array { return $this->imagesClinic; }
|
||||
|
||||
// خط ۱۷۶
|
||||
public function setImagesClinic(?array $v): self { $this->imagesClinic = $v; $this->touch(); return $this; }
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. افزودن فیلد `socialMedia`
|
||||
|
||||
```php
|
||||
// src/Clinic/Entity/Clinic.php — کنار imagesClinic
|
||||
#[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 (دقیقاً مشابه Doctor):
|
||||
|
||||
```json
|
||||
{
|
||||
"instagram": "https://instagram.com/clinic.example",
|
||||
"telegram": "https://t.me/clinic_example",
|
||||
"aparat": null,
|
||||
"youtube": null,
|
||||
"linkedin": null
|
||||
}
|
||||
```
|
||||
|
||||
### ۲. اضافهکردن به خروجی `toDetailArray()` و `toListArray()`
|
||||
|
||||
در `Clinic.php`، هرجا `images_clinic` در آرایهی خروجی است، `social_media` هم کنارش اضافه شود — هم در `toDetailArray()` (خط ۱۸۲) هم در `toListArray()` (خط ۲۲۴، اگر لیست هم باید نشانش بدهد؛ در غیر این صورت فقط در `toDetailArray()` کافی است چون JSON-LD فقط در صفحهی تکی کلینیک لازم است).
|
||||
|
||||
### ۳. Migration
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
### ۴. پذیرش در `ClinicController::update()`
|
||||
|
||||
در متد `update()` (خط ۲۰۵)، همان الگوی validation که در `DoctorController::hydrateDoctor()` برای `social_media` استفاده شد را تکرار کن (`filter_var($value, FILTER_VALIDATE_URL)` برای هر کلید، در غیر این صورت `null`):
|
||||
|
||||
```php
|
||||
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;
|
||||
}
|
||||
$clinic->setSocialMedia($socialMedia);
|
||||
}
|
||||
```
|
||||
|
||||
بررسی کن این منطق دقیقاً کجای متد `update()` باید قرار گیرد (کنار جایی که `imagesClinic`/فیلدهای دیگر از `$data` خوانده میشوند).
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- این فیلد nullable کامل است — کلینیکهایی بدون شبکهاجتماعی نباید خطا بگیرند.
|
||||
- اگر در پنل ادمین (`assets/admin/`) فرم ویرایش کلینیک وجود دارد، در صورت وقت یک بخش مشابه فرم شبکهاجتماعی Doctor (در `DoctorDetailPage.tsx`) برایش اضافه کن؛ این بخش اختیاری است و در صورت نبود وقت میتوان frontend عمومی (`nobat724_front`) را بدون این بخش هم اجرا کرد (فیلد در دیتابیس و API آماده میماند، فقط فعلاً از پنل قابل تنظیم نیست).
|
||||
- بعد از تغییر، `docs/api/clinic.md` را در همان session بهروزرسانی کن (نمونهی JSON پاسخ + جدول فیلدهای قابل ویرایش در POST/PATCH).
|
||||
@@ -73,6 +73,8 @@ interface OptUuid { id: number; uuid: string; name: string; }
|
||||
|
||||
// ── Edit form schema ───────────────────────────────────────────────────────
|
||||
|
||||
const urlOrEmpty = z.string().refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'آدرس URL معتبر نیست' });
|
||||
|
||||
const editSchema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
telephone: z.string().optional(),
|
||||
@@ -81,6 +83,11 @@ const editSchema = z.object({
|
||||
specialties: z.array(z.number()),
|
||||
insurance: z.array(z.number()),
|
||||
doctor_services: z.array(z.number()),
|
||||
sm_instagram: urlOrEmpty,
|
||||
sm_telegram: urlOrEmpty,
|
||||
sm_aparat: urlOrEmpty,
|
||||
sm_youtube: urlOrEmpty,
|
||||
sm_linkedin: urlOrEmpty,
|
||||
});
|
||||
type EditForm = z.infer<typeof editSchema>;
|
||||
|
||||
@@ -282,6 +289,11 @@ function EditModal({ clinic, onClose, onSaved }: {
|
||||
specialties: (clinic.specialties ?? []).map(s => Number(s.id)),
|
||||
insurance: (clinic.list_bime ?? []).map(s => Number(s.id)),
|
||||
doctor_services: (clinic.services ?? []).map(s => Number(s.id)),
|
||||
sm_instagram: clinic.social_media?.instagram ?? '',
|
||||
sm_telegram: clinic.social_media?.telegram ?? '',
|
||||
sm_aparat: clinic.social_media?.aparat ?? '',
|
||||
sm_youtube: clinic.social_media?.youtube ?? '',
|
||||
sm_linkedin: clinic.social_media?.linkedin ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -324,12 +336,19 @@ function EditModal({ clinic, onClose, onSaved }: {
|
||||
specialties: values.specialties,
|
||||
insurance: values.insurance,
|
||||
doctor_services: values.doctor_services,
|
||||
social_media: {
|
||||
instagram: values.sm_instagram || null,
|
||||
telegram: values.sm_telegram || null,
|
||||
aparat: values.sm_aparat || null,
|
||||
youtube: values.sm_youtube || null,
|
||||
linkedin: values.sm_linkedin || null,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => { toast.success('اطلاعات کلینیک ذخیره شد'); onSaved(); onClose(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'tags'>('basic');
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
@@ -343,7 +362,7 @@ function EditModal({ clinic, onClose, onSaved }: {
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div style={{ borderBottom: '1px solid var(--border)', padding: '0 16px', display: 'flex', gap: 4 }}>
|
||||
{([['basic', 'اطلاعات پایه'], ['tags', 'تخصص و بیمه']] as const).map(([id, label]) => (
|
||||
{([['basic', 'اطلاعات پایه'], ['tags', 'تخصص و بیمه'], ['social', 'شبکههای اجتماعی']] as const).map(([id, label]) => (
|
||||
<button key={id} type="button" onClick={() => setActiveTab(id)}
|
||||
style={{
|
||||
padding: '10px 14px', fontSize: 13, fontWeight: 600, border: 'none', background: 'none',
|
||||
@@ -381,6 +400,25 @@ function EditModal({ clinic, onClose, onSaved }: {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Social tab ── */}
|
||||
{activeTab === 'social' && (
|
||||
<>
|
||||
{([
|
||||
['sm_instagram', 'اینستاگرام', 'https://instagram.com/...'],
|
||||
['sm_telegram', 'تلگرام', 'https://t.me/...'],
|
||||
['sm_aparat', 'آپارات', 'https://aparat.com/...'],
|
||||
['sm_youtube', 'یوتیوب', 'https://youtube.com/...'],
|
||||
['sm_linkedin', 'لینکدین', 'https://linkedin.com/...'],
|
||||
] as const).map(([field, label, placeholder]) => (
|
||||
<div key={field}>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>{label}</label>
|
||||
<input className="input" dir="ltr" placeholder={placeholder} {...register(field)} />
|
||||
{errors[field] && <div className="err-text">{errors[field]?.message}</div>}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Tags tab ── */}
|
||||
{activeTab === 'tags' && (
|
||||
<>
|
||||
|
||||
@@ -60,6 +60,13 @@ export interface ClinicDetail {
|
||||
clinic_logo: string | null;
|
||||
caption: string | null;
|
||||
images_clinic: { url: string; fid?: number }[];
|
||||
social_media: {
|
||||
instagram: string | null;
|
||||
telegram: string | null;
|
||||
aparat: string | null;
|
||||
youtube: string | null;
|
||||
linkedin: string | null;
|
||||
} | null;
|
||||
specialties: { id: string; uuid: string; name: string }[];
|
||||
services: { id: string; uuid: string; name: string }[];
|
||||
list_bime: { id: string; uuid: string; name: string }[];
|
||||
|
||||
@@ -52,6 +52,7 @@ Create a new clinic.
|
||||
| `specialties` | integer[] | ❌ | Specialty IDs |
|
||||
| `doctor_services` | integer[] | ❌ | Service IDs |
|
||||
| `insurance` | integer[] | ❌ | Insurance IDs |
|
||||
| `social_media` | object | ❌ | Social media URLs — keys: `instagram`, `telegram`, `aparat`, `youtube`, `linkedin`. Values are validated as URLs; invalid/empty values are stored as `null`. |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
@@ -120,6 +121,13 @@ Get clinic detail.
|
||||
"logo": "/uploads/clinics/logo/...",
|
||||
"clinic_logo": "/uploads/clinics/logo/...",
|
||||
"images_clinic": [{ "url": "/uploads/clinics/gallery/..." }],
|
||||
"social_media": {
|
||||
"instagram": "https://instagram.com/clinic.example",
|
||||
"telegram": "https://t.me/clinic_example",
|
||||
"aparat": null,
|
||||
"youtube": null,
|
||||
"linkedin": null
|
||||
},
|
||||
"caption": "توضیحات کلینیک",
|
||||
"list_bime": [],
|
||||
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب", "parent": null }],
|
||||
|
||||
@@ -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 Version20260621094624 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 clinics 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 clinics DROP social_media');
|
||||
}
|
||||
}
|
||||
@@ -500,6 +500,18 @@ class ClinicController extends BaseController
|
||||
$clinic->setCityId((int) $data['city'][0]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
$clinic->setSocialMedia($socialMedia);
|
||||
}
|
||||
|
||||
// Images stored as JSON (from upload response); gallery is capped at 5.
|
||||
if (array_key_exists('image_clinic', $data) && is_array($data['image_clinic'])) {
|
||||
$clinic->setImagesClinic(array_slice(array_values($data['image_clinic']), 0, self::MAX_GALLERY_IMAGES));
|
||||
|
||||
@@ -70,6 +70,9 @@ class Clinic
|
||||
#[ORM\Column(name: 'images_clinic', type: 'json', nullable: true)]
|
||||
private ?array $imagesClinic = null;
|
||||
|
||||
#[ORM\Column(name: 'social_media', type: 'json', nullable: true)]
|
||||
private ?array $socialMedia = null;
|
||||
|
||||
#[ORM\Column(name: 'clinic_logo', type: 'string', length: 500, nullable: true)]
|
||||
private ?string $clinicLogo = null;
|
||||
|
||||
@@ -142,6 +145,7 @@ class Clinic
|
||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||
public function isActive(): bool { return $this->isActive; }
|
||||
public function getImagesClinic(): ?array { return $this->imagesClinic; }
|
||||
public function getSocialMedia(): ?array { return $this->socialMedia; }
|
||||
public function getClinicLogo(): ?string { return $this->clinicLogo; }
|
||||
public function getNotificationMobile(): ?string { return $this->notificationMobile; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
@@ -174,6 +178,7 @@ class Clinic
|
||||
public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; }
|
||||
public function setIsActive(bool $v): self { $this->isActive = $v; $this->touch(); return $this; }
|
||||
public function setImagesClinic(?array $v): self { $this->imagesClinic = $v; $this->touch(); return $this; }
|
||||
public function setSocialMedia(?array $v): self { $this->socialMedia = $v; $this->touch(); return $this; }
|
||||
public function setClinicLogo(?string $v): self { $this->clinicLogo = $v; $this->touch(); return $this; }
|
||||
public function setNotificationMobile(?string $v): self { $this->notificationMobile = $v; $this->touch(); return $this; }
|
||||
|
||||
@@ -190,6 +195,7 @@ class Clinic
|
||||
'phone' => $this->telephone,
|
||||
'logo' => $this->clinicLogo,
|
||||
'images_clinic' => $this->imagesClinic ?? [],
|
||||
'social_media' => $this->socialMedia,
|
||||
'clinic_logo' => $this->clinicLogo,
|
||||
'phone_number' => $this->telephone,
|
||||
'caption' => $this->info,
|
||||
|
||||
Reference in New Issue
Block a user