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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user