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>
143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
import { useAuthStore } from '../stores/authStore';
|
|
|
|
const BASE_URL = '';
|
|
|
|
function getToken(): string | null {
|
|
try {
|
|
const raw = localStorage.getItem('clinicpro-auth');
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw);
|
|
return parsed?.state?.token ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public code: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
// چند درخواست همزمان که ۴۰۱ میگیرند، فقط یکبار refresh را اجرا کنند
|
|
let refreshPromise: Promise<string | null> | null = null;
|
|
|
|
function refreshOnce(): Promise<string | null> {
|
|
if (!refreshPromise) {
|
|
refreshPromise = useAuthStore.getState().refresh().finally(() => {
|
|
refreshPromise = null;
|
|
});
|
|
}
|
|
return refreshPromise;
|
|
}
|
|
|
|
async function request<T>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
retry = true,
|
|
): Promise<T> {
|
|
const token = getToken();
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...(options.headers as Record<string, string>),
|
|
};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) {
|
|
// تلاش یکباره برای تازهسازی توکن، سپس اجرای مجدد همان درخواست
|
|
if (retry) {
|
|
const newToken = await refreshOnce();
|
|
if (newToken) {
|
|
return request<T>(path, options, false);
|
|
}
|
|
}
|
|
useAuthStore.getState().logout();
|
|
window.location.replace('/admin/login');
|
|
throw new ApiError(401, 'ERR_UNAUTHORIZED', 'نشست منقضی شده است');
|
|
}
|
|
const body = await res.json().catch(() => ({}));
|
|
const firstErr = body?.errors?.[0];
|
|
throw new ApiError(
|
|
res.status,
|
|
firstErr?.code ?? 'ERR_UNKNOWN',
|
|
firstErr?.message ?? 'خطای ناشناخته',
|
|
);
|
|
}
|
|
|
|
// پاسخ بدون بدنه (۲۰۴ یا Content-Length صفر) نباید به res.json() برسد
|
|
if (res.status === 204 || res.headers.get('Content-Length') === '0') {
|
|
return null as T;
|
|
}
|
|
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string) => request<T>(path),
|
|
post: <T>(path: string, body: unknown) =>
|
|
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
|
patch: <T>(path: string, body: unknown) =>
|
|
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
|
put: <T>(path: string, body: unknown) =>
|
|
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
|
download: (path: string) => downloadFile(path),
|
|
};
|
|
|
|
// دانلود فایل با ارسال توکن JWT در هدر و ذخیره روی دیسک کاربر
|
|
async function downloadFile(path: string, retry = true): Promise<void> {
|
|
const token = getToken();
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(`${BASE_URL}${path}`, { headers });
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401 && retry) {
|
|
const newToken = await refreshOnce();
|
|
if (newToken) return downloadFile(path, false);
|
|
useAuthStore.getState().logout();
|
|
window.location.replace('/admin/login');
|
|
}
|
|
throw new ApiError(res.status, 'ERR_DOWNLOAD', 'دانلود ناموفق بود');
|
|
}
|
|
|
|
const blob = await res.blob();
|
|
const disposition = res.headers.get('Content-Disposition') ?? '';
|
|
const match = disposition.match(/filename="?([^"]+)"?/);
|
|
const filename = match ? match[1] : 'download';
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export interface ApiResponse<T> {
|
|
success: boolean;
|
|
data: T;
|
|
errors: { code: string; message: string; field?: string }[];
|
|
}
|
|
|
|
export interface PaginatedResponse<T> {
|
|
success: boolean;
|
|
data: T[];
|
|
meta: {
|
|
totalRecords: number;
|
|
totalPages: number;
|
|
currentPage: number;
|
|
};
|
|
errors: [];
|
|
}
|