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