Files
clinicpro/src/Admin/Controller/AdminApiController.php
T
hamed ca71c49451 feat(payment): add payment detail endpoint and update payment model with order_id and patient_name
feat(appointment): enhance appointment detail page with time formatting and additional info
fix(payment): update payment query to fetch from the correct endpoint and adjust response structure
docs(api): add search parameter to payments API documentation and detail response structure
test(payment): add unit test for MellatGateway to verify null credentials handling
2026-07-02 15:10:15 +03:30

2050 lines
100 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\Shared\Constant\ErrorCodes;
use App\Appointment\Repository\SlotTakenException;
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\FinancialBreakdown;
use App\Settlement\Entity\Settlement;
use App\Shared\Logging\AppLog;
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,
private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup,
) {}
// ── 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(ErrorCodes::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(ErrorCodes::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(ErrorCodes::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(ErrorCodes::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(ErrorCodes::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(ErrorCodes::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(ErrorCodes::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(ErrorCodes::VALIDATION, 'موبایل و نام الزامی هستند', 422);
}
if (!InputValidator::isValidIranMobile($mobile)) {
return $this->error(ErrorCodes::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(ErrorCodes::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['activity_time'])) $doctor->setActivityTime((int) $data['activity_time']);
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(ErrorCodes::VALIDATION, 'شماره موبایل الزامی است', 422);
}
if (!InputValidator::isValidIranMobile($mobile)) {
return $this->error(ErrorCodes::VALIDATION, 'شماره موبایل نامعتبر است', 422, 'owner_mobile');
}
if ($name === '') {
return $this->error(ErrorCodes::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(ErrorCodes::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(ErrorCodes::CLINIC_NOT_FOUND, 'کلینیک یافت نشد', 404);
$this->insuranceCleanup->purgeForEntity(\App\Insurance\Entity\TenantInsurance::TYPE_CLINIC, $clinic->getId());
$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(ErrorCodes::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(ErrorCodes::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);
}
$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);
try {
$this->em->getRepository(Appointment::class)->bookAtomically($appointment);
} catch (SlotTakenException) {
return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409);
}
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);
}
#[OA\Get(
path: '/api/v1/admin/payments/{uuid}',
summary: 'Payment detail by UUID',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Payment detail'),
new OA\Response(response: 404, description: 'Payment not found'),
]
)]
#[Route('/api/v1/admin/payments/{uuid}', methods: ['GET'])]
public function paymentDetail(string $uuid): JsonResponse
{
$rows = $this->em->createQueryBuilder()
->select(
'p.uuid, p.orderId, p.amountRials, p.status, p.gateway, p.type, p.referenceId, p.createdAt, p.updatedAt',
'u.mobileNumber as patient_mobile, u.realName as patient_name',
'a.uuid as appointment_uuid',
)
->from(Payment::class, 'p')
->join('p.user', 'u')
->leftJoin('p.appointment', 'a')
->where('p.uuid = :uuid')->setParameter('uuid', $uuid)
->getQuery()->getArrayResult();
if (empty($rows)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404);
}
$p = $rows[0];
return $this->success([
'uuid' => $p['uuid'],
'order_id' => $p['orderId'],
'amount' => (int) $p['amountRials'],
'status' => $p['status'],
'gateway' => $p['gateway'],
'type' => $p['type'],
'ref_id' => $p['referenceId'],
'patient_mobile' => $p['patient_mobile'],
'patient_name' => $p['patient_name'],
'appointment_uuid' => $p['appointment_uuid'],
'paid_at' => $p['status'] === 'success' ? date('c', (int) $p['updatedAt']) : null,
'created_at' => date('c', (int) $p['createdAt']),
]);
}
// ── 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);
}
// ── Financial Breakdowns ──────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/financial-breakdowns',
summary: 'لیست تفکیک مالی تراکنش‌ها (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: 'representation_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'source', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['appointment', 'subscription'])),
new OA\Parameter(name: 'from', in: 'query', required: false, schema: new OA\Schema(type: 'integer', description: 'Unix timestamp')),
new OA\Parameter(name: 'to', in: 'query', required: false, schema: new OA\Schema(type: 'integer', description: 'Unix timestamp')),
],
responses: [new OA\Response(response: 200, description: 'لیست تفکیک مالی')]
)]
#[Route('/api/v1/admin/financial-breakdowns', methods: ['GET'])]
public function financialBreakdowns(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$repId = $request->query->get('representation_id');
$source = trim((string) $request->query->get('source', ''));
$from = $request->query->get('from');
$to = $request->query->get('to');
$qb = $this->em->createQueryBuilder()
->select(
'b.uuid, b.source, b.grossRials, b.smsFeeRials, b.taxPercent, b.taxRials,
b.netAfterTaxRials, b.commissionPercent, b.representationShareRials, b.systemShareRials,
b.representationId, b.doctorId, b.clinicId, b.createdAt,
p.orderId as order_id, r.fullName as representation_name'
)
->from(FinancialBreakdown::class, 'b')
->join('b.payment', 'p')
->leftJoin(Representation::class, 'r', 'WITH', 'r.id = b.representationId')
->orderBy('b.createdAt', 'DESC');
if ($repId !== null && $repId !== '') {
$qb->andWhere('b.representationId = :repId')->setParameter('repId', (int) $repId);
}
if ($source !== '') {
$qb->andWhere('b.source = :source')->setParameter('source', $source);
}
if ($from !== null && $from !== '') {
$qb->andWhere('b.createdAt >= :from')->setParameter('from', (int) $from);
}
if ($to !== null && $to !== '') {
$qb->andWhere('b.createdAt <= :to')->setParameter('to', (int) $to);
}
$total = (clone $qb)->select('COUNT(b.uuid)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
->getQuery()->getArrayResult();
$items = array_map(fn(array $b) => [
'uuid' => $b['uuid'],
'order_id' => $b['order_id'],
'source' => $b['source'],
'gross_rials' => (int) $b['grossRials'],
'sms_fee_rials' => (int) $b['smsFeeRials'],
'tax_percent' => (float) $b['taxPercent'],
'tax_rials' => (int) $b['taxRials'],
'net_after_tax_rials' => (int) $b['netAfterTaxRials'],
'commission_percent' => (float) $b['commissionPercent'],
'representation_share_rials' => (int) $b['representationShareRials'],
'system_share_rials' => (int) $b['systemShareRials'],
'representation_id' => $b['representationId'],
'representation_name' => $b['representation_name'] ?? null,
'doctor_id' => $b['doctorId'],
'clinic_id' => $b['clinicId'],
'created_at' => date('c', (int) $b['createdAt']),
], $rows);
return $this->paginated($items, (int) $total, $page, $limit);
}
#[OA\Get(
path: '/api/v1/admin/financial-summary',
summary: 'جمعِ کلِ سهم نماینده، مالیات، هزینه پیامک و سهم سیستم',
security: [['bearerAuth' => []]],
responses: [new OA\Response(response: 200, description: 'جمع‌های مالی')]
)]
#[Route('/api/v1/admin/financial-summary', methods: ['GET'])]
public function financialSummary(): JsonResponse
{
$row = $this->em->createQueryBuilder()
->select(
'COALESCE(SUM(b.grossRials), 0) as gross,
COALESCE(SUM(b.representationShareRials), 0) as rep_income,
COALESCE(SUM(b.taxRials), 0) as tax,
COALESCE(SUM(b.smsFeeRials), 0) as sms_fee,
COALESCE(SUM(b.systemShareRials), 0) as system_share'
)
->from(FinancialBreakdown::class, 'b')
->getQuery()->getSingleResult();
return $this->success([
'total_gross' => (int) $row['gross'],
'total_representation_income' => (int) $row['rep_income'],
'total_tax_collected' => (int) $row['tax'],
'total_sms_fee' => (int) $row['sms_fee'],
'total_system_share' => (int) $row['system_share'],
]);
}
// ── 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,
'bank_iban' => $s['bankAccount']['iban'] ?? null,
'bank_owner' => $s['bankAccount']['owner_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);
}
#[OA\Get(
path: '/api/v1/admin/settlement/{uuid}',
summary: 'جزئیات یک درخواست تسویه (ادمین)',
security: [['bearerAuth' => []]],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'جزئیات تسویه'),
new OA\Response(response: 404, description: 'یافت نشد'),
]
)]
#[Route('/api/v1/admin/settlement/{uuid}', methods: ['GET'])]
public function settlementDetail(string $uuid): JsonResponse
{
$row = $this->em->createQueryBuilder()
->select(
's.uuid, s.amountRials, s.status, s.bankAccount, s.adminNote, s.receipt, s.reviewedAt, s.createdAt, s.updatedAt',
'u.realName as user_name, u.mobileNumber as user_mobile',
)
->from(Settlement::class, 's')
->join('s.user', 'u')
->where('s.uuid = :uuid')
->setParameter('uuid', $uuid)
->getQuery()->getArrayResult();
if (empty($row)) {
return $this->error(ErrorCodes::NOT_FOUND, 'درخواست تسویه یافت نشد', 404);
}
$s = $row[0];
return $this->success([
'uuid' => $s['uuid'],
'representation_name' => $s['user_name'] ?? $s['user_mobile'],
'representation_mobile' => $s['user_mobile'],
'amount' => (int) $s['amountRials'],
'status' => $s['status'],
'bank_card' => $s['bankAccount']['card'] ?? null,
'bank_name' => $s['bankAccount']['bank_name'] ?? null,
'bank_iban' => $s['bankAccount']['iban'] ?? null,
'bank_owner' => $s['bankAccount']['owner_name'] ?? null,
'reject_reason' => $s['adminNote'],
'receipt' => $s['receipt'] ?? null,
'requested_at' => date('c', (int) $s['createdAt']),
'processed_at' => $s['reviewedAt'] ? date('c', (int) $s['reviewedAt']) : null,
]);
}
// ── 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],
]);
}
// ── Application logs ────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/logs',
summary: 'List persisted application 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: 25)),
new OA\Parameter(name: 'level', in: 'query', required: false, description: 'PSR level filter (warning/error/critical/...)', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'search', in: 'query', required: false, description: 'substring match on message', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'from', in: 'query', required: false, description: 'unix timestamp lower bound', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'to', in: 'query', required: false, description: 'unix timestamp upper bound', schema: new OA\Schema(type: 'integer')),
],
responses: [new OA\Response(response: 200, description: 'Paginated list of logs')]
)]
#[Route('/api/v1/admin/logs', methods: ['GET'])]
public function logs(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(5, (int) $request->query->get('limit', 25)));
$level = trim((string) $request->query->get('level', ''));
$search = trim((string) $request->query->get('search', ''));
$from = trim((string) $request->query->get('from', ''));
$to = trim((string) $request->query->get('to', ''));
$qb = $this->em->createQueryBuilder()
->select('l.id, l.level, l.message, l.context, l.channel, l.path, l.createdAt')
->from(AppLog::class, 'l');
if ($level !== '') {
$qb->andWhere('l.level = :level')->setParameter('level', $level);
}
if ($search !== '') {
$qb->andWhere('l.message LIKE :s')->setParameter('s', '%' . $search . '%');
}
if ($from !== '') {
$qb->andWhere('l.createdAt >= :from')->setParameter('from', (int) $from);
}
if ($to !== '') {
$qb->andWhere('l.createdAt <= :to')->setParameter('to', (int) $to);
}
$qb->orderBy('l.id', 'DESC');
$total = (clone $qb)->select('COUNT(l.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getArrayResult();
return $this->paginated(array_map(fn(array $l) => [
'id' => (int) $l['id'],
'level' => $l['level'],
'message' => $l['message'],
'context' => $l['context'],
'channel' => $l['channel'],
'path' => $l['path'],
'created_at' => (int) $l['createdAt'],
], $rows), (int) $total, $page, $limit);
}
#[Route('/api/v1/admin/logs', methods: ['DELETE'])]
public function clearLogs(): JsonResponse
{
$deleted = $this->em->getRepository(AppLog::class)->deleteAll();
return $this->success(['deleted' => $deleted]);
}
}