query->get('doctor_uuid', '')); $date = trim($request->query->get('date', '')); $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); } $sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date); return $this->success([ 'doctor_uuid' => $doctorUuid, 'date' => $date, 'sessions' => $sessions, ]); } #[Route('/api/v1/appointment-settings/month-availability/{doctorUuid}', methods: ['GET'])] public function monthAvailability(string $doctorUuid, Request $request): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $year = (int) $request->query->get('year'); $month = (int) $request->query->get('month'); if ($year < 1970 || $month < 1 || $month > 12) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month'); } $daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month))); $disabled = []; $enabled = []; for ($day = 1; $day <= $daysInMonth; $day++) { $date = sprintf('%04d-%02d-%02d', $year, $month, $day); if ($this->slotCalculator->hasAnyAvailability($doctor, $date)) { $enabled[] = $date; } else { $disabled[] = $date; } } $schedule = $this->scheduleRepo->findByDoctor($doctor); $meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META; return $this->success([ 'year' => $year, 'month' => $month, 'disabled_dates' => $disabled, 'enabled_dates' => $enabled, 'online_booking_enabled' => (bool) $meta['online_booking_enabled'], 'booking_window' => [ 'value' => (int) $meta['booking_window_value'], 'unit' => $meta['booking_window_unit'], ], ]); } // ── Authenticated: book / manage ───────────────────────────────────────── #[OA\Post( path: '/api/v1/appointment', summary: 'Book a new appointment', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['doctor_uuid', 'slot_start', 'slot_end'], properties: [ new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'), new OA\Property(property: 'slot_start', type: 'integer', description: 'Slot start Unix timestamp'), new OA\Property(property: 'slot_end', type: 'integer', description: 'Slot end Unix timestamp'), new OA\Property(property: 'note', type: 'string'), ] ) ), responses: [ new OA\Response( response: 201, description: 'Appointment booked successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Appointment object'), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), new OA\Response(response: 404, description: 'Doctor not found'), new OA\Response(response: 409, description: 'Slot already taken'), new OA\Response(response: 422, description: 'Validation error'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointment', methods: ['POST'])] public function book(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $doctorUuid = trim($data['doctor_uuid'] ?? ''); $slotStart = (int) ($data['slot_start'] ?? 0); $slotEnd = (int) ($data['slot_end'] ?? 0); if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422); } if ($slotStart < time()) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'زمان این اسلات گذشته است', 422); } $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $forSelf = (bool) ($data['for_self'] ?? true); $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); if (isset($data['note'])) $appointment->setNote($data['note']); // آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id). $locationId = $this->resolveSlotLocationId($doctor, $slotStart); if ($locationId !== null) { $appointment->setAddressId($locationId); } if ($forSelf) { $appointment->setPatientName($user->getRealName()); $appointment->setPatientMobile($user->getMobileNumber()); } else { $patientName = trim($data['patient_name'] ?? ''); $patientMobile = trim($data['patient_mobile'] ?? ''); if ($patientName === '' || $patientMobile === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422); } $appointment->setPatientName($patientName); $appointment->setPatientMobile($patientMobile); $appointment->setPatientNationalCode($data['patient_national_code'] ?? null); $appointment->setPatientGender($data['patient_gender'] ?? null); $appointment->setPatientReason($data['patient_reason'] ?? null); } $appointment->markPendingWithTtl(Appointment::PAYMENT_TTL); try { $this->appointmentRepo->bookAtomically($appointment); } catch (SlotTakenException) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409); } return $this->success(['data' => $appointment->toArray()], 201); } #[OA\Get( path: '/api/v1/appointment/{uuid}', summary: 'Get a single appointment by UUID', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter( name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), ], responses: [ new OA\Response( response: 200, description: 'Appointment returned', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Appointment object'), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), new OA\Response(response: 403, description: 'Access denied'), new OA\Response(response: 404, description: 'Appointment not found'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointment/{uuid}', methods: ['GET'])] public function get(string $uuid, #[CurrentUser] User $user): JsonResponse { $appointment = $this->appointmentRepo->findByUuid($uuid); if ($appointment === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); } if (!$this->canView($appointment, $user)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } return $this->success(['data' => $appointment->toArray()]); } #[OA\Get( path: '/api/v1/appointments/doctor/{doctorUuid}', summary: 'List appointments for a specific doctor', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter( name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), new OA\Parameter( name: 'status', in: 'query', required: false, description: 'Filter by appointment status', schema: new OA\Schema(type: 'string', example: 'pending') ), ], responses: [ new OA\Response( response: 200, description: 'Appointment list returned', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', type: 'array', items: new OA\Items(type: 'object', description: 'Appointment object') ), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), new OA\Response(response: 403, description: 'Access denied'), new OA\Response(response: 404, description: 'Doctor not found'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])] public function listByDoctor(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } $status = $request->query->get('status'); $appointments = $this->appointmentRepo->findByDoctor($doctor, $status); return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]); } #[OA\Get( path: '/api/v1/appointments/user', summary: 'List appointments for the authenticated user', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter( name: 'status', in: 'query', required: false, description: 'Filter by appointment status', schema: new OA\Schema(type: 'string', example: 'pending') ), ], responses: [ new OA\Response( response: 200, description: 'Appointment list returned', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', type: 'array', items: new OA\Items(type: 'object', description: 'Appointment object') ), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointments/user', methods: ['GET'])] public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse { $status = $request->query->get('status'); $appointments = $this->appointmentRepo->findByUser($user, $status); return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]); } private function canView(Appointment $a, User $user): bool { return $a->getUser()->getId() === $user->getId() || $a->getDoctor()->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN'); } private function canManage(Appointment $a, User $user): bool { return $a->getUser()->getId() === $user->getId() || $a->getDoctor()->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN'); } private function resolveSlotLocationId(Doctor $doctor, int $slotStart): ?int { return $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); } #[OA\Patch( path: '/api/v1/appointment/{uuid}/status', summary: 'Update the status of an appointment', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['status'], properties: [ new OA\Property(property: 'status', type: 'string', example: 'confirmed'), new OA\Property(property: 'version', type: 'integer', description: 'Optimistic lock version'), ] ) ), parameters: [ new OA\Parameter( name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), ], responses: [ new OA\Response( response: 200, description: 'Appointment status updated', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Updated appointment object'), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), new OA\Response(response: 403, description: 'Access denied'), new OA\Response(response: 404, description: 'Appointment not found'), new OA\Response(response: 409, description: 'Optimistic lock conflict'), new OA\Response(response: 422, description: 'Invalid status transition'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])] public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $appointment = $this->appointmentRepo->findByUuid($uuid); if ($appointment === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); } if (!$this->canManage($appointment, $user)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } $data = json_decode($request->getContent(), true) ?? []; $newStatus = trim($data['status'] ?? ''); $version = (int) ($data['version'] ?? $appointment->getVersion()); if (!$appointment->canTransitionTo($newStatus)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf( 'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus ), 422); } $appointment->transitionTo($newStatus); try { $this->appointmentRepo->saveWithLock($appointment, $version); } catch (OptimisticLockException) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409); } if ($newStatus === Appointment::STATUS_CONFIRMED) { $this->patientService->autoCreateOnAppointmentConfirm($appointment); } return $this->success(['data' => $appointment->toArray()]); } }