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 بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ * هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
* درستِ خطا همه اینجا یک‌جا جمع‌اند. * درستِ خطا همه اینجا یک‌جا جمع‌اند.
*
* هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
* غافلگیرکننده‌اند کامنت خورده‌اند، چون اشتباهشان خطای ۴۰۰/۴۰۴ می‌دهد که
* از پیامش علت معلوم نیست.
*/ */
import { createHttpClient, ApiError, localTokenStorage } from './client'; import { ApiError, createHttpClient, localTokenStorage } from './client';
import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client'; import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
import type { import type {
AnswerResult, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question, UserProfile, AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question,
ReportReason, ReportResponse, UserProfile,
} from './types'; } from './types';
export { ApiError, localTokenStorage, createHttpClient }; export { ApiError, localTokenStorage, createHttpClient };
@@ -28,11 +33,11 @@ export function createApi(options: ClientOptions = {}) {
/* ── ورود ─────────────────────────────────────────────────────────── */ /* ── ورود ─────────────────────────────────────────────────────────── */
auth: { auth: {
/** «شروع سریع» بدونِ شماره. برای دمو کافی‌ست. */ /** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
async guest(deviceId: string) { async guest(deviceId: string) {
const data = await http.post<{ userId: string; token: string; isNewUser: boolean }>( const data = await http.post<{
'/auth/session', { type: 'guest', deviceId }, userId: string; token: string; isNewUser: boolean; isGuest: boolean;
); }>('/auth/session', { type: 'guest', deviceId });
http.storage.set({ token: data.token, refreshToken: '' }); http.storage.set({ token: data.token, refreshToken: '' });
return data; return data;
}, },
@@ -61,12 +66,17 @@ export function createApi(options: ClientOptions = {}) {
/* ── کاربر ────────────────────────────────────────────────────────── */ /* ── کاربر ────────────────────────────────────────────────────────── */
profile: { profile: {
get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts), 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), http.patch<UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) => setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), 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) => grades: (opts?: RequestOptions) =>
http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades), http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
@@ -77,17 +87,27 @@ export function createApi(options: ClientOptions = {}) {
/* ── درس و آزمون ──────────────────────────────────────────────────── */ /* ── درس و آزمون ──────────────────────────────────────────────────── */
lessons: { lessons: {
intro: (lessonId: number, opts?: RequestOptions) => intro: (lessonId: number | string, opts?: RequestOptions) =>
http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts), 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) http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts)
.then((r) => r.questions), .then((r) => r.questions),
complete: (lessonId: number | string) =>
http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}),
},
questions: {
/** /**
* ثبتِ پاسخ. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا تکرارِ خودکارِ * ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
* شبکه، دو بار XP ندهد. * `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
*/ */
answer: (lessonId: number, questionId: number, choiceId: number) => answer: (questionId: number | string, selectedOptionId: number | string) =>
http.create<AnswerResult>(`/lessons/${lessonId}/answer`, { questionId, choiceId }), 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` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و * فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و
* فصل است و پاسخ را به کتاب مستند می‌کند. * فصل است و پاسخ را به کتاب مستند می‌کند.
*
* ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`.
*/ */
ask: (question: string, ctx: { lessonId?: number; conversationId?: number } = {}, opts?: RequestOptions) => ask: (
http.post<AskResponse>('/ai/ask', { question, ...ctx }, { userQuestion: string,
ctx: { lessonId?: number | string; conversationId?: string; gradeId?: number } = {},
opts?: RequestOptions,
) =>
http.post<AskResponse>('/ai/ask', { userQuestion, ...ctx }, {
timeoutMs: ASK_TIMEOUT_MS, timeoutMs: ASK_TIMEOUT_MS,
...opts, ...opts,
}), }),
/** `reason` اختیاری‌ست ولی برای تحلیلِ کیفیت خیلی ارزش دارد. */
feedback: (messageId: number, vote: 'up' | 'down', reason?: string) => /** ⚠️ `isHelpful` بولین است، نه رشته‌ی up/down. `reason` اختیاری. */
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, { vote, 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 واقعیِ سرویس نوشته شده، نه حدس. * تایپ‌های پاسخِ بک‌اند — مستقیم از OpenAPI واقعیِ سرویس، نه از حدس.
* نامِ فیلدها دقیقاً همان چیزی‌ست که سرور می‌دهد (camelCase). *
* دو نکته که موقعِ نوشتن غافلگیرکننده بودند و اگر رعایت نشوند درخواست‌ها
* بی‌سروصدا شکست می‌خورند:
* • شناسه‌ها در پاسخ‌ها اغلب رشته‌اند، نه عدد.
* • فیلدِ پرسش در /ai/ask اسمش userQuestion است، نه question.
*/ */
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed'; export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface HomeNode { export interface HomeNode {
id: string; id: string;
title: string; title: string;
status: NodeStatus; status: NodeStatusValue;
lessonIds: number[]; lessonIds: number[];
} }
@@ -36,58 +40,82 @@ export interface Grade {
color: string; color: string;
} }
/** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */
export interface UserProfile { export interface UserProfile {
userId: string; userId: string;
phone: string;
name: string; name: string;
avatar: string; avatar: string;
isGuest: boolean;
gradeId: number | null; gradeId: number | null;
birthYear?: number | null; birthYear: number | null;
parentPhone?: string | null; parentPhone: string;
bio?: string | null; bio: string;
xp: number;
coins: number;
streak: { current: number; longest: number };
subscription: string;
} }
export interface LessonIntro { export interface LessonIntro {
lessonId: number; lessonId: string;
title: string; title: string;
introText: string; introText: string;
mascotAnimation: string;
questionCount: number; questionCount: number;
} }
export interface Choice { export interface ChoiceOption {
id: number; id: string;
text: string; value: string;
} }
export interface Question { export interface Question {
id: number; id: string;
text: string; type: string;
choices: Choice[]; prompt: string;
options: ChoiceOption[];
} }
export interface AnswerResult { /** `mascotMood`: پاسخِ درست → happy، غلط → encourage. */
export interface AnswerResponse {
correct: boolean; correct: boolean;
correctChoiceId: number; correctOptionId: string;
explanation?: string; xpEarned: number;
xpEarned?: number; heartsRemaining: number;
mascotMood: string;
} }
/** پاسخِ دستیار. `grounded=false` یعنی از کتاب پیدا نشد و پاسخ عمومی است. */ export interface UsedChunk {
title: string;
summary: string;
}
/**
* `usedChunks` خالی یعنی پاسخ از کتاب پیدا نشده و عمومی است — برای نشان دادنِ
* برچسبِ «بر اساسِ کتاب» در برابرِ «پاسخِ عمومی» همین کافی‌ست.
*/
export interface AskResponse { export interface AskResponse {
conversationId: string;
messageId: string;
answer: string; answer: string;
conversationId: number; suggestedFollowUps: string[];
messageId: number; remainingQuota: number;
grounded?: boolean; usedChunks: UsedChunk[];
sources?: { title: string; score?: number }[]; retrievalDebug?: Record<string, unknown>;
} }
export interface Plan { export interface Plan {
id: number; id: string;
title: string; title: string;
price: number; price: number;
currency: string;
popular: boolean;
discountPercent: number;
durationDays: number; durationDays: number;
features: string[]; }
export type ReportReason =
| 'wrong_answer' | 'unclear' | 'typo' | 'too_hard' | 'other';
export interface ReportResponse {
success: boolean;
reportId: number;
alreadyReported: boolean;
} }