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,510 @@
|
||||
# مجوز ویرایش پروفایل پزشک و کلینیک برای نمایندهٔ ثبتکننده
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` — بکاند Symfony و پنل ادمین React.
|
||||
|
||||
تکریپو است. `nobat724_front` فقط سه فراخوانی داشبورد نماینده دارد
|
||||
(`services/response.js` خطوط ۲۰۲ تا ۲۱۱) و به هیچکدام از اندپوینتهای این تسک دست نمیزند.
|
||||
|
||||
## زمینه
|
||||
|
||||
نماینده امروز میتواند پزشک و کلینیک بسازد. هنگام ساخت،
|
||||
`representation_id` روی رکورد ست میشود:
|
||||
|
||||
```php
|
||||
// src/Representation/Controller/RepresentationActionController.php:299
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep !== null) {
|
||||
$doctor->setRepresentationId($rep->getId());
|
||||
}
|
||||
```
|
||||
|
||||
ولی بعد از ساخت، هیچ راهی برای کامل کردن پروفایل ندارد.
|
||||
نه لگو، نه گالری، نه متن معرفی، نه تخصص، نه آدرس.
|
||||
عملاً onboarding نیمهکاره میماند و پزشک تازهساخته روی سایت عمومی
|
||||
یک رکورد خالی است.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
نماینده باید روی هر پزشک و کلینیکی که `representation_id` آن به او اشاره دارد،
|
||||
فیلدهای **محتوایی و ظاهری** را ویرایش کند — و فقط همانها.
|
||||
|
||||
مجوز **دائمی** است و به `representation_id` گره میخورد.
|
||||
هیچ فیلد جدید، هیچ migration، هیچ state تازهای لازم نیست.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: نمایندهای که پزشک X را ساخته،
|
||||
`PATCH /api/v1/doctor/{X.uuid}` با بدنهٔ `{"info": "متن جدید", "images": [...]}` میفرستد
|
||||
→ `200` و رکورد ذخیره میشود.
|
||||
همین برای `PATCH /api/v1/clinic/{uuid}` با `{"clinic_logo": "...", "info": "..."}`.
|
||||
- ✅ موفق: `GET /api/v1/doctor/{X.uuid}` با توکن همان نماینده → `can_edit: true`.
|
||||
همان درخواست با توکن نمایندهٔ دیگر → `can_edit: false`.
|
||||
بدون توکن → `can_edit: false` و بقیهٔ پاسخ مثل قبل.
|
||||
- ❌ خطا: نمایندهٔ **دیگری** (که این پزشک را نساخته) همان PATCH را بفرستد
|
||||
→ `403` با `ERR_AUTH_006`.
|
||||
- ❌ خطا: نمایندهٔ مالک، کلید ممنوع بفرستد
|
||||
(`medical_system_code` یا `active` برای پزشک، `doctors` برای کلینیک)
|
||||
→ `403` با نام همان فیلد در `errors[0].field`. **هیچ چیزی ذخیره نمیشود.**
|
||||
- ⚠️ مرزی: کاربری با `ROLE_REPRESENTATION` که ردیف `Representation` ندارد
|
||||
→ `403`، نه `500`.
|
||||
- ⚠️ مرزی: پزشکی که `representation_id` آن `null` است
|
||||
→ هیچ نمایندهای اجازه ندارد؛ `403`.
|
||||
- ⚠️ مرزی: خودِ پزشک و مالک کلینیک و ادمین **دقیقاً مثل قبل** رفتار میکنند —
|
||||
whitelist روی آنها اعمال نمیشود و همچنان میتوانند `medical_system_code`
|
||||
و `doctors` را عوض کنند. این تسک هیچ دسترسی موجودی را تنگ نمیکند.
|
||||
- ⚠️ مرزی: هر PATCH موفقِ نماینده دقیقاً یک ردیف `AppLog` میسازد.
|
||||
PATCH مالک یا ادمین هیچ ردیفی نمیسازد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Representation/Security/RepresentationEditPolicy.php` | **جدید** — مالکیت و whitelist |
|
||||
| `src/Doctor/Controller/DoctorController.php` | `update`، `show`، سه اکشن آدرس پزشک |
|
||||
| `src/Clinic/Controller/ClinicController.php` | `update`، `show`، سه اکشن آدرس کلینیک |
|
||||
| `src/Representation/Repository/RepresentationRepository.php` | `findByUser()` موجود است |
|
||||
| `src/Shared/Logging/AppLog.php` | entity لاگ موجود |
|
||||
| `assets/admin/pages/DoctorDetailPage.tsx` | `isReadOnly` خط ۱۱۲۵ |
|
||||
| `assets/admin/pages/ClinicDetailPage.tsx` | `isReadOnly` خط ۳۶۴ |
|
||||
| `docs/api/doctor.md` · `docs/api/clinic.md` | سند اندپوینتها |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### دروازهٔ پزشک — فقط خود پزشک یا ادمین
|
||||
|
||||
```php
|
||||
// src/Doctor/Controller/DoctorController.php:340
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title']));
|
||||
|
||||
$this->hydrateDoctor($doctor, $data);
|
||||
$this->doctorRepo->save($doctor);
|
||||
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
|
||||
}
|
||||
```
|
||||
|
||||
### دروازهٔ کلینیک — از checker موجود رد میشود
|
||||
|
||||
```php
|
||||
// src/Clinic/Controller/ClinicController.php:223
|
||||
#[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
// مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update
|
||||
if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (($err = $this->validateGallerySize($data)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
$this->hydrateClinic($clinic, $data);
|
||||
$this->clinicRepo->save($clinic);
|
||||
|
||||
[$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic);
|
||||
|
||||
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]);
|
||||
}
|
||||
```
|
||||
|
||||
`ClinicDoctorPermissionChecker::can()` نقش نماینده را نمیشناسد:
|
||||
|
||||
```php
|
||||
// src/Clinic/Security/ClinicDoctorPermissionChecker.php:44
|
||||
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->permRepo->getOrCreate($clinic, $doctor)->can($resource, $action);
|
||||
}
|
||||
```
|
||||
|
||||
### آدرسها — دروازهٔ جدا
|
||||
|
||||
```php
|
||||
// src/Doctor/Controller/DoctorController.php:656 — PATCH آدرس پزشک
|
||||
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// src/Clinic/Controller/ClinicController.php:766 — PATCH آدرس کلینیک (و DELETE، خط ۷۹۱)
|
||||
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// src/Doctor/Controller/DoctorController.php:542 — POST آدرس پزشک
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر میتواند آدرس اضافه کند', 403);
|
||||
}
|
||||
```
|
||||
|
||||
### UI — عمداً قفل است
|
||||
|
||||
```tsx
|
||||
// assets/admin/pages/DoctorDetailPage.tsx:1124
|
||||
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
```
|
||||
|
||||
```tsx
|
||||
// assets/admin/pages/ClinicDetailPage.tsx:363
|
||||
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
```
|
||||
|
||||
### GET جزئیات — هنوز کاربر جاری را نمیگیرد
|
||||
|
||||
```php
|
||||
// src/Doctor/Controller/DoctorController.php:150
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid): JsonResponse
|
||||
```
|
||||
|
||||
```php
|
||||
// src/Clinic/Controller/ClinicController.php:159
|
||||
#[Route('/api/v1/clinic/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid): JsonResponse
|
||||
```
|
||||
|
||||
هر دو عمومیاند و `#[CurrentUser]` ندارند.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. کلاس سیاست — `RepresentationEditPolicy`
|
||||
|
||||
فایل جدید: `src/Representation/Security/RepresentationEditPolicy.php`
|
||||
|
||||
تنها تصمیمگیرندهٔ «این نماینده روی این رکورد چه اجازهای دارد».
|
||||
هیچ controllerی نباید `representation_id` را دستی مقایسه کند.
|
||||
|
||||
```php
|
||||
<?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',
|
||||
];
|
||||
|
||||
/** فیلدهایی که نماینده روی پروفایل کلینیک میتواند بفرستد. */
|
||||
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',
|
||||
];
|
||||
|
||||
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 list<string> $allowed یکی از DOCTOR_FIELDS یا CLINIC_FIELDS
|
||||
*/
|
||||
public function firstForbiddenField(array $data, array $allowed): ?string
|
||||
{
|
||||
foreach (array_keys($data) as $key) {
|
||||
if (!in_array($key, $allowed, true)) {
|
||||
return (string) $key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
نکتههای اجباری:
|
||||
|
||||
- `matches()` وقتی `ROLE_REPRESENTATION` نیست، **بدون کوئری** برمیگردد.
|
||||
این همان حالت مرزی «کاربر با نقش نماینده ولی بدون ردیف Representation» را هم
|
||||
به `false` میبندد، نه به exception.
|
||||
- کلاس هیچ HTTP نمیشناسد. پاسخ ۴۰۳ کارِ controller است.
|
||||
|
||||
**نحوه تست:** unit test خالص با entityهای ساختگی —
|
||||
`tests/Representation/RepresentationEditPolicyTest.php`.
|
||||
سناریوها: مالکِ درست `true`؛ نمایندهٔ دیگر `false`؛ `representation_id === null` → `false`؛
|
||||
کاربر بدون `ROLE_REPRESENTATION` → `false`؛ `firstForbiddenField` روی
|
||||
`['info' => 'x', 'doctors' => []]` با `CLINIC_FIELDS` باید `'doctors'` بدهد و
|
||||
روی `['info' => 'x']` باید `null` بدهد.
|
||||
|
||||
### ۲. لاگکردن ویرایشِ نماینده
|
||||
|
||||
سرویس کوچک کنار سیاست: `src/Representation/Security/RepresentationEditLogger.php`
|
||||
|
||||
از entity موجود `App\Shared\Logging\AppLog` استفاده کن — هیچ جدول جدیدی نساز.
|
||||
سازندهٔ آن `(level, message, context, channel, path)` میگیرد.
|
||||
|
||||
```php
|
||||
$this->em->persist(new AppLog(
|
||||
'info',
|
||||
sprintf('نماینده #%d پروفایل %s %s را ویرایش کرد', $repId, $entityType, $uuid),
|
||||
json_encode(['representation_id' => $repId, 'fields' => array_keys($data)], JSON_UNESCAPED_UNICODE),
|
||||
'representation_edit',
|
||||
$request->getPathInfo(),
|
||||
));
|
||||
```
|
||||
|
||||
فقط وقتی لاگ بنویس که ویرایشکننده **نماینده** باشد.
|
||||
مالک و ادمین هیچ ردیفی نمیسازند — وگرنه `/admin/logs` پر از نویز میشود.
|
||||
|
||||
**نحوه تست:** بعد از یک PATCH موفقِ نماینده،
|
||||
`SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'` باید یکی زیاد شده باشد.
|
||||
بعد از PATCH ادمین روی همان رکورد، عددی تغییر نکند.
|
||||
|
||||
### ۳. باز کردن `PATCH /api/v1/doctor/{uuid}`
|
||||
|
||||
در `DoctorController::update` شرط ۴۰۳ فعلی را نگه دار و یک شاخهٔ نماینده کنارش بگذار.
|
||||
ترتیب مهم است: اول مالکیت، بعد whitelist، بعد hydrate.
|
||||
|
||||
```php
|
||||
$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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
whitelist **فقط** روی `$isRepOwner` اجرا میشود. مسیر مالک و ادمین دستنخورده میماند.
|
||||
|
||||
**نحوه تست:** تست فانکشنال با `ApiTestCase` —
|
||||
`tests/Representation/RepresentationProfileEditTest.php`.
|
||||
یک نماینده و پزشکش بساز (از `POST /api/v1/representation/doctor` استفاده کن، نه fixture دستی)،
|
||||
بعد PATCH با `info` بزن و `200` بگیر، بعد PATCH با `medical_system_code` بزن و `403` بگیر
|
||||
و مطمئن شو مقدار قبلی در دیتابیس عوض نشده.
|
||||
|
||||
### ۴. باز کردن `PATCH /api/v1/clinic/{uuid}`
|
||||
|
||||
همان الگو. ولی اینجا `permChecker` جلوی راه است.
|
||||
|
||||
**آن را تغییر نده.** `ClinicDoctorPermissionChecker` دربارهٔ عضویت پزشک در کلینیک است
|
||||
و نماینده اصلاً پزشکِ عضو نیست؛ اضافهکردن نقش نماینده به آن، مسئولیتِ کلاس را دوتا میکند.
|
||||
بهجایش در controller کنارش بگذار:
|
||||
|
||||
```php
|
||||
$isRepOwner = $this->editPolicy->ownsClinic($user, $clinic);
|
||||
|
||||
if (!$isRepOwner && !$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
```
|
||||
|
||||
حواست به خط ۲۳۰ باشد:
|
||||
|
||||
```php
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
|
||||
```
|
||||
|
||||
این پیشچکِ منشی است و پیش از واکشی رکورد اجرا میشود.
|
||||
بررسی کن که برای کاربرِ `ROLE_REPRESENTATION` (که منشی نیست) throw نکند.
|
||||
اگر throw میکند، شرطش را طوری بگذار که فقط برای نقش منشی اجرا شود — و در تست ثابتش کن.
|
||||
|
||||
whitelist با `RepresentationEditPolicy::CLINIC_FIELDS`.
|
||||
|
||||
**نحوه تست:** نماینده و کلینیکش را با `POST /api/v1/representation/clinic` بساز.
|
||||
`PATCH` با `{"clinic_logo": "https://x/y.png", "info": "..."}` → `200`.
|
||||
`PATCH` با `{"doctors": [1]}` → `403` و `errors[0].field === 'doctors'`.
|
||||
یک نمایندهٔ دوم بساز و همان PATCH را بزن → `403`.
|
||||
|
||||
### ۵. آدرسها — شش اکشن
|
||||
|
||||
همان سیاست را در اینها هم صدا بزن:
|
||||
|
||||
- `POST /api/v1/clinic-pro/doctor-address` — نماینده باید `doctor_uuid` بفرستد،
|
||||
دقیقاً مثل مسیر ادمین در خط ۵۵۲. مالکیت همان پزشک بررسی شود.
|
||||
- `PATCH /api/v1/clinic-pro/doctor-address/{id}`
|
||||
- `DELETE /api/v1/clinic-pro/doctor-address/{id}`
|
||||
- `POST /api/v1/clinic/{clinicUuid}/address`
|
||||
- `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
- `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
|
||||
روی آدرسها whitelist لازم نیست — کل رکورد آدرس محتوایی است.
|
||||
شرط `TYPE_PERSONAL` در خط ۶۵۲ باید سر جایش بماند؛ نماینده هم نباید
|
||||
آدرس کلینیک را از مسیر آدرسِ پزشک عوض کند.
|
||||
|
||||
**نحوه تست:** نماینده برای پزشکش آدرس بسازد، ویرایش کند، حذف کند — هر سه `200`.
|
||||
نمایندهٔ دوم روی همان آدرس `403` بگیرد.
|
||||
|
||||
### ۶. فلگ `can_edit` در پاسخ GET جزئیات
|
||||
|
||||
هر دو `show` را طوری عوض کن که کاربر جاری را اختیاری بگیرند:
|
||||
|
||||
```php
|
||||
public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse
|
||||
```
|
||||
|
||||
و در آرایهٔ خروجی:
|
||||
|
||||
```php
|
||||
'can_edit' => $user !== null && (
|
||||
$doctor->getUser()->getId() === $user->getId()
|
||||
|| $user->hasRole('ROLE_ADMIN')
|
||||
|| $this->editPolicy->ownsDoctor($user, $doctor)
|
||||
),
|
||||
```
|
||||
|
||||
برای کلینیک همان با `permChecker->can(...) || editPolicy->ownsClinic(...)`.
|
||||
|
||||
هر دو اندپوینت عمومیاند. بدون توکن باید `can_edit: false` بدهند و
|
||||
هیچ بخش دیگری از پاسخ عوض نشود — سایت عمومی همینها را مصرف میکند.
|
||||
|
||||
**نحوه تست:** سه بار `GET /api/v1/doctor/{uuid}` — بدون توکن، با توکن نمایندهٔ مالک،
|
||||
با توکن نمایندهٔ دیگر. مقادیر `false` و `true` و `false`.
|
||||
|
||||
### ۷. باز کردن UI پنل
|
||||
|
||||
در `DoctorDetailPage.tsx` و `ClinicDetailPage.tsx` این خط را بردار:
|
||||
|
||||
```tsx
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
```
|
||||
|
||||
و جایش از پاسخ سرور بخوان:
|
||||
|
||||
```tsx
|
||||
const isReadOnly = primaryRole === 'representation' && !doctor?.can_edit;
|
||||
```
|
||||
|
||||
هیچ منطق مجوزی را در فرانت بازنویسی نکن. `can_edit` تنها منبع حقیقت است.
|
||||
|
||||
فیلدهای بیرون از whitelist باید برای نماینده در فرم **مخفی یا disabled** باشند،
|
||||
نه اینکه ارسال شوند و ۴۰۳ بگیرند:
|
||||
|
||||
- پزشک: کد نظام پزشکی، و کلید فعال/غیرفعال
|
||||
- کلینیک: مدیریت پزشکان کلینیک
|
||||
|
||||
نماینده همچنان میتواند پزشک را فعال/غیرفعال کند، ولی از اندپوینت اختصاصی خودش:
|
||||
`POST /api/v1/representation/doctors/{uuid}/status`.
|
||||
اگر آن دکمه در صفحه هست، به همان اندپوینت وصلش کن نه به `PATCH`.
|
||||
|
||||
تایپها را در `assets/admin/types/index.ts` بهروز کن: `can_edit?: boolean`.
|
||||
|
||||
**نحوه تست:** `npx tsc --noEmit --project tsconfig.json` سبز،
|
||||
`ddev exec yarn dev` بدون خطا، و ورود دستی با یک کاربر نماینده در
|
||||
`https://clinic-pro.ddev.site/admin/doctors/<uuid>` — دکمهٔ ویرایش و آپلود لگو دیده شود
|
||||
و کد نظام پزشکی دیده نشود.
|
||||
|
||||
### ۸. مستندات
|
||||
|
||||
- `docs/api/doctor.md` — `PATCH /api/v1/doctor/{uuid}`: نقش نماینده، فهرست فیلدهای مجاز،
|
||||
و ۴۰۳ فیلد ممنوع. `GET`: فیلد `can_edit`.
|
||||
- `docs/api/clinic.md` — همان برای کلینیک.
|
||||
- اندپوینتهای آدرس در هر دو سند.
|
||||
- JSON نمونه باید **خروجی اجرای واقعی** باشد، نه دستساز.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **الگو: Policy Object.** یک کلاس، یک سؤال: «این نماینده چه اجازهای دارد».
|
||||
دلیل انتخاب: منطق مجوز الان در هشت اکشن تکرار میشود؛ اگر inline بنویسی،
|
||||
فردا که قاعده عوض شود هشت جا باید عوض شود و یکی جا میماند.
|
||||
`ClinicDoctorPermissionChecker` را گسترش نده — آن دربارهٔ عضویت پزشک در کلینیک است،
|
||||
و پزشکِ نماینده اصلاً عضو نیست.
|
||||
- **این تسک هیچ دسترسی موجودی را تنگ نمیکند.** فقط باز میکند.
|
||||
اگر تستی از رفتار پزشک یا مالک یا ادمین شکست، یعنی whitelist اشتباه به آن مسیر هم خورده.
|
||||
- **`active` عمداً بیرون است.** نماینده اندپوینت اختصاصی دارد:
|
||||
`POST /api/v1/representation/doctors/{uuid}/status` (خط ۶۳۲ همان controller).
|
||||
دو مسیر برای یک کار نساز.
|
||||
- **اندپوینتهای آپلود دستنخورده میمانند.**
|
||||
`/file/upload/clinic_pro/doctor/field_image` و `.../clinic/field_clinic_logo`
|
||||
الان هم برای هر کاربر لاگینشده بازند و فقط URL برمیگردانند؛
|
||||
دروازهٔ واقعی همان PATCH است که URL را ذخیره میکند.
|
||||
- **کلید ممنوع = ۴۰۳، نه حذف بیصدا.** حذف بیصدا یعنی نماینده فکر میکند ذخیره شده
|
||||
و تا مدتها کسی نمیفهمد. `$this->error(..., 403, $fieldName)` امضای فیلد را هم میگیرد.
|
||||
- **`مالکیت` را از `representation_id` بخوان، نه از نقش.**
|
||||
هر کاربری میتواند `ROLE_REPRESENTATION` داشته باشد؛ آنچه مهم است اینکه
|
||||
رکورد `Representation` او همان id باشد که روی پزشک/کلینیک نشسته.
|
||||
- **قرارداد API عوض نمیشود، فقط گسترده میشود.** `can_edit` فیلد جدید و اختیاری است.
|
||||
ولی `GET /api/v1/doctor/{uuid}` و `GET /api/v1/clinic/{uuid}` را
|
||||
`nobat724_front` هم مصرف میکند؛ بعد از تغییر، صفحهٔ پزشک و کلینیک سایت عمومی را
|
||||
دستی باز کن و مطمئن شو چیزی نشکسته. build آنها خطا نمیدهد.
|
||||
- تستها زیر `tests/Representation/` بروند. دیتابیس تست هرگز reset نمیشود،
|
||||
پس دادهٔ هر تست را با مقدار یکتا بساز (`uniqid()`) تا اجرای دوم هم سبز بماند.
|
||||
@@ -360,8 +360,7 @@ export default function ClinicDetailPage() {
|
||||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||||
const authToken = useAuthStore(s => s.token);
|
||||
const isOwner = primaryRole === 'clinic' && dbUuid === uuid;
|
||||
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
const isRepresentative = primaryRole === 'representation';
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
@@ -450,6 +449,12 @@ export default function ClinicDetailPage() {
|
||||
return (raw as any)?.data ?? raw;
|
||||
}, [data]);
|
||||
|
||||
// نماینده فقط کلینیکی را ویرایش میکند که خودش ثبت کرده. تصمیم با سرور است —
|
||||
// `can_edit` را همان چکهایی میسازند که دروازهٔ PATCH را نگه میدارند.
|
||||
const isReadOnly = isRepresentative && !clinic?.can_edit;
|
||||
// مدیریت آدرسها برای نمایندهٔ مالک هم باز است؛ آدرس محتواست، نه عضویت.
|
||||
const canManageAddresses = isOwner || primaryRole === 'admin' || (isRepresentative && !!clinic?.can_edit);
|
||||
|
||||
const openEdit = () => setEditOpen(true);
|
||||
|
||||
const toggleMut = useMutation({
|
||||
@@ -674,8 +679,9 @@ export default function ClinicDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Doctors + Invitations — shared manager */}
|
||||
<ClinicDoctorsManager clinicUuid={uuid!} readOnly={isReadOnly} />
|
||||
{/* Doctors + Invitations — shared manager. عضویت پزشکان تصمیم مالک است،
|
||||
نه نماینده؛ `doctors` بیرون از whitelist است و PATCH ردش میکند. */}
|
||||
<ClinicDoctorsManager clinicUuid={uuid!} readOnly={isReadOnly || isRepresentative} />
|
||||
|
||||
{/* Gallery */}
|
||||
<div className="card card-pad">
|
||||
@@ -800,13 +806,13 @@ export default function ClinicDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Clinic Addresses Section */}
|
||||
{(isOwner || primaryRole === 'admin' || isReadOnly) && (
|
||||
{(isOwner || primaryRole === 'admin' || isRepresentative) && (
|
||||
<div className="card">
|
||||
<div className="toolbar" style={{ padding: '12px 16px' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
آدرسهای کلینیک ({formatNumber(clinicAddresses.length)})
|
||||
</div>
|
||||
{(isOwner || primaryRole === 'admin') && clinicAddresses.length === 0 && (
|
||||
{canManageAddresses && clinicAddresses.length === 0 && (
|
||||
<button className="btn primary sm" onClick={() => openAddrForm(null)}>
|
||||
<PlusIcon style={{ width: 14, height: 14 }} />
|
||||
افزودن آدرس
|
||||
@@ -841,7 +847,7 @@ export default function ClinicDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(isOwner || primaryRole === 'admin') && (
|
||||
{canManageAddresses && (
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button className="mini-btn" title="ویرایش آدرس" onClick={() => openAddrForm(addr)}>
|
||||
<PencilIcon style={{ width: 14, height: 14 }} />
|
||||
|
||||
@@ -66,6 +66,8 @@ interface DoctorDetail {
|
||||
city: { id: string; name: string }[];
|
||||
clinics: { id: string; uuid: string; name: string; address: string | null; telephone: string | null }[];
|
||||
representation: { id: number; uuid: string; full_name: string | null } | null;
|
||||
/** آیا کاربر جاری اجازهٔ ویرایش دارد؟ سرور تصمیم میگیرد، نه این صفحه. */
|
||||
can_edit?: boolean;
|
||||
}
|
||||
|
||||
interface SpecialtyOpt { id: number; uuid: string; name: string; parent_id: number | null; }
|
||||
@@ -1121,8 +1123,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
const context = useAuthStore(s => s.context);
|
||||
const availableContexts = useAuthStore(s => s.availableContexts);
|
||||
const uuid = isOwnProfile ? (doctorUuid ?? dbUuid ?? undefined) : paramUuid;
|
||||
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
const isRepresentative = primaryRole === 'representation';
|
||||
|
||||
// صفحهٔ پزشک در پنل کلینیک، تنظیمات نوبتدهیِ همان کلینیک را ویرایش میکند — نه
|
||||
// برنامهٔ مطب شخصی پزشک، که فقط خودش به آن دسترسی دارد.
|
||||
@@ -1174,6 +1175,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
return (raw as any)?.data ?? raw;
|
||||
}, [data]);
|
||||
|
||||
// نماینده فقط پزشکی را ویرایش میکند که خودش ثبت کرده. تصمیم با سرور است —
|
||||
// `can_edit` را همان سیاستی میسازد که دروازهٔ PATCH را نگه میدارد، پس این صفحه
|
||||
// قاعده را بازنویسی نمیکند.
|
||||
const isReadOnly = isRepresentative && !doctor?.can_edit;
|
||||
|
||||
const specialties: SpecialtyOpt[] = useMemo(
|
||||
() => specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? [], [specialtiesQ.data]);
|
||||
const services: ServiceOpt[] = useMemo(
|
||||
@@ -1228,7 +1234,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
title: body.name,
|
||||
gender: editGender || undefined,
|
||||
degree: body.degree || undefined,
|
||||
medical_system_code: body.medical_system_code || undefined,
|
||||
// کد نظام پزشکی بیرون از whitelistِ نماینده است؛ فرستادنش کل درخواست را ۴۰۳ میکند.
|
||||
...(isRepresentative ? {} : { medical_system_code: body.medical_system_code || undefined }),
|
||||
mobile_number: body.mobile_number || undefined,
|
||||
info: body.info || undefined,
|
||||
...(editActivityDate
|
||||
@@ -1466,8 +1473,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Toggle active / Delete — فقط ادمین */}
|
||||
{!isOwnProfile && primaryRole !== 'clinic' && !isReadOnly && (
|
||||
{/* Toggle active / Delete — فقط ادمین. نماینده حتی روی پزشکِ خودش هم
|
||||
اینجا را نمیبیند: `active` بیرون از whitelist است و PATCH ردش میکند.
|
||||
فعال/غیرفعال کردن از اندپوینت اختصاصیِ نماینده انجام میشود. */}
|
||||
{!isOwnProfile && primaryRole !== 'clinic' && !isRepresentative && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setToggleConfirm(true)}
|
||||
@@ -1748,9 +1757,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
/>
|
||||
)} />
|
||||
</EditField>
|
||||
<EditField label="کد نظام پزشکی">
|
||||
<input type="text" dir="ltr" className="cp-input" placeholder="123456" {...register('medical_system_code')} />
|
||||
</EditField>
|
||||
{!isRepresentative && (
|
||||
<EditField label="کد نظام پزشکی">
|
||||
<input type="text" dir="ltr" className="cp-input" placeholder="123456" {...register('medical_system_code')} />
|
||||
</EditField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface ClinicDetail {
|
||||
map: { latitude: string | null; longitude: string | null };
|
||||
"24_7": boolean;
|
||||
field_working_days: string | null;
|
||||
/** آیا کاربر جاری اجازهٔ ویرایش دارد؟ سرور تصمیم میگیرد، نه کلاینت. */
|
||||
can_edit?: boolean;
|
||||
}
|
||||
|
||||
export type AppointmentStatus =
|
||||
|
||||
+68
-13
@@ -109,13 +109,23 @@ unreachable data — the request is rejected instead.
|
||||
|
||||
Get clinic detail.
|
||||
|
||||
**Permission:** `PUBLIC`
|
||||
**Permission:** `PUBLIC` — a token is optional and only affects `can_edit`.
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `uuid` | string (UUID) | Clinic UUID |
|
||||
|
||||
### `can_edit`
|
||||
|
||||
The payload carries `can_edit: boolean` — whether the **caller** may `PATCH` this clinic. It is `true`
|
||||
for the owner, `ROLE_ADMIN`, a member doctor holding `clinic_info.update`, and the registering
|
||||
representative; `false` for everyone else and always `false` without a token. It is computed by the
|
||||
same checks the `PATCH` gate uses, so clients must read it rather than re-deriving the rule.
|
||||
|
||||
It says nothing about *which fields* are writable — a representative sees `can_edit: true` yet is still
|
||||
limited to the whitelist under `PATCH /api/v1/clinic/{uuid}`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
@@ -185,13 +195,39 @@ Get clinic detail.
|
||||
|
||||
Update a clinic.
|
||||
|
||||
**Permission:** `AUTH` — the clinic owner, `ROLE_ADMIN`, or a member doctor holding `clinic_info.update` (see **Clinic Doctor Permissions**)
|
||||
**Permission:** `AUTH` — the clinic owner, `ROLE_ADMIN`, a member doctor holding `clinic_info.update`
|
||||
(see **Clinic Doctor Permissions**), or the **registering representative** (a `ROLE_REPRESENTATION`
|
||||
user whose `Representation.id` equals the clinic's `representation_id`).
|
||||
|
||||
The representative's grant is permanent for as long as `representation_id` points at them, but it is
|
||||
restricted to content fields — see *Representative field whitelist* below. Owner, admin and member-doctor
|
||||
access is unchanged and unrestricted. The representative path deliberately bypasses
|
||||
`ClinicDoctorPermissionChecker`: that class answers "is this doctor a member of this clinic", and a
|
||||
representative is not a member at all.
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `uuid` | string (UUID) | Clinic UUID |
|
||||
|
||||
### Representative field whitelist
|
||||
|
||||
A representative may send only these keys. Any other key aborts the whole request with `403`
|
||||
and **nothing is saved** — the payload is not silently filtered.
|
||||
|
||||
`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`
|
||||
|
||||
Notably excluded: `doctors`. Which doctors belong to a clinic is a membership decision for the owner,
|
||||
not for the representative who registered it. `specialties` / `doctor_services` / `insurance` **are**
|
||||
allowed — they are the clinic's public-facing catalogue, the mirror of the same keys on the doctor
|
||||
whitelist, and are not membership.
|
||||
|
||||
Every successful representative edit writes one `app_log` row with `channel = 'representation_edit'`,
|
||||
recording the representative id, the target uuid, and the **names** of the changed fields (never
|
||||
their values). Edits by the owner, a member doctor, or an admin write no such row.
|
||||
|
||||
### Request Body
|
||||
Same fields as POST — all optional — plus:
|
||||
|
||||
@@ -211,10 +247,29 @@ Updated clinic object (same structure as GET). Carries `practice_domain` — the
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
|
||||
| `ERR_FORBIDDEN_001` | 403 | Secretary without `clinic_info.update` — thrown by the pre-check, before the clinic is even loaded |
|
||||
| `ERR_AUTH_006` | 403 | Not the owner, not an admin, not a permitted member doctor, and not the registering representative |
|
||||
| `ERR_AUTH_006` | 403 | Representative sent a field outside the whitelist — the offending key is in `errors[0].field` |
|
||||
| `ERR_VALIDATION_002` | 404 | Clinic not found |
|
||||
| `ERR_VALIDATION_001` | 422 | `image_clinic` بیش از ۵ عکس دارد |
|
||||
| `ERR_VALIDATION_002` | 422 | `practice_domain_uuid` به هیچ حوزهای اشاره نمیکند |
|
||||
|
||||
Real response for a whitelist violation (captured from a functional test run, not hand-written):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "ERR_AUTH_006",
|
||||
"message": "نماینده اجازهٔ تغییر این فیلد را ندارد",
|
||||
"field": "doctors"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/clinics`
|
||||
@@ -622,10 +677,12 @@ Returns all addresses registered for a clinic (type=clinic entries).
|
||||
|
||||
### `POST /api/v1/clinic/{clinicUuid}/address`
|
||||
|
||||
**Permission:** Clinic owner or `ROLE_ADMIN`
|
||||
**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative
|
||||
|
||||
Creates a new address for the clinic. The address will appear in `available-locations` for doctors belonging to this clinic.
|
||||
|
||||
> A clinic may hold only one address — posting a second one returns `409` (`ERR_CONFLICT_001`). Use PATCH to change it.
|
||||
|
||||
#### Request
|
||||
```json
|
||||
{
|
||||
@@ -664,9 +721,10 @@ Creates a new address for the clinic. The address will appear in `available-loca
|
||||
|
||||
### `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
|
||||
**Permission:** Clinic owner or `ROLE_ADMIN`
|
||||
**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative
|
||||
|
||||
Updates an existing clinic address. Same body fields as POST (all optional).
|
||||
Updates an existing clinic address. Same body fields as POST (all optional). The address is content,
|
||||
not membership, so no field whitelist applies to representatives here.
|
||||
|
||||
#### Response `200`
|
||||
```json
|
||||
@@ -677,18 +735,15 @@ Updates an existing clinic address. Same body fields as POST (all optional).
|
||||
|
||||
### `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
|
||||
**Permission:** Clinic owner or `ROLE_ADMIN`
|
||||
**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative
|
||||
|
||||
Deletes a clinic address.
|
||||
|
||||
> A clinic must retain at least one address — attempting to delete the last address returns `409`.
|
||||
Deletes a clinic address. There is no "must keep at least one" guard — the last address can be deleted.
|
||||
|
||||
#### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_CONFLICT_001` | 409 | Cannot delete the last address |
|
||||
| `ERR_VALIDATION_002` | 404 | Address or clinic not found |
|
||||
| `ERR_AUTH_006` | 403 | Not the clinic owner |
|
||||
| `ERR_AUTH_006` | 403 | Not the owner, not an admin, and not the registering representative |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+92
-15
@@ -84,13 +84,23 @@ Create a doctor profile for the authenticated user.
|
||||
|
||||
Get doctor detail with clinics.
|
||||
|
||||
**Permission:** `PUBLIC`
|
||||
**Permission:** `PUBLIC` — a token is optional and only affects `can_edit`.
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `uuid` | string (UUID) | Doctor UUID |
|
||||
|
||||
### `can_edit`
|
||||
|
||||
The payload carries `can_edit: boolean` — whether the **caller** may `PATCH` this doctor. It is `true`
|
||||
for the doctor themselves, `ROLE_ADMIN`, and the registering representative; `false` for everyone else
|
||||
and always `false` without a token. It is computed by the same policy the `PATCH` gate uses, so clients
|
||||
must read it rather than re-deriving the rule; a client that recomputes will drift.
|
||||
|
||||
It says nothing about *which fields* are writable — a representative sees `can_edit: true` yet is still
|
||||
limited to the whitelist under `PATCH /api/v1/doctor/{uuid}`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
@@ -314,13 +324,35 @@ php bin/console app:audit-polluted-records --force # خارجکردن از
|
||||
|
||||
Update doctor profile.
|
||||
|
||||
**Permission:** `AUTH` — must be the owner (or `ROLE_ADMIN`)
|
||||
**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the **registering representative**
|
||||
(a `ROLE_REPRESENTATION` user whose `Representation.id` equals the doctor's `representation_id`).
|
||||
|
||||
The representative's grant is permanent for as long as `representation_id` points at them, but it is
|
||||
restricted to content fields — see *Representative field whitelist* below. The doctor's and the
|
||||
admin's own access is unchanged and unrestricted.
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `uuid` | string (UUID) | Doctor UUID |
|
||||
|
||||
### Representative field whitelist
|
||||
|
||||
A representative may send only these keys. Any other key aborts the whole request with `403`
|
||||
and **nothing is saved** — the payload is not silently filtered.
|
||||
|
||||
`title` · `gender` · `degree` · `info` · `detail` · `mobile_number` · `activity_time` ·
|
||||
`images` · `image_data` · `social_media` · `specialties` · `doctor_services` · `expertise` ·
|
||||
`states` · `cities`
|
||||
|
||||
Notably excluded: `medical_system_code` (professional credential) and `active`. A representative
|
||||
activates or deactivates their own doctor through `POST /api/v1/representation/doctors/{uuid}/status`
|
||||
instead.
|
||||
|
||||
Every successful representative edit writes one `app_log` row with `channel = 'representation_edit'`,
|
||||
recording the representative id, the target uuid, and the **names** of the changed fields (never
|
||||
their values). Edits by the doctor or an admin write no such row.
|
||||
|
||||
### Request Body (`application/json`)
|
||||
Same fields as POST (all optional), plus:
|
||||
|
||||
@@ -341,14 +373,41 @@ Same fields as POST (all optional), plus:
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
Updated doctor object (same structure as GET single).
|
||||
Updated doctor object (same structure as GET single), wrapped as `{ "success": true, "data": { ... } }`.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||||
| `ERR_AUTH_006` | 403 | Not the doctor, not an admin, and not the registering representative |
|
||||
| `ERR_AUTH_006` | 403 | Representative sent a field outside the whitelist — the offending key is in `errors[0].field` |
|
||||
| `ERR_VALIDATION_002` | 404 | Doctor not found |
|
||||
|
||||
Real responses (captured from a functional test run, not hand-written):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "ERR_AUTH_006",
|
||||
"message": "نماینده اجازهٔ تغییر این فیلد را ندارد",
|
||||
"field": "medical_system_code"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{ "code": "ERR_AUTH_006", "message": "دسترسی ممنوع" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -487,11 +546,24 @@ Get all practice addresses for a doctor, including addresses of clinics the doct
|
||||
|
||||
Add a new practice address.
|
||||
|
||||
**Permission:** `AUTH` — must own the doctor profile
|
||||
**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the registering representative.
|
||||
|
||||
**Target resolution.** When `doctor_uuid` is present it is the target, and the caller must be that
|
||||
doctor, an admin, or that doctor's registering representative. When it is absent the target is the
|
||||
caller's own doctor profile. A representative who also happens to have a doctor profile therefore no
|
||||
longer silently writes the address onto their own profile — the explicit uuid always wins.
|
||||
|
||||
| Caller | `doctor_uuid` present | `doctor_uuid` absent |
|
||||
|---|---|---|
|
||||
| The doctor | must be their own profile, else `403` | their own profile |
|
||||
| `ROLE_ADMIN` | any doctor | `422` — `doctor_uuid` required |
|
||||
| Registering representative | their own doctors only, else `403` | `422` — `doctor_uuid` required |
|
||||
| Anyone else | `403` | `403` |
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"doctor_uuid": "9d5f94ba-322d-4735-88e1-b15e8447a0fa",
|
||||
"name": "مطب تهران",
|
||||
"address": "تهران، خیابان ولیعصر",
|
||||
"telephone": "02112345678",
|
||||
@@ -504,6 +576,7 @@ Add a new practice address.
|
||||
|
||||
| Field | Type | Required |
|
||||
|-------|------|----------|
|
||||
| `doctor_uuid` | string (UUID) | ✅ for admins and representatives; optional for the doctor themselves |
|
||||
| `name` | string | ❌ |
|
||||
| `address` | string | ✅ (frontend validation) |
|
||||
| `telephone` | string | ✅ (frontend validation) |
|
||||
@@ -540,7 +613,10 @@ Add a new practice address.
|
||||
|
||||
Update a practice address.
|
||||
|
||||
**Permission:** `AUTH` — must own the doctor profile
|
||||
**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the registering representative.
|
||||
|
||||
Only `type = personal` addresses are reachable here. A clinic address returns `403` regardless of
|
||||
caller, including the representative — clinic addresses are edited through the clinic routes.
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
@@ -548,17 +624,17 @@ Update a practice address.
|
||||
| `id` | integer | Address ID |
|
||||
|
||||
### Request Body
|
||||
Same fields as POST — all optional.
|
||||
Same fields as POST — all optional. `doctor_uuid` is ignored; the target comes from the address itself.
|
||||
|
||||
### Response `200`
|
||||
Updated address object.
|
||||
Updated address object, double-wrapped as `{ "success": true, "data": { "data": { … } } }`.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Address not found |
|
||||
| `ERR_AUTH_006` | 403 | Address is a clinic address, or the caller is none of: the doctor, an admin, the registering representative |
|
||||
| `ERR_VALIDATION_002` | 404 | Address not found |
|
||||
|
||||
---
|
||||
|
||||
@@ -566,19 +642,20 @@ Updated address object.
|
||||
|
||||
Delete a practice address.
|
||||
|
||||
**Permission:** `AUTH` — must own the doctor profile
|
||||
**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the registering representative.
|
||||
Clinic addresses return `403` here, same as PATCH.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "success": true, "data": { "message": "آدرس حذف شد" } }
|
||||
{ "success": true, "data": { "message": "آدرس با موفقیت حذف شد" } }
|
||||
```
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Address not found |
|
||||
| `ERR_AUTH_006` | 403 | Address is a clinic address, or the caller is none of: the doctor, an admin, the registering representative |
|
||||
| `ERR_VALIDATION_002` | 404 | Address not found |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* `can_edit` روی GET جزئیات — تنها منبع حقیقتِ پنل برای «فرم را باز کن یا نکن».
|
||||
*
|
||||
* هر دو اندپوینت عمومیاند و سایت عمومی هم مصرفشان میکند، پس بدون توکن باید
|
||||
* `false` بدهند و هیچ بخش دیگری از پاسخ عوض نشود.
|
||||
*/
|
||||
class ProfileCanEditFlagTest extends ApiTestCase
|
||||
{
|
||||
private function newRepresentative(): User
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
$this->em->persist(new Representation($user, 'نمایندهٔ ' . uniqid()));
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function doctorCreatedBy(User $repUser): string
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/representation/doctor', $repUser, [
|
||||
'mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'name' => 'دکتر فلگ ' . uniqid(),
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
return $body['data']['uuid'];
|
||||
}
|
||||
|
||||
private function clinicCreatedBy(User $repUser): string
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/representation/clinic', $repUser, [
|
||||
'owner_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'name' => 'کلینیک فلگ ' . uniqid(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
return $body['data']['uuid'];
|
||||
}
|
||||
|
||||
/** GET بدون هیچ توکنی. */
|
||||
private function anonymousGet(string $uri): array
|
||||
{
|
||||
$this->client->request('GET', $uri);
|
||||
|
||||
return json_decode($this->client->getResponse()->getContent(), true) ?? [];
|
||||
}
|
||||
|
||||
/** پاسخ جزئیات دولایه است: کنترلر `success(['data' => …])` میدهد. */
|
||||
private function payload(array $body): array
|
||||
{
|
||||
return $body['data']['data'];
|
||||
}
|
||||
|
||||
private function canEdit(array $body): bool
|
||||
{
|
||||
self::assertArrayHasKey('can_edit', $this->payload($body), 'پاسخ باید همیشه can_edit داشته باشد');
|
||||
|
||||
return $this->payload($body)['can_edit'];
|
||||
}
|
||||
|
||||
// ── پزشک ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testDoctorFlagIsTrueForTheOwningRepresentativeOnly(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$stranger = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($owner);
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $owner)));
|
||||
self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $stranger)));
|
||||
}
|
||||
|
||||
public function testDoctorFlagIsFalseWithoutAToken(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($owner);
|
||||
|
||||
$body = $this->anonymousGet('/api/v1/doctor/' . $uuid);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
self::assertFalse($this->canEdit($body));
|
||||
// بقیهٔ پاسخ نباید عوض شده باشد — سایت عمومی همین را مصرف میکند.
|
||||
self::assertArrayHasKey('uuid', $this->payload($body));
|
||||
self::assertArrayHasKey('clinics', $this->payload($body));
|
||||
}
|
||||
|
||||
public function testDoctorFlagIsTrueForTheDoctorAndForAnAdmin(): void
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'پزشک فلگ');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
$uuid = $doctor->getUuid();
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $doctorUser)));
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $this->createUser(['ROLE_ADMIN']))));
|
||||
self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $this->createUser(['ROLE_USER']))));
|
||||
}
|
||||
|
||||
// ── کلینیک ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testClinicFlagIsTrueForTheOwningRepresentativeOnly(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$stranger = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($owner);
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $owner)));
|
||||
self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $stranger)));
|
||||
}
|
||||
|
||||
public function testClinicFlagIsFalseWithoutAToken(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($owner);
|
||||
|
||||
$body = $this->anonymousGet('/api/v1/clinic/' . $uuid);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
self::assertFalse($this->canEdit($body));
|
||||
self::assertArrayHasKey('uuid', $this->payload($body));
|
||||
}
|
||||
|
||||
public function testClinicFlagIsTrueForTheOwnerAndForAnAdmin(): void
|
||||
{
|
||||
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($ownerUser);
|
||||
$clinic->setName('کلینیک فلگ');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
$uuid = $clinic->getUuid();
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $ownerUser)));
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $this->createUser(['ROLE_ADMIN']))));
|
||||
self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $this->createUser(['ROLE_USER']))));
|
||||
}
|
||||
|
||||
// ── مرزی ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testFlagMatchesWhatThePatchActuallyAllows(): void
|
||||
{
|
||||
// اگر فلگ true بدهد ولی PATCH ۴۰۳ کند، پنل دکمهٔ مرده نشان میدهد.
|
||||
$owner = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($owner);
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $owner)));
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $owner, ['info' => 'x']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* آدرسها اندپوینت جدا دارند و whitelist ندارند — کل رکورد آدرس محتوایی است.
|
||||
*
|
||||
* حالت مرزیِ مهم: نمایندهای که خودش پزشک هم هست. پیش از این تغییر، createAddress
|
||||
* اول findByUser میزد و چنین کاربری بیصدا آدرس را روی پروفایل خودش میساخت.
|
||||
*/
|
||||
class RepresentationAddressEditTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* پاسخِ ساختِ آدرس دولایه است — کنترلر `success(['data' => …])` میدهد و
|
||||
* BaseController خودش یک لایهٔ `data` دیگر میگذارد.
|
||||
*/
|
||||
private function createdAddress(array $body): array
|
||||
{
|
||||
return $body['data']['data'];
|
||||
}
|
||||
|
||||
private function createdAddressId(array $body): int
|
||||
{
|
||||
return (int) $this->createdAddress($body)['id'];
|
||||
}
|
||||
|
||||
private function createdAddressUuid(array $body): string
|
||||
{
|
||||
return $this->createdAddress($body)['uuid'];
|
||||
}
|
||||
|
||||
private function newRepresentative(): User
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
$this->em->persist(new Representation($user, 'نمایندهٔ ' . uniqid()));
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function doctorCreatedBy(User $repUser): string
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/representation/doctor', $repUser, [
|
||||
'mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'name' => 'دکتر آدرس ' . uniqid(),
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
return $body['data']['uuid'];
|
||||
}
|
||||
|
||||
private function clinicCreatedBy(User $repUser): string
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/representation/clinic', $repUser, [
|
||||
'owner_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'name' => 'کلینیک آدرس ' . uniqid(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
return $body['data']['uuid'];
|
||||
}
|
||||
|
||||
// ── آدرس پزشک ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testRepresentativeCreatesUpdatesAndDeletesADoctorAddress(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $repUser, [
|
||||
'doctor_uuid' => $uuid,
|
||||
'name' => 'مطب مرکزی',
|
||||
'address' => 'یزد، خیابان آزمون',
|
||||
'telephone' => '03512222222',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
$addressId = $this->createdAddressId($created);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic-pro/doctor-address/' . $addressId, $repUser, [
|
||||
'address' => 'یزد، خیابان تازه',
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
self::assertSame(
|
||||
'یزد، خیابان تازه',
|
||||
$this->em->getRepository(DoctorAddress::class)->find($addressId)->getAddress(),
|
||||
);
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/clinic-pro/doctor-address/' . $addressId, $repUser);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
self::assertNull($this->em->getRepository(DoctorAddress::class)->find($addressId));
|
||||
}
|
||||
|
||||
public function testAnotherRepresentativeCannotTouchTheAddress(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$stranger = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($owner);
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $owner, [
|
||||
'doctor_uuid' => $uuid,
|
||||
'address' => 'اصلی',
|
||||
]);
|
||||
$addressId = $this->createdAddressId($created);
|
||||
|
||||
$this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $stranger, [
|
||||
'doctor_uuid' => $uuid,
|
||||
'address' => 'نفوذی',
|
||||
]);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic-pro/doctor-address/' . $addressId, $stranger, ['address' => 'نفوذی']);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/clinic-pro/doctor-address/' . $addressId, $stranger);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
self::assertSame(
|
||||
'اصلی',
|
||||
$this->em->getRepository(DoctorAddress::class)->find($addressId)->getAddress(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testDoctorUuidWinsOverTheSenderOwnProfile(): void
|
||||
{
|
||||
// نمایندهای که خودش پزشک هم هست: آدرس باید روی پزشکِ زیرمجموعه بنشیند،
|
||||
// نه روی پروفایل خودش.
|
||||
$repUser = $this->newRepresentative();
|
||||
$selfDoc = new Doctor($repUser, 'پزشکِ خودِ نماینده');
|
||||
$this->em->persist($selfDoc);
|
||||
$this->em->flush();
|
||||
$selfDocId = $selfDoc->getId();
|
||||
|
||||
$targetUuid = $this->doctorCreatedBy($repUser);
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $repUser, [
|
||||
'doctor_uuid' => $targetUuid,
|
||||
'address' => 'باید روی پزشک زیرمجموعه بنشیند',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$address = $this->em->getRepository(DoctorAddress::class)->find($this->createdAddressId($created));
|
||||
$target = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $targetUuid]);
|
||||
|
||||
self::assertSame($target->getId(), $address->getDoctor()->getId());
|
||||
self::assertNotSame($selfDocId, $address->getDoctor()->getId());
|
||||
}
|
||||
|
||||
public function testRepresentativeWithoutDoctorUuidGetsAValidationError(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $repUser, ['address' => 'بیهدف']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('doctor_uuid', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testPlainUserStillGetsForbidden(): void
|
||||
{
|
||||
$plain = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $plain, ['address' => 'x']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDoctorCanStillCreateTheirOwnAddressWithoutUuid(): void
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'پزشک خودگردان');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
$doctorId = $doctor->getId();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $doctorUser, ['address' => 'مطب خودم']);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$address = $this->em->getRepository(DoctorAddress::class)->find($this->createdAddressId($created));
|
||||
self::assertSame($doctorId, $address->getDoctor()->getId());
|
||||
}
|
||||
|
||||
public function testClinicAddressIsStillUnreachableThroughTheDoctorAddressRoute(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
$clinicUuid = $this->clinicCreatedBy($repUser);
|
||||
|
||||
$clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $clinicUuid]);
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setAddress('آدرس کلینیک');
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic-pro/doctor-address/' . $address->getId(), $repUser, ['address' => 'x']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── آدرس کلینیک ───────────────────────────────────────────────────────────
|
||||
|
||||
public function testRepresentativeCreatesUpdatesAndDeletesAClinicAddress(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/clinic/' . $uuid . '/address', $repUser, [
|
||||
'address' => 'یزد، بلوار آزمون',
|
||||
'telephone' => '03513333333',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
$addressUuid = $this->createdAddressUuid($created);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $repUser, [
|
||||
'address' => 'یزد، بلوار تازه',
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $repUser);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnotherRepresentativeCannotTouchTheClinicAddress(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$stranger = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($owner);
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/clinic/' . $uuid . '/address', $owner, ['address' => 'اصلی']);
|
||||
$addressUuid = $this->createdAddressUuid($created);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $stranger, ['address' => 'نفوذی']);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $stranger);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicOwnerIsUnaffected(): void
|
||||
{
|
||||
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($ownerUser);
|
||||
$clinic->setName('کلینیک خودگردان');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('POST', '/api/v1/clinic/' . $clinic->getUuid() . '/address', $ownerUser, ['address' => 'مال خودم']);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* نمایندهٔ ثبتکننده روی کلینیکِ زیرمجموعهاش ویرایش میکند.
|
||||
*
|
||||
* قرینهٔ RepresentationProfileEditTest برای کلینیک، با دو تفاوت: فیلد ممنوعِ
|
||||
* شاخص اینجا `doctors` است (عضویت، نه محتوا)، و مسیر مجوز از
|
||||
* ClinicDoctorPermissionChecker میگذرد که عمداً دستنخورده مانده.
|
||||
*/
|
||||
class RepresentationClinicEditTest extends ApiTestCase
|
||||
{
|
||||
private function newRepresentative(): User
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
$this->em->persist(new Representation($user, 'نمایندهٔ ' . uniqid()));
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** کلینیکی که همان نماینده ثبتش کرده؛ uuid برمیگردد. */
|
||||
private function clinicCreatedBy(User $repUser): string
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/representation/clinic', $repUser, [
|
||||
'owner_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'name' => 'کلینیک آزمون ' . uniqid(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), 'ساخت کلینیک توسط نماینده باید ۲۰۰ بدهد');
|
||||
|
||||
return $body['data']['uuid'];
|
||||
}
|
||||
|
||||
private function editLogCount(): int
|
||||
{
|
||||
return (int) static::getContainer()->get(Connection::class)
|
||||
->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'");
|
||||
}
|
||||
|
||||
private function reloadClinic(string $uuid): Clinic
|
||||
{
|
||||
$this->em->clear();
|
||||
|
||||
return $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
// ── مسیر موفق ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testOwningRepresentativeCanEditLogoAndDescription(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, [
|
||||
'clinic_logo' => 'https://example.test/logo.png',
|
||||
'info' => 'معرفی تازهٔ کلینیک',
|
||||
'telephone' => '03511111111',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($body['success']);
|
||||
|
||||
$clinic = $this->reloadClinic($uuid);
|
||||
self::assertSame('https://example.test/logo.png', $clinic->getClinicLogo());
|
||||
self::assertSame('معرفی تازهٔ کلینیک', $clinic->getInfo());
|
||||
self::assertSame($before + 1, $this->editLogCount());
|
||||
}
|
||||
|
||||
public function testTheWholePayloadTheEditFormSendsIsAccepted(): void
|
||||
{
|
||||
// فرم ویرایش کلینیک همیشه specialties و doctor_services و insurance را
|
||||
// میفرستد؛ اگر بیرون از whitelist باشند هر ذخیرهای ۴۰۳ میشود.
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, [
|
||||
'name' => 'کلینیک با نام تازه',
|
||||
'telephone' => '03514444444',
|
||||
'info' => 'توضیحات',
|
||||
'24_7' => true,
|
||||
'specialties' => [],
|
||||
'insurance' => [],
|
||||
'doctor_services' => [],
|
||||
'social_media' => [
|
||||
'instagram' => 'https://instagram.com/test',
|
||||
'telegram' => null,
|
||||
'aparat' => null,
|
||||
'youtube' => null,
|
||||
'linkedin' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('کلینیک با نام تازه', $this->reloadClinic($uuid)->getName());
|
||||
}
|
||||
|
||||
public function testSecretaryPreCheckDoesNotBlockARepresentative(): void
|
||||
{
|
||||
// denyUnlessGranted پیش از واکشی رکورد اجرا میشود؛ این تست تثبیت میکند که
|
||||
// نقشِ غیرمنشی از آن رد میشود و ۴۰۳ زودهنگام نمیگیرد.
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, ['info' => 'x']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── مسیر خطا ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAnotherRepresentativeIsForbidden(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$stranger = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($owner);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $stranger, ['info' => 'نباید ذخیره شود']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('ERR_AUTH_006', $body['errors'][0]['code']);
|
||||
self::assertNotSame('نباید ذخیره شود', $this->reloadClinic($uuid)->getInfo());
|
||||
}
|
||||
|
||||
public function testChangingClinicMembershipIsForbiddenForRepresentative(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, [
|
||||
'info' => 'این هم نباید ذخیره شود',
|
||||
'doctors' => [1],
|
||||
]);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('doctors', $body['errors'][0]['field']);
|
||||
self::assertNotSame('این هم نباید ذخیره شود', $this->reloadClinic($uuid)->getInfo());
|
||||
}
|
||||
|
||||
// ── مرزی ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testClinicWithoutARepresentationIsNotEditableByAnyRepresentative(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
|
||||
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$orphan = new Clinic($ownerUser);
|
||||
$orphan->setName('کلینیک بینماینده');
|
||||
$this->em->persist($orphan);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $orphan->getUuid(), $repUser, ['info' => 'x']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicOwnerIsUnaffectedByTheWhitelist(): void
|
||||
{
|
||||
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($ownerUser);
|
||||
$clinic->setName('کلینیک خودگردان');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
$uuid = $clinic->getUuid();
|
||||
$before = $this->editLogCount();
|
||||
|
||||
// `doctors` برای نماینده ممنوع است اما برای مالک نه؛ آرایهٔ خالی یعنی «پاک کن».
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $ownerUser, [
|
||||
'doctors' => [],
|
||||
'info' => 'مالک آزادانه ویرایش میکند',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('مالک آزادانه ویرایش میکند', $this->reloadClinic($uuid)->getInfo());
|
||||
self::assertSame($before, $this->editLogCount(), 'ویرایش مالک نباید لاگ نماینده بسازد');
|
||||
}
|
||||
|
||||
public function testAdminIsUnaffectedByTheWhitelist(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $admin, ['doctors' => [], 'info' => 'ادمین']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('ادمین', $this->reloadClinic($uuid)->getInfo());
|
||||
self::assertSame($before, $this->editLogCount(), 'ویرایش ادمین نباید لاگ نماینده بسازد');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Representation\Security\RepresentationEditLogger;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* لاگِ ویرایشِ نماینده — یک ردیف app_log با channel اختصاصی، و هیچ ردیفی برای
|
||||
* کاربری که نماینده نیست.
|
||||
*/
|
||||
class RepresentationEditLoggerTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* سرویس فقط توسط کنترلرها مصرف میشود، پس کانتینر inlineاش میکند و از تست
|
||||
* قابل get نیست. public کردنش صرفاً برای تست، پیکربندی production را آلوده
|
||||
* میکرد؛ اینجا با همان وابستگیهای واقعی ساخته میشود.
|
||||
*/
|
||||
private function logger(): RepresentationEditLogger
|
||||
{
|
||||
return new RepresentationEditLogger(
|
||||
static::getContainer()->get(Connection::class),
|
||||
$this->em->getRepository(Representation::class),
|
||||
new RequestStack(),
|
||||
);
|
||||
}
|
||||
|
||||
private function editLogCount(): int
|
||||
{
|
||||
return (int) static::getContainer()->get(Connection::class)
|
||||
->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'");
|
||||
}
|
||||
|
||||
private function newRepresentative(): User
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
$this->em->persist(new Representation($user, 'نمایندهٔ لاگ'));
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function testEditByRepresentativeWritesOneRow(): void
|
||||
{
|
||||
$user = $this->newRepresentative();
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$this->logger()->logEdit($user, 'doctor', 'uuid-under-test', ['info' => 'x', 'images' => []]);
|
||||
|
||||
self::assertSame($before + 1, $this->editLogCount());
|
||||
}
|
||||
|
||||
public function testTheRowNamesTheRepresentativeAndTheChangedFields(): void
|
||||
{
|
||||
$user = $this->newRepresentative();
|
||||
$uuid = 'uuid-' . uniqid();
|
||||
|
||||
$this->logger()->logEdit($user, 'clinic', $uuid, ['clinic_logo' => 'https://a/b.png', 'info' => 'y']);
|
||||
|
||||
$row = static::getContainer()->get(Connection::class)->fetchAssociative(
|
||||
"SELECT message, context, level FROM app_log WHERE channel = 'representation_edit' ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
|
||||
self::assertSame('info', $row['level']);
|
||||
self::assertStringContainsString($uuid, $row['message']);
|
||||
|
||||
$context = json_decode((string) $row['context'], true);
|
||||
self::assertSame('clinic', $context['entity_type']);
|
||||
self::assertSame(['clinic_logo', 'info'], $context['fields']);
|
||||
// فقط کلیدها ثبت میشوند، نه مقادیر.
|
||||
self::assertStringNotContainsString('https://a/b.png', (string) $row['context']);
|
||||
}
|
||||
|
||||
public function testUserWithoutARepresentationRowWritesNothing(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$this->logger()->logEdit($user, 'doctor', 'uuid-x', ['info' => 'x']);
|
||||
|
||||
self::assertSame($before, $this->editLogCount());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Representation\Security\RepresentationEditPolicy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* سیاست ویرایش نماینده — بدون دیتابیس و بدون kernel.
|
||||
*
|
||||
* تنها قاعدهای که این کلاس نگه میدارد: مالکیت از representation_id میآید،
|
||||
* نه از نقش. داشتن ROLE_REPRESENTATION بهتنهایی هیچ اجازهای نمیدهد.
|
||||
*/
|
||||
class RepresentationEditPolicyTest extends TestCase
|
||||
{
|
||||
/** Representation با id مشخص — id در entity خصوصی و بدون setter است. */
|
||||
private function repWithId(User $user, int $id): Representation
|
||||
{
|
||||
$rep = new Representation($user, 'نمایندهٔ آزمون');
|
||||
|
||||
$ref = new \ReflectionProperty(Representation::class, 'id');
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($rep, $id);
|
||||
|
||||
return $rep;
|
||||
}
|
||||
|
||||
private function policyReturning(?Representation $rep): RepresentationEditPolicy
|
||||
{
|
||||
$repo = $this->createStub(RepresentationRepository::class);
|
||||
$repo->method('findByUser')->willReturn($rep);
|
||||
|
||||
return new RepresentationEditPolicy($repo);
|
||||
}
|
||||
|
||||
private function doctorOwnedBy(?int $representationId): Doctor
|
||||
{
|
||||
$doctor = new Doctor(new User('09120000001'), 'پزشک آزمون');
|
||||
$doctor->setRepresentationId($representationId);
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function clinicOwnedBy(?int $representationId): Clinic
|
||||
{
|
||||
$clinic = new Clinic(new User('09120000002'));
|
||||
$clinic->setRepresentationId($representationId);
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
// ── مالکیت ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testRepresentationOwningTheDoctorIsAllowed(): void
|
||||
{
|
||||
$user = new User('09120000003');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning($this->repWithId($user, 7));
|
||||
|
||||
self::assertTrue($policy->ownsDoctor($user, $this->doctorOwnedBy(7)));
|
||||
}
|
||||
|
||||
public function testAnotherRepresentationIsRejected(): void
|
||||
{
|
||||
$user = new User('09120000004');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning($this->repWithId($user, 7));
|
||||
|
||||
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(8)));
|
||||
}
|
||||
|
||||
public function testDoctorWithoutRepresentationIsNeverOwned(): void
|
||||
{
|
||||
$user = new User('09120000005');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning($this->repWithId($user, 7));
|
||||
|
||||
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(null)));
|
||||
}
|
||||
|
||||
public function testUserWithoutTheRoleIsRejectedWithoutHittingTheRepository(): void
|
||||
{
|
||||
$user = new User('09120000006');
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
|
||||
$repo = $this->createMock(RepresentationRepository::class);
|
||||
$repo->expects(self::never())->method('findByUser');
|
||||
|
||||
$policy = new RepresentationEditPolicy($repo);
|
||||
|
||||
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(7)));
|
||||
}
|
||||
|
||||
public function testRoleWithoutRepresentationRowIsRejectedNotFatal(): void
|
||||
{
|
||||
$user = new User('09120000007');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(7)));
|
||||
}
|
||||
|
||||
public function testClinicOwnershipFollowsTheSameRule(): void
|
||||
{
|
||||
$user = new User('09120000008');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning($this->repWithId($user, 3));
|
||||
|
||||
self::assertTrue($policy->ownsClinic($user, $this->clinicOwnedBy(3)));
|
||||
self::assertFalse($policy->ownsClinic($user, $this->clinicOwnedBy(4)));
|
||||
}
|
||||
|
||||
// ── whitelist ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testForbiddenClinicFieldIsNamed(): void
|
||||
{
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertSame(
|
||||
'doctors',
|
||||
$policy->firstForbiddenField(['info' => 'x', 'doctors' => []], RepresentationEditPolicy::CLINIC_FIELDS),
|
||||
);
|
||||
}
|
||||
|
||||
public function testAllowedClinicPayloadPasses(): void
|
||||
{
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertNull(
|
||||
$policy->firstForbiddenField(
|
||||
['info' => 'x', 'clinic_logo' => 'https://a/b.png', '24_7' => true],
|
||||
RepresentationEditPolicy::CLINIC_FIELDS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function testForbiddenDoctorFieldsAreNamed(): void
|
||||
{
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertSame(
|
||||
'medical_system_code',
|
||||
$policy->firstForbiddenField(['medical_system_code' => '123'], RepresentationEditPolicy::DOCTOR_FIELDS),
|
||||
);
|
||||
self::assertSame(
|
||||
'active',
|
||||
$policy->firstForbiddenField(['info' => 'x', 'active' => true], RepresentationEditPolicy::DOCTOR_FIELDS),
|
||||
);
|
||||
}
|
||||
|
||||
public function testEmptyPayloadHasNoForbiddenField(): void
|
||||
{
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertNull($policy->firstForbiddenField([], RepresentationEditPolicy::DOCTOR_FIELDS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* نمایندهٔ ثبتکننده روی پزشکِ زیرمجموعهاش ویرایش میکند — و فقط فیلدهای محتوایی.
|
||||
*
|
||||
* دادهٔ تست از راه اندپوینت واقعیِ نماینده ساخته میشود تا representation_id
|
||||
* همانطور بنشیند که در تولید مینشیند.
|
||||
*/
|
||||
class RepresentationProfileEditTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Representation} */
|
||||
private function newRepresentative(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
$rep = new Representation($user, 'نمایندهٔ ' . uniqid());
|
||||
$this->em->persist($rep);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $rep];
|
||||
}
|
||||
|
||||
/** پزشکی که همان نماینده ثبتش کرده؛ uuid برمیگردد. */
|
||||
private function doctorCreatedBy(User $repUser): string
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/representation/doctor', $repUser, [
|
||||
'mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'name' => 'دکتر آزمون ' . uniqid(),
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), 'ساخت پزشک توسط نماینده باید ۲۰۱ بدهد');
|
||||
|
||||
return $body['data']['uuid'];
|
||||
}
|
||||
|
||||
private function editLogCount(): int
|
||||
{
|
||||
return (int) static::getContainer()->get(Connection::class)
|
||||
->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'");
|
||||
}
|
||||
|
||||
private function reloadDoctor(string $uuid): Doctor
|
||||
{
|
||||
$this->em->clear();
|
||||
|
||||
return $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
// ── مسیر موفق ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testOwningRepresentativeCanEditContentFields(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, [
|
||||
'info' => 'متن معرفی تازه',
|
||||
'degree' => 'متخصص پوست',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($body['success']);
|
||||
self::assertSame('متن معرفی تازه', $this->reloadDoctor($uuid)->getInfo());
|
||||
}
|
||||
|
||||
public function testTheWholePayloadTheEditFormSendsIsAccepted(): void
|
||||
{
|
||||
// همان کلیدهایی که DoctorDetailPage در حالت نماینده میفرستد؛ اگر یکی
|
||||
// بیرون از whitelist بماند، هر ذخیرهای ۴۰۳ میشود.
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, [
|
||||
'title' => 'دکتر نام تازه',
|
||||
'gender' => 'man',
|
||||
'degree' => 'specialist',
|
||||
'mobile_number' => '09121111111',
|
||||
'info' => 'توضیحات',
|
||||
'activity_time' => 1600000000,
|
||||
'specialties' => [],
|
||||
'doctor_services' => [],
|
||||
'social_media' => [
|
||||
'instagram' => 'https://instagram.com/test',
|
||||
'telegram' => null,
|
||||
'aparat' => null,
|
||||
'youtube' => null,
|
||||
'linkedin' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('نام تازه', $this->reloadDoctor($uuid)->getName());
|
||||
}
|
||||
|
||||
public function testASuccessfulEditWritesExactlyOneLogRow(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, ['info' => 'x']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($before + 1, $this->editLogCount());
|
||||
}
|
||||
|
||||
// ── مسیر خطا ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAnotherRepresentativeIsForbidden(): void
|
||||
{
|
||||
[$ownerUser] = $this->newRepresentative();
|
||||
[$strangerUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($ownerUser);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $strangerUser, ['info' => 'نباید ذخیره شود']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('ERR_AUTH_006', $body['errors'][0]['code']);
|
||||
self::assertNotSame('نباید ذخیره شود', $this->reloadDoctor($uuid)->getInfo());
|
||||
}
|
||||
|
||||
public function testForbiddenFieldIsRejectedAndNothingIsSaved(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
$before = $this->reloadDoctor($uuid)->getMedicalSystemCode();
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, [
|
||||
'info' => 'این هم نباید ذخیره شود',
|
||||
'medical_system_code' => '999999',
|
||||
]);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('medical_system_code', $body['errors'][0]['field']);
|
||||
|
||||
$doctor = $this->reloadDoctor($uuid);
|
||||
self::assertSame($before, $doctor->getMedicalSystemCode());
|
||||
self::assertNotSame('این هم نباید ذخیره شود', $doctor->getInfo());
|
||||
}
|
||||
|
||||
public function testTogglingActiveThroughPatchIsForbiddenForRepresentative(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, ['active' => true]);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('active', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
// ── مرزی ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testDoctorWithoutARepresentationIsNotEditableByAnyRepresentative(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
|
||||
$orphanUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$orphan = new Doctor($orphanUser, 'پزشک بینماینده');
|
||||
$this->em->persist($orphan);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $orphan->getUuid(), $repUser, ['info' => 'x']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testRoleWithoutARepresentationRowIsForbiddenNotFatal(): void
|
||||
{
|
||||
[$ownerUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($ownerUser);
|
||||
|
||||
$rowless = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $rowless, ['info' => 'x']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDoctorEditingOwnProfileIsUnaffectedByTheWhitelist(): void
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'پزشک خودگردان');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
$uuid = $doctor->getUuid();
|
||||
|
||||
$before = $this->editLogCount();
|
||||
// یکتا per-run: doctors.source_code ایندکس یکتا دارد و db_test هرگز پاک نمیشود.
|
||||
$code = 'mc' . substr(uniqid(), -8);
|
||||
|
||||
// کد نظام پزشکی برای نماینده ممنوع است اما برای خودِ پزشک نه.
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $doctorUser, ['medical_system_code' => $code]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($code, $this->reloadDoctor($uuid)->getMedicalSystemCode());
|
||||
self::assertSame($before, $this->editLogCount(), 'ویرایش خودِ پزشک نباید لاگ نماینده بسازد');
|
||||
}
|
||||
|
||||
public function testAdminIsUnaffectedByTheWhitelist(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$before = $this->editLogCount();
|
||||
$code = 'ac' . substr(uniqid(), -8);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $admin, ['medical_system_code' => $code]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($code, $this->reloadDoctor($uuid)->getMedicalSystemCode());
|
||||
self::assertSame($before, $this->editLogCount(), 'ویرایش ادمین نباید لاگ نماینده بسازد');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user