- Implement DoctorPermissionsModal for managing doctor permissions in clinics. - Create usePermissions hook to handle user permissions context. - Add migration for clinic_doctor_permissions table with default permissions. - Develop ClinicDoctorPermissionController for handling permissions API. - Create ClinicDoctorPermission entity to manage permissions data. - Implement ClinicDoctorPermissionRepository for database interactions. - Add ClinicDoctorPermissionChecker for permission validation logic. - Write tests for clinic doctor permissions functionality.
16 KiB
یکسانسازی تنظیمات نوبتدهی + تب پزشکان در پنل کلینیک
زمینه
تنظیمات نوبتدهی امروز فقط برای پزشکِ مستقل در دسترس است. مالک کلینیک نمیتواند نوبتدهی پزشکان کلینیکش را تنظیم کند: مسیر /admin/appointment-settings با RoleRoute roles={['doctor']} بسته است، هر ۱۴ endpoint در AppointmentSettingsController شرط یکسانِ «پزشک == کاربر جاری یا ادمین» دارند، و هیچ ورودی منویی برای نقش clinic وجود ندارد.
خبر خوب: زیرساخت تقریباً کامل است. هر ۱۴ endpoint از قبل uuid پزشک را از path یا body میگیرند، و کامپوننت ScheduleSection هم doctorUuid را بهصورت prop میگیرد. یعنی برای «مالک کلینیک تنظیمات پزشک X را مدیریت کند» فقط سه چیز مانع است: شرط هویت در بکاند، گارد route، و نبود ورودی منو.
پیشنیاز:
clinic-doctor-permissions.md(Entity و چکر مجوز از آنجا میآید). اول آن را اجرا کن.
مشکل / هدف
۱. پنل شخصی پزشک باید دقیقاً مثل پزشک مستقل کار کند — هیچ تفاوتی در ساختار و امکانات. ۲. پنل کلینیک در «تنظیمات → نوبتدهی» باید برای هر پزشک یک تب داشته باشد و با انتخاب تب، تنظیمات همان پزشک را نشان دهد. ۳. یک پیادهسازی واحد — نه دو نسخه موازی. هر دو حالت باید همان کامپوننت را رندر کنند. ۴. تغییرات هر پزشک فقط روی خودش اثر بگذارد.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Appointment/Controller/AppointmentSettingsController.php |
هر ۱۴ endpoint تنظیمات نوبتدهی |
src/Appointment/Entity/WeeklySchedule.php |
OneToOne Doctor، unique روی doctor_id |
src/Appointment/Entity/DateOverride.php |
ManyToOne Doctor، unique روی (doctor_id, date) |
src/Appointment/Entity/Holiday.php |
ManyToOne Doctor |
assets/admin/pages/DoctorDetailPage.tsx:2058 |
ScheduleSection — پیادهسازی واقعی، داخل یک فایل صفحه |
assets/admin/pages/DoctorDetailPage.tsx:1252, 1772, 1944 |
WeeklyScheduleTab / DateOverridesTab / HolidaysTab |
assets/admin/pages/AppointmentSettingsPage.tsx |
صفحه پزشک مستقل (۳۶ خط، فقط پوسته) |
assets/admin/components/FreeVisitPrice.tsx |
قیمت ویزیت — بدون پارامتر پزشک، فقط JWT-scoped |
assets/admin/App.tsx:232 |
route appointment-settings با roles={['doctor']} blockClinicScope |
assets/admin/components/layout/SettingsLayout.tsx:22-35 |
SETTINGS_MENU (منوی موبایل) |
assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx:22 |
منوی دسکتاپ تنظیمات — تعریف موازی و جدا |
docs/api/appointment-settings.md |
مستند API |
وضعیت فعلی
شرط هویت، ۱۴ بار کپی شده — src/Appointment/Controller/AppointmentSettingsController.php:75-77 و مشابهش در :123-125، :199-201، :406-408:
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
نسخههای فرزند: $schedule->getDoctor()->getUser()->getId() !== $user->getId()، و همین برای $override و $holiday.
ROLE_CLINIC در کل این کنترلر یک بار هم استفاده نشده. ClinicRepository تزریق شده (:36) ولی فقط در availableLocations (:410) برای لیست آدرسها به کار میرود، نه برای مجوز.
صفحه پزشک مستقل، uuid را از authStore میگیرد — assets/admin/pages/AppointmentSettingsPage.tsx:12-14, 31:
const doctorUuid = useAuthStore((s) => s.doctorUuid);
const dbUuid = useAuthStore((s) => s.dbUuid);
const uuid = doctorUuid ?? dbUuid ?? undefined;
...
<ScheduleSection doctorUuid={uuid} />
کامپوننت اصلی از قبل پارامتری است — DoctorDetailPage.tsx:2062-2064 و :1263, 1309-1310:
queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`)
...
? api.patch(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
: api.post('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta });
گارد route پزشکِ در scope کلینیک را بیرون میاندازد — assets/admin/App.tsx:117-123:
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
return <Navigate to="/admin/dashboard" replace />;
}
ورودی منو فقط برای پزشک — PurchaseSubscriptionSidebar.tsx:22 و SettingsLayout.tsx (هر دو باید ویرایش شوند):
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] },
وظایف
۱. بکاند — یک helper واحد بهجای ۱۴ شرط تکراری
در AppointmentSettingsController یک متد خصوصی اضافه کن و هر ۱۴ شرط را با آن جایگزین کن:
private function assertDoctorAccess(Doctor $doctor, User $user): void
{
if ($user->hasRole('ROLE_ADMIN')) {
return;
}
if ($doctor->getUser()->getId() === $user->getId()) {
return;
}
// مالک کلینیکی که این پزشک عضو آن است
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic !== null && $clinic->hasDoctor($doctor)) {
return;
}
throw new AppException(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
نکات:
Clinic::hasDoctor()از قبل درsrc/Clinic/Entity/Clinic.php:156وجود دارد.- برای endpointهای فرزند (
{uuid}= uuid برنامه/override/holiday) همان helper را با$schedule->getDoctor()صدا بزن. - اگر پرامپت مجوزها اجرا شده، برای پزشکِ عضو کلینیک هم مسیر بده: اگر
$userخودش پزشکِ عضو همان کلینیک است،ClinicDoctorPermissionChecker::can($user, $clinic, 'appointment_settings', 'update')را چک کن. برای متدهای GET با'view'. - منطق اعتبارسنجی موجود دست نخورد:
assertModeImmutable(:48-54)،serviceModeHasNoBookable(:56-60)،validateSessionsHaveLocation(:432-442).
۲. استخراج ScheduleSection به فایل مستقل
امروز ScheduleSection داخل assets/admin/pages/DoctorDetailPage.tsx (خط ۲۰۵۸) تعریف و از آنجا export میشود. برای اینکه «یک ساختار واحد» واقعاً یک ماژول باشد و صفحهای از صفحه دیگر import نکند:
assets/admin/components/schedule/ScheduleSection.tsxبساز وScheduleSection+WeeklyScheduleTab(:1252) +DateOverridesTab(:1772) +DateOverrideModal(:1632) +HolidaysTab(:1944) +HolidayModal(:1871) و helperهای مربوطه (BookingMeta:101-114،calcSlotCount:338،SessionEditor:1118،SlotEditor:1077) را به آن منتقل کن.DoctorDetailPage.tsxوAppointmentSettingsPage.tsxهر دو از همان فایل import کنند.- هیچ تغییری در منطق نده — این مرحله صرفاً جابهجایی است. بعد از انتقال،
tscوyarn devباید بدون خطا رد شوند و رفتار صفحه پزشک مستقل عیناً همان باشد.
۳. صفحه تنظیمات نوبتدهی کلینیک با تب پزشکان
assets/admin/pages/ClinicAppointmentSettingsPage.tsx:
// لیست پزشکان کلینیک → تبها → همان ScheduleSection با doctorUuid انتخابشده
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
enabled: !!clinicUuid,
});
const doctorList = (doctorsQ.data?.data as any)?.data ?? doctorsQ.data?.data ?? [];
const [activeUuid, setActiveUuid] = useState<string | null>(null);
const selected = activeUuid ?? doctorList[0]?.uuid ?? null;
<SettingsLayout active="appointment">
<div className="seg"> {/* همان الگوی تب در ClinicDoctorsManager */}
{doctorList.map(d => (
<button key={d.uuid} className={selected === d.uuid ? 'active' : ''} onClick={() => setActiveUuid(d.uuid)}>
{d.name}
</button>
))}
</div>
{selected && <ScheduleSection key={selected} doctorUuid={selected} />}
</SettingsLayout>
نکات حیاتی:
key={selected}رویScheduleSectionالزامی است — بدون آن، state داخلی تب (برنامه هفتگی در حال ویرایش) بین پزشکها نشت میکند و ممکن است تنظیمات پزشک A روی B ذخیره شود. این دقیقاً همان چیزی است که خواسته «تغییرات هر پزشک فقط روی خودش» را نقض میکند.clinicUuidرا مثلClinicDoctorsPage.tsxاز context نوعclinicبگیر، نه مستقیم ازdbUuid(کاربری که هم پزشک است هم مالک کلینیک،dbUuidاش ممکن است uuid پزشک باشد و همه فراخوانیها ۴۰۴ شوند).- حالت خالی: کلینیک بدون پزشک → پیام «هیچ پزشکی به این کلینیک متصل نیست» + لینک به
/admin/settings/clinic-doctors. - اگر تعداد پزشکان زیاد شد، تبها باید افقی اسکرول شوند نه شکسته.
۴. Route و منو
assets/admin/App.tsx:
<Route path="settings/appointment-settings"
element={<RoleRoute roles={['clinic']}><ClinicAppointmentSettingsPage /></RoleRoute>} />
مسیر موجود appointment-settings (:232، roles={['doctor']} blockClinicScope) دستنخورده بماند — آن پنل شخصی پزشک است و باید دقیقاً مثل امروز کار کند.
هر دو منو باید ورودی بگیرند (تعریفشان موازی و جداست):
SettingsLayout.tsx:22-35→SETTINGS_MENU: یک آیتم باroles: ['clinic']و مقصد/admin/settings/appointment-settings. آیتم فعلیroles: ['doctor']دستنخورده بماند.PurchaseSubscriptionSidebar.tsx:22→ همان.
هر دو آیتم key: 'appointment' داشته باشند تا active="appointment" در SettingsLayout برای هر دو کار کند.
۵. تکلیف FreeVisitPrice
assets/admin/components/FreeVisitPrice.tsx روی /api/v1/insurance-pricing کار میکند و هیچ پارامتر پزشکی نمیگیرد — فقط از JWT scope میگیرد. اگر آن را داخل تب کلینیک رندر کنی، مالک کلینیک قیمت ویزیتِ خودش را ویرایش میکند نه پزشک انتخابشده. یکی از دو کار را بکن و در گزارش صریح بگو کدام:
- الف) به endpointهای
/api/v1/insurance-pricingپارامتر اختیاریdoctor_uuidاضافه کن (با همانassertDoctorAccess) وFreeVisitPriceرا prop-محور کن. سازگاری عقبرو حفظ شود: بدونdoctor_uuidرفتار امروز. - ب) فعلاً
FreeVisitPriceرا از تب کلینیک حذف کن و در همانجا یادداشت بگذار.
گزینه (الف) ارجح است چون خواسته «هیچ تفاوتی بین دو حالت نباشد» است، ولی اگر انتخاب شد باید مستند docs/api/insurance.md هم بهروز شود.
۶. تست
tests/Appointment/ClinicOwnerScheduleAccessTest.php:
- مالک کلینیک برنامه هفتگی پزشکِ عضو را میخواند و PATCH میکند → ۲۰۰
- مالک کلینیک روی پزشکی که عضو کلینیکش نیست → ۴۰۳
- پزشک روی برنامه خودش → ۲۰۰ (رگرسیون: رفتار قبلی نشکند)
- پزشک روی برنامه پزشک دیگر → ۴۰۳
ROLE_ADMINروی هر پزشکی → ۲۰۰- ذخیره برنامه پزشک A،
WeeklyScheduleپزشک B دستنخورده میماند (شرط «فقط روی همان پزشک اثر بگذارد») - همین ماتریس برای
date-overrideوholidays
docs/api/appointment-settings.md را بهروز کن: قاعده جدید دسترسی (مالک/عضو کلینیک) در هر ۱۴ endpoint، و کد خطای ۴۰۳.
نکات مهم
WeeklyScheduleرویdoctor_idقیدuniqueدارد (Entity:12) وOneToOneاست — یعنی هر پزشک دقیقاً یک برنامه دارد و منطق upsert است. اگر تبهاdoctorUuidرا درست پاس ندهند، PATCH روی برنامه پزشک اشتباه مینشیند و دادهی واقعی از بین میرود. این پرخطرترین بخش این تسک است.DateOverrideقیدunique(doctor_id, date)دارد؛ در حالت تب، تداخل تاریخ بین پزشکان معنا ندارد ولی خطای unique را باید به پیام فارسی معنادار تبدیل کنی نه ۵۰۰.- در booking mode سرویسی،
countBookableByEntity('doctor', $doctor->getId())(کنترلر:59) hard-code روی'doctor'است؛ کاتالوگ سرویس خود کلینیک این شرط را برآورده نمیکند. اگر پزشکِ عضو کلینیک سرویس شخصی ندارد، حالت سرویسی برایش قابل فعالسازی نیست — این را در UI با پیام فارسی روشن کن، نه با خطای خام. booking_modeبعد از اولین ذخیره قفل میشود (assertModeImmutable:48-54وmodeLockedدرWeeklyScheduleTab:1282) — این رفتار در تب کلینیک هم باید دقیقاً همان باشد.- هر session فعال باید
location_idداشته باشد (validateSessionsHaveLocation:432-442)؛ آدرسهای در دسترس ازGET /api/v1/appointment-settings/available-locations/{doctorUuid}میآید که خودش ازClinicRepositoryتغذیه میشود — برای پزشکِ عضو کلینیک، آدرسهای کلینیک باید در لیست باشند. - همه controllerها از
BaseController؛ پاسخ فقط با$this->success()/$this->paginated()/$this->error(). - تاریخها Unix timestamp صحیح؛ نمایش شمسی با
formatDate(). - Form: React Hook Form + Zod؛ server state: TanStack Query v5؛ برای هر select از
SearchableSelectاستفاده کن نه<select>بومی. - CSS: از کلاسهای موجود (
seg،card،btn primary sm،field) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL. - بعد از انتقال کامپوننتها حتماً
ddev exec npx tsc --noEmitوddev exec yarn devرا اجرا کن — این refactor حجم زیادی import جابهجا میکند.