[]]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of users', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'id', type: 'integer'), new OA\Property(property: 'mobile_number', type: 'string'), new OA\Property(property: 'name', type: 'string'), new OA\Property(property: 'email', type: 'string'), new OA\Property(property: 'roles', type: 'array', items: new OA\Items(type: 'string')), new OA\Property(property: 'is_active', type: 'boolean'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/users/stats', methods: ['GET'])] public function userStats(): JsonResponse { $conn = $this->em->getConnection(); $total = (int) $conn->fetchOne('SELECT COUNT(*) FROM users'); $active = (int) $conn->fetchOne('SELECT COUNT(*) FROM users WHERE status = 1'); $admins = (int) $conn->fetchOne("SELECT COUNT(*) FROM users WHERE roles LIKE '%ROLE_ADMIN%'"); $doctors = (int) $conn->fetchOne("SELECT COUNT(*) FROM users WHERE roles LIKE '%ROLE_DOCTOR%' AND roles NOT LIKE '%ROLE_ADMIN%'"); return $this->success([ 'total' => $total, 'active' => $active, 'inactive' => $total - $active, 'admins' => $admins, 'doctors' => $doctors, 'patients' => max(0, $total - $admins - $doctors), ]); } #[Route('/api/v1/admin/users/{uuid}/status', methods: ['POST'])] public function toggleUserStatus(string $uuid): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); $user->setStatus($user->getStatus() === 1 ? 0 : 1); $this->em->flush(); return $this->success(['is_active' => $user->getStatus() === 1, 'status' => $user->getStatus()]); } #[Route('/api/v1/admin/users/{uuid}/role', methods: ['PUT'])] public function updateUserRole(string $uuid, Request $request): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); $data = json_decode($request->getContent(), true) ?? []; $role = (string) ($data['role'] ?? ''); $roleMap = [ 'admin' => ['ROLE_USER', 'ROLE_ADMIN'], 'doctor' => ['ROLE_USER', 'ROLE_DOCTOR'], 'secretary' => ['ROLE_USER', 'ROLE_SECRETARY'], 'clinic' => ['ROLE_USER', 'ROLE_CLINIC'], 'patient' => ['ROLE_USER'], ]; if (!isset($roleMap[$role])) { return $this->error('INVALID_ROLE', 'نقش نامعتبر است', 422); } $user->setRoles($roleMap[$role]); $this->em->flush(); return $this->success(['roles' => $user->getRoles()]); } #[Route('/api/v1/admin/users/{uuid}', methods: ['GET'])] public function userDetail(string $uuid): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); return $this->success([ 'uuid' => $user->getUuid(), 'id' => $user->getId(), 'mobile_number' => $user->getMobileNumber(), 'name' => $user->getRealName(), 'email' => $user->getEmail(), 'roles' => $user->getRoles(), 'is_active' => $user->getStatus() === 1, 'status' => $user->getStatus(), 'created_at' => date('c', $user->getCreatedAt()), 'updated_at' => date('c', $user->getCreatedAt()), ]); } #[Route('/api/v1/admin/users/{uuid}', methods: ['PUT'])] public function updateUser(string $uuid, Request $request): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); $data = json_decode($request->getContent(), true) ?? []; if (array_key_exists('name', $data)) { $user->setRealName($data['name'] !== '' ? (string) $data['name'] : null); } if (array_key_exists('email', $data)) { $user->setEmail($data['email'] !== '' ? (string) $data['email'] : null); } if (!empty($data['password'])) { $user->setPasswordHash(password_hash((string) $data['password'], PASSWORD_BCRYPT)); } $this->em->flush(); return $this->success([ 'uuid' => $user->getUuid(), 'name' => $user->getRealName(), 'email' => $user->getEmail(), 'is_active' => $user->getStatus() === 1, ]); } #[Route('/api/v1/admin/users/{uuid}', methods: ['DELETE'])] public function deleteUser(string $uuid): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); $this->em->remove($user); $this->em->flush(); return $this->success(null); } #[Route('/api/v1/admin/users', methods: ['GET'])] public function users(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(5, (int) $request->query->get('limit', 25))); $search = trim((string) $request->query->get('search', '')); $role = trim((string) $request->query->get('role', '')); $status = $request->query->get('status', ''); $sort = trim((string) $request->query->get('sort', 'newest')); $qb = $this->em->createQueryBuilder() ->select('u.uuid, u.id, u.mobileNumber as mobile, u.realName as name, u.email, u.roles, u.status, u.createdAt') ->from(User::class, 'u'); if ($search !== '') { $qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s') ->setParameter('s', '%' . $search . '%'); } if ($role !== '') { match ($role) { 'admin' => $qb->andWhere("u.roles LIKE :role")->setParameter('role', '%ROLE_ADMIN%'), 'doctor' => $qb->andWhere("u.roles LIKE :role AND u.roles NOT LIKE :notRole") ->setParameter('role', '%ROLE_DOCTOR%')->setParameter('notRole', '%ROLE_ADMIN%'), 'secretary' => $qb->andWhere("u.roles LIKE :role")->setParameter('role', '%ROLE_SECRETARY%'), 'clinic' => $qb->andWhere("u.roles LIKE :role AND u.roles NOT LIKE :notRole") ->setParameter('role', '%ROLE_CLINIC%')->setParameter('notRole', '%ROLE_ADMIN%'), 'patient' => $qb->andWhere("u.roles NOT LIKE :d AND u.roles NOT LIKE :a") ->setParameter('d', '%ROLE_DOCTOR%')->setParameter('a', '%ROLE_ADMIN%'), default => null, }; } if ($status !== '') { $qb->andWhere('u.status = :status')->setParameter('status', (int) $status); } $qb->orderBy('u.createdAt', $sort === 'oldest' ? 'ASC' : 'DESC'); $total = (clone $qb)->select('COUNT(u.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getArrayResult(); return $this->paginated(array_map(fn(array $u) => [ 'uuid' => $u['uuid'], 'id' => $u['id'], 'mobile_number' => $u['mobile'], 'name' => $u['name'], 'email' => $u['email'], 'roles' => $u['roles'], 'is_active' => $u['status'] === 1, 'status' => $u['status'], 'created_at' => date('c', (int) $u['createdAt']), ], $rows), (int) $total, $page, $limit); } // ── Doctors ─────────────────────────────────────────────────────────────── #[Route('/api/v1/admin/doctors/stats', methods: ['GET'])] public function doctorStats(): JsonResponse { $conn = $this->em->getConnection(); $total = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors'); $active = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors WHERE active_doctor_appointment = 1'); $male = (int) $conn->fetchOne("SELECT COUNT(*) FROM doctors WHERE gender IN ('man','male')"); $female = (int) $conn->fetchOne("SELECT COUNT(*) FROM doctors WHERE gender IN ('woman','female')"); $topSpec = $conn->fetchAllAssociative( 'SELECT s.name, COUNT(*) as cnt FROM doctor_specialties ds JOIN specialties s ON s.id = ds.specialty_id GROUP BY s.id ORDER BY cnt DESC LIMIT 5' ); return $this->success([ 'total' => $total, 'active' => $active, 'inactive' => $total - $active, 'male' => $male, 'female' => $female, 'top_specialty' => $topSpec, ]); } #[Route('/api/v1/admin/doctors/{uuid}/status', methods: ['POST'])] public function toggleDoctorStatus(string $uuid): JsonResponse { $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]); if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404); $doctor->setActiveDoctorAppointment(!$doctor->isActiveDoctorAppointment()); $this->em->flush(); return $this->success(['is_active' => $doctor->isActiveDoctorAppointment()]); } #[Route('/api/v1/admin/doctors', methods: ['GET'])] public function doctorsList(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(5, (int) $request->query->get('limit', 25))); $search = trim((string) $request->query->get('search', '')); $status = $request->query->get('status', ''); $gender = trim((string) $request->query->get('gender', '')); $specId = (int) $request->query->get('specialty_id', 0); $sort = trim((string) $request->query->get('sort', 'newest')); $conn = $this->em->getConnection(); $where = ['1=1']; $params = []; if ($search !== '') { $where[] = '(d.name LIKE :s OR d.mobile_number LIKE :s OR u.mobile_number LIKE :s OR u.email LIKE :s)'; $params['s'] = '%' . $search . '%'; } if ($status !== '') { $where[] = 'd.active_doctor_appointment = :status'; $params['status'] = $status === '1' ? 1 : 0; } if ($gender !== '') { $where[] = 'd.gender = :gender'; $params['gender'] = $gender; } if ($specId > 0) { $where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :specId)'; $params['specId'] = $specId; } $whereStr = implode(' AND ', $where); $orderBy = match ($sort) { 'oldest' => 'd.created_at ASC', 'rating' => 'd.doctor_rate DESC', default => 'd.created_at DESC', }; $total = (int) $conn->fetchOne( "SELECT COUNT(*) FROM doctors d JOIN users u ON u.id = d.user_id WHERE $whereStr", $params ); $offset = ($page - 1) * $limit; $rows = $conn->fetchAllAssociative( "SELECT d.id, d.uuid, d.name, d.gender, d.degree, d.medical_system_code, d.mobile_number as doctor_mobile, d.active_doctor_appointment, d.doctor_rate, d.doctor_rate_percentage, d.images, d.created_at, u.mobile_number as user_mobile, u.email FROM doctors d JOIN users u ON u.id = d.user_id WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset", $params ); $doctorIds = array_column($rows, 'id'); $specMap = []; if (!empty($doctorIds)) { $specRows = $conn->fetchAllAssociative( 'SELECT ds.doctor_id, s.id, s.name FROM doctor_specialties ds JOIN specialties s ON s.id = ds.specialty_id WHERE ds.doctor_id IN (' . implode(',', array_map('intval', $doctorIds)) . ')' ); foreach ($specRows as $sr) { $specMap[(int) $sr['doctor_id']][] = ['id' => (int) $sr['id'], 'name' => $sr['name']]; } } $items = array_map(function (array $d) use ($specMap): array { $images = $d['images'] ? (json_decode($d['images'], true) ?? []) : []; return [ 'uuid' => $d['uuid'], 'id' => (int) $d['id'], 'name' => $d['name'], 'gender' => $d['gender'], 'degree' => $d['degree'], 'medical_code' => $d['medical_system_code'], 'mobile' => $d['doctor_mobile'] ?: $d['user_mobile'], 'email' => $d['email'], 'is_active' => (bool) $d['active_doctor_appointment'], 'rate' => (float) $d['doctor_rate'], 'specialties' => $specMap[(int) $d['id']] ?? [], 'profile_image' => !empty($images) ? ($images[0]['url'] ?? null) : null, 'created_at' => date('c', (int) $d['created_at']), ]; }, $rows); return $this->paginated($items, $total, $page, $limit); } #[Route('/api/v1/admin/doctors', methods: ['POST'])] public function createDoctor(Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? ''))); $name = trim((string) ($data['name'] ?? '')); if ($mobile === '' || $name === '') { return $this->error('VALIDATION', 'موبایل و نام الزامی هستند', 422); } if (!InputValidator::isValidIranMobile($mobile)) { return $this->error('VALIDATION', 'شماره موبایل نامعتبر است', 422, 'mobile'); } $user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); if (!$user) { $user = new User($mobile); $user->setRealName($name); $user->setPasswordHash(password_hash(bin2hex(random_bytes(8)), PASSWORD_BCRYPT)); $this->em->persist($user); } $existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $user]); if ($existing) { return $this->error('DOCTOR_EXISTS', 'این کاربر قبلاً پروفایل پزشک دارد', 409); } $doctor = new Doctor($user, $name); if (!empty($data['gender'])) $doctor->setGender($data['gender']); if (!empty($data['degree'])) $doctor->setDegree($data['degree']); if (!empty($data['medical_system_code'])) $doctor->setMedicalSystemCode($data['medical_system_code']); if (!empty($data['info'])) $doctor->setInfo($data['info']); if (!empty($data['mobile_number'])) $doctor->setMobileNumber($data['mobile_number']); if (!empty($data['specialties']) && is_array($data['specialties'])) { foreach ($data['specialties'] as $id) { $s = $this->em->getRepository(Specialty::class)->find((int) $id); if ($s !== null) $doctor->getSpecialties()->add($s); } } $roles = $user->getRoles(); if (!in_array('ROLE_DOCTOR', $roles, true)) { $roles[] = 'ROLE_DOCTOR'; $user->setRoles(array_values(array_unique($roles))); } $this->em->persist($doctor); $this->em->flush(); return $this->success(['uuid' => $doctor->getUuid()], 201); } // ── Clinics ─────────────────────────────────────────────────────────────── #[Route('/api/v1/admin/clinics', methods: ['GET'])] public function clinicsList(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(5, (int) $request->query->get('limit', 15))); $search = trim((string) $request->query->get('search', '')); $status = $request->query->get('status', ''); $conn = $this->em->getConnection(); $where = ['1=1']; $params = []; if ($search !== '') { $where[] = '(c.name LIKE :s OR c.telephone LIKE :s)'; $params['s'] = '%' . $search . '%'; } if ($status !== '') { $where[] = 'c.is_active = :active'; $params['active'] = $status === '1' ? 1 : 0; } $whereStr = implode(' AND ', $where); $total = (int) $conn->fetchOne( "SELECT COUNT(*) FROM clinics c WHERE $whereStr", $params ); $offset = ($page - 1) * $limit; $rows = $conn->fetchAllAssociative( "SELECT c.uuid, c.name, c.telephone, c.clinic_logo, c.is_active, c.created_at, COUNT(DISTINCT cd.doctor_id) as doctors_count, u.mobile_number as owner_mobile FROM clinics c LEFT JOIN clinic_doctors cd ON cd.clinic_id = c.id LEFT JOIN users u ON u.id = c.user_id WHERE $whereStr GROUP BY c.id, u.mobile_number ORDER BY c.created_at DESC LIMIT $limit OFFSET $offset", $params ); $items = array_map(fn(array $c) => [ 'uuid' => $c['uuid'], 'name' => $c['name'], 'phone' => $c['telephone'], 'logo' => $c['clinic_logo'], 'is_active' => (bool) $c['is_active'], 'doctors_count' => (int) $c['doctors_count'], 'created_at' => (int) $c['created_at'], 'owner_mobile' => $c['owner_mobile'], ], $rows); return $this->paginated($items, $total, $page, $limit); } #[Route('/api/v1/admin/clinic', methods: ['POST'])] public function createClinic(Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $mobile = InputValidator::toEnglishDigits(trim((string) ($data['owner_mobile'] ?? ''))); $name = trim((string) ($data['name'] ?? '')); if ($mobile === '') { return $this->error('VALIDATION', 'شماره موبایل الزامی است', 422); } if (!InputValidator::isValidIranMobile($mobile)) { return $this->error('VALIDATION', 'شماره موبایل نامعتبر است', 422, 'owner_mobile'); } if ($name === '') { return $this->error('VALIDATION', 'نام کلینیک الزامی است', 422); } $user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); if (!$user) { $user = new User($mobile); $this->em->persist($user); } $roles = $user->getRoles(); if (!in_array('ROLE_CLINIC', $roles, true)) { $roles[] = 'ROLE_CLINIC'; $user->setRoles(array_values(array_unique($roles))); } $clinic = new Clinic($user); $clinic->setName($name); if (!empty($data['telephone'])) $clinic->setTelephone($data['telephone']); if (!empty($data['address'])) $clinic->setAddress($data['address']); if (!empty($data['info'])) $clinic->setInfo($data['info']); $this->em->persist($clinic); $this->em->flush(); return $this->success(['uuid' => $clinic->getUuid(), 'name' => $clinic->getName(), 'is_active' => $clinic->isActive()]); } #[Route('/api/v1/admin/clinic/{uuid}/status', methods: ['PATCH'])] public function toggleClinicStatus(string $uuid): JsonResponse { $clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]); if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404); $clinic->setIsActive(!$clinic->isActive()); $this->em->flush(); return $this->success(['is_active' => $clinic->isActive()]); } #[Route('/api/v1/admin/clinic/{uuid}', methods: ['DELETE'])] public function deleteClinic(string $uuid): JsonResponse { $clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]); if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404); $this->em->remove($clinic); $this->em->flush(); return $this->success(null); } // ── Appointments ────────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/appointments/today-stats', summary: 'Get today appointment stats', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'date', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15'), description: 'Date (YYYY-MM-DD), defaults to today'), ], responses: [ new OA\Response( response: 200, description: 'Appointment stats for the given date', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', properties: [ new OA\Property(property: 'total', type: 'integer'), new OA\Property(property: 'completed', type: 'integer'), new OA\Property(property: 'waiting', type: 'integer'), new OA\Property(property: 'cancelled', type: 'integer'), ], type: 'object'), ] ) ), ] )] #[Route('/api/v1/admin/appointments/today-stats', methods: ['GET'])] public function appointmentsTodayStats(Request $request): JsonResponse { $date = trim((string) $request->query->get('date', date('Y-m-d'))); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { $date = date('Y-m-d'); } $dayStart = (int) strtotime($date . ' 00:00:00'); $dayEnd = (int) strtotime($date . ' 23:59:59'); $rows = $this->em->createQueryBuilder() ->select('a.status, COUNT(a.id) AS cnt') ->from(Appointment::class, 'a') ->where('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd') ->setParameter('dayStart', $dayStart) ->setParameter('dayEnd', $dayEnd) ->groupBy('a.status') ->getQuery() ->getArrayResult(); $byStatus = []; foreach ($rows as $row) { $byStatus[$row['status']] = (int) $row['cnt']; } $total = array_sum($byStatus); $completed = ($byStatus['completed'] ?? 0); $cancelled = ($byStatus['cancelled_by_doctor'] ?? 0) + ($byStatus['cancelled_by_user'] ?? 0) + ($byStatus['cancelled_by_admin'] ?? 0) + ($byStatus['no_show'] ?? 0) + ($byStatus['expired'] ?? 0); $waiting = $total - $completed - $cancelled; return $this->success([ 'total' => $total, 'completed' => $completed, 'waiting' => max(0, $waiting), 'cancelled' => $cancelled, ]); } #[OA\Get( path: '/api/v1/admin/appointments', summary: 'List appointments (paginated, filterable by date and doctor)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'date', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15'), description: 'Filter by slot date (YYYY-MM-DD)'), new OA\Parameter(name: 'doctor_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string'), description: 'Filter by doctor UUID'), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of appointments', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'patient_name', type: 'string'), new OA\Property(property: 'patient_mobile', type: 'string'), new OA\Property(property: 'doctor_uuid', type: 'string'), new OA\Property(property: 'doctor_name', type: 'string'), new OA\Property(property: 'slot_start', type: 'integer', description: 'Unix timestamp'), new OA\Property(property: 'slot_end', type: 'integer', description: 'Unix timestamp'), new OA\Property(property: 'appointment_date', type: 'string', format: 'date'), new OA\Property(property: 'appointment_time', type: 'string', example: '14:30'), new OA\Property(property: 'end_time', type: 'string', example: '14:50'), new OA\Property(property: 'status', type: 'string'), new OA\Property(property: 'version', type: 'integer'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/appointments', methods: ['GET'])] public function appointments(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(500, max(1, (int) $request->query->get('limit', 15))); $search = trim((string) $request->query->get('search', '')); $status = trim((string) $request->query->get('status', '')); $date = trim((string) $request->query->get('date', '')); $doctorUuid = trim((string) $request->query->get('doctor_uuid', '')); $qb = $this->em->createQueryBuilder() ->select( 'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt, a.version', 'd.uuid as doctor_uuid, d.name as doctor_name', 'u.mobileNumber as patient_mobile, u.realName as patient_name', ) ->from(Appointment::class, 'a') ->join('a.doctor', 'd') ->join('a.user', 'u') ->orderBy('a.slotStart', 'ASC'); if ($search !== '') { $qb->andWhere('u.mobileNumber LIKE :s OR d.name LIKE :s OR u.realName LIKE :s') ->setParameter('s', '%' . $search . '%'); } if ($status !== '') { $qb->andWhere('a.status = :status')->setParameter('status', $status); } if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { $dayStart = (int) strtotime($date . ' 00:00:00'); $dayEnd = (int) strtotime($date . ' 23:59:59'); $qb->andWhere('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd') ->setParameter('dayStart', $dayStart) ->setParameter('dayEnd', $dayEnd); } if ($doctorUuid !== '') { $qb->andWhere('d.uuid = :doctorUuid')->setParameter('doctorUuid', $doctorUuid); } $total = (clone $qb)->select('COUNT(a.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); $items = array_map(fn(array $a) => [ 'uuid' => $a['uuid'], 'patient_name' => $a['patient_name'] ?? '', 'patient_mobile' => $a['patient_mobile'], 'doctor_uuid' => $a['doctor_uuid'], 'doctor_name' => $a['doctor_name'], 'slot_start' => (int) $a['slotStart'], 'slot_end' => (int) $a['slotEnd'], 'appointment_date' => date('Y-m-d', (int) $a['slotStart']), 'appointment_time' => date('H:i', (int) $a['slotStart']), 'end_time' => date('H:i', (int) $a['slotEnd']), 'status' => $a['status'], 'version' => (int) $a['version'], 'created_at' => date('c', (int) $a['createdAt']), ], $rows); return $this->paginated($items, (int) $total, $page, $limit); } // ── Payments ────────────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/payments', summary: 'List all payments (paginated)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'success', 'failed'])), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of payments', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'amount', type: 'integer'), new OA\Property(property: 'status', type: 'string'), new OA\Property(property: 'gateway', type: 'string'), new OA\Property(property: 'ref_id', type: 'string', nullable: true), new OA\Property(property: 'patient_mobile', type: 'string'), new OA\Property(property: 'paid_at', type: 'string', format: 'date-time', nullable: true), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/appointment', methods: ['POST'])] public function createAppointment(Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $doctorUuid = trim($data['doctor_uuid'] ?? ''); $slotStart = (int) ($data['slot_start'] ?? 0); $slotEnd = (int) ($data['slot_end'] ?? 0); $mobile = trim($data['patient_mobile'] ?? ''); $patientName = trim($data['patient_name'] ?? ''); if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) { return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422); } $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]); if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404); $patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); if (!$patient) { $patient = new User($mobile); $patient->setRealName($patientName); $patient->setRoles(['ROLE_USER']); $this->em->persist($patient); } $conflict = $this->em->createQueryBuilder() ->select('COUNT(a.id)') ->from(Appointment::class, 'a') ->where('a.doctor = :doctor') ->andWhere('a.slotStart < :end AND a.slotEnd > :start') ->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')") ->setParameter('doctor', $doctor) ->setParameter('start', $slotStart) ->setParameter('end', $slotEnd) ->getQuery()->getSingleScalarResult(); if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409); $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); if (!empty($data['note'])) $appointment->setNote($data['note']); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); if ($locationId !== null) $appointment->setAddressId($locationId); $this->em->persist($appointment); $this->em->flush(); return $this->success([ 'uuid' => $appointment->getUuid(), 'slot_start' => $slotStart, 'slot_end' => $slotEnd, 'status' => $appointment->getStatus(), ], 201); } #[Route('/api/v1/admin/payments', methods: ['GET'])] public function payments(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 15))); $search = trim((string) $request->query->get('search', '')); $status = trim((string) $request->query->get('status', '')); $qb = $this->em->createQueryBuilder() ->select( 'p.uuid, p.amountRials, p.status, p.gateway, p.referenceId, p.createdAt, p.updatedAt', 'u.mobileNumber as patient_mobile', ) ->from(Payment::class, 'p') ->join('p.user', 'u') ->orderBy('p.createdAt', 'DESC'); if ($search !== '') { $qb->andWhere('u.mobileNumber LIKE :s OR p.referenceId LIKE :s OR p.orderId LIKE :s') ->setParameter('s', '%' . $search . '%'); } if ($status !== '') { $qb->andWhere('p.status = :status')->setParameter('status', $status); } $total = (clone $qb)->select('COUNT(p.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); $items = array_map(fn(array $p) => [ 'uuid' => $p['uuid'], 'amount' => (int) $p['amountRials'], 'status' => $p['status'], 'gateway' => $p['gateway'], 'ref_id' => $p['referenceId'], 'patient_mobile' => $p['patient_mobile'], 'paid_at' => $p['status'] === 'success' ? date('c', (int) $p['updatedAt']) : null, 'created_at' => date('c', (int) $p['createdAt']), ], $rows); return $this->paginated($items, (int) $total, $page, $limit); } // ── Representations ─────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/representations', summary: 'List all representations (paginated)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'city_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of representations', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'domain', type: 'string'), new OA\Property(property: 'full_name', type: 'string'), new OA\Property(property: 'mobile_number', type: 'string'), new OA\Property(property: 'city_id', type: 'integer', nullable: true), new OA\Property(property: 'city', type: 'string', nullable: true), new OA\Property(property: 'commission_percent', type: 'number', format: 'float'), new OA\Property(property: 'wallet_balance', type: 'integer'), new OA\Property(property: 'is_active', type: 'boolean'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/representations', methods: ['GET'])] public function representations(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 15))); $search = trim((string) $request->query->get('search', '')); $cityId = $request->query->get('city_id'); $qb = $this->em->createQueryBuilder() ->select('r.id, r.uuid, r.fullName, r.mobileNumber, u.mobileNumber as user_mobile, r.cityId, r.commissionPercent, r.active, r.createdAt, c.name as city_name') ->from(Representation::class, 'r') ->join('r.user', 'u') ->leftJoin(City::class, 'c', 'WITH', 'c.id = r.cityId') ->orderBy('r.createdAt', 'DESC'); if ($search !== '') { $qb->andWhere('r.fullName LIKE :s OR r.mobileNumber LIKE :s OR u.mobileNumber LIKE :s') ->setParameter('s', '%' . $search . '%'); } if ($cityId !== null && $cityId !== '') { $qb->andWhere('r.cityId = :cityId') ->setParameter('cityId', (int) $cityId); } $total = (clone $qb)->select('COUNT(r.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); $items = array_map(fn(array $r) => [ 'id' => (int) $r['id'], 'uuid' => $r['uuid'], 'domain' => $r['fullName'], 'full_name' => $r['fullName'], 'mobile_number' => $r['mobileNumber'] ?: ($r['user_mobile'] ?? null), 'city_id' => $r['cityId'], 'city' => $r['city_name'] ?? null, 'commission_percent' => (float) $r['commissionPercent'], 'wallet_balance' => 0, 'is_active' => (bool) $r['active'], 'created_at' => date('c', (int) $r['createdAt']), ], $rows); return $this->paginated($items, (int) $total, $page, $limit); } // ── Secretaries ─────────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/secretaries', summary: 'List all secretaries (paginated)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of secretaries', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'user_name', type: 'string'), new OA\Property(property: 'mobile_number', type: 'string'), new OA\Property(property: 'doctor_name', type: 'string'), new OA\Property(property: 'doctor_uuid', type: 'string'), new OA\Property(property: 'is_active', type: 'boolean'), new OA\Property(property: 'permissions', type: 'array', items: new OA\Items(type: 'string')), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/secretaries', methods: ['GET'])] public function secretaries(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 15))); $search = trim((string) $request->query->get('search', '')); $qb = $this->em->createQueryBuilder() ->select( 'ds.uuid, ds.permissions, ds.active, ds.createdAt', 'u.mobileNumber as mobile, u.realName as user_name', 'd.name as doctor_name, d.uuid as doctor_uuid', ) ->from(DoctorSecretary::class, 'ds') ->join('ds.doctor', 'd') ->join('ds.secretary', 'u') ->orderBy('ds.createdAt', 'DESC'); if ($search !== '') { $qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR d.name LIKE :s') ->setParameter('s', '%' . $search . '%'); } $total = (clone $qb)->select('COUNT(ds.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); $items = array_map(fn(array $ds) => [ 'uuid' => $ds['uuid'], 'user_name' => $ds['user_name'] ?? $ds['mobile'], 'mobile_number' => $ds['mobile'], 'doctor_name' => $ds['doctor_name'], 'doctor_uuid' => $ds['doctor_uuid'], 'is_active' => (bool) $ds['active'], 'permissions' => $ds['permissions'] ?? DoctorSecretary::DEFAULT_PERMISSIONS, 'created_at' => date('c', (int) $ds['createdAt']), ], $rows); return $this->paginated($items, (int) $total, $page, $limit); } // ── Ratings ─────────────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/rates', summary: 'List all ratings (paginated)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of ratings', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'patient_name', type: 'string'), new OA\Property(property: 'doctor_name', type: 'string'), new OA\Property(property: 'overall', type: 'integer'), new OA\Property(property: 'score', type: 'integer'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/rates', methods: ['GET'])] public function rates(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 15))); $search = trim((string) $request->query->get('search', '')); $qb = $this->em->createQueryBuilder() ->select( 'r.uuid, r.createdAt', 'r.waitingTimeAtClinic, r.accuracyOfDiagnosis, r.doctorBehavior, r.clinicCleanliness, r.doctorExpertise', 'u.realName as patient_name, u.mobileNumber as patient_mobile', 'd.name as doctor_name', ) ->from(Rate::class, 'r') ->join('r.doctor', 'd') ->join('r.user', 'u') ->orderBy('r.createdAt', 'DESC'); if ($search !== '') { $qb->andWhere('d.name LIKE :s OR u.realName LIKE :s') ->setParameter('s', '%' . $search . '%'); } $total = (clone $qb)->select('COUNT(r.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); $items = array_map(function (array $r) { $overall = (int) round(( $r['waitingTimeAtClinic'] + $r['accuracyOfDiagnosis'] + $r['doctorBehavior'] + $r['clinicCleanliness'] + $r['doctorExpertise'] ) / 5); return [ 'uuid' => $r['uuid'], 'patient_name' => $r['patient_name'] ?? $r['patient_mobile'], 'doctor_name' => $r['doctor_name'], 'overall' => $overall, 'score' => (int) round($overall / 20), 'created_at' => date('c', (int) $r['createdAt']), ]; }, $rows); return $this->paginated($items, (int) $total, $page, $limit); } // ── Comments ────────────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/comments', summary: 'List all comments (paginated)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'approved', 'rejected'])), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of comments', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'patient_name', type: 'string'), new OA\Property(property: 'doctor_name', type: 'string'), new OA\Property(property: 'title', type: 'string'), new OA\Property(property: 'body', type: 'string'), new OA\Property(property: 'is_approved', type: 'boolean'), new OA\Property(property: 'status', type: 'string'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/comments', methods: ['GET'])] public function comments(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 15))); $search = trim((string) $request->query->get('search', '')); $status = trim((string) $request->query->get('status', '')); $qb = $this->em->createQueryBuilder() ->select( 'c.uuid, c.body, c.status, c.createdAt', 'u.realName as patient_name', 'd.name as doctor_name', ) ->from(Comment::class, 'c') ->join('c.doctor', 'd') ->join('c.user', 'u') ->orderBy('c.createdAt', 'DESC'); if ($search !== '') { $qb->andWhere('d.name LIKE :s OR c.body LIKE :s OR u.realName LIKE :s') ->setParameter('s', '%' . $search . '%'); } if ($status !== '') { $qb->andWhere('c.status = :status')->setParameter('status', $status); } $total = (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); $items = array_map(fn(array $c) => [ 'uuid' => $c['uuid'], 'patient_name' => $c['patient_name'] ?? '', 'doctor_name' => $c['doctor_name'], 'title' => mb_substr($c['body'], 0, 50), 'body' => $c['body'], 'is_approved' => $c['status'] === 'approved', 'status' => $c['status'], 'created_at' => date('c', (int) $c['createdAt']), ], $rows); return $this->paginated($items, (int) $total, $page, $limit); } // ── SMS Logs ────────────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/sms/logs', summary: 'List SMS send logs (paginated)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of SMS logs', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'recipient', type: 'string'), new OA\Property(property: 'message', type: 'string'), new OA\Property(property: 'status', type: 'string', enum: ['sent', 'failed']), new OA\Property(property: 'provider', type: 'string'), new OA\Property(property: 'sent_at', type: 'string', format: 'date-time'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/sms/logs', methods: ['GET'])] public function smsLogs(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 15))); $tag = trim((string) $request->query->get('tag', '')); $qb = $this->em->createQueryBuilder() ->select('s.uuid, s.mobile, s.message, s.provider, s.success, s.tag, s.createdAt') ->from(SmsLog::class, 's') ->orderBy('s.createdAt', 'DESC'); if ($tag !== '') { $qb->andWhere('s.tag = :tag')->setParameter('tag', $tag); } $total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); $items = array_map(fn(array $l) => [ 'uuid' => $l['uuid'], 'recipient' => $l['mobile'], 'message' => $l['message'], 'status' => $l['success'] ? 'sent' : 'failed', 'provider' => $l['provider'], 'tag' => $l['tag'], 'sent_at' => date('c', (int) $l['createdAt']), 'created_at' => date('c', (int) $l['createdAt']), ], $rows); return $this->paginated($items, (int) $total, $page, $limit); } // ── Settlements ─────────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/settlements', summary: 'List all settlement requests (paginated)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'approved', 'rejected'])), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of settlement requests', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'representation_name', type: 'string'), new OA\Property(property: 'amount', type: 'integer'), new OA\Property(property: 'status', type: 'string'), new OA\Property(property: 'bank_card', type: 'string', nullable: true), new OA\Property(property: 'bank_name', type: 'string', nullable: true), new OA\Property(property: 'reject_reason', type: 'string', nullable: true), new OA\Property(property: 'requested_at', type: 'string', format: 'date-time'), new OA\Property(property: 'processed_at', type: 'string', format: 'date-time', nullable: true), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/settlements', methods: ['GET'])] public function settlements(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 15))); $status = trim((string) $request->query->get('status', '')); $qb = $this->em->createQueryBuilder() ->select( 's.uuid, s.amountRials, s.status, s.bankAccount, s.adminNote, s.reviewedAt, s.createdAt', 'u.realName as user_name, u.mobileNumber as user_mobile', ) ->from(Settlement::class, 's') ->join('s.user', 'u') ->orderBy('s.createdAt', 'DESC'); if ($status !== '') { $qb->andWhere('s.status = :status')->setParameter('status', $status); } $total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); $items = array_map(fn(array $s) => [ 'uuid' => $s['uuid'], 'representation_name' => $s['user_name'] ?? $s['user_mobile'], 'amount' => (int) $s['amountRials'], 'status' => $s['status'], 'bank_card' => $s['bankAccount']['card'] ?? null, 'bank_name' => $s['bankAccount']['bank_name'] ?? null, 'reject_reason' => $s['adminNote'], 'requested_at' => date('c', (int) $s['createdAt']), 'processed_at' => $s['reviewedAt'] ? date('c', (int) $s['reviewedAt']) : null, ], $rows); return $this->paginated($items, (int) $total, $page, $limit); } // ── SMS Templates (paginated list with optional status filter) ──────────── #[OA\Get( path: '/api/v1/admin/sms/sample-templates', summary: 'List SMS sample templates (paginated)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['approved', 'pending'], default: 'approved')), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of SMS templates', 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( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'name', type: 'string'), new OA\Property(property: 'body', type: 'string'), new OA\Property(property: 'provider_code', type: 'string', nullable: true), new OA\Property(property: 'status', type: 'string'), new OA\Property(property: 'admin_note', type: 'string', nullable: true), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/sms/sample-templates', methods: ['GET'])] public function smsSampleTemplates(Request $request): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 15))); $status = trim((string) $request->query->get('status', 'approved')); $qb = $this->em->createQueryBuilder() ->select('t.uuid, t.name, t.body, t.providerCode, t.status, t.adminNote, t.createdAt') ->from(SmsTemplate::class, 't') ->orderBy('t.createdAt', 'DESC'); if ($status !== '') { $qb->where('t.status = :status')->setParameter('status', $status); } $total = (clone $qb)->select('COUNT(t.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); $items = array_map(fn(array $t) => [ 'uuid' => $t['uuid'], 'name' => $t['name'], 'body' => $t['body'], 'provider_code' => $t['providerCode'], 'status' => $t['status'], 'admin_note' => $t['adminNote'], 'created_at' => date('c', (int) $t['createdAt']), ], $rows); return $this->paginated($items, (int) $total, $page, $limit); } // ── Dashboard Recent ────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/dashboard/recent', summary: 'Get recent dashboard data (appointments, payments, users)', security: [['bearerAuth' => []]], responses: [ new OA\Response( response: 200, description: 'Recent dashboard data', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', properties: [ new OA\Property(property: 'appointments', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'slot_start', type: 'string', format: 'date-time'), new OA\Property(property: 'status', type: 'string'), new OA\Property(property: 'doctor_name', type: 'string'), new OA\Property(property: 'user_mobile', type: 'string'), new OA\Property(property: 'user_name', type: 'string'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'payments', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'amount', type: 'integer'), new OA\Property(property: 'status', type: 'string'), new OA\Property(property: 'gateway', type: 'string'), new OA\Property(property: 'user_mobile', type: 'string'), new OA\Property(property: 'user_name', type: 'string'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), new OA\Property(property: 'users', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'mobile', type: 'string'), new OA\Property(property: 'name', type: 'string'), new OA\Property(property: 'email', type: 'string', nullable: true), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ] )), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/dashboard/recent', methods: ['GET'])] public function dashboardRecent(): JsonResponse { $em = $this->em; $recentAppointments = $em->createQueryBuilder() ->select( 'a.uuid, a.slotStart, a.status, a.createdAt', 'd.name as doctor_name', 'u.mobileNumber as user_mobile, u.realName as user_name' ) ->from(Appointment::class, 'a') ->join('a.doctor', 'd') ->join('a.user', 'u') ->orderBy('a.createdAt', 'DESC') ->setMaxResults(6) ->getQuery()->getArrayResult(); $recentPayments = $em->createQueryBuilder() ->select( 'p.uuid, p.amountRials, p.status, p.gateway, p.createdAt', 'u.mobileNumber as user_mobile, u.realName as user_name' ) ->from(Payment::class, 'p') ->join('p.user', 'u') ->orderBy('p.createdAt', 'DESC') ->setMaxResults(6) ->getQuery()->getArrayResult(); $recentUsers = $em->createQueryBuilder() ->select('u.uuid, u.mobileNumber, u.realName, u.email, u.createdAt') ->from(User::class, 'u') ->orderBy('u.createdAt', 'DESC') ->setMaxResults(6) ->getQuery()->getArrayResult(); return $this->success([ 'appointments' => array_map(fn(array $a) => [ 'uuid' => $a['uuid'], 'slot_start' => date('c', (int) $a['slotStart']), 'status' => $a['status'], 'doctor_name' => $a['doctor_name'], 'user_mobile' => $a['user_mobile'], 'user_name' => $a['user_name'], 'created_at' => date('c', (int) $a['createdAt']), ], $recentAppointments), 'payments' => array_map(fn(array $p) => [ 'uuid' => $p['uuid'], 'amount' => (int) $p['amountRials'], 'status' => $p['status'], 'gateway' => $p['gateway'], 'user_mobile' => $p['user_mobile'], 'user_name' => $p['user_name'], 'created_at' => date('c', (int) $p['createdAt']), ], $recentPayments), 'users' => array_map(fn(array $u) => [ 'uuid' => $u['uuid'], 'mobile' => $u['mobileNumber'], 'name' => $u['realName'], 'email' => $u['email'], 'created_at' => date('c', (int) $u['createdAt']), ], $recentUsers), ]); } // ── Dashboard Stats ─────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/admin/dashboard/stats', summary: 'Get aggregated dashboard statistics', security: [['bearerAuth' => []]], responses: [ new OA\Response( response: 200, description: 'Dashboard statistics', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', properties: [ new OA\Property(property: 'total_users', type: 'integer'), new OA\Property(property: 'active_doctors', type: 'integer'), new OA\Property(property: 'total_doctors', type: 'integer'), new OA\Property(property: 'total_clinics', type: 'integer'), new OA\Property(property: 'today_appointments', type: 'integer'), new OA\Property(property: 'total_appointments', type: 'integer'), new OA\Property(property: 'today_payments_count', type: 'integer'), new OA\Property(property: 'today_payments_amount', type: 'integer'), new OA\Property(property: 'total_payments_amount', type: 'integer'), new OA\Property(property: 'pending_comments', type: 'integer'), new OA\Property(property: 'pending_settlements', type: 'integer'), ], type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), ] )] #[Route('/api/v1/admin/dashboard/stats', methods: ['GET'])] public function dashboardStats(): JsonResponse { $em = $this->em; $todayStart = mktime(0, 0, 0); $todayEnd = mktime(23, 59, 59); $totalUsers = (int) $em->createQueryBuilder() ->select('COUNT(u.id)')->from(User::class, 'u') ->getQuery()->getSingleScalarResult(); $activeDoctors = (int) $em->createQueryBuilder() ->select('COUNT(d.id)')->from(Doctor::class, 'd') ->where('d.activeDoctorAppointment = true') ->getQuery()->getSingleScalarResult(); $totalDoctors = (int) $em->createQueryBuilder() ->select('COUNT(d.id)')->from(Doctor::class, 'd') ->getQuery()->getSingleScalarResult(); $totalClinics = (int) $em->createQueryBuilder() ->select('COUNT(c.id)')->from(Clinic::class, 'c') ->getQuery()->getSingleScalarResult(); $todayAppointments = (int) $em->createQueryBuilder() ->select('COUNT(a.id)')->from(Appointment::class, 'a') ->where('a.slotStart BETWEEN :s AND :e') ->setParameter('s', $todayStart)->setParameter('e', $todayEnd) ->getQuery()->getSingleScalarResult(); $totalAppointments = (int) $em->createQueryBuilder() ->select('COUNT(a.id)')->from(Appointment::class, 'a') ->getQuery()->getSingleScalarResult(); $todayPaymentsResult = $em->createQueryBuilder() ->select('COUNT(p.id) as cnt, COALESCE(SUM(p.amountRials), 0) as total') ->from(Payment::class, 'p') ->where('p.status = :s AND p.createdAt BETWEEN :ts AND :te') ->setParameter('s', Payment::STATUS_SUCCESS) ->setParameter('ts', $todayStart)->setParameter('te', $todayEnd) ->getQuery()->getSingleResult(); $totalPaymentsResult = $em->createQueryBuilder() ->select('COUNT(p.id) as cnt, COALESCE(SUM(p.amountRials), 0) as total') ->from(Payment::class, 'p') ->where('p.status = :s') ->setParameter('s', Payment::STATUS_SUCCESS) ->getQuery()->getSingleResult(); $pendingComments = (int) $em->createQueryBuilder() ->select('COUNT(c.id)')->from(Comment::class, 'c') ->where('c.status = :s')->setParameter('s', Comment::STATUS_PENDING) ->getQuery()->getSingleScalarResult(); $pendingSettlements = (int) $em->createQueryBuilder() ->select('COUNT(s.id)')->from(Settlement::class, 's') ->where('s.status = :s')->setParameter('s', Settlement::STATUS_PENDING) ->getQuery()->getSingleScalarResult(); $monthStart = mktime(0, 0, 0, (int) date('n'), 1); $monthEnd = time(); $thisMonthRevenue = (int) $em->createQueryBuilder() ->select('COALESCE(SUM(p.amountRials), 0)') ->from(Payment::class, 'p') ->where('p.status = :s AND p.createdAt BETWEEN :ts AND :te') ->setParameter('s', Payment::STATUS_SUCCESS) ->setParameter('ts', $monthStart)->setParameter('te', $monthEnd) ->getQuery()->getSingleScalarResult(); $thisMonthAppointments = (int) $em->createQueryBuilder() ->select('COUNT(a.id)') ->from(Appointment::class, 'a') ->where('a.createdAt BETWEEN :s AND :e') ->setParameter('s', $monthStart)->setParameter('e', $monthEnd) ->getQuery()->getSingleScalarResult(); return $this->success([ 'total_users' => $totalUsers, 'active_doctors' => $activeDoctors, 'total_doctors' => $totalDoctors, 'total_clinics' => $totalClinics, 'today_appointments' => $todayAppointments, 'total_appointments' => $totalAppointments, 'today_payments_count' => (int) $todayPaymentsResult['cnt'], 'today_payments_amount' => (int) $todayPaymentsResult['total'], 'total_payments_amount' => (int) $totalPaymentsResult['total'], 'pending_comments' => $pendingComments, 'pending_settlements' => $pendingSettlements, 'this_month_revenue' => $thisMonthRevenue, 'this_month_appointments' => $thisMonthAppointments, ]); } // ── Dashboard Charts ────────────────────────────────────────────────────── #[Route('/api/v1/admin/dashboard/charts', methods: ['GET'])] public function dashboardCharts(Request $request): JsonResponse { $conn = $this->em->getConnection(); $now = time(); $from = $request->query->get('from') ? (int) $request->query->get('from') : $now - (30 * 86400); $to = $request->query->get('to') ? (int) $request->query->get('to') : $now; $apptRows = $conn->fetchAllAssociative( 'SELECT DATE(FROM_UNIXTIME(slot_start)) AS d, COUNT(*) AS cnt FROM appointments WHERE slot_start BETWEEN :s AND :e GROUP BY d ORDER BY d', ['s' => $from, 'e' => $to] ); $apptMap = array_column($apptRows, 'cnt', 'd'); $revRows = $conn->fetchAllAssociative( 'SELECT DATE(FROM_UNIXTIME(created_at)) AS d, COALESCE(SUM(amount_rials), 0) AS total FROM payments WHERE status = :st AND created_at BETWEEN :s AND :e GROUP BY d ORDER BY d', ['st' => 'received', 's' => $from, 'e' => $to] ); $revMap = array_column($revRows, 'total', 'd'); $days = max(1, (int) ceil(($to - $from) / 86400)); $apptByDay = []; $revByDay = []; for ($i = 0; $i < $days; $i++) { $ts = $from + $i * 86400; $date = date('Y-m-d', $ts); $shortDate = date('m/d', $ts); $apptByDay[] = ['date' => $shortDate, 'count' => (int)($apptMap[$date] ?? 0)]; $revByDay[] = ['date' => $shortDate, 'amount' => (int)($revMap[$date] ?? 0)]; } $statusRows = $conn->fetchAllAssociative( 'SELECT status, COUNT(*) AS cnt FROM appointments GROUP BY status ORDER BY cnt DESC' ); $specRows = $conn->fetchAllAssociative( 'SELECT s.name, COUNT(a.id) AS cnt FROM appointments a JOIN doctor_specialties ds ON ds.doctor_id = a.doctor_id JOIN specialties s ON s.id = ds.specialty_id GROUP BY s.id, s.name ORDER BY cnt DESC LIMIT 8' ); $subRows = $conn->fetchAllAssociative( 'SELECT sp.name AS plan_name, COUNT(cs.id) AS cnt, COALESCE(SUM(p.amount_rials), 0) AS revenue FROM clinic_subscriptions cs JOIN subscription_plans sp ON sp.id = cs.plan_id LEFT JOIN payments p ON p.id = cs.payment_id AND p.status = :st WHERE cs.created_at BETWEEN :s AND :e GROUP BY sp.id, sp.name ORDER BY cnt DESC', ['st' => 'received', 's' => $from, 'e' => $to] ); return $this->success([ 'appointments_by_day' => $apptByDay, 'revenue_by_day' => $revByDay, 'appointment_status' => array_map(fn($r) => ['status' => $r['status'], 'count' => (int) $r['cnt']], $statusRows), 'top_specialties' => array_map(fn($r) => ['name' => $r['name'], 'count' => (int) $r['cnt']], $specRows), 'subscription_sales_by_plan' => array_map(fn($r) => [ 'plan' => $r['plan_name'], 'count' => (int) $r['cnt'], 'revenue' => (int) $r['revenue'], ], $subRows), 'period' => ['from' => $from, 'to' => $to], ]); } }