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:
@@ -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()];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user