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:
@@ -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' => 'آدرس با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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