feat(api): add dashboard endpoints for clinic, doctor, and secretary roles
- Implemented GET /api/v1/dashboard/clinic to return clinic stats and today's schedule for clinic owners. - Implemented GET /api/v1/dashboard/doctor to return doctor's stats and today's schedule for doctors. - Implemented GET /api/v1/dashboard/secretary to return stats and conditional appointments for secretaries. feat(migrations): create user_active_context and mobile_verification_otp tables - Added migration to create user_active_context table for tracking active user sessions. - Added migration to create mobile_verification_otp table for handling mobile number verification. feat(migrations): create site_config table for application settings - Added migration to create site_config table to store various site configuration settings. feat(appointments): create MyAppointmentsController for user-specific appointments - Added MyAppointmentsController to handle fetching user-specific appointments with pagination and filtering. feat(auth): implement NotificationMobileController for mobile number verification - Added NotificationMobileController to handle OTP requests and verification for mobile number changes. feat(auth): create MobileVerificationOtp entity for OTP management - Created MobileVerificationOtp entity to manage OTP records for mobile verification. feat(auth): create UserActiveContext entity for user session management - Created UserActiveContext entity to manage user active sessions. feat(config): implement SiteConfigController for managing site settings - Added SiteConfigController to handle fetching and updating site configuration settings. feat(config): create SiteConfig entity and repository for configuration management - Created SiteConfig entity and repository to manage site configuration data.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Controller;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class MyAppointmentsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/my/appointments', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function myAppointments(Request $request, #[CurrentUser] User $user): 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', ''));
|
||||
$date = trim((string) $request->query->get('date', ''));
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
|
||||
'd.uuid as doctor_uuid, d.name as doctor_name',
|
||||
'u.mobileNumber as patient_mobile, u.realName as patient_name',
|
||||
'c.name as clinic_name'
|
||||
)
|
||||
->from(Appointment::class, 'a')
|
||||
->join('a.doctor', 'd')
|
||||
->join('a.user', 'u')
|
||||
->leftJoin('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->orderBy('a.slotStart', 'DESC');
|
||||
|
||||
$roles = $user->getRoles();
|
||||
|
||||
if (in_array('ROLE_ADMIN', $roles, true)) {
|
||||
// Admin voit tout
|
||||
} elseif (in_array('ROLE_CLINIC', $roles, true)) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic === null) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$qb->andWhere(':clinic MEMBER OF d.clinics')
|
||||
->setParameter('clinic', $clinic);
|
||||
} elseif (in_array('ROLE_DOCTOR', $roles, true)) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$qb->andWhere('a.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor);
|
||||
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretary($user);
|
||||
if ($rel === null) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
if (!$canView) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$qb->andWhere('a.doctor = :doctor')
|
||||
->setParameter('doctor', $rel->getDoctor());
|
||||
} else {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
|
||||
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 !== '') {
|
||||
$dayStart = strtotime($date . ' 00:00:00');
|
||||
$dayEnd = strtotime($date . ' 23:59:59');
|
||||
if ($dayStart && $dayEnd) {
|
||||
$qb->andWhere('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd')
|
||||
->setParameter('dayStart', $dayStart)
|
||||
->setParameter('dayEnd', $dayEnd);
|
||||
}
|
||||
}
|
||||
|
||||
$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_name' => $a['doctor_name'],
|
||||
'clinic_name' => $a['clinic_name'] ?? null,
|
||||
'appointment_date' => date('Y-m-d', (int) $a['slotStart']),
|
||||
'appointment_time' => date('H:i', (int) $a['slotStart']),
|
||||
'slot_start' => (int) $a['slotStart'],
|
||||
'status' => $a['status'],
|
||||
'amount' => 0,
|
||||
'created_at' => date('c', (int) $a['createdAt']),
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,14 @@
|
||||
namespace App\Auth\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Auth\Service\OtpService;
|
||||
use App\Auth\Service\TokenService;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -14,15 +19,20 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Auth')]
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly OtpService $otpService,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly RateLimiterFactory $sendCodeLimiter,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly OtpService $otpService,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly RateLimiterFactory $sendCodeLimiter,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -459,16 +469,161 @@ class AuthController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401);
|
||||
}
|
||||
|
||||
$primaryRole = $this->resolvePrimaryRole($user);
|
||||
$availableContexts = $this->buildAvailableContexts($user);
|
||||
|
||||
// اگر یک context داری، خودکار فعال کن
|
||||
$activeCtx = $this->contextRepo->findByUser($user);
|
||||
if ($activeCtx === null && count($availableContexts) === 1) {
|
||||
$activeCtx = $this->contextRepo->upsert($user, $availableContexts[0]['db_uuid']);
|
||||
}
|
||||
|
||||
$dbUuid = $activeCtx?->getDbUuid();
|
||||
$dbKey = $dbUuid !== null ? $this->buildDbKey($dbUuid) : null;
|
||||
$context = $dbUuid !== null ? $this->findContextByDbUuid($dbUuid, $availableContexts) : null;
|
||||
|
||||
return $this->success([
|
||||
'id' => $user->getId(),
|
||||
'uuid' => $user->getUuid(),
|
||||
'mobile_number' => $user->getMobileNumber(),
|
||||
'realName' => $user->getRealName(),
|
||||
'status' => $user->getStatus(),
|
||||
'roles' => $user->getRoles(),
|
||||
'id' => $user->getId(),
|
||||
'uuid' => $user->getUuid(),
|
||||
'mobile_number' => $user->getMobileNumber(),
|
||||
'realName' => $user->getRealName(),
|
||||
'status' => $user->getStatus(),
|
||||
'roles' => $user->getRoles(),
|
||||
'primary_role' => $primaryRole,
|
||||
'db_uuid' => $dbUuid,
|
||||
'db_key' => $dbKey,
|
||||
'context' => $context,
|
||||
'available_contexts' => $availableContexts,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/v1/auth/switch-context',
|
||||
summary: 'تغییر محیط کاری فعال',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['db_uuid'],
|
||||
properties: [
|
||||
new OA\Property(property: 'db_uuid', type: 'string', format: 'uuid', description: 'UUID محیط کاری انتخابشده از لیست available_contexts'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Context تغییر کرد'),
|
||||
new OA\Response(response: 401, description: 'توکن وجود ندارد'),
|
||||
new OA\Response(response: 403, description: 'db_uuid در لیست context های این کاربر نیست'),
|
||||
new OA\Response(response: 422, description: 'db_uuid ارسال نشده'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/auth/switch-context', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function switchContext(Request $request, #[CurrentUser] ?User $user): JsonResponse
|
||||
{
|
||||
if ($user === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$dbUuid = trim($data['db_uuid'] ?? '');
|
||||
|
||||
if ($dbUuid === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'db_uuid الزامی است', 422);
|
||||
}
|
||||
|
||||
$availableContexts = $this->buildAvailableContexts($user);
|
||||
$matched = $this->findContextByDbUuid($dbUuid, $availableContexts);
|
||||
|
||||
if ($matched === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی به این محیط کاری مجاز نیست', 403);
|
||||
}
|
||||
|
||||
$this->contextRepo->upsert($user, $dbUuid);
|
||||
|
||||
return $this->success([
|
||||
'db_uuid' => $dbUuid,
|
||||
'db_key' => $this->buildDbKey($dbUuid),
|
||||
'context' => $matched,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function resolvePrimaryRole(User $user): string
|
||||
{
|
||||
$roles = $user->getRoles();
|
||||
if (in_array('ROLE_ADMIN', $roles, true)) return 'admin';
|
||||
if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic';
|
||||
if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor';
|
||||
if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
|
||||
return 'user';
|
||||
}
|
||||
|
||||
private function buildAvailableContexts(User $user): array
|
||||
{
|
||||
$contexts = [];
|
||||
|
||||
// دکتر: مطب شخصی + کلینیکهای عضو
|
||||
if ($doctor = $this->doctorRepo->findByUser($user)) {
|
||||
$contexts[] = [
|
||||
'type' => 'doctor',
|
||||
'db_uuid' => $doctor->getUuid(),
|
||||
'name' => 'مطب شخصی ' . $doctor->getName(),
|
||||
'role' => 'doctor',
|
||||
];
|
||||
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
|
||||
$contexts[] = [
|
||||
'type' => 'clinic',
|
||||
'db_uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName() ?? '',
|
||||
'role' => 'doctor',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// صاحب کلینیک (اگر قبلاً اضافه نشده)
|
||||
if ($clinic = $this->clinicRepo->findByUser($user)) {
|
||||
$alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinic->getUuid());
|
||||
if (empty($alreadyAdded)) {
|
||||
$contexts[] = [
|
||||
'type' => 'clinic',
|
||||
'db_uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName() ?? '',
|
||||
'role' => 'clinic',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// منشی: همه روابط فعال
|
||||
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
||||
$contexts[] = [
|
||||
'type' => 'doctor',
|
||||
'db_uuid' => $rel->getDoctor()->getUuid(),
|
||||
'name' => 'مطب ' . $rel->getDoctor()->getName(),
|
||||
'role' => 'secretary',
|
||||
'permissions' => $rel->getPermissions(),
|
||||
];
|
||||
}
|
||||
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
private function findContextByDbUuid(string $dbUuid, array $contexts): ?array
|
||||
{
|
||||
foreach ($contexts as $ctx) {
|
||||
if ($ctx['db_uuid'] === $dbUuid) {
|
||||
return $ctx;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function buildDbKey(string $dbUuid): string
|
||||
{
|
||||
return hash_hmac('sha256', $dbUuid, $this->getParameter('kernel.secret'));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/oauth/logout',
|
||||
summary: 'Logout and optionally revoke refresh token',
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Controller;
|
||||
|
||||
use App\Auth\Entity\MobileVerificationOtp;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Service\SmsService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class NotificationMobileController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly SmsService $smsService,
|
||||
) {}
|
||||
|
||||
// ── Request OTP ───────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/notification-mobile/request-otp', methods: ['POST'])]
|
||||
public function requestOtp(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$target = trim((string) ($data['target'] ?? ''));
|
||||
$mobile = trim((string) ($data['new_mobile'] ?? ''));
|
||||
|
||||
if (!in_array($target, ['doctor', 'clinic'], true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'target باید doctor یا clinic باشد', 422);
|
||||
}
|
||||
if (!preg_match('/^09\d{9}$/', $mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل معتبر نیست (فرمت: 09XXXXXXXXX)', 422);
|
||||
}
|
||||
|
||||
[$entity, $entityId] = $this->resolveEntity($target, $user);
|
||||
if ($entity === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروفایل یافت نشد', 404);
|
||||
}
|
||||
|
||||
// حذف OTP های قبلی
|
||||
$this->em->createQuery('DELETE FROM App\Auth\Entity\MobileVerificationOtp o WHERE o.entityType = :t AND o.entityId = :id')
|
||||
->setParameter('t', $target)
|
||||
->setParameter('id', $entityId)
|
||||
->execute();
|
||||
|
||||
$otp = new MobileVerificationOtp($target, $entityId, $mobile);
|
||||
$this->em->persist($otp);
|
||||
$this->em->flush();
|
||||
|
||||
// ارسال SMS
|
||||
$this->smsService->dispatchAsync(
|
||||
$mobile,
|
||||
"کد تأیید شماره اعلان شما: {$otp->getOtpCode()}\nاعتبار: ۵ دقیقه"
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'message' => 'کد تأیید ارسال شد',
|
||||
'expires_in' => 300,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Verify OTP ────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/notification-mobile/verify', methods: ['POST'])]
|
||||
public function verify(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$target = trim((string) ($data['target'] ?? ''));
|
||||
$otpInput = trim((string) ($data['otp_code'] ?? ''));
|
||||
|
||||
if (!in_array($target, ['doctor', 'clinic'], true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'target باید doctor یا clinic باشد', 422);
|
||||
}
|
||||
|
||||
[$entity, $entityId] = $this->resolveEntity($target, $user);
|
||||
if ($entity === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروفایل یافت نشد', 404);
|
||||
}
|
||||
|
||||
/** @var MobileVerificationOtp|null $otp */
|
||||
$otp = $this->em->createQuery(
|
||||
'SELECT o FROM App\Auth\Entity\MobileVerificationOtp o
|
||||
WHERE o.entityType = :t AND o.entityId = :id AND o.isUsed = false
|
||||
ORDER BY o.createdAt DESC'
|
||||
)->setParameter('t', $target)
|
||||
->setParameter('id', $entityId)
|
||||
->setMaxResults(1)
|
||||
->getOneOrNullResult();
|
||||
|
||||
if ($otp === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست OTP یافت نشد. ابتدا کد را درخواست دهید', 404);
|
||||
}
|
||||
if ($otp->isExpired()) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد تأیید منقضی شده. مجدداً درخواست دهید', 422);
|
||||
}
|
||||
if ($otp->getOtpCode() !== $otpInput) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد تأیید اشتباه است', 422);
|
||||
}
|
||||
|
||||
$otp->markUsed();
|
||||
$entity->setNotificationMobile($otp->getNewMobile());
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success([
|
||||
'notification_mobile' => $otp->getNewMobile(),
|
||||
'message' => 'شماره اعلان با موفقیت ذخیره شد',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── GET current notification mobile ──────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/notification-mobile/{target}', methods: ['GET'], requirements: ['target' => 'doctor|clinic'])]
|
||||
public function getCurrent(string $target, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entity] = $this->resolveEntity($target, $user);
|
||||
if ($entity === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروفایل یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'notification_mobile' => $entity->getNotificationMobile(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── REMOVE notification mobile ────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/notification-mobile/{target}', methods: ['DELETE'], requirements: ['target' => 'doctor|clinic'])]
|
||||
public function remove(string $target, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entity] = $this->resolveEntity($target, $user);
|
||||
if ($entity === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروفایل یافت نشد', 404);
|
||||
}
|
||||
|
||||
$entity->setNotificationMobile(null);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['message' => 'شماره اعلان حذف شد']);
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
private function resolveEntity(string $target, User $user): array
|
||||
{
|
||||
if ($target === 'doctor') {
|
||||
$entity = $this->doctorRepo->findByUser($user);
|
||||
return [$entity, $entity?->getId()];
|
||||
}
|
||||
$entity = $this->clinicRepo->findByUser($user);
|
||||
return [$entity, $entity?->getId()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'mobile_verification_otp')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_otp_entity')]
|
||||
class MobileVerificationOtp
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 20)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(name: 'new_mobile', type: 'string', length: 15)]
|
||||
private string $newMobile;
|
||||
|
||||
#[ORM\Column(name: 'otp_code', type: 'string', length: 6)]
|
||||
private string $otpCode;
|
||||
|
||||
#[ORM\Column(name: 'expires_at', type: 'integer')]
|
||||
private int $expiresAt;
|
||||
|
||||
#[ORM\Column(name: 'is_used', type: 'boolean')]
|
||||
private bool $isUsed = false;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId, string $newMobile)
|
||||
{
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->newMobile = $newMobile;
|
||||
$this->otpCode = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$this->expiresAt = time() + 300; // 5 minutes
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getNewMobile(): string { return $this->newMobile; }
|
||||
public function getOtpCode(): string { return $this->otpCode; }
|
||||
public function isExpired(): bool { return time() > $this->expiresAt; }
|
||||
public function isUsed(): bool { return $this->isUsed; }
|
||||
|
||||
public function markUsed(): void { $this->isUsed = true; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'user_active_context')]
|
||||
class UserActiveContext
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\OneToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(name: 'db_uuid', type: 'string', length: 36)]
|
||||
private string $dbUuid;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $user, string $dbUuid)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->dbUuid = $dbUuid;
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getDbUuid(): string { return $this->dbUuid; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setDbUuid(string $dbUuid): self
|
||||
{
|
||||
$this->dbUuid = $dbUuid;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class UserActiveContextRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, UserActiveContext::class);
|
||||
}
|
||||
|
||||
public function findByUser(User $user): ?UserActiveContext
|
||||
{
|
||||
return $this->findOneBy(['user' => $user]);
|
||||
}
|
||||
|
||||
public function upsert(User $user, string $dbUuid): UserActiveContext
|
||||
{
|
||||
$ctx = $this->findByUser($user);
|
||||
if ($ctx === null) {
|
||||
$ctx = new UserActiveContext($user, $dbUuid);
|
||||
$this->getEntityManager()->persist($ctx);
|
||||
} else {
|
||||
$ctx->setDbUuid($dbUuid);
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
return $ctx;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,9 @@ class Clinic
|
||||
#[ORM\Column(name: 'clinic_logo', type: 'string', length: 500, nullable: true)]
|
||||
private ?string $clinicLogo = null;
|
||||
|
||||
#[ORM\Column(name: 'notification_mobile', type: 'string', length: 15, nullable: true)]
|
||||
private ?string $notificationMobile = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -139,8 +142,9 @@ class Clinic
|
||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||
public function isActive(): bool { return $this->isActive; }
|
||||
public function getImagesClinic(): ?array { return $this->imagesClinic; }
|
||||
public function getClinicLogo(): ?string { return $this->clinicLogo; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getClinicLogo(): ?string { return $this->clinicLogo; }
|
||||
public function getNotificationMobile(): ?string { return $this->notificationMobile; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
public function getDoctors(): Collection { return $this->doctors; }
|
||||
public function getSpecialties(): Collection { return $this->specialties; }
|
||||
@@ -160,7 +164,8 @@ class Clinic
|
||||
public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; }
|
||||
public function setIsActive(bool $v): self { $this->isActive = $v; $this->touch(); return $this; }
|
||||
public function setImagesClinic(?array $v): self { $this->imagesClinic = $v; $this->touch(); return $this; }
|
||||
public function setClinicLogo(?string $v): self { $this->clinicLogo = $v; $this->touch(); return $this; }
|
||||
public function setClinicLogo(?string $v): self { $this->clinicLogo = $v; $this->touch(); return $this; }
|
||||
public function setNotificationMobile(?string $v): self { $this->notificationMobile = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config\Controller;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class SiteConfigController extends BaseController
|
||||
{
|
||||
private const ALLOWED_KEYS = [
|
||||
'commission_enabled',
|
||||
'commission_percent',
|
||||
'site_name',
|
||||
'support_phone',
|
||||
'max_cancel_hours_before',
|
||||
'appointment_reminder_hours',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/admin/settings', methods: ['GET'])]
|
||||
public function get(): JsonResponse
|
||||
{
|
||||
return $this->success($this->configRepo->getAll());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/settings', methods: ['PATCH'])]
|
||||
public function patch(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (!in_array($key, self::ALLOWED_KEYS, true)) {
|
||||
continue;
|
||||
}
|
||||
$this->configRepo->set($key, $value === null ? null : (string) $value);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($this->configRepo->getAll());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'site_config')]
|
||||
class SiteConfig
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(name: 'config_key', type: 'string', length: 100)]
|
||||
private string $configKey;
|
||||
|
||||
#[ORM\Column(name: 'config_value', type: 'text', nullable: true)]
|
||||
private ?string $configValue;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $key, ?string $value = null)
|
||||
{
|
||||
$this->configKey = $key;
|
||||
$this->configValue = $value;
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getKey(): string { return $this->configKey; }
|
||||
public function getValue(): ?string { return $this->configValue; }
|
||||
|
||||
public function setValue(?string $value): self
|
||||
{
|
||||
$this->configValue = $value;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config\Repository;
|
||||
|
||||
use App\Config\Entity\SiteConfig;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SiteConfigRepository extends ServiceEntityRepository
|
||||
{
|
||||
// Default values returned when a key is missing from DB
|
||||
private const DEFAULTS = [
|
||||
'commission_enabled' => '0',
|
||||
'commission_percent' => '0',
|
||||
'site_name' => 'ClinicPro',
|
||||
'support_phone' => '',
|
||||
'max_cancel_hours_before' => '24',
|
||||
'appointment_reminder_hours' => '2',
|
||||
];
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SiteConfig::class);
|
||||
}
|
||||
|
||||
public function get(string $key): ?string
|
||||
{
|
||||
$row = $this->find($key);
|
||||
if ($row !== null) {
|
||||
return $row->getValue();
|
||||
}
|
||||
return self::DEFAULTS[$key] ?? null;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
$rows = $this->findAll();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[$row->getKey()] = $row->getValue();
|
||||
}
|
||||
// Fill missing keys with defaults
|
||||
foreach (self::DEFAULTS as $key => $default) {
|
||||
if (!isset($map[$key])) {
|
||||
$map[$key] = $default;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function set(string $key, ?string $value): void
|
||||
{
|
||||
$row = $this->find($key);
|
||||
if ($row === null) {
|
||||
$row = new SiteConfig($key, $value);
|
||||
$this->getEntityManager()->persist($row);
|
||||
} else {
|
||||
$row->setValue($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace App\Dashboard\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class DashboardController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
// ── Clinic Dashboard ────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/dashboard/clinic', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_CLINIC')]
|
||||
public function clinic(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$clinicId = $clinic->getId();
|
||||
$todayStart = strtotime('today midnight');
|
||||
$todayEnd = strtotime('tomorrow midnight') - 1;
|
||||
$monthStart = strtotime('first day of this month midnight');
|
||||
|
||||
// آمار نوبتهای امروز و این ماه
|
||||
$stats = $this->em->createQuery('
|
||||
SELECT
|
||||
COUNT(a.id) AS today_appointments,
|
||||
SUM(CASE WHEN a.slotStart >= :monthStart THEN 1 ELSE 0 END) AS this_month_appointments
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d
|
||||
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
|
||||
WHERE c.id = :clinicId
|
||||
AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd
|
||||
')->setParameters([
|
||||
'clinicId' => $clinicId,
|
||||
'todayStart' => $todayStart,
|
||||
'todayEnd' => $todayEnd,
|
||||
'monthStart' => $monthStart,
|
||||
])->getOneOrNullResult() ?? [];
|
||||
|
||||
// شمارش کل نوبتهای این ماه (query جداگانه)
|
||||
$monthCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id)
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d
|
||||
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
|
||||
WHERE c.id = :clinicId
|
||||
AND a.slotStart >= :monthStart
|
||||
')->setParameters([
|
||||
'clinicId' => $clinicId,
|
||||
'monthStart' => $monthStart,
|
||||
])->getSingleScalarResult();
|
||||
|
||||
// تعداد دعوتنامههای در انتظار
|
||||
$pendingInvitations = (int) $this->em->createQuery('
|
||||
SELECT COUNT(i.id)
|
||||
FROM App\ClinicInvitation\Entity\ClinicDoctorInvitation i
|
||||
WHERE i.clinic = :clinic AND i.status = :status
|
||||
')->setParameters([
|
||||
'clinic' => $clinic,
|
||||
'status' => 'pending',
|
||||
])->getSingleScalarResult();
|
||||
|
||||
// ۵ نوبت امروز این کلینیک
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, d.name AS doctor_name,
|
||||
a.slotStart AS slot_start, a.status
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.doctor d
|
||||
JOIN a.user u
|
||||
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
|
||||
WHERE c.id = :clinicId
|
||||
AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd
|
||||
ORDER BY a.slotStart ASC
|
||||
')->setMaxResults(5)->setParameters([
|
||||
'clinicId' => $clinicId,
|
||||
'todayStart' => $todayStart,
|
||||
'todayEnd' => $todayEnd,
|
||||
])->getArrayResult();
|
||||
|
||||
// لیست پزشکان با شمارش نوبت امروز
|
||||
$doctors = $this->em->createQuery('
|
||||
SELECT d.uuid, d.name,
|
||||
COUNT(a.id) AS today_count
|
||||
FROM App\Doctor\Entity\Doctor d
|
||||
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
|
||||
LEFT JOIN App\Appointment\Entity\Appointment a
|
||||
WITH a.doctor = d
|
||||
AND a.slotStart >= :todayStart
|
||||
AND a.slotStart <= :todayEnd
|
||||
WHERE c.id = :clinicId
|
||||
GROUP BY d.id
|
||||
')->setParameters([
|
||||
'clinicId' => $clinicId,
|
||||
'todayStart' => $todayStart,
|
||||
'todayEnd' => $todayEnd,
|
||||
])->getArrayResult();
|
||||
|
||||
return $this->success([
|
||||
'clinic' => [
|
||||
'uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName(),
|
||||
'is_active' => $clinic->isActive(),
|
||||
'logo' => $clinic->getClinicLogo(),
|
||||
],
|
||||
'stats' => [
|
||||
'total_doctors' => count($doctors),
|
||||
'today_appointments' => (int) ($stats['today_appointments'] ?? 0),
|
||||
'this_month_appointments' => $monthCount,
|
||||
'pending_invitations' => $pendingInvitations,
|
||||
],
|
||||
'today_appointments' => $todayAppts,
|
||||
'doctors' => $doctors,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Doctor Dashboard ─────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/dashboard/doctor', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_DOCTOR')]
|
||||
public function doctor(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$doctorId = $doctor->getId();
|
||||
$todayStart = strtotime('today midnight');
|
||||
$todayEnd = strtotime('tomorrow midnight') - 1;
|
||||
$tmrStart = strtotime('tomorrow midnight');
|
||||
$tmrEnd = strtotime('tomorrow midnight') + 86399;
|
||||
$monthStart = strtotime('first day of this month midnight');
|
||||
|
||||
// آمار
|
||||
$todayCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$tmrCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$monthCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s
|
||||
')->setParameters(['doctor' => $doctor, 's' => $monthStart])
|
||||
->getSingleScalarResult();
|
||||
|
||||
// میانگین و تعداد امتیاز
|
||||
$ratingRow = $this->em->createQuery('
|
||||
SELECT AVG(r.score) AS avg_score, COUNT(r.id) AS total
|
||||
FROM App\Rating\Entity\Rate r WHERE r.doctor = :doctor
|
||||
')->setParameter('doctor', $doctor)->getOneOrNullResult() ?? [];
|
||||
|
||||
// نوبتهای امروز
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
a.slotStart AS slot_start, a.status
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.user u
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
ORDER BY a.slotStart ASC
|
||||
')->setMaxResults(10)->setParameters([
|
||||
'doctor' => $doctor,
|
||||
's' => $todayStart,
|
||||
'e' => $todayEnd,
|
||||
])->getArrayResult();
|
||||
|
||||
// کلینیکهای عضو
|
||||
$clinics = $this->em->createQuery('
|
||||
SELECT c.uuid, c.name, c.clinicLogo AS logo
|
||||
FROM App\Clinic\Entity\Clinic c
|
||||
JOIN c.doctors d
|
||||
WHERE d.id = :doctorId
|
||||
')->setParameter('doctorId', $doctorId)->getArrayResult();
|
||||
|
||||
return $this->success([
|
||||
'doctor' => [
|
||||
'uuid' => $doctor->getUuid(),
|
||||
'name' => $doctor->getName(),
|
||||
'degree' => $doctor->getDegree(),
|
||||
],
|
||||
'stats' => [
|
||||
'today_appointments' => $todayCount,
|
||||
'tomorrow_appointments' => $tmrCount,
|
||||
'this_month_appointments' => $monthCount,
|
||||
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
|
||||
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
|
||||
],
|
||||
'today_appointments' => $todayAppts,
|
||||
'clinics' => $clinics,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Secretary Dashboard ──────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/dashboard/secretary', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_SECRETARY')]
|
||||
public function secretary(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rel = $this->secretaryRepo->findActiveBySecretary($user);
|
||||
if ($rel === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
$doctor = $rel->getDoctor();
|
||||
$permissions = $rel->getPermissions();
|
||||
$canView = (bool) ($permissions['resources']['appointments']['view'] ?? false);
|
||||
|
||||
$todayStart = strtotime('today midnight');
|
||||
$todayEnd = strtotime('tomorrow midnight') - 1;
|
||||
$tmrStart = strtotime('tomorrow midnight');
|
||||
$tmrEnd = strtotime('tomorrow midnight') + 86399;
|
||||
|
||||
$todayCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$tmrCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$todayAppts = [];
|
||||
if ($canView) {
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
a.slotStart AS slot_start, a.status
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.user u
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
ORDER BY a.slotStart ASC
|
||||
')->setMaxResults(10)->setParameters([
|
||||
'doctor' => $doctor,
|
||||
's' => $todayStart,
|
||||
'e' => $todayEnd,
|
||||
])->getArrayResult();
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'doctor' => [
|
||||
'uuid' => $doctor->getUuid(),
|
||||
'name' => $doctor->getName(),
|
||||
'degree' => $doctor->getDegree(),
|
||||
],
|
||||
'permissions' => $permissions,
|
||||
'stats' => [
|
||||
'today_appointments' => $todayCount,
|
||||
'tomorrow_appointments' => $tmrCount,
|
||||
],
|
||||
'today_appointments' => $todayAppts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@ class Doctor
|
||||
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $representationId = null;
|
||||
|
||||
#[ORM\Column(name: 'notification_mobile', type: 'string', length: 15, nullable: true)]
|
||||
private ?string $notificationMobile = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -138,7 +141,8 @@ class Doctor
|
||||
public function getDoctorRate(): float { return $this->doctorRate; }
|
||||
public function getDoctorRatePercentage(): float { return $this->doctorRatePercentage; }
|
||||
public function isActiveDoctorAppointment(): bool { return $this->activeDoctorAppointment; }
|
||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||
public function getNotificationMobile(): ?string { return $this->notificationMobile; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
public function getSpecialties(): Collection { return $this->specialties; }
|
||||
@@ -158,7 +162,8 @@ class Doctor
|
||||
public function setDoctorRate(float $v): self { $this->doctorRate = $v; $this->touch(); return $this; }
|
||||
public function setDoctorRatePercentage(float $v): self { $this->doctorRatePercentage = $v; $this->touch(); return $this; }
|
||||
public function setActiveDoctorAppointment(bool $v): self { $this->activeDoctorAppointment = $v; $this->touch(); return $this; }
|
||||
public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; }
|
||||
public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; }
|
||||
public function setNotificationMobile(?string $v): self { $this->notificationMobile = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Secretary\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
@@ -36,6 +37,17 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
|
||||
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
|
||||
}
|
||||
|
||||
public function findActiveBySecretary(User $user): ?DoctorSecretary
|
||||
{
|
||||
return $this->findOneBy(['secretary' => $user, 'active' => true]);
|
||||
}
|
||||
|
||||
/** @return DoctorSecretary[] */
|
||||
public function findAllActiveBySecretary(User $user): array
|
||||
{
|
||||
return $this->findBy(['secretary' => $user, 'active' => true]);
|
||||
}
|
||||
|
||||
public function save(DoctorSecretary $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
Reference in New Issue
Block a user