fix(clinic-invitation): provision doctor accounts and repair panel actions

The invitation flow never created an account for the invitee. accept() only
looked up an existing doctor by mobile, so for a brand-new invitee it marked
the invitation accepted and burned the token while leaving doctor_id NULL —
no login, no clinic link, and every doctor-facing endpoint 404ing afterwards.

- invite/accept now provision the users + doctors pair, claim the profile on
  accept, link it to the clinic, and SMS generated credentials when the user
  has no password. Existing passwords are never overwritten.
- accept runs in one transaction so an invitation can no longer be marked
  accepted without its doctor profile and clinic link.
- changeStatus accepts `pending`, refreshing the token and re-sending the SMS
  so reactivating a suspended invitation yields a link that actually works.
  Answered invitations are rejected with 409.
- DELETE returns 200 with the standard envelope instead of a bodyless 204,
  which made the admin panel show a false error toast; api.ts also stops
  calling res.json() on empty responses.
- The clinic-doctors settings page sent the active context uuid as the clinic
  uuid, so users holding both a doctor and a clinic context got 404 on every
  invitation action. It now always resolves the clinic context.
- Adds app:invitations:repair to fix invitations already left orphaned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 09:14:13 +03:30
co-authored by Claude Opus 4.8
parent 1779e0d6de
commit 3a23aa242e
9 changed files with 689 additions and 33 deletions
@@ -87,7 +87,10 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
const changeInvStatusMut = useMutation({
mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) =>
api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }),
onSuccess: () => { toast.success('وضعیت دعوتنامه تغییر کرد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
onSuccess: (_d, v) => {
toast.success(v.status === 'pending' ? 'دعوتنامه فعال و پیامک مجدداً ارسال شد' : 'دعوتنامه تعلیق شد');
qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] });
},
onError: (e: Error) => toast.error(e.message),
});
@@ -230,7 +233,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
{inv.status !== 'removed' && inv.status !== 'accepted' && (
<button
className="mini-btn"
title="تعلیق"
title={inv.status === 'suspended' ? 'فعال‌سازی و ارسال مجدد پیامک' : 'تعلیق'}
disabled={changeInvStatusMut.isPending}
onClick={() => changeInvStatusMut.mutate({ invUuid: inv.uuid, status: inv.status === 'suspended' ? 'pending' : 'suspended' })}
>
+5
View File
@@ -71,6 +71,11 @@ async function request<T>(
);
}
// پاسخ بدون بدنه (۲۰۴ یا Content-Length صفر) نباید به res.json() برسد
if (res.status === 204 || res.headers.get('Content-Length') === '0') {
return null as T;
}
return res.json() as Promise<T>;
}
+15 -6
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { PencilIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../stores/authStore';
@@ -13,16 +13,25 @@ import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
* detail page, reachable via the "ویرایش اطلاعات کلینیک" link.
*/
function ClinicDoctorsContent() {
const { dbUuid, fetchMe } = useAuthStore();
const { dbUuid, context, availableContexts, fetchMe } = useAuthStore();
useEffect(() => {
if (!dbUuid) fetchMe();
}, [dbUuid, fetchMe]);
if (!dbUuid) {
// یک کاربر می‌تواند همزمان context پزشک و کلینیک داشته باشد؛ dbUuid فقط context فعال است.
// این صفحه همیشه باید uuid کلینیک را بفرستد، وگرنه همه اندپوینت‌های کلینیک ۴۰۴ می‌دهند.
const clinicUuid = useMemo(() => {
if (context?.type === 'clinic') return dbUuid;
return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null;
}, [context, dbUuid, availableContexts]);
if (!clinicUuid) {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>
{dbUuid ? 'کلینیکی برای این حساب کاربری یافت نشد' : 'در حال بارگذاری اطلاعات کلینیک...'}
</p>
</div>
);
}
@@ -34,12 +43,12 @@ function ClinicDoctorsContent() {
<h1 className="section-title">پزشکان کلینیک</h1>
<div className="muted">مدیریت پزشکان و دعوتنامههای کلینیک</div>
</div>
<Link className="btn ghost sm" to={`/admin/clinics/${dbUuid}`}>
<Link className="btn ghost sm" to={`/admin/clinics/${clinicUuid}`}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش اطلاعات کلینیک
</Link>
</div>
<ClinicDoctorsManager clinicUuid={dbUuid} />
<ClinicDoctorsManager clinicUuid={clinicUuid} />
</div>
);
}