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:
@@ -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 }} />
|
||||
|
||||
Reference in New Issue
Block a user