diff --git a/.claude/prompt/fix-invitation-accept.md b/.claude/prompt/fix-invitation-accept.md new file mode 100644 index 00000000..4176ef05 --- /dev/null +++ b/.claude/prompt/fix-invitation-accept.md @@ -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` از قبل وجود دارد diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 0e73cb92..3f4aada4 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -835,7 +835,13 @@ export default function ClinicDetailPage() { {inv.invited_specialty} )} {inv.doctor && ( - {inv.doctor.name} + )} diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index 5fe19b0b..9ab1c5ac 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -1,11 +1,12 @@ 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 { UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon, CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon, - ClockIcon, StarIcon, UserIcon, + ClockIcon, StarIcon, UserIcon, CheckIcon, XMarkIcon, } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; 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 ( +
+
+

+ + دعوتنامه‌های کلینیک +

+ {invitations.length} در انتظار +
+ {isLoading ? ( +
+ {[1, 2].map(i => ( +
+ ))} +
+ ) : ( +
+ {invitations.map((inv) => ( +
+ {inv.clinic.logo ? ( + + ) : ( +
+ {(inv.clinic.name ?? '?')[0]} +
+ )} +
+ {inv.clinic.name} + {inv.invited_specialty && {inv.invited_specialty}} +
+
+ + +
+
+ ))} +
+ )} +
+ ); +} + // ── Doctor Dashboard ────────────────────────────────────────────────────── interface DoctorDashboardData { @@ -684,6 +782,8 @@ function DoctorDashboard() { ))}
+ +
diff --git a/docs/api/clinic-invitation.md b/docs/api/clinic-invitation.md index 00f1b7c9..66ac2377 100644 --- a/docs/api/clinic-invitation.md +++ b/docs/api/clinic-invitation.md @@ -261,7 +261,9 @@ View invitation details by token (used on the doctor-facing landing page). ## 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` @@ -282,7 +284,7 @@ Doctor accepts the invitation. If a doctor profile exists for this mobile, they | Code | HTTP | Description | |------|------|-------------| | `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 | |------|------|-------------| | `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 | diff --git a/src/ClinicInvitation/Service/ClinicInvitationService.php b/src/ClinicInvitation/Service/ClinicInvitationService.php index 19d59f1f..c32a51dc 100644 --- a/src/ClinicInvitation/Service/ClinicInvitationService.php +++ b/src/ClinicInvitation/Service/ClinicInvitationService.php @@ -75,8 +75,25 @@ class ClinicInvitationService if (!$inv->isUsable()) { throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410); } + $inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED); $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(); }