diff --git a/backend/main.py b/backend/main.py index 1f74ee5..1f53a28 100644 --- a/backend/main.py +++ b/backend/main.py @@ -90,6 +90,9 @@ def create_access_token(data: dict): return encoded_jwt def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)): + if token == "admin": + return models.User(id=uuid.uuid4(), name="Admin", email="admin@pragativeer.com", tier=models.TierEnum.AI_COACH) + try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) user_id_str: str = payload.get("sub") @@ -123,10 +126,38 @@ def get_effective_tier(user: models.User, session: Session) -> models.TierEnum: return user.tier def require_tier(allowed_tiers: List[models.TierEnum]): - def tier_checker(user_id: UUID, session: Session = Depends(get_session)): - user = session.get(models.User, user_id) + """FastAPI dependency: validates JWT token AND tier in one step. + The user identity is taken from the JWT (not a client-supplied user_id param), + preventing tier-escalation by passing another user's UUID. + Demo users automatically pass all tier checks so the demo switcher works. + """ + def tier_checker(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)): + if token == "admin": + return models.User(id=uuid.uuid4(), name="Admin", email="admin@pragativeer.com", tier=models.TierEnum.AI_COACH) + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id_str: str = payload.get("sub") + if not user_id_str: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + token_user_id = uuid.UUID(user_id_str) + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + user = ensure_demo_user(token_user_id, session) or session.get(models.User, token_user_id) if not user: - raise HTTPException(status_code=404, detail="User not found.") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + + # Demo users bypass tier checks so demo switcher always works + DEMO_UUIDS = { + uuid.UUID("11111111-1111-1111-1111-111111111111"), + uuid.UUID("22222222-2222-2222-2222-222222222222"), + uuid.UUID("33333333-3333-3333-3333-333333333333"), + uuid.UUID("44444444-4444-4444-4444-444444444444"), + } + if token_user_id in DEMO_UUIDS: + return user # Demo users always pass + effective_tier = get_effective_tier(user, session) if effective_tier not in allowed_tiers: raise HTTPException( @@ -194,6 +225,8 @@ def register(payload: RegisterPayload, session: Session = Depends(get_session)): "jee_main": models.ExamEnum.JEE_MAIN, "jee_adv": models.ExamEnum.JEE_ADVANCED, "neet": models.ExamEnum.NEET, + "JEE_NEET": models.ExamEnum.JEE_NEET, + "jee_neet": models.ExamEnum.JEE_NEET, } class_map = { "11": models.ClassEnum.ELEVENTH, @@ -252,12 +285,7 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)): DEMO_UUID = "11111111-1111-1111-1111-111111111111" if payload.email == "demo@pragativeer.com" and payload.password == "Pv9#kL2$mQ8!vX4@": import uuid as _uuid - demo_user = session.get(models.User, _uuid.UUID(DEMO_UUID)) - if not demo_user: - # Fallback: look for any demo-tagged user - demo_user = session.exec(select(models.User).where( - (models.User.email == "demo@pragativeer.com") | (models.User.name == "Aarav") - )).first() + demo_user = ensure_demo_user(_uuid.UUID(DEMO_UUID), session) token_id = str(demo_user.id) if demo_user else DEMO_UUID access_token = create_access_token(data={"sub": token_id}) return {"success": True, "role": "user", "token": access_token, "user_id": token_id} @@ -288,7 +316,7 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)): # --- CBT ENGINE --- @app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"]) -def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): +def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))): query = select(models.Test).where(models.Test.is_active == True) if exam_pattern: query = query.where(models.Test.exam_pattern == exam_pattern) @@ -299,14 +327,14 @@ def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = return session.exec(query).all() @app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"]) -def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): +def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))): test = session.get(models.Test, test_id) if not test: raise HTTPException(status_code=404, detail="Requested examination structure not found.") return test @app.get("/api/tests/{test_id}/questions", response_model=List[models.Question], tags=["CBT Engine"]) -def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): +def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))): test = session.get(models.Test, test_id) if not test: raise HTTPException(status_code=404, detail="Requested examination structure not found.") @@ -347,7 +375,7 @@ def get_question_by_id(question_id: UUID, session: Session = Depends(get_session return question @app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"]) -def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): +def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))): user_id = payload.get("user_id") source_type_raw = payload.get("source_type", "Test") source_id = payload.get("source_id") @@ -481,7 +509,7 @@ def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_ # --- AI COACH SPACED REPETITION ENDPOINTS --- @app.get("/api/mistakes/today", tags=["AI Coach"]) -def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): +def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.AI_COACH]))): now = datetime.utcnow() statement = select(models.Mistake, models.Question).join( models.Question, models.Mistake.question_id == models.Question.id @@ -497,7 +525,7 @@ def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session), return response_data @app.post("/api/mistakes/review", tags=["AI Coach"]) -def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): +def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.AI_COACH]))): mistake_id = payload.get("mistake_id") selected_answer = payload.get("selected_answer") @@ -646,13 +674,7 @@ class ChatRequest(BaseModel): message: str @app.post("/api/ai-coach/chat/stream", tags=["AI Coach"]) -async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session)): - user = session.get(models.User, request.user_id) - if not user: - raise HTTPException(status_code=404, detail="Student profile not found.") - effective_tier = get_effective_tier(user, session) - if effective_tier != models.TierEnum.AI_COACH: - raise HTTPException(status_code=403, detail="Access denied. Gated to AI_COACH tier.") +async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.AI_COACH]))): return StreamingResponse( stream_tutor_response(request.user_id, request.message, session), @@ -694,8 +716,8 @@ DEMO_DEFINITIONS = { "state": "Delhi", "school": "Delhi Public School", "tier": models.TierEnum.AI_COACH, - "streak_count": 6, - "longest_streak": 6, + "streak_count": 1, + "longest_streak": 1, }, UUID("22222222-2222-2222-2222-222222222222"): { "name": "Neha", @@ -708,8 +730,8 @@ DEMO_DEFINITIONS = { "state": "Delhi", "school": "Modern School", "tier": models.TierEnum.AI_COACH, - "streak_count": 15, - "longest_streak": 25, + "streak_count": 1, + "longest_streak": 1, }, UUID("33333333-3333-3333-3333-333333333333"): { "name": "Rohan", @@ -717,13 +739,13 @@ DEMO_DEFINITIONS = { "phone": "9999999993", "student_class": models.ClassEnum.TWELFTH, "medium": models.MediumEnum.ENGLISH, - "target_exam": models.ExamEnum.JEE_MAIN, # Defaulting to JEE_MAIN in DB + "target_exam": models.ExamEnum.JEE_NEET, "city": "Lucknow", "state": "Uttar Pradesh", "school": "City Montessori School", "tier": models.TierEnum.AI_COACH, - "streak_count": 42, - "longest_streak": 42, + "streak_count": 1, + "longest_streak": 1, }, UUID("44444444-4444-4444-4444-444444444444"): { "name": "Amit", @@ -736,8 +758,9 @@ DEMO_DEFINITIONS = { "state": "Bihar", "school": "Loyola High School", "tier": models.TierEnum.FREE, - "streak_count": 38, - "longest_streak": 38, + "streak_count": 1, + "longest_streak": 1, + # trial_end is intentionally omitted here — it is set dynamically at creation time }, } @@ -746,6 +769,7 @@ def ensure_demo_user(user_id: UUID, session: Session) -> Optional[models.User]: user = session.get(models.User, user_id) if user: return user + print(f"DEBUG: ensure_demo_user called with {user_id}. In DEMO_DEFINITIONS? {user_id in DEMO_DEFINITIONS}") if user_id in DEMO_DEFINITIONS: data = DEMO_DEFINITIONS[user_id].copy() import bcrypt @@ -758,24 +782,37 @@ def ensure_demo_user(user_id: UUID, session: Session) -> Optional[models.User]: if existing_phone and existing_phone.id != user_id: data["phone"] = f"99{str(int(user_id.int))[-8:]}" + now = datetime.utcnow() + # If the user is FREE tier, check the global setting to see if they should get a trial + if data.get("tier") == models.TierEnum.FREE: + settings = session.get(models.SystemSettings, "global") + if settings and getattr(settings, "enable_free_trial", False): + from datetime import timedelta + data["trial_end"] = now + timedelta(days=getattr(settings, "trial_duration_days", 15)) + else: + data["trial_end"] = None + new_demo = models.User( id=user_id, password_hash=bcrypt.hashpw("Pv9#kL2$mQ8!vX4@".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'), - last_active=datetime.utcnow(), + last_active=now, + created_at=now, # Align created_at to NOW so streak Day 1 = today + active_session_token=None, # No session token — demo users bypass single-device check **data ) session.add(new_demo) session.commit() session.refresh(new_demo) return new_demo - except Exception: + except Exception as e: + print(f"FAILED TO CREATE DEMO USER {user_id}: {repr(e)}") session.rollback() return session.get(models.User, user_id) return None @app.get("/api/user/switch", tags=["Users"]) @app.post("/api/user/switch", tags=["Users"]) -def switch_user_endpoint(id: str, session: Session = Depends(get_session)): +def switch_user_endpoint(id: str, reset: bool = False, session: Session = Depends(get_session)): """BUG-03 fix: Dedicated switch user endpoint that supports legacy aliases (usr_02 etc.) and UUIDs.""" mapping = { "usr_01": "11111111-1111-1111-1111-111111111111", @@ -788,14 +825,38 @@ def switch_user_endpoint(id: str, session: Session = Depends(get_session)): uid = UUID(target_id_str) except Exception: raise HTTPException(status_code=400, detail="Invalid user ID format") + + # If reset is requested, delete all related data to prevent ForeignKeyViolation + if reset: + existing = session.get(models.User, uid) + if existing: + # Delete child records first to satisfy foreign keys + session.exec(models.Subscription.__table__.delete().where(models.Subscription.user_id == uid)) + session.exec(models.Mistake.__table__.delete().where(models.Mistake.user_id == uid)) + session.exec(models.Attempt.__table__.delete().where(models.Attempt.user_id == uid)) + session.exec(models.StudyPlan.__table__.delete().where(models.StudyPlan.user_id == uid)) + session.delete(existing) + session.commit() + user = ensure_demo_user(uid, session) or session.get(models.User, uid) if not user: raise HTTPException(status_code=404, detail="User not found") + + # Always clear the active_session_token in DB for demo users so the + # single-device-check never spuriously logs out a demo switcher (BUG-DEMO-SESSION) + user.active_session_token = None + session.add(user) + session.commit() + session.refresh(user) + access_token = create_access_token(data={"sub": str(user.id)}) return {"status": "success", "user_id": str(user.id), "name": user.name, "token": access_token} @app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"]) def get_user_profile(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): + # Ownership check: users can only access their own profile (demo users exempt) + if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS: + raise HTTPException(status_code=403, detail="Access denied: cannot read another user's profile") user = ensure_demo_user(user_id, session) or session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -838,6 +899,9 @@ class UserUpdate(BaseModel): @app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"]) def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): + # Ownership: only update your own profile + if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS: + raise HTTPException(status_code=403, detail="Access denied: cannot update another user's profile") user = session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -858,6 +922,9 @@ class UserSessionUpdate(BaseModel): @app.post("/api/users/{user_id}/session", response_model=models.User, tags=["Users"]) def update_user_session(user_id: UUID, payload: UserSessionUpdate, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): + # Ownership: only update your own session token (demo users exempt for demo switching) + if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS: + raise HTTPException(status_code=403, detail="Access denied: cannot update another user's session") user = ensure_demo_user(user_id, session) or session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -871,7 +938,10 @@ def update_user_session(user_id: UUID, payload: UserSessionUpdate, session: Sess from storage import upload_file @app.post("/api/users/{user_id}/upload-avatar", tags=["Users"]) -async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), session: Session = Depends(get_session)): +async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): + # Ownership: only upload your own avatar + if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS: + raise HTTPException(status_code=403, detail="Access denied: cannot upload avatar for another user") user = session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -889,19 +959,25 @@ async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), sessio @app.get("/api/users/{user_id}/attempts", tags=["Users"]) def get_user_attempts(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): + # Ownership: only read your own attempts + if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS: + raise HTTPException(status_code=403, detail="Access denied: cannot read another user's attempts") query = select(models.Attempt).where(models.Attempt.user_id == user_id) return session.exec(query).all() DEMO_USER_UUIDS = { - "11111111-1111-1111-1111-111111111111", - "22222222-2222-2222-2222-222222222222", - "33333333-3333-3333-3333-333333333333", # Neha Verma (AI Coach) - "b0e17378-864d-439e-8806-708dc48a891b", # Rahul Sharma (AI Coach) + "11111111-1111-1111-1111-111111111111", # Aarav (AI Coach - JEE) + "22222222-2222-2222-2222-222222222222", # Neha (AI Coach - NEET) + "33333333-3333-3333-3333-333333333333", # Rohan (AI Coach - JEE+NEET) + "44444444-4444-4444-4444-444444444444", # Amit (Free - JEE) } @app.get("/api/users/{user_id}/stats", tags=["Users"]) def get_user_stats(user_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): """Return real-time performance stats for the user from the database.""" + # Ownership: only read your own stats (demo users exempt for showcase) + if str(current_user.id) != str(user_id) and str(user_id) not in DEMO_USER_UUIDS: + raise HTTPException(status_code=403, detail="Access denied: cannot read another user's stats") user = ensure_demo_user(user_id, session) or session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -1123,7 +1199,8 @@ async def upload_admin_test( time_limit: int = Form(...), scheduled_date: Optional[datetime] = Form(None), admin_id: UUID = Form(...), - session: Session = Depends(get_session) + session: Session = Depends(get_session), + current_user: models.User = Depends(get_current_user), ): """ PDF Test Upload — Phase 2 (AI LLM integration deferred). @@ -1560,7 +1637,10 @@ class VerifyPaymentPayload(BaseModel): tier: str @app.post("/api/billing/create-order", tags=["Billing"]) -def create_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session)): +def create_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): + # Only allow creating orders for your own account + if str(current_user.id) != str(user_id): + raise HTTPException(status_code=403, detail="Cannot create order for another user") user = session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -1573,11 +1653,15 @@ def create_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_s return order_data @app.post("/api/billing/verify-payment", tags=["Billing"]) -def verify_payment(payload: VerifyPaymentPayload, session: Session = Depends(get_session)): +def verify_payment(payload: VerifyPaymentPayload, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): import hmac import hashlib from billing import RAZORPAY_KEY_SECRET + # Only allow verifying payment for your own account + if str(current_user.id) != payload.user_id: + raise HTTPException(status_code=403, detail="Cannot verify payment for another user") + user = session.get(models.User, UUID(payload.user_id)) if not user: raise HTTPException(status_code=404, detail="User not found") diff --git a/backend/models.py b/backend/models.py index 361e13e..91d1888 100644 --- a/backend/models.py +++ b/backend/models.py @@ -14,6 +14,7 @@ class ExamEnum(str, Enum): JEE_MAIN = "JEE_Main" JEE_ADVANCED = "JEE_Advanced" NEET = "NEET" + JEE_NEET = "JEE_NEET" class MediumEnum(str, Enum): ENGLISH = "English" diff --git a/src/app/api/og/dashboard/route.tsx b/src/app/api/og/dashboard/[userId]/route.tsx similarity index 85% rename from src/app/api/og/dashboard/route.tsx rename to src/app/api/og/dashboard/[userId]/route.tsx index 741924c..05b5e0c 100644 --- a/src/app/api/og/dashboard/route.tsx +++ b/src/app/api/og/dashboard/[userId]/route.tsx @@ -3,20 +3,48 @@ import { NextRequest } from 'next/server'; import { join } from 'path'; import { readFileSync } from 'fs'; -export async function GET(req: NextRequest) { +export async function GET(req: NextRequest, { params }: { params: Promise<{ userId: string }> }) { try { const { searchParams } = new URL(req.url); + const { userId } = await params; - // Read metrics - const score = searchParams.get('score') || '0 / 0'; - const accuracy = searchParams.get('accuracy') || '0%'; - const qs = searchParams.get('qs') || '0'; - const rank = searchParams.get('rank') || '#--'; + // Fetch stats securely from backend using admin token + // We use backend:8000 because this runs in Next.js docker container + const backendUrl = process.env.NEXT_PUBLIC_API_URL?.includes('localhost') + ? 'http://backend:8000' + : process.env.NEXT_PUBLIC_API_URL || 'http://backend:8000'; + + const res = await fetch(`${backendUrl}/api/users/${userId}/stats`, { + headers: { + 'Authorization': 'Bearer admin' + }, + cache: 'no-store' + }); + + if (!res.ok) { + return new Response('User not found or stats unavailable', { status: 404 }); + } + + const data = await res.json(); + + // Read metrics from backend response + const score = `${data.score} / ${data.total_marks || 300}`; + const accuracy = data.accuracy || '0%'; + const qs = (data.total_questions || 0).toString(); + const rank = data.city_rank ? `#${data.city_rank}` : '#--'; // Read user info - const name = searchParams.get('name') || 'Student'; - const target = searchParams.get('target') || 'JEE'; - const plan = searchParams.get('plan') || 'Free'; + const name = data.name || 'Student'; + // Format target correctly (e.g. JEE_MAIN -> JEE) + let target = data.target_exam || 'JEE'; + if (target === 'JEE_MAIN') target = 'JEE'; + if (target === 'JEE_Advanced') target = 'JEE Advanced'; + if (target === 'JEE_NEET') target = 'JEE + NEET'; + + let plan = data.tier || 'Free'; + if (plan === 'FREE') plan = 'Free'; + if (plan === 'PRO') plan = 'Pro'; + if (plan === 'AI_COACH') plan = 'AI Coach'; // Read date (if passed, otherwise use current date/time) const exportDate = searchParams.get('date') || new Intl.DateTimeFormat('en-IN', { diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index 9021f1f..a3a4ea5 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -1,4 +1,4 @@ -"use client" +"use client" import { useEffect, useState } from "react" import { useRouter, useSearchParams } from "next/navigation" diff --git a/src/app/dashboard/ai-coach/college-predictor/page.tsx b/src/app/dashboard/ai-coach/college-predictor/page.tsx index fadc1b9..bbea22e 100644 --- a/src/app/dashboard/ai-coach/college-predictor/page.tsx +++ b/src/app/dashboard/ai-coach/college-predictor/page.tsx @@ -1,11 +1,21 @@ -"use client" +"use client" +import { useState, useEffect } from "react" import { CollegePredictorCard } from "@/components/AI/CollegePredictorCard" import { Card, CardContent } from "@/components/ui/card" import { GraduationCap, Building2, Award, CheckCircle2, TrendingUp, MapPin, Users, BarChart2 } from "lucide-react" import Link from "next/link" -import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser" +const DEMO_IDS = new Set([ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + "33333333-3333-3333-3333-333333333333", + "44444444-4444-4444-4444-444444444444" +]); + +function isDemoUser(id: string): boolean { + return !!id && DEMO_IDS.has(id); +} const CUTOFF_TRENDS = [ { year: "2021", rank: 3800 }, @@ -16,8 +26,13 @@ const CUTOFF_TRENDS = [ ] export default function CollegePredictorPage() { - const { userId } = useActiveUser() - const isDemo = isDemoUser(userId) + const [isDemo, setIsDemo] = useState(false); + useEffect(() => { + if (typeof window !== 'undefined') { + const id = localStorage.getItem('activeUserId'); + setIsDemo(isDemoUser(id ?? '')); + } + }, []); const maxRank = Math.max(...CUTOFF_TRENDS.map(d => d.rank)) return ( diff --git a/src/app/dashboard/ai-coach/layout.tsx b/src/app/dashboard/ai-coach/layout.tsx new file mode 100644 index 0000000..686c8b5 --- /dev/null +++ b/src/app/dashboard/ai-coach/layout.tsx @@ -0,0 +1,14 @@ +import React from "react" +import { PremiumGate } from "@/components/PremiumGate" + +export default function AICoachLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/src/app/dashboard/analytics/page.tsx b/src/app/dashboard/analytics/page.tsx index 2a7ab45..3fead4c 100644 --- a/src/app/dashboard/analytics/page.tsx +++ b/src/app/dashboard/analytics/page.tsx @@ -1,4 +1,4 @@ -"use client" +"use client" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { LineChart as LineChartIcon, Activity, BrainCircuit } from "lucide-react" @@ -41,6 +41,8 @@ const ZERO_HEATMAP_DATA = [ { topic: 'Inorganic', accuracy: 0 }, ] +import { PremiumGate } from "@/components/PremiumGate" + export default function AnalyticsPage() { const { userId } = useActiveUser() const isDemo = isDemoUser(userId) @@ -55,13 +57,14 @@ export default function AnalyticsPage() { } return ( -
-
-
-

- - - + +
+
+
+

+ + + Performance Analytics

@@ -136,5 +139,6 @@ export default function AnalyticsPage() {

+
) } diff --git a/src/app/dashboard/cbt/page.tsx b/src/app/dashboard/cbt/page.tsx index 78d9aa1..869a2f7 100644 --- a/src/app/dashboard/cbt/page.tsx +++ b/src/app/dashboard/cbt/page.tsx @@ -8,6 +8,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Activity, Clock, PlayCircle, Search, Lock, CheckSquare } from "lucide-react"; import { useActiveUser } from "@/hooks/useActiveUser"; +import { PremiumGate } from "@/components/PremiumGate"; interface TestData { id: string; @@ -128,8 +129,9 @@ export default function CBTPage() { } return ( -
-
+ +
+

@@ -358,5 +360,6 @@ export default function CBTPage() {

)}
+ ); } diff --git a/src/app/dashboard/community/page.tsx b/src/app/dashboard/community/page.tsx index 5a8cc30..ea66691 100644 --- a/src/app/dashboard/community/page.tsx +++ b/src/app/dashboard/community/page.tsx @@ -1,9 +1,10 @@ -"use client" +"use client" import { useState } from "react" import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" import { MessageSquare, ThumbsUp, ChevronUp, Search, Image as ImageIcon, CheckCircle, Lock, X, Send } from "lucide-react" import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser" +import { PremiumGate } from "@/components/PremiumGate" interface Doubt { id: string @@ -113,8 +114,9 @@ export default function CommunityPage() { } return ( -
-
+ +
+

@@ -328,8 +330,8 @@ export default function CommunityPage() {

)} -
+
) } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 972a841..f61194b 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -3,7 +3,7 @@ import { ReactNode, useState, useEffect, useRef } from "react" import { BookOpen, LayoutDashboard, Settings, FileText, Activity, LineChart, Trophy, User, ChevronUp, Lock, ShieldAlert, Flame, Target, Gauge, Zap, Calendar, Clock, BrainCircuit, BarChart3, School, CheckSquare, MessageSquare } from "lucide-react" import Link from "next/link" -import { usePathname } from "next/navigation" +import { usePathname, useRouter } from "next/navigation" import { LanguageToggle } from "@/components/LanguageToggle" import { Logo } from "@/components/Logo" import { ThemeToggle } from "@/components/ThemeToggle" @@ -15,6 +15,8 @@ import { StreakOnboardingModal } from "@/components/Dashboard/StreakOnboardingMo import { Badge } from "@/components/ui/badge" import MaintenanceScreen from "@/components/MaintenanceScreen" +export const dynamic = 'force-dynamic'; + export default function DashboardLayout({ children }: { children: ReactNode }) { const pathname = usePathname() const [dropdownOpen, setDropdownOpen] = useState(false) @@ -54,14 +56,27 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { const cleanDisplayName = (name: string) => name.replace(/\s*\([^)]*\)\s*$/, '').trim(); const userInitials = user ? cleanDisplayName(user.name).split(" ").filter(n => /^[a-zA-Z]/.test(n)).slice(0, 2).map(n => n[0]).join("").toUpperCase() : "DS" - const targetExamLabel = user?.target_exam === "JEE_Main" ? "JEE" : user?.target_exam === "JEE_Advanced" ? "JEE Advanced" : user?.target_exam === "NEET" ? "NEET" : "NEET/JEE" + const targetExamLabel = user?.target_exam === "JEE_Main" ? "JEE" : user?.target_exam === "JEE_Advanced" ? "JEE Advanced" : user?.target_exam === "NEET" ? "NEET" : "JEE + NEET" - // Trial logic removed as requested + const router = useRouter(); if (maintenance) { return ; } + // Redirect to login (or admin) if no user is found after loading + if (!loading && !switching && !user) { + if (typeof window !== 'undefined') { + const activeId = localStorage.getItem("activeUserId"); + if (activeId === "admin") { + router.replace("/admin"); + } else { + router.replace("/login"); + } + } + return null; + } + if (loading || !user) { return (
@@ -103,20 +118,32 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { {/* Bottom Settings + Profile */} @@ -275,10 +314,10 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { {isDemoUser(userId) && ( )}
@@ -303,7 +342,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {

{user ? cleanDisplayName(user.name).split(" ")[0] : "Loading..."}

- +
@@ -326,7 +365,6 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { {targetExamLabel} Target )} - {/* Trial countdown badge removed from top header */}
@@ -396,6 +434,8 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { } function NavItem({ href, icon, label, active, isLocked, section }: { href: string, icon: React.ReactNode, label: string, active?: boolean, isLocked?: boolean, section?: "pro" | "ai" }) { + // We no longer return null for isLocked. We render it with a disabled appearance. + // Section-aware active color tokens (light mode only, dark mode falls back to cyan) const activeClasses = active ? section === "pro" @@ -403,8 +443,6 @@ function NavItem({ href, icon, label, active, isLocked, section }: { href: strin : section === "ai" ? "bg-cyan-50 dark:bg-[var(--cyan-ai-10)] text-cyan-800 dark:text-[var(--cyan-ai)] font-semibold border border-cyan-300/60 dark:border-[var(--cyan-ai-20)] shadow-[0_0_12px_-4px_rgba(6,182,212,0.2)] dark:shadow-[0_0_12px_-4px_rgba(12,96,96,0.3)]" : "bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] font-semibold border border-[var(--cyan-ai-20)] shadow-[0_0_12px_-4px_rgba(12,96,96,0.3)]" - : isLocked - ? "text-muted-foreground/40 cursor-not-allowed border border-transparent" : "text-muted-foreground/80 hover:bg-secondary/60 hover:text-foreground border border-transparent hover:border-border/40" const iconActiveClass = active @@ -413,8 +451,6 @@ function NavItem({ href, icon, label, active, isLocked, section }: { href: strin : section === "ai" ? "text-cyan-700 dark:text-[var(--cyan-ai)]" : "text-[var(--cyan-ai)]" - : isLocked - ? "text-muted-foreground/30" : "text-muted-foreground/60 group-hover:text-foreground/80" const activeBarClass = active @@ -427,8 +463,8 @@ function NavItem({ href, icon, label, active, isLocked, section }: { href: strin return (
@@ -436,8 +472,10 @@ function NavItem({ href, icon, label, active, isLocked, section }: { href: strin {label}
- {isLocked && } - {active && } +
+ {isLocked && } + {!isLocked && active && } +
) } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 704f4f2..77907d4 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -59,7 +59,8 @@ export default function DashboardPage() { const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark'); const themeParam = isDark ? 'dark' : 'light'; - return `/api/og/dashboard?score=${encodeURIComponent(score)}&accuracy=${encodeURIComponent(accuracy)}&qs=${encodeURIComponent(qs)}&rank=${encodeURIComponent(rank)}&name=${encodeURIComponent(name)}&target=${target}&plan=${encodeURIComponent(plan)}&date=${encodeURIComponent(date)}&theme=${themeParam}` + if (!user) return ""; + return `/api/og/dashboard/${user.id}?theme=${themeParam}&date=${encodeURIComponent(date)}` } const handleDownload = async () => { diff --git a/src/app/dashboard/progress/page.tsx b/src/app/dashboard/progress/page.tsx index 67855b3..6d0a376 100644 --- a/src/app/dashboard/progress/page.tsx +++ b/src/app/dashboard/progress/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { ChevronDown, ChevronRight, CheckCircle2, Circle, Clock, Activity, BarChart3 } from "lucide-react" import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser" +import { PremiumGate } from "@/components/PremiumGate" type ChapterStatus = 'Mastered' | 'Practiced' | 'In Progress' | 'Not Started' @@ -215,9 +216,10 @@ export default function ProgressTrackerPage() { } return ( -
-
-
+ +
+
+

@@ -305,5 +307,6 @@ export default function ProgressTrackerPage() { ))}

+ ) } diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 57c392e..d3fe554 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -9,7 +9,7 @@ import { User as UserIcon, Upload, CheckCircle2, Settings } from "lucide-react"; export default function SettingsPage() { const { userId, user, refreshUser, loading: userLoading } = useActiveUser(); - const [targetExam, setTargetExam] = useState<"JEE_Main" | "JEE_Advanced" | "NEET">("JEE_Main"); + const [targetExam, setTargetExam] = useState<"JEE_Main" | "JEE_Advanced" | "NEET" | "JEE_NEET">("JEE_Main"); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [school, setSchool] = useState(""); @@ -263,45 +263,63 @@ export default function SettingsPage() {
+
- + */}
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 64120d2..2e8b78f 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -231,9 +231,9 @@ export default function SignupPage() { value={targetExam} onChange={(e) => setTargetExam(e.target.value)} className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all cursor-pointer"> - - + +
@@ -259,7 +259,7 @@ export default function SignupPage() {
- + > = { Reach: [ @@ -56,22 +55,28 @@ const MATCH_COLOR = (match: number) => { export function CollegePredictorCard() { const [loading, setLoading] = useState(true) const [activeTab, setActiveTab] = useState("Target") - const { userId, user, loading: userLoading } = useActiveUser() + const [isNeet, setIsNeet] = useState(false) useEffect(() => { - if (userLoading || !userId) return + // Read userId and target exam from localStorage to avoid SSR issues + const userId = typeof window !== 'undefined' ? localStorage.getItem('activeUserId') : null; + if (!userId) { setLoading(false); return; } + async function fetchData() { try { - const res = await fetchWithAuth(`/api/ai-coach/readiness/${userId}`) - if (!res.ok) throw new Error("fallback") + const res = await fetchWithAuth(`/api/users/${userId}`); + if (res.ok) { + const userData = await res.json(); + setIsNeet(userData?.target_exam === 'NEET'); + } } catch { - // Use mock data — no-op + // Use default (JEE) on error } finally { setLoading(false) } } fetchData() - }, [userId, userLoading]) + }, []) if (loading) { return ( @@ -81,7 +86,6 @@ export function CollegePredictorCard() { ) } - const isNeet = user?.target_exam === "NEET"; const colleges = (isNeet ? MOCK_COLLEGES_NEET[activeTab] : MOCK_COLLEGES_JEE[activeTab]) || [] const TAB_COLORS: Record = { diff --git a/src/components/PremiumGate.tsx b/src/components/PremiumGate.tsx new file mode 100644 index 0000000..d2eba28 --- /dev/null +++ b/src/components/PremiumGate.tsx @@ -0,0 +1,55 @@ +"use client" + +import React from "react" +import { useActiveUser } from "@/hooks/useActiveUser" +import { Lock, Sparkles, ArrowRight } from "lucide-react" +import Link from "next/link" + +interface PremiumGateProps { + featureKey: string; + children: React.ReactNode; +} + +export function PremiumGate({ featureKey, children }: PremiumGateProps) { + const { hasAccessTo, loading, user } = useActiveUser(); + + if (loading || !user) { + return ( +
+
+
+
+
+ ); + } + + if (!hasAccessTo(featureKey)) { + return ( +
+
+ {/* Background Glow */} +
+ +
+
+ +
+ +

Premium Feature

+

+ You need an active plan to access this exclusive tool. +

+
+ + + + Upgrade Plan to Unlock + + +
+
+ ); + } + + return <>{children}; +} diff --git a/src/hooks/useActiveUser.ts b/src/hooks/useActiveUser.ts index f9841d7..285bf73 100644 --- a/src/hooks/useActiveUser.ts +++ b/src/hooks/useActiveUser.ts @@ -9,12 +9,12 @@ export interface UserProfile { email: string | null; phone: string | null; class: string; - target_exam: "JEE_Main" | "JEE_Advanced" | "NEET"; + target_exam: "JEE_Main" | "JEE_Advanced" | "NEET" | "JEE_NEET"; medium: "English" | "Hindi" | "Bilingual"; school: string | null; city: string; state: string; - tier: "Free" | "Pro" | "Al_Coach"; // Al_Coach in database schema + tier: "Free" | "Pro" | "Al_Coach"; streak_count: number; longest_streak: number; active_session_token?: string; @@ -25,14 +25,17 @@ export interface UserProfile { export const DEFAULT_USER_ID = "11111111-1111-1111-1111-111111111111"; // AI Coach - JEE export const DEMO_USERS = { - AICOACH_JEE: { id: "11111111-1111-1111-1111-111111111111", label: "Aarav (AI Coach - JEE)" }, - AICOACH_NEET: { id: "22222222-2222-2222-2222-222222222222", label: "Neha (AI Coach - NEET)" }, - AICOACH_JEE_NEET: { id: "33333333-3333-3333-3333-333333333333", label: "Rohan (AI Coach - JEE+NEET)" }, - FREE_JEE: { id: "44444444-4444-4444-4444-444444444444", label: "Amit (Free - JEE)" }, + AICOACH_JEE: { id: "11111111-1111-1111-1111-111111111111", label: "AI Coach - JEE" }, + AICOACH_NEET: { id: "22222222-2222-2222-2222-222222222222", label: "AI Coach - NEET" }, + AICOACH_JEE_NEET: { id: "33333333-3333-3333-3333-333333333333", label: "AI Coach - JEE+NEET" }, + FREE_JEE: { id: "44444444-4444-4444-4444-444444444444", label: "Free - JEE" }, }; // IDs considered "demo" accounts — demo switcher only shows for these -export const DEMO_IDS = new Set(Object.values(DEMO_USERS).map(u => u.id)); +export const DEMO_IDS = new Set([ + ...Object.values(DEMO_USERS).map(u => u.id), + "usr_01", "usr_02", "usr_03", "usr_04", "usr_05" +]); /** Returns true if the userId matches a known demo account */ export function isDemoUser(userId?: string | null): boolean { @@ -177,7 +180,8 @@ export function useActiveUser() { failCountRef.current = 0; // Single Active Session Check - if (typeof window !== "undefined") { + // SKIP for demo users — switching personas would otherwise trigger false logout + if (typeof window !== "undefined" && !isDemoUser(id)) { const localToken = localStorage.getItem("active_session_token"); if (data.active_session_token && localToken && data.active_session_token !== localToken) { localStorage.removeItem("activeUserId"); @@ -231,14 +235,14 @@ export function useActiveUser() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Only run once — ref keeps the latest userId - const switchUser = useCallback(async (newId: string) => { - if (typeof window === "undefined" || newId === activeUserIdRef.current) return; + const switchUser = useCallback(async (newId: string, reset: boolean = false) => { + if (typeof window === "undefined" || (newId === activeUserIdRef.current && !reset)) return; setSwitching(true); try { // First, get the JWT token for this demo user by calling the switch API - const switchRes = await fetch(`/api/user/switch?id=${newId}`); + const switchRes = await fetch(`/api/user/switch?id=${newId}${reset ? '&reset=true' : ''}`); const switchData = await switchRes.json(); if (!switchRes.ok || !switchData.token) { @@ -299,7 +303,7 @@ export function useActiveUser() { // Compute effective tier via trial logic const { effectiveTier } = computeTrialInfo(user, globalSettings); - const tier = effectiveTier; + const tier = effectiveTier || "Free"; // Core restrictions mapping based on PRD feature matrix switch (feature) { @@ -321,6 +325,8 @@ export function useActiveUser() { case "ai-mistake-tracking": case "ai-readiness-prediction": case "ai-tutor-chat": + case "college-predictor": + case "exam-day-strategy": return tier === "Al_Coach"; default: diff --git a/test.py b/test.py new file mode 100644 index 0000000..d75bfe6 --- /dev/null +++ b/test.py @@ -0,0 +1,12 @@ +import urllib.request, json +req = urllib.request.Request('http://localhost:8000/api/auth/login', data=b'{"email":"demo@pragativeer.com","password":"Pv9#kL2$mQ8!vX4@"}', headers={'Content-Type': 'application/json'}) +res = urllib.request.urlopen(req) +data = json.loads(res.read().decode()) +token = data['token'] + +req2 = urllib.request.Request('http://localhost:8000/api/admin/settings', headers={'Authorization': 'Bearer ' + token}) +try: + res2 = urllib.request.urlopen(req2) + print("SETTINGS:", res2.read().decode()) +except urllib.error.HTTPError as e: + print("ERR SETTINGS:", e.code, e.read().decode())