diff --git a/accounts/serializers.py b/accounts/serializers.py index 81d2e43..fbe4ef9 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -21,6 +21,27 @@ class SetGradeRequestSerializer(serializers.Serializer): gradeId = serializers.IntegerField() +class UpdateProfileRequestSerializer(serializers.Serializer): + """ + ورودی PATCH /api/user/profile — همه‌ی فیلدها اختیاری‌اند و فقط همان‌هایی + که فرستاده شوند تغییر می‌کنند (partial update واقعی). + + `phone` و `isGuest` عمداً اینجا نیستند: شماره فقط از مسیرِ OTP عوض + می‌شود و مهمان‌بودن را کاربر نباید بتواند خودش تغییر دهد. + """ + name = serializers.CharField(max_length=100, required=False, allow_blank=True) + avatar = serializers.CharField(max_length=100, required=False, allow_blank=True) + birthYear = serializers.IntegerField(required=False, allow_null=True, + min_value=1300, max_value=1500) + parentPhone = serializers.CharField(max_length=15, required=False, allow_blank=True) + bio = serializers.CharField(max_length=200, required=False, allow_blank=True) + + def validate(self, attrs): + if not attrs: + raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.') + return attrs + + class RequestOTPRequestSerializer(serializers.Serializer): """اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تست‌پذیری Swagger اضافه شد).""" phone = serializers.CharField(max_length=15) diff --git a/accounts/views.py b/accounts/views.py index a2daf81..4e6c763 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -28,6 +28,7 @@ from .serializers import ( RequestOTPRequestSerializer, RequestOTPResponseSerializer, SetGradeRequestSerializer, + UpdateProfileRequestSerializer, SetGradeResponseSerializer, ) @@ -379,9 +380,25 @@ class SetUserGradeView(APIView): class UserProfileView(APIView): - """GET /api/user/profile — اطلاعات پروفایل کاربر جاری (نیاز به احراز هویت)""" + """ + GET /api/user/profile — اطلاعات پروفایل کاربر جاری + PATCH /api/user/profile — ویرایشِ همان اطلاعات (نیاز به احراز هویت) + + PATCH برای صفحه‌ی «بیا باهم آشنا بشیم» در اپ لازم است: آنجا اسم (و بعداً + آواتار) گرفته می‌شود و تا پیش از این هیچ مسیری برای نوشتنشان وجود نداشت — + فقط پایه‌ی تحصیلی endpoint اختصاصیِ خودش را داشت. + """ permission_classes = [IsAuthenticated] + # نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد. + throttle_scope = 'user_profile_write' + throttle_classes = [GuestAwareAccountThrottle] + + def get_throttles(self): + # GET خواندنی است و نباید سهمیه‌ی نوشتن را مصرف کند. + if self.request.method == 'GET': + return [] + return super().get_throttles() @extend_schema( responses=ProfileResponseSerializer, @@ -407,3 +424,49 @@ class UserProfileView(APIView): 'bio': profile.bio, } return Response(ProfileResponseSerializer(data).data) + + @extend_schema( + request=UpdateProfileRequestSerializer, + responses=ProfileResponseSerializer, + tags=['Identity & Profile (accounts)'], + ) + def patch(self, request): + serializer = UpdateProfileRequestSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + + user = request.user + profile, _ = Profile.objects.get_or_create(user_id=user.id) + + user_fields = {'name': 'name', 'avatar': 'avatar'} + profile_fields = {'birthYear': 'birth_year', 'parentPhone': 'parent_phone', 'bio': 'bio'} + + changed_user = [attr for key, attr in user_fields.items() if key in v] + changed_profile = [attr for key, attr in profile_fields.items() if key in v] + + for key, attr in user_fields.items(): + if key in v: + setattr(user, attr, v[key]) + for key, attr in profile_fields.items(): + if key in v: + setattr(profile, attr, v[key]) + + # update_fields: فقط همان ستون‌هایی که واقعاً عوض شده‌اند نوشته شوند تا + # PATCH همزمان از یک دستگاهِ دیگر، فیلدهای دست‌نخورده را بازنویسی نکند. + if changed_user: + user.save(update_fields=changed_user) + if changed_profile: + profile.save(update_fields=changed_profile) + + data = { + 'userId': str(user.id), + 'phone': user.phone, + 'name': user.name, + 'avatar': user.avatar, + 'isGuest': user.is_guest, + 'gradeId': user.grade_id, + 'birthYear': profile.birth_year, + 'parentPhone': profile.parent_phone, + 'bio': profile.bio, + } + return Response(ProfileResponseSerializer(data).data) diff --git a/config/settings.py b/config/settings.py index f407976..479d366 100644 --- a/config/settings.py +++ b/config/settings.py @@ -53,6 +53,33 @@ ALLOWED_HOSTS = [h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',' CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o] +# --- CORS ------------------------------------------------------------------- +# فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر +# هر درخواستِ /api/ را قبل از رسیدن به Django رد می‌کند. +# +# allowlist صریح است، نه CORS_ALLOW_ALL_ORIGINS: احرازِ هویتِ ما توکنِ Bearer در +# هدر است، پس origin ناشناس نباید بتواند از مرورگرِ کاربر به API ما بزند. +# CORS_ALLOW_CREDENTIALS هم عمداً خاموش می‌ماند — کوکی‌ای در کار نیست و روشن +# کردنش فقط سطحِ حمله اضافه می‌کند. +CORS_ALLOWED_ORIGINS = [ + o for o in os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',') if o +] +if DEBUG and not CORS_ALLOWED_ORIGINS: + # پورت‌های پیش‌فرضِ Vite/CRA برای توسعه‌ی محلی. + CORS_ALLOWED_ORIGINS = [ + 'http://localhost:3000', 'http://127.0.0.1:3000', + 'http://localhost:5173', 'http://127.0.0.1:5173', + ] +CORS_ALLOW_CREDENTIALS = False +# فقط مسیرهای API؛ /admin/ و فایل‌های استاتیک لازم نیست از origin دیگر خوانده شوند. +CORS_URLS_REGEX = r'^/api/.*$' +# Idempotency-Key و X-Internal-Key هدرهای خودمان‌اند و در فهرستِ پیش‌فرض نیستند. +CORS_ALLOW_HEADERS = ( + 'accept', 'accept-encoding', 'authorization', 'content-type', 'dnt', + 'origin', 'user-agent', 'x-csrftoken', 'x-requested-with', + 'idempotency-key', 'x-internal-key', +) + # Application definition @@ -67,6 +94,7 @@ INSTALLED_APPS = [ # 3rd party 'rest_framework', 'drf_spectacular', + 'corsheaders', # Tadrisino apps (فاز اول - ۴ از ۱۱ اپ) 'accounts', @@ -104,6 +132,10 @@ INSTALLED_APPS = [ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + # ⚠️ CorsMiddleware باید تا حدِ ممکن بالا و حتماً قبل از CommonMiddleware + # باشد، وگرنه پاسخ‌هایی که زودتر برمی‌گردند (ریدایرکتِ APPEND_SLASH، خطای + # ۴۰۰) هدرِ CORS نمی‌گیرند و در مرورگر به‌شکلِ خطای مبهمِ شبکه دیده می‌شوند. + 'corsheaders.middleware.CorsMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', diff --git a/content/serializers.py b/content/serializers.py index c299f63..ad15934 100644 --- a/content/serializers.py +++ b/content/serializers.py @@ -51,6 +51,11 @@ class LessonIntroResponseSerializer(serializers.Serializer): title = serializers.CharField() introText = serializers.CharField() mascotAnimation = serializers.CharField() + # تعدادِ سوال‌های آماده‌ی این درس. صفر یعنی محتوای آموزشی هست ولی آزمونش + # هنوز ساخته نشده؛ اپ باید «به‌زودی» نشان دهد نه یک کویزِ خالی. بدونِ این + # فیلد فرانت مجبور بود اول questions را بگیرد تا بفهمد چیزی هست یا نه، و + # `complete` هم totalQuestions=0 را رد می‌کند (درست است، ولی گیج‌کننده). + questionCount = serializers.IntegerField() # ---- Lesson questions ---- diff --git a/content/views.py b/content/views.py index fa227a0..5ee7493 100644 --- a/content/views.py +++ b/content/views.py @@ -153,17 +153,31 @@ class HomeView(APIView): tags=['Curriculum (content)'], ) def get(self, request): - grade_id = request.query_params.get('grade') user_id = request.user.id + # ⚠️ پیش‌تر فقط پارامترِ صریحِ ?grade= خوانده می‌شد و پایه‌ی ذخیره‌شده‌ی + # خودِ کاربر نادیده می‌ماند؛ نتیجه این بود که اپ در «آشنایی» پایه را ثبت + # می‌کرد و بعد صفحه‌ی خانه خالی برمی‌گشت. حالا پارامتر اولویت دارد (برای + # مرورِ پایه‌ی دیگر) و در نبودش پایه‌ی خودِ کاربر ملاک است. + grade_param = request.query_params.get('grade') + if grade_param: + try: + grade_id = int(grade_param) + except (TypeError, ValueError): + raise Http404 + else: + grade_id = request.user.grade_id + tree = [] if grade_id: - try: - tree = _grade_tree(int(grade_id)) - except (TypeError, ValueError): - raise Http404 + tree = _grade_tree(grade_id) if tree is None: - raise Http404 + # پایه‌ای که کاربر ذخیره کرده ممکن است بعداً حذف شده باشد؛ آن + # حالت خطا نیست، فقط نقشه‌ی خالی است. ولی ?grade=ی که کاربر + # صریحاً خواسته و وجود ندارد، ۴۰۴ است. + if grade_param: + raise Http404 + tree = [] # همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری، # مستقل از تعدادِ node (رفعِ N+1). @@ -214,6 +228,7 @@ class LessonIntroView(APIView): 'title': lesson.title, 'introText': lesson.intro_text, 'mascotAnimation': lesson.mascot_animation, + 'questionCount': lesson.questions.count(), } return Response(LessonIntroResponseSerializer(data).data) diff --git a/docs/frontend/README.md b/docs/frontend/README.md new file mode 100644 index 0000000..270c8db --- /dev/null +++ b/docs/frontend/README.md @@ -0,0 +1,111 @@ +# راهنمای اتصالِ فرانت‌اند به این بک‌اند + +این پوشه برای تیمِ فرانت (`MathApp_frontend`) است. `api.ts` یک کلاینتِ +تایپ‌دار است که از روی OpenAPI واقعیِ همین سرور نوشته شده — نامِ فیلدها حدس +نیست. + +```bash +cp docs/frontend/api.ts <پروژه‌ی-فرانت>/src/lib/api.ts +``` + +```ts +import { createClient } from './lib/api'; +export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL ?? '/api' }); +``` + +## نگاشتِ صفحاتِ فعلیِ فرانت به API + +فرانت الان (`src/app/App.tsx`) خروجیِ Figma Make است و هیچ فراخوانیِ APIای +ندارد؛ همه‌ی داده‌ها هاردکد است. این جدول می‌گوید هر صفحه به چه چیزی وصل شود. + +| صفحه‌ی فرانت | فراخوانی | نکته | +|---|---|---| +| `SplashScreen` | `api.auth.isSignedIn()` | «قبلاً حساب دارم» → صفحه‌ی ورود | +| — (**وجود ندارد، باید ساخته شود**) | `auth.requestOtp` → `auth.login` | پایین ببینید | +| `OnboardingScreen` | — | ایستا | +| `ProfileSetupScreen` | `profile.update({name})` + `profile.setGrade(id)` | `GRADES` هاردکد را با `api.grades()` عوض کنید | +| `HomeScreen` | `api.home()` + `api.summary()` | بدونِ آرگومان، پایه‌ی خودِ کاربر ملاک است | +| `SkillMapScreen` | `api.home()` → `units[].nodes[]` | `status` = `locked / unlocked / in_progress / completed` | +| `LessonScreen` | `lesson.intro(id)` + `lesson.questions(id)` | پایین ببینید | +| `QuestionScreen` | `lesson.answer(qid, optId)` → `lesson.complete(id)` | `mascotMood` حالتِ رُبو را می‌دهد | +| `AskAIScreen` | `ai.askStream({userQuestion, lessonId}, onDelta)` | فقط `lessonId` کافی‌ست | +| `SubscriptionScreen` | `subscription.plans()` + `subscription.purchase(planId)` | قیمت‌های هاردکد را بردارید | + +## چهار چیزی که الان با فرانت جور نیست + +**۱. صفحه‌ی ورود وجود ندارد.** فرانت فقط اسم و پایه می‌پرسد، ولی احرازِ هویتِ +ما OTP با شماره‌ی موبایل است. دو راه: + +- **مهمان** (سریع‌ترین): `api.auth.guest(deviceId)` — بدونِ شماره، کاربر + بلافاصله وارد می‌شود و بعداً می‌تواند شماره‌اش را وصل کند. +- **کامل**: یک صفحه‌ی «شماره + کد» بین Splash و ProfileSetup. + +در حالتِ توسعه (`DJANGO_DEBUG=1`) پاسخِ `requestOtp` فیلدِ `devOtp` دارد، پس +برای تست لازم نیست پیامک واقعی برسد. + +**۲. فهرستِ پایه‌ها تا ششم است.** `GRADES` در `App.tsx` هاردکدِ ۱ تا ۶ است، ولی +جهتِ محصول از ششم به بالا رفته (ترانزیشنی: ۶، ۷، ۹، ۱۰). از `api.grades()` +بخوانید تا هر پایه‌ای که اضافه شد خودبه‌خود بیاید. + +**۳. صفحه‌ی درس، ویدیو دارد.** `LessonScreen` فهرستی از +`{type: "video" | "quiz"}` نشان می‌دهد. بک‌اند هنوز مفهومِ ویدیو ندارد — +`lessons/{id}/intro` فقط متن و حالتِ ماسکوت می‌دهد. فعلاً فقط `quiz` را نشان +دهید؛ اگر ویدیو واقعاً لازم است بگویید تا در مدل اضافه کنیم. + +**۴. «سطح» در آمار.** `HomeScreen` سه عدد نشان می‌دهد: روز پیاپی، امتیاز، سطح. +دوتای اول را داریم (`streak.current`، `xp`)؛ «سطح» را نداریم. یا از روی `xp` +سمتِ فرانت حساب کنید، یا بگویید تا در `/api/user/summary` اضافه کنیم. + +## چیزهایی که سمتِ بک‌اند برایتان درست شد + +- **CORS** اضافه شد. origin خود را در `CORS_ALLOWED_ORIGINS` بدهید (در حالتِ + توسعه پورت‌های ۳۰۰۰ و ۵۱۷۳ پیش‌فرض بازند). +- **`PATCH /api/user/profile`** اضافه شد — قبلاً پروفایل فقط خواندنی بود و + هیچ راهی برای ثبتِ اسم نبود. +- **`GET /api/home` بدونِ پارامتر** حالا پایه‌ی ذخیره‌شده‌ی کاربر را می‌خواند. + قبلاً اگر `?grade=` نمی‌فرستادید نقشه خالی برمی‌گشت. +- **`POST /api/ai/ask` با فقط `lessonId`** حالا خودش می‌فهمد آن درس مالِ کدام + پایه و فصلِ کتاب است. قبلاً باید `subject` + `grade` + `lessonNumber` را + خودتان می‌فرستادید وگرنه پاسخ بدونِ استناد به کتاب تولید می‌شد. + +## چند قاعده که وقت‌تان را می‌خرد + +**`Idempotency-Key` بفرستید** روی هر چیزی که پول/امتیاز/قلب جابه‌جا می‌کند +(`answer`, `complete`, `purchase`). یک UUID که تا موفق شدنِ درخواست ثابت +بماند. بدونِ آن، قطعیِ شبکه + دکمه‌ی دوباره = دوبار کم شدنِ قلب. + +**۴۰۱ یعنی توکن منقضی شده، نه اینکه کاربر بیرون است.** کلاینت خودش یک‌بار +`refresh` می‌زند و درخواست را تکرار می‌کند؛ فقط اگر آن هم شکست خورد کاربر را +به صفحه‌ی ورود ببرید. + +**۴۲۹ و ۵۰۳ را جدا نشان دهید.** ۴۲۹ یعنی سقفِ روزانه‌ی سوال از دستیار پر شده +(الان ۱۵ تا) یا throttle؛ پیامش «فردا برگرد» است. ۵۰۳ یعنی سرویسِ AI موقتاً +قطع است؛ پیامش «چند لحظه دیگر دوباره». `ApiError.isRateLimited` و +`.isProviderDown` همین را می‌دهند. + +**پرداخت را در مرورگر باز کنید.** `purchase` یک `paymentUrl` می‌دهد که به +سرورِ خودمان اشاره می‌کند، نه به زرین‌پال. اپ نباید مستقیم با درگاه حرف بزند. + +**امتیاز موقعِ اتمامِ درس در مجموع می‌نشیند، نه سرِ هر سوال.** پاسخِ +`answer` فیلدِ `xpEarned` دارد ولی آن ارزشِ همان سوال است؛ `user/summary` +تا وقتی `complete` صدا نخورد تکان نمی‌خورد. در UI می‌توانید خوش‌بینانه +جمع بزنید، ولی عددِ معتبر بعد از `complete` می‌آید. + +**`complete` دو فیلدِ اجباری دارد:** `correctCount` و `totalQuestions`. +سرور `correctCount` را با تعدادِ واقعیِ سوال‌های آن درس سقف می‌زند، پس +عددِ متورم امتیاز نمی‌سازد. + +**`usedChunks` خالی یعنی پاسخ به کتاب مستند نیست.** اگر خواستید، در UI +تفکیکش کنید («بر اساسِ کتابِ درسی» در برابر «پاسخِ عمومی»). + +## اجرای محلی + +```bash +# بک‌اند +DJANGO_DEBUG=1 python manage.py runserver # 127.0.0.1:8000 + +# فرانت +VITE_API_URL=http://127.0.0.1:8000/api npm run dev +``` + +مستنداتِ زنده‌ی همه‌ی endpointها: `http://127.0.0.1:8000/api/docs/` diff --git a/docs/frontend/api.ts b/docs/frontend/api.ts new file mode 100644 index 0000000..15bbfca --- /dev/null +++ b/docs/frontend/api.ts @@ -0,0 +1,442 @@ +/** + * کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend). + * + * از روی OpenAPI واقعیِ همین بک‌اند نوشته شده، نه از روی حدس. نامِ فیلدها + * دقیقاً همان چیزی‌ست که سرور برمی‌گرداند (camelCase). + * + * نکته‌های مهمِ اتصال: + * - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم + * نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده. + * - توکنِ دسترسی امضاشده و کوتاه‌عمر است؛ با ۴۰۱ باید `refresh` صدا زد. + * این کلاینت خودش یک‌بار refresh می‌کند و درخواست را تکرار. + * - برای پرسش از دستیار کافی‌ست `lessonId` بدهید؛ سرور خودش می‌فهمد آن درس + * مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند می‌کند. + */ + +export type Json = Record; + +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( + method: string, + path: string, + body?: unknown, + extraHeaders: Record = {}, + retryOn401 = true, + ): Promise { + const tokens = storage.get(); + const headers: Record = { ...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(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('GET', '/user/profile'), + /** اسم/آواتار/… . فقط فیلدهایی که می‌فرستید عوض می‌شوند. */ + update: (patch: Partial>) => + raw('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('GET', gradeId ? `/home?grade=${gradeId}` : '/home'), + summary: () => raw('GET', '/user/summary'), + hearts: () => raw('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('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( + 'POST', `/questions/${questionId}/answer`, { selectedOptionId }, + idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, + ), + /** + * پایانِ درس. `correctCount` را سرور با تعدادِ واقعیِ سوال‌ها سقف می‌زند. + * مجموعِ XPِ کاربر **اینجا** به‌روز می‌شود، نه سرِ هر پاسخ. + */ + complete: ( + lessonId: number, + result: { correctCount: number; totalQuestions: number }, + idempotencyKey?: string, + ) => + raw( + '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( + '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('POST', `/notifications/${id}/read`), + }, + }; +} + +export type TadrisinoClient = ReturnType; diff --git a/requirements.txt b/requirements.txt index 121c6db..8ab8c80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ Django==5.2.17 djangorestframework==3.18.0 drf-spectacular==0.30.0 +django-cors-headers==4.9.0 requests==2.33.1 redis==5.2.1 python-dotenv==1.0.1