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