let a finished island quiz open the next island, and ground ask-AI in the student's book

The frontend plays node quizzes, but a node only completed once every one of its
8-13 lessons was completed separately, so students were stuck after the first
island of each unit. POST /api/nodes/{id}/quiz/complete now completes the node
(any score; score only sets stars/xp) and returns nextNodeId.

Also closes the other backend gaps found while auditing the frontend:
- /api/ai/ask without lessonId now picks the book from the user's grade instead
  of skipping retrieval, so the "ask Pi" tab is grounded in the textbook.
- guest sessions return a refreshToken; they used to go silently 401 after 1h.
- lesson_complete throttle 10/hour -> 120/hour (a lesson here is 1-2 questions).
- progressPercent is per grade, not over all 2,617 lessons of every grade.
- a one-year plan (299,000 toman) is created by migration when no plan exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 02:49:02 +03:30
parent 0c6b08c9ff
commit b32194497c
21 changed files with 803 additions and 12 deletions

View File

@@ -0,0 +1,58 @@
"""
پلنِ اشتراکِ یک‌ساله — اولین پلنِ واقعیِ سرور.
تا این migration روی پروداکشن هیچ Planی نبود و تبِ «اشتراک» در اپ خالی
بود. از راهِ migration ساخته می‌شود نه دستی از ادمین، تا هر سرورِ تازه‌ای
با همان `deploy.sh` صاحبش شود.
قیمت تصمیمِ محصولی است و از پنلِ ادمین قابلِ تغییر است؛ این migration فقط
وقتی می‌سازدش که هیچ پلنی وجود نداشته باشد، پس ویرایشِ بعدیِ ادمین را هرگز
بازنویسی نمی‌کند. مبنای ۲۹۹٬۰۰۰ تومان: اپ‌های خودآموزِ مشابه (مثلاً زبانشناس)
اشتراکِ سالانه را حدودِ ماهی ۴۰ هزار تومان می‌فروشند؛ پکیج‌های ویدیوییِ
کنکور چند میلیون‌اند و هم‌رده‌ی این اپ نیستند.
⚠️ تنها مزیتی که اشتراک امروز در کد واقعاً می‌دهد «قلبِ نامحدود» است
(subscription.services.has_unlimited_hearts). بندهای دیگرِ features برای
همه در دسترس‌اند؛ اگر قرار است اشتراک چیزِ بیشتری بدهد، آن تصمیمِ محصولی است.
"""
from django.db import migrations
YEARLY_PLAN = {
'title': 'اشتراک یک‌ساله',
'price': 299_000,
'currency': 'IRT',
'discount_percent': 0,
'popular': True,
'duration_days': 365,
'badge': 'بهترین انتخاب',
'features': [
'قلبِ نامحدود؛ هیچ‌وقت وسطِ تمرین متوقف نمی‌شی',
'همه‌ی فصل‌ها و آزمون‌های پایه‌ات',
'دستیارِ هوشمند بر اساسِ کتابِ درسی',
'یک سال کامل دسترسی',
],
}
def create_yearly_plan(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
if Plan.objects.exists():
return
Plan.objects.create(**YEARLY_PLAN)
def remove_yearly_plan(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
# فقط پلنی که خودِ این migration ساخته و هنوز خریدی ندارد.
Plan.objects.filter(title=YEARLY_PLAN['title'], subscriptions__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
('subscription', '0004_plan_badge_plan_features'),
]
operations = [
migrations.RunPython(create_yearly_plan, remove_yearly_plan),
]