سینیور خواست زودتر به فرانتاند وصل شویم تا از آنجا هم تست شود. اینها
سمتِ ما لازم بودند:
CORS با allowlist صریح (نه ALLOW_ALL): احرازِ هویتِ ما توکنِ Bearer در هدر
است، پس origin ناشناس نباید از مرورگرِ کاربر به API ما بزند.
CORS_ALLOW_CREDENTIALS عمداً خاموش میماند — کوکیای در کار نیست و روشن
کردنش فقط سطحِ حمله اضافه میکند. CorsMiddleware بالاتر از CommonMiddleware
گذاشته شد وگرنه ریدایرکتِ APPEND_SLASH و خطای ۴۰۰ هدرِ CORS نمیگیرند و در
مرورگر بهشکلِ خطای مبهمِ شبکه دیده میشوند.
PATCH /api/user/profile اضافه شد؛ تا پیش از این هیچ راهی برای ثبتِ نامِ
کاربر نبود. GET /api/home بدونِ پارامتر حالا پایهی ذخیرهشدهی کاربر را
برمیگرداند و POST /api/ai/ask فقط با lessonId کار میکند — هر دو برای
اینکه فرانت لازم نباشد پایه را خودش نگه دارد. questionCount به
lessons/{id}/intro اضافه شد.
docs/frontend/api.ts و README.md کلاینتِ تایپدار و راهنمای اتصالاند، از
روی OpenAPI واقعیِ همین سرویس تولید شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
/**
|
||
* کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend).
|
||
*
|
||
* از روی OpenAPI واقعیِ همین بکاند نوشته شده، نه از روی حدس. نامِ فیلدها
|
||
* دقیقاً همان چیزیست که سرور برمیگرداند (camelCase).
|
||
*
|
||
* نکتههای مهمِ اتصال:
|
||
* - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم
|
||
* نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده.
|
||
* - توکنِ دسترسی امضاشده و کوتاهعمر است؛ با ۴۰۱ باید `refresh` صدا زد.
|
||
* این کلاینت خودش یکبار refresh میکند و درخواست را تکرار.
|
||
* - برای پرسش از دستیار کافیست `lessonId` بدهید؛ سرور خودش میفهمد آن درس
|
||
* مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند میکند.
|
||
*/
|
||
|
||
export type Json = Record<string, unknown>;
|
||
|
||
export interface Tokens {
|
||
token: string;
|
||
refreshToken: string;
|
||
}
|
||
|
||
/* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */
|
||
|
||
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
|
||
|
||
export interface HomeNode {
|
||
id: string;
|
||
title: string;
|
||
status: NodeStatus;
|
||
lessonIds: number[];
|
||
}
|
||
export interface HomeUnit {
|
||
id: string;
|
||
title: string;
|
||
nodes: HomeNode[];
|
||
}
|
||
export interface HomeResponse {
|
||
units: HomeUnit[];
|
||
stats: { progressPercent: number; xp: number; coins: number };
|
||
}
|
||
|
||
export interface Grade {
|
||
id: number;
|
||
title: string;
|
||
icon: string;
|
||
color: string;
|
||
}
|
||
|
||
export interface UserSummary {
|
||
userId: string;
|
||
name: string;
|
||
avatar: string;
|
||
gradeId: number | null;
|
||
xp: number;
|
||
coins: number;
|
||
streak: { current: number; longest: number };
|
||
subscription: string;
|
||
}
|
||
|
||
export interface UserProfile {
|
||
userId: string;
|
||
phone: string | null;
|
||
name: string;
|
||
avatar: string;
|
||
isGuest: boolean;
|
||
gradeId: number | null;
|
||
birthYear: number | null;
|
||
parentPhone: string;
|
||
bio: string;
|
||
}
|
||
|
||
export interface LessonIntro {
|
||
lessonId: string;
|
||
title: string;
|
||
introText: string;
|
||
/** برای انتخابِ حالتِ ماسکوت در صفحهی درس */
|
||
mascotAnimation: string;
|
||
/**
|
||
* صفر یعنی متنِ درس هست ولی آزمونش هنوز ساخته نشده — «بهزودی» نشان دهید،
|
||
* نه کویزِ خالی. `complete` هم `totalQuestions: 0` را نمیپذیرد.
|
||
*/
|
||
questionCount: number;
|
||
}
|
||
|
||
export interface QuestionOption {
|
||
id: string;
|
||
value: string;
|
||
}
|
||
export interface Question {
|
||
id: string;
|
||
type: 'multiple_choice' | 'count_objects';
|
||
prompt: string;
|
||
options: QuestionOption[];
|
||
}
|
||
|
||
export interface AnswerResult {
|
||
correct: boolean;
|
||
correctOptionId: string;
|
||
xpEarned: number;
|
||
heartsRemaining: number;
|
||
/** حالتِ ماسکوت: درست → happy، غلط → encourage */
|
||
mascotMood: string;
|
||
}
|
||
|
||
export interface LessonComplete {
|
||
stars: number;
|
||
xpEarned: number;
|
||
coinsEarned: number;
|
||
stats: { progressPercent: number; xp: number; coins: number };
|
||
}
|
||
|
||
export interface Hearts {
|
||
currentHearts: number;
|
||
maxHearts: number;
|
||
nextRefillAt: string | null;
|
||
isUnlimited: boolean;
|
||
}
|
||
|
||
export interface AskResponse {
|
||
conversationId: string;
|
||
messageId: string;
|
||
answer: string;
|
||
suggestedFollowUps: string[];
|
||
remainingQuota: number;
|
||
/** تکههای کتاب که پاسخ رویشان بنا شده؛ خالی یعنی پاسخِ عمومی */
|
||
usedChunks: { title: string; summary: string }[];
|
||
}
|
||
|
||
export interface Plan {
|
||
id: string;
|
||
title: string;
|
||
price: number;
|
||
durationDays: number;
|
||
features: string[];
|
||
}
|
||
|
||
/* ─── خطا ───────────────────────────────────────────────────────────────── */
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
readonly status: number,
|
||
readonly detail: string,
|
||
readonly body: Json,
|
||
) {
|
||
super(detail);
|
||
this.name = 'ApiError';
|
||
}
|
||
|
||
/** ۴۲۹: throttle یا اتمامِ سهمیهی روزانهی دستیار */
|
||
get isRateLimited() {
|
||
return this.status === 429;
|
||
}
|
||
/** ۵۰۳: سرویسِ هوش مصنوعی/پیامک موقتاً در دسترس نیست */
|
||
get isProviderDown() {
|
||
return this.status === 503;
|
||
}
|
||
}
|
||
|
||
/* ─── کلاینت ────────────────────────────────────────────────────────────── */
|
||
|
||
export interface ClientOptions {
|
||
baseUrl?: string;
|
||
/** خواندن/نوشتنِ توکن؛ پیشفرض localStorage */
|
||
storage?: {
|
||
get(): Tokens | null;
|
||
set(t: Tokens | null): void;
|
||
};
|
||
}
|
||
|
||
const STORAGE_KEY = 'tadrisino.tokens';
|
||
|
||
function defaultStorage() {
|
||
return {
|
||
get(): Tokens | null {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
return raw ? (JSON.parse(raw) as Tokens) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
set(t: Tokens | null) {
|
||
try {
|
||
if (t) localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
|
||
else localStorage.removeItem(STORAGE_KEY);
|
||
} catch {
|
||
/* حالتِ خصوصیِ مرورگر — نادیده */
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
export function createClient(options: ClientOptions = {}) {
|
||
const baseUrl = (options.baseUrl ?? '/api').replace(/\/$/, '');
|
||
const storage = options.storage ?? defaultStorage();
|
||
|
||
async function raw<T>(
|
||
method: string,
|
||
path: string,
|
||
body?: unknown,
|
||
extraHeaders: Record<string, string> = {},
|
||
retryOn401 = true,
|
||
): Promise<T> {
|
||
const tokens = storage.get();
|
||
const headers: Record<string, string> = { ...extraHeaders };
|
||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||
if (tokens?.token) headers['Authorization'] = `Bearer ${tokens.token}`;
|
||
|
||
const response = await fetch(`${baseUrl}${path}`, {
|
||
method,
|
||
headers,
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
});
|
||
|
||
if (response.status === 401 && retryOn401 && tokens?.refreshToken) {
|
||
const refreshed = await refresh(tokens.refreshToken).catch(() => null);
|
||
if (refreshed) return raw<T>(method, path, body, extraHeaders, false);
|
||
storage.set(null);
|
||
}
|
||
|
||
const text = await response.text();
|
||
const parsed: Json = text ? (JSON.parse(text) as Json) : {};
|
||
if (!response.ok) {
|
||
const detail =
|
||
(parsed.detail as string) ??
|
||
(parsed.error as string) ??
|
||
`خطای ${response.status}`;
|
||
throw new ApiError(response.status, detail, parsed);
|
||
}
|
||
return parsed as T;
|
||
}
|
||
|
||
async function refresh(refreshToken: string) {
|
||
const response = await fetch(`${baseUrl}/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ refreshToken }),
|
||
});
|
||
if (!response.ok) return null;
|
||
const data = (await response.json()) as Tokens;
|
||
storage.set({ token: data.token, refreshToken: data.refreshToken });
|
||
return data;
|
||
}
|
||
|
||
return {
|
||
/* ── ورود (صفحهای که فعلاً در فرانت نیست و باید ساخته شود) ────────── */
|
||
auth: {
|
||
/** مهمان: بدونِ شماره، فقط با شناسهی دستگاه. برای «شروع سریع». */
|
||
async guest(deviceId: string) {
|
||
const data = await raw<{ userId: string; token: string; isNewUser: boolean; isGuest: boolean }>(
|
||
'POST', '/auth/session', { type: 'guest', deviceId },
|
||
);
|
||
storage.set({ token: data.token, refreshToken: '' });
|
||
return data;
|
||
},
|
||
/** گامِ ۱: ارسالِ پیامکِ کد. در حالتِ توسعه `devOtp` هم برمیگردد. */
|
||
requestOtp(phone: string) {
|
||
return raw<{ success: boolean; devOtp?: string; note?: string }>(
|
||
'POST', '/auth/otp/request', { phone },
|
||
);
|
||
},
|
||
/** گامِ ۲: تأییدِ کد و گرفتنِ توکن. نامِ فیلد در سرور `otp` است. */
|
||
async login(phone: string, otp: string) {
|
||
const data = await raw<{ userId: string } & Tokens>(
|
||
'POST', '/auth/login', { phone, otp },
|
||
);
|
||
storage.set({ token: data.token, refreshToken: data.refreshToken });
|
||
return data;
|
||
},
|
||
logout() {
|
||
storage.set(null);
|
||
},
|
||
isSignedIn() {
|
||
return Boolean(storage.get()?.token);
|
||
},
|
||
},
|
||
|
||
/* ── صفحهی «بیا باهم آشنا بشیم» ──────────────────────────────────── */
|
||
profile: {
|
||
get: () => raw<UserProfile>('GET', '/user/profile'),
|
||
/** اسم/آواتار/… . فقط فیلدهایی که میفرستید عوض میشوند. */
|
||
update: (patch: Partial<Pick<UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
|
||
raw<UserProfile>('PATCH', '/user/profile', patch),
|
||
setGrade: (gradeId: number) =>
|
||
raw<{ success: boolean; gradeId: number }>('POST', '/user/grade', { gradeId }),
|
||
},
|
||
|
||
/** فهرستِ پایهها را از سرور بگیرید، هاردکد نکنید — پایهها اضافه میشوند. */
|
||
grades: () => raw<{ grades: Grade[] }>('GET', '/grades').then((r) => r.grades),
|
||
|
||
/* ── صفحهی خانه و نقشهی مهارت ────────────────────────────────────── */
|
||
/** بدونِ gradeId، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||
home: (gradeId?: number) =>
|
||
raw<HomeResponse>('GET', gradeId ? `/home?grade=${gradeId}` : '/home'),
|
||
summary: () => raw<UserSummary>('GET', '/user/summary'),
|
||
hearts: () => raw<Hearts>('GET', '/user/hearts'),
|
||
streakCalendar: () =>
|
||
raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>(
|
||
'GET', '/user/streak/calendar',
|
||
),
|
||
leaderboard: (period: 'weekly' | 'monthly' = 'weekly') =>
|
||
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
|
||
|
||
/* ── صفحهی درس و سوال ─────────────────────────────────────────────── */
|
||
lesson: {
|
||
intro: (lessonId: number) => raw<LessonIntro>('GET', `/lessons/${lessonId}/intro`),
|
||
questions: (lessonId: number) =>
|
||
raw<{ questions: Question[] }>('GET', `/lessons/${lessonId}/questions`).then((r) => r.questions),
|
||
/**
|
||
* `idempotencyKey` را بدهید تا اگر شبکه قطع شد و کاربر دوباره زد،
|
||
* امتیاز/قلب دوبار حساب نشود.
|
||
*/
|
||
answer: (questionId: number, selectedOptionId: number, idempotencyKey?: string) =>
|
||
raw<AnswerResult>(
|
||
'POST', `/questions/${questionId}/answer`, { selectedOptionId },
|
||
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||
),
|
||
/**
|
||
* پایانِ درس. `correctCount` را سرور با تعدادِ واقعیِ سوالها سقف میزند.
|
||
* مجموعِ XPِ کاربر **اینجا** بهروز میشود، نه سرِ هر پاسخ.
|
||
*/
|
||
complete: (
|
||
lessonId: number,
|
||
result: { correctCount: number; totalQuestions: number },
|
||
idempotencyKey?: string,
|
||
) =>
|
||
raw<LessonComplete>(
|
||
'POST', `/lessons/${lessonId}/complete`, result,
|
||
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||
),
|
||
},
|
||
|
||
/* ── صفحهی «از رُبو بپرس» ─────────────────────────────────────────── */
|
||
ai: {
|
||
/**
|
||
* `lessonId` را بدهید تا پاسخ به کتابِ همان درس مستند شود؛ لازم نیست
|
||
* subject/grade/chapter را خودتان حساب کنید.
|
||
* `conversationId` را از پاسخِ قبلی پاس بدهید تا گفتگو ادامه پیدا کند.
|
||
*/
|
||
ask: (input: {
|
||
userQuestion: string;
|
||
lessonId?: number | string;
|
||
conversationId?: string;
|
||
questionId?: number | string;
|
||
idempotencyKey?: string;
|
||
}) => {
|
||
const { idempotencyKey, ...body } = input;
|
||
return raw<AskResponse>(
|
||
'POST', '/ai/ask', body,
|
||
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||
);
|
||
},
|
||
|
||
/**
|
||
* همان ask ولی توکنبهتوکن (SSE). اولین کلمه ~۳.۵ ثانیه بهجای ~۱۰.
|
||
* برای حبابِ چتِ «در حالِ تایپ» همین را استفاده کنید.
|
||
*/
|
||
async askStream(
|
||
input: { userQuestion: string; lessonId?: number | string; conversationId?: string },
|
||
onDelta: (text: string) => void,
|
||
): Promise<{ conversationId?: string; messageId?: string }> {
|
||
const tokens = storage.get();
|
||
const response = await fetch(`${baseUrl}/ai/ask`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(tokens?.token ? { Authorization: `Bearer ${tokens.token}` } : {}),
|
||
},
|
||
body: JSON.stringify({ ...input, stream: true }),
|
||
});
|
||
if (!response.ok || !response.body) {
|
||
throw new ApiError(response.status, 'استریم شروع نشد', {});
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let done: { conversationId?: string; messageId?: string } = {};
|
||
|
||
for (;;) {
|
||
const { value, done: finished } = await reader.read();
|
||
if (finished) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
// رویدادها با یک خطِ خالی از هم جدا میشوند.
|
||
const parts = buffer.split('\n\n');
|
||
buffer = parts.pop() ?? '';
|
||
for (const part of parts) {
|
||
const event = /^event: (.+)$/m.exec(part)?.[1];
|
||
const dataLine = /^data: (.+)$/m.exec(part)?.[1];
|
||
if (!event || !dataLine) continue;
|
||
const payload = JSON.parse(dataLine) as Json;
|
||
if (event === 'chunk') onDelta(String(payload.delta ?? ''));
|
||
else if (event === 'done') done = payload as typeof done;
|
||
else if (event === 'error') {
|
||
throw new ApiError(503, String(payload.detail ?? 'خطای دستیار'), payload);
|
||
}
|
||
}
|
||
}
|
||
return done;
|
||
},
|
||
|
||
/** 👍/👎 روی یک پاسخ. `reason` اختیاری ولی خیلی مفید است. */
|
||
feedback: (messageId: string | number, isHelpful: boolean, reason?: string) =>
|
||
raw<{ success: boolean }>('POST', `/ai/messages/${messageId}/feedback`, {
|
||
isHelpful,
|
||
...(reason ? { reason } : {}),
|
||
}),
|
||
|
||
history: (conversationId: string | number) =>
|
||
raw<{ conversationId: string; lessonId: string | null; messages: unknown[] }>(
|
||
'GET', `/ai/conversations/${conversationId}/messages`,
|
||
),
|
||
},
|
||
|
||
/* ── صفحهی اشتراک ─────────────────────────────────────────────────── */
|
||
subscription: {
|
||
plans: () => raw<{ plans: Plan[] }>('GET', '/subscription/plans').then((r) => r.plans),
|
||
status: () =>
|
||
raw<{ active: boolean; planId: string | null; expiresAt: string | null }>(
|
||
'GET', '/subscription/status',
|
||
),
|
||
/**
|
||
* `paymentUrl` را در مرورگر باز کنید. آن آدرس خودش سراغِ زرینپال
|
||
* میرود؛ اپ نباید مستقیم با درگاه حرف بزند.
|
||
*/
|
||
purchase: (planId: string, idempotencyKey?: string) =>
|
||
raw<{ orderId: string; paymentUrl: string }>(
|
||
'POST', '/subscription/purchase', { planId },
|
||
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||
),
|
||
},
|
||
|
||
notifications: {
|
||
list: () => raw<{ items: unknown[]; unreadCount: number }>('GET', '/notifications'),
|
||
markRead: (id: number) => raw<Json>('POST', `/notifications/${id}/read`),
|
||
},
|
||
};
|
||
}
|
||
|
||
export type TadrisinoClient = ReturnType<typeof createClient>;
|