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
+59 -7
View File
@@ -27,6 +27,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 Symfony\Component\Uid\Uuid;
#[OA\Tag(name: 'Clinics')]
@@ -51,6 +52,8 @@ class ClinicController extends BaseController
private readonly FileValidatorService $fileValidator,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
private readonly \App\Representation\Security\RepresentationEditPolicy $editPolicy,
private readonly \App\Representation\Security\RepresentationEditLogger $editLogger,
private readonly string $projectDir,
) {}
@@ -157,7 +160,7 @@ class ClinicController extends BaseController
]
)]
#[Route('/api/v1/clinic/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($uuid);
if ($clinic === null) {
@@ -166,7 +169,17 @@ class ClinicController extends BaseController
[$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic);
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]);
// can_edit تنها منبع حقیقتِ پنل است تا قاعدهٔ مجوز در فرانت بازنویسی نشود.
// اندپوینت عمومی است؛ بدون توکن همیشه false.
$canEdit = $user !== null && (
$this->permChecker->can($user, $clinic, 'clinic_info', 'update')
|| $this->editPolicy->ownsClinic($user, $clinic)
);
return $this->success(['data' => array_merge(
$clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone),
['can_edit' => $canEdit],
)]);
}
#[OA\Patch(
@@ -234,18 +247,34 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
// مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update
if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
// مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update؛ و نمایندهٔ
// ثبت‌کننده فقط روی فیلدهای محتوایی. نقش نماینده عمداً وارد permChecker نشد —
// آن کلاس دربارهٔ عضویتِ پزشک در کلینیک است و نماینده اصلاً عضو نیست.
$isRepOwner = $this->editPolicy->ownsClinic($user, $clinic);
if (!$isRepOwner && !$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if ($isRepOwner) {
$bad = $this->editPolicy->firstForbiddenField($data, RepresentationEditPolicy::CLINIC_FIELDS);
if ($bad !== null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'نماینده اجازهٔ تغییر این فیلد را ندارد', 403, $bad);
}
}
if (($err = $this->validateGallerySize($data)) !== null) {
return $err;
}
$this->hydrateClinic($clinic, $data);
$this->clinicRepo->save($clinic);
if ($isRepOwner) {
$this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data);
}
[$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic);
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]);
@@ -518,6 +547,17 @@ class ClinicController extends BaseController
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* آیا این کاربر مجاز به تغییر آدرس‌های این کلینیک است؟ مالک، ادمین، یا نمایندهٔ
* ثبت‌کننده. آدرس بخشی از محتوای پروفایل است، پس whitelist ندارد.
*/
private function mayTouchClinicAddress(User $user, Clinic $clinic): bool
{
return $clinic->getUser()->getId() === $user->getId()
|| $user->hasRole('ROLE_ADMIN')
|| $this->editPolicy->ownsClinic($user, $clinic);
}
private function validateGallerySize(array $data): ?JsonResponse
{
if (array_key_exists('image_clinic', $data)
@@ -738,7 +778,7 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
if (!$this->mayTouchClinicAddress($user, $clinic)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
@@ -751,6 +791,10 @@ class ClinicController extends BaseController
$this->hydrateClinicAddress($address, $data);
$this->addressRepo->save($address);
if ($this->editPolicy->ownsClinic($user, $clinic)) {
$this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data);
}
return $this->success(['data' => $address->toArray()], 201);
}
@@ -763,7 +807,7 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
if (!$this->mayTouchClinicAddress($user, $clinic)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
@@ -776,6 +820,10 @@ class ClinicController extends BaseController
$this->hydrateClinicAddress($address, $data);
$this->addressRepo->save($address);
if ($this->editPolicy->ownsClinic($user, $clinic)) {
$this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data);
}
return $this->success(['data' => $address->toArray()]);
}
@@ -788,7 +836,7 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
if (!$this->mayTouchClinicAddress($user, $clinic)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
@@ -799,6 +847,10 @@ class ClinicController extends BaseController
$this->addressRepo->remove($address);
if ($this->editPolicy->ownsClinic($user, $clinic)) {
$this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), ['address_deleted' => $addressUuid]);
}
return $this->success(['message' => 'آدرس با موفقیت حذف شد']);
}