feat: implement doctor invitation acceptance logic to add doctors to clinic_doctors
This commit is contained in:
@@ -0,0 +1,106 @@
|
|||||||
|
# Fix: پذیرفتن دعوتنامه کلینیک، پزشک را به clinic_doctors اضافه نمیکند
|
||||||
|
|
||||||
|
## مشکل
|
||||||
|
|
||||||
|
وقتی پزشک دعوتنامه کلینیک را میپذیرد (از طریق پنل پزشک یا لینک SMS)، فقط وضعیت دعوتنامه به `accepted` تغییر میکند — اما پزشک به جدول `clinic_doctors` اضافه **نمیشود**.
|
||||||
|
|
||||||
|
## ریشه باگ
|
||||||
|
|
||||||
|
`src/ClinicInvitation/Service/ClinicInvitationService.php` — متد `accept()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function accept(ClinicDoctorInvitation $inv): void
|
||||||
|
{
|
||||||
|
if (!$inv->isUsable()) {
|
||||||
|
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
|
||||||
|
}
|
||||||
|
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
|
||||||
|
$inv->markUsed();
|
||||||
|
$this->em->flush(); // ← فقط status عوض میشود، هیچ رابطهای ثبت نمیشود
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
باید بعد از `accept`، پزشک به `$clinic->getDoctors()` اضافه شود. رابطه در `Clinic::$doctors` (ManyToMany → جدول `clinic_doctors`) تعریف شده است.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `src/ClinicInvitation/Service/ClinicInvitationService.php` | متد `accept()` — جای اصلی باگ |
|
||||||
|
| `src/ClinicInvitation/Entity/ClinicDoctorInvitation.php` | entity دعوتنامه — دارای `getClinic()` و `getDoctor()` |
|
||||||
|
| `src/Clinic/Entity/Clinic.php` | دارای `getDoctors(): Collection` و رابطه ManyToMany |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
- `ClinicDoctorInvitation::$doctor` وقتی پزشک در سیستم وجود دارد، set میشود (در `invite()`)
|
||||||
|
- `ClinicDoctorInvitation::$doctor` ممکن است `null` باشد (پزشک هنوز ثبتنام نکرده)
|
||||||
|
- پذیرش از دو مسیر اتفاق میافتد:
|
||||||
|
1. **لینک SMS** → `POST /api/v1/clinic-invitation/{token}/accept` (بدون احراز هویت)
|
||||||
|
2. **پنل پزشک** → `POST /api/v1/doctor/invitation/{invUuid}/respond` با `action=accept`
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. رفع باگ در `ClinicInvitationService::accept()`
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function accept(ClinicDoctorInvitation $inv): void
|
||||||
|
{
|
||||||
|
if (!$inv->isUsable()) {
|
||||||
|
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
|
||||||
|
$inv->markUsed();
|
||||||
|
|
||||||
|
// اضافه کردن پزشک به کلینیک
|
||||||
|
$doctor = $inv->getDoctor();
|
||||||
|
if ($doctor !== null) {
|
||||||
|
$clinic = $inv->getClinic();
|
||||||
|
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||||
|
$clinic->getDoctors()->add($doctor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. بررسی حالت لبهای: پزشک null است
|
||||||
|
|
||||||
|
اگر `$inv->getDoctor()` در زمان accept هنوز null باشد (پزشک بعداً ثبتنام کرده)، باید با موبایل در DoctorRepository جستجو کرد و doctor را set و اضافه کرد:
|
||||||
|
|
||||||
|
```php
|
||||||
|
if ($doctor === null) {
|
||||||
|
$doctor = $this->doctorRepo->findOneByMobile($inv->getMobile());
|
||||||
|
if ($doctor !== null) {
|
||||||
|
$inv->setDoctor($doctor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. تست
|
||||||
|
|
||||||
|
بعد از رفع:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev exec php -l src/ClinicInvitation/Service/ClinicInvitationService.php
|
||||||
|
ddev exec php bin/console cache:clear
|
||||||
|
ddev exec yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
تست دستی:
|
||||||
|
- یک دعوتنامه ارسال کن
|
||||||
|
- پزشک از پنل داشبورد دعوت را بپذیرد
|
||||||
|
- در صفحه کلینیک بررسی کن که پزشک در لیست پزشکان کلینیک ظاهر شده
|
||||||
|
|
||||||
|
### ۴. مستندات
|
||||||
|
|
||||||
|
فایل `docs/api/clinic-invitation.md` را بهروزرسانی کن:
|
||||||
|
- اضافه کن: هنگام accept، پزشک به clinic_doctors اضافه میشود
|
||||||
|
- edge case: اگر doctor هنگام دعوت ثبتنام نکرده بود، هنگام accept با موبایل match میشود
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **هر دو مسیر** (لینک SMS و پنل پزشک) از همان `$invitationService->accept()` استفاده میکنند — رفع باگ در یک جا کافی است
|
||||||
|
- `$clinic->getDoctors()->contains($doctor)` را چک کن تا duplicate نشود
|
||||||
|
- بعد از flush نیازی به migration نیست — جدول `clinic_doctors` از قبل وجود دارد
|
||||||
@@ -835,7 +835,13 @@ export default function ClinicDetailPage() {
|
|||||||
<span className="muted" style={{ fontSize: 11 }}>{inv.invited_specialty}</span>
|
<span className="muted" style={{ fontSize: 11 }}>{inv.invited_specialty}</span>
|
||||||
)}
|
)}
|
||||||
{inv.doctor && (
|
{inv.doctor && (
|
||||||
<span className="badge blue" style={{ fontSize: 11 }}><span className="bdot" />{inv.doctor.name}</span>
|
<button
|
||||||
|
className="badge blue"
|
||||||
|
style={{ fontSize: 11, cursor: 'pointer', border: 'none', background: 'none', padding: 0 }}
|
||||||
|
onClick={() => navigate(`/admin/doctors/${inv.doctor!.uuid}`)}
|
||||||
|
>
|
||||||
|
<span className="bdot" />{inv.doctor.name}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon,
|
UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon,
|
||||||
CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon,
|
CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon,
|
||||||
ClockIcon, StarIcon, UserIcon,
|
ClockIcon, StarIcon, UserIcon, CheckIcon, XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
|
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
|
||||||
@@ -626,6 +627,103 @@ function ClinicDashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Doctor Clinic Invitations Card ───────────────────────────────────────
|
||||||
|
|
||||||
|
interface ClinicInvitation {
|
||||||
|
uuid: string;
|
||||||
|
invited_specialty: string | null;
|
||||||
|
invited_at: number;
|
||||||
|
expires_at: number;
|
||||||
|
clinic: { uuid: string; name: string; logo: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function DoctorClinicInvitationsCard() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['doctor-my-invitations'],
|
||||||
|
queryFn: () => api.get<{ data: { data: ClinicInvitation[] } }>('/api/v1/doctor/invitations'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const respondMut = useMutation({
|
||||||
|
mutationFn: ({ uuid, action }: { uuid: string; action: 'accept' | 'reject' }) =>
|
||||||
|
api.post(`/api/v1/doctor/invitation/${uuid}/respond`, { action }),
|
||||||
|
onSuccess: (_res, { action }) => {
|
||||||
|
toast.success(action === 'accept' ? 'دعوتنامه پذیرفته شد' : 'دعوتنامه رد شد');
|
||||||
|
qc.invalidateQueries({ queryKey: ['doctor-my-invitations'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['dashboard-doctor'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const invitations: ClinicInvitation[] = (data?.data as any) ?? [];
|
||||||
|
|
||||||
|
if (!isLoading && invitations.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||||
|
<div className="card-title-row">
|
||||||
|
<h3 style={{ fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<BellAlertIcon style={{ width: 18, height: 18, color: 'var(--danger)' }} />
|
||||||
|
دعوتنامههای کلینیک
|
||||||
|
</h3>
|
||||||
|
<span className="badge red"><span className="bdot" />{invitations.length} در انتظار</span>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{[1, 2].map(i => (
|
||||||
|
<div key={i} className="skeleton" style={{ height: 60, borderRadius: 8 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{invitations.map((inv) => (
|
||||||
|
<div key={inv.uuid} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '12px 14px', borderRadius: 8,
|
||||||
|
background: 'var(--surface-2, var(--primary-soft))',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
{inv.clinic.logo ? (
|
||||||
|
<img src={inv.clinic.logo} alt="" style={{ width: 40, height: 40, borderRadius: 8, objectFit: 'cover', flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'var(--primary)', color: 'var(--on-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 16, flexShrink: 0 }}>
|
||||||
|
{(inv.clinic.name ?? '?')[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<b style={{ fontSize: 13.5, display: 'block' }}>{inv.clinic.name}</b>
|
||||||
|
{inv.invited_specialty && <span className="muted" style={{ fontSize: 12 }}>{inv.invited_specialty}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
className="btn primary sm"
|
||||||
|
style={{ padding: '5px 12px', fontSize: 12 }}
|
||||||
|
disabled={respondMut.isPending}
|
||||||
|
onClick={() => respondMut.mutate({ uuid: inv.uuid, action: 'accept' })}
|
||||||
|
>
|
||||||
|
<CheckIcon style={{ width: 14, height: 14 }} />
|
||||||
|
پذیرفتن
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn ghost sm"
|
||||||
|
style={{ padding: '5px 12px', fontSize: 12 }}
|
||||||
|
disabled={respondMut.isPending}
|
||||||
|
onClick={() => respondMut.mutate({ uuid: inv.uuid, action: 'reject' })}
|
||||||
|
>
|
||||||
|
<XMarkIcon style={{ width: 14, height: 14 }} />
|
||||||
|
رد کردن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Doctor Dashboard ──────────────────────────────────────────────────────
|
// ── Doctor Dashboard ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface DoctorDashboardData {
|
interface DoctorDashboardData {
|
||||||
@@ -684,6 +782,8 @@ function DoctorDashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DoctorClinicInvitationsCard />
|
||||||
|
|
||||||
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||||
<div className="card card-pad">
|
<div className="card card-pad">
|
||||||
<div className="card-title-row">
|
<div className="card-title-row">
|
||||||
|
|||||||
@@ -261,7 +261,9 @@ View invitation details by token (used on the doctor-facing landing page).
|
|||||||
|
|
||||||
## POST `/api/v1/clinic-invitation/{token}/accept`
|
## POST `/api/v1/clinic-invitation/{token}/accept`
|
||||||
|
|
||||||
Doctor accepts the invitation. If a doctor profile exists for this mobile, they are automatically linked to the clinic.
|
Doctor accepts the invitation via SMS link.
|
||||||
|
|
||||||
|
**Side-effect:** If a doctor profile exists for this mobile, they are added to `clinic_doctors`. If the invitation's doctor FK was null (doctor registered after invite), the match is resolved at accept time using the mobile number.
|
||||||
|
|
||||||
**Permission:** `PUBLIC`
|
**Permission:** `PUBLIC`
|
||||||
|
|
||||||
@@ -282,7 +284,7 @@ Doctor accepts the invitation. If a doctor profile exists for this mobile, they
|
|||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| `ERR_NOT_FOUND_001` | 404 | Token not found |
|
| `ERR_NOT_FOUND_001` | 404 | Token not found |
|
||||||
| `ERR_VALIDATION_001` | 422 | Token expired or already used |
|
| `ERR_NOT_FOUND_001` | 410 | Token expired or already used |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -309,4 +311,77 @@ Doctor rejects the invitation.
|
|||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| `ERR_NOT_FOUND_001` | 404 | Token not found |
|
| `ERR_NOT_FOUND_001` | 404 | Token not found |
|
||||||
| `ERR_VALIDATION_001` | 422 | Token expired or already used |
|
| `ERR_NOT_FOUND_001` | 410 | Token expired or already used |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET `/api/v1/doctor/invitations`
|
||||||
|
|
||||||
|
Returns all pending invitations for the authenticated doctor.
|
||||||
|
|
||||||
|
**Permission:** `ROLE_DOCTOR`
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"uuid": "...",
|
||||||
|
"mobile": "09xxxxxxxxx",
|
||||||
|
"invited_name": "دکتر علی",
|
||||||
|
"invited_specialty": "قلب",
|
||||||
|
"status": "pending",
|
||||||
|
"token_used": false,
|
||||||
|
"invited_at": 1718000000,
|
||||||
|
"expires_at": 1718259200,
|
||||||
|
"responded_at": null,
|
||||||
|
"doctor": { "uuid": "...", "name": "علی احمدی" },
|
||||||
|
"clinic": { "uuid": "...", "name": "کلینیک نور", "logo": null }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
| Code | HTTP | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ERR_NOT_FOUND_001` | 404 | Doctor profile not found for user |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST `/api/v1/doctor/invitation/{invUuid}/respond`
|
||||||
|
|
||||||
|
Doctor accepts or rejects an invitation from their panel (no SMS token needed).
|
||||||
|
|
||||||
|
**Permission:** `ROLE_DOCTOR`
|
||||||
|
|
||||||
|
**Side-effect on accept:** Doctor is added to `clinic_doctors`. If doctor FK was null at invite time, it is resolved via mobile number at respond time.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `invUuid` | string | UUID of the invitation |
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
```json
|
||||||
|
{ "action": "accept" }
|
||||||
|
```
|
||||||
|
| Field | Type | Values |
|
||||||
|
|-------|------|--------|
|
||||||
|
| `action` | string | `accept` \| `reject` |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "message": "دعوتنامه پذیرفته شد" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
| Code | HTTP | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ERR_NOT_FOUND_001` | 404 | Invitation not found or not owned by doctor |
|
||||||
|
| `ERR_NOT_FOUND_001` | 410 | Invitation expired or already used |
|
||||||
|
| `ERR_VALIDATION_001` | 422 | action is not accept or reject |
|
||||||
|
|||||||
@@ -75,8 +75,25 @@ class ClinicInvitationService
|
|||||||
if (!$inv->isUsable()) {
|
if (!$inv->isUsable()) {
|
||||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
|
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
|
||||||
}
|
}
|
||||||
|
|
||||||
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
|
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
|
||||||
$inv->markUsed();
|
$inv->markUsed();
|
||||||
|
|
||||||
|
$doctor = $inv->getDoctor();
|
||||||
|
if ($doctor === null) {
|
||||||
|
$doctor = $this->doctorRepo->findOneByMobile($inv->getMobile());
|
||||||
|
if ($doctor !== null) {
|
||||||
|
$inv->setDoctor($doctor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($doctor !== null) {
|
||||||
|
$clinic = $inv->getClinic();
|
||||||
|
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||||
|
$clinic->getDoctors()->add($doctor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user