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
+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 }} />