feat(representation): let registering reps edit their doctors and clinics

A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.

Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.

ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.

Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.

Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.

Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.

Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.

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