Files
clinicpro/assets/admin/lib/api.ts
T
hamedandClaude Opus 4.8 3a23aa242e 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>
2026-07-18 09:14:13 +03:30

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: [];
}