add a typed API layer for the Tadrisino backend

اپ تا الان هیچ درخواستی به سرور نمی‌زد و همه‌ی داده‌ها داخلِ خودِ کامپوننت‌ها
هاردکد بود. این کامیت لایه‌ی ارتباط را اضافه می‌کند، بدونِ اینکه هیچ صفحه‌ای
را تغییر دهد.

عمداً فقط فایلِ جدید اضافه شده و App.tsx دست نخورده است، چون کارِ در جریانِ
برنچِ updateApp روی همان فایل است و نباید با هم تصادف کنند. وصل کردنِ
صفحه‌ها به این لایه کارِ بعدی‌ست.

هیچ پکیجِ جدیدی اضافه نشد — فقط fetch استاندارد.

چیزهایی که در client.ts هست برای حالتِ خراب‌اند، نه حالتِ خوب:
  • timeout روی هر درخواست — وگرنه بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد تا
    مرورگر به سقفِ ۶ درخواستِ همزمان بخورد و اپ قفل شود.
  • backoff با jitter — بدونِ jitter همه‌ی کلاینت‌ها بعد از یک خطا همزمان
    دوباره می‌زنند و موجِ دوم از موجِ اول بدتر است.
  • تکرارِ خودکار فقط روی GET؛ POST فقط وقتی Idempotency-Key دارد (ثبتِ پاسخ،
    سفارش) تا دابل‌کلیکِ کاربر دو بار XP یا دو سفارش نسازد.
  • refresh تک‌نسخه‌ای — ۲۰ درخواستِ همزمانِ ۴۰۱ نباید ۲۰ بار refresh بزنند.
  • ادغامِ GETهای یکسانِ در جریان.
  • احترام به هدرِ Retry-After که بک‌اند موقعِ throttle می‌فرستد.

credentials: 'omit' عمدی‌ست: احرازِ هویت توکنِ Bearer در هدر است و سمتِ سرور
CORS_ALLOW_CREDENTIALS=False تنظیم شده.

.env هم به .gitignore اضافه شد؛ قبلاً فقط node_modules داخلش بود و آدرس/
تنظیماتِ محلی می‌توانست ناخواسته کامیت شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 19:46:05 +03:30
parent 3cca227337
commit be9f99d52a
7 changed files with 722 additions and 0 deletions

118
src/api/index.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* API تدریسینو — تنها جایی که فرانت باید برای صدا زدنِ بک‌اند به آن نگاه کند.
*
* import { api } from '@/api';
* const home = await api.home();
*
* هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
* درستِ خطا همه اینجا یک‌جا جمع‌اند.
*/
import { createHttpClient, ApiError, localTokenStorage } from './client';
import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
import type {
AnswerResult, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question, UserProfile,
} from './types';
export { ApiError, localTokenStorage, createHttpClient };
export type { Tokens, HttpClient, ClientOptions, RequestOptions };
export * from './types';
/** پرسش از دستیار کند است (تولیدِ متن)؛ ۱۵ ثانیه‌ی پیش‌فرض کم است. */
const ASK_TIMEOUT_MS = 45_000;
export function createApi(options: ClientOptions = {}) {
const http = createHttpClient(options);
return {
http,
/* ── ورود ─────────────────────────────────────────────────────────── */
auth: {
/** «شروع سریع» بدونِ شماره. برای دمو کافی‌ست. */
async guest(deviceId: string) {
const data = await http.post<{ userId: string; token: string; isNewUser: boolean }>(
'/auth/session', { type: 'guest', deviceId },
);
http.storage.set({ token: data.token, refreshToken: '' });
return data;
},
/** گامِ ۱ — در حالتِ توسعه `devOtp` هم برمی‌گردد. */
requestOtp(phone: string) {
return http.post<{ success: boolean; devOtp?: string }>(
'/auth/otp/request', { phone },
);
},
/** گامِ ۲ — نامِ فیلد در سرور `otp` است، نه `code`. */
async login(phone: string, otp: string) {
const data = await http.post<{ userId: string } & Tokens>(
'/auth/login', { phone, otp },
);
http.storage.set({ token: data.token, refreshToken: data.refreshToken });
return data;
},
logout() {
http.storage.set(null);
},
isSignedIn() {
return Boolean(http.storage.get()?.token);
},
},
/* ── کاربر ────────────────────────────────────────────────────────── */
profile: {
get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts),
update: (patch: Partial<Pick<UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
http.patch<UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
},
/** پایه‌ها را هاردکد نکنید — اضافه می‌شوند. */
grades: (opts?: RequestOptions) =>
http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
/** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر استفاده می‌شود. */
home: (gradeId?: number, opts?: RequestOptions) =>
http.get<HomeResponse>(gradeId ? `/home?gradeId=${gradeId}` : '/home', opts),
/* ── درس و آزمون ──────────────────────────────────────────────────── */
lessons: {
intro: (lessonId: number, opts?: RequestOptions) =>
http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts),
questions: (lessonId: number, opts?: RequestOptions) =>
http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts)
.then((r) => r.questions),
/**
* ثبتِ پاسخ. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا تکرارِ خودکارِ
* شبکه، دو بار XP ندهد.
*/
answer: (lessonId: number, questionId: number, choiceId: number) =>
http.create<AnswerResult>(`/lessons/${lessonId}/answer`, { questionId, choiceId }),
},
/* ── دستیار ───────────────────────────────────────────────────────── */
ai: {
/**
* فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و
* فصل است و پاسخ را به کتاب مستند می‌کند.
*/
ask: (question: string, ctx: { lessonId?: number; conversationId?: number } = {}, opts?: RequestOptions) =>
http.post<AskResponse>('/ai/ask', { question, ...ctx }, {
timeoutMs: ASK_TIMEOUT_MS,
...opts,
}),
/** `reason` اختیاری‌ست ولی برای تحلیلِ کیفیت خیلی ارزش دارد. */
feedback: (messageId: number, vote: 'up' | 'down', reason?: string) =>
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, { vote, reason }),
},
/* ── اشتراک ───────────────────────────────────────────────────────── */
plans: (opts?: RequestOptions) =>
http.get<{ plans: Plan[] }>('/subscription/plans', opts).then((r) => r.plans),
};
}
export type Api = ReturnType<typeof createApi>;
/** نمونه‌ی پیش‌فرض — برای حالتِ عادی همین کافی‌ست. */
export const api = createApi();