From af0ae519878c81615b5ef33db41ef0e0e13f81b2 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 10 Jun 2026 22:13:39 +0330 Subject: [PATCH] feat: add clinic doctor invitation feature - Implemented ClinicInvitationController to handle doctor invitations. - Created ClinicDoctorInvitation entity and repository for managing invitations. - Added ClinicInvitationService for business logic related to invitations. - Introduced endpoints for inviting, listing, resending, changing status, and deleting invitations. - Updated security configuration to allow public access to invitation endpoints. - Added migration for clinic_doctor_invitations table. - Enhanced DoctorRepository with a method to find doctors by mobile number. - Updated ClinicDetailPage to include invitation management UI. --- assets/admin/pages/ClinicDetailPage.tsx | 270 +++++++++++++++--- config/packages/security.yaml | 3 +- config/services.yaml | 4 + migrations/Version20260610183655.php | 37 +++ .../Controller/ClinicInvitationController.php | 171 +++++++++++ .../Entity/ClinicDoctorInvitation.php | 140 +++++++++ .../ClinicDoctorInvitationRepository.php | 41 +++ .../Service/ClinicInvitationService.php | 104 +++++++ src/Doctor/Repository/DoctorRepository.php | 11 + 9 files changed, 747 insertions(+), 34 deletions(-) create mode 100644 migrations/Version20260610183655.php create mode 100644 src/ClinicInvitation/Controller/ClinicInvitationController.php create mode 100644 src/ClinicInvitation/Entity/ClinicDoctorInvitation.php create mode 100644 src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php create mode 100644 src/ClinicInvitation/Service/ClinicInvitationService.php diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 4ccb1d89..846f7caf 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -9,13 +9,14 @@ import { ArrowRightIcon, PencilIcon, TrashIcon, BuildingOffice2Icon, PhoneIcon, MapPinIcon, XMarkIcon, PlusIcon, CameraIcon, ChevronDownIcon, + EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, } from '@heroicons/react/24/outline'; import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { toast } from 'sonner'; import { api } from '../lib/api'; -import type { ApiResponse } from '../lib/api'; +import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { ClinicDetail } from '../types'; import { formatNumber } from '../lib/utils'; import ConfirmDialog from '../components/ui/ConfirmDialog'; @@ -33,6 +34,19 @@ const IRAN_CENTER: [number, number] = [32.4279, 53.6880]; // ── Types ────────────────────────────────────────────────────────────────── +interface ClinicInvitation { + uuid: string; + mobile: string; + invited_name: string | null; + invited_specialty: string | null; + status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed'; + token_used: boolean; + invited_at: number; + expires_at: number; + responded_at: number | null; + doctor: { uuid: string; name: string } | null; +} + interface ClinicDoctorItem { id: string; uuid: string; name: string; gender: string | null; degree: string | null; @@ -478,6 +492,75 @@ function EditModal({ clinic, onClose, onSaved }: { ); } +// ── Invitation status badge ──────────────────────────────────────────────── + +const INV_STATUS_MAP: Record = { + pending: { label: 'در انتظار', cls: 'amber' }, + accepted: { label: 'پذیرفته‌شده', cls: 'green' }, + rejected: { label: 'رد شده', cls: 'gray' }, + suspended: { label: 'تعلیق', cls: 'violet' }, + removed: { label: 'حذف‌شده', cls: 'gray' }, +}; + +// ── Invite modal ─────────────────────────────────────────────────────────── + +const inviteSchema = z.object({ + mobile: z.string().regex(/^09\d{9}$/, 'شماره موبایل ۱۱ رقمی با 09 شروع می‌شود'), + name: z.string().optional(), + specialty: z.string().optional(), +}); +type InviteForm = z.infer; + +function InviteModal({ clinicUuid, onClose, onInvited }: { + clinicUuid: string; onClose: () => void; onInvited: () => void; +}) { + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(inviteSchema), + }); + + const inviteMut = useMutation({ + mutationFn: (d: InviteForm) => + api.post>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d), + onSuccess: () => { toast.success('دعوتنامه ارسال شد'); onInvited(); onClose(); }, + onError: (e: Error) => toast.error(e.message), + }); + + return ( +
+
e.stopPropagation()}> +
+ دعوت پزشک به کلینیک + +
+
inviteMut.mutate(d))}> +
+
+ + + {errors.mobile &&
{errors.mobile.message}
} +
+
+ + +
+
+ + +
+

پیامک دعوتنامه با لینک ۷۲ ساعته ارسال می‌شود

+
+
+ + +
+
+
+
+ ); +} + // ── Main Page ────────────────────────────────────────────────────────────── export default function ClinicDetailPage() { @@ -486,6 +569,8 @@ export default function ClinicDetailPage() { const qc = useQueryClient(); const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); + const [inviteOpen, setInviteOpen] = useState(false); + const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors'); const logoInputRef = useRef(null); const galleryInputRef = useRef(null); const [logoUploading, setLogoUploading] = useState(false); @@ -503,6 +588,12 @@ export default function ClinicDetailPage() { enabled: !!uuid, }); + const invitationsQ = useQuery({ + queryKey: ['clinic-invitations', uuid], + queryFn: () => api.get>(`/api/v1/admin/clinic/${uuid}/invitations?limit=50`), + enabled: !!uuid, + }); + const clinic: ClinicDetail | undefined = useMemo(() => { const raw = data?.data; return (raw as any)?.data ?? raw; @@ -513,6 +604,8 @@ export default function ClinicDetailPage() { return (raw as any)?.data ?? raw ?? []; }, [doctorsQ.data]); + const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? []; + const toggleMut = useMutation({ mutationFn: () => api.patch>(`/api/v1/admin/clinic/${uuid}/status`, {}), onSuccess: () => { @@ -529,6 +622,25 @@ export default function ClinicDetailPage() { onError: (e: Error) => toast.error(e.message), }); + const resendInvMut = useMutation({ + mutationFn: (invUuid: string) => api.post>(`/api/v1/admin/clinic/invitation/${invUuid}/resend`, {}), + onSuccess: () => { toast.success('پیامک مجدداً ارسال شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); }, + onError: (e: Error) => toast.error(e.message), + }); + + const changeInvStatusMut = useMutation({ + mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) => + api.patch>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }), + onSuccess: () => { toast.success('وضعیت دعوتنامه تغییر کرد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); }, + onError: (e: Error) => toast.error(e.message), + }); + + const deleteInvMut = useMutation({ + mutationFn: (invUuid: string) => api.delete>(`/api/v1/admin/clinic/invitation/${invUuid}`), + onSuccess: () => { toast.success('دعوتنامه حذف شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); }, + onError: (e: Error) => toast.error(e.message), + }); + const handleLogoUpload = async (file: File) => { setLogoUploading(true); try { @@ -567,7 +679,7 @@ export default function ClinicDetailPage() { const json = await res.json(); const url = json?.data?.url; if (url && clinic) { - const existing = (clinic.images_clinic ?? []).map(img => img.url); + const existing = (clinic.images_clinic ?? []).filter(img => img?.url).map(img => img.url); await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, url] }); toast.success('تصویر اضافه شد'); qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); @@ -695,39 +807,122 @@ export default function ClinicDetailPage() { )} - {/* Doctors */} + {/* Doctors + Invitations card */}
-
- پزشکان ({formatNumber(doctorList.length)}) + {/* Card header */} +
+
+ + +
+
- {doctorList.length === 0 ? ( -
-

هیچ پزشکی به این کلینیک متصل نیست

-
- ) : ( -
- {doctorList.map(doc => { - const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length]; - const img = doc.img?.[0]?.url; - return ( -
- {img - ? - :
{doc.name?.[0] ?? '?'}
- } -
-
{doc.name}
- {doc.specialties?.length > 0 && ( -
{doc.specialties.map(s => s.name).join('، ')}
- )} + + {/* Doctors tab */} + {doctorsTab === 'doctors' && ( + doctorList.length === 0 ? ( +
+

هیچ پزشکی به این کلینیک متصل نیست

+
+ ) : ( +
+ {doctorList.map(doc => { + const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length]; + const img = doc.img?.[0]?.url; + return ( +
+ {img + ? + :
{doc.name?.[0] ?? '?'}
+ } +
+
{doc.name}
+ {doc.specialties?.length > 0 && ( +
{doc.specialties.map(s => s.name).join('، ')}
+ )} +
+ + {doc.active ? 'فعال' : 'غیرفعال'} +
- - {doc.active ? 'فعال' : 'غیرفعال'} - -
- ); - })} -
+ ); + })} +
+ ) + )} + + {/* Invitations tab */} + {doctorsTab === 'invitations' && ( + invitationList.length === 0 ? ( +
+ +

هیچ دعوتنامه‌ای ارسال نشده

+
+ ) : ( +
+ {invitationList.map(inv => { + const statusInfo = INV_STATUS_MAP[inv.status] ?? { label: inv.status, cls: 'gray' }; + const isExpired = !inv.token_used && inv.status === 'pending' && Date.now() / 1000 > inv.expires_at; + return ( +
+
+
{inv.invited_name ?? inv.mobile}
+
+ {inv.mobile} + {inv.invited_specialty && ( + {inv.invited_specialty} + )} + {inv.doctor && ( + {inv.doctor.name} + )} +
+
+
+ + {isExpired ? 'منقضی' : statusInfo.label} + +
+ {inv.status === 'pending' && ( + + )} + {inv.status !== 'removed' && inv.status !== 'accepted' && ( + + )} + +
+
+
+ ); + })} +
+ ) )}
@@ -748,7 +943,7 @@ export default function ClinicDetailPage() {
) : (
- {clinic.images_clinic.map((img, i) => ( + {clinic.images_clinic.filter(img => img?.url).map((img, i) => (
@@ -829,6 +1024,15 @@ export default function ClinicDetailPage() { onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }} /> )} + {/* Invite doctor modal */} + {inviteOpen && uuid && ( + setInviteOpen(false)} + onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }} + /> + )} + {/* Delete confirm */} addSql('CREATE TABLE clinic_doctor_invitations (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, mobile VARCHAR(20) NOT NULL, invited_name VARCHAR(255) DEFAULT NULL, invited_specialty VARCHAR(255) DEFAULT NULL, token VARCHAR(128) NOT NULL, token_used TINYINT NOT NULL, status VARCHAR(20) NOT NULL, invited_at INT NOT NULL, expires_at INT NOT NULL, responded_at INT DEFAULT NULL, clinic_id INT NOT NULL, invited_by_id INT NOT NULL, doctor_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_26DCCFEBD17F50A6 (uuid), UNIQUE INDEX UNIQ_26DCCFEB5F37A13B (token), INDEX IDX_26DCCFEBA7B4A7E3 (invited_by_id), INDEX IDX_26DCCFEB87F4FB17 (doctor_id), INDEX idx_cdi_token (token), INDEX idx_cdi_clinic (clinic_id), INDEX idx_cdi_mobile (mobile), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE clinic_doctor_invitations ADD CONSTRAINT FK_26DCCFEBCC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE clinic_doctor_invitations ADD CONSTRAINT FK_26DCCFEBA7B4A7E3 FOREIGN KEY (invited_by_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE clinic_doctor_invitations ADD CONSTRAINT FK_26DCCFEB87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE clinic_doctor_invitations DROP FOREIGN KEY FK_26DCCFEBCC22AD4'); + $this->addSql('ALTER TABLE clinic_doctor_invitations DROP FOREIGN KEY FK_26DCCFEBA7B4A7E3'); + $this->addSql('ALTER TABLE clinic_doctor_invitations DROP FOREIGN KEY FK_26DCCFEB87F4FB17'); + $this->addSql('DROP TABLE clinic_doctor_invitations'); + } +} diff --git a/src/ClinicInvitation/Controller/ClinicInvitationController.php b/src/ClinicInvitation/Controller/ClinicInvitationController.php new file mode 100644 index 00000000..2e316c0a --- /dev/null +++ b/src/ClinicInvitation/Controller/ClinicInvitationController.php @@ -0,0 +1,171 @@ +clinicRepo->findByUuid($uuid); + if (!$clinic) { + throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404); + } + + $body = json_decode($request->getContent(), true) ?? []; + $mobile = trim($body['mobile'] ?? ''); + $name = !empty($body['name']) ? trim($body['name']) : null; + $specialty = !empty($body['specialty']) ? trim($body['specialty']) : null; + + if (!$mobile || !preg_match('/^09\d{9}$/', $mobile)) { + throw new AppException('ERR_VALIDATION_001', 'شماره موبایل نامعتبر است', 422, 'mobile'); + } + + $inv = $this->invitationService->invite($clinic, $this->getUser(), $mobile, $name, $specialty); + + return $this->success($inv->toArray(), 201); + } + + #[Route('/api/v1/admin/clinic/{uuid}/invitations', methods: ['GET'])] + #[IsGranted('ROLE_ADMIN')] + public function listInvitations(string $uuid, Request $request): JsonResponse + { + $clinic = $this->clinicRepo->findByUuid($uuid); + if (!$clinic) { + throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404); + } + + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(50, max(1, (int) $request->query->get('limit', 20))); + + $qb = $this->invRepo->createQueryBuilder('i') + ->leftJoin('i.doctor', 'd') + ->where('i.clinic = :clinic') + ->setParameter('clinic', $clinic) + ->orderBy('i.invitedAt', 'DESC'); + + $total = (clone $qb)->select('COUNT(i.id)')->getQuery()->getSingleScalarResult(); + + $items = $qb->setFirstResult(($page - 1) * $limit) + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + + $data = array_map(fn($inv) => $inv->toArray(), $items); + + return $this->paginated($data, (int) $total, $page, $limit); + } + + #[Route('/api/v1/admin/clinic/invitation/{invUuid}/resend', methods: ['POST'])] + #[IsGranted('ROLE_ADMIN')] + public function resendInvitation(string $invUuid): JsonResponse + { + $inv = $this->invRepo->findOneBy(['uuid' => $invUuid]); + if (!$inv) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); + } + + $this->invitationService->resend($inv); + + return $this->success(['message' => 'پیامک مجدداً ارسال شد']); + } + + #[Route('/api/v1/admin/clinic/invitation/{invUuid}/status', methods: ['PATCH'])] + #[IsGranted('ROLE_ADMIN')] + public function changeInvitationStatus(string $invUuid, Request $request): JsonResponse + { + $inv = $this->invRepo->findOneBy(['uuid' => $invUuid]); + if (!$inv) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); + } + + $body = json_decode($request->getContent(), true) ?? []; + $status = $body['status'] ?? ''; + + $this->invitationService->changeStatus($inv, $status); + + return $this->success(['status' => $inv->getStatus()]); + } + + #[Route('/api/v1/admin/clinic/invitation/{invUuid}', methods: ['DELETE'])] + #[IsGranted('ROLE_ADMIN')] + public function deleteInvitation(string $invUuid): JsonResponse + { + $inv = $this->invRepo->findOneBy(['uuid' => $invUuid]); + if (!$inv) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); + } + + $this->invitationService->delete($inv); + + return $this->success(null, 204); + } + + // ── Public endpoints ────────────────────────────────────────────────────── + + #[Route('/api/v1/clinic-invitation/{token}', methods: ['GET'])] + public function viewInvitation(string $token): JsonResponse + { + $inv = $this->invRepo->findByToken($token); + if (!$inv) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); + } + + $clinic = $inv->getClinic(); + + return $this->success([ + 'invitation' => $inv->toArray(), + 'clinic' => [ + 'uuid' => $clinic->getUuid(), + 'name' => $clinic->getName(), + 'logo' => $clinic->getClinicLogo(), + ], + 'is_usable' => $inv->isUsable(), + ]); + } + + #[Route('/api/v1/clinic-invitation/{token}/accept', methods: ['POST'])] + public function acceptInvitation(string $token): JsonResponse + { + $inv = $this->invRepo->findByToken($token); + if (!$inv) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); + } + + $this->invitationService->accept($inv); + + return $this->success(['message' => 'دعوتنامه پذیرفته شد']); + } + + #[Route('/api/v1/clinic-invitation/{token}/reject', methods: ['POST'])] + public function rejectInvitation(string $token): JsonResponse + { + $inv = $this->invRepo->findByToken($token); + if (!$inv) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); + } + + $this->invitationService->reject($inv); + + return $this->success(['message' => 'دعوتنامه رد شد']); + } +} diff --git a/src/ClinicInvitation/Entity/ClinicDoctorInvitation.php b/src/ClinicInvitation/Entity/ClinicDoctorInvitation.php new file mode 100644 index 00000000..cc9f2a19 --- /dev/null +++ b/src/ClinicInvitation/Entity/ClinicDoctorInvitation.php @@ -0,0 +1,140 @@ +uuid = \Symfony\Component\Uid\Uuid::v4()->toRfc4122(); + $this->clinic = $clinic; + $this->invitedBy = $invitedBy; + $this->mobile = $mobile; + $this->token = bin2hex(random_bytes(48)); + $this->invitedAt = time(); + $this->expiresAt = $this->invitedAt + 72 * 3600; + } + + public function isUsable(): bool + { + return $this->status === self::STATUS_PENDING + && !$this->tokenUsed + && time() < $this->expiresAt; + } + + public function markUsed(): void + { + $this->tokenUsed = true; + $this->respondedAt = time(); + } + + public function refresh(): void + { + $this->token = bin2hex(random_bytes(48)); + $this->tokenUsed = false; + $this->invitedAt = time(); + $this->expiresAt = $this->invitedAt + 72 * 3600; + $this->status = self::STATUS_PENDING; + } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'mobile' => $this->mobile, + 'invited_name' => $this->invitedName, + 'invited_specialty' => $this->invitedSpecialty, + 'status' => $this->status, + 'token_used' => $this->tokenUsed, + 'invited_at' => $this->invitedAt, + 'expires_at' => $this->expiresAt, + 'responded_at' => $this->respondedAt, + 'doctor' => $this->doctor ? [ + 'uuid' => $this->doctor->getUuid(), + 'name' => $this->doctor->getName(), + ] : null, + ]; + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getClinic(): Clinic { return $this->clinic; } + public function getDoctor(): ?Doctor { return $this->doctor; } + public function getMobile(): string { return $this->mobile; } + public function getToken(): string { return $this->token; } + public function getStatus(): string { return $this->status; } + public function getInvitedAt(): int { return $this->invitedAt; } + public function getExpiresAt(): int { return $this->expiresAt; } + public function isTokenUsed(): bool { return $this->tokenUsed; } + public function getInvitedName(): ?string { return $this->invitedName; } + public function getInvitedSpecialty(): ?string { return $this->invitedSpecialty; } + + public function setDoctor(?Doctor $doctor): void { $this->doctor = $doctor; } + public function setStatus(string $status): void { $this->status = $status; } + public function setInvitedName(?string $n): void { $this->invitedName = $n; } + public function setInvitedSpecialty(?string $s): void { $this->invitedSpecialty = $s; } +} diff --git a/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php b/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php new file mode 100644 index 00000000..c4c52b08 --- /dev/null +++ b/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php @@ -0,0 +1,41 @@ +findOneBy(['token' => $token]); + } + + public function findPendingByMobileAndClinic(string $mobile, int $clinicId): ?ClinicDoctorInvitation + { + return $this->createQueryBuilder('i') + ->where('i.mobile = :mobile') + ->andWhere('i.clinic = :clinicId') + ->andWhere('i.status = :status') + ->setParameter('mobile', $mobile) + ->setParameter('clinicId', $clinicId) + ->setParameter('status', ClinicDoctorInvitation::STATUS_PENDING) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function save(ClinicDoctorInvitation $invitation): void + { + $em = $this->getEntityManager(); + $em->persist($invitation); + $em->flush(); + } +} diff --git a/src/ClinicInvitation/Service/ClinicInvitationService.php b/src/ClinicInvitation/Service/ClinicInvitationService.php new file mode 100644 index 00000000..19d59f1f --- /dev/null +++ b/src/ClinicInvitation/Service/ClinicInvitationService.php @@ -0,0 +1,104 @@ +repo->findPendingByMobileAndClinic($mobile, $clinic->getId()); + if ($existing !== null) { + throw new AppException('ERR_CONFLICT_001', 'این شماره قبلاً برای این کلینیک دعوت شده است', 409); + } + + $inv = new ClinicDoctorInvitation($clinic, $invitedBy, $mobile); + $inv->setInvitedName($name); + $inv->setInvitedSpecialty($specialty); + + $doctor = $this->doctorRepo->findOneByMobile($mobile); + if ($doctor !== null) { + $inv->setDoctor($doctor); + } + + $this->repo->save($inv); + $this->sendSms($inv, $clinic); + + return $inv; + } + + public function resend(ClinicDoctorInvitation $inv): void + { + if (in_array($inv->getStatus(), [ClinicDoctorInvitation::STATUS_REMOVED, ClinicDoctorInvitation::STATUS_ACCEPTED], true)) { + throw new AppException('ERR_CONFLICT_001', 'امکان ارسال مجدد دعوتنامه وجود ندارد', 409); + } + + $inv->refresh(); + $this->em->flush(); + $this->sendSms($inv, $inv->getClinic()); + } + + public function changeStatus(ClinicDoctorInvitation $inv, string $status): void + { + $allowed = [ClinicDoctorInvitation::STATUS_SUSPENDED, ClinicDoctorInvitation::STATUS_REMOVED]; + if (!in_array($status, $allowed, true)) { + throw new AppException('ERR_VALIDATION_001', 'وضعیت نامعتبر است', 422); + } + $inv->setStatus($status); + $this->em->flush(); + } + + public function delete(ClinicDoctorInvitation $inv): void + { + $this->em->remove($inv); + $this->em->flush(); + } + + 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(); + } + + public function reject(ClinicDoctorInvitation $inv): void + { + if (!$inv->isUsable()) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410); + } + $inv->setStatus(ClinicDoctorInvitation::STATUS_REJECTED); + $inv->markUsed(); + $this->em->flush(); + } + + private function sendSms(ClinicDoctorInvitation $inv, Clinic $clinic): void + { + $clinicName = $clinic->getName() ?? 'کلینیک'; + $link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken(); + + $message = "دکتر گرامی، کلینیک {$clinicName} شما را برای همکاری دعوت کرده است.\n" + . "برای بررسی: {$link}\n" + . "این لینک تا ۷۲ ساعت معتبر است."; + + $this->smsService->dispatchAsync($inv->getMobile(), $message); + } +} diff --git a/src/Doctor/Repository/DoctorRepository.php b/src/Doctor/Repository/DoctorRepository.php index 22d7ae57..55e21a7b 100644 --- a/src/Doctor/Repository/DoctorRepository.php +++ b/src/Doctor/Repository/DoctorRepository.php @@ -25,6 +25,17 @@ class DoctorRepository extends ServiceEntityRepository return $this->findOneBy(['user' => $user]); } + public function findOneByMobile(string $mobile): ?Doctor + { + return $this->createQueryBuilder('d') + ->join('d.user', 'u') + ->where('u.mobileNumber = :mobile') + ->setParameter('mobile', $mobile) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + public function findWithFilters(array $filters): array { $page = max(1, (int) ($filters['page'] ?? 1));