Payment return decided by the server only; encoded path ids; no sign-out on a two-tab refresh race
- ?orderId must be numeric; every id in an API path is encodeURIComponent'd.
- The payment sheet shows success only from GET /payment/orders/{id}; a failed lookup says the
status is unknown instead of "paid".
- A 401 from /auth/refresh is not a sign-out when another tab already stored a rotated token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -202,7 +202,13 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
} catch {
|
||||
return new ApiError(0, 'ارتباط با سرور برقرار نشد');
|
||||
}
|
||||
if (response.status === 401) return 'invalid';
|
||||
if (response.status === 401) {
|
||||
// تبِ دیگری همین توکن را زودتر تمدید کرده (سرور توکنِ قبلی را باطل میکند) و توکنِ تازه
|
||||
// را در همان localStorageِ مشترک گذاشته؛ آن نشست سالم است و نباید هر دو تب خارج شوند.
|
||||
const latest = storage.get();
|
||||
if (latest?.refreshToken && latest.refreshToken !== current.refreshToken) return latest;
|
||||
return 'invalid';
|
||||
}
|
||||
if (!response.ok) return errorFrom(response, await parseBody(response));
|
||||
const data = (await response.json()) as Tokens;
|
||||
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
|
||||
|
||||
@@ -22,6 +22,9 @@ function saveTokens(data: T.AuthResponse) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** یک بخشِ مسیر؛ شناسهای که از آدرسِ صفحه یا سرور میآید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
|
||||
const seg = (value: string | number) => encodeURIComponent(String(value));
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
isSignedIn: () => Boolean(http.storage.get()?.token),
|
||||
@@ -85,37 +88,37 @@ export const api = {
|
||||
|
||||
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||||
home: () => http.get<T.HomeResponse>('/home'),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
|
||||
|
||||
/**
|
||||
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیشفرض ۴). ترتیب برای هر
|
||||
* کاربر ثابت است، پس رفرش همان آزمون را میدهد.
|
||||
*/
|
||||
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${nodeId}/quiz`),
|
||||
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/${nodeId}/quiz/complete`, result, { idempotencyKey }),
|
||||
http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
|
||||
|
||||
lesson: {
|
||||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`),
|
||||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${seg(lessonId)}/intro`),
|
||||
questions: (lessonId: number) =>
|
||||
http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions),
|
||||
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/${questionId}/answer`,
|
||||
`/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/${lessonId}/complete`, result, { idempotencyKey }),
|
||||
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/${questionId}/report`,
|
||||
`/questions/${seg(questionId)}/report`,
|
||||
{ reason, ...(note ? { note } : {}) },
|
||||
),
|
||||
},
|
||||
@@ -157,19 +160,19 @@ export const api = {
|
||||
return done;
|
||||
},
|
||||
feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
|
||||
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, {
|
||||
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/${conversationId}/messages`,
|
||||
`/ai/conversations/${seg(conversationId)}/messages`,
|
||||
),
|
||||
},
|
||||
|
||||
notifications: {
|
||||
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
|
||||
markRead: (id: number) => http.patch(`/notifications/${id}/read`),
|
||||
markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`),
|
||||
markAllRead: () => http.patch('/notifications/read-all'),
|
||||
},
|
||||
|
||||
@@ -190,7 +193,7 @@ export const api = {
|
||||
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
|
||||
redeem: (code: string, orderId: string) =>
|
||||
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
|
||||
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`),
|
||||
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${seg(orderId)}`),
|
||||
/** پرداختها و فاکتورهای همین کاربر، تازهترین اول. */
|
||||
invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices),
|
||||
},
|
||||
@@ -200,9 +203,9 @@ export const api = {
|
||||
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/${ticketId}`),
|
||||
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${seg(ticketId)}`),
|
||||
reply: (ticketId: string, text: string) =>
|
||||
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }),
|
||||
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 }),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user