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);
}
}