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:
hamed
2026-06-09 22:00:34 +03:30
commit de1a78a235
222 changed files with 36388 additions and 0 deletions
@@ -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()]);
}
}
+134
View File
@@ -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,
];
}
}
+81
View File
@@ -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,
];
}
}
+81
View File
@@ -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,
];
}
}
+63
View File
@@ -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);
}
}
+176
View File
@@ -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))]);
}
}
+105
View File
@@ -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');
}
}
+41
View File
@@ -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();
}
}
}
+105
View File
@@ -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);
}
}
+98
View File
@@ -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));
}
}
+71
View File
@@ -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);
}
}
+151
View File
@@ -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]);
}
}
+135
View File
@@ -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,
];
}
}
+39
View File
@@ -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' => 'دسته‌بندی با موفقیت حذف شد']);
}
}
+155
View File
@@ -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();
}
}
}
+97
View File
@@ -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);
}
}
}
+268
View File
@@ -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);
}
}
}
+212
View File
@@ -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();
}
}
}
View File
+367
View File
@@ -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']);
}
}
+232
View File
@@ -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());
}
}
+85
View File
@@ -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();
}
}
}
View File
@@ -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' => 'بیمه با موفقیت حذف شد']);
}
}
+55
View File
@@ -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();
}
}
}
+11
View File
@@ -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);
}
}
+122
View File
@@ -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,
];
}
}
+131
View File
@@ -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;
}
+13
View File
@@ -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,
) {}
}
+80
View File
@@ -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);
}
}
+188
View File
@@ -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);
}
}
+88
View File
@@ -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,
];
}
}
+55
View File
@@ -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,
];
}
}
+67
View File
@@ -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(); }
}
+19
View File
@@ -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(); }
}
+28
View File
@@ -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(); }
}
View File
@@ -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');
}
}
+116
View File
@@ -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()]);
}
}
+112
View File
@@ -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();
}
}
+81
View File
@@ -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 => 'خطای ناشناخته',
};
}
}
+51
View File
@@ -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'];
}
}
+21
View File
@@ -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; }
}
+12
View File
@@ -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);
}
}
+202
View File
@@ -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' => 'پیامک در صف ارسال قرار گرفت']);
}
}
+63
View File
@@ -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,
];
}
}
+115
View File
@@ -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,
];
}
}
+15
View File
@@ -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,
) {}
}
+58
View File
@@ -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;
}
}
}
+50
View File
@@ -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;
}
}
}
+14
View File
@@ -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;
}
+13
View File
@@ -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(); }
}
+17
View File
@@ -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);
}
}
+61
View File
@@ -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);
}
}
}
+181
View File
@@ -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();
}
}
}