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:
hamed
2026-06-11 12:20:12 +03:30
parent 54c491c734
commit e7b90a6399
32 changed files with 3780 additions and 354 deletions
+165 -10
View File
@@ -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()];
}
}