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 }} />
|
||||
|
||||
@@ -66,6 +66,8 @@ interface DoctorDetail {
|
||||
city: { id: string; name: string }[];
|
||||
clinics: { id: string; uuid: string; name: string; address: string | null; telephone: string | null }[];
|
||||
representation: { id: number; uuid: string; full_name: string | null } | null;
|
||||
/** آیا کاربر جاری اجازهٔ ویرایش دارد؟ سرور تصمیم میگیرد، نه این صفحه. */
|
||||
can_edit?: boolean;
|
||||
}
|
||||
|
||||
interface SpecialtyOpt { id: number; uuid: string; name: string; parent_id: number | null; }
|
||||
@@ -1121,8 +1123,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
const context = useAuthStore(s => s.context);
|
||||
const availableContexts = useAuthStore(s => s.availableContexts);
|
||||
const uuid = isOwnProfile ? (doctorUuid ?? dbUuid ?? undefined) : paramUuid;
|
||||
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
const isRepresentative = primaryRole === 'representation';
|
||||
|
||||
// صفحهٔ پزشک در پنل کلینیک، تنظیمات نوبتدهیِ همان کلینیک را ویرایش میکند — نه
|
||||
// برنامهٔ مطب شخصی پزشک، که فقط خودش به آن دسترسی دارد.
|
||||
@@ -1174,6 +1175,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
return (raw as any)?.data ?? raw;
|
||||
}, [data]);
|
||||
|
||||
// نماینده فقط پزشکی را ویرایش میکند که خودش ثبت کرده. تصمیم با سرور است —
|
||||
// `can_edit` را همان سیاستی میسازد که دروازهٔ PATCH را نگه میدارد، پس این صفحه
|
||||
// قاعده را بازنویسی نمیکند.
|
||||
const isReadOnly = isRepresentative && !doctor?.can_edit;
|
||||
|
||||
const specialties: SpecialtyOpt[] = useMemo(
|
||||
() => specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? [], [specialtiesQ.data]);
|
||||
const services: ServiceOpt[] = useMemo(
|
||||
@@ -1228,7 +1234,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
title: body.name,
|
||||
gender: editGender || undefined,
|
||||
degree: body.degree || undefined,
|
||||
medical_system_code: body.medical_system_code || undefined,
|
||||
// کد نظام پزشکی بیرون از whitelistِ نماینده است؛ فرستادنش کل درخواست را ۴۰۳ میکند.
|
||||
...(isRepresentative ? {} : { medical_system_code: body.medical_system_code || undefined }),
|
||||
mobile_number: body.mobile_number || undefined,
|
||||
info: body.info || undefined,
|
||||
...(editActivityDate
|
||||
@@ -1466,8 +1473,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Toggle active / Delete — فقط ادمین */}
|
||||
{!isOwnProfile && primaryRole !== 'clinic' && !isReadOnly && (
|
||||
{/* Toggle active / Delete — فقط ادمین. نماینده حتی روی پزشکِ خودش هم
|
||||
اینجا را نمیبیند: `active` بیرون از whitelist است و PATCH ردش میکند.
|
||||
فعال/غیرفعال کردن از اندپوینت اختصاصیِ نماینده انجام میشود. */}
|
||||
{!isOwnProfile && primaryRole !== 'clinic' && !isRepresentative && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setToggleConfirm(true)}
|
||||
@@ -1748,9 +1757,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
/>
|
||||
)} />
|
||||
</EditField>
|
||||
<EditField label="کد نظام پزشکی">
|
||||
<input type="text" dir="ltr" className="cp-input" placeholder="123456" {...register('medical_system_code')} />
|
||||
</EditField>
|
||||
{!isRepresentative && (
|
||||
<EditField label="کد نظام پزشکی">
|
||||
<input type="text" dir="ltr" className="cp-input" placeholder="123456" {...register('medical_system_code')} />
|
||||
</EditField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface ClinicDetail {
|
||||
map: { latitude: string | null; longitude: string | null };
|
||||
"24_7": boolean;
|
||||
field_working_days: string | null;
|
||||
/** آیا کاربر جاری اجازهٔ ویرایش دارد؟ سرور تصمیم میگیرد، نه کلاینت. */
|
||||
can_edit?: boolean;
|
||||
}
|
||||
|
||||
export type AppointmentStatus =
|
||||
|
||||
Reference in New Issue
Block a user