Files
clinicpro/.claude/prompt/representation-edit-owned-doctor-clinic.md
T
hamedandClaude Opus 5 fb1cb20c11 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>
2026-08-08 15:50:17 +03:30

24 KiB
Raw Blame History

مجوز ویرایش پروفایل پزشک و کلینیک برای نمایندهٔ ثبت‌کننده

پروژه

clinicpro — بک‌اند Symfony و پنل ادمین React.

تک‌ریپو است. nobat724_front فقط سه فراخوانی داشبورد نماینده دارد (services/response.js خطوط ۲۰۲ تا ۲۱۱) و به هیچ‌کدام از اندپوینت‌های این تسک دست نمی‌زند.

زمینه

نماینده امروز می‌تواند پزشک و کلینیک بسازد. هنگام ساخت، representation_id روی رکورد ست می‌شود:

// 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 سند اندپوینت‌ها

وضعیت فعلی

دروازهٔ پزشک — فقط خود پزشک یا ادمین

// 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 موجود رد می‌شود

// 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() نقش نماینده را نمی‌شناسد:

// 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);
}

آدرس‌ها — دروازهٔ جدا

// 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);
}
// 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);
}
// 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 — عمداً قفل است

// assets/admin/pages/DoctorDetailPage.tsx:1124
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
// assets/admin/pages/ClinicDetailPage.tsx:363
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly  = primaryRole === 'representation';

GET جزئیات — هنوز کاربر جاری را نمی‌گیرد

// src/Doctor/Controller/DoctorController.php:150
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
// 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

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 === nullfalse؛ کاربر بدون ROLE_REPRESENTATIONfalse؛ 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) می‌گیرد.

$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.

$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 اجرا می‌شود. مسیر مالک و ادمین دست‌نخورده می‌ماند.

نحوه تست: تست فانکشنال با ApiTestCasetests/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 کنارش بگذار:

$isRepOwner = $this->editPolicy->ownsClinic($user, $clinic);

if (!$isRepOwner && !$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
    return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}

حواست به خط ۲۳۰ باشد:

$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 را طوری عوض کن که کاربر جاری را اختیاری بگیرند:

public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse

و در آرایهٔ خروجی:

'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 این خط را بردار:

const isReadOnly = primaryRole === 'representation';

و جایش از پاسخ سرور بخوان:

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.mdPATCH /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()) تا اجرای دوم هم سبز بماند.