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
@@ -0,0 +1,60 @@
<?php
namespace App\Representation\Security;
use App\Auth\Entity\User;
use App\Representation\Repository\RepresentationRepository;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* ردِ ویرایش‌هایی که نماینده روی پروفایلِ کسِ دیگری انجام می‌دهد.
*
* فقط نماینده لاگ می‌شود؛ مالک و ادمین نه — وگرنه /admin/logs پر از نویز می‌شود
* و همان چیزی که باید دیده شود گم می‌شود. صاحبِ رکورد به این ویرایش رضایت نداده،
* پس باید بعداً بتوان پرسید «چه کسی، کِی، کدام فیلدها».
*
* نوشتن با INSERT خامِ DBAL است، مثل DbLogger و به همان دلیل: لاگ نباید در
* unit of workِ درخواست بنشیند و با rollback بپرد، و شکستِ لاگ نباید یک ویرایشِ
* موفق را خراب کند.
*/
class RepresentationEditLogger
{
public function __construct(
private readonly Connection $conn,
private readonly RepresentationRepository $repRepo,
private readonly RequestStack $requestStack,
) {}
/**
* @param 'doctor'|'clinic' $entityType
* @param array<string, mixed> $data بدنهٔ درخواست؛ فقط کلیدهایش ثبت می‌شود، نه مقادیر
*/
public function logEdit(User $user, string $entityType, string $uuid, array $data): void
{
$repId = $this->repRepo->findByUser($user)?->getId();
if ($repId === null) {
return;
}
$label = $entityType === 'clinic' ? 'کلینیک' : 'پزشک';
try {
$this->conn->insert('app_log', [
'level' => 'info',
'message' => sprintf('نماینده #%d پروفایل %s %s را ویرایش کرد', $repId, $label, $uuid),
'context' => json_encode([
'representation_id' => $repId,
'entity_type' => $entityType,
'uuid' => $uuid,
'fields' => array_keys($data),
], JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR),
'channel' => 'representation_edit',
'path' => $this->requestStack->getCurrentRequest()?->getPathInfo(),
'created_at' => time(),
]);
} catch (\Throwable) {
// ویرایش انجام شده؛ نبودِ لاگ نباید آن را به خطا تبدیل کند.
}
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Representation\Security;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Representation\Repository\RepresentationRepository;
/**
* «نمایندهٔ ثبت‌کننده روی پروفایلی که خودش ساخته چه اجازه‌ای دارد؟»
*
* مجوز دائمی است و تنها به representation_id گره می‌خورد — نماینده تا وقتی رکورد
* به او اشاره می‌کند مالکِ محتوای آن است. عمداً فقط فیلدهای محتوایی باز است:
* عضویت پزشکان در کلینیک، کد نظام پزشکی و فعال/غیرفعال بودن، تصمیم‌های صاحبِ
* رکوردند نه نماینده‌ای که او را ثبت کرده.
*/
class RepresentationEditPolicy
{
/** فیلدهایی که نماینده روی پروفایل پزشک می‌تواند بفرستد. */
public const DOCTOR_FIELDS = [
'title', 'gender', 'degree', 'info', 'detail',
'mobile_number', 'activity_time',
'images', 'image_data', 'social_media',
'specialties', 'doctor_services', 'expertise',
'states', 'cities',
];
/**
* فیلدهایی که نماینده روی پروفایل کلینیک می‌تواند بفرستد.
*
* specialties و doctor_services و insurance کاتالوگِ نمایشیِ کلینیک‌اند و در
* جستجوی عمومی دیده می‌شوند — قرینهٔ همان‌ها در DOCTOR_FIELDS. با `doctors`
* اشتباه نشوند: آن عضویتِ پزشکان است و بیرون می‌ماند.
*/
public const CLINIC_FIELDS = [
'name', 'info', 'address', 'telephone',
'working_days', '24_7', 'latitude', 'longitude',
'practice_domain_uuid', 'state', 'city',
'social_media', 'image_clinic', 'clinic_logo',
'specialties', 'doctor_services', 'insurance',
];
public function __construct(
private readonly RepresentationRepository $repRepo,
) {}
public function ownsDoctor(User $user, Doctor $doctor): bool
{
return $this->matches($user, $doctor->getRepresentationId());
}
public function ownsClinic(User $user, Clinic $clinic): bool
{
return $this->matches($user, $clinic->getRepresentationId());
}
/**
* اولین کلیدِ ممنوع در بدنهٔ درخواست، یا null اگر همه مجاز باشند.
*
* @param array<string, mixed> $data
* @param list<string> $allowed یکی از DOCTOR_FIELDS یا CLINIC_FIELDS
*/
public function firstForbiddenField(array $data, array $allowed): ?string
{
foreach (array_keys($data) as $key) {
if (!in_array((string) $key, $allowed, true)) {
return (string) $key;
}
}
return null;
}
/**
* کاربرِ بدون نقش نماینده بدون کوئری رد می‌شود؛ همین مسیر، کاربرِ دارای نقش
* ولی بدون ردیف Representation را هم به false می‌بندد، نه به exception.
*/
private function matches(User $user, ?int $representationId): bool
{
if ($representationId === null || !$user->hasRole('ROLE_REPRESENTATION')) {
return false;
}
$rep = $this->repRepo->findByUser($user);
return $rep !== null && $rep->getId() === $representationId;
}
}