Files
clinicpro/src/Admin/Controller/AdminApiController.php
T
hamedandClaude Opus 4.8 af881231d0 fix: store appointment address from schedule and auto-add patient to clinic
Appointments now persist address_id resolved from the weekly-schedule
session (location_id) across all booking paths (online, secretary, admin).
On confirm, the patient is added to the clinic owning that address, or to
the doctor's single clinic as fallback. Weekly-schedule create/update now
requires location_id on every active session. PatientSession exposes
doctor_uuid/doctor_name so clinic records show which doctor each visit is for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:10:36 +03:30

1783 lines
87 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Admin\Controller;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Shared\Service\InputValidator;
use App\Location\Entity\City;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Payment\Entity\Payment;
use App\Rating\Entity\Comment;
use App\Rating\Entity\Rate;
use App\Representation\Entity\Representation;
use App\Secretary\Entity\DoctorSecretary;
use App\Settlement\Entity\Settlement;
use App\Sms\Entity\SmsLog;
use App\Sms\Entity\SmsTemplate;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Admin')]
#[IsGranted('ROLE_ADMIN')]
class AdminApiController extends BaseController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator,
) {}
// ── Users ─────────────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/users',
summary: 'List all users (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 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],
]);
}
}