Files
MathApp_frontend/src/api/index.ts
2026-10-05 17:38:18 +03:30

215 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* تنها درِ ارتباط با بک‌اند. هیچ جای دیگرِ اپ fetch نمی‌زند.
*
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
*/
import { resolveApiBaseUrl } from '@/config';
import { ApiError, createHttpClient, newIdempotencyKey } from './http';
import type * as T from './types';
export { ApiError };
export type * from './types';
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl() });
const q = (params: Record<string, string | number | undefined>) => {
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
return entries.length ? `?${new URLSearchParams(entries.map(([k, v]) => [k, String(v)]))}` : '';
};
function saveTokens(data: T.AuthResponse) {
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
return data;
}
/** یک بخشِ مسیر؛ شناسه‌ای که از آدرسِ صفحه یا سرور می‌آید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
const seg = (value: string | number) => encodeURIComponent(String(value));
export const api = {
auth: {
isSignedIn: () => Boolean(http.storage.get()?.token),
/**
* گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمی‌گردد.
* مهلتِ ۴۵ ثانیه، نه ۱۵ِ پیش‌فرض: بک‌اند تا ۱۰+۳۰ ثانیه منتظرِ کاوه‌نگار می‌ماند
* (۲۰۲۶-۰۹-۲۷، پیامک رسید ولی جوابِ کاوه‌نگار دیر آمد). اگر این زودتر قطع کند،
* کاربر «ارتباط برقرار نشد» می‌بیند در حالی که کد در گوشی‌اش است.
*/
requestOtp: (phone: string) =>
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }, { timeoutMs: 45_000 }),
/**
* گامِ ۲: تأییدِ کد. اگر الان مهمان باشد، سرور همان کاربر را نگه می‌دارد و
* پیشرفتش از دست نمی‌رود (توکنِ مهمان همراهِ همین درخواست می‌رود).
*/
login: (phone: string, otp: string) =>
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens),
/**
* حذفِ همیشگیِ حساب. کاربرِ شماره‌دار کدِ تازه‌ی پیامکی می‌دهد (اول requestOtp با شماره‌ی خودش)،
* مهمان کلمه‌ی «حذف» را. بعد از موفقیت توکن‌ها پاک می‌شوند؛ دیگر کاربری پشتشان نیست.
*/
deleteAccount: (body: { otp?: string; confirm?: string }) =>
http.post<{ success: boolean }>('/user/delete', body).then((r) => {
http.storage.set(null);
return r;
}),
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
async logout() {
const refreshToken = http.storage.get()?.refreshToken;
try {
await http.post('/auth/logout', refreshToken ? { refreshToken } : {});
} finally {
http.storage.set(null);
}
},
},
grades: () => http.get<{ grades: T.Grade[] }>('/grades').then((r) => r.grades),
profile: {
get: () => http.get<T.UserProfile>('/user/profile'),
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'birthDate' | 'bio'>>) =>
http.patch<T.UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
},
summary: () => http.get<T.UserSummary>('/user/summary'),
hearts: () => http.get<T.Hearts>('/user/hearts'),
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: () => http.get<T.HomeResponse>('/home'),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
/**
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر
* کاربر ثابت است، پس رفرش همان آزمون را می‌دهد.
*/
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${seg(nodeId)}/quiz`),
/** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */
nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
lesson: {
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${seg(lessonId)}/intro`),
questions: (lessonId: number) =>
http.get<{ questions: T.Question[] }>(`/lessons/${seg(lessonId)}/questions`).then((r) => r.questions),
/**
* کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا
* امتیاز و قلب دوبار حساب نشود.
*/
answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) =>
http.post<T.AnswerResult>(
`/questions/${seg(questionId)}/answer`,
{ selectedOptionId: Number(selectedOptionId) },
{ idempotencyKey, retry: true },
),
/** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.LessonComplete>(`/lessons/${seg(lessonId)}/complete`, result, { idempotencyKey }),
report: (questionId: string, reason: T.ReportReason, note?: string) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/questions/${seg(questionId)}/report`,
{ reason, ...(note ? { note } : {}) },
),
},
leaderboard: (period: T.LeaderboardPeriod) =>
http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries),
characters: {
list: () => http.get<{ characters: T.Character[] }>('/characters').then((r) => r.characters),
select: (characterId: string) =>
http.post<{ characterId: string; selected: boolean }>('/characters/select', { characterId }),
},
ai: {
/** «امروز X سوالِ رایگان + Y سوالِ خریده‌شده»، پیش از اولین سوال. */
quota: () => http.get<T.TutorQuota>('/ai/quota'),
/**
* پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛
* `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده.
*/
async ask(
input: { userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string },
onDelta: (text: string) => void,
signal?: AbortSignal,
): Promise<T.AskDone> {
let done: T.AskDone | null = null;
await http.stream(
'/ai/ask',
{ ...input, lessonId: input.lessonId?.toString(), stream: true },
(event, data) => {
if (event === 'chunk') onDelta(String(data.delta ?? ''));
else if (event === 'done') done = data as unknown as T.AskDone;
else if (event === 'error') throw new ApiError(503, String(data.detail ?? 'دستیار در دسترس نیست'), data);
},
{ signal },
);
if (!done) throw new ApiError(0, 'پاسخ کامل نرسید');
return done;
},
feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(`/ai/messages/${seg(messageId)}/feedback`, {
isHelpful,
...(reason ? { reason } : {}),
}),
history: (conversationId: string) =>
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
`/ai/conversations/${seg(conversationId)}/messages`,
),
},
notifications: {
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`),
markAllRead: () => http.patch('/notifications/read-all'),
},
subscription: {
plans: () => http.get<T.PlansResponse>('/subscription/plans'),
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
/** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
/**
* خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت
* همان سفارش را برگرداند (src/lib/bazaar.ts).
*/
purchaseBazaar: (planId: string, idempotencyKey: string) =>
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */
verifyBazaar: (orderId: string, purchaseToken: string) =>
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
redeem: (code: string, orderId: string) =>
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${seg(orderId)}`),
/** پرداخت‌ها و فاکتورهای همین کاربر، تازه‌ترین اول. */
invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices),
},
support: {
createTicket: (subject: string, category: string) =>
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
/** تیکت‌های همین کاربر، تازه‌ترین اول (۲۰ تای آخر). */
tickets: () => http.get<{ tickets: T.TicketSummary[] }>('/support/tickets').then((r) => r.tickets),
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${seg(ticketId)}`),
reply: (ticketId: string, text: string) =>
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${seg(ticketId)}/messages`, { text }),
bugReport: (title: string, description: string) =>
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
},
affiliate: {
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل می‌کند. */
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
},
};