farhan1221 commited on
Commit
91990f9
·
verified ·
1 Parent(s): 750c54b

Upload 26 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Hugging face runs on port 7860 by default
11
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
config/database.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from supabase import create_client, Client
2
+ from config.settings import get_settings
3
+
4
+ settings = get_settings()
5
+
6
+ def get_supabase() -> Client:
7
+ return create_client(
8
+ settings.supabase_url,
9
+ settings.supabase_anon_key
10
+ )
11
+
12
+ def get_supabase_admin() -> Client:
13
+ return create_client(
14
+ settings.supabase_url,
15
+ settings.supabase_service_key
16
+ )
config/settings.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from functools import lru_cache
3
+
4
+ class Settings(BaseSettings):
5
+ supabase_url: str = ""
6
+ supabase_anon_key: str = ""
7
+ supabase_service_key: str = ""
8
+ jwt_secret: str = ""
9
+ admin_secret_url: str = ""
10
+ frontend_url: str = ""
11
+ environment: str = "development"
12
+ gemini_api_key: str = ""
13
+ groq_api_key: str = ""
14
+ openrouter_api_key: str = ""
15
+ openrouter_model: str = "openrouter/free"
16
+ huggingface_api_token: str = ""
17
+ huggingface_free_models: list[str] = [
18
+ "meta-llama/Llama-3.2-3B-Instruct",
19
+ "Qwen/Qwen2.5-7B-Instruct",
20
+ "mistralai/Mistral-7B-Instruct-v0.3",
21
+ "google/gemma-2-9b-it"
22
+ ]
23
+ resend_api_key: str = ""
24
+ piston_api_url: str = "https://emkc.org/api/v2/piston"
25
+ jdoodle_client_id: str = ""
26
+ jdoodle_client_secret: str = ""
27
+ admin_emails: list[str] = ["farhanahmad4709@gmail.com"]
28
+
29
+ class Config:
30
+ env_file = ".env"
31
+ case_sensitive = False
32
+
33
+ @lru_cache()
34
+ def get_settings():
35
+ return Settings()
main.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from config.settings import get_settings
4
+ from routers import auth, analyze, challenges, user, support, share, admin
5
+
6
+ settings = get_settings()
7
+
8
+ app = FastAPI(title="CodeNexus API", version="1.0.0")
9
+
10
+ app.add_middleware(
11
+ CORSMiddleware,
12
+ allow_origins=[
13
+ settings.frontend_url,
14
+ "http://localhost:3000",
15
+ "https://codenexus-ea4.pages.dev",
16
+ "https://codenexus.qzz.io",
17
+ ],
18
+ allow_credentials=True,
19
+ allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
20
+ allow_headers=["Content-Type", "Authorization", "X-Admin-Secret"],
21
+ )
22
+
23
+ app.include_router(auth.router)
24
+ app.include_router(analyze.router)
25
+ app.include_router(challenges.router)
26
+ app.include_router(user.router)
27
+ app.include_router(support.router)
28
+ app.include_router(share.router)
29
+ app.include_router(admin.router)
30
+
31
+ @app.get("/")
32
+ def root():
33
+ return {"status": "CodeNexus API is running"}
34
+
35
+ @app.get("/health")
36
+ def health():
37
+ return {"status": "healthy"}
middleware/admin_guard.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Request, HTTPException
2
+ from middleware.auth_guard import get_current_user
3
+ from config.database import get_supabase_admin
4
+
5
+
6
+ async def get_admin_user(request: Request):
7
+ """
8
+ Verifies user is authenticated AND has admin role.
9
+ Also verifies the secret admin prefix from the URL matches the one in DB.
10
+ """
11
+ # 1. Verify User Role
12
+ user = await get_current_user(request)
13
+ admin_client = get_supabase_admin()
14
+
15
+ try:
16
+ profile = admin_client.table("users").select("role").eq("id", str(user.id)).single().execute()
17
+ if not profile.data or profile.data.get("role") != "admin":
18
+ raise HTTPException(status_code=403, detail="Admin access required")
19
+ except Exception:
20
+ raise HTTPException(status_code=403, detail="Admin access required")
21
+
22
+ # 2. Verify Secret URL Segment (X-Admin-Secret header or similar)
23
+ # To avoid changing all routes to {secret}, we'll check it from DB.
24
+ # The Admin Panel will send the current secret in the X-Admin-Secret header.
25
+
26
+ provided_secret = request.headers.get("X-Admin-Secret")
27
+
28
+ # Fallback to hardcoded if DB call fails or is empty for bootstrapping
29
+ # But ideally it should be in DB.
30
+ settings_res = admin_client.table("site_settings").select("value").eq("key", "admin_secret_url").single().execute()
31
+ db_secret = settings_res.data.get("value") if settings_res.data else "cn-admin-nc-0947"
32
+
33
+ if provided_secret != db_secret:
34
+ # We also allow the secret to be part of the path if we use the old prefix
35
+ # for backward compatibility during transition.
36
+ if "cn-admin-nc-0947" not in request.url.path and provided_secret != db_secret:
37
+ raise HTTPException(status_code=403, detail="Invalid admin secret")
38
+
39
+ return user
middleware/auth_guard.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Request, HTTPException
2
+ from config.database import get_supabase_admin
3
+
4
+
5
+ async def get_current_user(request: Request):
6
+ """
7
+ Check for token in two places:
8
+ 1. httpOnly cookie 'access_token' (email/password login)
9
+ 2. Authorization: Bearer header (GitHub/Google OAuth)
10
+ """
11
+ token = request.cookies.get("access_token")
12
+
13
+ if not token:
14
+ auth_header = request.headers.get("Authorization", "")
15
+ if auth_header.startswith("Bearer "):
16
+ token = auth_header[7:]
17
+
18
+ if not token:
19
+ raise HTTPException(status_code=401, detail="Not authenticated")
20
+
21
+ try:
22
+ admin_client = get_supabase_admin()
23
+ user_response = admin_client.auth.get_user(token)
24
+ if not user_response or not user_response.user:
25
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
26
+
27
+ user = user_response.user
28
+
29
+ # New: Automatically ensure user exists in public table
30
+ from services.user_sync import ensure_user_exists
31
+ await ensure_user_exists(user)
32
+
33
+ # New: Fetch role from public.users table
34
+ from config.settings import get_settings
35
+ settings = get_settings()
36
+
37
+ profile = admin_client.table("users").select("role").eq("id", str(user.id)).single().execute()
38
+
39
+ # Force admin role for configured emails (Case-Insensitive)
40
+ admin_list = [e.lower() for e in settings.admin_emails]
41
+ user_role = "admin" if user.email.lower() in admin_list else (profile.data.get("role", "").lower() if profile.data else "free")
42
+
43
+ # Attach role to the user object (patch it)
44
+ user.role = user_role
45
+
46
+ return user
47
+ except HTTPException:
48
+ raise
49
+ except Exception:
50
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
51
+
52
+
53
+ async def get_current_user_optional(request: Request):
54
+ """Returns user if logged in, None if not. Never raises."""
55
+ try:
56
+ return await get_current_user(request)
57
+ except HTTPException:
58
+ return None
middleware/rate_limit.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Request, HTTPException
2
+ from datetime import datetime, timedelta
3
+ from collections import defaultdict
4
+ import asyncio
5
+
6
+ # In-memory store: {key: [timestamp, ...]}
7
+ _request_log: dict = defaultdict(list)
8
+ _lock = asyncio.Lock()
9
+
10
+ LIMITS = {
11
+ "guest": {"analyze": 3, "run": 10, "challenge": 2},
12
+ "free": {"analyze": 15, "run": 50, "challenge": 10},
13
+ "pro": {"analyze": 999, "run": 999, "challenge": 999},
14
+ "admin": {"analyze": 999, "run": 999, "challenge": 999},
15
+ }
16
+
17
+
18
+ async def check_rate_limit(request: Request, action: str, user=None, role: str = "guest"):
19
+ """Check if user/IP has exceeded their daily limit for a given action."""
20
+ if role in ("admin", "pro"):
21
+ return # Unlimited
22
+
23
+ identifier = str(user.id) if user else request.client.host
24
+ key = f"{identifier}:{action}"
25
+ now = datetime.utcnow()
26
+ window_start = now - timedelta(hours=24)
27
+
28
+ async with _lock:
29
+ # Remove entries older than 24h
30
+ _request_log[key] = [t for t in _request_log[key] if t > window_start]
31
+ count = len(_request_log[key])
32
+ limit = LIMITS.get(role, LIMITS["guest"]).get(action, 5)
33
+
34
+ if count >= limit:
35
+ raise HTTPException(
36
+ status_code=429,
37
+ detail=f"Daily limit of {limit} {action}s reached. Upgrade to Pro for unlimited access."
38
+ )
39
+ _request_log[key].append(now)
models/analysis.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List
3
+
4
+ class RunRequest(BaseModel):
5
+ language: str
6
+ version: str = ""
7
+ code: str
8
+
9
+ class AnalyzeRequest(BaseModel):
10
+ language: str
11
+ code: str
12
+
13
+ class AnalysisResult(BaseModel):
14
+ time_complexity: str
15
+ space_complexity: str
16
+ issues: List[str]
17
+ suggestions: List[str]
18
+ optimized_code: str
models/challenge.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List, Dict, Any
3
+
4
+ class ChallengeSubmit(BaseModel):
5
+ language: Optional[str] = None
6
+ code: Optional[str] = None
7
+ selected_option: Optional[int] = None
8
+ selected_options: Optional[List[int]] = None
9
+
10
+ class ChallengeCreate(BaseModel):
11
+ title: str
12
+ description: str
13
+ difficulty: str = "easy"
14
+ category: str = "arrays"
15
+ type: str = "coding"
16
+ options: List[str] = []
17
+ correct_option: Optional[int] = None
18
+ questions: Optional[List[Dict[str, Any]]] = []
19
+ company_tag: str = ""
20
+ is_interview_question: bool = False
21
+ starter_code: Dict[str, str] = {}
22
+ test_cases: List[Dict[str, Any]] = []
models/support.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr
2
+ from typing import Optional
3
+
4
+ class SupportTicket(BaseModel):
5
+ user_email: EmailStr
6
+ category: str
7
+ message: str
8
+ screenshot_url: Optional[str] = None
9
+
10
+ class TicketReply(BaseModel):
11
+ reply: str
models/user.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+
4
+ class ProfileUpdate(BaseModel):
5
+ display_name: Optional[str] = None
6
+ theme: Optional[str] = None
requirements.txt ADDED
Binary file (478 Bytes). View file
 
routers/admin.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Request
2
+ from config.database import get_supabase_admin
3
+ from middleware.admin_guard import get_admin_user
4
+ from services.email import send_ticket_reply_to_user
5
+
6
+ router = APIRouter(prefix="/api/cn-admin-nc-0947", tags=["admin"])
7
+
8
+
9
+ @router.get("/stats")
10
+ async def get_stats(request: Request):
11
+ await get_admin_user(request)
12
+ db = get_supabase_admin()
13
+ users = db.table("users").select("id", count="exact").execute()
14
+ analyses = db.table("analyses").select("id", count="exact").execute()
15
+ tickets = db.table("support_tickets").select("id", count="exact").eq("status", "open").execute()
16
+ subs = db.table("challenge_submissions").select("id", count="exact").execute()
17
+ return {
18
+ "total_users": users.count or 0,
19
+ "total_analyses": analyses.count or 0,
20
+ "open_tickets": tickets.count or 0,
21
+ "total_submissions": subs.count or 0,
22
+ }
23
+
24
+
25
+ @router.get("/users")
26
+ async def get_users(request: Request, page: int = 1):
27
+ await get_admin_user(request)
28
+ db = get_supabase_admin()
29
+ offset = (page - 1) * 50
30
+ result = db.table("users").select("*").order("created_at", desc=True).range(offset, offset + 49).execute()
31
+ return result.data or []
32
+
33
+
34
+ @router.patch("/users/{user_id}/ban")
35
+ async def ban_user(user_id: str, request: Request):
36
+ await get_admin_user(request)
37
+ body = await request.json()
38
+ db = get_supabase_admin()
39
+ db.table("users").update({"is_banned": body.get("is_banned", True)}).eq("id", user_id).execute()
40
+ return {"message": "User ban status updated"}
41
+
42
+
43
+ @router.patch("/users/{user_id}/role")
44
+ async def change_role(user_id: str, request: Request):
45
+ await get_admin_user(request)
46
+ body = await request.json()
47
+ db = get_supabase_admin()
48
+ db.table("users").update({"role": body.get("role", "free")}).eq("id", user_id).execute()
49
+ return {"message": "Role updated"}
50
+
51
+
52
+ @router.delete("/users/{user_id}")
53
+ async def delete_user(user_id: str, request: Request):
54
+ await get_admin_user(request)
55
+ db = get_supabase_admin()
56
+ db.table("analyses").delete().eq("user_id", user_id).execute()
57
+ db.table("challenge_submissions").delete().eq("user_id", user_id).execute()
58
+ db.table("users").delete().eq("id", user_id).execute()
59
+ try:
60
+ db.auth.admin.delete_user(user_id)
61
+ except Exception:
62
+ pass
63
+ return {"message": "User deleted"}
64
+
65
+
66
+ @router.post("/challenges")
67
+ async def create_challenge(request: Request):
68
+ user = await get_admin_user(request)
69
+ body = await request.json()
70
+ db = get_supabase_admin()
71
+ body["created_by"] = str(user.id)
72
+ body.setdefault("status", "published")
73
+ body.setdefault("starter_code", {})
74
+ body.setdefault("test_cases", [])
75
+ result = db.table("challenges").insert(body).execute()
76
+ return result.data[0] if result.data else {}
77
+
78
+
79
+ @router.patch("/challenges/{challenge_id}")
80
+ async def update_challenge(challenge_id: str, request: Request):
81
+ await get_admin_user(request)
82
+ body = await request.json()
83
+ db = get_supabase_admin()
84
+ db.table("challenges").update(body).eq("id", challenge_id).execute()
85
+ return {"message": "Challenge updated"}
86
+
87
+
88
+ @router.delete("/challenges/{challenge_id}")
89
+ async def delete_challenge(challenge_id: str, request: Request):
90
+ await get_admin_user(request)
91
+ db = get_supabase_admin()
92
+ db.table("challenges").delete().eq("id", challenge_id).execute()
93
+ return {"message": "Challenge deleted"}
94
+
95
+
96
+ @router.get("/tickets")
97
+ async def get_tickets(request: Request, status: str = ""):
98
+ await get_admin_user(request)
99
+ db = get_supabase_admin()
100
+ query = db.table("support_tickets").select("*").order("created_at", desc=True)
101
+ if status:
102
+ query = query.eq("status", status)
103
+ return query.execute().data or []
104
+
105
+
106
+ @router.patch("/tickets/{ticket_id}/reply")
107
+ async def reply_ticket(ticket_id: str, request: Request):
108
+ await get_admin_user(request)
109
+ body = await request.json()
110
+ reply = body.get("reply", "")
111
+ db = get_supabase_admin()
112
+ ticket = db.table("support_tickets").select("*").eq("id", ticket_id).single().execute()
113
+ if not ticket.data:
114
+ raise HTTPException(status_code=404, detail="Ticket not found")
115
+ db.table("support_tickets").update({"admin_reply": reply, "status": "resolved"}).eq("id", ticket_id).execute()
116
+ try:
117
+ await send_ticket_reply_to_user(ticket.data["user_email"], reply)
118
+ except Exception:
119
+ pass
120
+ return {"message": "Reply sent"}
121
+
122
+
123
+ @router.post("/blog")
124
+ async def create_blog_post(request: Request):
125
+ user = await get_admin_user(request)
126
+ body = await request.json()
127
+ db = get_supabase_admin()
128
+ body["author_id"] = str(user.id)
129
+ body.setdefault("status", "draft")
130
+ result = db.table("blog_posts").insert(body).execute()
131
+ return result.data[0] if result.data else {}
132
+
133
+
134
+ @router.patch("/blog/{post_id}")
135
+ async def update_blog_post(post_id: str, request: Request):
136
+ await get_admin_user(request)
137
+ body = await request.json()
138
+ db = get_supabase_admin()
139
+ db.table("blog_posts").update(body).eq("id", post_id).execute()
140
+ return {"message": "Post updated"}
141
+
142
+
143
+ @router.delete("/blog/{post_id}")
144
+ async def delete_blog_post(post_id: str, request: Request):
145
+ await get_admin_user(request)
146
+ db = get_supabase_admin()
147
+ db.table("blog_posts").delete().eq("id", post_id).execute()
148
+ return {"message": "Post deleted"}
149
+
150
+
151
+ @router.post("/announcements")
152
+ async def create_announcement(request: Request):
153
+ await get_admin_user(request)
154
+ body = await request.json()
155
+ db = get_supabase_admin()
156
+ db.table("announcements").update({"is_active": False}).eq("is_active", True).execute()
157
+ result = db.table("announcements").insert({"message": body.get("message"), "is_active": True}).execute()
158
+ return result.data[0] if result.data else {}
159
+
160
+
161
+ @router.get("/security-logs")
162
+ async def get_security_logs(request: Request):
163
+ await get_admin_user(request)
164
+ db = get_supabase_admin()
165
+ result = db.table("security_logs").select("*").order("created_at", desc=True).limit(200).execute()
166
+ return result.data or []
167
+
168
+
169
+ @router.get("/settings")
170
+ async def get_site_settings(request: Request):
171
+ await get_admin_user(request)
172
+ db = get_supabase_admin()
173
+ result = db.table("site_settings").select("*").execute()
174
+ return result.data or []
175
+
176
+
177
+ @router.patch("/settings/{key}")
178
+ async def update_site_setting(key: str, request: Request):
179
+ await get_admin_user(request)
180
+ body = await request.json()
181
+ db = get_supabase_admin()
182
+ db.table("site_settings").update({"value": body.get("value")}).eq("key", key).execute()
183
+ return {"message": f"Setting {key} updated"}
routers/analyze.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Request
2
+ from pydantic import BaseModel
3
+ import httpx
4
+ import logging
5
+ from config.settings import get_settings
6
+ from services.ai_router import route_analysis
7
+ from services.quotas import check_quota, check_guest_quota
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ router = APIRouter(prefix="/api/analyze", tags=["analyze"])
12
+ settings = get_settings()
13
+
14
+ JDOODLE_LANGUAGE_MAP = {
15
+ "python": {"language": "python3", "versionIndex": "4"},
16
+ "javascript": {"language": "nodejs", "versionIndex": "4"},
17
+ "typescript": {"language": "typescript", "versionIndex": "1"},
18
+ "java": {"language": "java", "versionIndex": "4"},
19
+ "c++": {"language": "cpp17", "versionIndex": "1"},
20
+ "c": {"language": "c", "versionIndex": "5"},
21
+ "go": {"language": "go", "versionIndex": "4"},
22
+ "rust": {"language": "rust", "versionIndex": "4"},
23
+ "ruby": {"language": "ruby", "versionIndex": "4"},
24
+ "php": {"language": "php", "versionIndex": "4"},
25
+ }
26
+
27
+ class RunRequest(BaseModel):
28
+ language: str
29
+ version: str
30
+ code: str
31
+ stdin: str = ""
32
+
33
+ class AnalyzeRequest(BaseModel):
34
+ model_config = {"protected_namespaces": ()}
35
+ language: str
36
+ code: str
37
+ model_choice: str = "auto"
38
+
39
+ @router.get("/config")
40
+ async def get_ai_config():
41
+ from services.settings import is_ai_enabled
42
+ return {"ai_features_enabled": is_ai_enabled()}
43
+
44
+ @router.post("/run")
45
+ async def run_code(data: RunRequest):
46
+ try:
47
+ lang_config = JDOODLE_LANGUAGE_MAP.get(data.language.lower())
48
+ if not lang_config:
49
+ raise HTTPException(status_code=400, detail=f"Language {data.language} not supported")
50
+ async with httpx.AsyncClient(timeout=30.0) as client:
51
+ response = await client.post(
52
+ "https://api.jdoodle.com/v1/execute",
53
+ json={
54
+ "clientId": settings.jdoodle_client_id,
55
+ "clientSecret": settings.jdoodle_client_secret,
56
+ "script": data.code,
57
+ "stdin": data.stdin,
58
+ "language": lang_config["language"],
59
+ "versionIndex": lang_config["versionIndex"],
60
+ }
61
+ )
62
+ result = response.json()
63
+ output = result.get("output", "No output")
64
+ return {"output": output}
65
+ except HTTPException:
66
+ raise
67
+ except Exception as e:
68
+ logger.error(f"Analysis failed: {str(e)}")
69
+ raise HTTPException(status_code=500, detail="AI analysis failed. Please try again later.")
70
+
71
+ @router.post("/analyze")
72
+ async def analyze_code(data: AnalyzeRequest, request: Request):
73
+ from services.settings import is_ai_enabled
74
+ from middleware.auth_guard import get_current_user_optional, get_current_user
75
+
76
+ if not is_ai_enabled():
77
+ raise HTTPException(status_code=403, detail="AI analysis features are currently disabled by the administrator.")
78
+
79
+ try:
80
+ # Get user
81
+ user = await get_current_user_optional(request)
82
+
83
+ # 1. Tier Enforcement
84
+ # Free Tier (4 Models + Auto): 'auto', 'gemma-4-31b', 'llama-3.1', 'qwen-2.5', 'nemotron-120b'
85
+ # Pro Tier (4 Elite Models): 'minimax-2.5', 'mistral-large', 'groq-70b', 'gemini-flash'
86
+ effective_model = data.model_choice
87
+ user_role = user.role if user else "guest"
88
+
89
+ free_models = ["auto", "gemma-4-31b", "llama-3.1", "qwen-2.5", "nemotron-120b"]
90
+
91
+ if user_role != "pro" and data.model_choice not in free_models:
92
+ logger.info(f"User {user.id if user else 'guest'} requested {data.model_choice} but is not Pro. Defaulting to auto.")
93
+ effective_model = "auto"
94
+
95
+ # 2. Quota Check
96
+ if user:
97
+ await check_quota(str(user.id), "analysis")
98
+ else:
99
+ await check_guest_quota(request.client.host, "analysis")
100
+
101
+ prompt = f"""[CRITICAL: ELITE SYSTEMS ARCHITECT PERSONA]
102
+ Analyze this {data.language} code with the precision of a Lead Performance Engineer.
103
+
104
+ CODE TO ANALYZE:
105
+ {data.code}
106
+
107
+ RETURN ONLY THIS JSON STRUCTURE:
108
+ {{
109
+ "time_complexity": "string (Big O)",
110
+ "time_explanation": "Elite technical insight (e.g. 'Constant time access via hash map—Excellent speed.')",
111
+ "space_complexity": "string (Big O)",
112
+ "space_explanation": "Elite technical insight (e.g. 'Minimal auxiliary space—Optimal memory footprint.')",
113
+ "issues": ["list of sharp technical issues"],
114
+ "suggestions": ["list of architectural improvements - MINIMUM 3"],
115
+ "optimized_code": "string (the superior solution)",
116
+ "optimized_time_complexity": "string (Big O of optimized version)",
117
+ "optimized_time_explanation": "Technical optimization insight",
118
+ "optimized_space_complexity": "string (Big O of optimized version)",
119
+ "optimized_space_explanation": "Memory optimization insight"
120
+ }}
121
+ [NOTE: Return valid JSON only. If the code is already optimally written, structure/format it securely into standard format for `optimized_code`, and return the same complexities.]"""
122
+
123
+ # Using the robust AI router with Smart Recovery Failover
124
+ result_with_meta = await route_analysis(prompt, model_choice=effective_model)
125
+
126
+ # Extract the actual model used from the metadata
127
+ actual_model = result_with_meta.get("_actual_model", effective_model)
128
+
129
+ # Remove internal metadata before returning to frontend
130
+ parsed = {k: v for k, v in result_with_meta.items() if not k.startswith("_")}
131
+
132
+ # Save to DB if logged in
133
+ if user:
134
+ try:
135
+ from config.database import get_supabase_admin
136
+ admin_client = get_supabase_admin()
137
+ admin_client.table("analyses").insert({
138
+ "user_id": str(user.id),
139
+ "language": data.language,
140
+ "code": data.code,
141
+ "ai_result": parsed,
142
+ "time_complexity": parsed.get("time_complexity", ""),
143
+ "space_complexity": parsed.get("space_complexity", ""),
144
+ "model_used": actual_model # Use the ACTUAL model that responded
145
+ }).execute()
146
+ logger.info(f"Analysis saved for user {user.id} (Actual model: {actual_model})")
147
+ except Exception as save_err:
148
+ logger.error(f"Failed to save analysis to DB for user {user.id}: {save_err}")
149
+
150
+ # Return result with actual model name so frontend can know
151
+ return {**parsed, "actual_model_used": actual_model}
152
+ except HTTPException:
153
+ raise
154
+ except Exception as e:
155
+ logger.error(f"Analysis process failed: {str(e)}")
156
+ raise HTTPException(status_code=500, detail="AI analysis failed. Please try again later.")
routers/auth.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Response, Request
2
+ from pydantic import BaseModel, EmailStr
3
+ from config.database import get_supabase, get_supabase_admin
4
+ from config.settings import get_settings
5
+
6
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
7
+ settings = get_settings()
8
+
9
+ class RegisterRequest(BaseModel):
10
+ email: EmailStr
11
+ password: str
12
+ display_name: str = ""
13
+
14
+ class LoginRequest(BaseModel):
15
+ email: EmailStr
16
+ password: str
17
+
18
+ @router.post("/register")
19
+ async def register(data: RegisterRequest):
20
+ supabase = get_supabase()
21
+ try:
22
+ response = supabase.auth.sign_up({
23
+ "email": data.email,
24
+ "password": data.password,
25
+ "options": {
26
+ "data": {
27
+ "full_name": data.display_name or data.email.split("@")[0]
28
+ }
29
+ }
30
+ })
31
+ if response.user is None:
32
+ raise HTTPException(status_code=400, detail="Registration failed")
33
+
34
+ # Sync to public users table
35
+ admin_db = get_supabase_admin()
36
+ admin_db.table("users").insert({
37
+ "id": response.user.id,
38
+ "email": data.email,
39
+ "display_name": data.display_name or data.email.split("@")[0],
40
+ "role": "free"
41
+ }).execute()
42
+
43
+ return {"message": "Registration successful. Please check your email to verify your account."}
44
+ except Exception as e:
45
+ error_msg = str(e)
46
+ if "23505" in error_msg or "users_pkey" in error_msg:
47
+ raise HTTPException(status_code=400, detail="An account with this email already exists.")
48
+
49
+ # Hide raw database dictionary strings if they slip through
50
+ if error_msg.startswith("{") and "'code'" in error_msg:
51
+ import ast
52
+ try:
53
+ err_dict = ast.literal_eval(error_msg)
54
+ if isinstance(err_dict, dict) and "message" in err_dict:
55
+ # Still, we might not want to expose raw DB messages, but it's better than the dict
56
+ raise HTTPException(status_code=400, detail=err_dict["message"])
57
+ except (ValueError, SyntaxError):
58
+ pass
59
+ raise HTTPException(status_code=400, detail="An error occurred during registration.")
60
+
61
+ raise HTTPException(status_code=400, detail=error_msg)
62
+
63
+ @router.post("/login")
64
+ async def login(data: LoginRequest, response: Response):
65
+ supabase = get_supabase()
66
+ try:
67
+ auth_response = supabase.auth.sign_in_with_password({
68
+ "email": data.email,
69
+ "password": data.password
70
+ })
71
+ if auth_response.user is None:
72
+ raise HTTPException(status_code=401, detail="Invalid email or password")
73
+
74
+ response.set_cookie(
75
+ key="access_token",
76
+ value=auth_response.session.access_token,
77
+ httponly=True,
78
+ secure=True,
79
+ samesite="lax",
80
+ max_age=604800
81
+ )
82
+ return {
83
+ "message": "Login successful",
84
+ "user": {
85
+ "id": str(auth_response.user.id),
86
+ "email": auth_response.user.email,
87
+ "display_name": auth_response.user.user_metadata.get("full_name", "")
88
+ }
89
+ }
90
+ except Exception as e:
91
+ raise HTTPException(status_code=401, detail="Invalid email or password")
92
+
93
+ @router.post("/logout")
94
+ async def logout(response: Response):
95
+ response.delete_cookie("access_token")
96
+ return {"message": "Logged out successfully"}
97
+
98
+ @router.post("/forgot-password")
99
+ async def forgot_password(email: EmailStr):
100
+ supabase = get_supabase()
101
+ try:
102
+ supabase.auth.reset_password_email(
103
+ email,
104
+ options={"redirect_to": f"{settings.frontend_url}/auth/reset-password"}
105
+ )
106
+ return {"message": "Password reset email sent"}
107
+ except Exception as e:
108
+ raise HTTPException(status_code=400, detail=str(e))
routers/challenges.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Request, Depends
2
+ from config.database import get_supabase_admin
3
+ from middleware.auth_guard import get_current_user, get_current_user_optional
4
+ from models.challenge import ChallengeSubmit
5
+ from services.ai_router import route_analysis
6
+ from services.code_runner import run_code
7
+
8
+ router = APIRouter(prefix="/api/challenges", tags=["challenges"])
9
+
10
+
11
+ @router.get("/all")
12
+ async def get_challenges(difficulty: str = "", category: str = "", company: str = ""):
13
+ db = get_supabase_admin()
14
+ query = db.table("challenges").select("*").eq("status", "published")
15
+ if difficulty:
16
+ query = query.eq("difficulty", difficulty)
17
+ if category:
18
+ query = query.eq("category", category)
19
+ if company:
20
+ query = query.eq("company_tag", company)
21
+ result = query.order("created_at", desc=True).execute()
22
+ return result.data or []
23
+
24
+
25
+ @router.get("/interview")
26
+ async def get_interview_questions(company: str = ""):
27
+ db = get_supabase_admin()
28
+ query = db.table("challenges").select("*").eq("status", "published").eq("is_interview_question", True)
29
+ if company:
30
+ query = query.eq("company_tag", company)
31
+ result = query.execute()
32
+ return result.data or []
33
+
34
+
35
+ @router.get("/{challenge_id}")
36
+ async def get_challenge(challenge_id: str):
37
+ db = get_supabase_admin()
38
+ result = db.table("challenges").select("*").eq("id", challenge_id).eq("status", "published").single().execute()
39
+ if not result.data:
40
+ raise HTTPException(status_code=404, detail="Challenge not found")
41
+ return result.data
42
+
43
+
44
+ @router.post("/{challenge_id}/submit")
45
+ async def submit_challenge(challenge_id: str, data: ChallengeSubmit, request: Request):
46
+ user = await get_current_user_optional(request)
47
+ db = get_supabase_admin()
48
+
49
+ # Get challenge
50
+ ch = db.table("challenges").select("*").eq("id", challenge_id).single().execute()
51
+ if not ch.data:
52
+ raise HTTPException(status_code=404, detail="Challenge not found")
53
+ challenge = ch.data
54
+
55
+ # Scenario 1: MCQ Challenge (Quiz or Single)
56
+ if challenge.get('type') == 'mcq':
57
+ questions = challenge.get('questions', [])
58
+
59
+ # Multi-question Quiz Logic
60
+ if questions and len(questions) > 0:
61
+ if data.selected_options is None or len(data.selected_options) != len(questions):
62
+ raise HTTPException(status_code=400, detail=f"selected_options array is required and must match questions length ({len(questions)})")
63
+
64
+ results = []
65
+ correct_count = 0
66
+ for i, q in enumerate(questions):
67
+ sel = data.selected_options[i]
68
+ # Flexible key for backward compatibility or different bulk upload formats
69
+ cor = q.get('correct_index')
70
+ if cor is None:
71
+ cor = q.get('correct_option', 0)
72
+
73
+ is_correct = sel == cor
74
+ if is_correct:
75
+ correct_count += 1
76
+
77
+ results.append({
78
+ "question_index": i,
79
+ "selected": sel,
80
+ "correct": cor,
81
+ "passed": is_correct,
82
+ "explanation": q.get('explanation', "No explanation provided.")
83
+ })
84
+
85
+ score = round((correct_count / len(questions)) * 100, 2)
86
+ passed = correct_count == len(questions)
87
+
88
+ ai_feedback = {
89
+ "feedback": f"You scored {correct_count}/{len(questions)} ({score}%). " +
90
+ ("Perfect! You've mastered this topic." if passed else "Good effort! Review the explanations below to improve."),
91
+ "quiz_results": results,
92
+ "score": score,
93
+ "time_complexity": "N/A",
94
+ "space_complexity": "N/A",
95
+ }
96
+
97
+ if user:
98
+ try:
99
+ # Final sanity check on fields
100
+ insert_data = {
101
+ "user_id": str(user.id),
102
+ "challenge_id": challenge_id,
103
+ "challenge_type": "mcq",
104
+ "passed": passed,
105
+ "ai_feedback": ai_feedback or {},
106
+ "metadata": {
107
+ "score": score,
108
+ "selected_options": data.selected_options,
109
+ "quiz_length": len(questions)
110
+ }
111
+ }
112
+ db.table("challenge_submissions").insert(insert_data).execute()
113
+ except Exception as db_err:
114
+ import logging
115
+ logger = logging.getLogger(__name__)
116
+ logger.error(f"Failed to save MCQ submission: {str(db_err)}")
117
+
118
+ return {
119
+ "passed": passed,
120
+ "score": score,
121
+ "ai_feedback": ai_feedback,
122
+ }
123
+
124
+ # Single Question MCQ Logic (Backward compatibility)
125
+ if data.selected_option is None:
126
+ raise HTTPException(status_code=400, detail="selected_option is required for single-question MCQ")
127
+
128
+ correct_idx = challenge.get('correct_option', 0)
129
+ passed = data.selected_option == correct_idx
130
+
131
+ ai_feedback = {
132
+ "feedback": "Correct! You have a good understanding of this concept." if passed else "Incorrect. Review the concept and try again.",
133
+ "correct_approach": f"The correct answer is option index {correct_idx}.",
134
+ "time_complexity": "N/A",
135
+ "space_complexity": "N/A",
136
+ "improvements": []
137
+ }
138
+
139
+ if user:
140
+ try:
141
+ db.table("challenge_submissions").insert({
142
+ "user_id": str(user.id),
143
+ "challenge_id": challenge_id,
144
+ "challenge_type": "mcq",
145
+ "selected_option": data.selected_option,
146
+ "passed": passed,
147
+ "ai_feedback": ai_feedback,
148
+ "metadata": {"score": 100 if passed else 0}
149
+ }).execute()
150
+ except Exception as db_err:
151
+ import logging
152
+ logger = logging.getLogger(__name__)
153
+ logger.error(f"Failed to save Single MCQ submission: {str(db_err)}")
154
+
155
+ return {
156
+ "passed": passed,
157
+ "correct_option": correct_idx,
158
+ "ai_feedback": ai_feedback,
159
+ }
160
+
161
+ # Scenario 2: Coding Challenge
162
+ if not data.code:
163
+ raise HTTPException(status_code=400, detail="code is required for coding challenges")
164
+
165
+ try:
166
+ output = await run_code(data.language, data.code)
167
+ except Exception as e:
168
+ raise HTTPException(status_code=400, detail=str(e))
169
+
170
+ # Check test cases
171
+ test_cases = challenge.get("test_cases", [])
172
+ passed = True
173
+ test_results = []
174
+ for tc in test_cases:
175
+ expected = str(tc.get("output", "")).strip()
176
+ actual = output.strip()
177
+ ok = expected in actual
178
+ test_results.append({"input": tc.get("input"), "expected": expected, "actual": actual, "passed": ok})
179
+ if not ok:
180
+ passed = False
181
+
182
+ # Get AI feedback
183
+ from services.settings import is_ai_enabled
184
+ if is_ai_enabled():
185
+ prompt = f"""A user submitted this {data.language} code for a coding challenge.
186
+ Challenge: {challenge.get('title')}
187
+ Description: {challenge.get('description')}
188
+ Code:
189
+ {data.code}
190
+ Output: {output}
191
+ Tests passed: {passed}
192
+
193
+ Respond in JSON only:
194
+ {{
195
+ "feedback": "2-3 sentence explanation of their solution quality",
196
+ "correct_approach": "brief explanation of the optimal approach",
197
+ "time_complexity": "O(?)",
198
+ "space_complexity": "O(?)",
199
+ "improvements": ["improvement 1", "improvement 2"]
200
+ }}"""
201
+
202
+ try:
203
+ ai_feedback = await route_analysis(prompt)
204
+ except Exception:
205
+ ai_feedback = {"feedback": "Good attempt! Keep practicing.", "correct_approach": "", "improvements": []}
206
+ else:
207
+ ai_feedback = {
208
+ "feedback": "Submission processed. Detailed AI analysis is currently disabled by administrator.",
209
+ "correct_approach": "AI analysis is required to generate the optimal approach.",
210
+ "time_complexity": "N/A",
211
+ "space_complexity": "N/A",
212
+ "improvements": []
213
+ }
214
+
215
+ # Save submission if logged in
216
+ if user:
217
+ try:
218
+ db.table("challenge_submissions").insert({
219
+ "user_id": str(user.id),
220
+ "challenge_id": challenge_id,
221
+ "challenge_type": "coding",
222
+ "code": data.code,
223
+ "language": data.language,
224
+ "passed": passed,
225
+ "ai_feedback": ai_feedback,
226
+ }).execute()
227
+ except Exception:
228
+ pass
229
+
230
+ return {
231
+ "passed": passed,
232
+ "output": output,
233
+ "test_results": test_results,
234
+ "ai_feedback": ai_feedback,
235
+ }
236
+
237
+
238
+ @router.get("/{challenge_id}/submissions")
239
+ async def get_my_submissions(challenge_id: str, request: Request):
240
+ user = await get_current_user(request)
241
+ db = get_supabase_admin()
242
+ result = db.table("challenge_submissions") \
243
+ .select("*") \
244
+ .eq("user_id", str(user.id)) \
245
+ .eq("challenge_id", challenge_id) \
246
+ .order("submitted_at", desc=True) \
247
+ .execute()
248
+ return result.data or []
routers/share.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import string
3
+ from fastapi import APIRouter, HTTPException, Request
4
+ from config.database import get_supabase_admin
5
+ from middleware.auth_guard import get_current_user
6
+
7
+ router = APIRouter(prefix="/api/share", tags=["share"])
8
+
9
+
10
+ def _generate_code(length=8) -> str:
11
+ return ''.join(random.choices(string.ascii_letters + string.digits, k=length))
12
+
13
+
14
+ @router.post("/create")
15
+ async def create_share(request: Request):
16
+ user = await get_current_user(request)
17
+ body = await request.json()
18
+ analysis_id = body.get("analysis_id")
19
+ if not analysis_id:
20
+ raise HTTPException(status_code=400, detail="analysis_id is required")
21
+
22
+ db = get_supabase_admin()
23
+
24
+ # Verify the analysis belongs to this user
25
+ analysis = db.table("analyses").select("id").eq("id", analysis_id).eq("user_id", str(user.id)).single().execute()
26
+ if not analysis.data:
27
+ raise HTTPException(status_code=404, detail="Analysis not found")
28
+
29
+ # Generate unique short code
30
+ for _ in range(5):
31
+ code = _generate_code()
32
+ existing = db.table("shared_analyses").select("id").eq("short_code", code).execute()
33
+ if not existing.data:
34
+ break
35
+
36
+ result = db.table("shared_analyses").insert({
37
+ "analysis_id": analysis_id,
38
+ "short_code": code,
39
+ "is_public": True,
40
+ }).execute()
41
+
42
+ return {"short_code": code, "url": f"https://codenexus.qzz.io/share/{code}"}
43
+
44
+
45
+ @router.get("/{code}")
46
+ async def get_shared_analysis(code: str):
47
+ db = get_supabase_admin()
48
+ shared = db.table("shared_analyses").select("*, analyses(*)").eq("short_code", code).eq("is_public", True).single().execute()
49
+ if not shared.data:
50
+ raise HTTPException(status_code=404, detail="Shared analysis not found or has been removed")
51
+ return shared.data
52
+
53
+
54
+ @router.delete("/{code}")
55
+ async def delete_share(code: str, request: Request):
56
+ user = await get_current_user(request)
57
+ db = get_supabase_admin()
58
+
59
+ shared = db.table("shared_analyses").select("*, analyses(user_id)").eq("short_code", code).single().execute()
60
+ if not shared.data:
61
+ raise HTTPException(status_code=404, detail="Share not found")
62
+ if shared.data["analyses"]["user_id"] != str(user.id):
63
+ raise HTTPException(status_code=403, detail="Not your share link")
64
+
65
+ db.table("shared_analyses").delete().eq("short_code", code).execute()
66
+ return {"message": "Share link deleted"}
routers/support.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Request
2
+ from config.database import get_supabase_admin
3
+ from middleware.auth_guard import get_current_user_optional
4
+ from models.support import SupportTicket, TicketReply
5
+ from services.email import send_support_notification_to_admin, send_support_confirmation_to_user
6
+
7
+ router = APIRouter(prefix="/api/support", tags=["support"])
8
+
9
+ ADMIN_EMAIL = "admin@codenexus.qzz.io"
10
+
11
+
12
+ @router.post("/ticket")
13
+ async def submit_ticket(data: SupportTicket, request: Request):
14
+ user = await get_current_user_optional(request)
15
+ db = get_supabase_admin()
16
+
17
+ ticket = {
18
+ "user_email": data.user_email,
19
+ "category": data.category,
20
+ "message": data.message,
21
+ "status": "open",
22
+ }
23
+ if data.screenshot_url:
24
+ ticket["screenshot_url"] = data.screenshot_url
25
+ if user:
26
+ ticket["user_id"] = str(user.id)
27
+
28
+ result = db.table("support_tickets").insert(ticket).execute()
29
+
30
+ # Send emails (non-blocking — don't fail ticket if email fails)
31
+ try:
32
+ await send_support_notification_to_admin(ADMIN_EMAIL, data.user_email, data.category, data.message)
33
+ await send_support_confirmation_to_user(data.user_email, data.category)
34
+ except Exception:
35
+ pass
36
+
37
+ return {"message": "Ticket submitted successfully", "id": result.data[0]["id"] if result.data else None}
38
+
39
+
40
+ @router.post("/feedback")
41
+ async def submit_feedback(request: Request):
42
+ body = await request.json()
43
+ analysis_id = body.get("analysis_id")
44
+ helpful = body.get("helpful", True)
45
+ db = get_supabase_admin()
46
+ if analysis_id:
47
+ db.table("analyses").update({"feedback": "helpful" if helpful else "not_helpful"}).eq("id", analysis_id).execute()
48
+ return {"message": "Feedback recorded"}
routers/user.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Request
2
+ from config.database import get_supabase_admin
3
+ from middleware.auth_guard import get_current_user
4
+ from models.user import ProfileUpdate
5
+
6
+ router = APIRouter(prefix="/api/user", tags=["user"])
7
+
8
+
9
+ @router.get("/profile")
10
+ async def get_profile(request: Request):
11
+ user = await get_current_user(request)
12
+ db = get_supabase_admin()
13
+
14
+ profile = db.table("users").select("*").eq("id", str(user.id)).single().execute()
15
+ profile_data = profile.data
16
+
17
+ analyses_count = db.table("analyses").select("id", count="exact").eq("user_id", str(user.id)).execute()
18
+ # Filter by coding type to avoid counting MCQs here
19
+ submissions_count = db.table("challenge_submissions").select("id", count="exact").eq("user_id", str(user.id)).eq("challenge_type", "coding").execute()
20
+ passed_count = db.table("challenge_submissions").select("id", count="exact").eq("user_id", str(user.id)).eq("challenge_type", "coding").eq("passed", True).execute()
21
+
22
+ # MCQ stats - submissions where challenge_type is mcq
23
+ mcq_attempted = db.table("challenge_submissions").select("id", count="exact").eq("user_id", str(user.id)).eq("challenge_type", "mcq").execute()
24
+ mcq_passed = db.table("challenge_submissions").select("id", count="exact").eq("user_id", str(user.id)).eq("challenge_type", "mcq").eq("passed", True).execute()
25
+
26
+ return {
27
+ **(profile_data or {}),
28
+ "role": user.role, # Prioritize the role from auth_guard (Admin override)
29
+ "stats": {
30
+ "total_analyses": analyses_count.count or 0,
31
+ "total_submissions": submissions_count.count or 0,
32
+ "challenges_passed": passed_count.count or 0,
33
+ "mcq_attempted": mcq_attempted.count or 0,
34
+ "mcq_passed": mcq_passed.count or 0,
35
+ }
36
+ }
37
+
38
+
39
+ @router.patch("/profile")
40
+ async def update_profile(data: ProfileUpdate, request: Request):
41
+ user = await get_current_user(request)
42
+ db = get_supabase_admin()
43
+ update = {k: v for k, v in data.dict().items() if v is not None}
44
+ if not update:
45
+ raise HTTPException(status_code=400, detail="No fields to update")
46
+ db.table("users").update(update).eq("id", str(user.id)).execute()
47
+ return {"message": "Profile updated"}
48
+
49
+
50
+ @router.delete("/account")
51
+ async def delete_account(request: Request):
52
+ user = await get_current_user(request)
53
+ db = get_supabase_admin()
54
+ # Delete all user data
55
+ db.table("analyses").delete().eq("user_id", str(user.id)).execute()
56
+ db.table("challenge_submissions").delete().eq("user_id", str(user.id)).execute()
57
+ db.table("users").delete().eq("id", str(user.id)).execute()
58
+ db.auth.admin.delete_user(str(user.id))
59
+ return {"message": "Account permanently deleted"}
60
+
61
+
62
+ @router.get("/history")
63
+ async def get_history(request: Request, page: int = 1):
64
+ user = await get_current_user(request)
65
+ db = get_supabase_admin()
66
+ offset = (page - 1) * 20
67
+ result = db.table("analyses") \
68
+ .select("id, language, time_complexity, space_complexity, created_at") \
69
+ .eq("user_id", str(user.id)) \
70
+ .order("created_at", desc=True) \
71
+ .range(offset, offset + 19) \
72
+ .execute()
73
+ return result.data or []
74
+
75
+
76
+ @router.get("/history/{analysis_id}")
77
+ async def get_analysis(analysis_id: str, request: Request):
78
+ user = await get_current_user(request)
79
+ db = get_supabase_admin()
80
+ result = db.table("analyses").select("*").eq("id", analysis_id).eq("user_id", str(user.id)).single().execute()
81
+ if not result.data:
82
+ raise HTTPException(status_code=404, detail="Analysis not found")
83
+ return result.data
services/ai_router.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import json
3
+ import re
4
+ import logging
5
+ from config.settings import get_settings
6
+
7
+ logger = logging.getLogger(__name__)
8
+ settings = get_settings()
9
+
10
+
11
+ def _extract_json(text: str) -> dict:
12
+ """
13
+ Bulletproof JSON extraction for Level 4-8 'Heavyweight' models.
14
+ Supports markdown fences, meta-commentary, and nested structures.
15
+ """
16
+ text = text.strip()
17
+
18
+ # 1. Trial: Pure JSON
19
+ try:
20
+ return json.loads(text)
21
+ except json.JSONDecodeError:
22
+ pass
23
+
24
+ # 2. Trial: Regex for ```json codes blocks
25
+ fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
26
+ if fence_match:
27
+ try:
28
+ return json.loads(fence_match.group(1).strip())
29
+ except json.JSONDecodeError:
30
+ pass
31
+
32
+ # 3. Trial: Brute-force first '{' to last '}'
33
+ start = text.find('{')
34
+ end = text.rfind('}')
35
+ if start != -1 and end != -1 and end > start:
36
+ json_str = text[start:end+1]
37
+ try:
38
+ return json.loads(json_str)
39
+ except json.JSONDecodeError:
40
+ pass
41
+
42
+ raise ValueError(f"Could not extract valid JSON from AI response. Status: {text[:100]}...")
43
+
44
+ def _sanitize_result(data: dict) -> dict:
45
+ """
46
+ Ensures that 'issues' and 'suggestions' are always flat lists of strings.
47
+ Handles 'Mistral Categorization' where the AI returns objects instead of arrays.
48
+ """
49
+ for key in ["issues", "suggestions"]:
50
+ val = data.get(key)
51
+ if isinstance(val, dict):
52
+ # Flatten dictionary: {"category": "issue"} -> ["category: issue"]
53
+ new_list = []
54
+ for k, v in val.items():
55
+ if isinstance(v, list):
56
+ new_list.extend([f"{k}: {item}" for item in v])
57
+ else:
58
+ new_list.append(f"{k}: {v}")
59
+ data[key] = new_list
60
+ elif val and not isinstance(val, list):
61
+ data[key] = [str(val)]
62
+ elif not val:
63
+ data[key] = []
64
+
65
+ return data
66
+
67
+
68
+ # Smart Model Dictionary: Maps logical names to provider-specific model IDs
69
+ # Ordered from Level 1 (Entry) to Level 8 (Flagship)
70
+ MODEL_MAP = {
71
+ "gemma-4-31b": {
72
+ "openrouter": "google/gemma-4-31b-it:free"
73
+ },
74
+ "llama-3.1": {
75
+ "groq": "llama-3.1-8b-instant",
76
+ "openrouter": "meta-llama/llama-3.1-8b-instruct:free"
77
+ },
78
+ "qwen-2.5": {
79
+ "openrouter": "qwen/qwen-2.5-7b-instruct:free",
80
+ "huggingface": "Qwen/Qwen2.5-7B-Instruct"
81
+ },
82
+ "nemotron-120b": {
83
+ "openrouter": "nvidia/nemotron-3-super-120b-a12b:free"
84
+ },
85
+ "minimax-2.5": {
86
+ "openrouter": "minimax/minimax-m2.5:free"
87
+ },
88
+ "mistral-large": {
89
+ "openrouter": "mistralai/mistral-large-2407"
90
+ },
91
+ "groq-70b": {
92
+ "groq": "llama-3.3-70b-versatile",
93
+ "openrouter": "meta-llama/llama-3.3-70b-instruct:free"
94
+ },
95
+ "gemini-flash": {
96
+ "gemini": "gemini-2.0-flash",
97
+ "openrouter": "google/gemini-2.0-flash-001"
98
+ }
99
+ }
100
+
101
+
102
+ async def analyze_with_groq(prompt: str, model_id: str = "llama-3.1-8b-instant") -> dict:
103
+ """Backup AI — Groq with specified model."""
104
+ if not settings.groq_api_key:
105
+ raise Exception("Groq API key not configured")
106
+
107
+ # Dynamic timeout for large codebases
108
+ timeout = 60.0 if len(prompt) > 5000 else 30.0
109
+
110
+ async with httpx.AsyncClient(timeout=timeout) as client:
111
+ response = await client.post(
112
+ "https://api.groq.com/openai/v1/chat/completions",
113
+ headers={"Authorization": f"Bearer {settings.groq_api_key}", "Content-Type": "application/json"},
114
+ json={
115
+ "model": model_id,
116
+ "messages": [{"role": "user", "content": prompt}],
117
+ "temperature": 0.1,
118
+ "max_tokens": 2000 # Increased for large code analysis
119
+ }
120
+ )
121
+ if response.status_code != 200:
122
+ raise Exception(f"Groq error {response.status_code}: {response.text[:200]}")
123
+ result = response.json()
124
+ text = result["choices"][0]["message"]["content"].strip()
125
+ return _sanitize_result(_extract_json(text))
126
+
127
+
128
+ async def analyze_with_gemini(prompt: str, model_id: str = "gemini-2.0-flash") -> dict:
129
+ """Primary AI — Gemini with specified model."""
130
+ if not settings.gemini_api_key:
131
+ raise Exception("Gemini API key not configured")
132
+
133
+ # Support large context in Gemini
134
+ timeout = 60.0 if len(prompt) > 5000 else 30.0
135
+
136
+ async with httpx.AsyncClient(timeout=timeout) as client:
137
+ response = await client.post(
138
+ f"https://generativelanguage.googleapis.com/v1beta/models/{model_id}:generateContent?key={settings.gemini_api_key}",
139
+ json={
140
+ "contents": [{"parts": [{"text": prompt}]}],
141
+ "generationConfig": {"maxOutputTokens": 2000} # Increased
142
+ }
143
+ )
144
+ if response.status_code != 200:
145
+ raise Exception(f"Gemini error {response.status_code}: {response.text[:200]}")
146
+ result = response.json()
147
+ candidates = result.get("candidates")
148
+ if not candidates:
149
+ raise Exception("Gemini returned no candidates")
150
+ text = candidates[0]["content"]["parts"][0]["text"].strip()
151
+ return _sanitize_result(_extract_json(text))
152
+
153
+
154
+ async def analyze_with_openrouter(prompt: str, model_id: str = None) -> dict:
155
+ """Last Resort AI — OpenRouter with customizable model."""
156
+ if not settings.openrouter_api_key:
157
+ raise Exception("OpenRouter API key not configured")
158
+
159
+ # Use settings default if no specific model requested
160
+ target_model = model_id or settings.openrouter_model
161
+
162
+ # Support large context in OpenRouter
163
+ timeout = 60.0 if len(prompt) > 5000 else 30.0
164
+
165
+ async with httpx.AsyncClient(timeout=timeout) as client:
166
+ response = await client.post(
167
+ "https://openrouter.ai/api/v1/chat/completions",
168
+ headers={"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"},
169
+ json={
170
+ "model": target_model,
171
+ "messages": [{"role": "user", "content": prompt}],
172
+ "max_tokens": 2000 # Increased
173
+ }
174
+ )
175
+ if response.status_code != 200:
176
+ raise Exception(f"OpenRouter error {response.status_code}: {response.text[:200]}")
177
+ result = response.json()
178
+ if "choices" not in result:
179
+ raise Exception(f"Unexpected OpenRouter response: {result}")
180
+ text = result["choices"][0]["message"]["content"].strip()
181
+ return _sanitize_result(_extract_json(text))
182
+
183
+
184
+ async def analyze_with_huggingface(prompt: str, model_id: str = None) -> dict:
185
+ """Extra Backup — Hugging Face with specific model OR rotation."""
186
+ if not settings.huggingface_api_token:
187
+ raise Exception("Hugging Face API token not configured")
188
+
189
+ # If a specific model is requested, try ONLY that one
190
+ # Otherwise, use the rotation logic
191
+ models_to_try = [model_id] if model_id else settings.huggingface_free_models
192
+
193
+ last_error = None
194
+ for target_model in models_to_try:
195
+ try:
196
+ timeout = 60.0 if len(prompt) > 5000 else 30.0
197
+ async with httpx.AsyncClient(timeout=timeout) as client:
198
+ response = await client.post(
199
+ f"https://api-inference.huggingface.co/models/{target_model}",
200
+ headers={"Authorization": f"Bearer {settings.huggingface_api_token}"},
201
+ json={
202
+ "inputs": prompt,
203
+ "parameters": {"max_new_tokens": 2000, "return_full_text": False}
204
+ }
205
+ )
206
+ if response.status_code != 200:
207
+ raise Exception(f"HF Model {target_model} failed ({response.status_code})")
208
+
209
+ result = response.json()
210
+ text = result[0]["generated_text"].strip() if isinstance(result, list) else result.get("generated_text", "").strip()
211
+ return _sanitize_result(_extract_json(text))
212
+ except Exception as e:
213
+ logger.warning(f"Hugging Face model {target_model} failed: {e}")
214
+ last_error = e
215
+ if model_id: # Don't rotate if specific model was forced
216
+ break
217
+ continue
218
+
219
+ raise Exception(f"Hugging Face attempt failed. Last error: {last_error}")
220
+
221
+
222
+ async def route_analysis(prompt: str, model_choice: str = "auto") -> dict:
223
+ """
224
+ Smart AI router with Zero-Failure 'Smart Recovery' logic.
225
+
226
+ Tiered Chain: Gemma 4 -> Llama 3.1 -> Qwen 2.5 -> Nemotron 120B ->
227
+ MiniMax 2.5 -> Mistral Large -> Groq 70B -> Gemini Flash
228
+ """
229
+
230
+ # 1. Prepare Provider List
231
+ providers = []
232
+
233
+ # Handle Specific Model Choice (With Recovery)
234
+ if model_choice != "auto" and model_choice in MODEL_MAP:
235
+ config = MODEL_MAP[model_choice]
236
+ if "groq" in config:
237
+ providers.append({"func": lambda p: analyze_with_groq(p, config["groq"]), "name": model_choice})
238
+ if "gemini" in config:
239
+ providers.append({"func": lambda p: analyze_with_gemini(p, config["gemini"]), "name": model_choice})
240
+ if "openrouter" in config:
241
+ providers.append({"func": lambda p: analyze_with_openrouter(p, config["openrouter"]), "name": model_choice})
242
+ if "huggingface" in config:
243
+ providers.append({"func": lambda p: analyze_with_huggingface(p, config["huggingface"]), "name": model_choice})
244
+
245
+ # SMART RECOVERY: If the specific choice fails, pivot to the full AUTO chain
246
+ providers.append({"func": lambda p: route_analysis(p, "auto"), "is_meta": True})
247
+
248
+ else:
249
+ # Full Power Progression Chain (8 Levels)
250
+ order = ["gemma-4-31b", "llama-3.1", "qwen-2.5", "nemotron-120b", "minimax-2.5", "mistral-large", "groq-70b", "gemini-flash"]
251
+ for m_id in order:
252
+ m_cfg = MODEL_MAP[m_id]
253
+ # Primary provider logic for auto-chain
254
+ if m_id == "llama-3.1": # Prioritize Groq's 8B for speed
255
+ providers.append({"func": lambda p: analyze_with_groq(p, "llama-3.1-8b-instant"), "name": m_id})
256
+ elif m_id == "groq-70b": # Prioritize Groq's 70B
257
+ providers.append({"func": lambda p: analyze_with_groq(p, "llama-3.3-70b-versatile"), "name": m_id})
258
+ elif m_id == "gemini-flash":
259
+ providers.append({"func": lambda p: analyze_with_gemini(p), "name": m_id})
260
+ elif "openrouter" in m_cfg:
261
+ providers.append({"func": lambda p: analyze_with_openrouter(p, m_cfg["openrouter"]), "name": m_id})
262
+
263
+ # Ultimate Last Resort
264
+ providers.append({"func": lambda p: analyze_with_openrouter(p, "openrouter/free"), "name": "openrouter-free-fallback"})
265
+
266
+ # 2. Execute with Failover Logic
267
+ last_error = None
268
+ for i, p_item in enumerate(providers):
269
+ try:
270
+ p_name = p_item.get("name", "failover-chain")
271
+ logger.info(f"Attempting {p_name} ({i+1}/{len(providers)})")
272
+
273
+ result = await p_item["func"](prompt)
274
+
275
+ # If it's a metadata-wrapped result from a nested call, return it
276
+ if isinstance(result, dict) and "_actual_model" in result:
277
+ return result
278
+
279
+ # Attach actual model name to the result
280
+ if isinstance(result, dict):
281
+ result["_actual_model"] = p_name
282
+ return result
283
+
284
+ except Exception as e:
285
+ logger.error(f"Provider {i+1} failed: {str(e)}")
286
+ last_error = e
287
+ continue
288
+
289
+ raise Exception(f"Absolute failure in AI Hub. Last error: {last_error}")
services/code_runner.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import logging
3
+ from config.settings import get_settings
4
+
5
+ logger = logging.getLogger(__name__)
6
+ settings = get_settings()
7
+
8
+ JDOODLE_LANGUAGE_MAP = {
9
+ "python": {"language": "python3", "versionIndex": "4"},
10
+ "javascript": {"language": "nodejs", "versionIndex": "4"},
11
+ "typescript": {"language": "typescript", "versionIndex": "1"},
12
+ "java": {"language": "java", "versionIndex": "4"},
13
+ "c++": {"language": "cpp17", "versionIndex": "1"},
14
+ "c": {"language": "c", "versionIndex": "5"},
15
+ "go": {"language": "go", "versionIndex": "4"},
16
+ "rust": {"language": "rust", "versionIndex": "4"},
17
+ "ruby": {"language": "ruby", "versionIndex": "4"},
18
+ "php": {"language": "php", "versionIndex": "4"},
19
+ }
20
+
21
+
22
+ async def run_code_jdoodle(language: str, code: str) -> str:
23
+ """Run code via JDoodle API. Returns output string."""
24
+ lang_config = JDOODLE_LANGUAGE_MAP.get(language.lower())
25
+ if not lang_config:
26
+ raise ValueError(f"Language '{language}' not supported")
27
+
28
+ async with httpx.AsyncClient(timeout=30.0) as client:
29
+ response = await client.post(
30
+ "https://api.jdoodle.com/v1/execute",
31
+ json={
32
+ "clientId": settings.jdoodle_client_id,
33
+ "clientSecret": settings.jdoodle_client_secret,
34
+ "script": code,
35
+ "language": lang_config["language"],
36
+ "versionIndex": lang_config["versionIndex"],
37
+ }
38
+ )
39
+ if response.status_code != 200:
40
+ logger.error(f"JDoodle error {response.status_code}: {response.text}")
41
+ raise Exception(f"Code execution service error: {response.status_code}")
42
+
43
+ result = response.json()
44
+
45
+ # JDoodle returns error info in statusCode field
46
+ if result.get("statusCode") == 400:
47
+ raise Exception("JDoodle: Invalid request")
48
+ if result.get("statusCode") == 429:
49
+ raise Exception("Daily code execution limit reached. Try again tomorrow.")
50
+
51
+ return result.get("output", "No output")
52
+
53
+
54
+ async def run_code_piston(language: str, version: str, code: str) -> str:
55
+ """Run code via Piston API (fallback). Returns output string."""
56
+ async with httpx.AsyncClient(timeout=30.0) as client:
57
+ response = await client.post(
58
+ f"{settings.piston_api_url}/execute",
59
+ json={
60
+ "language": language,
61
+ "version": version,
62
+ "files": [{"content": code}],
63
+ }
64
+ )
65
+ if response.status_code != 200:
66
+ raise Exception(f"Piston error: {response.status_code}")
67
+
68
+ result = response.json()
69
+ run = result.get("run", {})
70
+ return run.get("output") or run.get("stderr") or "No output"
71
+
72
+
73
+ async def run_code(language: str, code: str, version: str = "") -> str:
74
+ """Run code with JDoodle primary, Piston fallback."""
75
+ try:
76
+ return await run_code_jdoodle(language, code)
77
+ except Exception as e:
78
+ logger.warning(f"JDoodle failed ({e}), falling back to Piston")
79
+ try:
80
+ return await run_code_piston(language, version, code)
81
+ except Exception as e2:
82
+ raise Exception(f"Both code runners failed. JDoodle: {e} | Piston: {e2}")
services/complexity.py ADDED
File without changes
services/email.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import logging
3
+ from config.settings import get_settings
4
+
5
+ logger = logging.getLogger(__name__)
6
+ settings = get_settings()
7
+
8
+ FROM_EMAIL = "CodeNexus <noreply@codenexus.qzz.io>"
9
+
10
+
11
+ async def send_email(to: str, subject: str, html: str) -> bool:
12
+ """Send email via Resend. Returns True on success."""
13
+ try:
14
+ async with httpx.AsyncClient(timeout=15.0) as client:
15
+ response = await client.post(
16
+ "https://api.resend.com/emails",
17
+ headers={"Authorization": f"Bearer {settings.resend_api_key}", "Content-Type": "application/json"},
18
+ json={"from": FROM_EMAIL, "to": [to], "subject": subject, "html": html}
19
+ )
20
+ if response.status_code not in (200, 201):
21
+ logger.error(f"Resend error {response.status_code}: {response.text}")
22
+ return False
23
+ return True
24
+ except Exception as e:
25
+ logger.error(f"Email send failed: {e}")
26
+ return False
27
+
28
+
29
+ async def send_welcome_email(to: str, name: str) -> bool:
30
+ return await send_email(
31
+ to=to,
32
+ subject="Welcome to CodeNexus! 🚀",
33
+ html=f"""
34
+ <div style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;background:#030712;color:#fff;padding:40px;border-radius:16px;">
35
+ <h1 style="color:#6366f1;font-size:28px;margin-bottom:8px;">Welcome to CodeNexus!</h1>
36
+ <p style="color:#9ca3af;font-size:16px;">Hi {name}, your account is ready.</p>
37
+ <p style="color:#d1d5db;">Start writing and analyzing code right now — it's completely free.</p>
38
+ <a href="https://codenexus.qzz.io/editor" style="display:inline-block;margin-top:24px;padding:14px 28px;background:#6366f1;color:#fff;border-radius:10px;text-decoration:none;font-weight:600;">
39
+ Open Editor →
40
+ </a>
41
+ <p style="color:#4b5563;font-size:12px;margin-top:32px;">CodeNexus — Where Code Meets Intelligence</p>
42
+ </div>
43
+ """
44
+ )
45
+
46
+
47
+ async def send_support_notification_to_admin(admin_email: str, user_email: str, category: str, message: str) -> bool:
48
+ return await send_email(
49
+ to=admin_email,
50
+ subject=f"[CodeNexus Support] New {category} ticket from {user_email}",
51
+ html=f"""
52
+ <div style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;background:#030712;color:#fff;padding:40px;border-radius:16px;">
53
+ <h2 style="color:#f59e0b;">New Support Ticket</h2>
54
+ <p><strong>From:</strong> {user_email}</p>
55
+ <p><strong>Category:</strong> {category}</p>
56
+ <p><strong>Message:</strong></p>
57
+ <div style="background:#111827;padding:16px;border-radius:8px;color:#d1d5db;">{message}</div>
58
+ <a href="https://codenexus.qzz.io/admin" style="display:inline-block;margin-top:24px;padding:14px 28px;background:#6366f1;color:#fff;border-radius:10px;text-decoration:none;font-weight:600;">
59
+ View in Admin Panel →
60
+ </a>
61
+ </div>
62
+ """
63
+ )
64
+
65
+
66
+ async def send_support_confirmation_to_user(to: str, category: str) -> bool:
67
+ return await send_email(
68
+ to=to,
69
+ subject="We received your support request — CodeNexus",
70
+ html=f"""
71
+ <div style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;background:#030712;color:#fff;padding:40px;border-radius:16px;">
72
+ <h2 style="color:#6366f1;">We got your message!</h2>
73
+ <p style="color:#9ca3af;">Thanks for reaching out about <strong>{category}</strong>.</p>
74
+ <p style="color:#d1d5db;">Our team will review your request and get back to you as soon as possible.</p>
75
+ <p style="color:#4b5563;font-size:12px;margin-top:32px;">CodeNexus — Where Code Meets Intelligence</p>
76
+ </div>
77
+ """
78
+ )
79
+
80
+
81
+ async def send_ticket_reply_to_user(to: str, reply: str) -> bool:
82
+ return await send_email(
83
+ to=to,
84
+ subject="Reply to your CodeNexus support ticket",
85
+ html=f"""
86
+ <div style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;background:#030712;color:#fff;padding:40px;border-radius:16px;">
87
+ <h2 style="color:#6366f1;">Support Reply</h2>
88
+ <div style="background:#111827;padding:16px;border-radius:8px;color:#d1d5db;">{reply}</div>
89
+ <p style="color:#4b5563;font-size:12px;margin-top:32px;">CodeNexus Support Team</p>
90
+ </div>
91
+ """
92
+ )
services/quotas.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date
2
+ from fastapi import HTTPException
3
+ from config.database import get_supabase_admin
4
+
5
+ async def check_quota(user_id: str, quota_type: str):
6
+ """
7
+ Checks if a user has exceeded their daily quota for a given action.
8
+ quota_type can be 'analysis' or 'ai_input_converter'.
9
+ """
10
+ db = get_supabase_admin()
11
+
12
+ # 1. Get user's role/tier
13
+ user_res = db.table("users").select("role, daily_analyses_count, last_analysis_date, daily_ai_input_count, last_ai_input_date").eq("id", user_id).single().execute()
14
+ if not user_res.data:
15
+ raise HTTPException(status_code=404, detail="User not found")
16
+
17
+ user = user_res.data
18
+ role = user.get("role", "free")
19
+
20
+ # 2. Master Admin Bypass
21
+ if role == "admin":
22
+ return True
23
+
24
+ today = str(date.today())
25
+
26
+ # 2. Get site-wide limits from site_settings
27
+ settings_res = db.table("site_settings").select("value").eq("key", "quotas").single().execute()
28
+
29
+ # Standardized limits as requested: Guest 3, Free 5, Pro 100
30
+ default_limits = {
31
+ "analysis": {"guest": 3, "free": 5, "pro": 100},
32
+ "ai_input_converter": {"guest": 3, "free": 5, "pro": 25}
33
+ }
34
+
35
+ # Safe fetch from DB with fallback
36
+ limits = settings_res.data.get("value", default_limits) if (settings_res.data and "value" in settings_res.data) else default_limits
37
+
38
+ # Determine the specific limit for this action and user role
39
+ type_limits = limits.get(quota_type, default_limits.get(quota_type, {}))
40
+ limit = type_limits.get(role, type_limits.get("free", 5))
41
+
42
+ # 3. Check and reset counter if it's a new day
43
+ counter_field = "daily_analyses_count" if quota_type == "analysis" else "daily_ai_input_count"
44
+ date_field = "last_analysis_date" if quota_type == "analysis" else "last_ai_input_date"
45
+
46
+ current_count = user.get(counter_field, 0)
47
+ last_date = user.get(date_field)
48
+
49
+ if last_date != today:
50
+ current_count = 0
51
+ db.table("users").update({counter_field: 0, date_field: today}).eq("id", user_id).execute()
52
+
53
+ if limit != -1 and current_count >= limit:
54
+ raise HTTPException(
55
+ status_code=429,
56
+ detail=f"Daily {quota_type} limit ({limit}) reached for {role} tier. Please upgrade to Pro for more access."
57
+ )
58
+
59
+ # 4. Increment count
60
+ db.table("users").update({counter_field: current_count + 1, date_field: today}).eq("id", user_id).execute()
61
+
62
+ return True
63
+
64
+ async def check_guest_quota(client_ip: str, quota_type: str):
65
+ """
66
+ Checks guest quota based on IP address.
67
+ Standard limits: 3 analyses per day.
68
+ """
69
+ db = get_supabase_admin()
70
+ today = str(date.today())
71
+
72
+ # Get guest limits from DB or fallback
73
+ settings_res = db.table("site_settings").select("value").eq("key", "quotas").single().execute()
74
+
75
+ default_guest_limit = 3
76
+ limit = default_guest_limit
77
+
78
+ if settings_res.data and "value" in settings_res.data:
79
+ limit = settings_res.data["value"].get(quota_type, {}).get("guest", default_guest_limit)
80
+
81
+ # Block immediately if limit is 0 — before any DB operations
82
+ # This ensures the setting takes effect even if guest_usage table is missing
83
+ if limit == 0:
84
+ raise HTTPException(status_code=429, detail=f"Guest access to {quota_type} is currently disabled. Please sign in for access.")
85
+
86
+ # Check IP usage in guest_usage table
87
+ try:
88
+ log_res = db.table("guest_usage").select("count").eq("ip", client_ip).eq("action_type", quota_type).eq("usage_date", today).single().execute()
89
+
90
+ count = 0
91
+ if log_res.data:
92
+ count = log_res.data["count"]
93
+ if count >= limit:
94
+ raise HTTPException(status_code=429, detail=f"Guest limit of {limit} daily {quota_type}s reached. Please sign in for higher limits.")
95
+
96
+ db.table("guest_usage").update({"count": count + 1}).eq("ip", client_ip).eq("action_type", quota_type).eq("usage_date", today).execute()
97
+ else:
98
+ db.table("guest_usage").insert({
99
+ "ip": client_ip,
100
+ "action_type": quota_type,
101
+ "usage_date": today,
102
+ "count": 1
103
+ }).execute()
104
+ except HTTPException:
105
+ raise
106
+ except Exception:
107
+ # Ignore if the guest_usage table doesn't exist
108
+ pass
109
+
110
+ return True
services/settings.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from config.database import get_supabase_admin
2
+
3
+ def get_site_setting(key: str, default: any = None):
4
+ """
5
+ Fetches a site-wide setting from the 'site_settings' table.
6
+ """
7
+ try:
8
+ db = get_supabase_admin()
9
+ result = db.table("site_settings").select("value").eq("key", key).single().execute()
10
+ if result.data:
11
+ return result.data.get("value")
12
+ except Exception:
13
+ pass
14
+ return default
15
+
16
+ def is_ai_enabled() -> bool:
17
+ """
18
+ Convenience function to check if AI features are globally enabled.
19
+ """
20
+ return get_site_setting("ai_features_enabled", True)
services/user_sync.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from config.database import get_supabase_admin
2
+ import logging
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+ async def ensure_user_exists(user):
7
+ """
8
+ Ensures that a user from auth.users has a corresponding record in public.users.
9
+ This handles OAuth users and manual signups uniformly.
10
+ """
11
+ if not user:
12
+ return None
13
+
14
+ from config.settings import get_settings
15
+ settings = get_settings()
16
+ db = get_supabase_admin()
17
+
18
+ try:
19
+ # Check if user already exists
20
+ profile = db.table("users").select("*").eq("id", str(user.id)).execute()
21
+
22
+ # Determine target role (Case-Insensitive)
23
+ admin_list = [e.lower() for e in settings.admin_emails]
24
+ target_role = "admin" if user.email.lower() in admin_list else "free"
25
+
26
+ if not profile.data:
27
+ # Create user on the fly
28
+ display_name = user.user_metadata.get("full_name") or user.email.split("@")[0]
29
+ logger.info(f"Syncing new user to public table: {user.email} as {target_role}")
30
+
31
+ db.table("users").insert({
32
+ "id": str(user.id),
33
+ "email": user.email,
34
+ "display_name": display_name,
35
+ "role": target_role,
36
+ "is_banned": False
37
+ }).execute()
38
+ else:
39
+ # Existing user - Check if they need an admin upgrade
40
+ current_role = profile.data[0].get("role")
41
+ if target_role == "admin" and current_role != "admin":
42
+ logger.info(f"Upgrading existing user to admin: {user.email}")
43
+ db.table("users").update({"role": "admin"}).eq("id", str(user.id)).execute()
44
+
45
+ return True
46
+ except Exception as e:
47
+ logger.error(f"Error in ensure_user_exists: {str(e)}")
48
+ return False