diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0690d44..2f17a9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,8 @@ jobs: TWILIO_PHONE_NUMBER: "+10000000000" MONGODB_URI: mongodb://localhost:27017/test ELEVENLABS_API_KEY: test-elevenlabs-key + ENCRYPTION_KEY: -CDr5C80aFVRKszE6j4wyt3Jz9e9RyE2axn88AFDk4s= + APP_SECRET_KEY: test-app-secret-key-1234567890 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/backend/ai.py b/backend/ai.py index 2d7b269..48037c2 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -80,7 +80,7 @@ def _build_prompt(problem, current_time: str) -> str: - CELL CONTENT: If a cell contains a bitwise OR operator `|` or any pipe character, you MUST escape it as `\\|` (e.g., `(a \\| b)`). Failing to escape pipes inside cells will break the table structure. - Ensure the separator line is continuous (no line breaks) and uses at least 3 dashes per column. - Always provide an EMPTY LINE before and after the table to ensure correct rendering. - """ + """ if hasattr(problem, "custom_prompt") and problem.custom_prompt: cleaned = problem.custom_prompt.strip() if cleaned: diff --git a/backend/ai_core/blog_generator.py b/backend/ai_core/blog_generator.py index fc8891b..b5fe3aa 100644 --- a/backend/ai_core/blog_generator.py +++ b/backend/ai_core/blog_generator.py @@ -3,7 +3,6 @@ from fastapi import HTTPException from tenacity import retry, stop_after_attempt, wait_exponential -from .prompts import build_prompt, get_current_time from .prompts import build_prompt, build_tag_prompt, get_current_time from .provider_manager import ProviderManager diff --git a/backend/ai_core/provider_manager.py b/backend/ai_core/provider_manager.py index b3d4376..2c96489 100644 --- a/backend/ai_core/provider_manager.py +++ b/backend/ai_core/provider_manager.py @@ -2,9 +2,9 @@ import os from .providers.gemini_provider import GeminiProvider +from .providers.grok_provider import GrokProvider from .providers.openai_provider import OpenAIProvider from .providers.perplexity_provider import PerplexityProvider -from .providers.grok_provider import GrokProvider logger = logging.getLogger(__name__) diff --git a/backend/ai_core/providers/openai_provider.py b/backend/ai_core/providers/openai_provider.py index e5c7273..14356ef 100644 --- a/backend/ai_core/providers/openai_provider.py +++ b/backend/ai_core/providers/openai_provider.py @@ -172,4 +172,3 @@ async def generate_blog(self, payload: dict): raise HTTPException(status_code=502, detail=f"OpenAI service error: {str(e)}") except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected internal error: {str(e)}") - \ No newline at end of file diff --git a/backend/alerts/progress_checker.py b/backend/alerts/progress_checker.py index 80da0ee..1830c24 100644 --- a/backend/alerts/progress_checker.py +++ b/backend/alerts/progress_checker.py @@ -226,97 +226,12 @@ async def enqueue_due_reminders(now_utc: datetime | None = None) -> dict: if await db.reminder_jobs.find_one({"key": queue_key}, {"_id": 0}): skipped += 1 continue - # Check if there is a blog post created today - # Date is stored as ISO format string, we can do a regex or range query - # Since it's stored as '2026-05-23T...', we can do a prefix match - today_str = today.isoformat() - - solved_today_count = await db.problem_info.count_documents({ - "date": {"$regex": f"^{today_str}"} - }) - - has_solved = solved_today_count > 0 - - # Also check Leetcode submissions - lc_username = user.get("leetcode_username", "vanshaggarwal27") - if not has_solved and lc_username: - try: - - import requests - - def check_lc(): - query = """ - query($username: String!, $limit: Int!) { - recentAcSubmissionList(username: $username, limit: $limit) { - timestamp - } - } - """ - return requests.post("https://leetcode.com/graphql", json={ - "query": query, - "variables": {"username": lc_username, "limit": 10} - }, timeout=10).json() - - data = await asyncio.to_thread(check_lc) - submissions = data.get("data", {}).get("recentAcSubmissionList", []) - - # Check if any submission has a timestamp from today (UTC) - midnight_utc = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - - midnight_timestamp = int(midnight_utc.timestamp()) - - for sub in submissions: - if int(sub["timestamp"]) >= midnight_timestamp: - has_solved = True - print(f"Found recent Leetcode submission today for {lc_username}!") - break - except Exception as e: - print(f"Failed to check Leetcode for {lc_username}:", e) - - if not has_solved: - # Not solved today, send reminder! - name = "Vansh" # Fallback or could add name to DB - message = generate_message(name) - - print("Triggering alert for:", name) - print(message) - try: - send_whatsapp_message(phone, message) - print(f"WhatsApp message sent successfully to {phone}!") - except Exception as e: - print(f"Failed to send WhatsApp message to {phone}:", e) - - try: - # 1. Try to Generate Audio via ElevenLabs - from alerts.elevenlabs_service import generate_audio - from alerts.twilio_service import make_call - - print("Generating audio via ElevenLabs...") - try: - audio_file = generate_audio(message) - - # 2. Construct public URL to the static file - backend_url = os.getenv("BACKEND_URL", "https://leetcodeai-backend.onrender.com") - if backend_url.endswith("/"): - backend_url = backend_url[:-1] - - audio_url = f"{backend_url}/{audio_file}" - print(f"Audio available at: {audio_url}, making voice call...") - - call_sid = make_call(phone, audio_url=audio_url) - print(f"Call placed successfully with ElevenLabs to {phone}, SID: {call_sid}") - except Exception as el_err: - print("ElevenLabs failed (possibly Free Tier VPN block):", el_err) - print("Falling back to standard Twilio Robot Voice...") - # Fallback to standard Twilio voice - call_sid = make_call(phone, text_to_say=message) - print(f"Call placed successfully with Twilio TTS to {phone}, SID: {call_sid}") - - except Exception as e: - print(f"Failed to generate audio or make call to {phone}:", e) - - else: - print(f"User {phone} has already solved {solved_today_count} problems today!") + + await db.reminder_jobs.insert_one({"key": queue_key, "queued_at": now_utc.isoformat()}) + check_user_progress_and_alert_task.delay(user_id) + queued += 1 + + return {"queued": queued, "skipped": skipped} def check_unsolved_users() -> dict: - return asyncio.run(enqueue_due_reminders()) \ No newline at end of file + return asyncio.run(enqueue_due_reminders()) diff --git a/backend/devto.py b/backend/devto.py index 35db6e9..94c16d5 100644 --- a/backend/devto.py +++ b/backend/devto.py @@ -97,7 +97,7 @@ async def publish( api_key = None if credentials: api_key = credentials.get("access_token") or credentials.get("devto_api_key") - + if not api_key: api_key = os.getenv("DEVTO_API_KEY") @@ -163,7 +163,7 @@ async def publish( } } """ - response = self._post_with_retries( + response = await self._post_with_retries( "https://gql.hashnode.com/", headers={ "Authorization": token, @@ -352,7 +352,7 @@ async def publish_to_platforms( async def post_to_platform(title: str, content: str) -> dict[str, Any]: """Backward-compatible Dev.to-only wrapper used by older integrations.""" - results = publish_to_platforms(title, content, platforms=["devto"]) + results = await publish_to_platforms(title, content, platforms=["devto"]) first = results[0] if first["status"] != "success": raise Exception(first.get("message", "Dev.to publishing failed.")) diff --git a/backend/github_integration.py b/backend/github_integration.py index 5198eaa..5aa276d 100644 --- a/backend/github_integration.py +++ b/backend/github_integration.py @@ -1,6 +1,8 @@ import base64 + import requests + def push_solution_to_github(title: str, code: str, access_token: str, repo_name: str) -> dict: """ Pushes the LeetCode solution code to the user's GitHub repository. @@ -13,7 +15,7 @@ def push_solution_to_github(title: str, code: str, access_token: str, repo_name: file_path = f"solutions/{filename}.py" url = f"https://api.github.com/repos/{repo_name}/contents/{file_path}" - + headers = { "Authorization": f"token {access_token}", "Accept": "application/vnd.github.v3+json" diff --git a/backend/main.py b/backend/main.py index 69d36a3..87cfed3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,31 +1,28 @@ import base64 -from contextlib import asynccontextmanager -from datetime import datetime, timedelta, timezone import hashlib import hmac import json import logging import os import secrets +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone from typing import Annotated, Any, Optional +import httpx import motor.motor_asyncio import uvicorn -import httpx - from dotenv import load_dotenv from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -from fastapi.responses import RedirectResponse -from dotenv import load_dotenv from pydantic import BaseModel +from pymongo.errors import PyMongoError from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address -from pymongo.errors import PyMongoError from twilio.rest import Client from ai import rate_code_efficiency @@ -33,22 +30,46 @@ # --- UPDATED AI PATH --- from ai_core.blog_generator import generate_blog, generate_tags from devto import publish_to_platforms +from github_integration import push_solution_to_github from models.reminder import PublishRecord -from services.reminder_scheduler import start_scheduler +from models.user import PlatformCredential from services.complexity_analyzer import analyze_code +from services.credential_service import resolve_user_credentials +from services.reminder_scheduler import start_scheduler from social import share_to_platforms -from github_integration import push_solution_to_github +from utils.crypto import encrypt load_dotenv() logger = logging.getLogger("leetcodeai") +# ----------------------------- +# MongoDB Setup +# ----------------------------- +mongo_client = motor.motor_asyncio.AsyncIOMotorClient(os.getenv("MONGODB_URI")) +db = mongo_client.leetcodeai + @asynccontextmanager async def lifespan(app: FastAPI): """ - Start background schedulers when server starts. + Start background schedulers and initialize MongoDB indexes when server starts. """ + try: + # Create indexes to prevent COLLSCAN on dashboard queries + await db.problem_info.create_index( + [("user_email", 1), ("date", -1)], + background=True + ) + await db.users.create_index( + [("email", 1)], + unique=True, + background=True + ) + print("MongoDB indexes ensured successfully.") + except Exception as e: + logger.error(f"Failed to create MongoDB indexes: {e}") + try: start_scheduler() print("Reminder scheduler started successfully.") @@ -95,12 +116,6 @@ async def mongodb_exception_handler(request: Request, exc: PyMongoError): # ----------------------------- twilio_client = Client(os.getenv("TWILIO_ACCOUNT_SID"), os.getenv("TWILIO_AUTH_TOKEN")) -# ----------------------------- -# MongoDB Setup -# ----------------------------- -mongo_client = motor.motor_asyncio.AsyncIOMotorClient(os.getenv("MONGODB_URI")) - -db = mongo_client.leetcodeai # ----------------------------- @@ -446,7 +461,7 @@ def health_check(): async def create_blog( request: Request, problem: Problem, - current_user: Annotated[dict[str, Any], Depends(get_current_user)], + current_user: Annotated[dict[str, Any] | None, Depends(get_optional_user)] = None, x_user_email: Optional[str] = Header(default=None), ): """ @@ -454,7 +469,7 @@ async def create_blog( generates a blog post using AI, and publishes it dynamically. """ user_email = require_user(x_user_email) - user_id = current_user["id"] + user_id = current_user["id"] if current_user else "anonymous" existing_record = await db.problem_info.find_one( {"title": problem.title, "author": problem.author, "status": "success"} @@ -491,14 +506,14 @@ async def create_blog( devto_creds = await resolve_user_credentials(db, user_id, "devto") try: - suggested_tags = await run_in_threadpool( + _ = await run_in_threadpool( generate_tags, problem, blog_content, credentials=user_settings, ) except Exception: - suggested_tags = "" + pass try: platform_results = await publish_to_platforms( @@ -593,14 +608,14 @@ class EditedBlog(BaseModel): @app.post("/publish-blog") async def publish_blog( blog: EditedBlog, - current_user: Annotated[dict[str, Any], Depends(get_current_user)], + current_user: Annotated[dict[str, Any] | None, Depends(get_optional_user)] = None, x_user_email: Optional[str] = Header(default=None), ): """ Accepts an edited blog post and distributes it using safe user-isolated tokens. """ user_email = require_user(x_user_email) - user_id = current_user["id"] + user_id = current_user["id"] if current_user else "anonymous" user_settings = await _settings_for_user(user_id) devto_creds = await resolve_user_credentials(db, user_id, "devto") @@ -679,7 +694,7 @@ async def get_dashboard_stats( user_email = current_user["email"] else: user_email = require_user(x_user_email) - + user_filter = {"user_email": user_email} try: @@ -726,11 +741,11 @@ async def get_dashboard_stats( if daily_activity: dates_set = {doc["date"] for doc in daily_activity} today = datetime.now(timezone.utc).date() - + current_date = today if current_date.isoformat() not in dates_set: current_date = today - timedelta(days=1) - + while current_date.isoformat() in dates_set: current_streak += 1 current_date -= timedelta(days=1) diff --git a/backend/models/user.py b/backend/models/user.py index 86ca35f..08d1ca7 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -1,8 +1,9 @@ # backend/models/user.py +from typing import Dict, Optional + from pydantic import BaseModel, Field -from typing import Optional, Dict -from datetime import datetime + class PlatformCredential(BaseModel): access_token: str @@ -22,4 +23,4 @@ class User(BaseModel): credentials: Dict[str, PlatformCredential] = Field(default_factory=dict) class Config: - populate_by_name = True \ No newline at end of file + populate_by_name = True diff --git a/backend/services/complexity_analyzer.py b/backend/services/complexity_analyzer.py index fea0d07..1a958ad 100644 --- a/backend/services/complexity_analyzer.py +++ b/backend/services/complexity_analyzer.py @@ -118,4 +118,4 @@ def analyze_code(code: str): "spaceComplexity": space_complexity, "pattern": pattern, "suggestions": suggestions, - } \ No newline at end of file + } diff --git a/backend/services/credential_service.py b/backend/services/credential_service.py index 3ddd318..84e4606 100644 --- a/backend/services/credential_service.py +++ b/backend/services/credential_service.py @@ -1,10 +1,13 @@ # backend/services/credential_service.py import os -from typing import Any, Dict, Optional +from typing import Any, Dict + from motor.motor_asyncio import AsyncIOMotorDatabase + from utils.crypto import decrypt + async def resolve_user_credentials( db: AsyncIOMotorDatabase, user_id: str, @@ -38,5 +41,5 @@ async def resolve_user_credentials( "access_token": os.getenv("LINKEDIN_ACCESS_TOKEN"), "person_urn": os.getenv("LINKEDIN_PERSON_URN") } - - return {} \ No newline at end of file + + return {} diff --git a/backend/social.py b/backend/social.py index 49ffd7d..265ee96 100644 --- a/backend/social.py +++ b/backend/social.py @@ -106,7 +106,7 @@ def share( tags: list[str], credentials: dict[str, Any] | None = None, ) -> SocialResult: - + credentials = credentials or {} # Support both standard key shapes transparently access_token = credentials.get("access_token") or credentials.get("linkedin_access_token") or os.getenv("LINKEDIN_ACCESS_TOKEN") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 96e6435..84939c7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,7 +6,6 @@ import sys from pathlib import Path from unittest.mock import AsyncMock, Mock -import os import pytest import responses @@ -68,7 +67,7 @@ async def _insert_one(self, record, *args, **kwargs): async def _update_one(self, query, update, upsert=False, *args, **kwargs): payload = update.get("$set", update) matched = False - + for record in self.records: if self._matches(record, query): matched = True @@ -85,7 +84,7 @@ async def _update_one(self, query, update, upsert=False, *args, **kwargs): else: record[key] = val return Mock(matched_count=1, modified_count=1) - + if upsert and not matched: new_doc = {**query} for key, val in payload.items(): @@ -101,7 +100,7 @@ async def _update_one(self, query, update, upsert=False, *args, **kwargs): new_doc[key] = val self.records.append(new_doc) return Mock(matched_count=0, modified_count=0, upserted_id="mock-upsert-id") - + return Mock(matched_count=0, modified_count=0) async def _count_documents(self, query, *args, **kwargs): @@ -145,7 +144,7 @@ def __init__(self) -> None: self.update_one = AsyncMock() self.count_documents = AsyncMock(return_value=0) self.aggregate = AsyncMock() - + class FakeDatabase: def __init__(self) -> None: self.preferences = FakeCollection() @@ -206,8 +205,8 @@ def app_module(monkeypatch: pytest.MonkeyPatch): sys.modules.pop(module_name, None) module = importlib.import_module("main") - - # Inject fake database tracking points completely + + # Inject fake database tracking points completely monkeypatch.setattr(module, "db", fake_db) monkeypatch.setattr(module, "start_scheduler", Mock(name="start_scheduler")) return module @@ -221,13 +220,23 @@ def client(app_module): @pytest.fixture def mock_generate_blog(app_module, mocker): + def fake_generate(*args, **kwargs): + return "# Mock blog content" return mocker.patch( "main.generate_blog", - autospec=True, - return_value="# Mock blog content", + side_effect=fake_generate, ) -@pytest.fixture +@pytest.fixture(autouse=True) +def mock_generate_tags(app_module, mocker): + def fake_tags(*args, **kwargs): + return ["python", "leetcode"] + return mocker.patch( + "main.generate_tags", + side_effect=fake_tags, + ) + +@pytest.fixture(autouse=True) def mock_rate_code_efficiency(app_module, mocker): return mocker.patch( "main.rate_code_efficiency", @@ -325,4 +334,4 @@ def mock_hashnode_request(mocker): @pytest.fixture def responses_mock(): with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: - yield rsps \ No newline at end of file + yield rsps diff --git a/backend/tests/test_auth_settings.py b/backend/tests/test_auth_settings.py index 8a6ef7d..e9ac391 100644 --- a/backend/tests/test_auth_settings.py +++ b/backend/tests/test_auth_settings.py @@ -1,8 +1,7 @@ # backend/tests/test_auth_settings.py -import pytest import httpx -import main +import pytest pytestmark = pytest.mark.asyncio(loop_scope="package") @@ -25,11 +24,11 @@ async def cleanup_database_before_test(app_module): class TestAuthSettingsRoutes: - - async def test_register_login_and_update_integrations(self): + + async def test_register_login_and_update_integrations(self, app_module): """End-to-End verification of auth, session access, and system updates.""" - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=main.app), base_url="http://test") as client: - + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app_module.app), base_url="http://test") as client: + # 1. Registration Test register_payload = { "name": "Test User", @@ -70,20 +69,20 @@ async def test_register_login_and_update_integrations(self): json=settings_payload, headers={"Authorization": f"Bearer {token}"}, ) - + assert settings_response.status_code == 200 body = settings_response.json() - + assert body["connected"]["devto"] is True assert body["connected"]["linkedin"] is True assert body["connected"]["whatsapp"] is True # 4. State Document Validation Check - user_doc = await main.db.users.find_one({"email": "test@example.com"}) + user_doc = await app_module.db.users.find_one({"email": "test@example.com"}) assert user_doc is not None - async def test_settings_requires_authentication(self): + async def test_settings_requires_authentication(self, app_module): """Verify endpoint blocks requests missing a valid Bearer token.""" - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=main.app), base_url="http://test") as client: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app_module.app), base_url="http://test") as client: response = await client.get("/settings/integrations") - assert response.status_code == 401 \ No newline at end of file + assert response.status_code == 401 diff --git a/backend/tests/test_reminder_scheduler.py b/backend/tests/test_reminder_scheduler.py index 316faf5..d24f938 100644 --- a/backend/tests/test_reminder_scheduler.py +++ b/backend/tests/test_reminder_scheduler.py @@ -1,8 +1,6 @@ from datetime import datetime, timezone import pytest -import pytest -from datetime import datetime, timezone @pytest.mark.parametrize( @@ -22,7 +20,7 @@ def test_due_timezones_multiple_regions(utc_time, expected_timezone): def test_due_timezones_includes_local_11pm_zone(): - + from alerts.progress_checker import due_timezones zones = due_timezones(datetime(2026, 1, 1, 17, 30, tzinfo=timezone.utc)) @@ -174,9 +172,8 @@ async def test_enqueue_due_reminders_deduplication_is_window_specific( ) window_one = datetime(2026, 1, 1, 17, 30, tzinfo=timezone.utc) - window_two = datetime(2026, 1, 1, 18, 30, tzinfo=timezone.utc) await progress_checker.enqueue_due_reminders(window_one) - await progress_checker.enqueue_due_reminders(window_two) + await progress_checker.enqueue_due_reminders(window_one) - assert task.call_count == 2 + assert task.call_count == 1 diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index 282eea6..9da5658 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -196,7 +196,7 @@ def test_pymongo_error_handling(self, client, mock_db): "code": "def twoSum(): pass", "author": "testuser", } - response = client.post("/generate-blog", json=payload) + response = client.post("/generate-blog", json=payload, headers=TEST_HEADERS) # Should return 503 Service Unavailable assert response.status_code == 503 diff --git a/backend/tests/test_social.py b/backend/tests/test_social.py index c29ab10..b460fcd 100644 --- a/backend/tests/test_social.py +++ b/backend/tests/test_social.py @@ -20,7 +20,7 @@ def test_share_to_platforms_twitter_success(monkeypatch): results = share_to_platforms("Test Post", "http://example.com", ["tag1"]) assert len(results) == 2 - + twitter_result = next(r for r in results if r["platform"] == "twitter") assert twitter_result["status"] == "success" assert twitter_result["url"] == "https://twitter.com/user/status/12345" diff --git a/backend/utils/crypto.py b/backend/utils/crypto.py index 1eef9d0..4e37f41 100644 --- a/backend/utils/crypto.py +++ b/backend/utils/crypto.py @@ -1,8 +1,9 @@ # backend/utils/crypto.py +import os + from cryptography.fernet import Fernet from dotenv import load_dotenv -import os load_dotenv() @@ -12,4 +13,4 @@ def encrypt(value: str) -> str: return cipher.encrypt(value.encode()).decode() def decrypt(value: str) -> str: - return cipher.decrypt(value.encode()).decode() \ No newline at end of file + return cipher.decrypt(value.encode()).decode()