diff --git a/backend/app/routers/papers.py b/backend/app/routers/papers.py index 2c88262..629784c 100644 --- a/backend/app/routers/papers.py +++ b/backend/app/routers/papers.py @@ -151,7 +151,12 @@ async def create_paper( # 4. Enqueue background task redis = await create_pool(RedisSettings.from_dsn(settings.REDIS_URL)) try: - job = await redis.enqueue_job("generate_paper_task", str(new_paper.id), str(new_job.id)) + job = await redis.enqueue_job( + "generate_paper_task", + str(new_paper.id), + str(new_job.id), + _job_id=str(new_job.id) + ) if job is None: raise RuntimeError("Failed to enqueue generate_paper_task") await db.commit() diff --git a/backend/app/routers/resources.py b/backend/app/routers/resources.py index 9d7a050..11b4505 100644 --- a/backend/app/routers/resources.py +++ b/backend/app/routers/resources.py @@ -13,9 +13,15 @@ from arq import create_pool from ..config import settings from arq.connections import RedisSettings +import logging router = APIRouter(prefix="/resources", tags=["resources"]) +# Configure logging for the task +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + @router.post("/", response_model=ResourceOut) async def upload_resource( type: str = Form(...), @@ -88,46 +94,58 @@ async def upload_resource( detail="Failed to upload file to storage" ) - # Create DB record (not committed yet) - new_resource = Resource( - user_id=current_user.id, - filename=file.filename, - file_url=file_url, - type=type, - status="processing" - ) - db.add(new_resource) - await db.flush() # Flush to get the ID but don't commit - - from ..models.job import Job - new_job = Job( - user_id=current_user.id, - job_type="ingest", - status="queued", - ref_id=new_resource.id - ) - db.add(new_job) - await db.flush() - - # Enqueue background extraction task before committing DB try: - redis = await create_pool(RedisSettings.from_dsn(settings.REDIS_URL)) - # Pass job_id as second argument - await redis.enqueue_job('extraction_task', str(new_resource.id), str(new_job.id)) + # Create DB record (not committed yet) + new_resource = Resource( + user_id=current_user.id, + filename=file.filename, + file_url=file_url, + type=type, + status="processing" + ) + db.add(new_resource) + await db.flush() # Flush to get the ID - # Only commit if enqueue was successful - await db.commit() - await db.refresh(new_resource) + from ..models.job import Job + new_job = Job( + user_id=current_user.id, + job_type="ingest", + status="queued", + ref_id=new_resource.id + ) + db.add(new_job) + await db.flush() + + # Enqueue background extraction task + redis = await create_pool(RedisSettings.from_dsn(settings.REDIS_URL)) + try: + # Use _job_id to make it easy to find in hooks + await redis.enqueue_job( + 'extraction_task', + str(new_resource.id), + str(new_job.id), + _job_id=str(new_job.id) + ) + await db.commit() + await db.refresh(new_resource) + except Exception as e: + await db.rollback() + raise e # Caught by outer try/except + finally: + if 'redis' in locals(): + await redis.close() + except Exception as e: - await db.rollback() - # Should also ideally delete the file from Spaces here if we were strict + # CLEANUP: Delete from storage if DB transaction failed + logger.error(f"Upload transaction failed, cleaning up storage: {e}") + storage_service.delete_file(object_name) + + if isinstance(e, HTTPException): + raise e raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to queue background task: {str(e)}" + detail=f"Failed to complete upload transaction: {str(e)}" ) - finally: - if 'redis' in locals(): - await redis.close() return new_resource diff --git a/backend/app/workers/arq_worker.py b/backend/app/workers/arq_worker.py index 755e9c5..ffa5ea1 100644 --- a/backend/app/workers/arq_worker.py +++ b/backend/app/workers/arq_worker.py @@ -1,17 +1,93 @@ import asyncio +import logging +import sys +from datetime import datetime +from sqlalchemy import select from arq.connections import RedisSettings from arq.cron import cron -from app.config import settings +from ..config import settings from .tasks import extraction_task, generate_paper_task, reset_monthly_quotas +from ..database import SessionLocal +from ..models.job import Job +from ..models.resource import Resource +from ..models.paper import Paper + +# Use the arq.worker logger so messages show up in the same stream +logger = logging.getLogger('arq.worker') async def ping(ctx): return "pong" async def startup(ctx): - pass + logger.info("šŸš€ [WORKER] Starting up and initializing hooks...") async def shutdown(ctx): - pass + logger.info("šŸ›‘ [WORKER] Shutting down...") + +async def on_job_start(ctx): + logger.info(f"ā–¶ļø [HOOK] Job Start: {ctx.get('job_id')}") + +async def after_job_end(ctx): + """ + Global hook called after every job in arq. + Ensures DB status is updated even if the task itself crashed. + """ + job_id = ctx.get('job_id') + success = ctx.get('success') + + # ARQ logs the failure, but we want our own trace + logger.info(f"šŸ” [HOOK] Job {job_id} ended. Success: {success}") + sys.stdout.flush() + + if not job_id: + logger.warning("āš ļø [HOOK] No job_id in context.") + return + + async with SessionLocal() as db: + try: + # We explicitly set _job_id in routers to match our DB Job ID + # So job_id here SHOULD be the UUID of our Job record. + result = await db.execute(select(Job).where(Job.id == job_id)) + job = result.scalar_one_or_none() + + if not job: + logger.error(f"āŒ [HOOK] Job {job_id} not found in database.") + return + + logger.info(f"šŸ“Š [HOOK] Current DB Job Status: {job.status}") + + # If the job is still in a transient state, force it to its final state + if job.status in ["running", "queued", "pending"]: + job.status = "done" if success else "failed" + job.completed_at = datetime.utcnow() + + # Update the specific entity status (Resource or Paper) + if not success: + if job.job_type == "ingest" and job.ref_id: + res_result = await db.execute(select(Resource).where(Resource.id == job.ref_id)) + resource = res_result.scalar_one_or_none() + if resource and resource.status == "processing": + resource.status = "failed" + logger.info(f"šŸ“‰ [HOOK] Marked Resource {job.ref_id} as FAILED") + + elif job.job_type == "generate_paper" and job.ref_id: + paper_result = await db.execute(select(Paper).where(Paper.id == job.ref_id)) + paper = paper_result.scalar_one_or_none() + if paper and paper.status in ["pending", "generating"]: + paper.status = "failed" + logger.info(f"šŸ“‰ [HOOK] Marked Paper {job.ref_id} as FAILED") + + await db.commit() + logger.info(f"āœ… [HOOK] Finalized DB Job {job_id} as {job.status}") + else: + logger.info(f"ā„¹ļø [HOOK] DB Job {job_id} was already finalized as {job.status}") + + except Exception as e: + logger.error(f"āŒ [HOOK] Error updating database for job {job_id}: {e}") + import traceback + logger.error(traceback.format_exc()) + finally: + sys.stdout.flush() class WorkerSettings: functions = [ping, extraction_task, generate_paper_task] @@ -20,4 +96,6 @@ class WorkerSettings: ] on_startup = startup on_shutdown = shutdown + on_job_start = on_job_start + after_job_end = after_job_end redis_settings = RedisSettings.from_dsn(settings.REDIS_URL) diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 4c7cb3c..e51adf0 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -18,6 +18,8 @@ async def extraction_task(ctx, resource_id: str, job_id: str = None): print(f"\nšŸš€ [TASK START] Resource ID: {resource_id}") + + # raise RuntimeError("Simulated Worker Crash") async with SessionLocal() as db: try: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f1e642..d0ed429 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Resources from './pages/Resources'; import Solver from './pages/Solver'; import Generator from './pages/Generator'; import ProtectedRoute from './components/ProtectedRoute'; +import PublicRoute from './components/PublicRoute'; import Layout from './components/Layout'; import { useAuthStore } from './store/authStore'; @@ -39,6 +40,7 @@ import { ArrowRight } from "lucide-react" import { cn } from './lib/utils'; +import { WelcomeBanner } from './components/WelcomeBanner'; const queryClient = new QueryClient(); @@ -110,6 +112,7 @@ function Dashboard() { return (
+

@@ -357,8 +360,22 @@ function App() { - } /> - } /> + + + + } + /> + + + + } + /> {/* Protected Routes with Layout */} state.token); + + if (token) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/UpgradeModal.tsx b/frontend/src/components/UpgradeModal.tsx new file mode 100644 index 0000000..961b64e --- /dev/null +++ b/frontend/src/components/UpgradeModal.tsx @@ -0,0 +1,84 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "./ui/dialog"; +import { Button } from "./ui/button"; +import { CheckCircle2, Zap, FileText, FileEdit, Crown } from "lucide-react"; + +interface UpgradeModalProps { + isOpen: boolean; + onClose: () => void; + message?: string; +} + +export function UpgradeModal({ isOpen, onClose, message }: UpgradeModalProps) { + const benefits = [ + { + icon: , + text: "Unlimited AI Tutor Questions" + }, + { + icon: , + text: "Unlimited Mock Exam Generations" + }, + { + icon: , + text: "Unlimited Study Resource Storage" + }, + { + icon: , + text: "Priority AI Model Access" + } + ]; + + return ( + + +
+ + +
+ +
+ Upgrade to Premium + + {message || "You've reached your free tier limit. Upgrade now to unlock the full power of PYQ Gen."} + +
+ +
+

Premium Benefits:

+
+ {benefits.map((benefit, i) => ( +
+
{benefit.icon}
+ {benefit.text} + +
+ ))} +
+
+ + + + + + +
+ ); +} diff --git a/frontend/src/components/WelcomeBanner.tsx b/frontend/src/components/WelcomeBanner.tsx new file mode 100644 index 0000000..0354400 --- /dev/null +++ b/frontend/src/components/WelcomeBanner.tsx @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react'; +import { X, FileText, Zap, FileEdit, Rocket, CheckCircle2 } from 'lucide-react'; +import { Card, CardContent } from './ui/card'; +import { Button } from './ui/button'; +import { cn } from '../lib/utils'; + +export function WelcomeBanner() { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const isDismissed = localStorage.getItem('hideWelcomeBanner'); + if (!isDismissed) { + setIsVisible(true); + } + }, []); + + const handleDismiss = () => { + localStorage.setItem('hideWelcomeBanner', 'true'); + setIsVisible(false); + }; + + if (!isVisible) return null; + + const steps = [ + { + icon: , + title: "1. Upload", + description: "Add your syllabus, notes, or past papers in the Resources section." + }, + { + icon: , + title: "2. Ask", + description: "Use the AI Solver to get instant answers grounded in your materials." + }, + { + icon: , + title: "3. Generate", + description: "Create realistic mock exams with answers and explanations." + } + ]; + + return ( + +
+ +
+ + +
+
+ +
+ +
+
+

+ Welcome to PYQ Gen! + Quick Start +

+

+ Get started with your academic journey in three simple steps: +

+
+ +
+ {steps.map((step, i) => ( +
+
+ {step.icon} +
+
+

{step.title}

+

{step.description}

+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/Generator.tsx b/frontend/src/pages/Generator.tsx index b82023e..3795e69 100644 --- a/frontend/src/pages/Generator.tsx +++ b/frontend/src/pages/Generator.tsx @@ -49,6 +49,7 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { Checkbox } from "@/components/ui/checkbox" import { toast } from 'react-hot-toast'; import api from '@/api/auth'; +import { UpgradeModal } from '../components/UpgradeModal'; export default function Generator() { const queryClient = useQueryClient(); @@ -70,6 +71,8 @@ export default function Generator() { const [activePaperId, setActivePaperId] = useState(paperId || null); const [editingPaperId, setEditingPaperId] = useState(null); const [newPaperTitle, setNewPaperTitle] = useState(''); + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); + const [upgradeMessage, setUpgradeMessage] = useState(''); // Sync state with URL useEffect(() => { @@ -143,7 +146,13 @@ export default function Generator() { toast.success('Paper generation started!'); }, onError: (error: any) => { - toast.error(error.response?.data?.detail || 'Failed to create paper.'); + if (error.response?.status === 403) { + setUpgradeMessage(error.response?.data?.detail || "You've reached your monthly paper limit."); + setIsUpgradeModalOpen(true); + setIsCreateOpen(false); + } else { + toast.error(error.response?.data?.detail || 'Failed to create paper.'); + } } }); @@ -241,7 +250,9 @@ export default function Generator() { const monthlyPapers = papers?.filter((p: any) => new Date(p.created_at) >= startOfMonth) || []; if (user?.plan === 'free' && monthlyPapers.length >= 3) { - toast.error('limit exceed, upgrade to continue'); + setUpgradeMessage("You've reached your monthly limit for paper generation. Please upgrade to continue generating more papers."); + setIsUpgradeModalOpen(true); + setIsCreateOpen(false); return; } @@ -254,6 +265,11 @@ export default function Generator() { return (
+ setIsUpgradeModalOpen(false)} + message={upgradeMessage} + />

diff --git a/frontend/src/pages/Resources.tsx b/frontend/src/pages/Resources.tsx index a273bcb..272da38 100644 --- a/frontend/src/pages/Resources.tsx +++ b/frontend/src/pages/Resources.tsx @@ -55,6 +55,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { UpgradeModal } from '../components/UpgradeModal'; export default function Resources() { const queryClient = useQueryClient(); @@ -63,6 +64,8 @@ export default function Resources() { const [isUploading, setIsUploading] = useState(false); const [editingId, setEditingId] = useState(null); const [newFilename, setNewFilename] = useState(''); + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); + const [upgradeMessage, setUpgradeMessage] = useState(''); // Dialog States const [deleteId, setDeleteId] = useState(null); @@ -84,8 +87,13 @@ export default function Resources() { toast.success('File uploaded successfully!'); }, onError: (error: any) => { - const message = error.response?.data?.detail || 'Failed to upload file.'; - toast.error(message); + if (error.response?.status === 403) { + setUpgradeMessage(error.response?.data?.detail || "You've reached your resource storage limit."); + setIsUpgradeModalOpen(true); + } else { + const message = error.response?.data?.detail || 'Failed to upload file.'; + toast.error(message); + } }, }); @@ -181,6 +189,11 @@ export default function Resources() { return (
+ setIsUpgradeModalOpen(false)} + message={upgradeMessage} + />

diff --git a/frontend/src/pages/Solver.tsx b/frontend/src/pages/Solver.tsx index 4b58498..21382cb 100644 --- a/frontend/src/pages/Solver.tsx +++ b/frontend/src/pages/Solver.tsx @@ -38,6 +38,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet" +import { UpgradeModal } from '../components/UpgradeModal'; export default function Solver() { const queryClient = useQueryClient(); @@ -53,6 +54,8 @@ export default function Solver() { const [newSessionTitle, setNewSessionTitle] = useState(''); const [showContext, setShowContext] = useState(true); const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(false); + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); + const [upgradeMessage, setUpgradeMessage] = useState(''); const scrollRef = useRef(null); // Sync state with URL @@ -178,6 +181,15 @@ export default function Solver() { session_id: activeSessionId || undefined, }); + if (response.status === 403) { + const errData = await response.json(); + setUpgradeMessage(errData.detail || "You've reached your question limit."); + setIsUpgradeModalOpen(true); + setMessages(prev => prev.slice(0, -1)); + setIsStreaming(false); + return; + } + if (!response.ok) { const errData = await response.json(); throw new Error(errData.detail || 'Failed to connect to solver.'); @@ -421,6 +433,11 @@ export default function Solver() { return (
+ setIsUpgradeModalOpen(false)} + message={upgradeMessage} + /> {/* --- DESKTOP HISTORY SIDEBAR --- */}