match the real API contract, verified against a running backend

اولین نسخه از روی حافظه و حدس نوشته شده بود و سه صدای مهم اصلاً کار نمی‌کردند.
این بار هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شد و بعد کلِ
کلاینت در برابرِ یک بک‌اندِ در حالِ اجرا تست شد.

چیزهایی که غلط بودند:
  • ثبتِ پاسخ به /lessons/{id}/answer می‌رفت — چنین مسیری وجود ندارد.
    مسیرِ درست /questions/{id}/answer است و فیلدش selectedOptionId، نه
    questionId/choiceId.
  • /ai/ask فیلدِ userQuestion می‌خواهد، نه question. با نامِ غلط، پرسش
    اصلاً به مدل نمی‌رسید.
  • فیدبک isHelpful بولین می‌خواهد، نه رشته‌ی up/down.
  • UserProfile فیلدهای xp/coins/streak را نداشت (آن‌ها در /user/summary
    هستند) و isGuest/phone را نداشت.
  • شناسه‌ها در پاسخ‌ها رشته‌اند، نه عدد.

پاسخِ دستیار هم usedChunks دارد نه sources؛ خالی بودنش یعنی از کتاب چیزی
پیدا نشده، که همان چیزی‌ست که برچسبِ «بر اساسِ کتاب» به آن نیاز دارد.

questions.report تازه اضافه شد: بک‌اند حالا POST /questions/{id}/report
دارد تا کاربر بتواند ایرادِ خودِ سوال را گزارش کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 21:41:24 +03:30
parent be9f99d52a
commit f61c58eb76
2 changed files with 104 additions and 47 deletions

View File

@@ -6,11 +6,16 @@
*
* هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
* درستِ خطا همه اینجا یک‌جا جمع‌اند.
*
* هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
* غافلگیرکننده‌اند کامنت خورده‌اند، چون اشتباهشان خطای ۴۰۰/۴۰۴ می‌دهد که
* از پیامش علت معلوم نیست.
*/
import { createHttpClient, ApiError, localTokenStorage } from './client';
import { ApiError, createHttpClient, localTokenStorage } from './client';
import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
import type {
AnswerResult, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question, UserProfile,
AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question,
ReportReason, ReportResponse, UserProfile,
} from './types';
export { ApiError, localTokenStorage, createHttpClient };
@@ -28,11 +33,11 @@ export function createApi(options: ClientOptions = {}) {
/* ── ورود ─────────────────────────────────────────────────────────── */
auth: {
/** «شروع سریع» بدونِ شماره. برای دمو کافی‌ست. */
/** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
async guest(deviceId: string) {
const data = await http.post<{ userId: string; token: string; isNewUser: boolean }>(
'/auth/session', { type: 'guest', deviceId },
);
const data = await http.post<{
userId: string; token: string; isNewUser: boolean; isGuest: boolean;
}>('/auth/session', { type: 'guest', deviceId });
http.storage.set({ token: data.token, refreshToken: '' });
return data;
},
@@ -61,12 +66,17 @@ export function createApi(options: ClientOptions = {}) {
/* ── کاربر ────────────────────────────────────────────────────────── */
profile: {
get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts),
update: (patch: Partial<Pick<UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
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 }),
},
/** xp/سکه/استریک اینجاست، نه در profile. */
summary: (opts?: RequestOptions) =>
http.get<Record<string, unknown>>('/user/summary', opts),
/** پایه‌ها را هاردکد نکنید — اضافه می‌شوند. */
grades: (opts?: RequestOptions) =>
http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
@@ -77,17 +87,27 @@ export function createApi(options: ClientOptions = {}) {
/* ── درس و آزمون ──────────────────────────────────────────────────── */
lessons: {
intro: (lessonId: number, opts?: RequestOptions) =>
intro: (lessonId: number | string, opts?: RequestOptions) =>
http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts),
questions: (lessonId: number, opts?: RequestOptions) =>
questions: (lessonId: number | string, opts?: RequestOptions) =>
http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts)
.then((r) => r.questions),
complete: (lessonId: number | string) =>
http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}),
},
questions: {
/**
* ثبتِ پاسخ. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا تکرارِ خودکارِ
* شبکه، دو بار XP ندهد.
* ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
* `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
*/
answer: (lessonId: number, questionId: number, choiceId: number) =>
http.create<AnswerResult>(`/lessons/${lessonId}/answer`, { questionId, choiceId }),
answer: (questionId: number | string, selectedOptionId: number | string) =>
http.create<AnswerResponse>(`/questions/${questionId}/answer`, { selectedOptionId }),
/** «این سوال ایراد دارد» — گزارشِ تکراریِ همان کاربر ردیفِ تازه نمی‌سازد. */
report: (questionId: number | string, reason: ReportReason, note = '') =>
http.post<ReportResponse>(`/questions/${questionId}/report`, { reason, note }),
},
/* ── دستیار ───────────────────────────────────────────────────────── */
@@ -95,15 +115,24 @@ export function createApi(options: ClientOptions = {}) {
/**
* فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و
* فصل است و پاسخ را به کتاب مستند می‌کند.
*
* ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`.
*/
ask: (question: string, ctx: { lessonId?: number; conversationId?: number } = {}, opts?: RequestOptions) =>
http.post<AskResponse>('/ai/ask', { question, ...ctx }, {
ask: (
userQuestion: string,
ctx: { lessonId?: number | string; conversationId?: string; gradeId?: number } = {},
opts?: RequestOptions,
) =>
http.post<AskResponse>('/ai/ask', { userQuestion, ...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 }),
/** ⚠️ `isHelpful` بولین است، نه رشته‌ی up/down. `reason` اختیاری. */
feedback: (messageId: number | string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(
`/ai/messages/${messageId}/feedback`, { isHelpful, reason },
),
},
/* ── اشتراک ───────────────────────────────────────────────────────── */

View File

@@ -1,14 +1,18 @@
/**
* تایپ‌های پاسخِ بک‌اند. از روی OpenAPI واقعیِ سرویس نوشته شده، نه حدس.
* نامِ فیلدها دقیقاً همان چیزی‌ست که سرور می‌دهد (camelCase).
* تایپ‌های پاسخِ بک‌اند — مستقیم از OpenAPI واقعیِ سرویس، نه از حدس.
*
* دو نکته که موقعِ نوشتن غافلگیرکننده بودند و اگر رعایت نشوند درخواست‌ها
* بی‌سروصدا شکست می‌خورند:
* • شناسه‌ها در پاسخ‌ها اغلب رشته‌اند، نه عدد.
* • فیلدِ پرسش در /ai/ask اسمش userQuestion است، نه question.
*/
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface HomeNode {
id: string;
title: string;
status: NodeStatus;
status: NodeStatusValue;
lessonIds: number[];
}
@@ -36,58 +40,82 @@ export interface Grade {
color: string;
}
/** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */
export interface UserProfile {
userId: string;
phone: string;
name: string;
avatar: string;
isGuest: boolean;
gradeId: number | null;
birthYear?: number | null;
parentPhone?: string | null;
bio?: string | null;
xp: number;
coins: number;
streak: { current: number; longest: number };
subscription: string;
birthYear: number | null;
parentPhone: string;
bio: string;
}
export interface LessonIntro {
lessonId: number;
lessonId: string;
title: string;
introText: string;
mascotAnimation: string;
questionCount: number;
}
export interface Choice {
id: number;
text: string;
export interface ChoiceOption {
id: string;
value: string;
}
export interface Question {
id: number;
text: string;
choices: Choice[];
id: string;
type: string;
prompt: string;
options: ChoiceOption[];
}
export interface AnswerResult {
/** `mascotMood`: پاسخِ درست → happy، غلط → encourage. */
export interface AnswerResponse {
correct: boolean;
correctChoiceId: number;
explanation?: string;
xpEarned?: number;
correctOptionId: string;
xpEarned: number;
heartsRemaining: number;
mascotMood: string;
}
/** پاسخِ دستیار. `grounded=false` یعنی از کتاب پیدا نشد و پاسخ عمومی است. */
export interface UsedChunk {
title: string;
summary: string;
}
/**
* `usedChunks` خالی یعنی پاسخ از کتاب پیدا نشده و عمومی است — برای نشان دادنِ
* برچسبِ «بر اساسِ کتاب» در برابرِ «پاسخِ عمومی» همین کافی‌ست.
*/
export interface AskResponse {
conversationId: string;
messageId: string;
answer: string;
conversationId: number;
messageId: number;
grounded?: boolean;
sources?: { title: string; score?: number }[];
suggestedFollowUps: string[];
remainingQuota: number;
usedChunks: UsedChunk[];
retrievalDebug?: Record<string, unknown>;
}
export interface Plan {
id: number;
id: string;
title: string;
price: number;
currency: string;
popular: boolean;
discountPercent: number;
durationDays: number;
features: string[];
}
export type ReportReason =
| 'wrong_answer' | 'unclear' | 'typo' | 'too_hard' | 'other';
export interface ReportResponse {
success: boolean;
reportId: number;
alreadyReported: boolean;
}