Files
clinicpro/src/Patient/Controller/PatientController.php
T
hamedandClaude Opus 4.8 537bb8c7b3 feat(patients): phase B2 — attachments (ضمیمه)
Add patient file attachments: a new PatientAttachment entity (record-scoped,
CASCADE) + repository, and endpoints GET /patient/{uuid}/attachments,
POST /patient/{uuid}/attachment (raw-body upload) and DELETE
/patient/attachment/{uuid} (owner-scoped). Factor the shared raw-body upload
logic into FileUploadService. Wire the "ضمیمه" tab in PatientDetailPage
(upload + list + delete). PHPUnit covers list/delete/ownership; Vitest covers
the tab. API docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:20:00 +03:30

638 lines
29 KiB
PHP

<?php
namespace App\Patient\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Auth\Repository\UserRepository;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\ClaimService;
use App\Billing\Service\InvoiceService;
use Psr\Log\LoggerInterface;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Entity\PatientRecord;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Service\PatientService;
use App\UserProfile\Entity\UserProfile;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Subscription\Service\SubscriptionService;
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 OpenApi\Attributes as OA;
#[OA\Tag(name: 'Patients')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class PatientController extends BaseController
{
public function __construct(
private readonly PatientRecordRepository $recordRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly PatientService $patientService,
private readonly SubscriptionService $subscriptionService,
private readonly UserRepository $userRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo,
private readonly InvoiceService $invoiceService,
private readonly ClaimService $claimService,
private readonly InvoiceRepository $invoiceRepo,
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo,
private readonly \App\Shared\Service\FileUploadService $fileUpload,
private readonly LoggerInterface $logger,
) {}
// ── Attachments (ضمیمه) ───────────────────────────────────────────────────
#[Route('/api/v1/patient/{uuid}/attachments', methods: ['GET'])]
public function listAttachments(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
return $this->success(array_map(
fn(\App\Patient\Entity\PatientAttachment $a) => $a->toArray(),
$this->attachmentRepo->findByRecord($record)
));
}
#[Route('/api/v1/patient/{uuid}/attachment', methods: ['POST'])]
public function uploadAttachment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
try {
$stored = $this->fileUpload->storeFromRequest($request, 'patients/attachments');
} catch (\RuntimeException $e) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
}
$name = trim((string) $request->query->get('name', '')) ?: $stored['filename'];
$attachment = new \App\Patient\Entity\PatientAttachment($record, $name, $stored['url'], $stored['filemime'], $stored['size']);
$this->attachmentRepo->save($attachment);
return $this->success($attachment->toArray(), 201);
}
#[Route('/api/v1/patient/attachment/{uuid}', methods: ['DELETE'])]
public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$attachment = $this->attachmentRepo->findByUuid($uuid);
if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'ضمیمه یافت نشد', 404);
}
$this->attachmentRepo->remove($attachment);
return $this->success(['message' => 'ضمیمه حذف شد']);
}
/**
* Assign record labels from the payload (`tags` = array of TenantTag uuids),
* scoped to the caller's entity. Returns a 422 response on a foreign tag,
* otherwise null. Does nothing when `tags` is absent.
*/
private function applyRecordTags(PatientRecord $record, array $data, string $entityType, int $entityId): ?JsonResponse
{
if (!array_key_exists('tags', $data)) {
return null;
}
$uuids = is_array($data['tags']) ? $data['tags'] : [];
$tags = [];
foreach (array_values(array_unique(array_filter($uuids))) as $uuid) {
$tag = $this->tenantTagRepo->findByUuid((string) $uuid);
if ($tag === null || $tag->getEntityType() !== $entityType || $tag->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برچسب انتخاب‌شده متعلق به شما نیست', 422, 'tags');
}
$tags[] = $tag;
}
$record->setTags($tags);
return null;
}
private function buildPatientProfile(User $patient): array
{
$p = $this->profileRepo->findByUser($patient);
$insName = function (?int $id): ?string {
if ($id === null) { return null; }
return $this->insuranceRepo->find($id)?->getName();
};
return [
'full_name' => trim(($patient->getRealName() ?? '') . ' ' . ($p?->getFamily() ?? '')) ?: $patient->getRealName(),
'name' => $patient->getRealName(),
'family' => $p?->getFamily(),
'fathers_name' => $p?->getFathersName(),
'national_code' => $p?->getNationalCode() ?? $patient->getNationalCode(),
'gender' => $p?->getGender(),
'date_of_birth' => $p?->getDateOfBirth(),
'blood_type' => $p?->getBloodType(),
'marital_status' => $p?->getMaritalStatus(),
'education' => $p?->getEducation(),
'field_of_study' => $p?->getFieldOfStudy(),
'job' => $p?->getJob(),
'address' => $p?->getAddress(),
'province_id' => $p?->getProvinceId(),
'city_id' => $p?->getCityId(),
'postal_code' => $p?->getPostalCode(),
'referral_source' => $p?->getReferralSource(),
'description' => $p?->getDescription(),
'home_phone' => $p?->getHomePhone(),
'work_phone' => $p?->getWorkPhone(),
'mobile' => $patient->getMobileNumber(),
'basic_insurance_id' => $p?->getBasicInsuranceId(),
'basic_insurance_name' => $insName($p?->getBasicInsuranceId()),
'supplementary_insurance_id' => $p?->getSupplementaryInsuranceId(),
'supplementary_insurance_name' => $insName($p?->getSupplementaryInsuranceId()),
];
}
#[Route('/api/v1/patient/search-user', methods: ['GET'])]
public function searchUser(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$mobile = trim($request->query->get('mobile', ''));
if ($mobile === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'mobile الزامی است', 422);
}
$patient = $this->userRepo->findByMobile($mobile);
if ($patient === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربری با این شماره یافت نشد', 404);
}
return $this->success([
'uuid' => $patient->getUuid(),
'name' => $patient->getRealName(),
'mobile' => $patient->getMobileNumber(),
'national_code' => $patient->getNationalCode(),
]);
}
#[Route('/api/v1/patients', methods: ['GET'])]
public function list(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
$search = $request->query->get('search') ?: null;
$records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search);
$total = $this->recordRepo->countByEntity($entityType, $entityId, $search);
return $this->paginated(
array_map(fn(PatientRecord $r) => $r->toArray(), $records),
$total,
$page,
$limit
);
}
#[Route('/api/v1/patient', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$data = json_decode($request->getContent(), true) ?? [];
$userUuid = trim($data['user_uuid'] ?? '');
$mobile = trim($data['mobile'] ?? '');
$name = trim($data['name'] ?? '');
$nationalCode = trim((string) ($data['national_code'] ?? ''));
if ($nationalCode !== '' && !preg_match('/^\d{10}$/', $nationalCode)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422);
}
$patient = null;
if ($userUuid !== '') {
$patient = $this->userRepo->findByUuid($userUuid);
} elseif ($mobile !== '') {
if (!preg_match('/^09\d{9}$/', $mobile)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422);
}
$patient = $this->userRepo->findByMobile($mobile);
}
// کد ملی باید در سطح بیمار یکتا باشد: اگر به پروفایلِ کاربر دیگری تعلق دارد، رد کن
if ($nationalCode !== '') {
$owner = $this->profileRepo->findOneByNationalCode($nationalCode);
if ($owner !== null && ($patient === null || $owner->getUser()->getId() !== $patient->getId())) {
$masked = \App\Shared\Service\InputValidator::maskMobile($owner->getUser()->getMobileNumber());
return $this->error(
ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN,
"این کد ملی قبلاً با شماره {$masked} ثبت شده است",
409,
'national_code'
);
}
}
// بیمار جدید بدون ثبت‌نام قبلی: موبایل + نام آمده ولی کاربری وجود ندارد
if ($patient === null) {
if ($mobile === '' || $name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای ساخت بیمار جدید، شماره موبایل و نام الزامی است', 422);
}
$patient = new User($mobile);
$patient->setRealName($name);
if ($nationalCode !== '') {
$patient->setNationalCode($nationalCode);
}
$this->userRepo->save($patient);
} elseif ($nationalCode !== '' && $patient->getNationalCode() === null) {
$patient->setNationalCode($nationalCode);
$this->userRepo->save($patient);
}
$existing = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($existing !== null) {
return $this->success($existing->toArray());
}
$record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId);
if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') {
$record->setRecordNumber($rn);
}
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
if ($tagError !== null) {
return $tagError;
}
$this->recordRepo->save($record);
return $this->success($record->toArray(), 201);
}
#[Route('/api/v1/patient/{uuid}', methods: ['GET'])]
public function show(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
$data = $record->toArray();
$data['profile'] = $this->buildPatientProfile($record->getUser());
return $this->success($data);
}
#[Route('/api/v1/patient/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
$patient = $record->getUser();
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('name', $data)) {
$name = trim((string) $data['name']);
if ($name !== '') {
$patient->setRealName($name);
}
}
// کد ملی باید ۱۰ رقم و در سطح بیمار یکتا باشد
if (array_key_exists('national_code', $data)) {
$nationalCode = trim((string) ($data['national_code'] ?? ''));
if ($nationalCode !== '') {
if (!preg_match('/^\d{10}$/', $nationalCode)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422, 'national_code');
}
$owner = $this->profileRepo->findOneByNationalCode($nationalCode);
if ($owner !== null && $owner->getUser()->getId() !== $patient->getId()) {
$masked = \App\Shared\Service\InputValidator::maskMobile($owner->getUser()->getMobileNumber());
return $this->error(
ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN,
"این کد ملی قبلاً با شماره {$masked} ثبت شده است",
409,
'national_code'
);
}
$patient->setNationalCode($nationalCode);
}
}
// موبایل = شناسه ورود کاربر؛ تغییر آن باید ۱۱ رقمی معتبر و در سطح کاربران یکتا باشد
if (array_key_exists('mobile', $data)) {
$mobile = trim((string) ($data['mobile'] ?? ''));
if ($mobile !== '' && $mobile !== $patient->getMobileNumber()) {
if (!preg_match('/^09\d{9}$/', $mobile)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$owner = $this->userRepo->findByMobile($mobile);
if ($owner !== null && $owner->getId() !== $patient->getId()) {
return $this->error(
ErrorCodes::ERR_PROFILE_MOBILE_TAKEN,
ErrorCodes::message(ErrorCodes::ERR_PROFILE_MOBILE_TAKEN),
409,
'mobile'
);
}
$patient->setMobileNumber($mobile);
}
}
$this->userRepo->save($patient);
$profile = $this->profileRepo->findByUser($patient) ?? new UserProfile($patient);
$stringFields = [
'family' => 'setFamily',
'fathers_name' => 'setFathersName',
'gender' => 'setGender',
'blood_type' => 'setBloodType',
'marital_status' => 'setMaritalStatus',
'education' => 'setEducation',
'field_of_study' => 'setFieldOfStudy',
'job' => 'setJob',
'address' => 'setAddress',
'postal_code' => 'setPostalCode',
'referral_source' => 'setReferralSource',
'description' => 'setDescription',
'home_phone' => 'setHomePhone',
'work_phone' => 'setWorkPhone',
];
foreach ($stringFields as $key => $setter) {
if (array_key_exists($key, $data)) {
$v = is_string($data[$key]) ? trim($data[$key]) : $data[$key];
$profile->$setter($v === '' ? null : $v);
}
}
$intFields = [
'province_id' => 'setProvinceId',
'city_id' => 'setCityId',
];
foreach ($intFields as $key => $setter) {
if (array_key_exists($key, $data)) {
$v = $data[$key];
$profile->$setter(($v === null || $v === '') ? null : (int) $v);
}
}
if (array_key_exists('national_code', $data)) {
$nc = trim((string) ($data['national_code'] ?? ''));
$profile->setNationalCode($nc === '' ? null : $nc);
}
if (array_key_exists('date_of_birth', $data)) {
$dob = $data['date_of_birth'];
$profile->setDateOfBirth(($dob === null || $dob === '') ? null : (int) $dob);
}
if (array_key_exists('basic_insurance_id', $data)) {
$bi = $data['basic_insurance_id'];
$profile->setBasicInsuranceId(($bi === null || $bi === '') ? null : (int) $bi);
}
if (array_key_exists('supplementary_insurance_id', $data)) {
$si = $data['supplementary_insurance_id'];
$profile->setSupplementaryInsuranceId(($si === null || $si === '') ? null : (int) $si);
}
$this->profileRepo->save($profile);
if (array_key_exists('record_number', $data)) {
$rn = trim((string) ($data['record_number'] ?? ''));
$record->setRecordNumber($rn === '' ? null : $rn);
}
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
if ($tagError !== null) {
return $tagError;
}
$this->recordRepo->save($record);
$out = $record->toArray();
$out['profile'] = $this->buildPatientProfile($patient);
return $this->success($out);
}
#[Route('/api/v1/patient/{uuid}/sessions', methods: ['GET'])]
public function sessions(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
$sessions = $this->sessionRepo->findByRecord($record, $page, $limit);
$total = $this->sessionRepo->countByRecord($record);
return $this->paginated(
array_map(fn($s) => $this->sessionWithBilling($s), $sessions),
$total,
$page,
$limit
);
}
#[Route('/api/v1/patient/{uuid}/appointments', methods: ['GET'])]
public function appointments(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
// نوبت‌های این بیمار فقط با پزشک(های) همین ارائه‌دهنده نمایش داده می‌شوند تا
// نوبت‌های او با کلینیک‌های دیگر نشت نکند.
$doctorIds = $entityType === 'doctor'
? [$entityId]
: $this->invitationRepo->acceptedDoctorIdsByClinic($entityId);
$appointments = $this->appointmentRepo->findByUserAndDoctorIds($record->getUser(), $doctorIds);
return $this->success(array_map(fn(\App\Appointment\Entity\Appointment $a) => [
'uuid' => $a->getUuid(),
'starts_at' => $a->getSlotStart(),
'ends_at' => $a->getSlotEnd(),
'status' => $a->getStatus(),
'doctor_name' => $a->getDoctor()->getName(),
'service_name' => null,
'price_rials' => null,
'created_at' => $a->getSlotStart(),
], $appointments));
}
/**
* خروجی session به‌همراه خلاصه‌ی صورتحساب: uuid فاکتور (در صورت وجود) و
* مانده‌ی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending)
* بدهی صفر است؛ در غیر این صورت سهم بیمار از فاکتور یا کل مبلغ نهایی.
*/
private function sessionWithBilling(\App\Patient\Entity\PatientSession $session): array
{
$data = $session->toArray();
$invoice = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null;
$data['invoice_uuid'] = $invoice?->getUuid();
$data['invoice_status'] = $invoice?->getStatus();
if ($data['is_paid']) {
$data['patient_debt_rials'] = 0;
} elseif ($invoice !== null) {
$data['patient_debt_rials'] = $invoice->getPatientRials();
} else {
$data['patient_debt_rials'] = $session->getFinalPriceRials();
}
return $data;
}
#[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])]
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$session = $this->patientService->createSession($record, $data, $entityType, $entityId);
$this->autoCreateClaim($session, $entityType, $entityId);
return $this->success($session->toArray(), 201);
}
/**
* اتصال خودکار مطالبه‌ی بیمه: اگر session بیمه‌ی پایه یا مکمل داشته باشد،
* صورتحساب ساخته و نهایی می‌شود و مطالبه (در وضعیت pending) ایجاد می‌گردد.
* ارسال مطالبه دستی از صفحه‌ی مطالبات انجام می‌شود.
* خطا در این مرحله نباید ثبت session را خراب کند.
*/
private function autoCreateClaim(\App\Patient\Entity\PatientSession $session, string $entityType, int $entityId): void
{
if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) {
return;
}
try {
$invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId);
$this->invoiceService->finalize($invoice);
$this->claimService->createFromInvoice($invoice);
} catch (\App\Shared\Exception\AppException $e) {
// سهم بیمه‌ای صفر بود یا صورتحساب از قبل وجود داشت — قابل چشم‌پوشی.
$this->logger->info('auto claim skipped', ['session' => $session->getUuid(), 'reason' => $e->getMessage()]);
} catch (\Throwable $e) {
$this->logger->error('auto claim failed', ['session' => $session->getUuid(), 'error' => $e->getMessage()]);
}
}
#[Route('/api/v1/session/{uuid}', methods: ['PATCH'])]
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$session = $this->sessionRepo->findByUuid($uuid);
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
if (isset($data['payment_method'])) { $session->setPaymentMethod($data['payment_method']); }
$this->sessionRepo->save($session);
return $this->success($session->toArray());
}
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
if ($user->hasRole('ROLE_SECRETARY')) {
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid !== null) {
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
if ($rel !== null) {
return ['clinic', $clinic->getId()];
}
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
if ($rel !== null) {
return ['doctor', $doctor->getId()];
}
}
}
}
return ['unknown', null];
}
private function assertPatientGate(string $entityType, ?int $entityId): void
{
if ($entityId === null) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
}
}
private function ownsRecord($record, string $entityType, ?int $entityId): bool
{
return $entityId !== null
&& $record->getEntityType() === $entityType
&& $record->getEntityId() === $entityId;
}
}