feat(representation): let registering reps edit their doctors and clinics

A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.

Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.

ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.

Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.

Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.

Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.

Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-08 15:50:17 +03:30
co-authored by Claude Opus 5
parent d74a351e5a
commit fb1cb20c11
16 changed files with 2106 additions and 68 deletions
+95 -18
View File
@@ -26,6 +26,7 @@ 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 App\Representation\Security\RepresentationEditPolicy;
use App\Shared\Util\PersianText;
#[OA\Tag(name: 'Doctors')]
@@ -46,6 +47,8 @@ class DoctorController extends BaseController
private readonly TenantInsuranceCleanupService $insuranceCleanup,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
private readonly \App\Representation\Security\RepresentationEditPolicy $editPolicy,
private readonly \App\Representation\Security\RepresentationEditLogger $editLogger,
private readonly string $projectDir,
) {}
@@ -148,7 +151,7 @@ class DoctorController extends BaseController
]
)]
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
@@ -176,10 +179,19 @@ class DoctorController extends BaseController
? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()]
: null;
// can_edit تنها منبع حقیقتِ پنل است تا قاعدهٔ مجوز در فرانت بازنویسی نشود.
// اندپوینت عمومی است؛ بدون توکن همیشه false.
$canEdit = $user !== null && (
$doctor->getUser()->getId() === $user->getId()
|| $user->hasRole('ROLE_ADMIN')
|| $this->editPolicy->ownsDoctor($user, $doctor)
);
$schedules = $this->scheduleRepo->findAllByDoctor($doctor);
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), [
'clinics' => $clinicData,
'representation' => $representation,
'can_edit' => $canEdit,
])]);
}
@@ -346,16 +358,33 @@ class DoctorController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
// نمایندهٔ ثبت‌کننده هم ویرایش می‌کند، اما فقط فیلدهای محتوایی. مسیر پزشک و
// ادمین دست‌نخورده می‌ماند — whitelist تنها روی شاخهٔ نماینده اعمال می‌شود.
$isOwnerOrAdmin = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
$isRepOwner = !$isOwnerOrAdmin && $this->editPolicy->ownsDoctor($user, $doctor);
if (!$isOwnerOrAdmin && !$isRepOwner) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if ($isRepOwner) {
$bad = $this->editPolicy->firstForbiddenField($data, RepresentationEditPolicy::DOCTOR_FIELDS);
if ($bad !== null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'نماینده اجازهٔ تغییر این فیلد را ندارد', 403, $bad);
}
}
if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title']));
$this->hydrateDoctor($doctor, $data);
$this->doctorRepo->save($doctor);
if ($isRepOwner) {
$this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data);
}
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
}
@@ -541,29 +570,41 @@ class DoctorController extends BaseController
#[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) ?? [];
$doctorUuid = $data['doctor_uuid'] ?? null;
$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);
// وقتی doctor_uuid آمده باشد، هدف صریح است و همان معیار قرار می‌گیرد — حتی
// اگر فرستنده خودش پزشک باشد. نماینده‌ای که پزشک هم هست وگرنه بی‌صدا آدرس را
// روی پروفایل خودش می‌ساخت، نه روی پزشکِ زیرمجموعه.
if ($doctorUuid !== null && $doctorUuid !== '') {
$doctor = $this->doctorRepo->findByUuid((string) $doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$isSelfOrAdmin = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
if (!$isSelfOrAdmin && !$this->editPolicy->ownsDoctor($user, $doctor)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
} else {
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null) {
// ادمین و نماینده پروفایل پزشک ندارند؛ برایشان نبودِ doctor_uuid خطای
// ورودی است، نه نداشتن دسترسی.
return $user->hasRole('ROLE_ADMIN') || $user->hasRole('ROLE_REPRESENTATION')
? $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid الزامی است', 422, 'doctor_uuid')
: $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر می‌تواند آدرس اضافه کند', 403);
}
}
$address = DoctorAddress::forDoctor($doctor);
$this->hydrateAddress($address, $data);
$this->addressRepo->save($address);
if ($this->editPolicy->ownsDoctor($user, $doctor)) {
$this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data);
}
return $this->success(['data' => $address->toArray()], 201);
}
@@ -598,7 +639,7 @@ class DoctorController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404);
}
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
if (!$this->mayTouchAddress($user, $address)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
@@ -653,7 +694,7 @@ class DoctorController extends BaseController
return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک ویرایش می‌شود', 403);
}
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
if (!$this->mayTouchAddress($user, $address)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
@@ -661,6 +702,11 @@ class DoctorController extends BaseController
$this->hydrateAddress($address, $data);
$this->addressRepo->save($address);
$doctor = $address->getDoctor();
if ($doctor !== null && $this->editPolicy->ownsDoctor($user, $doctor)) {
$this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data);
}
return $this->success(['data' => $address->toArray()]);
}
@@ -702,11 +748,20 @@ class DoctorController extends BaseController
return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک حذف می‌شود', 403);
}
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
if (!$this->mayTouchAddress($user, $address)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$doctor = $address->getDoctor();
$logRep = $doctor !== null && $this->editPolicy->ownsDoctor($user, $doctor);
$uuid = $doctor?->getUuid();
$this->addressRepo->remove($address);
if ($logRep) {
$this->editLogger->logEdit($user, 'doctor', (string) $uuid, ['address_deleted' => $id]);
}
return $this->success(['message' => 'آدرس با موفقیت حذف شد']);
}
@@ -828,6 +883,28 @@ class DoctorController extends BaseController
}
}
/**
* آیا این کاربر مجاز به دیدن/تغییر این آدرس است؟ خودِ پزشک، ادمین، یا نمایندهٔ
* ثبت‌کنندهٔ همان پزشک.
*
* ادمین پیش از هر چیز مجاز است تا رفتار قبلی روی آدرسِ بدون پزشک (آدرس کلینیک)
* دست‌نخورده بماند.
*/
private function mayTouchAddress(User $user, DoctorAddress $address): bool
{
if ($user->hasRole('ROLE_ADMIN')) {
return true;
}
$doctor = $address->getDoctor();
if ($doctor === null) {
return false;
}
return $doctor->getUser()->getId() === $user->getId()
|| $this->editPolicy->ownsDoctor($user, $doctor);
}
private function hydrateAddress(DoctorAddress $address, array $data): void
{
if (array_key_exists('name', $data)) $address->setName($data['name']);