feat(appointment-settings): let clinics manage each member doctor's booking

The API and React components were already parameterized by doctor uuid, but 14
copy-pasted identity checks limited every endpoint to "the doctor themselves or
an admin", so a clinic owner could not touch a member doctor's booking setup.

- Replaces those 14 checks with one denyDoctorAccess() that also admits the
  owner of a clinic the doctor belongs to, and a member doctor holding the
  clinic's appointment_settings permission (view for GET, update for writes).
  A doctor's own settings short-circuit before any permission lookup.
- Moves ScheduleSection and its tabs out of DoctorDetailPage into
  components/schedule/ScheduleSection.tsx so the doctor panel and the new
  clinic page render the same module instead of one page importing another.
  Pure relocation — no logic changed.
- Adds ClinicAppointmentSettingsPage: one tab per clinic doctor, each rendering
  that same section. The tab wrapper is keyed by doctor uuid so in-progress
  schedule edits cannot leak onto the wrong doctor.
- insurance-pricing accepts an optional doctor_uuid (query on GET, body on PUT)
  under the same access rule, so the visit-price card works inside the clinic
  tabs. Fixes saveInsurancePricing calling getInsurancePricing with the wrong
  argument by extracting the shared pricingPayload().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 10:02:27 +03:30
co-authored by Claude Opus 4.8
parent e4ddd38f0c
commit c103c393f3
13 changed files with 1773 additions and 1364 deletions
+2
View File
@@ -62,6 +62,7 @@ import SettingsMenuPage from './pages/SettingsMenuPage';
import AccountSettingsPage from './pages/AccountSettingsPage';
import TagsSettingsPage from './pages/TagsSettingsPage';
import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
import PatientRecordFormPage from './pages/PatientRecordFormPage';
@@ -199,6 +200,7 @@ export default function App() {
{/* پزشکان کلینیک — تب تنظیماتِ مالک کلینیک */}
<Route path="settings/clinic-doctors" element={<RoleRoute roles={['clinic']} blockClinicScope><ClinicDoctorsPage /></RoleRoute>} />
<Route path="settings/appointment-settings" element={<RoleRoute roles={['clinic']}><ClinicAppointmentSettingsPage /></RoleRoute>} />
{/* مسیر قدیمی «مدیریت مطب» → ریدایرکت به تب جدید */}
<Route path="my-clinic" element={<Navigate to="/admin/settings/clinic-doctors" replace />} />
+8 -4
View File
@@ -6,15 +6,18 @@ import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
export default function FreeVisitPrice() {
/** بدون doctorUuid روی موجودیت کاربر جاری کار می‌کند؛ با آن، قیمت همان پزشک. */
export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string }) {
const qc = useQueryClient();
const [value, setValue] = useState('');
const [required, setRequired] = useState(false);
const [error, setError] = useState('');
const { data } = useQuery<{ data: Pricing }>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
queryKey: ['insurance-pricing', doctorUuid ?? 'self'],
queryFn: () => api.get(doctorUuid
? `/api/v1/insurance-pricing?doctor_uuid=${doctorUuid}`
: '/api/v1/insurance-pricing'),
});
const pricing = (data as any)?.data as Pricing | undefined;
@@ -29,10 +32,11 @@ export default function FreeVisitPrice() {
mutationFn: () => api.put('/api/v1/insurance-pricing', {
free_visit_price_rials: tomanToRial(Number(value) || 0),
require_visit_price: required,
...(doctorUuid ? { doctor_uuid: doctorUuid } : {}),
}),
onSuccess: () => {
toast.success('قیمت ویزیت ذخیره شد');
qc.invalidateQueries({ queryKey: ['insurance-pricing'] });
qc.invalidateQueries({ queryKey: ['insurance-pricing', doctorUuid ?? 'self'] });
},
onError: (e: Error) => toast.error(e.message),
});
@@ -20,6 +20,7 @@ const NAV_ITEMS: NavItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription' },
{ key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial' },
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/settings/appointment-settings', roles: ['clinic'] },
{ key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing' },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', to: '/admin/discounts', roles: ['doctor', 'clinic'] },
{ key: 'tags', label: 'تگ ها', to: '/admin/tags-settings' },
@@ -23,6 +23,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' },
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'] },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'] },
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial' },
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout';
import FreeVisitPrice from '../components/FreeVisitPrice';
import { ScheduleSection } from './DoctorDetailPage';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
/**
* مدیریت نوبت دهی — the doctor's appointment settings: visit price and the full
@@ -0,0 +1,107 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { UserGroupIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import FreeVisitPrice from '../components/FreeVisitPrice';
import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
/**
* تنظیمات نوبت‌دهی همه پزشکان کلینیک — یک تب به ازای هر پزشک.
*
* هر تب دقیقاً همان ScheduleSection پنل پزشک مستقل را رندر می‌کند؛ تنها تفاوت،
* امکان جابه‌جایی بین پزشکان است.
*/
function ClinicAppointmentSettingsContent() {
const { dbUuid, context, availableContexts } = useAuthStore();
const [activeUuid, setActiveUuid] = useState<string | null>(null);
// کاربری که هم پزشک است هم مالک کلینیک، dbUuid‌اش ممکن است uuid پزشک باشد.
const clinicUuid = useMemo(() => {
if (context?.type === 'clinic') return dbUuid;
return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null;
}, [context, dbUuid, availableContexts]);
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
enabled: !!clinicUuid,
});
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQ.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
const selected = activeUuid ?? doctorList[0]?.uuid ?? null;
if (!clinicUuid) {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>
{dbUuid ? 'کلینیکی برای این حساب کاربری یافت نشد' : 'در حال بارگذاری اطلاعات کلینیک...'}
</p>
</div>
);
}
return (
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
<div className="card-title-row">
<div>
<h1 className="section-title">مدیریت نوبت دهی</h1>
<div className="muted">تنظیمات نوبتدهی پزشکان کلینیک</div>
</div>
</div>
{doctorsQ.isLoading ? (
<div className="card card-pad"><p className="muted">در حال بارگذاری پزشکان...</p></div>
) : doctorList.length === 0 ? (
<div className="card card-pad">
<div className="empty" style={{ padding: '20px 0' }}>
<UserGroupIcon style={{ width: 30, height: 30 }} />
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
<Link className="btn primary sm" to="/admin/settings/clinic-doctors">مدیریت پزشکان کلینیک</Link>
</div>
</div>
) : (
<>
<div className="card card-pad" style={{ paddingBottom: 12 }}>
<div className="seg" style={{ overflowX: 'auto', flexWrap: 'nowrap' }}>
{doctorList.map(doc => (
<button
key={doc.uuid}
className={selected === doc.uuid ? 'active' : ''}
style={{ whiteSpace: 'nowrap' }}
onClick={() => setActiveUuid(doc.uuid)}
>
{doc.name}
</button>
))}
</div>
</div>
{/* key اجباری است: بدون آن state ویرایشِ برنامه بین پزشکان نشت می‌کند */}
{selected && (
<div key={selected}>
<FreeVisitPrice doctorUuid={selected} />
<ScheduleSection doctorUuid={selected} />
</div>
)}
</>
)}
</div>
);
}
export default function ClinicAppointmentSettingsPage() {
return (
<SettingsLayout active="appointment">
<ClinicAppointmentSettingsContent />
</SettingsLayout>
);
}
File diff suppressed because it is too large Load Diff
+26 -13
View File
@@ -1,10 +1,23 @@
# Appointment Settings API
> **Prefix:** `/api/v1/appointment-settings`
> **Permission:** All write endpoints require `AUTH` — must be the doctor owner or `ROLE_ADMIN`
> **Permission:** every endpoint requires `AUTH` and resolves access through one shared rule (below)
Doctors configure their availability via three resources: **weekly schedule**, **date overrides**, and **holidays**.
## Access rule
All 14 endpoints in this file share a single check. Given the target doctor (resolved from the path/body uuid, or from the parent schedule/override/holiday), access is granted when the caller is:
1. `ROLE_ADMIN`, **or**
2. the doctor themselves, **or**
3. the **owner of a clinic** the doctor belongs to, **or**
4. a **doctor member of that clinic** holding the `appointment_settings` permission — `view` for `GET`, `update` for `POST`/`PATCH`/`DELETE` (see `docs/api/clinic.md`*Clinic Doctor Permissions*)
Anything else → `403 ERR_AUTH_006`. This is what lets the clinic panel manage every member doctor's booking settings from `تنظیمات → نوبت‌دهی`, one tab per doctor, using the same endpoints the doctor's own panel calls.
A doctor's own settings are never affected by clinic permissions — rule 2 short-circuits before any permission lookup.
---
## Weekly Schedule
@@ -29,7 +42,7 @@ Each doctor has **one** weekly schedule (upsert). The schedule is keyed by **day
Create or update the weekly schedule for a doctor (upsert).
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
> **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود.
@@ -190,7 +203,7 @@ Same structure as POST response.
Update weekly schedule. `{uuid}` can be schedule UUID or doctor UUID.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body
```json
@@ -233,7 +246,7 @@ Updated schedule object (same structure as POST).
Delete a weekly schedule.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
> Note: route is `/booking-setting/`, not `/appointment-settings/`
@@ -259,7 +272,7 @@ Override a specific date — mark it inactive (day off) or give it custom sessio
Get all date overrides for a doctor.
**Permission:** `AUTH`must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
**Permission:** `AUTH`see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise).
### Response `200`
```json
@@ -289,7 +302,7 @@ Get all date overrides for a doctor.
Create a date override.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body
```json
@@ -380,7 +393,7 @@ Override object (same structure as above).
Update a date override.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body (all optional)
```json
@@ -419,7 +432,7 @@ Updated override object.
Delete a date override.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Response `200`
```json
@@ -436,7 +449,7 @@ Mark a date range as holiday — all slots blocked, no overrides apply.
Get all holidays for a doctor.
**Permission:** `AUTH`must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
**Permission:** `AUTH`see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise).
### Response `200`
```json
@@ -466,7 +479,7 @@ Get all holidays for a doctor.
Create a holiday range.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body
```json
@@ -525,7 +538,7 @@ Get a single holiday.
Update a holiday.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body (all optional)
```json
@@ -546,7 +559,7 @@ Updated holiday object.
Delete a holiday.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Response `200`
```json
@@ -584,7 +597,7 @@ The `SlotCalculatorService` calculates available slots in this priority order:
### `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`
**Permission:** `AUTH`must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
**Permission:** `AUTH`see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise).
Returns all locations a doctor can assign as `location_id` in their schedule sessions. Includes both the doctor's personal addresses and the addresses of all clinics they belong to.
+10
View File
@@ -284,6 +284,13 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ❌ | قیمت‌گذاری همان پزشک را برمی‌گرداند به‌جای موجودیت کاربر جاری. برای تب‌های نوبت‌دهی پنل کلینیک. |
با `doctor_uuid`، دسترسی این‌گونه بررسی می‌شود: `ROLE_ADMIN`، خودِ پزشک، مالک کلینیکی که پزشک عضو آن است، یا پزشکِ عضو همان کلینیک با مجوز `services.view` (برای `PUT`: `services.update`). در غیر این صورت `403 ERR_ACCESS_DENIED`؛ پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. بدون این پارامتر رفتار قبلی (موجودیت کاربر جاری) دست‌نخورده است.
### Response `200`
```json
{
@@ -326,8 +333,11 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
### Request Body
> `doctor_uuid` (اختیاری) در بدنه پذیرفته می‌شود و مثل نسخهٔ `GET` عمل می‌کند — همان قواعد دسترسی، با اکشن `services.update`.
```json
{
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"free_visit_price_rials": 5000000,
"require_visit_price": true,
"insurances": [
@@ -35,6 +35,7 @@ class AppointmentSettingsController extends BaseController
private readonly DoctorAddressRepository $addressRepo,
private readonly ClinicRepository $clinicRepo,
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
) {}
/**
@@ -72,8 +73,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($doctor, $user, 'update')) !== null) {
return $err;
}
if (($err = $this->validateSessionsHaveLocation($data['schedule'] ?? [])) !== null) {
@@ -120,8 +121,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
}
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update')) !== null) {
return $err;
}
$prevMode = $schedule->getStoredBookingMode();
@@ -162,8 +163,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
}
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'view')) !== null) {
return $err;
}
return $this->success(['data' => $schedule->toArray()]);
@@ -177,8 +178,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
}
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update')) !== null) {
return $err;
}
$this->scheduleRepo->remove($schedule);
@@ -196,8 +197,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($doctor, $user, 'view')) !== null) {
return $err;
}
$overrides = array_map(
@@ -220,8 +221,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($doctor, $user, 'update')) !== null) {
return $err;
}
$timestamp = strtotime($dateStr);
@@ -246,8 +247,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
}
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update')) !== null) {
return $err;
}
$data = json_decode($request->getContent(), true) ?? [];
@@ -272,8 +273,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
}
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update')) !== null) {
return $err;
}
$this->overrideRepo->remove($override);
@@ -289,8 +290,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
}
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'view')) !== null) {
return $err;
}
return $this->success(['data' => $override->toArray()]);
@@ -306,8 +307,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($doctor, $user, 'view')) !== null) {
return $err;
}
$items = array_map(fn(Holiday $h) => $h->toArray(), $this->holidayRepo->findAllByDoctor($doctor));
@@ -323,8 +324,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
}
if ($holiday->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update')) !== null) {
return $err;
}
$this->holidayRepo->remove($holiday);
@@ -345,8 +346,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($doctor, $user, 'update')) !== null) {
return $err;
}
$startTs = strtotime($startStr);
@@ -372,8 +373,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
}
if ($holiday->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update')) !== null) {
return $err;
}
$data = json_decode($request->getContent(), true) ?? [];
@@ -403,8 +404,8 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
if (($err = $this->denyDoctorAccess($doctor, $user, 'view')) !== null) {
return $err;
}
$clinics = $this->clinicRepo->findByDoctor($doctor);
@@ -425,6 +426,29 @@ class AppointmentSettingsController extends BaseController
return $this->success(['data' => $result]);
}
/**
* تنها نقطهٔ تصمیم‌گیری دربارهٔ «چه کسی تنظیمات نوبت‌دهی این پزشک را می‌بیند/می‌نویسد».
*
* مجاز: ادمین، خود پزشک، مالکِ کلینیکی که پزشک عضو آن است، و پزشکِ عضوِ همان
* کلینیک در صورت داشتن مجوز appointment_settings مربوطه.
*
* @param 'view'|'update' $action
*/
private function denyDoctorAccess(\App\Doctor\Entity\Doctor $doctor, User $user, string $action): ?JsonResponse
{
if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
return null;
}
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
if ($this->permChecker->can($user, $clinic, 'appointment_settings', $action)) {
return null;
}
}
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
/**
* هر session فعال در برنامه‌ی هفتگی باید آدرس (location_id) داشته باشد.
* در صورت نقص، پیام خطا برمی‌گرداند؛ در غیر این صورت null.
@@ -42,9 +42,42 @@ class InsuranceController extends BaseController
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly FileValidatorService $fileValidator,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
private readonly string $projectDir,
) {}
/**
* وقتی doctor_uuid داده شود، قیمت‌گذاری همان پزشک هدف است — برای مدیریت پزشکان
* کلینیک از پنل کلینیک. بدون آن، رفتار قبلی (موجودیتِ خودِ کاربر) حفظ می‌شود.
*
* @param 'view'|'update' $action
* @return array{0: string, 1: int|null, 2: JsonResponse|null}
*/
private function resolveTargetEntity(User $user, ?string $doctorUuid, string $action): array
{
if ($doctorUuid === null || $doctorUuid === '') {
[$type, $id] = $this->resolveEntity($user);
return [$type, $id, null];
}
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return ['unknown', null, $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404)];
}
if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
}
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
if ($this->permChecker->can($user, $clinic, 'services', $action)) {
return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
}
}
return ['unknown', null, $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403)];
}
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
@@ -219,13 +252,21 @@ class InsuranceController extends BaseController
#[Route('/api/v1/insurance-pricing', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function getInsurancePricing(#[CurrentUser] User $user): JsonResponse
public function getInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
}
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
return $this->success($this->pricingPayload($entityType, $entityId));
}
private function pricingPayload(string $entityType, int $entityId): array
{
$rows = $this->pricingRepo->findByEntity($entityType, $entityId);
$freeVisitPriceRials = 0;
@@ -249,26 +290,29 @@ class InsuranceController extends BaseController
];
}, $this->insuranceRepo->findActive(null));
return $this->success([
return [
'entity_type' => $entityType,
'entity_id' => $entityId,
'free_visit_price_rials' => $freeVisitPriceRials,
'require_visit_price' => $requireVisitPrice,
'insurances' => $insurances,
]);
];
}
#[Route('/api/v1/insurance-pricing', methods: ['PUT'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
if ($err !== null) {
return $err;
}
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$freeVisitRow = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null);
$requireVisitPrice = array_key_exists('require_visit_price', $data)
@@ -306,7 +350,7 @@ class InsuranceController extends BaseController
$this->pricingRepo->getEntityManager()->flush();
return $this->getInsurancePricing($user);
return $this->success($this->pricingPayload($entityType, $entityId));
}
private function upsertPricing(string $entityType, int $entityId, ?int $insuranceId, int $shareRials): EntityInsurancePricing
@@ -0,0 +1,177 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* Appointment settings are reachable by the doctor, an admin, and the owner of a
* clinic the doctor belongs to. A member doctor's own access is governed by the
* clinic's appointment_settings permission. Edits never leak across doctors.
*/
class ClinicOwnerScheduleAccessTest extends ApiTestCase
{
private function makeDoctor(string $name): Doctor
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, $name);
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function makeClinicWith(Doctor ...$doctors): array
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($owner);
$clinic->setName('کلینیک تست');
foreach ($doctors as $d) {
$clinic->getDoctors()->add($d);
}
$this->em->persist($clinic);
$this->em->flush();
return [$owner, $clinic];
}
private function addressFor(Doctor $doctor): DoctorAddress
{
$address = DoctorAddress::forDoctor($doctor);
$this->em->persist($address);
$this->em->flush();
return $address;
}
private function schedulePayload(Doctor $doctor, int $locationId, string $start): array
{
return [
'doctor_uuid' => $doctor->getUuid(),
'schedule' => [
['day' => 'saturday', 'sessions' => [
['active' => true, 'location_id' => $locationId, 'start' => $start, 'end' => '12:00'],
]],
],
];
}
public function testClinicOwnerCanReadAndWriteMemberDoctorSchedule(): void
{
$doctor = $this->makeDoctor('دکتر عضو');
[$owner] = $this->makeClinicWith($doctor);
$address = $this->addressFor($doctor);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $address->getId(), '09:00'));
self::assertSame(201, $this->responseCode());
$this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner);
self::assertSame(200, $this->responseCode());
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner, [
'schedule' => [
['day' => 'saturday', 'sessions' => [
['active' => true, 'location_id' => $address->getId(), 'start' => '10:00', 'end' => '13:00'],
]],
],
]);
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCannotTouchOutsideDoctor(): void
{
$member = $this->makeDoctor('دکتر عضو');
[$owner] = $this->makeClinicWith($member);
$stranger = $this->makeDoctor('دکتر بیرونی');
$address = $this->addressFor($stranger);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($stranger, $address->getId(), '09:00'));
self::assertSame(403, $this->responseCode());
}
public function testDoctorKeepsFullAccessToOwnSchedule(): void
{
$doctor = $this->makeDoctor('دکتر مستقل');
$address = $this->addressFor($doctor);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $address->getId(), '09:00'));
self::assertSame(201, $this->responseCode());
$this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode());
}
public function testDoctorCannotTouchAnotherDoctorSchedule(): void
{
$mine = $this->makeDoctor('دکتر یک');
$theirs = $this->makeDoctor('دکتر دو');
$address = $this->addressFor($theirs);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $mine->getUser(), $this->schedulePayload($theirs, $address->getId(), '09:00'));
self::assertSame(403, $this->responseCode());
}
public function testAdminCanWriteAnyDoctorSchedule(): void
{
$doctor = $this->makeDoctor('دکتر هدف');
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$address = $this->addressFor($doctor);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $admin, $this->schedulePayload($doctor, $address->getId(), '09:00'));
self::assertSame(201, $this->responseCode());
}
public function testMemberDoctorLosesAccessWhenPermissionRevoked(): void
{
$doctor = $this->makeDoctor('دکتر عضو');
$other = $this->makeDoctor('دکتر دیگر');
[, $clinic] = $this->makeClinicWith($doctor, $other);
$address = $this->addressFor($other);
$perm = static::getContainer()->get(ClinicDoctorPermissionRepository::class)->getOrCreate($clinic, $doctor);
$perm->mergePermissions(['resources' => ['appointment_settings' => ['update' => false, 'view' => false]]]);
$this->em->flush();
// پزشک همچنان به برنامهٔ خودش دسترسی دارد؛ مجوز کلینیک فقط دیگران را محدود می‌کند
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($other, $address->getId(), '09:00'));
self::assertSame(403, $this->responseCode());
}
public function testEditingOneDoctorDoesNotAffectAnother(): void
{
$first = $this->makeDoctor('دکتر اول');
$second = $this->makeDoctor('دکتر دوم');
[$owner] = $this->makeClinicWith($first, $second);
$addrFirst = $this->addressFor($first);
$addrSecond = $this->addressFor($second);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($first, $addrFirst->getId(), '08:00'));
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($second, $addrSecond->getId(), '16:00'));
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$first->getUuid()}", $owner, [
'schedule' => [
['day' => 'saturday', 'sessions' => [
['active' => true, 'location_id' => $addrFirst->getId(), 'start' => '11:00', 'end' => '15:00'],
]],
],
]);
self::assertSame(200, $this->responseCode());
$this->em->clear();
$reloaded = $this->em->getRepository(WeeklySchedule::class)->findOneBy([
'doctor' => $this->em->getRepository(Doctor::class)->find($second->getId()),
]);
self::assertSame('16:00', $reloaded->getSetting()[0]['sessions'][0]['start'], "the other doctor's schedule is untouched");
}
}