feat: Implement SMS sending functionality with KavehNegar and Rangineh providers
- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:cancel-expired-appointments',
|
||||
description: 'Marks pending appointments whose slot_start is in the past as expired',
|
||||
)]
|
||||
class CancelExpiredAppointmentsCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly AppointmentRepository $appointmentRepo)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$expired = $this->appointmentRepo->findExpiredPending(time());
|
||||
$count = 0;
|
||||
|
||||
foreach ($expired as $appointment) {
|
||||
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
|
||||
$this->appointmentRepo->save($appointment, false);
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ($count > 0) {
|
||||
$this->appointmentRepo->save($expired[0]); // flush once
|
||||
}
|
||||
|
||||
$output->writeln(sprintf('Expired %d appointments.', $count));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Controller;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Service\SlotCalculatorService;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
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 AppointmentController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
) {}
|
||||
|
||||
// ── Public: available slots ───────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-slots', methods: ['GET'])]
|
||||
public function slots(Request $request): JsonResponse
|
||||
{
|
||||
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
|
||||
$date = trim($request->query->get('date', ''));
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
||||
}
|
||||
|
||||
$slots = $this->slotCalculator->getAvailableSlots($doctor, $date);
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'date' => $date,
|
||||
'slots' => $slots,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Authenticated: book / manage ─────────────────────────────────────────
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointment', methods: ['POST'])]
|
||||
public function book(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($this->appointmentRepo->isSlotTaken($doctor, $slotStart, $slotEnd)) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
|
||||
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||
|
||||
$this->appointmentRepo->save($appointment);
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()], 201);
|
||||
}
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointment/{uuid}', methods: ['GET'])]
|
||||
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||
if ($appointment === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canView($appointment, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])]
|
||||
public function listByDoctor(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$status = $request->query->get('status');
|
||||
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status);
|
||||
|
||||
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
||||
}
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointments/user', methods: ['GET'])]
|
||||
public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$status = $request->query->get('status');
|
||||
$appointments = $this->appointmentRepo->findByUser($user, $status);
|
||||
|
||||
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
||||
}
|
||||
|
||||
private function canView(Appointment $a, User $user): bool
|
||||
{
|
||||
return $a->getUser()->getId() === $user->getId()
|
||||
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|
||||
|| $user->hasRole('ROLE_ADMIN');
|
||||
}
|
||||
|
||||
private function canManage(Appointment $a, User $user): bool
|
||||
{
|
||||
return $a->getUser()->getId() === $user->getId()
|
||||
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|
||||
|| $user->hasRole('ROLE_ADMIN');
|
||||
}
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])]
|
||||
public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||
if ($appointment === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManage($appointment, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$newStatus = trim($data['status'] ?? '');
|
||||
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
||||
|
||||
if (!$appointment->canTransitionTo($newStatus)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
||||
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
|
||||
), 422);
|
||||
}
|
||||
|
||||
$appointment->transitionTo($newStatus);
|
||||
|
||||
try {
|
||||
$this->appointmentRepo->saveWithLock($appointment, $version);
|
||||
} catch (OptimisticLockException) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Controller;
|
||||
|
||||
use App\Appointment\Entity\DateOverride;
|
||||
use App\Appointment\Entity\Holiday;
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Appointment\Repository\DateOverrideRepository;
|
||||
use App\Appointment\Repository\HolidayRepository;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
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 AppointmentSettingsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly DateOverrideRepository $overrideRepo,
|
||||
private readonly HolidayRepository $holidayRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
) {}
|
||||
|
||||
// ── Weekly Schedule ───────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule', methods: ['POST'])]
|
||||
public function createSchedule(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
// Only one schedule per doctor — upsert
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
if ($schedule !== null) {
|
||||
$schedule->setSetting($data['schedule'] ?? []);
|
||||
} else {
|
||||
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? []);
|
||||
}
|
||||
|
||||
$this->scheduleRepo->save($schedule);
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['PATCH'])]
|
||||
public function updateSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
// uuid may be doctor uuid or schedule uuid
|
||||
$schedule = $this->scheduleRepo->findByUuid($uuid);
|
||||
if ($schedule === null) {
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
$schedule = $doctor ? $this->scheduleRepo->findByDoctor($doctor) : null;
|
||||
}
|
||||
|
||||
if ($schedule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (isset($data['schedule'])) {
|
||||
$schedule->setSetting($data['schedule']);
|
||||
}
|
||||
|
||||
$this->scheduleRepo->save($schedule);
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['GET'])]
|
||||
public function getSchedule(string $uuid): JsonResponse
|
||||
{
|
||||
// Try doctor uuid first, then schedule uuid
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
$schedule = $doctor
|
||||
? $this->scheduleRepo->findByDoctor($doctor)
|
||||
: $this->scheduleRepo->findByUuid($uuid);
|
||||
|
||||
if ($schedule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/booking-setting/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteSchedule(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$schedule = $this->scheduleRepo->findByUuid($uuid);
|
||||
if ($schedule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$this->scheduleRepo->remove($schedule);
|
||||
|
||||
return $this->success(['message' => 'برنامه هفتگی با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
// ── Date Overrides ────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override/list/{doctorUuid}', methods: ['GET'])]
|
||||
public function listOverrides(string $doctorUuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$overrides = array_map(
|
||||
fn(DateOverride $o) => $o->toArray(),
|
||||
$this->overrideRepo->findByDoctor($doctor)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $overrides]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override', methods: ['POST'])]
|
||||
public function createOverride(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$dateStr = trim($data['date'] ?? '');
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$timestamp = strtotime($dateStr);
|
||||
if ($timestamp === false || $timestamp === -1) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است', 422, 'date');
|
||||
}
|
||||
|
||||
$override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false));
|
||||
if (isset($data['reason'])) $override->setReason($data['reason']);
|
||||
if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']);
|
||||
|
||||
$this->overrideRepo->save($override);
|
||||
|
||||
return $this->success(['data' => $override->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['PATCH'])]
|
||||
public function updateOverride(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$override = $this->overrideRepo->findByUuid($uuid);
|
||||
if ($override === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('active', $data)) $override->setActive((bool) $data['active']);
|
||||
if (array_key_exists('reason', $data)) $override->setReason($data['reason']);
|
||||
if (array_key_exists('custom_slots', $data)) $override->setSetting($data['custom_slots']);
|
||||
if (!empty($data['date'])) {
|
||||
$ts = strtotime($data['date']);
|
||||
if ($ts !== false) $override->setDate($ts);
|
||||
}
|
||||
|
||||
$this->overrideRepo->save($override);
|
||||
|
||||
return $this->success(['data' => $override->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteOverride(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$override = $this->overrideRepo->findByUuid($uuid);
|
||||
if ($override === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$this->overrideRepo->remove($override);
|
||||
|
||||
return $this->success(['message' => 'Override با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['GET'])]
|
||||
public function getOverride(string $uuid): JsonResponse
|
||||
{
|
||||
$override = $this->overrideRepo->findByUuid($uuid);
|
||||
if ($override === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $override->toArray()]);
|
||||
}
|
||||
|
||||
// ── Holidays ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/holidays', methods: ['POST'])]
|
||||
public function createHoliday(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$startStr = trim($data['start_date'] ?? '');
|
||||
$endStr = trim($data['end_date'] ?? '');
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$startTs = strtotime($startStr);
|
||||
$endTs = strtotime($endStr);
|
||||
|
||||
if (!$startTs || !$endTs || $endTs < $startTs) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ نادرست است', 422);
|
||||
}
|
||||
|
||||
$holiday = new Holiday($doctor, $startTs, $endTs);
|
||||
if (isset($data['reason'])) $holiday->setReason($data['reason']);
|
||||
|
||||
$this->holidayRepo->save($holiday);
|
||||
|
||||
return $this->success(['data' => $holiday->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['PATCH'])]
|
||||
public function updateHoliday(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$holiday = $this->holidayRepo->findByUuid($uuid);
|
||||
if ($holiday === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($holiday->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('active', $data)) $holiday->setActive((bool) $data['active']);
|
||||
if (array_key_exists('reason', $data)) $holiday->setReason($data['reason']);
|
||||
if (!empty($data['start_date'])) {
|
||||
$ts = strtotime($data['start_date']);
|
||||
if ($ts) $holiday->setStartDate($ts);
|
||||
}
|
||||
if (!empty($data['end_date'])) {
|
||||
$ts = strtotime($data['end_date']);
|
||||
if ($ts) $holiday->setEndDate($ts);
|
||||
}
|
||||
|
||||
$this->holidayRepo->save($holiday);
|
||||
|
||||
return $this->success(['data' => $holiday->toArray()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'appointments')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')]
|
||||
#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')]
|
||||
class Appointment
|
||||
{
|
||||
// Status machine: pending → confirmed → completed
|
||||
// ↘ cancelled_by_doctor / cancelled_by_user
|
||||
// pending → expired (cron)
|
||||
// confirmed → no_show
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_CANCELLED_BY_DOCTOR = 'cancelled_by_doctor';
|
||||
public const STATUS_CANCELLED_BY_USER = 'cancelled_by_user';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_NO_SHOW = 'no_show';
|
||||
|
||||
public const ALLOWED_TRANSITIONS = [
|
||||
self::STATUS_PENDING => [self::STATUS_CONFIRMED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_EXPIRED],
|
||||
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
|
||||
];
|
||||
|
||||
// Optimistic locking
|
||||
#[ORM\Version]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(name: 'slot_start', type: 'integer')]
|
||||
private int $slotStart;
|
||||
|
||||
#[ORM\Column(name: 'slot_end', type: 'integer')]
|
||||
private int $slotEnd;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 30)]
|
||||
private string $status = self::STATUS_PENDING;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $note = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->user = $user;
|
||||
$this->slotStart = $slotStart;
|
||||
$this->slotEnd = $slotEnd;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getSlotStart(): int { return $this->slotStart; }
|
||||
public function getSlotEnd(): int { return $this->slotEnd; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getNote(): ?string { return $this->note; }
|
||||
public function getVersion(): int { return $this->version; }
|
||||
|
||||
public function setNote(?string $v): self { $this->note = $v; return $this; }
|
||||
|
||||
public function canTransitionTo(string $newStatus): bool
|
||||
{
|
||||
return in_array($newStatus, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
|
||||
}
|
||||
|
||||
public function transitionTo(string $newStatus): self
|
||||
{
|
||||
if (!$this->canTransitionTo($newStatus)) {
|
||||
throw new \LogicException(sprintf(
|
||||
'Cannot transition appointment from "%s" to "%s"',
|
||||
$this->status, $newStatus
|
||||
));
|
||||
}
|
||||
$this->status = $newStatus;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor' => [
|
||||
'uuid' => $this->doctor->getUuid(),
|
||||
'name' => $this->doctor->getName(),
|
||||
],
|
||||
'user' => [
|
||||
'uuid' => $this->user->getUuid(),
|
||||
'mobile' => $this->user->getMobileNumber(),
|
||||
],
|
||||
'slot_start' => $this->slotStart,
|
||||
'slot_end' => $this->slotEnd,
|
||||
'status' => $this->status,
|
||||
'note' => $this->note,
|
||||
'version' => $this->version,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'date_overrides')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'date'], name: 'idx_date_overrides_doctor_date')]
|
||||
class DateOverride
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $date;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = false;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $setting = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, int $date, bool $active = false)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->date = $date;
|
||||
$this->active = $active;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getDate(): int { return $this->date; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getSetting(): ?array { return $this->setting; }
|
||||
public function getReason(): ?string { return $this->reason; }
|
||||
|
||||
public function setDate(int $v): self { $this->date = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setSetting(?array $v): self { $this->setting = $v; $this->touch(); return $this; }
|
||||
public function setReason(?string $v): self { $this->reason = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'date' => $this->date,
|
||||
'active' => $this->active,
|
||||
'reason' => $this->reason,
|
||||
'custom_slots' => $this->setting ?? [],
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'holidays')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'start_date', 'end_date'], name: 'idx_holidays_doctor_range')]
|
||||
class Holiday
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(name: 'start_date', type: 'integer')]
|
||||
private int $startDate;
|
||||
|
||||
#[ORM\Column(name: 'end_date', type: 'integer')]
|
||||
private int $endDate;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, int $startDate, int $endDate)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->startDate = $startDate;
|
||||
$this->endDate = $endDate;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getStartDate(): int { return $this->startDate; }
|
||||
public function getEndDate(): int { return $this->endDate; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getReason(): ?string { return $this->reason; }
|
||||
|
||||
public function setStartDate(int $v): self { $this->startDate = $v; $this->touch(); return $this; }
|
||||
public function setEndDate(int $v): self { $this->endDate = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setReason(?string $v): self { $this->reason = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'start_date' => $this->startDate,
|
||||
'end_date' => $this->endDate,
|
||||
'active' => $this->active,
|
||||
'reason' => $this->reason,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'weekly_schedules')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor', columns: ['doctor_id'])]
|
||||
class WeeklySchedule
|
||||
{
|
||||
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $setting = [];
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, array $setting)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->setting = $setting;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getSetting(): array { return $this->setting; }
|
||||
|
||||
public function setSetting(array $setting): self { $this->setting = $setting; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'schedule' => $this->setting,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class AppointmentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Appointment::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Appointment
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** Check if a slot is already taken (confirmed or pending) */
|
||||
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.status IN (:activeStatuses)')
|
||||
->andWhere('a.slotStart < :slotEnd')
|
||||
->andWhere('a.slotEnd > :slotStart')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('activeStatuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED])
|
||||
->setParameter('slotStart', $slotStart)
|
||||
->setParameter('slotEnd', $slotEnd);
|
||||
|
||||
if ($excludeId !== null) {
|
||||
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
|
||||
}
|
||||
|
||||
/** @return Appointment[] */
|
||||
public function findByDoctor(Doctor $doctor, ?string $status = null): array
|
||||
{
|
||||
$criteria = ['doctor' => $doctor];
|
||||
if ($status !== null) $criteria['status'] = $status;
|
||||
return $this->findBy($criteria, ['slotStart' => 'ASC']);
|
||||
}
|
||||
|
||||
/** @return Appointment[] */
|
||||
public function findByUser(User $user, ?string $status = null): array
|
||||
{
|
||||
$criteria = ['user' => $user];
|
||||
if ($status !== null) $criteria['status'] = $status;
|
||||
return $this->findBy($criteria, ['slotStart' => 'DESC']);
|
||||
}
|
||||
|
||||
/** @return Appointment[] pending appointments older than given timestamp */
|
||||
public function findExpiredPending(int $before): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.status = :status')
|
||||
->andWhere('a.slotStart < :before')
|
||||
->setParameter('status', Appointment::STATUS_PENDING)
|
||||
->setParameter('before', $before)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(Appointment $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OptimisticLockException
|
||||
*/
|
||||
public function saveWithLock(Appointment $entity, int $expectedVersion): void
|
||||
{
|
||||
$this->getEntityManager()->lock($entity, \Doctrine\DBAL\LockMode::OPTIMISTIC, $expectedVersion);
|
||||
$this->getEntityManager()->persist($entity);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(Appointment $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\DateOverride;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class DateOverrideRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DateOverride::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?DateOverride
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return DateOverride[] */
|
||||
public function findByDoctor(Doctor $doctor): array
|
||||
{
|
||||
return $this->findBy(['doctor' => $doctor], ['date' => 'ASC']);
|
||||
}
|
||||
|
||||
public function save(DateOverride $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(DateOverride $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\Holiday;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class HolidayRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Holiday::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Holiday
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return Holiday[] */
|
||||
public function findActiveByDoctor(Doctor $doctor, int $from, int $to): array
|
||||
{
|
||||
return $this->createQueryBuilder('h')
|
||||
->where('h.doctor = :doctor')
|
||||
->andWhere('h.active = true')
|
||||
->andWhere('h.startDate <= :to')
|
||||
->andWhere('h.endDate >= :from')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(Holiday $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(Holiday $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class WeeklyScheduleRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, WeeklySchedule::class);
|
||||
}
|
||||
|
||||
public function findByDoctor(Doctor $doctor): ?WeeklySchedule
|
||||
{
|
||||
return $this->findOneBy(['doctor' => $doctor]);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?WeeklySchedule
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function save(WeeklySchedule $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(WeeklySchedule $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Repository\DateOverrideRepository;
|
||||
use App\Appointment\Repository\HolidayRepository;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
|
||||
class SlotCalculatorService
|
||||
{
|
||||
private const DAY_MAP = [
|
||||
0 => 'sunday', 1 => 'monday', 2 => 'tuesday', 3 => 'wednesday',
|
||||
4 => 'thursday', 5 => 'friday', 6 => 'saturday',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly DateOverrideRepository $overrideRepo,
|
||||
private readonly HolidayRepository $holidayRepo,
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns available slots for a doctor on a given date.
|
||||
* @param string $date 'Y-m-d' format
|
||||
* @return array[] [{start: int, end: int, start_time: string, end_time: string}]
|
||||
*/
|
||||
public function getAvailableSlots(Doctor $doctor, string $date): array
|
||||
{
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
$dayEnd = $dayStart + 86400;
|
||||
|
||||
// Check if in holiday
|
||||
$holidays = $this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1);
|
||||
if (!empty($holidays)) return [];
|
||||
|
||||
// Check date override first
|
||||
$overrides = $this->overrideRepo->findByDoctor($doctor);
|
||||
foreach ($overrides as $override) {
|
||||
$overDate = date('Y-m-d', $override->getDate());
|
||||
if ($overDate === $date) {
|
||||
if (!$override->isActive()) return [];
|
||||
return $this->buildSlots($override->getSetting() ?? [], $dayStart);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to weekly schedule
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
if ($schedule === null) return [];
|
||||
|
||||
$dow = (int) date('w', $dayStart);
|
||||
$dayName = self::DAY_MAP[$dow];
|
||||
$setting = $schedule->getSetting();
|
||||
|
||||
$dayConfig = $setting[$dayName] ?? null;
|
||||
if ($dayConfig === null || !($dayConfig['active'] ?? false)) return [];
|
||||
|
||||
$rawSlots = $this->buildSlots($dayConfig['slots'] ?? [], $dayStart);
|
||||
|
||||
// Filter out already-booked slots
|
||||
return $this->filterBookedSlots($doctor, $rawSlots);
|
||||
}
|
||||
|
||||
/** @return array[] */
|
||||
private function buildSlots(array $slotConfigs, int $dayStart): array
|
||||
{
|
||||
$slots = [];
|
||||
foreach ($slotConfigs as $config) {
|
||||
$startSec = $this->parseTime($config['start'] ?? '00:00');
|
||||
$endSec = $this->parseTime($config['end'] ?? '00:00');
|
||||
$duration = (int) ($config['duration'] ?? 30) * 60;
|
||||
|
||||
if ($duration <= 0 || $endSec <= $startSec) continue;
|
||||
|
||||
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
|
||||
$slotStart = $dayStart + $t;
|
||||
$slotEnd = $slotStart + $duration;
|
||||
$slots[] = [
|
||||
'start' => $slotStart,
|
||||
'end' => $slotEnd,
|
||||
'start_time' => gmdate('H:i', $t),
|
||||
'end_time' => gmdate('H:i', $t + $duration),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $slots;
|
||||
}
|
||||
|
||||
private function filterBookedSlots(Doctor $doctor, array $slots): array
|
||||
{
|
||||
return array_values(array_filter($slots, function (array $slot) use ($doctor): bool {
|
||||
return !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']);
|
||||
}));
|
||||
}
|
||||
|
||||
private function parseTime(string $time): int
|
||||
{
|
||||
[$h, $m] = explode(':', $time, 2) + [0, 0];
|
||||
return ((int)$h * 3600) + ((int)$m * 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Auth\Service\OtpService;
|
||||
use App\Auth\Service\TokenService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly OtpService $otpService,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly RateLimiterFactory $sendCodeLimiter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Route exists so the router resolves it; PasswordAuthenticator intercepts
|
||||
* and returns the JWT response before this controller body ever runs.
|
||||
*/
|
||||
#[Route('/api/v1/user/login', methods: ['POST'])]
|
||||
public function login(): JsonResponse
|
||||
{
|
||||
return $this->error(ErrorCodes::ERR_AUTH_005, ErrorCodes::message(ErrorCodes::ERR_AUTH_005), 401);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user/send-code', methods: ['POST'])]
|
||||
public function sendCode(Request $request): JsonResponse
|
||||
{
|
||||
$limiter = $this->sendCodeLimiter->create($request->getClientIp() ?? 'unknown');
|
||||
if (!$limiter->consume(1)->isAccepted()) {
|
||||
return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($data['mobile'] ?? '');
|
||||
|
||||
if (!preg_match('/^09[0-9]{9}$/', $mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت شماره موبایل نادرست است', 422, 'mobile');
|
||||
}
|
||||
|
||||
$uuid = $this->otpService->sendCode($mobile);
|
||||
|
||||
return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user/verify-code', methods: ['POST'])]
|
||||
public function verifyCode(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$code = trim($data['code'] ?? '');
|
||||
|
||||
if (empty($uuid) || empty($code)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid و code الزامی است', 422);
|
||||
}
|
||||
|
||||
$this->otpService->verifyCode($uuid, $code);
|
||||
|
||||
return $this->success(['message' => 'کد با موفقیت تایید شد.']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user/register', methods: ['POST'])]
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$realName = trim($data['real_name'] ?? '');
|
||||
|
||||
if (empty($uuid)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid الزامی است', 422);
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$mobile = $otpData['mobile'];
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
if ($realName !== '') {
|
||||
$user->setRealName($realName);
|
||||
}
|
||||
|
||||
$this->userRepo->save($user);
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return $this->success(['message' => 'ثبتنام با موفقیت انجام شد.', 'uuid' => $user->getUuid()], 201);
|
||||
}
|
||||
|
||||
#[Route('/oauth/token', methods: ['POST'])]
|
||||
public function issueToken(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grant = $data['grant_type'] ?? '';
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
|
||||
if ($grant !== 'mobile') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant_type نامعتبر است', 400);
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$mobile = $otpData['mobile'];
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
$this->userRepo->save($user);
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return new JsonResponse($this->tokenService->issueTokens($user));
|
||||
}
|
||||
|
||||
#[Route('/oauth/token/refresh', methods: ['POST'])]
|
||||
public function refreshToken(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$refreshToken = trim($data['refresh_token'] ?? '');
|
||||
|
||||
if (empty($refreshToken)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_001, 'refresh_token الزامی است', 401);
|
||||
}
|
||||
|
||||
$result = $this->tokenService->refreshToken($refreshToken);
|
||||
$user = $this->userRepo->find($result['userId']);
|
||||
|
||||
if ($user === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401);
|
||||
}
|
||||
|
||||
$tokens = $this->tokenService->issueTokens($user);
|
||||
$tokens['refresh_token'] = $result['rawToken'];
|
||||
|
||||
return new JsonResponse($tokens);
|
||||
}
|
||||
|
||||
#[Route('/oauth/userinfo', methods: ['GET'])]
|
||||
public function userInfo(#[CurrentUser] ?User $user): JsonResponse
|
||||
{
|
||||
if ($user === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'id' => $user->getId(),
|
||||
'uuid' => $user->getUuid(),
|
||||
'mobile_number' => $user->getMobileNumber(),
|
||||
'realName' => $user->getRealName(),
|
||||
'status' => $user->getStatus(),
|
||||
'roles' => $user->getRoles(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/oauth/logout', methods: ['POST'])]
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$refreshToken = trim($data['refresh_token'] ?? '');
|
||||
|
||||
if ($refreshToken !== '') {
|
||||
$this->tokenService->revokeRefreshToken($refreshToken);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'خروج با موفقیت انجام شد']);
|
||||
}
|
||||
|
||||
#[Route('/session/token', methods: ['GET'])]
|
||||
public function sessionToken(): JsonResponse
|
||||
{
|
||||
return new JsonResponse(['token' => bin2hex(random_bytes(16))]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'users')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_mobile', columns: ['mobile_number'])]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(name: 'mobile_number', type: 'string', length: 20, unique: true)]
|
||||
private string $mobileNumber;
|
||||
|
||||
#[ORM\Column(name: 'password_hash', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $passwordHash = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(name: 'real_name', type: 'string', length: 100, nullable: true)]
|
||||
private ?string $realName = null;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $roles = ['ROLE_USER'];
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $status = 1;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $mobileNumber)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->mobileNumber = $mobileNumber;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getMobileNumber(): string { return $this->mobileNumber; }
|
||||
public function getEmail(): ?string { return $this->email; }
|
||||
public function getRealName(): ?string { return $this->realName; }
|
||||
public function getStatus(): int { return $this->status; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function getPassword(): ?string { return $this->passwordHash; }
|
||||
public function getPasswordHash(): ?string { return $this->passwordHash; }
|
||||
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
if (!in_array('ROLE_USER', $roles, true)) {
|
||||
$roles[] = 'ROLE_USER';
|
||||
}
|
||||
return array_unique($roles);
|
||||
}
|
||||
|
||||
public function getUserIdentifier(): string { return $this->mobileNumber; }
|
||||
public function eraseCredentials(): void {}
|
||||
|
||||
public function setEmail(?string $email): self { $this->email = $email; return $this; }
|
||||
public function setRealName(?string $name): self { $this->realName = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setPasswordHash(?string $hash): self { $this->passwordHash = $hash; $this->updatedAt = time(); return $this; }
|
||||
public function setRoles(array $roles): self { $this->roles = $roles; $this->updatedAt = time(); return $this; }
|
||||
public function setStatus(int $status): self { $this->status = $status; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function addRole(string $role): self
|
||||
{
|
||||
if (!in_array($role, $this->roles, true)) {
|
||||
$this->roles[] = $role;
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->getRoles(), true);
|
||||
}
|
||||
|
||||
public function isStaff(): bool
|
||||
{
|
||||
return $this->hasRole('ROLE_DOCTOR')
|
||||
|| $this->hasRole('ROLE_CLINIC')
|
||||
|| $this->hasRole('ROLE_SECRETARY')
|
||||
|| $this->hasRole('ROLE_ADMIN');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
public function findByMobile(string $mobile): ?User
|
||||
{
|
||||
return $this->findOneBy(['mobileNumber' => $mobile]);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?User
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function save(User $user, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($user);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(User $user, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($user);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Security;
|
||||
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class PasswordAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly JWTTokenManagerInterface $jwtManager,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly RateLimiterFactory $loginLimiter,
|
||||
private readonly int $refreshTokenTtl = 2592000,
|
||||
) {}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
return $request->getPathInfo() === '/api/v1/user/login'
|
||||
&& $request->isMethod('POST');
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$limiter = $this->loginLimiter->create($request->getClientIp() ?? 'unknown');
|
||||
if (!$limiter->consume(1)->isAccepted()) {
|
||||
throw new TooManyRequestsHttpException(60, 'تعداد تلاشهای ورود از حد مجاز گذشت');
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($data['mobile_number'] ?? '');
|
||||
$pass = $data['password'] ?? '';
|
||||
|
||||
return new Passport(
|
||||
new UserBadge($mobile, fn(string $id) => $this->userRepository->findByMobile($id)),
|
||||
new PasswordCredentials($pass)
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user->isStaff()) {
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'data' => null,
|
||||
'errors' => [['code' => ErrorCodes::ERR_AUTH_006, 'message' => ErrorCodes::message(ErrorCodes::ERR_AUTH_006)]],
|
||||
], 403);
|
||||
}
|
||||
|
||||
$accessToken = $this->jwtManager->create($user);
|
||||
$rawToken = bin2hex(random_bytes(32));
|
||||
$cacheKey = 'refresh_' . hash('sha256', $rawToken);
|
||||
|
||||
$item = $this->cache->getItem($cacheKey);
|
||||
$item->set((string) $user->getId());
|
||||
$item->expiresAfter($this->refreshTokenTtl);
|
||||
$this->cache->save($item);
|
||||
|
||||
$this->logger->info('login_success', [
|
||||
'user_id' => $user->getId(),
|
||||
'ip' => $request->getClientIp(),
|
||||
'method' => 'password',
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $rawToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'refresh_token_expires_in' => $this->refreshTokenTtl,
|
||||
]);
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
{
|
||||
$this->logger->warning('login_failed', [
|
||||
'ip' => $request->getClientIp(),
|
||||
'reason' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'data' => null,
|
||||
'errors' => [['code' => ErrorCodes::ERR_AUTH_005, 'message' => ErrorCodes::message(ErrorCodes::ERR_AUTH_005)]],
|
||||
], 401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Service;
|
||||
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Message\SendSmsMessage;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class OtpService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly int $otpTtl = 1200,
|
||||
private readonly string $appEnv = 'dev',
|
||||
) {}
|
||||
|
||||
private function key(string $uuid): string
|
||||
{
|
||||
// Cache PSR-6 reserved chars: {}()/\@: and - in UUID must be escaped
|
||||
return 'otp_' . str_replace('-', '_', $uuid);
|
||||
}
|
||||
|
||||
public function sendCode(string $mobile): string
|
||||
{
|
||||
$uuid = Uuid::v4()->toRfc4122();
|
||||
$code = $this->appEnv === 'dev'
|
||||
? '12345'
|
||||
: str_pad((string) random_int(10000, 99999), 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$item = $this->cache->getItem($this->key($uuid));
|
||||
$item->set(json_encode(['mobile' => $mobile, 'code' => $code, 'attempts' => 0, 'verified' => false]));
|
||||
$item->expiresAfter($this->otpTtl);
|
||||
$this->cache->save($item);
|
||||
|
||||
if ($this->appEnv !== 'dev') {
|
||||
$this->bus->dispatch(new SendSmsMessage($mobile, "کد تأیید شما: {$code}"));
|
||||
}
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
public function verifyCode(string $uuid, string $submittedCode): array
|
||||
{
|
||||
$item = $this->cache->getItem($this->key($uuid));
|
||||
|
||||
if (!$item->isHit()) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_003, null, 400);
|
||||
}
|
||||
|
||||
$data = json_decode($item->get(), true);
|
||||
|
||||
if ($data['attempts'] >= 5) {
|
||||
$this->cache->delete($this->key($uuid));
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_004, null, 429);
|
||||
}
|
||||
|
||||
if (!hash_equals($data['code'], $submittedCode)) {
|
||||
$data['attempts']++;
|
||||
$item->set(json_encode($data));
|
||||
$item->expiresAfter($this->otpTtl);
|
||||
$this->cache->save($item);
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
|
||||
}
|
||||
|
||||
$data['verified'] = true;
|
||||
$item->set(json_encode($data));
|
||||
$item->expiresAfter($this->otpTtl);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getVerifiedOtpData(string $uuid): array
|
||||
{
|
||||
$item = $this->cache->getItem($this->key($uuid));
|
||||
|
||||
if (!$item->isHit()) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_003, null, 400);
|
||||
}
|
||||
|
||||
$data = json_decode($item->get(), true);
|
||||
|
||||
if (!($data['verified'] ?? false)) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function deleteOtp(string $uuid): void
|
||||
{
|
||||
$this->cache->delete($this->key($uuid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class TokenService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JWTTokenManagerInterface $jwtManager,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly int $refreshTokenTtl = 2592000,
|
||||
) {}
|
||||
|
||||
public function issueTokens(User $user): array
|
||||
{
|
||||
$accessToken = $this->jwtManager->create($user);
|
||||
$rawToken = $this->storeRefreshToken($user->getId());
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $rawToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'refresh_token_expires_in' => $this->refreshTokenTtl,
|
||||
];
|
||||
}
|
||||
|
||||
public function refreshToken(string $submittedToken): array
|
||||
{
|
||||
$key = $this->refreshKey($submittedToken);
|
||||
$item = $this->cache->getItem($key);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_001, 'Refresh Token نامعتبر یا منقضی شده است', 401);
|
||||
}
|
||||
|
||||
$userId = (int) $item->get();
|
||||
$this->cache->delete($key);
|
||||
|
||||
return ['userId' => $userId, 'rawToken' => $this->storeRefreshToken($userId)];
|
||||
}
|
||||
|
||||
public function revokeRefreshToken(string $rawToken): void
|
||||
{
|
||||
$this->cache->delete($this->refreshKey($rawToken));
|
||||
}
|
||||
|
||||
private function storeRefreshToken(int $userId): string
|
||||
{
|
||||
$rawToken = bin2hex(random_bytes(32));
|
||||
$key = $this->refreshKey($rawToken);
|
||||
|
||||
$item = $this->cache->getItem($key);
|
||||
$item->set((string) $userId);
|
||||
$item->expiresAfter($this->refreshTokenTtl);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $rawToken;
|
||||
}
|
||||
|
||||
private function refreshKey(string $rawToken): string
|
||||
{
|
||||
// hash → hex string → safe cache key (no reserved chars)
|
||||
return 'refresh_' . hash('sha256', $rawToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Blog\Entity\Blog;
|
||||
use App\Blog\Repository\BlogRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Service\FileValidatorService;
|
||||
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 BlogController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BlogRepository $blogRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
// ── Public list/detail ────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/blogs', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$blogs = array_map(fn(Blog $b) => $b->toListArray(), $this->blogRepo->findPublished($page, $limit));
|
||||
$total = $this->blogRepo->countPublished();
|
||||
|
||||
return $this->paginated($blogs, $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/blog/{slug}', methods: ['GET'])]
|
||||
public function detail(string $slug): JsonResponse
|
||||
{
|
||||
$blog = $this->blogRepo->findBySlug($slug) ?? $this->blogRepo->findByUuid($slug);
|
||||
if ($blog === null || $blog->getStatus() !== Blog::STATUS_PUBLISHED) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $blog->toArray()]);
|
||||
}
|
||||
|
||||
// ── Admin CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/blog', methods: ['POST'])]
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$title = trim($data['title'] ?? '');
|
||||
$body = trim($data['body'] ?? '');
|
||||
|
||||
if (empty($title) || empty($body)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body الزامی است', 422);
|
||||
}
|
||||
|
||||
$blog = new Blog($user, $title, $body);
|
||||
if (!empty($data['summary'])) $blog->setSummary($data['summary']);
|
||||
if (!empty($data['tags'])) $blog->setTags((array)$data['tags']);
|
||||
if (!empty($data['status'])) $blog->setStatus($data['status']);
|
||||
|
||||
// Ensure slug uniqueness
|
||||
if ($this->blogRepo->findBySlug($blog->getSlug()) !== null) {
|
||||
$blog->setSlug($blog->getSlug() . '-' . substr(uniqid(), -4));
|
||||
}
|
||||
|
||||
$this->blogRepo->save($blog);
|
||||
|
||||
return $this->success(['data' => $blog->toArray()], 201);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/blog/{uuid}', methods: ['PATCH'])]
|
||||
public function update(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$blog = $this->blogRepo->findByUuid($uuid);
|
||||
if ($blog === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('title', $data)) $blog->setTitle($data['title']);
|
||||
if (array_key_exists('body', $data)) $blog->setBody($data['body']);
|
||||
if (array_key_exists('summary', $data)) $blog->setSummary($data['summary']);
|
||||
if (array_key_exists('tags', $data)) $blog->setTags((array)$data['tags']);
|
||||
if (array_key_exists('status', $data)) $blog->setStatus($data['status']);
|
||||
|
||||
$this->blogRepo->save($blog);
|
||||
|
||||
return $this->success(['data' => $blog->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/blog/{uuid}', methods: ['DELETE'])]
|
||||
public function delete(string $uuid): JsonResponse
|
||||
{
|
||||
$blog = $this->blogRepo->findByUuid($uuid);
|
||||
if ($blog === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->blogRepo->remove($blog);
|
||||
return $this->success(['message' => 'مقاله با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
// ── Image upload ──────────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/file/upload/clinic_pro/blog/field_image', methods: ['POST'])]
|
||||
public function uploadImage(Request $request): JsonResponse
|
||||
{
|
||||
$file = $request->files->get('file');
|
||||
$blogUuid = $request->request->get('blog_uuid', '');
|
||||
|
||||
if ($file === null) {
|
||||
return $this->error(ErrorCodes::ERR_FILE_001, 'فایل ارسال نشده است', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$safeFilename = $this->fileValidator->validateUploadedFile($file);
|
||||
} catch (\App\Shared\Exception\AppException $e) {
|
||||
return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus());
|
||||
}
|
||||
|
||||
$uploadDir = $this->projectDir . '/public/uploads/blogs/';
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
|
||||
$filename = uniqid('blog_') . '_' . $safeFilename;
|
||||
$file->move($uploadDir, $filename);
|
||||
|
||||
$imageUrl = '/uploads/blogs/' . $filename;
|
||||
$imagePath = $uploadDir . $filename;
|
||||
|
||||
if (!empty($blogUuid)) {
|
||||
$blog = $this->blogRepo->findByUuid($blogUuid);
|
||||
if ($blog !== null) {
|
||||
$blog->setImageUrl($imageUrl)->setImagePath($imagePath);
|
||||
$this->blogRepo->save($blog);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success(['image_url' => $imageUrl, 'filename' => $filename]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'blogs')]
|
||||
#[ORM\Index(columns: ['status', 'created_at'], name: 'idx_blogs_status')]
|
||||
class Blog
|
||||
{
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $title;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, unique: true)]
|
||||
private string $slug;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $body;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 500, nullable: true)]
|
||||
private ?string $summary = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 500, nullable: true)]
|
||||
private ?string $imageUrl = null;
|
||||
|
||||
#[ORM\Column(name: 'image_path', type: 'string', length: 500, nullable: true)]
|
||||
private ?string $imagePath = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'author_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private User $author;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $tags = [];
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $status = self::STATUS_DRAFT;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $author, string $title, string $body)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->author = $author;
|
||||
$this->title = $title;
|
||||
$this->slug = $this->generateSlug($title);
|
||||
$this->body = $body;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getTitle(): string { return $this->title; }
|
||||
public function getSlug(): string { return $this->slug; }
|
||||
public function getBody(): string { return $this->body; }
|
||||
public function getSummary(): ?string { return $this->summary; }
|
||||
public function getImageUrl(): ?string { return $this->imageUrl; }
|
||||
public function getImagePath(): ?string { return $this->imagePath; }
|
||||
public function getAuthor(): User { return $this->author; }
|
||||
public function getTags(): array { return $this->tags; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
|
||||
public function setTitle(string $v): self { $this->title = $v; $this->touch(); return $this; }
|
||||
public function setSlug(string $v): self { $this->slug = $v; $this->touch(); return $this; }
|
||||
public function setBody(string $v): self { $this->body = $v; $this->touch(); return $this; }
|
||||
public function setSummary(?string $v): self { $this->summary = $v; $this->touch(); return $this; }
|
||||
public function setImageUrl(?string $v): self { $this->imageUrl = $v; $this->touch(); return $this; }
|
||||
public function setImagePath(?string $v): self { $this->imagePath = $v; $this->touch(); return $this; }
|
||||
public function setTags(array $v): self { $this->tags = $v; $this->touch(); return $this; }
|
||||
public function setStatus(string $v): self { $this->status = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
private function generateSlug(string $title): string
|
||||
{
|
||||
$slug = mb_strtolower(trim($title));
|
||||
$slug = preg_replace('/\s+/', '-', $slug);
|
||||
$slug = preg_replace('/[^a-z0-9\-\p{Arabic}]/u', '', $slug);
|
||||
return $slug . '-' . substr(str_replace('-', '', $this->uuid), 0, 8);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'title' => $this->title,
|
||||
'slug' => $this->slug,
|
||||
'summary' => $this->summary,
|
||||
'body' => $this->body,
|
||||
'image_url' => $this->imageUrl,
|
||||
'tags' => $this->tags,
|
||||
'status' => $this->status,
|
||||
'author' => [
|
||||
'uuid' => $this->author->getUuid(),
|
||||
'name' => $this->author->getRealName(),
|
||||
],
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function toListArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'title' => $this->title,
|
||||
'slug' => $this->slug,
|
||||
'summary' => $this->summary,
|
||||
'image_url' => $this->imageUrl,
|
||||
'tags' => $this->tags,
|
||||
'status' => $this->status,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Repository;
|
||||
|
||||
use App\Blog\Entity\Blog;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class BlogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Blog::class); }
|
||||
|
||||
public function findByUuid(string $uuid): ?Blog { return $this->findOneBy(['uuid' => $uuid]); }
|
||||
public function findBySlug(string $slug): ?Blog { return $this->findOneBy(['slug' => $slug]); }
|
||||
|
||||
/** @return Blog[] published, newest first */
|
||||
public function findPublished(int $page = 1, int $limit = 20): array
|
||||
{
|
||||
return $this->createQueryBuilder('b')
|
||||
->where('b.status = :status')
|
||||
->setParameter('status', Blog::STATUS_PUBLISHED)
|
||||
->orderBy('b.createdAt', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function countPublished(): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('b')
|
||||
->select('COUNT(b.id)')
|
||||
->where('b.status = :status')
|
||||
->setParameter('status', Blog::STATUS_PUBLISHED)
|
||||
->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function save(Blog $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
public function remove(Blog $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Category\Controller;
|
||||
|
||||
use App\Category\Entity\Category;
|
||||
use App\Category\Repository\CategoryRepository;
|
||||
use App\Category\Service\CategoryService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class CategoryController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CategoryRepository $repository,
|
||||
private readonly CategoryService $service,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/categorys/tag', methods: ['GET'])]
|
||||
public function listTags(): JsonResponse
|
||||
{
|
||||
return $this->success(['data' => $this->service->listByBundle('tag')]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/categorys/supplementary_insurance', methods: ['GET'])]
|
||||
public function listSupplementaryInsurance(): JsonResponse
|
||||
{
|
||||
return $this->success(['data' => $this->service->listByBundle('supplementary_insurance')]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/categorys/insurance_type', methods: ['GET'])]
|
||||
public function listInsuranceType(): JsonResponse
|
||||
{
|
||||
return $this->success(['data' => $this->service->listByBundle('insurance_type')]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/categorys/state', methods: ['GET'])]
|
||||
public function listStates(): JsonResponse
|
||||
{
|
||||
return $this->success(['data' => $this->service->listByBundle('state')]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/categorys/city', methods: ['GET'])]
|
||||
public function listCities(Request $request): JsonResponse
|
||||
{
|
||||
$stateId = $request->query->get('state_id');
|
||||
$parentId = $stateId !== null ? (int) $stateId : null;
|
||||
|
||||
return $this->success(['data' => $this->service->listByBundle('city', $parentId)]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/categorys/specially_doctor', methods: ['GET'])]
|
||||
public function listSpecialties(): JsonResponse
|
||||
{
|
||||
return $this->success(['data' => $this->service->listByBundle('specially_doctor')]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/categorys/doctor_services', methods: ['GET'])]
|
||||
public function listDoctorServices(): JsonResponse
|
||||
{
|
||||
return $this->success(['data' => $this->service->listByBundle('doctor_services')]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/category', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$bundle = trim($data['bundle'] ?? '');
|
||||
$label = trim($data['label'] ?? '');
|
||||
|
||||
if (!in_array($bundle, Category::BUNDLES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'bundle نامعتبر است', 422, 'bundle');
|
||||
}
|
||||
|
||||
if ($label === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'label الزامی است', 422, 'label');
|
||||
}
|
||||
|
||||
$category = $this->service->create($bundle, $label, $data);
|
||||
|
||||
return $this->success(['data' => $category->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/category/{id}', methods: ['PATCH'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function update(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$category = $this->repository->find($id);
|
||||
|
||||
if ($category === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دستهبندی یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (isset($data['bundle']) && !in_array($data['bundle'], Category::BUNDLES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'bundle نامعتبر است', 422, 'bundle');
|
||||
}
|
||||
|
||||
$category = $this->service->update($category, $data);
|
||||
|
||||
return $this->success(['data' => $category->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/category/{id}', methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
$category = $this->repository->find($id);
|
||||
|
||||
if ($category === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دستهبندی یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->service->delete($category);
|
||||
|
||||
return $this->success(['message' => 'دستهبندی با موفقیت حذف شد']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Category\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'categories')]
|
||||
#[ORM\Index(columns: ['bundle'], name: 'idx_categories_bundle')]
|
||||
#[ORM\Index(columns: ['parent_id'], name: 'idx_categories_parent')]
|
||||
#[ORM\Index(columns: ['status', 'bundle'], name: 'idx_categories_status')]
|
||||
class Category
|
||||
{
|
||||
public const BUNDLES = [
|
||||
'state', 'city', 'specially_doctor', 'doctor_services',
|
||||
'insurance_type', 'supplementary_insurance', 'tag',
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32)]
|
||||
private string $bundle;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $status = 1;
|
||||
|
||||
#[ORM\Column(name: 'parent_id', type: 'integer', nullable: true)]
|
||||
private ?int $parentId = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $weight = 0;
|
||||
|
||||
#[ORM\Column(name: 'logo_id', type: 'integer', nullable: true)]
|
||||
private ?int $logoId = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $representationId = null;
|
||||
|
||||
// City-specific fields
|
||||
#[ORM\Column(name: 'contact_phone', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $contactPhone = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $slogan = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $domain = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $keywords = null;
|
||||
|
||||
#[ORM\Column(name: 'footer_description', type: 'text', nullable: true)]
|
||||
private ?string $footerDescription = null;
|
||||
|
||||
#[ORM\Column(name: 'footer_disclaimer', type: 'text', nullable: true)]
|
||||
private ?string $footerDisclaimer = null;
|
||||
|
||||
#[ORM\Column(name: 'social_media', type: 'json', nullable: true)]
|
||||
private ?array $socialMedia = null;
|
||||
|
||||
public function __construct(string $bundle, string $label)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->bundle = $bundle;
|
||||
$this->label = $label;
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getBundle(): string { return $this->bundle; }
|
||||
public function getLabel(): ?string { return $this->label; }
|
||||
public function getStatus(): int { return $this->status; }
|
||||
public function getParentId(): ?int { return $this->parentId; }
|
||||
public function getWeight(): int { return $this->weight; }
|
||||
public function getLogoId(): ?int { return $this->logoId; }
|
||||
public function getTitle(): ?string { return $this->title; }
|
||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||
public function getContactPhone(): ?string { return $this->contactPhone; }
|
||||
public function getEmail(): ?string { return $this->email; }
|
||||
public function getDescription(): ?string { return $this->description; }
|
||||
public function getSlogan(): ?string { return $this->slogan; }
|
||||
public function getDomain(): ?string { return $this->domain; }
|
||||
public function getKeywords(): ?string { return $this->keywords; }
|
||||
public function getFooterDescription(): ?string { return $this->footerDescription; }
|
||||
public function getFooterDisclaimer(): ?string { return $this->footerDisclaimer; }
|
||||
public function getSocialMedia(): ?array { return $this->socialMedia; }
|
||||
|
||||
public function setBundle(string $bundle): self { $this->bundle = $bundle; return $this; }
|
||||
public function setLabel(?string $label): self { $this->label = $label; return $this; }
|
||||
public function setStatus(int $status): self { $this->status = $status; return $this; }
|
||||
public function setParentId(?int $id): self { $this->parentId = $id; return $this; }
|
||||
public function setWeight(int $weight): self { $this->weight = $weight; return $this; }
|
||||
public function setLogoId(?int $id): self { $this->logoId = $id; return $this; }
|
||||
public function setTitle(?string $title): self { $this->title = $title; return $this; }
|
||||
public function setRepresentationId(?int $id): self { $this->representationId = $id; return $this; }
|
||||
public function setContactPhone(?string $v): self { $this->contactPhone = $v; return $this; }
|
||||
public function setEmail(?string $v): self { $this->email = $v; return $this; }
|
||||
public function setDescription(?string $v): self { $this->description = $v; return $this; }
|
||||
public function setSlogan(?string $v): self { $this->slogan = $v; return $this; }
|
||||
public function setDomain(?string $v): self { $this->domain = $v; return $this; }
|
||||
public function setKeywords(?string $v): self { $this->keywords = $v; return $this; }
|
||||
public function setFooterDescription(?string $v): self { $this->footerDescription = $v; return $this; }
|
||||
public function setFooterDisclaimer(?string $v): self { $this->footerDisclaimer = $v; return $this; }
|
||||
public function setSocialMedia(?array $v): self { $this->socialMedia = $v; return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'id' => $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'bundle' => $this->bundle,
|
||||
'label' => $this->label,
|
||||
'status' => $this->status,
|
||||
'weight' => $this->weight,
|
||||
];
|
||||
|
||||
if ($this->parentId !== null) {
|
||||
$data['parent_id'] = $this->parentId;
|
||||
}
|
||||
if ($this->title !== null) {
|
||||
$data['title'] = $this->title;
|
||||
}
|
||||
if ($this->bundle === 'city') {
|
||||
$data['contact_phone'] = $this->contactPhone;
|
||||
$data['email'] = $this->email;
|
||||
$data['description'] = $this->description;
|
||||
$data['slogan'] = $this->slogan;
|
||||
$data['domain'] = $this->domain;
|
||||
$data['keywords'] = $this->keywords;
|
||||
$data['footer_description'] = $this->footerDescription;
|
||||
$data['social_media'] = $this->socialMedia;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Category\Repository;
|
||||
|
||||
use App\Category\Entity\Category;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class CategoryRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Category::class);
|
||||
}
|
||||
|
||||
/** @return Category[] */
|
||||
public function findByBundle(string $bundle, ?int $parentId = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->where('c.bundle = :bundle')
|
||||
->andWhere('c.status = 1')
|
||||
->setParameter('bundle', $bundle)
|
||||
->orderBy('c.weight', 'ASC')
|
||||
->addOrderBy('c.label', 'ASC');
|
||||
|
||||
if ($parentId !== null) {
|
||||
$qb->andWhere('c.parentId = :parentId')->setParameter('parentId', $parentId);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function save(Category $category, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($category);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(Category $category, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($category);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Category\Service;
|
||||
|
||||
use App\Category\Entity\Category;
|
||||
use App\Category\Repository\CategoryRepository;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
class CategoryService
|
||||
{
|
||||
private const TTL = 3600;
|
||||
|
||||
public function __construct(
|
||||
private readonly CategoryRepository $repository,
|
||||
private readonly CacheItemPoolInterface $cache,
|
||||
) {}
|
||||
|
||||
/** @return array[] */
|
||||
public function listByBundle(string $bundle, ?int $parentId = null): array
|
||||
{
|
||||
$cacheKey = 'cat_' . $bundle . ($parentId !== null ? '_p' . $parentId : '');
|
||||
$item = $this->cache->getItem($cacheKey);
|
||||
|
||||
if ($item->isHit()) {
|
||||
return $item->get();
|
||||
}
|
||||
|
||||
$rows = array_map(fn(Category $c) => $c->toArray(), $this->repository->findByBundle($bundle, $parentId));
|
||||
|
||||
$item->set($rows)->expiresAfter(self::TTL);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function create(string $bundle, string $label, array $extra = []): Category
|
||||
{
|
||||
$category = new Category($bundle, $label);
|
||||
$this->applyExtra($category, $extra);
|
||||
$this->repository->save($category);
|
||||
$this->invalidate($bundle);
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
public function update(Category $category, array $data): Category
|
||||
{
|
||||
$bundle = $data['bundle'] ?? $category->getBundle();
|
||||
|
||||
if (isset($data['label'])) $category->setLabel($data['label']);
|
||||
if (isset($data['status'])) $category->setStatus((int) $data['status']);
|
||||
if (isset($data['weight'])) $category->setWeight((int) $data['weight']);
|
||||
if (isset($data['title'])) $category->setTitle($data['title']);
|
||||
if (isset($data['bundle'])) $category->setBundle($data['bundle']);
|
||||
if (array_key_exists('parent_id', $data)) $category->setParentId($data['parent_id']);
|
||||
|
||||
$this->applyExtra($category, $data);
|
||||
$this->repository->save($category);
|
||||
$this->invalidate($bundle);
|
||||
$this->invalidate($category->getBundle());
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
public function delete(Category $category): void
|
||||
{
|
||||
$bundle = $category->getBundle();
|
||||
$this->repository->remove($category);
|
||||
$this->invalidate($bundle);
|
||||
}
|
||||
|
||||
private function applyExtra(Category $category, array $data): void
|
||||
{
|
||||
if (array_key_exists('parent_id', $data)) $category->setParentId($data['parent_id']);
|
||||
if (array_key_exists('weight', $data)) $category->setWeight((int) $data['weight']);
|
||||
if (array_key_exists('logo_id', $data)) $category->setLogoId($data['logo_id']);
|
||||
if (array_key_exists('title', $data)) $category->setTitle($data['title']);
|
||||
if (array_key_exists('contact_phone', $data)) $category->setContactPhone($data['contact_phone']);
|
||||
if (array_key_exists('email', $data)) $category->setEmail($data['email']);
|
||||
if (array_key_exists('description', $data)) $category->setDescription($data['description']);
|
||||
if (array_key_exists('slogan', $data)) $category->setSlogan($data['slogan']);
|
||||
if (array_key_exists('domain', $data)) $category->setDomain($data['domain']);
|
||||
if (array_key_exists('keywords', $data)) $category->setKeywords($data['keywords']);
|
||||
if (array_key_exists('footer_description', $data)) $category->setFooterDescription($data['footer_description']);
|
||||
if (array_key_exists('footer_disclaimer', $data)) $category->setFooterDisclaimer($data['footer_disclaimer']);
|
||||
if (array_key_exists('social_media', $data)) $category->setSocialMedia($data['social_media']);
|
||||
}
|
||||
|
||||
private function invalidate(string $bundle): void
|
||||
{
|
||||
$this->cache->deleteItem('cat_' . $bundle);
|
||||
// Also delete any parent-filtered variants
|
||||
foreach (range(1, 50) as $id) {
|
||||
$this->cache->deleteItem('cat_' . $bundle . '_p' . $id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Category\Repository\CategoryRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Service\FileValidatorService;
|
||||
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;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
class ClinicController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly CategoryRepository $categoryRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/clinic', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$clinic = new Clinic($user);
|
||||
$this->hydrateClinic($clinic, $data);
|
||||
$this->clinicRepo->save($clinic);
|
||||
|
||||
// Grant ROLE_CLINIC to user
|
||||
$roles = $user->getRoles();
|
||||
if (!in_array('ROLE_CLINIC', $roles, true)) {
|
||||
$roles[] = 'ROLE_CLINIC';
|
||||
$user->setRoles(array_values(array_unique($roles)));
|
||||
$this->userRepo->save($user);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $clinic->toDetailArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
[$stateData, $cityData] = $this->loadLocationData($clinic);
|
||||
|
||||
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$this->hydrateClinic($clinic, $data);
|
||||
$this->clinicRepo->save($clinic);
|
||||
|
||||
[$stateData, $cityData] = $this->loadLocationData($clinic);
|
||||
|
||||
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinics', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$filters = $request->query->all();
|
||||
$result = $this->clinicRepo->findWithFilters($filters);
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(Clinic $c) => $c->toListArray(), $result['items']),
|
||||
$result['total'],
|
||||
$result['page'],
|
||||
$result['limit']
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic/doctor-list/{clinicUuid}', methods: ['GET'])]
|
||||
public function doctorList(string $clinicUuid): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$doctors = array_map(
|
||||
fn(Doctor $d) => $d->toListArray(),
|
||||
$clinic->getDoctors()->toArray()
|
||||
);
|
||||
|
||||
return $this->success(['data' => $doctors]);
|
||||
}
|
||||
|
||||
#[Route('/file/upload/clinic_pro/clinic/field_image_clinic', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function uploadImage(Request $request): JsonResponse
|
||||
{
|
||||
return $this->handleFileUpload($request, 'clinics/gallery');
|
||||
}
|
||||
|
||||
#[Route('/file/upload/clinic_pro/clinic/field_clinic_logo', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function uploadLogo(Request $request): JsonResponse
|
||||
{
|
||||
return $this->handleFileUpload($request, 'clinics/logo');
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function hydrateClinic(Clinic $clinic, array $data): void
|
||||
{
|
||||
if (array_key_exists('name', $data)) $clinic->setName($data['name']);
|
||||
if (array_key_exists('info', $data)) $clinic->setInfo($data['info']);
|
||||
if (array_key_exists('address', $data)) $clinic->setAddress($data['address']);
|
||||
if (array_key_exists('telephone', $data)) $clinic->setTelephone($data['telephone']);
|
||||
if (array_key_exists('working_days', $data)) $clinic->setWorkingDays($data['working_days']);
|
||||
if (array_key_exists('24_7', $data)) $clinic->setIs247((bool) $data['24_7']);
|
||||
if (array_key_exists('latitude', $data)) $clinic->setLatitude((float) $data['latitude']);
|
||||
if (array_key_exists('longitude', $data)) $clinic->setLongitude((float) $data['longitude']);
|
||||
|
||||
// Location
|
||||
if (!empty($data['state']) && is_array($data['state'])) {
|
||||
$clinic->setStateId((int) $data['state'][0]);
|
||||
}
|
||||
if (!empty($data['city']) && is_array($data['city'])) {
|
||||
$clinic->setCityId((int) $data['city'][0]);
|
||||
}
|
||||
|
||||
// Images stored as JSON (from upload response)
|
||||
if (array_key_exists('image_clinic', $data) && is_array($data['image_clinic'])) {
|
||||
$clinic->setImagesClinic($data['image_clinic']);
|
||||
}
|
||||
if (array_key_exists('clinic_logo', $data) && is_array($data['clinic_logo'])) {
|
||||
$clinic->setClinicLogo($data['clinic_logo']);
|
||||
}
|
||||
|
||||
// ManyToMany: doctors
|
||||
if (array_key_exists('doctors', $data) && is_array($data['doctors'])) {
|
||||
$clinic->getDoctors()->clear();
|
||||
foreach ($data['doctors'] as $doctorId) {
|
||||
$doctor = $this->doctorRepo->find((int) $doctorId);
|
||||
if ($doctor !== null) {
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ManyToMany: specialties
|
||||
if (array_key_exists('specialties', $data) && is_array($data['specialties'])) {
|
||||
$clinic->getSpecialties()->clear();
|
||||
foreach ($data['specialties'] as $catId) {
|
||||
$cat = $this->categoryRepo->find((int) $catId);
|
||||
if ($cat !== null) {
|
||||
$clinic->getSpecialties()->add($cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ManyToMany: services (doctor_services)
|
||||
if (array_key_exists('doctor_services', $data) && is_array($data['doctor_services'])) {
|
||||
$clinic->getServices()->clear();
|
||||
foreach ($data['doctor_services'] as $catId) {
|
||||
$cat = $this->categoryRepo->find((int) $catId);
|
||||
if ($cat !== null) {
|
||||
$clinic->getServices()->add($cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ManyToMany: insurances
|
||||
if (array_key_exists('insurance', $data) && is_array($data['insurance'])) {
|
||||
$clinic->getInsurances()->clear();
|
||||
foreach ($data['insurance'] as $catId) {
|
||||
$cat = $this->categoryRepo->find((int) $catId);
|
||||
if ($cat !== null) {
|
||||
$clinic->getInsurances()->add($cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function loadLocationData(Clinic $clinic): array
|
||||
{
|
||||
$stateData = [];
|
||||
$cityData = [];
|
||||
|
||||
if ($clinic->getStateId() !== null) {
|
||||
$state = $this->categoryRepo->find($clinic->getStateId());
|
||||
if ($state !== null) {
|
||||
$stateData = ['uuid' => $state->getUuid(), 'id' => (string) $state->getId(), 'name' => $state->getLabel()];
|
||||
}
|
||||
}
|
||||
if ($clinic->getCityId() !== null) {
|
||||
$city = $this->categoryRepo->find($clinic->getCityId());
|
||||
if ($city !== null) {
|
||||
$cityData = [
|
||||
'uuid' => $city->getUuid(),
|
||||
'id' => (string) $city->getId(),
|
||||
'name' => $city->getLabel(),
|
||||
'parent' => $city->getParentId() !== null ? (string) $city->getParentId() : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [$stateData, $cityData];
|
||||
}
|
||||
|
||||
private function handleFileUpload(Request $request, string $subDir): JsonResponse
|
||||
{
|
||||
$content = $request->getContent();
|
||||
$disposition = $request->headers->get('Content-Disposition', '');
|
||||
preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m);
|
||||
$filename = $m[1] ?? 'upload.jpg';
|
||||
|
||||
$tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true);
|
||||
file_put_contents($tmpPath, $content);
|
||||
|
||||
try {
|
||||
$safeFilename = $this->fileValidator->sanitizeFilename($filename);
|
||||
$mime = $this->fileValidator->detectMimeType($tmpPath);
|
||||
|
||||
$year = date('Y'); $month = date('m');
|
||||
$dir = $this->projectDir . '/public/uploads/' . $subDir . '/' . $year . '-' . $month;
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
|
||||
$storedName = uniqid('', true) . '_' . $safeFilename;
|
||||
rename($tmpPath, $dir . '/' . $storedName);
|
||||
|
||||
$url = '/uploads/' . $subDir . '/' . $year . '-' . $month . '/' . $storedName;
|
||||
|
||||
return $this->success([
|
||||
'fid' => time(),
|
||||
'uuid' => Uuid::v4()->toRfc4122(),
|
||||
'url' => $url,
|
||||
'filename' => $safeFilename,
|
||||
'filemime' => $mime,
|
||||
'filesize' => strlen($content),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
if (file_exists($tmpPath)) unlink($tmpPath);
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Category\Entity\Category;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'clinics')]
|
||||
#[ORM\Index(columns: ['user_id'], name: 'idx_clinics_owner')]
|
||||
#[ORM\Index(columns: ['city_id'], name: 'idx_clinics_city')]
|
||||
#[ORM\Index(columns: ['state_id'], name: 'idx_clinics_state')]
|
||||
class Clinic
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false)]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $info = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $address = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 50, nullable: true)]
|
||||
private ?string $telephone = null;
|
||||
|
||||
#[ORM\Column(name: 'is_24_7', type: 'boolean')]
|
||||
private bool $is247 = false;
|
||||
|
||||
#[ORM\Column(name: 'working_days', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $workingDays = null;
|
||||
|
||||
#[ORM\Column(type: 'float', nullable: true)]
|
||||
private ?float $latitude = null;
|
||||
|
||||
#[ORM\Column(type: 'float', nullable: true)]
|
||||
private ?float $longitude = null;
|
||||
|
||||
#[ORM\Column(name: 'city_id', type: 'integer', nullable: true)]
|
||||
private ?int $cityId = null;
|
||||
|
||||
#[ORM\Column(name: 'state_id', type: 'integer', nullable: true)]
|
||||
private ?int $stateId = null;
|
||||
|
||||
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $representationId = null;
|
||||
|
||||
#[ORM\Column(name: 'images_clinic', type: 'json', nullable: true)]
|
||||
private ?array $imagesClinic = null;
|
||||
|
||||
#[ORM\Column(name: 'clinic_logo', type: 'json', nullable: true)]
|
||||
private ?array $clinicLogo = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'clinic_doctors',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
)]
|
||||
private Collection $doctors;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'clinic_specialties',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
|
||||
)]
|
||||
private Collection $specialties;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'clinic_services',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
|
||||
)]
|
||||
private Collection $services;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'clinic_insurances',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
|
||||
)]
|
||||
private Collection $insurances;
|
||||
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->doctors = new ArrayCollection();
|
||||
$this->specialties = new ArrayCollection();
|
||||
$this->services = new ArrayCollection();
|
||||
$this->insurances = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getName(): ?string { return $this->name; }
|
||||
public function getInfo(): ?string { return $this->info; }
|
||||
public function getAddress(): ?string { return $this->address; }
|
||||
public function getTelephone(): ?string { return $this->telephone; }
|
||||
public function isIs247(): bool { return $this->is247; }
|
||||
public function getWorkingDays(): ?string { return $this->workingDays; }
|
||||
public function getLatitude(): ?float { return $this->latitude; }
|
||||
public function getLongitude(): ?float { return $this->longitude; }
|
||||
public function getCityId(): ?int { return $this->cityId; }
|
||||
public function getStateId(): ?int { return $this->stateId; }
|
||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||
public function getImagesClinic(): ?array { return $this->imagesClinic; }
|
||||
public function getClinicLogo(): ?array { return $this->clinicLogo; }
|
||||
public function getDoctors(): Collection { return $this->doctors; }
|
||||
public function getSpecialties(): Collection { return $this->specialties; }
|
||||
public function getServices(): Collection { return $this->services; }
|
||||
public function getInsurances(): Collection { return $this->insurances; }
|
||||
|
||||
public function setName(?string $v): self { $this->name = $v; $this->touch(); return $this; }
|
||||
public function setInfo(?string $v): self { $this->info = $v; $this->touch(); return $this; }
|
||||
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
|
||||
public function setTelephone(?string $v): self { $this->telephone = $v; $this->touch(); return $this; }
|
||||
public function setIs247(bool $v): self { $this->is247 = $v; $this->touch(); return $this; }
|
||||
public function setWorkingDays(?string $v): self { $this->workingDays = $v; $this->touch(); return $this; }
|
||||
public function setLatitude(?float $v): self { $this->latitude = $v; $this->touch(); return $this; }
|
||||
public function setLongitude(?float $v): self { $this->longitude = $v; $this->touch(); return $this; }
|
||||
public function setCityId(?int $v): self { $this->cityId = $v; $this->touch(); return $this; }
|
||||
public function setStateId(?int $v): self { $this->stateId = $v; $this->touch(); return $this; }
|
||||
public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; }
|
||||
public function setImagesClinic(?array $v): self { $this->imagesClinic = $v; $this->touch(); return $this; }
|
||||
public function setClinicLogo(?array $v): self { $this->clinicLogo = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toDetailArray(array $stateData = [], array $cityData = []): array
|
||||
{
|
||||
$formatCat = fn(Category $c) => [
|
||||
'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getLabel(),
|
||||
];
|
||||
$formatCatWithParent = fn(Category $c) => [
|
||||
'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getLabel(),
|
||||
'parent' => $c->getParentId() !== null ? (string) $c->getParentId() : null,
|
||||
];
|
||||
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'title' => $this->name,
|
||||
'images_clinic' => $this->imagesClinic ?? [],
|
||||
'clinic_logo' => $this->clinicLogo ?? [],
|
||||
'phone_number' => $this->telephone,
|
||||
'caption' => $this->info,
|
||||
'list_bime' => array_map($formatCat, $this->insurances->toArray()),
|
||||
'specialties' => array_map($formatCatWithParent, $this->specialties->toArray()),
|
||||
'services' => array_map($formatCat, $this->services->toArray()),
|
||||
'clinic_specialty' => array_map($formatCatWithParent, $this->specialties->toArray()),
|
||||
'doctors' => $this->doctors->count(),
|
||||
'doctor_list' => null,
|
||||
'city' => $cityData ? [$cityData] : [],
|
||||
'state' => $stateData ? [$stateData] : [],
|
||||
'location' => $this->address,
|
||||
'map' => [
|
||||
'latitude' => $this->latitude !== null ? (string) $this->latitude : null,
|
||||
'longitude' => $this->longitude !== null ? (string) $this->longitude : null,
|
||||
],
|
||||
'24_7' => $this->is247,
|
||||
'field_working_days' => $this->workingDays,
|
||||
];
|
||||
}
|
||||
|
||||
public function toListArray(): array
|
||||
{
|
||||
$formatCat = fn(Category $c) => [
|
||||
'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getLabel(),
|
||||
];
|
||||
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'title' => $this->name,
|
||||
'images_clinic' => $this->imagesClinic ?? [],
|
||||
'clinic_logo' => $this->clinicLogo ?? [],
|
||||
'phone_number' => $this->telephone,
|
||||
'specialties' => array_map($formatCat, $this->specialties->toArray()),
|
||||
'doctors' => $this->doctors->count(),
|
||||
'24_7' => $this->is247,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ClinicRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Clinic::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Clinic
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByUser(User $user): ?Clinic
|
||||
{
|
||||
return $this->findOneBy(['user' => $user]);
|
||||
}
|
||||
|
||||
public function findWithFilters(array $filters): array
|
||||
{
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$limit = min(50, max(1, (int) ($filters['limit'] ?? 10)));
|
||||
$sort = strtoupper($filters['sort'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->leftJoin('c.specialties', 's')
|
||||
->distinct();
|
||||
|
||||
if (!empty($filters['state'])) {
|
||||
$qb->andWhere('c.stateId = :state')->setParameter('state', (int) $filters['state']);
|
||||
}
|
||||
if (!empty($filters['city'])) {
|
||||
$qb->andWhere('c.cityId = :city')->setParameter('city', (int) $filters['city']);
|
||||
}
|
||||
if (!empty($filters['specialty'])) {
|
||||
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);
|
||||
}
|
||||
|
||||
$qb->orderBy('c.id', $sort);
|
||||
|
||||
$total = (new Paginator($qb))->count();
|
||||
$results = $qb->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return [
|
||||
'items' => $results,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'totalPages' => (int) ceil($total / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
public function save(Clinic $clinic, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($clinic);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(Clinic $clinic, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($clinic);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Category\Repository\CategoryRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Doctor\Repository\DoctorAddressRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Service\FileValidatorService;
|
||||
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 DoctorController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorAddressRepository $addressRepo,
|
||||
private readonly CategoryRepository $categoryRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
// ── Doctor CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/doctor', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
if ($this->doctorRepo->findByUser($user) !== null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پروفایل دکتر قبلاً ایجاد شده است', 409);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$name = trim($data['title'] ?? $data['name'] ?? '');
|
||||
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام دکتر الزامی است', 422, 'title');
|
||||
}
|
||||
|
||||
$doctor = new Doctor($user, $name);
|
||||
$this->hydrateDoctor($doctor, $data);
|
||||
$this->doctorRepo->save($doctor);
|
||||
|
||||
// Grant ROLE_DOCTOR to user
|
||||
$roles = $user->getRoles();
|
||||
if (!in_array('ROLE_DOCTOR', $roles, true)) {
|
||||
$roles[] = 'ROLE_DOCTOR';
|
||||
$user->setRoles(array_values(array_unique($roles)));
|
||||
$this->userRepo->save($user);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $doctor->toDetailArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $doctor->toDetailArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/doctors', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$filters = $request->query->all();
|
||||
$result = $this->doctorRepo->findWithFilters($filters);
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(Doctor $d) => $d->toListArray(), $result['items']),
|
||||
$result['total'],
|
||||
$result['page'],
|
||||
$result['limit']
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (!empty($data['title'])) $doctor->setName($data['title']);
|
||||
|
||||
$this->hydrateDoctor($doctor, $data);
|
||||
$this->doctorRepo->save($doctor);
|
||||
|
||||
return $this->success(['data' => $doctor->toDetailArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function delete(string $uuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->doctorRepo->remove($doctor);
|
||||
return $this->success(['message' => 'دکتر با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
// ── File Upload ───────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/file/upload/clinic_pro/doctor/field_image', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function uploadImage(Request $request): JsonResponse
|
||||
{
|
||||
$content = $request->getContent();
|
||||
$disposition = $request->headers->get('Content-Disposition', '');
|
||||
preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m);
|
||||
$filename = $m[1] ?? 'upload.jpg';
|
||||
|
||||
// Write to a tmp file for magic bytes validation
|
||||
$tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true);
|
||||
file_put_contents($tmpPath, $content);
|
||||
|
||||
try {
|
||||
// validate() checks size, magic bytes, and sanitizes filename
|
||||
$safeFilename = $this->fileValidator->validate($content, $filename);
|
||||
$mime = $this->fileValidator->detectMimeType($tmpPath);
|
||||
|
||||
$year = date('Y');
|
||||
$month = date('m');
|
||||
$dir = $this->projectDir . '/public/uploads/doctors/' . $year . '-' . $month;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$storedName = uniqid('', true) . '_' . $safeFilename;
|
||||
$fullPath = $dir . '/' . $storedName;
|
||||
rename($tmpPath, $fullPath);
|
||||
|
||||
$filesize = filesize($fullPath);
|
||||
$url = '/uploads/doctors/' . $year . '-' . $month . '/' . $storedName;
|
||||
|
||||
return $this->success([
|
||||
'fid' => time(),
|
||||
'uuid' => \Symfony\Component\Uid\Uuid::v4()->toRfc4122(),
|
||||
'url' => $url,
|
||||
'filename' => $safeFilename,
|
||||
'filemime' => $mime,
|
||||
'filesize' => $filesize,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
if (file_exists($tmpPath)) unlink($tmpPath);
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Doctor Addresses ──────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/clinic-pro/doctor-address', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function createAddress(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر میتواند آدرس اضافه کند', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
// Admin can specify doctor_id/doctor_uuid
|
||||
if ($doctor === null && $user->hasRole('ROLE_ADMIN')) {
|
||||
$doctorUuid = $data['doctor_uuid'] ?? null;
|
||||
if (!$doctorUuid) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid الزامی است', 422);
|
||||
}
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
}
|
||||
|
||||
$address = new DoctorAddress($doctor);
|
||||
$this->hydrateAddress($address, $data);
|
||||
$this->addressRepo->save($address);
|
||||
|
||||
return $this->success(['data' => $address->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function showAddress(int $id): JsonResponse
|
||||
{
|
||||
$address = $this->addressRepo->find($id);
|
||||
if ($address === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $address->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function updateAddress(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$address = $this->addressRepo->find($id);
|
||||
if ($address === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($address->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$this->hydrateAddress($address, $data);
|
||||
$this->addressRepo->save($address);
|
||||
|
||||
return $this->success(['data' => $address->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['DELETE'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function deleteAddress(int $id, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$address = $this->addressRepo->find($id);
|
||||
if ($address === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($address->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$this->addressRepo->remove($address);
|
||||
return $this->success(['message' => 'آدرس با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic-pro/doctor-addresses/{doctorId}', methods: ['GET'])]
|
||||
public function listAddresses(int $doctorId): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->find($doctorId);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$addresses = array_map(fn(DoctorAddress $a) => $a->toArray(), $doctor->getAddresses()->toArray());
|
||||
|
||||
return $this->success(['data' => $addresses]);
|
||||
}
|
||||
|
||||
// ── Clinic/Doctor list (stub — implemented fully in Task 06) ──────────────
|
||||
|
||||
#[Route('/api/v1/clinic/doctor-list/{clinicUuid}', methods: ['GET'])]
|
||||
public function clinicDoctorList(string $clinicUuid): JsonResponse
|
||||
{
|
||||
// Full implementation in Task 06 (Clinic entity not yet created)
|
||||
return $this->success(['data' => []]);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function hydrateDoctor(Doctor $doctor, array $data): void
|
||||
{
|
||||
if (array_key_exists('gender', $data)) $doctor->setGender($data['gender']);
|
||||
if (array_key_exists('medical_system_code', $data)) $doctor->setMedicalSystemCode($data['medical_system_code']);
|
||||
if (array_key_exists('mobile_number', $data)) $doctor->setMobileNumber($data['mobile_number']);
|
||||
if (array_key_exists('activity_time', $data)) $doctor->setActivityTime((int) $data['activity_time']);
|
||||
if (array_key_exists('degree', $data)) $doctor->setDegree($data['degree']);
|
||||
if (array_key_exists('info', $data)) $doctor->setInfo($data['info']);
|
||||
if (array_key_exists('detail', $data)) $doctor->setInfo($data['detail']);
|
||||
if (array_key_exists('active', $data)) $doctor->setActiveDoctorAppointment((bool) $data['active']);
|
||||
|
||||
// Images array (from file upload response)
|
||||
if (array_key_exists('image_data', $data)) {
|
||||
$existing = $doctor->getImages() ?? [];
|
||||
$existing[] = $data['image_data'];
|
||||
$doctor->setImages($existing);
|
||||
}
|
||||
if (array_key_exists('images', $data) && is_array($data['images'])) {
|
||||
$doctor->setImages($data['images']);
|
||||
}
|
||||
|
||||
// Specialties (array of category IDs)
|
||||
if (array_key_exists('specialties', $data) && is_array($data['specialties'])) {
|
||||
$doctor->getSpecialties()->clear();
|
||||
foreach ($data['specialties'] as $catId) {
|
||||
$cat = $this->categoryRepo->find((int) $catId);
|
||||
if ($cat !== null) {
|
||||
$doctor->getSpecialties()->add($cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expertise / doctor_services
|
||||
if (array_key_exists('doctor_services', $data) && is_array($data['doctor_services'])) {
|
||||
$doctor->getExpertise()->clear();
|
||||
foreach ($data['doctor_services'] as $catId) {
|
||||
$cat = is_numeric($catId)
|
||||
? $this->categoryRepo->find((int) $catId)
|
||||
: $this->categoryRepo->findOneBy(['label' => $catId, 'bundle' => 'doctor_services']);
|
||||
if ($cat !== null) {
|
||||
$doctor->getExpertise()->add($cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (array_key_exists('expertise', $data) && is_array($data['expertise'])) {
|
||||
$doctor->getExpertise()->clear();
|
||||
foreach ($data['expertise'] as $catId) {
|
||||
$cat = $this->categoryRepo->find((int) $catId);
|
||||
if ($cat !== null) {
|
||||
$doctor->getExpertise()->add($cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// States
|
||||
if (array_key_exists('states', $data) && is_array($data['states'])) {
|
||||
$doctor->getStates()->clear();
|
||||
foreach ($data['states'] as $catId) {
|
||||
$cat = $this->categoryRepo->find((int) $catId);
|
||||
if ($cat !== null) {
|
||||
$doctor->getStates()->add($cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cities
|
||||
if (array_key_exists('cities', $data) && is_array($data['cities'])) {
|
||||
$doctor->getCities()->clear();
|
||||
foreach ($data['cities'] as $catId) {
|
||||
$cat = $this->categoryRepo->find((int) $catId);
|
||||
if ($cat !== null) {
|
||||
$doctor->getCities()->add($cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function hydrateAddress(DoctorAddress $address, array $data): void
|
||||
{
|
||||
if (array_key_exists('name', $data)) $address->setName($data['name']);
|
||||
if (array_key_exists('address', $data)) $address->setAddress($data['address']);
|
||||
if (array_key_exists('telephone', $data)) $address->setTelephone($data['telephone']);
|
||||
|
||||
if (isset($data['map']['latitude'])) $address->setLatitude((float) $data['map']['latitude']);
|
||||
if (isset($data['map']['longitude'])) $address->setLongitude((float) $data['map']['longitude']);
|
||||
|
||||
// Also support flat keys
|
||||
if (array_key_exists('latitude', $data)) $address->setLatitude((float) $data['latitude']);
|
||||
if (array_key_exists('longitude', $data)) $address->setLongitude((float) $data['longitude']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Category\Entity\Category;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'doctors')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_doctors_user', columns: ['user_id'])]
|
||||
#[ORM\Index(columns: ['active_doctor_appointment'], name: 'idx_doctors_active')]
|
||||
class Doctor
|
||||
{
|
||||
public const DEGREES = ['expert', 'general', 'specialist', 'subspecialistplus'];
|
||||
public const GENDERS = ['man', 'woman'];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false)]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10, nullable: true)]
|
||||
private ?string $gender = null;
|
||||
|
||||
#[ORM\Column(name: 'medical_system_code', type: 'string', length: 25, nullable: true)]
|
||||
private ?string $medicalSystemCode = null;
|
||||
|
||||
#[ORM\Column(name: 'mobile_number', type: 'string', length: 15, nullable: true)]
|
||||
private ?string $mobileNumber = null;
|
||||
|
||||
#[ORM\Column(name: 'activity_time', type: 'integer', nullable: true)]
|
||||
private ?int $activityTime = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 30, nullable: true)]
|
||||
private ?string $degree = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $info = null;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $images = null;
|
||||
|
||||
#[ORM\Column(name: 'doctor_rate', type: 'float')]
|
||||
private float $doctorRate = 3.5;
|
||||
|
||||
#[ORM\Column(name: 'doctor_rate_percentage', type: 'float')]
|
||||
private float $doctorRatePercentage = 60.0;
|
||||
|
||||
#[ORM\Column(name: 'active_doctor_appointment', type: 'boolean')]
|
||||
private bool $activeDoctorAppointment = true;
|
||||
|
||||
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $representationId = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'doctor_specialties',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
|
||||
)]
|
||||
private Collection $specialties;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'doctor_expertise',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
|
||||
)]
|
||||
private Collection $expertise;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'doctor_states',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
|
||||
)]
|
||||
private Collection $states;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'doctor_cities',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
|
||||
)]
|
||||
private Collection $cities;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: DoctorAddress::class, mappedBy: 'doctor', cascade: ['remove'])]
|
||||
private Collection $addresses;
|
||||
|
||||
public function __construct(User $user, string $name)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->name = $name;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->specialties = new ArrayCollection();
|
||||
$this->expertise = new ArrayCollection();
|
||||
$this->states = new ArrayCollection();
|
||||
$this->cities = new ArrayCollection();
|
||||
$this->addresses = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getGender(): ?string { return $this->gender; }
|
||||
public function getMedicalSystemCode(): ?string { return $this->medicalSystemCode; }
|
||||
public function getMobileNumber(): ?string { return $this->mobileNumber; }
|
||||
public function getActivityTime(): ?int { return $this->activityTime; }
|
||||
public function getDegree(): ?string { return $this->degree; }
|
||||
public function getInfo(): ?string { return $this->info; }
|
||||
public function getImages(): ?array { return $this->images; }
|
||||
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 getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
public function getSpecialties(): Collection { return $this->specialties; }
|
||||
public function getExpertise(): Collection { return $this->expertise; }
|
||||
public function getStates(): Collection { return $this->states; }
|
||||
public function getCities(): Collection { return $this->cities; }
|
||||
public function getAddresses(): Collection { return $this->addresses; }
|
||||
|
||||
public function setName(string $v): self { $this->name = $v; return $this; }
|
||||
public function setGender(?string $v): self { $this->gender = $v; $this->touch(); return $this; }
|
||||
public function setMedicalSystemCode(?string $v): self { $this->medicalSystemCode = $v; $this->touch(); return $this; }
|
||||
public function setMobileNumber(?string $v): self { $this->mobileNumber = $v; $this->touch(); return $this; }
|
||||
public function setActivityTime(?int $v): self { $this->activityTime = $v; $this->touch(); return $this; }
|
||||
public function setDegree(?string $v): self { $this->degree = $v; $this->touch(); return $this; }
|
||||
public function setInfo(?string $v): self { $this->info = $v; $this->touch(); return $this; }
|
||||
public function setImages(?array $v): self { $this->images = $v; $this->touch(); return $this; }
|
||||
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; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function getExperience(): int
|
||||
{
|
||||
if ($this->activityTime === null) {
|
||||
return 0;
|
||||
}
|
||||
return max(0, (int)((time() - $this->activityTime) / (365.25 * 24 * 3600)));
|
||||
}
|
||||
|
||||
public function toListArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'gender' => $this->gender,
|
||||
'degree' => $this->degree,
|
||||
'img' => $this->images ?? [],
|
||||
'specialties' => $this->formatCategories($this->specialties),
|
||||
'satisfaction' => (string) $this->doctorRatePercentage,
|
||||
'point' => (string) $this->doctorRate,
|
||||
'free_turn' => 'نوبت آزادی موجود نیست',
|
||||
'hours_of_work' => 'برنامه کاری تنظیم نشده',
|
||||
'active' => $this->activeDoctorAppointment,
|
||||
];
|
||||
}
|
||||
|
||||
public function toDetailArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'gender' => $this->gender,
|
||||
'experience' => $this->getExperience(),
|
||||
'activity_time' => $this->activityTime !== null ? (string) $this->activityTime : null,
|
||||
'medical_system_code' => $this->medicalSystemCode,
|
||||
'detail' => $this->info,
|
||||
'degree' => $this->degree,
|
||||
'specialties' => $this->formatCategories($this->specialties),
|
||||
'img' => $this->images ?? [],
|
||||
'expertise' => $this->formatCategories($this->expertise),
|
||||
'satisfaction' => (string) $this->doctorRatePercentage,
|
||||
'point' => (string) $this->doctorRate,
|
||||
'free_turn' => 'نوبت آزادی موجود نیست',
|
||||
'hours_of_work' => 'برنامه کاری تنظیم نشده',
|
||||
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()),
|
||||
'average_rate' => ['total_rates' => null],
|
||||
'state' => $this->formatCategories($this->states),
|
||||
'city' => $this->formatCategoriesWithParent($this->cities),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatCategories(Collection $collection): array
|
||||
{
|
||||
return array_map(fn(Category $c) => [
|
||||
'uuid' => $c->getUuid(),
|
||||
'id' => (string) $c->getId(),
|
||||
'name' => $c->getLabel(),
|
||||
], $collection->toArray());
|
||||
}
|
||||
|
||||
private function formatCategoriesWithParent(Collection $collection): array
|
||||
{
|
||||
return array_map(fn(Category $c) => [
|
||||
'uuid' => $c->getUuid(),
|
||||
'id' => (string) $c->getId(),
|
||||
'name' => $c->getLabel(),
|
||||
'parent' => $c->getParentId() !== null ? (string) $c->getParentId() : null,
|
||||
], $collection->toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'doctor_addresses')]
|
||||
#[ORM\Index(columns: ['doctor_id'], name: 'idx_doctor_addresses_doctor')]
|
||||
class DoctorAddress
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $address = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 50, nullable: true)]
|
||||
private ?string $telephone = null;
|
||||
|
||||
#[ORM\Column(type: 'float', nullable: true)]
|
||||
private ?float $latitude = null;
|
||||
|
||||
#[ORM\Column(type: 'float', nullable: true)]
|
||||
private ?float $longitude = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getName(): ?string { return $this->name; }
|
||||
public function getAddress(): ?string { return $this->address; }
|
||||
public function getTelephone(): ?string { return $this->telephone; }
|
||||
public function getLatitude(): ?float { return $this->latitude; }
|
||||
public function getLongitude(): ?float { return $this->longitude; }
|
||||
|
||||
public function setName(?string $v): self { $this->name = $v; return $this; }
|
||||
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
|
||||
public function setTelephone(?string $v): self { $this->telephone = $v; $this->touch(); return $this; }
|
||||
public function setLatitude(?float $v): self { $this->latitude = $v; $this->touch(); return $this; }
|
||||
public function setLongitude(?float $v): self { $this->longitude = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'map' => [
|
||||
'latitude' => $this->latitude !== null ? (string) $this->latitude : null,
|
||||
'longitude' => $this->longitude !== null ? (string) $this->longitude : null,
|
||||
],
|
||||
'address' => $this->address,
|
||||
'telephone' => $this->telephone,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Repository;
|
||||
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class DoctorAddressRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DoctorAddress::class);
|
||||
}
|
||||
|
||||
public function save(DoctorAddress $address, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($address);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(DoctorAddress $address, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($address);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class DoctorRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Doctor::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Doctor
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByUser(User $user): ?Doctor
|
||||
{
|
||||
return $this->findOneBy(['user' => $user]);
|
||||
}
|
||||
|
||||
public function findWithFilters(array $filters): array
|
||||
{
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$limit = min(50, max(1, (int) ($filters['limit'] ?? 10)));
|
||||
$sort = strtoupper($filters['sort'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||
|
||||
$qb = $this->createQueryBuilder('d')
|
||||
->leftJoin('d.specialties', 's')
|
||||
->leftJoin('d.states', 'st')
|
||||
->leftJoin('d.cities', 'ci')
|
||||
->distinct();
|
||||
|
||||
if (!empty($filters['state'])) {
|
||||
$qb->andWhere('st.id = :state')->setParameter('state', (int) $filters['state']);
|
||||
}
|
||||
if (!empty($filters['city'])) {
|
||||
$qb->andWhere('ci.id = :city')->setParameter('city', (int) $filters['city']);
|
||||
}
|
||||
if (!empty($filters['specialty'])) {
|
||||
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);
|
||||
}
|
||||
if (!empty($filters['gender'])) {
|
||||
$qb->andWhere('d.gender = :gender')->setParameter('gender', $filters['gender']);
|
||||
}
|
||||
if (!empty($filters['degree'])) {
|
||||
$qb->andWhere('d.degree = :degree')->setParameter('degree', $filters['degree']);
|
||||
}
|
||||
if (!empty($filters['name'])) {
|
||||
$qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%');
|
||||
}
|
||||
if (isset($filters['active'])) {
|
||||
$qb->andWhere('d.activeDoctorAppointment = :active')
|
||||
->setParameter('active', (bool) $filters['active']);
|
||||
}
|
||||
|
||||
$qb->orderBy('d.doctorRate', $sort);
|
||||
|
||||
$total = (new Paginator($qb))->count();
|
||||
$results = $qb->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return [
|
||||
'items' => $results,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'totalPages' => (int) ceil($total / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
public function save(Doctor $doctor, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($doctor);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(Doctor $doctor, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($doctor);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Insurance\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Category\Repository\CategoryRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Insurance\Entity\DoctorInsurance;
|
||||
use App\Insurance\Repository\DoctorInsuranceRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
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 InsuranceController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorInsuranceRepository $repository,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly CategoryRepository $categoryRepo,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/insurance/', methods: ['POST'])]
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorId = $data['doctor_id'] ?? null;
|
||||
$categoryId = $data['category_id'] ?? null;
|
||||
|
||||
if (!$doctorId || !$categoryId) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_id و category_id الزامی است', 422);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->find((int) $doctorId);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
// Only the doctor owner or admin can add insurance
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$category = $this->categoryRepo->find((int) $categoryId);
|
||||
if ($category === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دستهبندی بیمه یافت نشد', 404);
|
||||
}
|
||||
|
||||
// Check duplicate
|
||||
$existing = $this->repository->findOneBy(['doctor' => $doctor, 'category' => $category]);
|
||||
if ($existing !== null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این بیمه قبلاً اضافه شده است', 409);
|
||||
}
|
||||
|
||||
$insurance = new DoctorInsurance($doctor, $category);
|
||||
if (isset($data['price'])) {
|
||||
$insurance->setPrice((int) $data['price']);
|
||||
}
|
||||
|
||||
$this->repository->save($insurance);
|
||||
|
||||
return $this->success(['data' => $insurance->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/insurance/{id}', methods: ['GET'])]
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$insurance = $this->repository->find($id);
|
||||
if ($insurance === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $insurance->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/insurance/{id}', methods: ['PATCH'])]
|
||||
public function update(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$insurance = $this->repository->find($id);
|
||||
if ($insurance === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($insurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('price', $data)) {
|
||||
$insurance->setPrice($data['price'] !== null ? (int) $data['price'] : null);
|
||||
}
|
||||
|
||||
$this->repository->save($insurance);
|
||||
|
||||
return $this->success(['data' => $insurance->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/insurance/{id}', methods: ['DELETE'])]
|
||||
public function delete(int $id, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$insurance = $this->repository->find($id);
|
||||
if ($insurance === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($insurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$this->repository->remove($insurance);
|
||||
|
||||
return $this->success(['message' => 'بیمه با موفقیت حذف شد']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Insurance\Entity;
|
||||
|
||||
use App\Category\Entity\Category;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'doctor_insurances')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_doctor_insurance', columns: ['doctor_id', 'category_id'])]
|
||||
#[ORM\Index(columns: ['category_id'], name: 'idx_doctor_insurance_cat')]
|
||||
class DoctorInsurance
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: false)]
|
||||
private Category $category;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
private ?int $price = null;
|
||||
|
||||
public function __construct(Doctor $doctor, Category $category)
|
||||
{
|
||||
$this->doctor = $doctor;
|
||||
$this->category = $category;
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getCategory(): Category { return $this->category; }
|
||||
public function getPrice(): ?int { return $this->price; }
|
||||
|
||||
public function setPrice(?int $v): self { $this->price = $v; return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'doctor_id' => $this->doctor->getId(),
|
||||
'category_id' => $this->category->getId(),
|
||||
'category_name' => $this->category->getLabel(),
|
||||
'bundle' => $this->category->getBundle(),
|
||||
'price' => $this->price,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Insurance\Repository;
|
||||
|
||||
use App\Insurance\Entity\DoctorInsurance;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class DoctorInsuranceRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DoctorInsurance::class);
|
||||
}
|
||||
|
||||
public function save(DoctorInsurance $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(DoctorInsurance $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Controller;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Payment\Gateway\MellatGateway;
|
||||
use App\Payment\Gateway\SepGateway;
|
||||
use App\Payment\Repository\PaymentRepository;
|
||||
use App\Payment\Service\CircuitBreakerService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
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 PaymentController extends BaseController
|
||||
{
|
||||
// Shaparak payment network callback IP ranges
|
||||
private const ALLOWED_CALLBACK_IPS = [
|
||||
'91.92.0.0/16',
|
||||
'195.146.32.0/22',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly PaymentRepository $paymentRepo,
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly MellatGateway $mellat,
|
||||
private readonly SepGateway $sep,
|
||||
private readonly CircuitBreakerService $circuitBreaker,
|
||||
private readonly string $appBaseUrl,
|
||||
private readonly string $allowedFrontendHosts = '',
|
||||
) {}
|
||||
|
||||
// ── Appointment Payment ───────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/payment/appointment', methods: ['POST'])]
|
||||
public function initiateAppointment(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$appointmentUuid = trim($data['appointment_uuid'] ?? '');
|
||||
$gatewayName = trim($data['gateway'] ?? 'mellat');
|
||||
$frontendAddress = trim($data['frontend_address'] ?? '');
|
||||
|
||||
$appointment = $this->appointmentRepo->findByUuid($appointmentUuid);
|
||||
if ($appointment === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($appointment->getUser()->getId() !== $user->getId()) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_003, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_003), 422);
|
||||
}
|
||||
|
||||
// Validate Open Redirect
|
||||
if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address');
|
||||
}
|
||||
|
||||
$gateway = $this->resolveGateway($gatewayName);
|
||||
if ($gateway === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422, 'gateway');
|
||||
}
|
||||
|
||||
if ($this->circuitBreaker->isOpen($gatewayName)) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503);
|
||||
}
|
||||
|
||||
$payment = new Payment($user, 150000, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress);
|
||||
$payment->setAppointment($appointment);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
||||
$result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl);
|
||||
|
||||
if (!$result->success) {
|
||||
$this->circuitBreaker->recordFailure($gatewayName);
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage, 503);
|
||||
}
|
||||
|
||||
$this->circuitBreaker->recordSuccess($gatewayName);
|
||||
$payment->setGatewayToken($result->token);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->success([
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'redirect_url' => $result->redirectUrl,
|
||||
'order_id' => $payment->getOrderId(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Payment Callback (public — no JWT) ───────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])]
|
||||
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$clientIp = $request->getClientIp() ?? '';
|
||||
if (!$this->isAllowedCallbackIp($clientIp)) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403);
|
||||
}
|
||||
|
||||
$callbackData = array_merge($request->query->all(), $request->request->all());
|
||||
$orderId = $callbackData['order_id'] ?? $callbackData['ResNum'] ?? '';
|
||||
|
||||
$payment = $this->paymentRepo->findByOrderId($orderId);
|
||||
if ($payment === null) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404);
|
||||
}
|
||||
|
||||
$payment->setCallbackIp($clientIp);
|
||||
|
||||
$gw = $this->resolveGateway($gateway);
|
||||
$result = $gw?->verify($callbackData) ?? null;
|
||||
|
||||
if ($result === null || !$result->success) {
|
||||
$payment->setStatus(Payment::STATUS_FAILED);
|
||||
$this->paymentRepo->save($payment);
|
||||
$this->circuitBreaker->recordFailure($gateway);
|
||||
|
||||
return $this->redirectToFrontend($payment, false);
|
||||
}
|
||||
|
||||
$this->circuitBreaker->recordSuccess($gateway);
|
||||
$payment->setStatus(Payment::STATUS_SUCCESS);
|
||||
$payment->setReferenceId($result->referenceId);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->redirectToFrontend($payment, true);
|
||||
}
|
||||
|
||||
// ── Subscription Payment ──────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/subscription-payment', methods: ['POST'])]
|
||||
public function initiateSubscription(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$gatewayName = trim($data['gateway'] ?? 'mellat');
|
||||
$frontendAddress = trim($data['frontend_address'] ?? '');
|
||||
$amountRials = (int) ($data['amount_rials'] ?? 0);
|
||||
|
||||
if ($amountRials <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_002, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_002), 422);
|
||||
}
|
||||
|
||||
if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address');
|
||||
}
|
||||
|
||||
$gateway = $this->resolveGateway($gatewayName);
|
||||
if ($gateway === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422, 'gateway');
|
||||
}
|
||||
|
||||
if ($this->circuitBreaker->isOpen($gatewayName)) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503);
|
||||
}
|
||||
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
$callbackUrl = $this->appBaseUrl . '/api/v1/subscription-payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
||||
$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl);
|
||||
|
||||
if (!$result->success) {
|
||||
$this->circuitBreaker->recordFailure($gatewayName);
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage, 503);
|
||||
}
|
||||
|
||||
$this->circuitBreaker->recordSuccess($gatewayName);
|
||||
$payment->setGatewayToken($result->token);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->success([
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'redirect_url' => $result->redirectUrl,
|
||||
'order_id' => $payment->getOrderId(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/subscription-payment/callback/{gateway}', methods: ['POST', 'GET'])]
|
||||
public function subscriptionCallback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
return $this->callback($gateway, $request);
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/payment/{uuid}', methods: ['GET'])]
|
||||
public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$payment = $this->paymentRepo->findByUuid($uuid);
|
||||
if ($payment === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($payment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $payment->toArray()]);
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private function resolveGateway(string $name): \App\Payment\Gateway\PaymentGatewayInterface|null
|
||||
{
|
||||
return match ($name) {
|
||||
'mellat' => $this->mellat,
|
||||
'sep' => $this->sep,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function isAllowedFrontend(string $url): bool
|
||||
{
|
||||
$hosts = array_filter(array_map('trim', explode(',', $this->allowedFrontendHosts)));
|
||||
if (empty($hosts)) {
|
||||
return false;
|
||||
}
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
return in_array($host, $hosts, true);
|
||||
}
|
||||
|
||||
private function isAllowedCallbackIp(string $ip): bool
|
||||
{
|
||||
if (empty($ip)) {
|
||||
return false;
|
||||
}
|
||||
foreach (self::ALLOWED_CALLBACK_IPS as $cidr) {
|
||||
[$subnet, $maskBits] = explode('/', $cidr);
|
||||
$maskBits = (int) $maskBits;
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
if ($ipLong === false || $subnetLong === false) {
|
||||
continue;
|
||||
}
|
||||
$mask = -1 << (32 - $maskBits);
|
||||
if (($ipLong & $mask) === ($subnetLong & $mask)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$base = $payment->getFrontendAddress();
|
||||
if (empty($base)) {
|
||||
return new JsonResponse([
|
||||
'success' => $success,
|
||||
'payment' => $payment->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
$sep = str_contains($base, '?') ? '&' : '?';
|
||||
$url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus();
|
||||
return new RedirectResponse($url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'payments')]
|
||||
#[ORM\Index(columns: ['order_id'], name: 'idx_payments_order')]
|
||||
#[ORM\Index(columns: ['user_id'], name: 'idx_payments_user')]
|
||||
class Payment
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_REFUNDED = 'refunded';
|
||||
|
||||
public const TYPE_APPOINTMENT = 'appointment';
|
||||
public const TYPE_SUBSCRIPTION = 'subscription';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(name: 'order_id', type: 'string', length: 64, unique: true)]
|
||||
private string $orderId;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Appointment::class)]
|
||||
#[ORM\JoinColumn(name: 'appointment_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Appointment $appointment = null;
|
||||
|
||||
#[ORM\Column(name: 'amount_rials', type: 'integer')]
|
||||
private int $amountRials;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 30)]
|
||||
private string $status = self::STATUS_PENDING;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $gateway;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 30)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(name: 'gateway_token', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $gatewayToken = null;
|
||||
|
||||
#[ORM\Column(name: 'reference_id', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $referenceId = null;
|
||||
|
||||
#[ORM\Column(name: 'frontend_address', type: 'string', length: 500, nullable: true)]
|
||||
private ?string $frontendAddress = null;
|
||||
|
||||
#[ORM\Column(name: 'callback_ip', type: 'string', length: 45, nullable: true)]
|
||||
private ?string $callbackIp = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $user, int $amountRials, string $gateway, string $type, string $frontendAddress = '')
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->orderId = 'ORD-' . strtoupper(substr(str_replace('-', '', Uuid::v4()->toRfc4122()), 0, 16));
|
||||
$this->user = $user;
|
||||
$this->amountRials = $amountRials;
|
||||
$this->gateway = $gateway;
|
||||
$this->type = $type;
|
||||
$this->frontendAddress = $frontendAddress ?: null;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getOrderId(): string { return $this->orderId; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getAppointment(): ?Appointment { return $this->appointment; }
|
||||
public function getAmountRials(): int { return $this->amountRials; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getGateway(): string { return $this->gateway; }
|
||||
public function getType(): string { return $this->type; }
|
||||
public function getGatewayToken(): ?string { return $this->gatewayToken; }
|
||||
public function getReferenceId(): ?string { return $this->referenceId; }
|
||||
public function getFrontendAddress(): ?string { return $this->frontendAddress; }
|
||||
public function getCallbackIp(): ?string { return $this->callbackIp; }
|
||||
|
||||
public function setAppointment(?Appointment $a): self { $this->appointment = $a; return $this; }
|
||||
public function setGatewayToken(?string $t): self { $this->gatewayToken = $t; $this->touch(); return $this; }
|
||||
public function setReferenceId(?string $r): self { $this->referenceId = $r; $this->touch(); return $this; }
|
||||
public function setStatus(string $s): self { $this->status = $s; $this->touch(); return $this; }
|
||||
public function setCallbackIp(?string $ip): self { $this->callbackIp = $ip; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'order_id' => $this->orderId,
|
||||
'amount_rials' => $this->amountRials,
|
||||
'status' => $this->status,
|
||||
'gateway' => $this->gateway,
|
||||
'type' => $this->type,
|
||||
'reference_id' => $this->referenceId,
|
||||
'appointment_uuid' => $this->appointment?->getUuid(),
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Gateway;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class MellatGateway implements PaymentGatewayInterface
|
||||
{
|
||||
private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly string $terminalId,
|
||||
private readonly string $username,
|
||||
private readonly string $password,
|
||||
) {}
|
||||
|
||||
public function getName(): string { return 'mellat'; }
|
||||
|
||||
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request('POST',
|
||||
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [
|
||||
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
|
||||
$resCode = $this->parseResCode($response->getContent());
|
||||
|
||||
if ($resCode !== '0') {
|
||||
return new PaymentInitResult(false, errorMessage: "Mellat error: $resCode");
|
||||
}
|
||||
|
||||
$refId = $this->parseRefId($response->getContent());
|
||||
$redirectUrl = self::PAYMENT_URL . '?RefId=' . $refId;
|
||||
|
||||
return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $refId);
|
||||
} catch (\Throwable $e) {
|
||||
return new PaymentInitResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function verify(array $callbackData): PaymentVerifyResult
|
||||
{
|
||||
$refId = $callbackData['RefId'] ?? '';
|
||||
$resCode = $callbackData['ResCode'] ?? '';
|
||||
|
||||
if ($resCode !== '0') {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Payment failed: $resCode");
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('POST',
|
||||
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [
|
||||
'body' => $this->buildVerifyPayload($refId),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
|
||||
$verifyCode = $this->parseResCode($response->getContent());
|
||||
if ($verifyCode !== '0') {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Verify failed: $verifyCode");
|
||||
}
|
||||
|
||||
return new PaymentVerifyResult(true, referenceId: $refId);
|
||||
} catch (\Throwable $e) {
|
||||
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function buildRequestPayload(int $amount, string $orderId, string $callbackUrl): string
|
||||
{
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpPayRequest>
|
||||
<terminalId>{$this->terminalId}</terminalId>
|
||||
<userName>{$this->username}</userName>
|
||||
<userPassword>{$this->password}</userPassword>
|
||||
<orderId>{$orderId}</orderId>
|
||||
<amount>{$amount}</amount>
|
||||
<localDate>{$this->date()}</localDate>
|
||||
<localTime>{$this->time()}</localTime>
|
||||
<additionalData></additionalData>
|
||||
<callBackUrl>{$callbackUrl}</callBackUrl>
|
||||
<payerId>0</payerId>
|
||||
</int:bpPayRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function buildVerifyPayload(string $refId): string
|
||||
{
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpVerifyRequest>
|
||||
<terminalId>{$this->terminalId}</terminalId>
|
||||
<userName>{$this->username}</userName>
|
||||
<userPassword>{$this->password}</userPassword>
|
||||
<orderId>{$refId}</orderId>
|
||||
<saleOrderId>{$refId}</saleOrderId>
|
||||
<saleReferenceId>{$refId}</saleReferenceId>
|
||||
</int:bpVerifyRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function parseResCode(string $xml): string
|
||||
{
|
||||
preg_match('/<return>(.*?)<\/return>/', $xml, $m);
|
||||
$parts = explode(',', $m[1] ?? '');
|
||||
return trim($parts[0] ?? '-1');
|
||||
}
|
||||
|
||||
private function parseRefId(string $xml): string
|
||||
{
|
||||
preg_match('/<return>(.*?)<\/return>/', $xml, $m);
|
||||
$parts = explode(',', $m[1] ?? '');
|
||||
return trim($parts[1] ?? '');
|
||||
}
|
||||
|
||||
private function date(): string { return date('Ymd'); }
|
||||
private function time(): string { return date('His'); }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Gateway;
|
||||
|
||||
interface PaymentGatewayInterface
|
||||
{
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Initiates payment, returns redirect URL or token.
|
||||
*/
|
||||
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult;
|
||||
|
||||
/**
|
||||
* Verifies callback and confirms payment.
|
||||
*/
|
||||
public function verify(array $callbackData): PaymentVerifyResult;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Gateway;
|
||||
|
||||
final class PaymentInitResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly string $redirectUrl = '',
|
||||
public readonly string $token = '',
|
||||
public readonly string $errorMessage = '',
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Gateway;
|
||||
|
||||
final class PaymentVerifyResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly string $referenceId = '',
|
||||
public readonly string $errorMessage = '',
|
||||
public readonly int $amountRials = 0,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Gateway;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class SepGateway implements PaymentGatewayInterface
|
||||
{
|
||||
private const TOKEN_URL = 'https://sep.shaparak.ir/onlinepg/onlinepg';
|
||||
private const PAYMENT_URL = 'https://sep.shaparak.ir/OnlinePG/OnlinePG';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly string $terminalId,
|
||||
) {}
|
||||
|
||||
public function getName(): string { return 'sep'; }
|
||||
|
||||
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', self::TOKEN_URL, [
|
||||
'json' => [
|
||||
'action' => 'token',
|
||||
'TerminalId' => $this->terminalId,
|
||||
'Amount' => $amountRials,
|
||||
'ResNum' => $orderId,
|
||||
'RedirectUrl' => $callbackUrl,
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
if (($data['status'] ?? -1) !== 1) {
|
||||
return new PaymentInitResult(false, errorMessage: $data['errorDesc'] ?? 'SEP error');
|
||||
}
|
||||
|
||||
$token = $data['token'];
|
||||
$redirectUrl = self::PAYMENT_URL . '?Token=' . $token . '&GetMethod=true';
|
||||
|
||||
return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $token);
|
||||
} catch (\Throwable $e) {
|
||||
return new PaymentInitResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function verify(array $callbackData): PaymentVerifyResult
|
||||
{
|
||||
$state = $callbackData['State'] ?? '';
|
||||
if (strtolower($state) !== 'ok') {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Payment state: $state");
|
||||
}
|
||||
|
||||
$refNum = $callbackData['RefNum'] ?? '';
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', self::TOKEN_URL, [
|
||||
'json' => [
|
||||
'action' => 'verify',
|
||||
'TerminalId' => $this->terminalId,
|
||||
'RefNum' => $refNum,
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
if (($data['TransactionDetail']['AffectiveAmount'] ?? 0) <= 0) {
|
||||
return new PaymentVerifyResult(false, errorMessage: 'SEP verify failed');
|
||||
}
|
||||
|
||||
return new PaymentVerifyResult(
|
||||
true,
|
||||
referenceId: $refNum,
|
||||
amountRials: (int) $data['TransactionDetail']['AffectiveAmount']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Repository;
|
||||
|
||||
use App\Payment\Entity\Payment;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PaymentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Payment::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Payment
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByOrderId(string $orderId): ?Payment
|
||||
{
|
||||
return $this->findOneBy(['orderId' => $orderId]);
|
||||
}
|
||||
|
||||
public function save(Payment $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Service;
|
||||
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
class CircuitBreakerService
|
||||
{
|
||||
private const FAIL_THRESHOLD = 3;
|
||||
private const OPEN_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(private readonly CacheInterface $cache) {}
|
||||
|
||||
public function isOpen(string $gateway): bool
|
||||
{
|
||||
$item = $this->cache->getItem('cb_open_' . $gateway);
|
||||
return $item->isHit();
|
||||
}
|
||||
|
||||
public function recordFailure(string $gateway): void
|
||||
{
|
||||
$countKey = 'cb_fail_' . $gateway;
|
||||
$item = $this->cache->getItem($countKey);
|
||||
$count = ($item->isHit() ? (int) $item->get() : 0) + 1;
|
||||
|
||||
$item->set($count)->expiresAfter(self::OPEN_TTL);
|
||||
$this->cache->save($item);
|
||||
|
||||
if ($count >= self::FAIL_THRESHOLD) {
|
||||
$openItem = $this->cache->getItem('cb_open_' . $gateway);
|
||||
$openItem->set(true)->expiresAfter(self::OPEN_TTL);
|
||||
$this->cache->save($openItem);
|
||||
}
|
||||
}
|
||||
|
||||
public function recordSuccess(string $gateway): void
|
||||
{
|
||||
$this->cache->deleteItem('cb_fail_' . $gateway);
|
||||
$this->cache->deleteItem('cb_open_' . $gateway);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rating\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Rating\Entity\Comment;
|
||||
use App\Rating\Entity\Like;
|
||||
use App\Rating\Entity\Rate;
|
||||
use App\Rating\Repository\CommentRepository;
|
||||
use App\Rating\Repository\LikeRepository;
|
||||
use App\Rating\Repository\RateRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
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 RatingController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RateRepository $rateRepo,
|
||||
private readonly CommentRepository $commentRepo,
|
||||
private readonly LikeRepository $likeRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
) {}
|
||||
|
||||
// ── Ratings ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/rate', methods: ['POST'])]
|
||||
public function rate(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$score = (int) ($data['score'] ?? 0);
|
||||
|
||||
if ($score < 1 || $score > 5) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'امتیاز باید بین ۱ تا ۵ باشد', 422);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$existing = $this->rateRepo->findByUserAndDoctor($user, $doctor);
|
||||
if ($existing !== null) {
|
||||
$existing->setScore($score);
|
||||
$this->rateRepo->save($existing);
|
||||
return $this->success(['data' => $existing->toArray()]);
|
||||
}
|
||||
|
||||
$rate = new Rate($user, $doctor, $score);
|
||||
$this->rateRepo->save($rate);
|
||||
|
||||
return $this->success(['data' => $rate->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/rate/{doctorUuid}', methods: ['GET'])]
|
||||
public function getAverage(string $doctorUuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['average' => $this->rateRepo->getAverageScore($doctor)]);
|
||||
}
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/comment', methods: ['POST'])]
|
||||
public function createComment(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$body = trim($data['body'] ?? '');
|
||||
|
||||
if (empty($body)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'متن نظر الزامی است', 422);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$comment = new Comment($user, $doctor, $body);
|
||||
$this->commentRepo->save($comment);
|
||||
|
||||
return $this->success(['data' => $comment->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/comments/{doctorUuid}', methods: ['GET'])]
|
||||
public function listComments(string $doctorUuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$comments = array_map(
|
||||
fn(Comment $c) => $c->toArray(),
|
||||
$this->commentRepo->findApprovedByDoctor($doctor)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $comments]);
|
||||
}
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/comment/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteComment(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->findByUuid($uuid);
|
||||
if ($comment === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($comment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$this->commentRepo->remove($comment);
|
||||
return $this->success(['message' => 'نظر با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
// ── Admin: comment moderation ─────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/admin/comments/pending', methods: ['GET'])]
|
||||
public function pendingComments(): JsonResponse
|
||||
{
|
||||
$comments = array_map(fn(Comment $c) => $c->toArray(), $this->commentRepo->findPending());
|
||||
return $this->success(['data' => $comments]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/admin/comment/{uuid}/approve', methods: ['POST'])]
|
||||
public function approveComment(string $uuid): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->findByUuid($uuid);
|
||||
if ($comment === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404);
|
||||
}
|
||||
$comment->approve();
|
||||
$this->commentRepo->save($comment);
|
||||
return $this->success(['data' => $comment->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/admin/comment/{uuid}/reject', methods: ['POST'])]
|
||||
public function rejectComment(string $uuid): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->findByUuid($uuid);
|
||||
if ($comment === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404);
|
||||
}
|
||||
$comment->reject();
|
||||
$this->commentRepo->save($comment);
|
||||
return $this->success(['data' => $comment->toArray()]);
|
||||
}
|
||||
|
||||
// ── Likes (toggle) ────────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/like/{commentUuid}', methods: ['POST'])]
|
||||
public function toggleLike(string $commentUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->findByUuid($commentUuid);
|
||||
if ($comment === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$existing = $this->likeRepo->findByUserAndComment($user, $comment);
|
||||
if ($existing !== null) {
|
||||
$this->likeRepo->remove($existing);
|
||||
return $this->success(['liked' => false, 'likes' => $comment->getLikes()->count() - 1]);
|
||||
}
|
||||
|
||||
$like = new Like($user, $comment);
|
||||
$this->likeRepo->save($like);
|
||||
return $this->success(['liked' => true, 'likes' => $comment->getLikes()->count()], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rating\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'comments')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'status'], name: 'idx_comments_doctor_status')]
|
||||
class Comment
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $body;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $status = self::STATUS_PENDING;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: Like::class, mappedBy: 'comment', cascade: ['remove'])]
|
||||
private Collection $likes;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $user, Doctor $doctor, string $body)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->doctor = $doctor;
|
||||
$this->body = $body;
|
||||
$this->likes = new ArrayCollection();
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getBody(): string { return $this->body; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getLikes(): Collection { return $this->likes; }
|
||||
|
||||
public function setBody(string $v): self { $this->body = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function approve(): self { $this->status = self::STATUS_APPROVED; $this->updatedAt = time(); return $this; }
|
||||
public function reject(): self { $this->status = self::STATUS_REJECTED; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'user_uuid' => $this->user->getUuid(),
|
||||
'body' => $this->body,
|
||||
'status' => $this->status,
|
||||
'likes' => $this->likes->count(),
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rating\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'likes')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_likes_user_comment', columns: ['user_id', 'comment_id'])]
|
||||
class Like
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'likes')]
|
||||
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Comment $comment;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(User $user, Comment $comment)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->comment = $comment;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getComment(): Comment { return $this->comment; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'comment_uuid' => $this->comment->getUuid(),
|
||||
'user_uuid' => $this->user->getUuid(),
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rating\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'rates')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_rates_user_doctor', columns: ['user_id', 'doctor_id'])]
|
||||
class Rate
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $score;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $user, Doctor $doctor, int $score)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->doctor = $doctor;
|
||||
$this->score = max(1, min(5, $score));
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getScore(): int { return $this->score; }
|
||||
|
||||
public function setScore(int $v): self { $this->score = max(1, min(5, $v)); $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'score' => $this->score,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rating\Repository;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Rating\Entity\Comment;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class CommentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Comment::class); }
|
||||
|
||||
public function findByUuid(string $uuid): ?Comment { return $this->findOneBy(['uuid' => $uuid]); }
|
||||
|
||||
/** @return Comment[] */
|
||||
public function findApprovedByDoctor(Doctor $doctor): array
|
||||
{
|
||||
return $this->findBy(['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED], ['createdAt' => 'DESC']);
|
||||
}
|
||||
|
||||
/** @return Comment[] */
|
||||
public function findPending(): array
|
||||
{
|
||||
return $this->findBy(['status' => Comment::STATUS_PENDING], ['createdAt' => 'ASC']);
|
||||
}
|
||||
|
||||
public function save(Comment $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
public function remove(Comment $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rating\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Rating\Entity\Comment;
|
||||
use App\Rating\Entity\Like;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class LikeRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Like::class); }
|
||||
|
||||
public function findByUserAndComment(User $user, Comment $comment): ?Like { return $this->findOneBy(['user' => $user, 'comment' => $comment]); }
|
||||
|
||||
public function save(Like $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
public function remove(Like $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rating\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Rating\Entity\Rate;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class RateRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Rate::class); }
|
||||
|
||||
public function findByUserAndDoctor(User $user, Doctor $doctor): ?Rate { return $this->findOneBy(['user' => $user, 'doctor' => $doctor]); }
|
||||
|
||||
public function getAverageScore(Doctor $doctor): float
|
||||
{
|
||||
$result = $this->createQueryBuilder('r')
|
||||
->select('AVG(r.score) as avg, COUNT(r.id) as cnt')
|
||||
->where('r.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor)
|
||||
->getQuery()->getSingleResult();
|
||||
return round((float)($result['avg'] ?? 0), 1);
|
||||
}
|
||||
|
||||
public function save(Rate $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace App\Representation\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Representation\Service\JalaliDateService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
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;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class RepresentationController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RepresentationRepository $representationRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly JalaliDateService $jalali,
|
||||
) {}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/representation', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($data['mobile_number'] ?? '');
|
||||
$fullName = trim($data['full_name'] ?? '');
|
||||
|
||||
if (empty($mobile) || empty($fullName)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'mobile_number و full_name الزامی است', 422);
|
||||
}
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
$this->em->persist($user);
|
||||
}
|
||||
$user->addRole('ROLE_REPRESENTATION');
|
||||
$this->em->flush();
|
||||
|
||||
if ($this->representationRepo->findByUser($user) !== null) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً نماینده است', 409);
|
||||
}
|
||||
|
||||
$rep = new Representation($user, $fullName);
|
||||
if (!empty($data['city'])) $rep->setCity($data['city']);
|
||||
if (!empty($data['commission_percent'])) $rep->setCommissionPercent((string)$data['commission_percent']);
|
||||
if (!empty($data['bank_account'])) $rep->setBankAccount($data['bank_account']);
|
||||
|
||||
$this->representationRepo->save($rep);
|
||||
|
||||
return $this->success(['data' => $rep->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/representation/{uuid}', methods: ['GET'])]
|
||||
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUuid($uuid);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $rep->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/representation/{uuid}', methods: ['PATCH'])]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUuid($uuid);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('full_name', $data)) $rep->setFullName($data['full_name']);
|
||||
if (array_key_exists('city', $data)) $rep->setCity($data['city']);
|
||||
if (array_key_exists('bank_account', $data)) $rep->setBankAccount($data['bank_account']);
|
||||
if (array_key_exists('commission_percent', $data)) $rep->setCommissionPercent((string)$data['commission_percent']);
|
||||
if (array_key_exists('active', $data)) $rep->setActive((bool)$data['active']);
|
||||
|
||||
$this->representationRepo->save($rep);
|
||||
|
||||
return $this->success(['data' => $rep->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/representation/{uuid}', methods: ['DELETE'])]
|
||||
public function delete(string $uuid): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUuid($uuid);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->representationRepo->remove($rep);
|
||||
return $this->success(['message' => 'نماینده با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
// ── Dashboard: monthly stats ──────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/representation/{uuid}/dashboard/monthly', methods: ['GET'])]
|
||||
public function dashboardMonthly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUuid($uuid);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$jYear = (int) ($request->query->get('year', $this->jalali->jalaliYear(time())));
|
||||
$jMonth = (int) ($request->query->get('month', $this->jalali->jalaliMonth(time())));
|
||||
|
||||
[$startTs, $endTs] = $this->jalali->jalaliMonthRange($jYear, $jMonth);
|
||||
|
||||
return $this->success([
|
||||
'period' => ['jalali_year' => $jYear, 'jalali_month' => $jMonth],
|
||||
'stats' => $this->buildStats($startTs, $endTs),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/representation/{uuid}/dashboard/yearly', methods: ['GET'])]
|
||||
public function dashboardYearly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUuid($uuid);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$jYear = (int) ($request->query->get('year', $this->jalali->jalaliYear(time())));
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
[$mStart, $mEnd] = $this->jalali->jalaliMonthRange($jYear, $m);
|
||||
$months[] = [
|
||||
'jalali_month' => $m,
|
||||
'stats' => $this->buildStats($mStart, $mEnd),
|
||||
];
|
||||
}
|
||||
|
||||
[$startTs, $endTs] = $this->jalali->jalaliYearRange($jYear);
|
||||
|
||||
return $this->success([
|
||||
'period' => ['jalali_year' => $jYear],
|
||||
'months' => $months,
|
||||
'totals' => $this->buildStats($startTs, $endTs),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildStats(int $startTs, int $endTs): array
|
||||
{
|
||||
$totalPayments = (int) $this->em->createQuery(
|
||||
'SELECT COUNT(p.id) FROM App\Payment\Entity\Payment p
|
||||
WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['status' => 'success', 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$totalRevenue = (int) ($this->em->createQuery(
|
||||
'SELECT SUM(p.amountRials) FROM App\Payment\Entity\Payment p
|
||||
WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['status' => 'success', 'start' => $startTs, 'end' => $endTs])
|
||||
->getSingleScalarResult() ?? 0);
|
||||
|
||||
$totalAppointments = (int) $this->em->createQuery(
|
||||
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.createdAt BETWEEN :start AND :end'
|
||||
)->setParameters(['start' => $startTs, 'end' => $endTs])->getSingleScalarResult();
|
||||
|
||||
return [
|
||||
'total_payments' => $totalPayments,
|
||||
'total_revenue_rials' => $totalRevenue,
|
||||
'total_appointments' => $totalAppointments,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Representation\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'representations')]
|
||||
class Representation
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $fullName;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20, nullable: true)]
|
||||
private ?string $mobileNumber = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(name: 'commission_percent', type: 'decimal', precision: 5, scale: 2)]
|
||||
private string $commissionPercent = '10.00';
|
||||
|
||||
#[ORM\Column(name: 'bank_account', type: 'json', nullable: true)]
|
||||
private ?array $bankAccount = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $user, string $fullName)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->fullName = $fullName;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getFullName(): string { return $this->fullName; }
|
||||
public function getMobileNumber(): ?string { return $this->mobileNumber; }
|
||||
public function getCity(): ?string { return $this->city; }
|
||||
public function getCommissionPercent(): string { return $this->commissionPercent; }
|
||||
public function getBankAccount(): ?array { return $this->bankAccount; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
|
||||
public function setFullName(string $v): self { $this->fullName = $v; $this->touch(); return $this; }
|
||||
public function setMobileNumber(?string $v): self { $this->mobileNumber = $v; $this->touch(); return $this; }
|
||||
public function setCity(?string $v): self { $this->city = $v; $this->touch(); return $this; }
|
||||
public function setCommissionPercent(string $v): self { $this->commissionPercent = $v; $this->touch(); return $this; }
|
||||
public function setBankAccount(?array $v): self { $this->bankAccount = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'full_name' => $this->fullName,
|
||||
'mobile_number' => $this->mobileNumber,
|
||||
'city' => $this->city,
|
||||
'commission_percent' => $this->commissionPercent,
|
||||
'bank_account' => $this->bankAccount,
|
||||
'active' => $this->active,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Representation\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Representation\Entity\Representation;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class RepresentationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Representation::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Representation
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByUser(User $user): ?Representation
|
||||
{
|
||||
return $this->findOneBy(['user' => $user]);
|
||||
}
|
||||
|
||||
public function save(Representation $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(Representation $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Representation\Service;
|
||||
|
||||
/**
|
||||
* Gregorian ↔ Jalali (Solar Hijri) conversion.
|
||||
*/
|
||||
class JalaliDateService
|
||||
{
|
||||
public function toJalali(\DateTimeInterface $date): array
|
||||
{
|
||||
[$gy, $gm, $gd] = [(int)$date->format('Y'), (int)$date->format('m'), (int)$date->format('d')];
|
||||
return $this->gregorianToJalali($gy, $gm, $gd);
|
||||
}
|
||||
|
||||
/** Returns [year, month, day] in Jalali */
|
||||
public function gregorianToJalali(int $gy, int $gm, int $gd): array
|
||||
{
|
||||
$g_d_no = 365 * $gy + (int)(($gy + 3) / 4) - (int)(($gy + 99) / 100) + (int)(($gy + 399) / 400);
|
||||
$g_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||
for ($i = 1; $i < $gm; $i++) $g_d_no += $g_days[$i];
|
||||
if ($gm > 2 && (($gy % 4 === 0 && $gy % 100 !== 0) || ($gy % 400 === 0))) $g_d_no++;
|
||||
|
||||
$j_d_no = $g_d_no - 79;
|
||||
$j_np = (int)($j_d_no / 12053);
|
||||
$j_d_no %= 12053;
|
||||
$jy = 979 + 33 * $j_np + 4 * (int)($j_d_no / 1461);
|
||||
$j_d_no %= 1461;
|
||||
|
||||
if ($j_d_no >= 366) {
|
||||
$jy += (int)(($j_d_no - 1) / 365);
|
||||
$j_d_no = ($j_d_no - 1) % 365;
|
||||
}
|
||||
|
||||
$j_days = [0, 31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
|
||||
$jm = 0;
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
if ($j_d_no < $j_days[$i]) { $jm = $i; break; }
|
||||
$j_d_no -= $j_days[$i];
|
||||
}
|
||||
$jd = $j_d_no + 1;
|
||||
|
||||
return [$jy, $jm, $jd];
|
||||
}
|
||||
|
||||
public function jalaliYear(int $timestamp): int
|
||||
{
|
||||
return $this->gregorianToJalali(
|
||||
(int)date('Y', $timestamp),
|
||||
(int)date('m', $timestamp),
|
||||
(int)date('d', $timestamp)
|
||||
)[0];
|
||||
}
|
||||
|
||||
public function jalaliMonth(int $timestamp): int
|
||||
{
|
||||
return $this->gregorianToJalali(
|
||||
(int)date('Y', $timestamp),
|
||||
(int)date('m', $timestamp),
|
||||
(int)date('d', $timestamp)
|
||||
)[1];
|
||||
}
|
||||
|
||||
/** Returns [startTs, endTs] for a given Jalali month/year */
|
||||
public function jalaliMonthRange(int $jYear, int $jMonth): array
|
||||
{
|
||||
// Convert first day of Jalali month to Gregorian
|
||||
$start = $this->jalaliToGregorian($jYear, $jMonth, 1);
|
||||
$daysInMonth = $jMonth <= 6 ? 31 : ($jMonth <= 11 ? 30 : 29);
|
||||
$end = $this->jalaliToGregorian($jYear, $jMonth, $daysInMonth);
|
||||
|
||||
$startTs = mktime(0, 0, 0, $start[1], $start[2], $start[0]);
|
||||
$endTs = mktime(23, 59, 59, $end[1], $end[2], $end[0]);
|
||||
|
||||
return [$startTs, $endTs];
|
||||
}
|
||||
|
||||
public function jalaliYearRange(int $jYear): array
|
||||
{
|
||||
$start = $this->jalaliToGregorian($jYear, 1, 1);
|
||||
$end = $this->jalaliToGregorian($jYear, 12, 29);
|
||||
|
||||
return [
|
||||
mktime(0, 0, 0, $start[1], $start[2], $start[0]),
|
||||
mktime(23, 59, 59, $end[1], $end[2], $end[0]),
|
||||
];
|
||||
}
|
||||
|
||||
public function jalaliToGregorian(int $jy, int $jm, int $jd): array
|
||||
{
|
||||
$jy += 1595;
|
||||
$days = -355779 + 365 * $jy + (int)($jy / 33) * 8 + (int)((($jy % 33) + 3) / 4) + $jd;
|
||||
$jm_days = [0, 31, 62, 93, 124, 155, 186, 216, 246, 276, 306, 336];
|
||||
$days += $jm_days[$jm - 1];
|
||||
|
||||
$gy = 400 * (int)($days / 146097);
|
||||
$days %= 146097;
|
||||
if ($days > 36524) { $gy += 100 * (int)(--$days / 36524); $days %= 36524; if ($days >= 365) $days++; }
|
||||
$gy += 4 * (int)($days / 1461);
|
||||
$days %= 1461;
|
||||
if ($days > 365) { $gy += (int)(($days - 1) / 365); $days = ($days - 1) % 365; }
|
||||
$gd = $days + 1;
|
||||
$gm_days = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
$gm = 0;
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
if ($gd <= $gm_days[$i]) { $gm = $i; break; }
|
||||
$gd -= $gm_days[$i];
|
||||
}
|
||||
|
||||
return [$gy, $gm, $gd];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Secretary\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
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 SecretaryController extends BaseController
|
||||
{
|
||||
// TODO: link to subscription plan (Task 15). Basic plan = 1, advanced = 3
|
||||
private const MAX_SECRETARIES = 1;
|
||||
|
||||
public function __construct(
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/secretary', methods: ['POST'])]
|
||||
public function create(Request $request, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$mobile = trim($data['mobile_number'] ?? '');
|
||||
|
||||
if (empty($doctorUuid) || empty($mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid و mobile_number الزامی است', 422);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
// Only the doctor owner or admin can create secretary
|
||||
if ($doctor->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
// Check plan limit
|
||||
$activeCount = $this->secretaryRepo->countActiveByDoctor($doctor);
|
||||
if ($activeCount >= self::MAX_SECRETARIES) {
|
||||
return $this->error(ErrorCodes::ERR_SECRETARY_001, ErrorCodes::message(ErrorCodes::ERR_SECRETARY_001), 422);
|
||||
}
|
||||
|
||||
// Find or create secretary user
|
||||
$secretaryUser = $this->userRepo->findByMobile($mobile);
|
||||
if ($secretaryUser === null) {
|
||||
$secretaryUser = new User($mobile);
|
||||
// Set a temporary password if provided
|
||||
if (!empty($data['password'])) {
|
||||
$hash = $this->hasher->hashPassword($secretaryUser, $data['password']);
|
||||
$secretaryUser->setPasswordHash($hash);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign ROLE_SECRETARY
|
||||
$roles = $secretaryUser->getRoles();
|
||||
if (!in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$roles[] = 'ROLE_SECRETARY';
|
||||
$secretaryUser->setRoles(array_values(array_unique($roles)));
|
||||
}
|
||||
$this->userRepo->save($secretaryUser);
|
||||
|
||||
// Check duplicate
|
||||
$existing = $this->secretaryRepo->findOneBy(['doctor' => $doctor, 'secretary' => $secretaryUser]);
|
||||
if ($existing !== null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این منشی قبلاً اضافه شده است', 409);
|
||||
}
|
||||
|
||||
$secretary = new DoctorSecretary($doctor, $secretaryUser);
|
||||
|
||||
// Apply custom permissions if provided
|
||||
if (!empty($data['permissions'])) {
|
||||
$secretary->mergePermissions($data['permissions']);
|
||||
}
|
||||
|
||||
$this->secretaryRepo->save($secretary);
|
||||
|
||||
return $this->success(['data' => $secretary->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/secretary/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
$secretary = $this->secretaryRepo->findByUuid($uuid);
|
||||
if ($secretary === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منشی یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManage($secretary, $currentUser)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $secretary->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/secretary/{uuid}', methods: ['PATCH'])]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
$secretary = $this->secretaryRepo->findByUuid($uuid);
|
||||
if ($secretary === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منشی یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManage($secretary, $currentUser)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (array_key_exists('active', $data)) {
|
||||
$secretary->setActive((bool) $data['active']);
|
||||
}
|
||||
|
||||
if (!empty($data['permissions'])) {
|
||||
$secretary->mergePermissions($data['permissions']);
|
||||
}
|
||||
|
||||
$this->secretaryRepo->save($secretary);
|
||||
|
||||
return $this->success(['data' => $secretary->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/secretary/{uuid}', methods: ['DELETE'])]
|
||||
public function delete(string $uuid, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
$secretary = $this->secretaryRepo->findByUuid($uuid);
|
||||
if ($secretary === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منشی یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManage($secretary, $currentUser)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$this->secretaryRepo->remove($secretary);
|
||||
|
||||
return $this->success(['message' => 'منشی با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])]
|
||||
public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$secretaries = array_map(
|
||||
fn(DoctorSecretary $s) => $s->toArray(),
|
||||
$this->secretaryRepo->findByDoctor($doctor)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $secretaries]);
|
||||
}
|
||||
|
||||
private function canManage(DoctorSecretary $secretary, User $user): bool
|
||||
{
|
||||
return $secretary->getDoctor()->getUser()->getId() === $user->getId()
|
||||
|| $user->hasRole('ROLE_ADMIN');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Secretary\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'doctor_secretaries')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_doctor_secretaries_pair', columns: ['doctor_id', 'secretary_id'])]
|
||||
class DoctorSecretary
|
||||
{
|
||||
public const DEFAULT_PERMISSIONS = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
|
||||
'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'clinic_info' => ['view' => true, 'update' => false],
|
||||
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
],
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'secretary_id', referencedColumnName: 'id', nullable: false)]
|
||||
private User $secretary;
|
||||
|
||||
#[ORM\Column(name: 'permission', type: 'json', nullable: true)]
|
||||
private ?array $permissions = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, User $secretary)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->secretary = $secretary;
|
||||
$this->permissions = self::DEFAULT_PERMISSIONS;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getSecretary(): User { return $this->secretary; }
|
||||
public function getPermissions(): array { return $this->permissions ?? self::DEFAULT_PERMISSIONS; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setPermissions(array $v): self { $this->permissions = $v; $this->touch(); return $this; }
|
||||
|
||||
/** Deep merge: only provided resources/actions are updated */
|
||||
public function mergePermissions(array $patch): void
|
||||
{
|
||||
$current = $this->getPermissions();
|
||||
|
||||
if (isset($patch['resources']) && is_array($patch['resources'])) {
|
||||
foreach ($patch['resources'] as $resource => $actions) {
|
||||
if (!is_array($actions)) continue;
|
||||
foreach ($actions as $action => $value) {
|
||||
$current['resources'][$resource][$action] = (bool) $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($patch['version'])) {
|
||||
$current['version'] = (int) $patch['version'];
|
||||
}
|
||||
|
||||
$this->permissions = $current;
|
||||
$this->touch();
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'user' => [
|
||||
'uuid' => $this->secretary->getUuid(),
|
||||
'realname' => $this->secretary->getRealName(),
|
||||
'mobile' => $this->secretary->getMobileNumber(),
|
||||
'picture' => null,
|
||||
],
|
||||
'doctor' => [
|
||||
'uuid' => $this->doctor->getUuid(),
|
||||
'name' => $this->doctor->getName(),
|
||||
],
|
||||
'active' => $this->active,
|
||||
'permissions' => $this->getPermissions(),
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Secretary\Repository;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class DoctorSecretaryRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DoctorSecretary::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?DoctorSecretary
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function countActiveByDoctor(Doctor $doctor): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('s')
|
||||
->select('COUNT(s.id)')
|
||||
->where('s.doctor = :doctor')
|
||||
->andWhere('s.active = true')
|
||||
->setParameter('doctor', $doctor)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/** @return DoctorSecretary[] */
|
||||
public function findByDoctor(Doctor $doctor): array
|
||||
{
|
||||
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
|
||||
}
|
||||
|
||||
public function save(DoctorSecretary $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(DoctorSecretary $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Secretary\Security;
|
||||
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
|
||||
class SecretaryPermissionChecker
|
||||
{
|
||||
public function can(DoctorSecretary $secretary, string $resource, string $action): bool
|
||||
{
|
||||
if (!$secretary->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$permissions = $secretary->getPermissions();
|
||||
|
||||
return (bool) ($permissions['resources'][$resource][$action] ?? false);
|
||||
}
|
||||
|
||||
public function canAll(DoctorSecretary $secretary, string $resource, array $actions): bool
|
||||
{
|
||||
return array_reduce(
|
||||
$actions,
|
||||
fn(bool $carry, string $action) => $carry && $this->can($secretary, $resource, $action),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settlement\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Settlement\Entity\Settlement;
|
||||
use App\Settlement\Entity\WalletTransaction;
|
||||
use App\Settlement\Repository\SettlementRepository;
|
||||
use App\Settlement\Repository\WalletTransactionRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
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 SettlementController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettlementRepository $settlementRepo,
|
||||
private readonly WalletTransactionRepository $walletRepo,
|
||||
) {}
|
||||
|
||||
// ── Wallet ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/wallet/balance', methods: ['GET'])]
|
||||
public function balance(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$balance = $this->settlementRepo->getWalletBalance($user);
|
||||
$transactions = array_map(
|
||||
fn(WalletTransaction $t) => $t->toArray(),
|
||||
$this->walletRepo->findByUser($user, 10)
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'balance_rials' => $balance,
|
||||
'recent_transactions' => $transactions,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/wallet/transactions', methods: ['GET'])]
|
||||
public function transactions(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$transactions = array_map(
|
||||
fn(WalletTransaction $t) => $t->toArray(),
|
||||
$this->walletRepo->findByUser($user)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $transactions]);
|
||||
}
|
||||
|
||||
// ── Settlement Requests ───────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/settlement', methods: ['POST'])]
|
||||
public function request(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$amountRials = (int) ($data['amount_rials'] ?? 0);
|
||||
$bankAccount = $data['bank_account'] ?? null;
|
||||
|
||||
if ($amountRials <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ برداشت باید بیشتر از صفر باشد', 422);
|
||||
}
|
||||
|
||||
$balance = $this->settlementRepo->getWalletBalance($user);
|
||||
if ($amountRials > $balance) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'موجودی کافی نیست', 422);
|
||||
}
|
||||
|
||||
$settlement = new Settlement($user, $amountRials, $bankAccount);
|
||||
$this->settlementRepo->save($settlement);
|
||||
|
||||
// Reserve amount by debit transaction
|
||||
$tx = new WalletTransaction($user, $amountRials, WalletTransaction::TYPE_DEBIT, $balance - $amountRials);
|
||||
$tx->setDescription('درخواست برداشت ' . $settlement->getUuid());
|
||||
$this->walletRepo->save($tx);
|
||||
|
||||
return $this->success(['data' => $settlement->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/settlement', methods: ['GET'])]
|
||||
public function listMine(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$settlements = array_map(
|
||||
fn(Settlement $s) => $s->toArray(),
|
||||
$this->settlementRepo->findByUser($user)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $settlements]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/settlement/{uuid}', methods: ['GET'])]
|
||||
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$settlement = $this->settlementRepo->findByUuid($uuid);
|
||||
if ($settlement === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($settlement->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $settlement->toArray()]);
|
||||
}
|
||||
|
||||
// ── Admin Actions ─────────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/settlement/{uuid}/approve', methods: ['POST'])]
|
||||
public function approve(string $uuid, Request $request, #[CurrentUser] User $admin): JsonResponse
|
||||
{
|
||||
$settlement = $this->settlementRepo->findByUuid($uuid);
|
||||
if ($settlement === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($settlement->getStatus() !== Settlement::STATUS_PENDING) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این درخواست قابل تأیید نیست', 422);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$settlement->approve($admin->getId(), $data['note'] ?? null);
|
||||
$this->settlementRepo->save($settlement);
|
||||
|
||||
return $this->success(['data' => $settlement->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/settlement/{uuid}/reject', methods: ['POST'])]
|
||||
public function reject(string $uuid, Request $request, #[CurrentUser] User $admin): JsonResponse
|
||||
{
|
||||
$settlement = $this->settlementRepo->findByUuid($uuid);
|
||||
if ($settlement === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($settlement->getStatus() !== Settlement::STATUS_PENDING) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این درخواست قابل رد نیست', 422);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$note = trim($data['note'] ?? '');
|
||||
if (empty($note)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دلیل رد الزامی است', 422);
|
||||
}
|
||||
|
||||
$settlement->reject($admin->getId(), $note);
|
||||
$this->settlementRepo->save($settlement);
|
||||
|
||||
// Refund the reserved amount back to wallet
|
||||
$balance = $this->settlementRepo->getWalletBalance($settlement->getUser());
|
||||
$tx = new WalletTransaction(
|
||||
$settlement->getUser(),
|
||||
$settlement->getAmountRials(),
|
||||
WalletTransaction::TYPE_CREDIT,
|
||||
$balance + $settlement->getAmountRials()
|
||||
);
|
||||
$tx->setDescription('برگشت برداشت رد شده ' . $settlement->getUuid());
|
||||
$this->walletRepo->save($tx);
|
||||
|
||||
return $this->success(['data' => $settlement->toArray()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settlement\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'settlements')]
|
||||
#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_settlements_user_status')]
|
||||
class Settlement
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(name: 'amount_rials', type: 'integer')]
|
||||
private int $amountRials;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $status = self::STATUS_PENDING;
|
||||
|
||||
#[ORM\Column(name: 'bank_account', type: 'json', nullable: true)]
|
||||
private ?array $bankAccount = null;
|
||||
|
||||
#[ORM\Column(name: 'admin_note', type: 'string', length: 500, nullable: true)]
|
||||
private ?string $adminNote = null;
|
||||
|
||||
#[ORM\Column(name: 'reviewed_by', type: 'integer', nullable: true)]
|
||||
private ?int $reviewedBy = null;
|
||||
|
||||
#[ORM\Column(name: 'reviewed_at', type: 'integer', nullable: true)]
|
||||
private ?int $reviewedAt = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $user, int $amountRials, ?array $bankAccount = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->amountRials = $amountRials;
|
||||
$this->bankAccount = $bankAccount;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getAmountRials(): int { return $this->amountRials; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getBankAccount(): ?array { return $this->bankAccount; }
|
||||
public function getAdminNote(): ?string { return $this->adminNote; }
|
||||
|
||||
public function approve(int $adminUserId, ?string $note = null): self
|
||||
{
|
||||
$this->status = self::STATUS_APPROVED;
|
||||
$this->reviewedBy = $adminUserId;
|
||||
$this->reviewedAt = time();
|
||||
$this->adminNote = $note;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function reject(int $adminUserId, string $note): self
|
||||
{
|
||||
$this->status = self::STATUS_REJECTED;
|
||||
$this->reviewedBy = $adminUserId;
|
||||
$this->reviewedAt = time();
|
||||
$this->adminNote = $note;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPaid(): self
|
||||
{
|
||||
$this->status = self::STATUS_PAID;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'amount_rials' => $this->amountRials,
|
||||
'status' => $this->status,
|
||||
'bank_account' => $this->bankAccount,
|
||||
'admin_note' => $this->adminNote,
|
||||
'reviewed_at' => $this->reviewedAt,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settlement\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Payment\Entity\Payment;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'wallet_transactions')]
|
||||
#[ORM\Index(columns: ['user_id', 'created_at'], name: 'idx_wallet_user_date')]
|
||||
class WalletTransaction
|
||||
{
|
||||
public const TYPE_CREDIT = 'credit';
|
||||
public const TYPE_DEBIT = 'debit';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Payment::class)]
|
||||
#[ORM\JoinColumn(name: 'payment_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Payment $payment = null;
|
||||
|
||||
#[ORM\Column(name: 'amount_rials', type: 'integer')]
|
||||
private int $amountRials;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(name: 'balance_after', type: 'integer')]
|
||||
private int $balanceAfter;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(User $user, int $amountRials, string $type, int $balanceAfter)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->amountRials = $amountRials;
|
||||
$this->type = $type;
|
||||
$this->balanceAfter = $balanceAfter;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getPayment(): ?Payment { return $this->payment; }
|
||||
public function getAmountRials(): int { return $this->amountRials; }
|
||||
public function getType(): string { return $this->type; }
|
||||
public function getDescription(): ?string { return $this->description; }
|
||||
public function getBalanceAfter(): int { return $this->balanceAfter; }
|
||||
|
||||
public function setPayment(?Payment $p): self { $this->payment = $p; return $this; }
|
||||
public function setDescription(?string $d): self { $this->description = $d; return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'amount_rials' => $this->amountRials,
|
||||
'type' => $this->type,
|
||||
'description' => $this->description,
|
||||
'balance_after' => $this->balanceAfter,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settlement\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Settlement\Entity\Settlement;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SettlementRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Settlement::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Settlement
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return Settlement[] */
|
||||
public function findByUser(User $user): array
|
||||
{
|
||||
return $this->findBy(['user' => $user], ['createdAt' => 'DESC']);
|
||||
}
|
||||
|
||||
/** Balance = sum of credits - sum of debits from wallet_transactions */
|
||||
public function getWalletBalance(User $user): int
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$credit = (int) ($em->createQuery(
|
||||
'SELECT SUM(w.amountRials) FROM App\Settlement\Entity\WalletTransaction w
|
||||
WHERE w.user = :user AND w.type = :type'
|
||||
)->setParameters(['user' => $user, 'type' => 'credit'])->getSingleScalarResult() ?? 0);
|
||||
|
||||
$debit = (int) ($em->createQuery(
|
||||
'SELECT SUM(w.amountRials) FROM App\Settlement\Entity\WalletTransaction w
|
||||
WHERE w.user = :user AND w.type = :type'
|
||||
)->setParameters(['user' => $user, 'type' => 'debit'])->getSingleScalarResult() ?? 0);
|
||||
|
||||
return $credit - $debit;
|
||||
}
|
||||
|
||||
public function save(Settlement $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settlement\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Settlement\Entity\WalletTransaction;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class WalletTransactionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, WalletTransaction::class);
|
||||
}
|
||||
|
||||
/** @return WalletTransaction[] */
|
||||
public function findByUser(User $user, int $limit = 50): array
|
||||
{
|
||||
return $this->findBy(['user' => $user], ['createdAt' => 'DESC'], $limit);
|
||||
}
|
||||
|
||||
public function save(WalletTransaction $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Constant;
|
||||
|
||||
class ErrorCodes
|
||||
{
|
||||
// Auth
|
||||
public const ERR_AUTH_001 = 'ERR_AUTH_001';
|
||||
public const ERR_AUTH_002 = 'ERR_AUTH_002';
|
||||
public const ERR_AUTH_003 = 'ERR_AUTH_003';
|
||||
public const ERR_AUTH_004 = 'ERR_AUTH_004';
|
||||
public const ERR_AUTH_005 = 'ERR_AUTH_005';
|
||||
public const ERR_AUTH_006 = 'ERR_AUTH_006';
|
||||
|
||||
// Validation
|
||||
public const ERR_VALIDATION_001 = 'ERR_VALIDATION_001';
|
||||
public const ERR_VALIDATION_002 = 'ERR_VALIDATION_002';
|
||||
|
||||
// Not Found
|
||||
public const ERR_NOT_FOUND_001 = 'ERR_NOT_FOUND_001';
|
||||
|
||||
// Conflict
|
||||
public const ERR_CONFLICT_001 = 'ERR_CONFLICT_001';
|
||||
|
||||
// Forbidden
|
||||
public const ERR_FORBIDDEN_001 = 'ERR_FORBIDDEN_001';
|
||||
|
||||
// Payment
|
||||
public const ERR_PAYMENT_001 = 'ERR_PAYMENT_001';
|
||||
public const ERR_PAYMENT_002 = 'ERR_PAYMENT_002';
|
||||
public const ERR_PAYMENT_003 = 'ERR_PAYMENT_003';
|
||||
|
||||
// Appointment
|
||||
public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001';
|
||||
public const ERR_APPOINTMENT_002 = 'ERR_APPOINTMENT_002';
|
||||
|
||||
// File
|
||||
public const ERR_FILE_001 = 'ERR_FILE_001';
|
||||
public const ERR_FILE_002 = 'ERR_FILE_002';
|
||||
|
||||
// SMS
|
||||
public const ERR_SMS_001 = 'ERR_SMS_001';
|
||||
public const ERR_SMS_002 = 'ERR_SMS_002';
|
||||
public const ERR_SMS_003 = 'ERR_SMS_003';
|
||||
|
||||
// Secretary
|
||||
public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001';
|
||||
|
||||
// Rate Limit
|
||||
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
|
||||
|
||||
public static function message(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
self::ERR_AUTH_001 => 'توکن JWT منقضی شده یا نامعتبر است',
|
||||
self::ERR_AUTH_002 => 'کد OTP نامعتبر است',
|
||||
self::ERR_AUTH_003 => 'کد OTP منقضی شده است',
|
||||
self::ERR_AUTH_004 => 'تعداد تلاشهای OTP به حد مجاز رسیده است',
|
||||
self::ERR_AUTH_005 => 'نام کاربری یا رمز عبور اشتباه است',
|
||||
self::ERR_AUTH_006 => 'این نوع حساب فقط از طریق کد OTP وارد میشود',
|
||||
self::ERR_VALIDATION_001 => 'ورودی نامعتبر است',
|
||||
self::ERR_VALIDATION_002 => 'فیلد الزامی وارد نشده است',
|
||||
self::ERR_NOT_FOUND_001 => 'منبع درخواستی یافت نشد',
|
||||
self::ERR_FORBIDDEN_001 => 'دسترسی به این منبع مجاز نیست',
|
||||
self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست',
|
||||
self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است',
|
||||
self::ERR_PAYMENT_003 => 'وضعیت نوبت برای پرداخت مناسب نیست',
|
||||
self::ERR_APPOINTMENT_001 => 'اسلات انتخابشده در دسترس نیست',
|
||||
self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست',
|
||||
self::ERR_FILE_001 => 'فرمت فایل مجاز نیست',
|
||||
self::ERR_FILE_002 => 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)',
|
||||
self::ERR_SMS_001 => 'موجودی پیامک کافی نیست',
|
||||
self::ERR_SMS_002 => 'متغیر نامعتبر در تمپلیت',
|
||||
self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است',
|
||||
self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمیدهد',
|
||||
self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است',
|
||||
self::ERR_RATE_LIMIT_001 => 'درخواستهای زیاد. لطفاً بعداً تلاش کنید',
|
||||
default => 'خطای ناشناخته',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
|
||||
abstract class BaseController extends AbstractController
|
||||
{
|
||||
protected function success(mixed $data, int $status = 200, array $meta = []): JsonResponse
|
||||
{
|
||||
$response = ['success' => true, 'data' => $data];
|
||||
if (!empty($meta)) {
|
||||
$response['meta'] = $meta;
|
||||
}
|
||||
return new JsonResponse($response, $status);
|
||||
}
|
||||
|
||||
protected function paginated(mixed $data, int $total, int $page, int $limit): JsonResponse
|
||||
{
|
||||
return $this->success($data, 200, [
|
||||
'totalRecords' => $total,
|
||||
'totalPages' => (int) ceil($total / max($limit, 1)),
|
||||
'currentPage' => $page,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function error(string $code, string $message, int $status = 400, ?string $field = null): JsonResponse
|
||||
{
|
||||
$err = ['code' => $code, 'message' => $message];
|
||||
if ($field !== null) {
|
||||
$err['field'] = $field;
|
||||
}
|
||||
return new JsonResponse(['success' => false, 'data' => null, 'errors' => [$err]], $status);
|
||||
}
|
||||
|
||||
protected function validationError(array $violations): JsonResponse
|
||||
{
|
||||
$errors = [];
|
||||
foreach ($violations as $field => $messages) {
|
||||
foreach ((array) $messages as $message) {
|
||||
$errors[] = [
|
||||
'code' => 'ERR_VALIDATION_001',
|
||||
'field' => $field,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
return new JsonResponse(['success' => false, 'data' => null, 'errors' => $errors], 422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Controller;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class HealthController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly CacheInterface $cache,
|
||||
) {}
|
||||
|
||||
#[Route('/health', methods: ['GET'])]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$checks = [];
|
||||
$status = 'ok';
|
||||
|
||||
try {
|
||||
$this->em->getConnection()->executeQuery('SELECT 1');
|
||||
$checks['database'] = 'ok';
|
||||
} catch (\Throwable) {
|
||||
$checks['database'] = 'error';
|
||||
$status = 'degraded';
|
||||
}
|
||||
|
||||
try {
|
||||
$item = $this->cache->getItem('health_check');
|
||||
$checks['redis'] = 'ok';
|
||||
} catch (\Throwable) {
|
||||
$checks['redis'] = 'error';
|
||||
$status = 'degraded';
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => $status,
|
||||
'checks' => $checks,
|
||||
'timestamp' => time(),
|
||||
], $status === 'ok' ? 200 : 503);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\EventSubscriber;
|
||||
|
||||
use App\Shared\Exception\AppException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
|
||||
class ExceptionSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly TokenStorageInterface $tokenStorage,
|
||||
) {}
|
||||
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
$exception = $event->getThrowable();
|
||||
|
||||
if ($exception instanceof AppException) {
|
||||
$err = ['code' => $exception->getErrorCode(), 'message' => $exception->getMessage()];
|
||||
if ($exception->getField()) {
|
||||
$err['field'] = $exception->getField();
|
||||
}
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [$err]],
|
||||
$exception->getHttpStatus()
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof TooManyRequestsHttpException) {
|
||||
$headers = [];
|
||||
$retryAfter = $exception->getHeaders()['Retry-After'] ?? null;
|
||||
if ($retryAfter !== null) {
|
||||
$headers['Retry-After'] = $retryAfter;
|
||||
}
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_RATE_LIMIT_001', 'message' => 'درخواستهای زیاد. لطفاً بعداً تلاش کنید']]],
|
||||
429,
|
||||
$headers
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof NotFoundHttpException) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_NOT_FOUND_001', 'message' => 'منبع درخواستی یافت نشد']]],
|
||||
404
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof AccessDeniedHttpException) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]],
|
||||
403
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof UnauthorizedHttpException) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]],
|
||||
401
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Security exceptions not yet wrapped into HttpException
|
||||
if ($exception instanceof AccessDeniedException) {
|
||||
$token = $this->tokenStorage->getToken();
|
||||
$isAuthenticated = $token !== null && $token->getUser() !== null;
|
||||
|
||||
if (!$isAuthenticated) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]],
|
||||
401
|
||||
));
|
||||
} else {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]],
|
||||
403
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof AuthenticationException) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]],
|
||||
401
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic fallback: never leak stack traces or internal details in API responses
|
||||
$this->logger->error('Unhandled exception', [
|
||||
'exception' => $exception,
|
||||
'path' => $event->getRequest()->getPathInfo(),
|
||||
]);
|
||||
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_INTERNAL_001', 'message' => 'خطای داخلی سرور']]],
|
||||
500
|
||||
));
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::EXCEPTION => ['onKernelException', 10]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $event->getResponse();
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('X-Frame-Options', 'DENY');
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
|
||||
if ($event->getRequest()->isSecure()) {
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'none'");
|
||||
}
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::RESPONSE => 'onKernelResponse'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Exception;
|
||||
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
|
||||
class AppException extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $errorCode,
|
||||
?string $message = null,
|
||||
private readonly int $httpStatus = 400,
|
||||
private readonly ?string $field = null,
|
||||
) {
|
||||
parent::__construct($message ?? ErrorCodes::message($errorCode));
|
||||
}
|
||||
|
||||
public function getErrorCode(): string { return $this->errorCode; }
|
||||
public function getHttpStatus(): int { return $this->httpStatus; }
|
||||
public function getField(): ?string { return $this->field; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Message;
|
||||
|
||||
class SendSmsMessage
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $mobile,
|
||||
public readonly string $message,
|
||||
public readonly ?int $smsLogId = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Service;
|
||||
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class FileValidatorService
|
||||
{
|
||||
private const ALLOWED_SIGNATURES = [
|
||||
'image/jpeg' => ["\xFF\xD8\xFF"],
|
||||
'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"],
|
||||
'image/webp' => ["RIFF"],
|
||||
];
|
||||
|
||||
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
|
||||
public function __construct(private readonly int $maxSizeBytes = 5_242_880) {}
|
||||
|
||||
public function validateUploadedFile(UploadedFile $file): string
|
||||
{
|
||||
if ($file->getSize() > $this->maxSizeBytes) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_002, null, 422);
|
||||
}
|
||||
|
||||
$binaryContent = (string) file_get_contents($file->getPathname());
|
||||
return $this->validate($binaryContent, $file->getClientOriginalName());
|
||||
}
|
||||
|
||||
public function validate(string $binaryContent, string $claimedFilename): string
|
||||
{
|
||||
if (strlen($binaryContent) > $this->maxSizeBytes) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_002, null, 422);
|
||||
}
|
||||
|
||||
$detected = false;
|
||||
foreach (self::ALLOWED_SIGNATURES as $signatures) {
|
||||
foreach ($signatures as $sig) {
|
||||
if (str_starts_with($binaryContent, $sig)) {
|
||||
$detected = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$detected) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
|
||||
}
|
||||
|
||||
return $this->sanitizeFilename($claimedFilename);
|
||||
}
|
||||
|
||||
public function sanitizeFilename(string $filename): string
|
||||
{
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($filename));
|
||||
if (empty($safeName) || str_contains($safeName, '..')) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
|
||||
}
|
||||
|
||||
return $safeName;
|
||||
}
|
||||
|
||||
public function detectMimeType(string $filePath): string
|
||||
{
|
||||
$handle = fopen($filePath, 'rb');
|
||||
$header = fread($handle, 12);
|
||||
fclose($handle);
|
||||
|
||||
foreach (self::ALLOWED_SIGNATURES as $mime => $signatures) {
|
||||
foreach ($signatures as $sig) {
|
||||
if (str_starts_with($header, $sig)) {
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, 'نوع فایل پشتیبانی نمیشود', 422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Entity\SmsTemplate;
|
||||
use App\Sms\Repository\SmsTemplateRepository;
|
||||
use App\Sms\Service\SmsService;
|
||||
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 SmsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SmsTemplateRepository $templateRepo,
|
||||
private readonly SmsService $smsService,
|
||||
) {}
|
||||
|
||||
// ── Send SMS directly ─────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/sms/send', methods: ['POST'])]
|
||||
public function send(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($data['mobile'] ?? '');
|
||||
$message = trim($data['message'] ?? '');
|
||||
$provider = $data['provider'] ?? 'kavenegar';
|
||||
|
||||
if (empty($mobile) || empty($message)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'mobile و message الزامی است', 422);
|
||||
}
|
||||
|
||||
$this->smsService->dispatchAsync($mobile, $message, $provider);
|
||||
|
||||
return $this->success(['message' => 'پیامک در صف ارسال قرار گرفت']);
|
||||
}
|
||||
|
||||
// ── Templates ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/sms/template', methods: ['POST'])]
|
||||
public function createTemplate(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$name = trim($data['name'] ?? '');
|
||||
$body = trim($data['body'] ?? '');
|
||||
$variables = $data['variables'] ?? [];
|
||||
|
||||
if (empty($name) || empty($body)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'name و body الزامی است', 422);
|
||||
}
|
||||
|
||||
$template = new SmsTemplate($name, $body, $variables);
|
||||
$this->templateRepo->save($template);
|
||||
|
||||
return $this->success(['data' => $template->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/sms/template/{uuid}', methods: ['GET'])]
|
||||
public function getTemplate(string $uuid): JsonResponse
|
||||
{
|
||||
$template = $this->templateRepo->findByUuid($uuid);
|
||||
if ($template === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404);
|
||||
}
|
||||
return $this->success(['data' => $template->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/sms/template/{uuid}', methods: ['PATCH'])]
|
||||
public function updateTemplate(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$template = $this->templateRepo->findByUuid($uuid);
|
||||
if ($template === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($template->getStatus() === SmsTemplate::STATUS_APPROVED) {
|
||||
return $this->error(ErrorCodes::ERR_SMS_003, ErrorCodes::message(ErrorCodes::ERR_SMS_003), 422);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('name', $data)) $template->setName($data['name']);
|
||||
if (array_key_exists('body', $data)) $template->setBody($data['body']);
|
||||
if (array_key_exists('variables', $data)) $template->setVariables($data['variables']);
|
||||
|
||||
$this->templateRepo->save($template);
|
||||
|
||||
return $this->success(['data' => $template->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/sms/template/{uuid}/submit', methods: ['POST'])]
|
||||
public function submitTemplate(string $uuid): JsonResponse
|
||||
{
|
||||
$template = $this->templateRepo->findByUuid($uuid);
|
||||
if ($template === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404);
|
||||
}
|
||||
|
||||
$template->submitForReview();
|
||||
$this->templateRepo->save($template);
|
||||
|
||||
return $this->success(['data' => $template->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/sms/template/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteTemplate(string $uuid): JsonResponse
|
||||
{
|
||||
$template = $this->templateRepo->findByUuid($uuid);
|
||||
if ($template === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404);
|
||||
}
|
||||
$this->templateRepo->remove($template);
|
||||
return $this->success(['message' => 'تمپلیت حذف شد']);
|
||||
}
|
||||
|
||||
// ── Admin moderation ──────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/admin/sms/templates', methods: ['GET'])]
|
||||
public function listTemplates(): JsonResponse
|
||||
{
|
||||
$templates = array_map(fn(SmsTemplate $t) => $t->toArray(), $this->templateRepo->findAll());
|
||||
return $this->success(['data' => $templates]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/admin/sms/template/{uuid}/approve', methods: ['POST'])]
|
||||
public function approveTemplate(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$template = $this->templateRepo->findByUuid($uuid);
|
||||
if ($template === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$template->approve($data['note'] ?? null);
|
||||
if (!empty($data['provider_code'])) $template->setProviderCode($data['provider_code']);
|
||||
$this->templateRepo->save($template);
|
||||
|
||||
return $this->success(['data' => $template->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/admin/sms/template/{uuid}/reject', methods: ['POST'])]
|
||||
public function rejectTemplate(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$template = $this->templateRepo->findByUuid($uuid);
|
||||
if ($template === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$note = trim($data['note'] ?? '');
|
||||
if (empty($note)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دلیل رد الزامی است', 422);
|
||||
}
|
||||
$template->reject($note);
|
||||
$this->templateRepo->save($template);
|
||||
|
||||
return $this->success(['data' => $template->toArray()]);
|
||||
}
|
||||
|
||||
// ── Send via template ─────────────────────────────────────────────────────
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/sms/send-template', methods: ['POST'])]
|
||||
public function sendViaTemplate(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($data['mobile'] ?? '');
|
||||
$templateUuid = trim($data['template_uuid'] ?? '');
|
||||
$vars = $data['vars'] ?? [];
|
||||
$provider = $data['provider'] ?? 'kavenegar';
|
||||
|
||||
$template = $this->templateRepo->findByUuid($templateUuid);
|
||||
if ($template === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($template->getStatus() !== SmsTemplate::STATUS_APPROVED) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تمپلیت هنوز تأیید نشده است', 422);
|
||||
}
|
||||
|
||||
$message = $template->renderBody($vars);
|
||||
$this->smsService->dispatchAsync(
|
||||
$mobile, $message, $provider, $template->getUuid(),
|
||||
$vars, $template->getProviderCode()
|
||||
);
|
||||
|
||||
return $this->success(['message' => 'پیامک در صف ارسال قرار گرفت']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'sms_logs')]
|
||||
#[ORM\Index(columns: ['mobile', 'created_at'], name: 'idx_sms_logs_mobile')]
|
||||
class SmsLog
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $mobile;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $message;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $provider;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $success;
|
||||
|
||||
#[ORM\Column(name: 'template_uuid', type: 'string', length: 36, nullable: true)]
|
||||
private ?string $templateUuid = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(string $mobile, string $message, string $provider, bool $success)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->mobile = $mobile;
|
||||
$this->message = $message;
|
||||
$this->provider = $provider;
|
||||
$this->success = $success;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function setTemplateUuid(?string $v): self { $this->templateUuid = $v; return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'mobile' => $this->mobile,
|
||||
'message' => $this->message,
|
||||
'provider' => $this->provider,
|
||||
'success' => $this->success,
|
||||
'template_uuid' => $this->templateUuid,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'sms_templates')]
|
||||
class SmsTemplate
|
||||
{
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 100)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $body;
|
||||
|
||||
#[ORM\Column(name: 'provider_code', type: 'string', length: 100, nullable: true)]
|
||||
private ?string $providerCode = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $status = self::STATUS_DRAFT;
|
||||
|
||||
#[ORM\Column(name: 'variables', type: 'json')]
|
||||
private array $variables = [];
|
||||
|
||||
#[ORM\Column(name: 'admin_note', type: 'string', length: 500, nullable: true)]
|
||||
private ?string $adminNote = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $name, string $body, array $variables = [])
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->name = $name;
|
||||
$this->body = $body;
|
||||
$this->variables = $variables;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getBody(): string { return $this->body; }
|
||||
public function getProviderCode(): ?string { return $this->providerCode; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getVariables(): array { return $this->variables; }
|
||||
public function getAdminNote(): ?string { return $this->adminNote; }
|
||||
|
||||
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
|
||||
public function setBody(string $v): self { $this->body = $v; $this->touch(); return $this; }
|
||||
public function setProviderCode(?string $v): self { $this->providerCode = $v; $this->touch(); return $this; }
|
||||
public function setVariables(array $v): self { $this->variables = $v; $this->touch(); return $this; }
|
||||
|
||||
public function submitForReview(): self { $this->status = self::STATUS_PENDING; $this->touch(); return $this; }
|
||||
|
||||
public function approve(?string $note = null): self
|
||||
{
|
||||
$this->status = self::STATUS_APPROVED;
|
||||
$this->adminNote = $note;
|
||||
$this->touch();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function reject(string $note): self
|
||||
{
|
||||
$this->status = self::STATUS_REJECTED;
|
||||
$this->adminNote = $note;
|
||||
$this->touch();
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function renderBody(array $vars): string
|
||||
{
|
||||
$body = $this->body;
|
||||
foreach ($vars as $key => $value) {
|
||||
$body = str_replace('{{' . $key . '}}', $value, $body);
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'body' => $this->body,
|
||||
'provider_code' => $this->providerCode,
|
||||
'variables' => $this->variables,
|
||||
'status' => $this->status,
|
||||
'admin_note' => $this->adminNote,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Message;
|
||||
|
||||
final class SendSmsMessage
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $mobile,
|
||||
public readonly string $message,
|
||||
public readonly string $provider = 'kavenegar',
|
||||
public readonly ?string $templateUuid = null,
|
||||
public readonly array $templateVars = [],
|
||||
public readonly ?string $templateCode = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Provider;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class KavehNegarProvider implements SmsProviderInterface
|
||||
{
|
||||
private const BASE = 'https://api.kavenegar.com/v1';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly string $apiKey,
|
||||
private readonly string $sender,
|
||||
) {}
|
||||
|
||||
public function getName(): string { return 'kavenegar'; }
|
||||
|
||||
public function send(string $mobile, string $message): bool
|
||||
{
|
||||
try {
|
||||
$resp = $this->httpClient->request('POST',
|
||||
self::BASE . '/' . $this->apiKey . '/sms/send.json', [
|
||||
'body' => http_build_query([
|
||||
'receptor' => $mobile,
|
||||
'message' => $message,
|
||||
'sender' => $this->sender,
|
||||
]),
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
$data = $resp->toArray();
|
||||
return ($data['return']['status'] ?? 0) === 200;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function sendTemplate(string $mobile, string $templateCode, array $vars): bool
|
||||
{
|
||||
try {
|
||||
$params = ['receptor' => $mobile, 'template' => $templateCode];
|
||||
foreach (array_values($vars) as $i => $v) {
|
||||
$params['token' . ($i > 0 ? $i + 1 : '')] = $v;
|
||||
}
|
||||
$resp = $this->httpClient->request('POST',
|
||||
self::BASE . '/' . $this->apiKey . '/verify/lookup.json', [
|
||||
'body' => http_build_query($params),
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
$data = $resp->toArray();
|
||||
return ($data['return']['status'] ?? 0) === 200;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Provider;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class RanginehProvider implements SmsProviderInterface
|
||||
{
|
||||
private const BASE = 'https://rest.payamresan.com/api/v1';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly string $apiKey,
|
||||
private readonly string $sender,
|
||||
) {}
|
||||
|
||||
public function getName(): string { return 'rangineh'; }
|
||||
|
||||
public function send(string $mobile, string $message): bool
|
||||
{
|
||||
try {
|
||||
$resp = $this->httpClient->request('POST', self::BASE . '/send', [
|
||||
'json' => ['from' => $this->sender, 'to' => [$mobile], 'text' => $message],
|
||||
'headers' => ['ApiKey' => $this->apiKey],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
return $resp->getStatusCode() === 200;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function sendTemplate(string $mobile, string $templateCode, array $vars): bool
|
||||
{
|
||||
try {
|
||||
$resp = $this->httpClient->request('POST', self::BASE . '/send/verify', [
|
||||
'json' => [
|
||||
'mobile' => $mobile,
|
||||
'template' => $templateCode,
|
||||
'params' => $vars,
|
||||
],
|
||||
'headers' => ['ApiKey' => $this->apiKey],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
return $resp->getStatusCode() === 200;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Provider;
|
||||
|
||||
interface SmsProviderInterface
|
||||
{
|
||||
public function getName(): string;
|
||||
|
||||
/** @return bool true on success */
|
||||
public function send(string $mobile, string $message): bool;
|
||||
|
||||
/** Send via approved template (pattern send) */
|
||||
public function sendTemplate(string $mobile, string $templateCode, array $vars): bool;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Repository;
|
||||
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SmsLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, SmsLog::class); }
|
||||
public function save(SmsLog $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Repository;
|
||||
|
||||
use App\Sms\Entity\SmsTemplate;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SmsTemplateRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, SmsTemplate::class); }
|
||||
public function findByUuid(string $uuid): ?SmsTemplate { return $this->findOneBy(['uuid' => $uuid]); }
|
||||
public function save(SmsTemplate $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
public function remove(SmsTemplate $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Service;
|
||||
|
||||
use App\Sms\Message\SendSmsMessage;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
class SendSmsHandler
|
||||
{
|
||||
public function __construct(private readonly SmsService $smsService) {}
|
||||
|
||||
public function __invoke(SendSmsMessage $message): void
|
||||
{
|
||||
$this->smsService->sendNow($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Service;
|
||||
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Message\SendSmsMessage;
|
||||
use App\Sms\Provider\KavehNegarProvider;
|
||||
use App\Sms\Provider\RanginehProvider;
|
||||
use App\Sms\Provider\SmsProviderInterface;
|
||||
use App\Sms\Repository\SmsLogRepository;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
class SmsService
|
||||
{
|
||||
private array $providers;
|
||||
|
||||
public function __construct(
|
||||
private readonly KavehNegarProvider $kavenegar,
|
||||
private readonly RanginehProvider $rangineh,
|
||||
private readonly SmsLogRepository $logRepo,
|
||||
private readonly MessageBusInterface $bus,
|
||||
) {
|
||||
$this->providers = [
|
||||
'kavenegar' => $kavenegar,
|
||||
'rangineh' => $rangineh,
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatchAsync(
|
||||
string $mobile,
|
||||
string $message,
|
||||
string $provider = 'kavenegar',
|
||||
?string $templateUuid = null,
|
||||
array $templateVars = [],
|
||||
?string $templateCode = null,
|
||||
): void {
|
||||
$this->bus->dispatch(new SendSmsMessage(
|
||||
$mobile, $message, $provider, $templateUuid, $templateVars, $templateCode
|
||||
));
|
||||
}
|
||||
|
||||
public function sendNow(SendSmsMessage $msg): bool
|
||||
{
|
||||
$provider = $this->resolveProvider($msg->provider);
|
||||
|
||||
$success = ($msg->templateCode !== null)
|
||||
? $provider->sendTemplate($msg->mobile, $msg->templateCode, $msg->templateVars)
|
||||
: $provider->send($msg->mobile, $msg->message);
|
||||
|
||||
$log = new SmsLog($msg->mobile, $msg->message, $provider->getName(), $success);
|
||||
if ($msg->templateUuid) $log->setTemplateUuid($msg->templateUuid);
|
||||
$this->logRepo->save($log);
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
private function resolveProvider(string $name): SmsProviderInterface
|
||||
{
|
||||
return $this->providers[$name] ?? $this->kavenegar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\UserProfile\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
use App\UserProfile\Repository\UserProfileRepository;
|
||||
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 UserProfileController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserProfileRepository $repository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/user-profile', methods: ['POST'])]
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
if ($this->repository->findByUser($user) !== null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پروفایل قبلاً ایجاد شده است', 409);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$profile = new UserProfile($user);
|
||||
$this->hydrate($profile, $data);
|
||||
$this->repository->save($profile);
|
||||
|
||||
return $this->success(['data' => $profile->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user-profile/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$profile = $this->repository->findByUuid($uuid);
|
||||
|
||||
if ($profile === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پروفایل یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canAccess($profile, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $profile->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user-profile/{uuid}', methods: ['PATCH'])]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$profile = $this->repository->findByUuid($uuid);
|
||||
|
||||
if ($profile === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پروفایل یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canAccess($profile, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$this->hydrate($profile, $data);
|
||||
$this->repository->save($profile);
|
||||
|
||||
return $this->success(['data' => $profile->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user-profile/{uuid}', methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function delete(string $uuid): JsonResponse
|
||||
{
|
||||
$profile = $this->repository->findByUuid($uuid);
|
||||
|
||||
if ($profile === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پروفایل یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->repository->remove($profile);
|
||||
|
||||
return $this->success(['message' => 'پروفایل با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
private function canAccess(UserProfile $profile, User $currentUser): bool
|
||||
{
|
||||
return $profile->getUser()->getId() === $currentUser->getId()
|
||||
|| $currentUser->hasRole('ROLE_ADMIN');
|
||||
}
|
||||
|
||||
private function hydrate(UserProfile $profile, array $data): void
|
||||
{
|
||||
if (array_key_exists('name', $data)) $profile->setLabel($data['name']);
|
||||
if (array_key_exists('label', $data)) $profile->setLabel($data['label']);
|
||||
if (array_key_exists('family', $data)) $profile->setFamily($data['family']);
|
||||
if (array_key_exists('fathers_name', $data)) $profile->setFathersName($data['fathers_name']);
|
||||
if (array_key_exists('national_code', $data)) $profile->setNationalCode($data['national_code']);
|
||||
if (array_key_exists('gender', $data)) $profile->setGender($data['gender']);
|
||||
if (array_key_exists('blood_type', $data)) $profile->setBloodType($data['blood_type']);
|
||||
if (array_key_exists('marital_status', $data)) $profile->setMaritalStatus($data['marital_status']);
|
||||
if (array_key_exists('education', $data)) $profile->setEducation($data['education']);
|
||||
if (array_key_exists('job', $data)) $profile->setJob($data['job']);
|
||||
if (array_key_exists('address', $data)) $profile->setAddress($data['address']);
|
||||
if (array_key_exists('home_phone', $data)) $profile->setHomePhone($data['home_phone']);
|
||||
if (array_key_exists('work_phone', $data)) $profile->setWorkPhone($data['work_phone']);
|
||||
if (array_key_exists('insurance_id', $data)) $profile->setInsuranceId($data['insurance_id']);
|
||||
if (array_key_exists('description', $data)) $profile->setDescription(
|
||||
is_array($data['description']) ? ($data['description'][0]['value'] ?? null) : $data['description']
|
||||
);
|
||||
if (array_key_exists('sharing_with_user', $data)) $profile->setSharingWithUser((bool) $data['sharing_with_user']);
|
||||
|
||||
// birthday: accept Jalali string "1370-05-15" stored as-is converted to Unix
|
||||
if (array_key_exists('birthday', $data) && $data['birthday'] !== null) {
|
||||
// Store as string-encoded Unix; for now keep as null if conversion unavailable
|
||||
// Will be replaced with JalaliDateService in Task 16
|
||||
$profile->setDateOfBirth(null);
|
||||
}
|
||||
if (array_key_exists('date_of_birth', $data)) $profile->setDateOfBirth($data['date_of_birth']);
|
||||
|
||||
// Insurance references (category IDs)
|
||||
if (array_key_exists('basic_insurance', $data)) {
|
||||
$id = is_array($data['basic_insurance']) ? ($data['basic_insurance'][0] ?? null) : $data['basic_insurance'];
|
||||
$profile->setBasicInsuranceId($id !== null ? (int) $id : null);
|
||||
}
|
||||
if (array_key_exists('supplementary_insurance', $data)) {
|
||||
$id = is_array($data['supplementary_insurance'])
|
||||
? ($data['supplementary_insurance'][0] ?? null)
|
||||
: $data['supplementary_insurance'];
|
||||
$profile->setSupplementaryInsuranceId($id !== null ? (int) $id : null);
|
||||
}
|
||||
|
||||
// Medical history JSON
|
||||
if (array_key_exists('other', $data)) {
|
||||
$other = is_array($data['other']) && isset($data['other'][0])
|
||||
? $data['other'][0]
|
||||
: $data['other'];
|
||||
$profile->setOther($other);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace App\UserProfile\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'profiles')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_profiles_user', columns: ['user_id'])]
|
||||
#[ORM\Index(columns: ['national_code'], name: 'idx_profiles_national_code')]
|
||||
class UserProfile
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 25, nullable: true)]
|
||||
private ?string $family = null;
|
||||
|
||||
#[ORM\Column(name: 'fathers_name', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $fathersName = null;
|
||||
|
||||
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
|
||||
private ?string $nationalCode = null;
|
||||
|
||||
#[ORM\Column(name: 'national_code_approved', type: 'boolean')]
|
||||
private bool $nationalCodeApproved = false;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10, nullable: true)]
|
||||
private ?string $gender = null;
|
||||
|
||||
#[ORM\Column(name: 'date_of_birth', type: 'integer', nullable: true)]
|
||||
private ?int $dateOfBirth = null;
|
||||
|
||||
#[ORM\Column(name: 'blood_type', type: 'string', length: 20, nullable: true)]
|
||||
private ?string $bloodType = null;
|
||||
|
||||
#[ORM\Column(name: 'marital_status', type: 'string', length: 30, nullable: true)]
|
||||
private ?string $maritalStatus = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
||||
private ?string $education = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
||||
private ?string $job = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $address = null;
|
||||
|
||||
#[ORM\Column(name: 'home_phone', type: 'string', length: 30, nullable: true)]
|
||||
private ?string $homePhone = null;
|
||||
|
||||
#[ORM\Column(name: 'work_phone', type: 'string', length: 30, nullable: true)]
|
||||
private ?string $workPhone = null;
|
||||
|
||||
#[ORM\Column(name: 'insurance_id', type: 'string', length: 50, nullable: true)]
|
||||
private ?string $insuranceId = null;
|
||||
|
||||
#[ORM\Column(name: 'basic_insurance_id', type: 'integer', nullable: true)]
|
||||
private ?int $basicInsuranceId = null;
|
||||
|
||||
#[ORM\Column(name: 'supplementary_insurance_id', type: 'integer', nullable: true)]
|
||||
private ?int $supplementaryInsuranceId = null;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $other = null;
|
||||
|
||||
#[ORM\Column(name: 'sharing_with_user', type: 'boolean')]
|
||||
private bool $sharingWithUser = false;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
|
||||
public function getLabel(): ?string { return $this->label; }
|
||||
public function getFamily(): ?string { return $this->family; }
|
||||
public function getFathersName(): ?string { return $this->fathersName; }
|
||||
public function getNationalCode(): ?string { return $this->nationalCode; }
|
||||
public function isNationalCodeApproved(): bool { return $this->nationalCodeApproved; }
|
||||
public function getGender(): ?string { return $this->gender; }
|
||||
public function getDateOfBirth(): ?int { return $this->dateOfBirth; }
|
||||
public function getBloodType(): ?string { return $this->bloodType; }
|
||||
public function getMaritalStatus(): ?string { return $this->maritalStatus; }
|
||||
public function getEducation(): ?string { return $this->education; }
|
||||
public function getJob(): ?string { return $this->job; }
|
||||
public function getAddress(): ?string { return $this->address; }
|
||||
public function getHomePhone(): ?string { return $this->homePhone; }
|
||||
public function getWorkPhone(): ?string { return $this->workPhone; }
|
||||
public function getInsuranceId(): ?string { return $this->insuranceId; }
|
||||
public function getBasicInsuranceId(): ?int { return $this->basicInsuranceId; }
|
||||
public function getSupplementaryInsuranceId(): ?int { return $this->supplementaryInsuranceId; }
|
||||
public function getOther(): ?array { return $this->other; }
|
||||
public function isSharingWithUser(): bool { return $this->sharingWithUser; }
|
||||
public function getDescription(): ?string { return $this->description; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setLabel(?string $v): self { $this->label = $v; return $this; }
|
||||
public function setFamily(?string $v): self { $this->family = $v; $this->touch(); return $this; }
|
||||
public function setFathersName(?string $v): self { $this->fathersName = $v; $this->touch(); return $this; }
|
||||
public function setNationalCode(?string $v): self { $this->nationalCode = $v; $this->touch(); return $this; }
|
||||
public function setNationalCodeApproved(bool $v): self { $this->nationalCodeApproved = $v; $this->touch(); return $this; }
|
||||
public function setGender(?string $v): self { $this->gender = $v; $this->touch(); return $this; }
|
||||
public function setDateOfBirth(?int $v): self { $this->dateOfBirth = $v; $this->touch(); return $this; }
|
||||
public function setBloodType(?string $v): self { $this->bloodType = $v; $this->touch(); return $this; }
|
||||
public function setMaritalStatus(?string $v): self { $this->maritalStatus = $v; $this->touch(); return $this; }
|
||||
public function setEducation(?string $v): self { $this->education = $v; $this->touch(); return $this; }
|
||||
public function setJob(?string $v): self { $this->job = $v; $this->touch(); return $this; }
|
||||
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
|
||||
public function setHomePhone(?string $v): self { $this->homePhone = $v; $this->touch(); return $this; }
|
||||
public function setWorkPhone(?string $v): self { $this->workPhone = $v; $this->touch(); return $this; }
|
||||
public function setInsuranceId(?string $v): self { $this->insuranceId = $v; $this->touch(); return $this; }
|
||||
public function setBasicInsuranceId(?int $v): self { $this->basicInsuranceId = $v; $this->touch(); return $this; }
|
||||
public function setSupplementaryInsuranceId(?int $v): self { $this->supplementaryInsuranceId = $v; $this->touch(); return $this; }
|
||||
public function setOther(?array $v): self { $this->other = $v; $this->touch(); return $this; }
|
||||
public function setSharingWithUser(bool $v): self { $this->sharingWithUser = $v; $this->touch(); return $this; }
|
||||
public function setDescription(?string $v): self { $this->description = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'user_uuid' => $this->user->getUuid(),
|
||||
'label' => $this->label,
|
||||
'family' => $this->family,
|
||||
'fathers_name' => $this->fathersName,
|
||||
'national_code' => $this->nationalCode,
|
||||
'national_code_approved' => $this->nationalCodeApproved,
|
||||
'gender' => $this->gender,
|
||||
'date_of_birth' => $this->dateOfBirth,
|
||||
'blood_type' => $this->bloodType,
|
||||
'marital_status' => $this->maritalStatus,
|
||||
'education' => $this->education,
|
||||
'job' => $this->job,
|
||||
'address' => $this->address,
|
||||
'home_phone' => $this->homePhone,
|
||||
'work_phone' => $this->workPhone,
|
||||
'insurance_id' => $this->insuranceId,
|
||||
'basic_insurance_id' => $this->basicInsuranceId,
|
||||
'supplementary_insurance_id' => $this->supplementaryInsuranceId,
|
||||
'other' => $this->other,
|
||||
'sharing_with_user' => $this->sharingWithUser,
|
||||
'description' => $this->description,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\UserProfile\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class UserProfileRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, UserProfile::class);
|
||||
}
|
||||
|
||||
public function findByUser(User $user): ?UserProfile
|
||||
{
|
||||
return $this->findOneBy(['user' => $user]);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?UserProfile
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function save(UserProfile $profile, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($profile);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(UserProfile $profile, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($profile);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user