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,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()]);
}
}