feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays

- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.

feat(command): create AssignScheduleClinicCommand to move schedules

- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.

feat(context): implement EntityContext and EntityContextResolver

- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.

test: add ServiceModeContextTest for appointment scheduling

- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
This commit is contained in:
hamed
2026-07-18 13:32:56 +03:30
parent 2553b45990
commit f1258d206d
28 changed files with 2126 additions and 276 deletions
@@ -493,7 +493,14 @@ function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = f
// ── Weekly Schedule Tab ────────────────────────────────────────────────────
export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; addresses: AddressData[]; readOnly?: boolean }) {
/**
* هر درخواست تنظیمات نوبت‌دهی باید محیطش را حمل کند: بدون clinic_uuid یعنی مطب
* شخصی پزشک، و با آن یعنی همان پزشک داخل آن کلینیک. این دو داده‌ی جدا دارند.
*/
const withClinic = (url: string, clinicUuid?: string | null): string =>
clinicUuid ? `${url}${url.includes('?') ? '&' : '?'}clinic_uuid=${encodeURIComponent(clinicUuid)}` : url;
export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
const qc = useQueryClient();
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
@@ -504,8 +511,8 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
const [confirmMode, setConfirmMode] = useState(false);
const scheduleQ = useQuery({
queryKey: ['doctor-schedule', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`),
queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null],
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, clinicUuid)),
staleTime: 0,
retry: (count, err) => !(err instanceof ApiError && err.status === 404),
});
@@ -550,15 +557,15 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است');
return scheduleUuid
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta });
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null })
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null });
},
onSuccess: (res) => {
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود
toast.success('برنامه هفتگی ذخیره شد');
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid] });
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] });
},
onError: (e: Error) => toast.error(e.message),
});
@@ -712,7 +719,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
<span className="text-sm text-slate-600 dark:text-slate-400">دقیقه</span>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 leading-relaxed">
مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود. لازم است حداقل یک سرویس با «نمایش در نوبتدهی» در بخش <span className="font-medium">سرویسها</span> تعریف کنید، وگرنه ذخیره نمیشود.
مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود. لازم است حداقل یک سرویس با «نمایش در نوبتدهی» در بخش <span className="font-medium">{clinicUuid ? 'سرویس‌های کلینیک' : 'سرویس‌ها'}</span> تعریف کنید، وگرنه ذخیره نمیشود.
</p>
</>
) : (
@@ -875,9 +882,9 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
// ── Date Override Modal ────────────────────────────────────────────────────
function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addresses }: {
function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved, addresses }: {
open: boolean; onClose: () => void;
existing: DateOverrideData | null; doctorUuid: string;
existing: DateOverrideData | null; doctorUuid: string; clinicUuid?: string | null;
onSaved: () => void; addresses: AddressData[];
}) {
const [dateStr, setDateStr] = useState('');
@@ -917,7 +924,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addre
const body = { date: dateStr, active, reason: reason || undefined, custom_slots: active ? slots : [] };
if (existing)
return api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${existing.uuid}`, body);
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/date-override', { ...body, doctor_uuid: doctorUuid });
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/date-override', { ...body, doctor_uuid: doctorUuid, clinic_uuid: clinicUuid ?? null });
},
onSuccess: () => { toast.success(existing ? 'ویرایش شد' : 'تاریخ خاص اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
@@ -1015,15 +1022,15 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addre
// ── Date Overrides Tab ─────────────────────────────────────────────────────
function DateOverridesTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; addresses: AddressData[]; readOnly?: boolean }) {
function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<DateOverrideData | null>(null);
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
const listQ = useQuery({
queryKey: ['doctor-overrides', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/list/${doctorUuid}`),
queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null],
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/date-override/list/${doctorUuid}`, clinicUuid)),
staleTime: 0,
});
const overrides: DateOverrideData[] = useMemo(
@@ -1032,7 +1039,7 @@ function DateOverridesTab({ doctorUuid, addresses, readOnly = false }: { doctorU
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${uuid}`),
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] }); },
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null] }); },
onError: (e: Error) => toast.error(e.message),
});
@@ -1103,8 +1110,8 @@ function DateOverridesTab({ doctorUuid, addresses, readOnly = false }: { doctorU
</div>
)}
<DateOverrideModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
existing={editing} doctorUuid={doctorUuid} addresses={addresses}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] })} />
existing={editing} doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={addresses}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null] })} />
<ConfirmDialog open={!!deletingUuid} title="حذف تاریخ خاص" message="آیا از حذف این تاریخ خاص اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
@@ -1114,9 +1121,9 @@ function DateOverridesTab({ doctorUuid, addresses, readOnly = false }: { doctorU
// ── Holiday Modal ──────────────────────────────────────────────────────────
function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
function HolidayModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved }: {
open: boolean; onClose: () => void;
existing: HolidayData | null; doctorUuid: string;
existing: HolidayData | null; doctorUuid: string; clinicUuid?: string | null;
onSaved: () => void;
}) {
const [startDate, setStartDate] = useState('');
@@ -1143,7 +1150,7 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
const body = { start_date: startDate, end_date: endDate, reason: reason || undefined };
if (existing)
return api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${existing.uuid}`, body);
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/holidays', { ...body, doctor_uuid: doctorUuid });
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/holidays', { ...body, doctor_uuid: doctorUuid, clinic_uuid: clinicUuid ?? null });
},
onSuccess: () => { toast.success(existing ? 'تعطیلات ویرایش شد' : 'تعطیلات اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
@@ -1187,15 +1194,15 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
// ── Holidays Tab ───────────────────────────────────────────────────────────
function HolidaysTab({ doctorUuid, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) {
function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; readOnly?: boolean }) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<HolidayData | null>(null);
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
const listQ = useQuery({
queryKey: ['doctor-holidays', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/list/${doctorUuid}`),
queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null],
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/holidays/list/${doctorUuid}`, clinicUuid)),
staleTime: 0,
});
const holidays: HolidayData[] = useMemo(
@@ -1204,13 +1211,13 @@ function HolidaysTab({ doctorUuid, readOnly = false }: { doctorUuid: string; rea
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${uuid}`),
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] }); },
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] }); },
onError: (e: Error) => toast.error(e.message),
});
const toggleMut = useMutation({
mutationFn: (h: HolidayData) => api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${h.uuid}`, { active: !h.active }),
onSuccess: () => { toast.success('وضعیت بروز شد'); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] }); },
onSuccess: () => { toast.success('وضعیت بروز شد'); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] }); },
onError: (e: Error) => toast.error(e.message),
});
@@ -1284,8 +1291,8 @@ function HolidaysTab({ doctorUuid, readOnly = false }: { doctorUuid: string; rea
</div>
)}
<HolidayModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
existing={editing} doctorUuid={doctorUuid}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] })} />
existing={editing} doctorUuid={doctorUuid} clinicUuid={clinicUuid}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] })} />
<ConfirmDialog open={!!deletingUuid} title="حذف تعطیلات" message="آیا از حذف این تعطیلات اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
@@ -1301,12 +1308,12 @@ const SCHEDULE_TABS = [
{ id: 'holidays' as const, label: 'تعطیلات' },
];
export function ScheduleSection({ doctorUuid, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) {
export function ScheduleSection({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; readOnly?: boolean }) {
const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly');
const locationsQ = useQuery({
queryKey: ['available-locations', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/available-locations/${doctorUuid}`),
queryKey: ['available-locations', doctorUuid, clinicUuid ?? null],
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/available-locations/${doctorUuid}`, clinicUuid)),
enabled: !!doctorUuid,
staleTime: 30_000,
});
@@ -1327,9 +1334,9 @@ export function ScheduleSection({ doctorUuid, readOnly = false }: { doctorUuid:
</button>
))}
</div>
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={availableLocations} readOnly={readOnly} />}
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={availableLocations} readOnly={readOnly} />}
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} readOnly={readOnly} />}
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={availableLocations} readOnly={readOnly} />}
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={availableLocations} readOnly={readOnly} />}
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} clinicUuid={clinicUuid} readOnly={readOnly} />}
</div>
);
}